cc-haha/adapters/wechat/protocol.ts
程序员阿江(Relakkes) 2ca5228171 feat: make IM adapter behavior consistent
WeChat and DingTalk were using different pairing, attachment, and response
state paths, which made the new IM channels behave differently from Feishu
and Telegram. Align the shared pairing model, wire inbound media into the
existing attachment bridge, and map platform response capabilities to their
real APIs: WeChat block streaming plus typing, DingTalk AI Card streaming.

Constraint: WeChat iLink exposes typing and block streaming, but no editable message/card streaming API
Constraint: DingTalk streaming depends on the AI Card create/deliver/stream/finalize lifecycle
Rejected: Fake DingTalk typing with standalone markdown | it would add chat noise instead of platform state
Rejected: Auto-pair WeChat after QR login | it bypasses the shared IM pairing model
Confidence: high
Scope-risk: moderate
Directive: Keep WeChat streaming as block-send unless iLink adds editable messages; keep DingTalk streaming on AI Card APIs
Tested: bun run check:adapters; bun run check:server; cd desktop && bun run test src/stores/adapterStore.test.ts; cd desktop && bun run build; bunx tsc -p adapters/tsconfig.json --noEmit; git diff --check
Not-tested: Live WeChat and DingTalk platform smoke with real production credentials
2026-05-03 21:08:12 +08:00

440 lines
12 KiB
TypeScript

