import { createHash, randomBytes, randomUUID } from 'node:crypto' import { wechatSidecarCommandSchema, wechatSidecarStartAccountCommandSchema, type WechatSidecarCommand, type WechatSidecarCredentialMessage, type WechatSidecarMessage, type WechatSidecarStartAccountCommand } from './wechat-sidecar-protocol' import { isAllowedWechatUrl, redactWechatSidecarError } from './wechat-sidecar-security' import { downloadWechatFile, downloadWechatImage, uploadWechatAttachment } from './wechat-media' import { CHANNEL_LIMITS } from '../../shared/channel-contracts' const QR_BASE_URL = 'https://ilinkai.weixin.qq.com' const DEFAULT_API_BASE_URL = QR_BASE_URL const BOT_TYPE = '3' const LONG_POLL_TIMEOUT_MS = 35_000 const API_TIMEOUT_MS = 15_000 const MAX_REPLY_CONTEXTS = 1_000 const MAX_API_REDIRECTS = 3 const ILINK_CHANNEL_VERSION = '2.4.6' const ILINK_CLIENT_VERSION = '132102' const parentPort = process.parentPort class RequestTimeoutError extends Error {} type QrResponse = { qrcode?: string qrcode_img_content?: string } type QrStatusResponse = { status?: | 'wait' | 'scaned' | 'confirmed' | 'expired' | 'scaned_but_redirect' | 'need_verifycode' | 'verify_code_blocked' | 'binded_redirect' bot_token?: string ilink_bot_id?: string ilink_user_id?: string baseurl?: string redirect_host?: string } type WeixinMessageItem = { type?: number text_item?: { text?: string } image_item?: { media?: { encrypt_query_param?: string aes_key?: string full_url?: string } aeskey?: string mid_size?: number hd_size?: number } file_item?: { media?: { encrypt_query_param?: string aes_key?: string full_url?: string } file_name?: string len?: string } } type WeixinMessage = { seq?: number message_id?: number from_user_id?: string create_time_ms?: number message_type?: number item_list?: WeixinMessageItem[] context_token?: string } type UpdatesResponse = { ret?: number errcode?: number errmsg?: string msgs?: WeixinMessage[] get_updates_buf?: string longpolling_timeout_ms?: number } type ReplyContext = { recipientId: string contextToken?: string } const replyContexts = new Map() const replyControllers = new Map() let activeQr: | { qrcode: string pollingBaseUrl: string expiresAt: number verifyCode?: string } | undefined let account: WechatSidecarStartAccountCommand | undefined let lifecycleController = new AbortController() function post(message: WechatSidecarMessage | WechatSidecarCredentialMessage): void { parentPort.postMessage(message) } function safeDetail(error: unknown): string { return redactWechatSidecarError(error) } function assertTencentUrl(raw: string): URL { if (!isAllowedWechatUrl(raw)) { throw new Error('微信服务返回了不受信任的地址') } const url = new URL(raw) return url } function normalizeBaseUrl(raw: string | undefined): string { return assertTencentUrl(raw?.trim() || DEFAULT_API_BASE_URL) .toString() .replace(/\/$/u, '') } function randomWechatUin(): string { const value = randomBytes(4).readUInt32BE(0) return Buffer.from(String(value), 'utf8').toString('base64') } function commonHeaders(token?: string): Record { return { 'Content-Type': 'application/json', 'iLink-App-Id': 'bot', 'iLink-App-ClientVersion': ILINK_CLIENT_VERSION, AuthorizationType: 'ilink_bot_token', 'X-WECHAT-UIN': randomWechatUin(), ...(token ? { Authorization: `Bearer ${token}` } : {}) } } function baseInfo(): { channel_version: string; bot_agent: string } { return { channel_version: ILINK_CHANNEL_VERSION, bot_agent: 'GoodBuddy/0.8.6' } } async function requestJson(input: { baseUrl: string endpoint: string method: 'GET' | 'POST' token?: string body?: unknown timeoutMs: number signal?: AbortSignal }): Promise { const baseUrl = normalizeBaseUrl(input.baseUrl) const url = new URL(input.endpoint, `${baseUrl}/`) assertTencentUrl(url.toString()) const timeoutController = new AbortController() let timedOut = false const timeout = setTimeout(() => { timedOut = true timeoutController.abort() }, input.timeoutMs) const abort = (): void => timeoutController.abort(input.signal?.reason) input.signal?.addEventListener('abort', abort, { once: true }) if (input.signal?.aborted) { abort() } try { try { const body = input.body === undefined ? undefined : JSON.stringify(input.body) let requestUrl = url for ( let redirectCount = 0; redirectCount <= MAX_API_REDIRECTS; redirectCount += 1 ) { const response = await fetch(requestUrl, { method: input.method, headers: commonHeaders(input.token), ...(body === undefined ? {} : { body }), redirect: 'manual', signal: timeoutController.signal }) if (response.status >= 300 && response.status < 400) { const location = response.headers.get('location') if ( !location || redirectCount === MAX_API_REDIRECTS ) { throw new Error('微信服务重定向无效') } requestUrl = assertTencentUrl( new URL(location, requestUrl).toString() ) continue } const text = await response.text() if (!response.ok) { throw new Error( `微信服务请求失败(${response.status})` ) } return JSON.parse(text) as T } throw new Error('微信服务重定向过多') } catch (error) { if (timedOut) { throw new RequestTimeoutError('微信请求等待超时') } throw error } } finally { clearTimeout(timeout) input.signal?.removeEventListener('abort', abort) } } async function startLogin(): Promise { lifecycleController.abort() lifecycleController = new AbortController() activeQr = undefined post({ type: 'status', status: 'starting' }) try { const result = await requestJson({ baseUrl: QR_BASE_URL, endpoint: `ilink/bot/get_bot_qrcode?bot_type=${BOT_TYPE}`, method: 'POST', body: { local_token_list: [] }, timeoutMs: API_TIMEOUT_MS, signal: lifecycleController.signal }) if (!result.qrcode || !result.qrcode_img_content) { throw new Error('微信服务未返回有效二维码') } activeQr = { qrcode: result.qrcode, pollingBaseUrl: QR_BASE_URL, expiresAt: Date.now() + 5 * 60_000 } const expiresAt = new Date(activeQr.expiresAt).toISOString() post({ type: 'status', status: 'pending' }) post({ type: 'qr', qrId: randomUUID(), payload: result.qrcode_img_content, expiresAt }) void pollQr(lifecycleController.signal) } catch (error) { if (!lifecycleController.signal.aborted) { post({ type: 'status', status: 'failed', detail: safeDetail(error) }) } } } async function pollQr(signal: AbortSignal): Promise { while (!signal.aborted && activeQr) { const current = activeQr if (Date.now() >= current.expiresAt) { post({ type: 'status', status: 'expired' }) activeQr = undefined return } try { let endpoint = `ilink/bot/get_qrcode_status?qrcode=${encodeURIComponent(current.qrcode)}` if (current.verifyCode) { endpoint += `&verify_code=${encodeURIComponent(current.verifyCode)}` } const status = await requestJson({ baseUrl: current.pollingBaseUrl, endpoint, method: 'GET', timeoutMs: LONG_POLL_TIMEOUT_MS, signal }) if (signal.aborted || !activeQr) { return } switch (status.status) { case 'wait': case undefined: break case 'scaned': activeQr.verifyCode = undefined post({ type: 'status', status: 'scanned' }) break case 'need_verifycode': post({ type: 'status', status: 'verification_required' }) post({ type: 'verification_required', prompt: activeQr.verifyCode ? '数字不匹配,请重新输入手机微信显示的数字' : '请输入手机微信显示的数字' }) return case 'verify_code_blocked': post({ type: 'status', status: 'failed', detail: '验证码错误次数过多,请重新扫码' }) activeQr = undefined return case 'expired': post({ type: 'status', status: 'expired' }) activeQr = undefined return case 'scaned_but_redirect': if (!status.redirect_host) { throw new Error('微信扫码重定向地址缺失') } activeQr.pollingBaseUrl = normalizeBaseUrl( `https://${status.redirect_host}` ) break case 'binded_redirect': post({ type: 'status', status: 'failed', detail: '此微信已绑定,但本地凭据不可用,请先在微信中解除旧连接' }) activeQr = undefined return case 'confirmed': { if ( !status.bot_token || !status.ilink_bot_id || !status.ilink_user_id ) { throw new Error('微信确认结果缺少账号凭据') } const baseUrl = normalizeBaseUrl(status.baseurl) const credential: WechatSidecarCredentialMessage = { type: 'credential', accountId: status.ilink_bot_id, userId: status.ilink_user_id, baseUrl, token: status.bot_token } post(credential) post({ type: 'connected', accountId: credential.accountId, userId: credential.userId }) post({ type: 'status', status: 'connected' }) activeQr = undefined return } } } catch (error) { if (signal.aborted) { return } if (error instanceof RequestTimeoutError) { continue } await sleep(2_000, signal) } } } function submitVerification(code: string): void { if (!activeQr) { post({ type: 'status', status: 'failed', detail: '当前没有等待验证的微信扫码' }) return } activeQr.verifyCode = code post({ type: 'status', status: 'scanned' }) void pollQr(lifecycleController.signal) } async function startAccount( command: WechatSidecarStartAccountCommand ): Promise { lifecycleController.abort() lifecycleController = new AbortController() account = { ...command, baseUrl: normalizeBaseUrl(command.baseUrl) } post({ type: 'status', status: 'starting' }) try { await notifyLifecycle('notifystart') } catch { // Connection notification is advisory; polling remains authoritative. } post({ type: 'connected', accountId: account.accountId, userId: account.userId }) post({ type: 'status', status: 'connected' }) void pollMessages(lifecycleController.signal) } async function pollMessages(signal: AbortSignal): Promise { let cursor = '' let timeoutMs = LONG_POLL_TIMEOUT_MS let failures = 0 while (!signal.aborted && account) { try { const result = await requestJson({ baseUrl: account.baseUrl, endpoint: 'ilink/bot/getupdates', method: 'POST', token: account.token, body: { get_updates_buf: cursor, base_info: baseInfo() }, timeoutMs, signal }) if (signal.aborted) { return } if ( (result.ret !== undefined && result.ret !== 0) || (result.errcode !== undefined && result.errcode !== 0) ) { throw new Error('微信消息轮询失败') } failures = 0 if (result.get_updates_buf) { cursor = result.get_updates_buf } if ( result.longpolling_timeout_ms && result.longpolling_timeout_ms > 0 ) { timeoutMs = Math.min(result.longpolling_timeout_ms, 60_000) } for (const message of result.msgs ?? []) { await handleInboundMessage(message, signal) } } catch (error) { if (signal.aborted) { return } if (error instanceof RequestTimeoutError) { continue } failures += 1 if (failures >= 3) { post({ type: 'status', status: 'failed', detail: safeDetail(error) }) failures = 0 await sleep(30_000, signal) continue } await sleep(2_000, signal) } } } async function handleInboundMessage( message: WeixinMessage, signal: AbortSignal ): Promise { if (message.message_type !== undefined && message.message_type !== 1) { return } const senderId = message.from_user_id?.trim() const text = message.item_list ?.find((item) => item.type === 1) ?.text_item?.text?.trim() ?? '' const mediaItems = (message.item_list ?? []) .filter((item) => item.type === 2 || item.type === 4) .slice(0, CHANNEL_LIMITS.maximumAttachmentCount) if (!senderId || (!text && mediaItems.length === 0)) { return } const eventId = stableEventId( message, senderId, text || `media:${mediaItems.length}` ) replyContexts.set(eventId, { recipientId: senderId, ...(message.context_token ? { contextToken: message.context_token } : {}) }) while (replyContexts.size > MAX_REPLY_CONTEXTS) { const oldest = replyContexts.keys().next().value if (oldest === undefined) { break } replyContexts.delete(oldest) } const results = await Promise.all( mediaItems.map(async (item, index) => { try { if (item.type === 2 && item.image_item) { return { attachment: await downloadWechatImage( item.image_item, `微信图片-${message.message_id ?? message.seq ?? index + 1}`, signal ) } } if (item.type === 4 && item.file_item) { return { attachment: await downloadWechatFile( item.file_item, signal ) } } return {} } catch (error) { return { error: safeDetail(error) } } }) ) const attachments = [] let attachmentError: string | undefined for (const result of results) { attachmentError ??= result.error if (!result.attachment) { continue } const total = attachments.reduce( (sum, candidate) => sum + candidate.size, 0 ) if ( total + result.attachment.size > CHANNEL_LIMITS.maximumAttachmentBytes ) { attachmentError = '微信附件总大小超过 12MB 限制' break } attachments.push(result.attachment) } post({ type: 'inbound_message', eventId, senderId, conversationId: senderId, text, ...(attachments.length > 0 ? { attachments } : {}), ...(attachmentError ? { attachmentError } : {}) }) } function stableEventId( message: WeixinMessage, senderId: string, text: string ): string { if (message.message_id !== undefined) { return `message-${message.message_id}` } if (message.seq !== undefined) { return `sequence-${message.seq}` } return `digest-${createHash('sha256') .update( `${senderId}\u0000${message.create_time_ms ?? 0}\u0000${text}`, 'utf8' ) .digest('hex')}` } async function sendReply( command: Extract ): Promise { const currentAccount = account const context = replyContexts.get(command.inReplyToEventId) if (!currentAccount || !context) { post({ type: 'reply_result', replyId: command.replyId, ok: false, error: '微信回复上下文已失效' }) return } const controller = new AbortController() const lifecycleSignal = lifecycleController.signal const abortFromLifecycle = (): void => controller.abort(lifecycleSignal.reason) lifecycleSignal.addEventListener( 'abort', abortFromLifecycle, { once: true } ) if (lifecycleSignal.aborted) { abortFromLifecycle() } replyControllers.set(command.replyId, controller) try { const items: Array<{ item: WeixinMessageItem stableKey: string }> = [ { item: { type: 1, text_item: { text: command.text } }, stableKey: `text\u0000${command.text}` } ] for (const [index, attachment] of ( command.attachments ?? [] ).entries()) { items.push( { item: await uploadWechatAttachment({ attachment, recipientId: context.recipientId, signal: controller.signal, getUploadUrl: (request) => requestJson({ baseUrl: currentAccount.baseUrl, endpoint: 'ilink/bot/getuploadurl', method: 'POST', token: currentAccount.token, body: { ...request, base_info: baseInfo() }, timeoutMs: API_TIMEOUT_MS, signal: controller.signal }) }), stableKey: `attachment\u0000${index}\u0000${attachment.kind}\u0000${createHash( 'sha256' ) .update(attachment.dataBase64, 'ascii') .digest('hex')}` } ) } for (const [index, entry] of items.entries()) { const clientId = `goodbuddy-${createHash('sha256') .update( `${command.inReplyToEventId}\u0000${index}\u0000${entry.stableKey}` ) .digest('hex') .slice(0, 32)}` const response = await requestJson<{ ret?: number errmsg?: string }>({ baseUrl: currentAccount.baseUrl, endpoint: 'ilink/bot/sendmessage', method: 'POST', token: currentAccount.token, body: { msg: { from_user_id: '', to_user_id: context.recipientId, client_id: clientId, context_token: context.contextToken, message_type: 2, message_state: 2, item_list: [entry.item] }, base_info: baseInfo() }, timeoutMs: API_TIMEOUT_MS, signal: controller.signal }) if (response.ret !== undefined && response.ret !== 0) { throw new Error(response.errmsg || '微信消息发送失败') } } post({ type: 'reply_result', replyId: command.replyId, ok: true }) } catch (error) { post({ type: 'reply_result', replyId: command.replyId, ok: false, error: safeDetail(error) }) } finally { lifecycleSignal.removeEventListener( 'abort', abortFromLifecycle ) replyControllers.delete(command.replyId) } } function cancelReply(replyId: string): void { replyControllers .get(replyId) ?.abort(new Error('微信回复已取消')) } async function notifyLifecycle( endpoint: 'notifystart' | 'notifystop' ): Promise { if (!account) { return } await requestJson({ baseUrl: account.baseUrl, endpoint: `ilink/bot/msg/${endpoint}`, method: 'POST', token: account.token, body: { base_info: baseInfo() }, timeoutMs: 10_000 }) } async function disconnect(): Promise { lifecycleController.abort() try { await notifyLifecycle('notifystop') } catch { // Best effort during local disconnect and shutdown. } activeQr = undefined account = undefined replyContexts.clear() replyControllers.clear() post({ type: 'status', status: 'stopped' }) } function sleep(ms: number, signal: AbortSignal): Promise { return new Promise((resolve) => { let settled = false const finish = (): void => { if (settled) { return } settled = true clearTimeout(timeout) signal.removeEventListener('abort', finish) resolve() } const timeout = setTimeout(finish, ms) signal.addEventListener('abort', finish, { once: true }) if (signal.aborted) { finish() } }) } parentPort.on('message', (event) => { const startAccountCommand = wechatSidecarStartAccountCommandSchema.safeParse(event.data) if (startAccountCommand.success) { void startAccount(startAccountCommand.data) return } const command = wechatSidecarCommandSchema.safeParse(event.data) if (!command.success) { post({ type: 'status', status: 'failed', detail: '微信 Sidecar 收到无效命令' }) return } switch (command.data.type) { case 'start_login': void startLogin() break case 'submit_verification': submitVerification(command.data.code) break case 'reply': void sendReply(command.data) break case 'cancel_reply': cancelReply(command.data.replyId) break case 'disconnect': void disconnect() break case 'shutdown': void disconnect().finally(() => process.exit(0)) break } }) post({ type: 'status', status: 'stopped' })