File size: 7,934 Bytes
1f21206
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
/**
 * Adapters API — IM Adapter 配置读写
 *
 * GET  /api/adapters  → 返回配置(敏感字段脱敏)
 * PUT  /api/adapters  → 更新配置(浅合并),返回更新后的脱敏配置
 */

import { adapterService } from '../services/adapterService.js'
import { ApiError, errorResponse } from '../middleware/errorHandler.js'
import {
  pollWechatLoginWithQr,
  startWechatLoginWithQr,
  WECHAT_DEFAULT_BASE_URL,
} from '../../../adapters/wechat/protocol.js'

const ALLOWED_TOP_KEYS = new Set(['serverUrl', 'defaultProjectDir', 'telegram', 'feishu', 'wechat', 'dingtalk', 'pairing'])

type RegistrationApiResponse<T extends Record<string, unknown>> = T & {
  errcode: number
  errmsg?: string
}

type RegistrationBeginPayload = {
  deviceCode: string
  userCode?: string
  verificationUri?: string
  verificationUriComplete: string
  expiresInSeconds: number
  intervalSeconds: number
  qrDataUrl?: string
}

const DINGTALK_REGISTRATION_BASE_URL =
  process.env.DINGTALK_REGISTRATION_BASE_URL?.trim() || 'https://oapi.dingtalk.com'
const DINGTALK_REGISTRATION_SOURCE =
  process.env.DINGTALK_REGISTRATION_SOURCE?.trim() || 'DING_DWS_CLAW'

async function postDingtalkRegistration<T extends Record<string, unknown>>(
  path: string,
  body: Record<string, unknown>,
  action: string,
): Promise<RegistrationApiResponse<T>> {
  const res = await fetch(`${DINGTALK_REGISTRATION_BASE_URL}${path}`, {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify(body),
  })
  const data = await res.json().catch(() => null) as RegistrationApiResponse<T> | null
  if (!res.ok || !data || data.errcode !== 0) {
    throw ApiError.internal(`[DingTalk ${action}] ${data?.errmsg || res.statusText || 'unknown error'}`)
  }
  return data
}

async function createQrDataUrl(text: string): Promise<string | undefined> {
  try {
    const qr = await import('qrcode') as any
    return await qr.toDataURL(text, { margin: 1, width: 220 })
  } catch {
    return undefined
  }
}

async function beginDingtalkRegistration(): Promise<RegistrationBeginPayload> {
  const initData = await postDingtalkRegistration<{ nonce?: string }>(
    '/app/registration/init',
    { source: DINGTALK_REGISTRATION_SOURCE },
    'init',
  )
  const nonce = String(initData.nonce ?? '').trim()
  if (!nonce) throw ApiError.internal('[DingTalk init] missing nonce')

  const beginData = await postDingtalkRegistration<{
    device_code?: string
    user_code?: string
    verification_uri?: string
    verification_uri_complete?: string
    expires_in?: number
    interval?: number
  }>('/app/registration/begin', { nonce }, 'begin')

  const deviceCode = String(beginData.device_code ?? '').trim()
  const verificationUriComplete = String(beginData.verification_uri_complete ?? '').trim()
  if (!deviceCode) throw ApiError.internal('[DingTalk begin] missing device_code')
  if (!verificationUriComplete) throw ApiError.internal('[DingTalk begin] missing verification_uri_complete')

  const expiresInSeconds = Number(beginData.expires_in ?? 7200)
  const intervalSeconds = Number(beginData.interval ?? 3)

  return {
    deviceCode,
    userCode: String(beginData.user_code ?? '').trim() || undefined,
    verificationUri: String(beginData.verification_uri ?? '').trim() || undefined,
    verificationUriComplete,
    expiresInSeconds: Number.isFinite(expiresInSeconds) && expiresInSeconds > 0 ? expiresInSeconds : 7200,
    intervalSeconds: Number.isFinite(intervalSeconds) && intervalSeconds > 0 ? intervalSeconds : 3,
    qrDataUrl: await createQrDataUrl(verificationUriComplete),
  }
}