import crypto from 'node:crypto'
export const WECHAT_DEFAULT_BASE_URL = 'https://ilinkai.weixin.qq.com'
export const WECHAT_DEFAULT_BOT_TYPE = '3'
const ILINK_APP_ID = 'bot'
const CHANNEL_VERSION = '2.1.7'
const ILINK_APP_CLIENT_VERSION = buildClientVersion(CHANNEL_VERSION)
const QR_LOGIN_TTL_MS = 5 * 60_000
const QR_STATUS_TIMEOUT_MS = 35_000
const GET_UPDATES_TIMEOUT_MS = 35_000
const API_TIMEOUT_MS = 15_000
type QrLoginStatus = 'wait' | 'scaned' | 'confirmed' | 'expired' | 'scaned_but_redirect'
type ActiveLogin = {
sessionKey: string
qrcode: string
qrcodeUrl: string
startedAt: number
currentApiBaseUrl: string
}
type QrCodeResponse = {
qrcode: string
qrcode_img_content: string
}
type QrStatusResponse = {
status: QrLoginStatus
bot_token?: string
ilink_bot_id?: string
baseurl?: string
ilink_user_id?: string
redirect_host?: string
}
export type WechatQrStartResult = {
qrcodeUrl?: string
message: string
sessionKey: string
}
export type WechatQrPollResult = {
connected: boolean
status: QrLoginStatus | 'not_started'
message: string
botToken?: string
accountId?: string
baseUrl?: string
userId?: string
}
export type WechatMessageItem = {
type?: number
msg_id?: string
text_item?: { text?: string }
voice_item?: { text?: string }
media?: WechatCdnMedia
image_item?: {
media?: WechatCdnMedia
thumb_media?: WechatCdnMedia
aeskey?: string
url?: string
}
file_item?: {
media?: WechatCdnMedia
file_name?: string
len?: string
}
video_item?: {
media?: WechatCdnMedia
thumb_media?: WechatCdnMedia
}
ref_msg?: {
title?: string
message_item?: WechatMessageItem
}
}
export type WechatCdnMedia = {
encrypt_query_param?: string
aes_key?: string
full_url?: string
}
export type WechatMessage = {
seq?: number
message_id?: number
from_user_id?: string
to_user_id?: string
client_id?: string
create_time_ms?: number
session_id?: string
message_type?: number
message_state?: number
item_list?: WechatMessageItem[]
context_token?: string
}
export type WechatGetUpdatesResp = {
ret?: number
errcode?: number
errmsg?: string
msgs?: WechatMessage[]
get_updates_buf?: string
longpolling_timeout_ms?: number
}
const activeLogins = new Map<string, ActiveLogin>()
export function buildClientVersion(version: string): number {
const parts = version.split('.').map((p) => parseInt(p, 10))
const major = parts[0] ?? 0
const minor = parts[1] ?? 0
const patch = parts[2] ?? 0
return ((major & 0xff) << 16) | ((minor & 0xff) << 8) | (patch & 0xff)
}
export function extractWechatText(itemList?: WechatMessageItem[]): string {
if (!itemList?.length) return ''
for (const item of itemList) {
if (item.type === 1 && item.text_item?.text != null) {
const text = String(item.text_item.text)
const ref = item.ref_msg
if (!ref) return text
const parts: string[] = []
if (ref.title) parts.push(ref.title)
if (ref.message_item) {
const refBody = extractWechatText([ref.message_item])
if (refBody) parts.push(refBody)
}
return parts.length ? `[引用: ${parts.join(' | ')}]\n${text}` : text
}
if (item.type === 3 && item.voice_item?.text) {
return item.voice_item.text
}
}
return ''
}
export async function startWechatLoginWithQr(opts: {
force?: boolean
sessionKey?: string
botType?: string
} = {}): Promise<WechatQrStartResult> {
purgeExpiredLogins()
const sessionKey = opts.sessionKey || crypto.randomUUID()
const existing = activeLogins.get(sessionKey)
if (!opts.force && existing && isLoginFresh(existing)) {
return {
qrcodeUrl: existing.qrcodeUrl,
message: '二维码已就绪,请使用微信扫描。',
sessionKey,
}
}
const botType = opts.botType || WECHAT_DEFAULT_BOT_TYPE
const rawText = await apiGetFetch({
baseUrl: WECHAT_DEFAULT_BASE_URL,
endpoint: `ilink/bot/get_bot_qrcode?bot_type=${encodeURIComponent(botType)}`,
label: 'wechatQrStart',
})
const qr = JSON.parse(rawText) as QrCodeResponse
if (!qr.qrcode || !qr.qrcode_img_content) {
throw new Error('WeChat QR response did not include a QR code URL')
}
activeLogins.set(sessionKey, {
sessionKey,
qrcode: qr.qrcode,
qrcodeUrl: qr.qrcode_img_content,
startedAt: Date.now(),
currentApiBaseUrl: WECHAT_DEFAULT_BASE_URL,
})
return {
qrcodeUrl: qr.qrcode_img_content,
message: '使用微信扫描二维码完成绑定。',
sessionKey,
}
}
export async function pollWechatLoginWithQr(opts: {
sessionKey: string
}): Promise<WechatQrPollResult> {
purgeExpiredLogins()
const login = activeLogins.get(opts.sessionKey)
if (!login) {
return {
connected: false,
status: 'not_started',
message: '当前没有进行中的微信绑定,请重新生成二维码。',
}
}
const status = await pollQrStatus(login.currentApiBaseUrl, login.qrcode)
switch (status.status) {
case 'wait':
return { connected: false, status: 'wait', message: '等待扫码。' }
case 'scaned':
return { connected: false, status: 'scaned', message: '已扫码,请在微信中确认。' }
case 'scaned_but_redirect':
if (status.redirect_host) {
login.currentApiBaseUrl = `https://${status.redirect_host}`
}
return { connected: false, status: 'scaned_but_redirect', message: '已扫码,正在切换微信网关。' }
case 'expired':
activeLogins.delete(opts.sessionKey)
return { connected: false, status: 'expired', message: '二维码已过期,请重新生成。' }
case 'confirmed':
activeLogins.delete(opts.sessionKey)
if (!status.bot_token || !status.ilink_bot_id) {
return { connected: false, status: 'confirmed', message: '微信已确认,但服务端未返回完整凭据。' }
}
return {
connected: true,
status: 'confirmed',
message: '微信绑定成功。',
botToken: status.bot_token,
accountId: status.ilink_bot_id,
baseUrl: status.baseurl || login.currentApiBaseUrl || WECHAT_DEFAULT_BASE_URL,
userId: status.ilink_user_id,
}
}
}
export async function getWechatUpdates(params: {
baseUrl: string
token: string
getUpdatesBuf?: string
timeoutMs?: number
}): Promise<WechatGetUpdatesResp> {
try {
const rawText = await apiPostFetch({
baseUrl: params.baseUrl,
endpoint: 'ilink/bot/getupdates',
body: JSON.stringify({
get_updates_buf: params.getUpdatesBuf ?? '',
base_info: buildBaseInfo(),
}),
token: params.token,
timeoutMs: params.timeoutMs ?? GET_UPDATES_TIMEOUT_MS,
label: 'wechatGetUpdates',
})
return JSON.parse(rawText) as WechatGetUpdatesResp
} catch (err) {
if (err instanceof Error && err.name === 'AbortError') {
return { ret: 0, msgs: [], get_updates_buf: params.getUpdatesBuf }
}
throw err
}
}
export async function sendWechatText(params: {
baseUrl: string
token: string
to: string
text: string
contextToken?: string
timeoutMs?: number
}): Promise<void> {
const body = {
msg: {
from_user_id: '',
to_user_id: params.to,
client_id: `claude-code-haha-wechat-${crypto.randomUUID()}`,
message_type: 2,
message_state: 2,
item_list: params.text ? [{ type: 1, text_item: { text: params.text } }] : undefined,
context_token: params.contextToken,
},
base_info: buildBaseInfo(),
}
await apiPostFetch({
baseUrl: params.baseUrl,
endpoint: 'ilink/bot/sendmessage',
body: JSON.stringify(body),
token: params.token,
timeoutMs: params.timeoutMs ?? API_TIMEOUT_MS,
label: 'wechatSendMessage',
})
}
export async function getWechatConfig(params: {
baseUrl: string
token: string
ilinkUserId: string
contextToken?: string
timeoutMs?: number
}): Promise<{ ret?: number; errmsg?: string; typing_ticket?: string }> {
const rawText = await apiPostFetch({
baseUrl: params.baseUrl,
endpoint: 'ilink/bot/getconfig',
body: JSON.stringify({
ilink_user_id: params.ilinkUserId,
context_token: params.contextToken,
base_info: buildBaseInfo(),
}),
token: params.token,
timeoutMs: params.timeoutMs ?? 10_000,
label: 'wechatGetConfig',
})
return JSON.parse(rawText) as { ret?: number; errmsg?: string; typing_ticket?: string }
}
export async function sendWechatTyping(params: {
baseUrl: string
token: string
ilinkUserId: string
typingTicket: string
status: 'typing' | 'cancel'
timeoutMs?: number
}): Promise<void> {
await apiPostFetch({
baseUrl: params.baseUrl,
endpoint: 'ilink/bot/sendtyping',
body: JSON.stringify({
ilink_user_id: params.ilinkUserId,
typing_ticket: params.typingTicket,
status: params.status === 'typing' ? 1 : 2,
base_info: buildBaseInfo(),
}),
token: params.token,
timeoutMs: params.timeoutMs ?? 10_000,
label: 'wechatSendTyping',
})
}
async function pollQrStatus(apiBaseUrl: string, qrcode: string): Promise<QrStatusResponse> {
try {
const rawText = await apiGetFetch({
baseUrl: apiBaseUrl,
endpoint: `ilink/bot/get_qrcode_status?qrcode=${encodeURIComponent(qrcode)}`,
timeoutMs: QR_STATUS_TIMEOUT_MS,
label: 'wechatQrStatus',
})
return JSON.parse(rawText) as QrStatusResponse
} catch (err) {
if (err instanceof Error && err.name === 'AbortError') return { status: 'wait' }
return { status: 'wait' }
}
}
async function apiGetFetch(params: {
baseUrl: string
endpoint: string
timeoutMs?: number
label: string
}): Promise<string> {
const url = new URL(params.endpoint, ensureTrailingSlash(params.baseUrl))
const controller = params.timeoutMs ? new AbortController() : undefined
const timer = controller ? setTimeout(() => controller.abort(), params.timeoutMs) : undefined
try {
const res = await fetch(url.toString(), {
method: 'GET',
headers: buildCommonHeaders(),
...(controller ? { signal: controller.signal } : {}),
})
const rawText = await res.text()
if (!res.ok) throw new Error(`${params.label} ${res.status}: ${rawText}`)
return rawText
} finally {
if (timer) clearTimeout(timer)
}
}
async function apiPostFetch(params: {
baseUrl: string
endpoint: string
body: string
token?: string
timeoutMs: number
label: string
}): Promise<string> {
const url = new URL(params.endpoint, ensureTrailingSlash(params.baseUrl))
const controller = new AbortController()
const timer = setTimeout(() => controller.abort(), params.timeoutMs)
try {
const res = await fetch(url.toString(), {
method: 'POST',
headers: buildHeaders({ token: params.token, body: params.body }),
body: params.body,
signal: controller.signal,
})
const rawText = await res.text()
if (!res.ok) throw new Error(`${params.label} ${res.status}: ${rawText}`)
return rawText
} finally {
clearTimeout(timer)
}
}
function buildBaseInfo(): { channel_version: string } {
return { channel_version: CHANNEL_VERSION }
}
function buildCommonHeaders(): Record<string, string> {
return {
'iLink-App-Id': ILINK_APP_ID,
'iLink-App-ClientVersion': String(ILINK_APP_CLIENT_VERSION),
}
}
function buildHeaders(opts: { token?: string; body: string }): Record<string, string> {
const headers: Record<string, string> = {
'Content-Type': 'application/json',
AuthorizationType: 'ilink_bot_token',
'Content-Length': String(Buffer.byteLength(opts.body, 'utf-8')),
'X-WECHAT-UIN': randomWechatUin(),
...buildCommonHeaders(),
}
if (opts.token?.trim()) {
headers.Authorization = `Bearer ${opts.token.trim()}`
}
return headers
}
function ensureTrailingSlash(url: string): string {
return url.endsWith('/') ? url : `${url}/`
}
function randomWechatUin(): string {
const uint32 = crypto.randomBytes(4).readUInt32BE(0)
return Buffer.from(String(uint32), 'utf-8').toString('base64')
}
function isLoginFresh(login: ActiveLogin): boolean {
return Date.now() - login.startedAt < QR_LOGIN_TTL_MS
}
function purgeExpiredLogins(): void {
for (const [sessionKey, login] of activeLogins) {
if (!isLoginFresh(login)) activeLogins.delete(sessionKey)
}
}