cc-haha/adapters/wechat/protocol.ts
程序员阿江(Relakkes) a627bb19f2 feat: support WeChat as a first-class IM channel
WeChat needs a QR-paired path instead of bot-token setup, so the adapter layer now includes the iLink protocol calls, desktop pairing UI, server-side bind/unbind APIs, and shared IM command behavior. Empty project history falls back to the user's default work directory so mobile /new works without pre-opening a desktop project.

Constraint: Tencent iLink login returns a URL that the desktop UI must render as a QR image locally
Constraint: IM adapters should keep /new, /projects, status, permission, and default workdir behavior consistent across WeChat, Feishu, and Telegram
Rejected: Require users to paste absolute project paths for first WeChat sessions | mobile onboarding should work from the default user working directory
Confidence: high
Scope-risk: moderate
Directive: Do not change WeChat polling back to overlapping intervals; getupdates is a long-poll endpoint and must remain serialized
Tested: Real WeChat QR scan, inbound /status, outbound reply, and unbind E2E
Tested: bun run check:adapters
Tested: bun run quality:pr
Not-tested: Re-scan live WeChat after the default-workdir fallback tweak; covered by adapter config tests and PR gate
2026-05-03 17:38:08 +08:00

372 lines
10 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
text_item?: { text?: string }
voice_item?: { text?: string }
ref_msg?: {
title?: string
message_item?: WechatMessageItem
}
}
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',
})
}
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)
}
}