async function pollDingtalkRegistration(deviceCode: string): Promise<Response> {
  if (!deviceCode) throw ApiError.badRequest('deviceCode is required')

  const pollData = await postDingtalkRegistration<{
    status?: string
    client_id?: string
    client_secret?: string
    fail_reason?: string
  }>('/app/registration/poll', { device_code: deviceCode }, 'poll')

  const status = String(pollData.status ?? '').trim().toUpperCase()
  if (status === 'SUCCESS') {
    const clientId = String(pollData.client_id ?? '').trim()
    const clientSecret = String(pollData.client_secret ?? '').trim()
    if (!clientId || !clientSecret) {
      throw ApiError.internal('DingTalk authorization succeeded but credentials are missing')
    }
    await adapterService.updateConfig({
      dingtalk: {
        clientId,
        clientSecret,
      },
    })
    return Response.json({
      status,
      config: await adapterService.getConfig(),
    })
  }

  return Response.json({
    status: status || 'UNKNOWN',
    failReason: String(pollData.fail_reason ?? '').trim() || undefined,
  })
}

export async function handleAdaptersApi(
  req: Request,
  _url: URL,
  _segments: string[],
): Promise<Response> {
  try {
    const tail = _segments.slice(2)
    if (tail[0] === 'wechat') {
      return handleWechatAdaptersApi(req, tail.slice(1))
    }
    if (tail[0] === 'dingtalk' && req.method === 'POST' && tail[1] === 'unbind') {
      await adapterService.updateConfig({
        dingtalk: {
          clientId: undefined,
          clientSecret: undefined,
          allowedUsers: [],
          pairedUsers: [],
          permissionCardTemplateId: undefined,
        },
      })
      return Response.json(await adapterService.getConfig())
    }
    if (tail[0] === 'dingtalk' && tail[1] === 'registration') {
      if (req.method === 'POST' && tail[2] === 'begin') {
        return Response.json(await beginDingtalkRegistration())
      }
      if (req.method === 'POST' && tail[2] === 'poll') {
        const body = await req.json().catch(() => ({})) as { deviceCode?: string }
        return pollDingtalkRegistration(String(body.deviceCode ?? '').trim())
      }
    }

    if (req.method === 'GET') {
      const config = await adapterService.getConfig()
      return Response.json(config)
    }

    if (req.method === 'PUT') {
      const body = (await req.json()) as Record<string, unknown>
      // Basic validation: only allow known top-level keys
      for (const key of Object.keys(body)) {
        if (!ALLOWED_TOP_KEYS.has(key)) {
          throw ApiError.badRequest(`Unknown config key: ${key}`)
        }
      }
      await adapterService.updateConfig(body)
      const config = await adapterService.getConfig()
      return Response.json(config)
    }

    throw new ApiError(405, `Method ${req.method} not allowed`, 'METHOD_NOT_ALLOWED')
  } catch (error) {
    return errorResponse(error)
  }
}

async function handleWechatAdaptersApi(req: Request, tail: string[]): Promise<Response> {
  if (req.method === 'POST' && tail[0] === 'login' && tail[1] === 'start') {
    const result = await startWechatLoginWithQr({ force: true })
    return Response.json(result)
  }

  if (req.method === 'POST' && tail[0] === 'login' && tail[1] === 'poll') {
    const body = (await req.json()) as { sessionKey?: string }
    if (!body.sessionKey) throw ApiError.badRequest('Missing sessionKey')
    const result = await pollWechatLoginWithQr({ sessionKey: body.sessionKey })
    if (result.connected) {
      await adapterService.updateConfig({
        wechat: {
          accountId: result.accountId,
          botToken: result.botToken,
          baseUrl: result.baseUrl || WECHAT_DEFAULT_BASE_URL,
          userId: result.userId,
          pairedUsers: [],
        },
      })
    }
    return Response.json(result.connected ? await adapterService.getConfig() : result)
  }

  if (req.method === 'POST' && tail[0] === 'unbind') {
    await adapterService.updateConfig({
      wechat: {
        accountId: undefined,
        botToken: undefined,
        baseUrl: WECHAT_DEFAULT_BASE_URL,
        userId: undefined,
        pairedUsers: [],
        allowedUsers: [],
      },
    })
    return Response.json(await adapterService.getConfig())
  }

  throw new ApiError(404, 'Unknown WeChat adapter endpoint', 'NOT_FOUND')
}