mirror of
https://github.com/NanmiCoder/cc-haha
synced 2026-07-16 13:03:31 +08:00
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
372 lines
10 KiB
TypeScript
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)
|
|
}
|
|
}
|