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
This commit is contained in:
程序员阿江(Relakkes)
2026-05-03 17:38:08 +08:00
parent 915d6f413e
commit a627bb19f2
29 changed files with 1521 additions and 49 deletions
@@ -0,0 +1,34 @@
import { describe, expect, it } from 'bun:test'
import { buildClientVersion, extractWechatText } from '../protocol.js'
describe('WeChat protocol helpers', () => {
it('encodes iLink client versions like the OpenClaw Weixin plugin', () => {
expect(buildClientVersion('2.1.7')).toBe((2 << 16) | (1 << 8) | 7)
expect(buildClientVersion('1.0.11')).toBe(65547)
})
it('extracts plain text from WeChat message items', () => {
expect(extractWechatText([
{ type: 1, text_item: { text: 'hello' } },
])).toBe('hello')
})
it('extracts voice transcription when text items are absent', () => {
expect(extractWechatText([
{ type: 3, voice_item: { text: 'voice text' } },
])).toBe('voice text')
})
it('preserves quoted text context', () => {
expect(extractWechatText([
{
type: 1,
text_item: { text: 'reply' },
ref_msg: {
title: 'quote title',
message_item: { type: 1, text_item: { text: 'quoted body' } },
},
},
])).toBe('[引用: quote title | quoted body]\nreply')
})
})
+397
View File
@@ -0,0 +1,397 @@
import * as path from 'node:path'
import { WsBridge, type ServerMessage } from '../common/ws-bridge.js'
import { MessageDedup } from '../common/message-dedup.js'
import { enqueue } from '../common/chat-queue.js'
import { getConfiguredWorkDir, loadConfig } from '../common/config.js'
import {
formatImHelp,
formatImStatus,
formatPermissionRequest,
splitMessage,
} from '../common/format.js'
import { SessionStore } from '../common/session-store.js'
import { AdapterHttpClient } from '../common/http-client.js'
import { isAllowedUser } from '../common/pairing.js'
import {
extractWechatText,
getWechatUpdates,
sendWechatText,
WECHAT_DEFAULT_BASE_URL,
type WechatMessage,
} from './protocol.js'
const WECHAT_TEXT_LIMIT = 3500
const GET_UPDATES_TIMEOUT_MS = 35_000
const config = loadConfig()
if (!config.wechat.botToken || !config.wechat.accountId) {
console.error('[WeChat] Missing QR-bound account. Bind WeChat in Desktop Settings > IM.')
process.exit(1)
}
const baseUrl = config.wechat.baseUrl || WECHAT_DEFAULT_BASE_URL
const accountId = config.wechat.accountId
const botToken = config.wechat.botToken
const bridge = new WsBridge(config.serverUrl, 'wechat')
const dedup = new MessageDedup()
const sessionStore = new SessionStore()
const httpClient = new AdapterHttpClient(config.serverUrl)
const defaultWorkDir = getConfiguredWorkDir(config, config.wechat)
const pendingProjectSelection = new Map<string, boolean>()
const runtimeStates = new Map<string, ChatRuntimeState>()
const accumulatedText = new Map<string, string>()
const contextTokens = new Map<string, string>()
const pendingPermissions = new Map<string, Set<string>>()
let getUpdatesBuf = ''
let stopped = false
type ChatRuntimeState = {
state: 'idle' | 'thinking' | 'streaming' | 'tool_executing' | 'permission_pending'
verb?: string
model?: string
pendingPermissionCount: number
}
function getRuntimeState(chatId: string): ChatRuntimeState {
let state = runtimeStates.get(chatId)
if (!state) {
state = { state: 'idle', pendingPermissionCount: 0 }
runtimeStates.set(chatId, state)
}
return state
}
async function sendText(chatId: string, text: string): Promise<void> {
const chunks = splitMessage(text, WECHAT_TEXT_LIMIT)
const contextToken = contextTokens.get(chatId)
for (const chunk of chunks) {
await sendWechatText({
baseUrl,
token: botToken,
to: chatId,
text: chunk,
contextToken,
})
}
console.log(`[WeChat] Sent ${chunks.length} message chunk(s) to ${redactChatId(chatId)}`)
}
function clearTransientChatState(chatId: string): void {
accumulatedText.delete(chatId)
pendingPermissions.delete(chatId)
const runtime = getRuntimeState(chatId)
runtime.state = 'idle'
runtime.verb = undefined
runtime.pendingPermissionCount = 0
}
async function ensureExistingSession(chatId: string): Promise<{ sessionId: string; workDir: string } | null> {
const stored = sessionStore.get(chatId)
if (!stored) return null
if (!bridge.hasSession(chatId)) {
bridge.connectSession(chatId, stored.sessionId)
bridge.onServerMessage(chatId, (msg) => handleServerMessage(chatId, msg))
const opened = await bridge.waitForOpen(chatId)
if (!opened) return null
}
return stored
}
async function buildStatusText(chatId: string): Promise<string> {
const stored = await ensureExistingSession(chatId)
if (!stored) return formatImStatus(null)
const runtime = getRuntimeState(chatId)
let projectName = path.basename(stored.workDir) || stored.workDir
let branch: string | null = null
try {
const gitInfo = await httpClient.getGitInfo(stored.sessionId)
projectName = gitInfo.repoName || path.basename(gitInfo.workDir) || projectName
branch = gitInfo.branch
} catch {
// Keep IM status best-effort.
}
return formatImStatus({
sessionId: stored.sessionId,
projectName,
branch,
model: runtime.model,
state: runtime.state,
verb: runtime.verb,
pendingPermissionCount: runtime.pendingPermissionCount,
})
}
async function ensureSession(chatId: string): Promise<boolean> {
if (bridge.hasSession(chatId)) return true
const stored = sessionStore.get(chatId)
if (stored) {
bridge.connectSession(chatId, stored.sessionId)
bridge.onServerMessage(chatId, (msg) => handleServerMessage(chatId, msg))
return await bridge.waitForOpen(chatId)
}
const workDir = defaultWorkDir
if (workDir) return await createSessionForChat(chatId, workDir)
await showProjectPicker(chatId)
return false
}
async function createSessionForChat(chatId: string, workDir: string): Promise<boolean> {
try {
bridge.resetSession(chatId)
clearTransientChatState(chatId)
const sessionId = await httpClient.createSession(workDir)
sessionStore.set(chatId, sessionId, workDir)
bridge.connectSession(chatId, sessionId)
bridge.onServerMessage(chatId, (msg) => handleServerMessage(chatId, msg))
const opened = await bridge.waitForOpen(chatId)
if (!opened) {
await sendText(chatId, '连接服务器超时,请重试。')
return false
}
return true
} catch (err) {
await sendText(chatId, `无法创建会话: ${err instanceof Error ? err.message : String(err)}`)
return false
}
}
async function showProjectPicker(chatId: string): Promise<void> {
try {
const projects = await httpClient.listRecentProjects()
if (projects.length === 0) {
await sendText(chatId, `没有找到最近的项目。发送 /new 会使用默认工作目录:${defaultWorkDir}\n也可以发送 /new /path/to/project 指定项目。`)
return
}
const lines = projects.slice(0, 10).map((p, i) =>
`${i + 1}. ${p.projectName}${p.branch ? ` (${p.branch})` : ''}\n ${p.realPath}`
)
pendingProjectSelection.set(chatId, true)
await sendText(chatId, `选择项目(回复编号):\n\n${lines.join('\n\n')}\n\n下次可直接 /new <编号、名称或绝对路径> 快速新建会话`)
} catch (err) {
await sendText(chatId, `无法获取项目列表: ${err instanceof Error ? err.message : String(err)}`)
}
}
async function startNewSession(chatId: string, query?: string): Promise<void> {
bridge.resetSession(chatId)
sessionStore.delete(chatId)
clearTransientChatState(chatId)
pendingProjectSelection.delete(chatId)
if (query) {
try {
const { project, ambiguous } = await httpClient.matchProject(query)
if (project) {
const ok = await createSessionForChat(chatId, project.realPath)
if (ok) await sendText(chatId, `已新建会话:${project.projectName}${project.branch ? ` (${project.branch})` : ''}`)
return
}
if (ambiguous) {
const list = ambiguous.map((p, i) => `${i + 1}. ${p.projectName} - ${p.realPath}`).join('\n')
await sendText(chatId, `匹配到多个项目,请更精确:\n\n${list}`)
return
}
await sendText(chatId, `未找到匹配 "${query}" 的项目。发送 /projects 查看完整列表。`)
} catch (err) {
await sendText(chatId, err instanceof Error ? err.message : String(err))
}
return
}
const workDir = defaultWorkDir
if (workDir) {
const ok = await createSessionForChat(chatId, workDir)
if (ok) await sendText(chatId, '已新建会话,可以开始对话了。')
} else {
await showProjectPicker(chatId)
}
}
async function handleServerMessage(chatId: string, msg: ServerMessage): Promise<void> {
const runtime = getRuntimeState(chatId)
switch (msg.type) {
case 'connected':
break
case 'status':
runtime.state = msg.state
runtime.verb = typeof msg.verb === 'string' ? msg.verb : undefined
break
case 'content_delta':
if (typeof msg.text === 'string' && msg.text) {
accumulatedText.set(chatId, (accumulatedText.get(chatId) ?? '') + msg.text)
}
break
case 'permission_request': {
runtime.pendingPermissionCount += 1
runtime.state = 'permission_pending'
let pending = pendingPermissions.get(chatId)
if (!pending) {
pending = new Set()
pendingPermissions.set(chatId, pending)
}
pending.add(msg.requestId)
await sendText(
chatId,
`${formatPermissionRequest(msg.toolName, msg.input, msg.requestId)}\n\n回复 /allow ${msg.requestId} 允许,或 /deny ${msg.requestId} 拒绝。`,
)
break
}
case 'message_complete': {
runtime.state = 'idle'
runtime.verb = undefined
const text = accumulatedText.get(chatId)
accumulatedText.delete(chatId)
if (text?.trim()) await sendText(chatId, text)
break
}
case 'error':
runtime.state = 'idle'
runtime.verb = undefined
accumulatedText.delete(chatId)
await sendText(chatId, `错误: ${msg.message}`)
break
case 'system_notification':
if (msg.subtype === 'init' && msg.data && typeof msg.data === 'object') {
const model = (msg.data as Record<string, unknown>).model
if (typeof model === 'string' && model.trim()) runtime.model = model
}
break
}
}
async function routeUserMessage(message: WechatMessage): Promise<void> {
const chatId = message.from_user_id
if (!chatId) return
const messageKey = `${message.message_id ?? ''}:${message.seq ?? ''}:${message.create_time_ms ?? ''}`
if (!dedup.tryRecord(messageKey)) return
if (message.context_token) contextTokens.set(chatId, message.context_token)
const text = extractWechatText(message.item_list).trim()
if (!text) return
console.log(`[WeChat] Received from ${redactChatId(chatId)}: ${text.slice(0, 80)}`)
if (!isAllowedUser('wechat', chatId)) {
await sendText(chatId, '未绑定。请在 Claude Code 桌面端「设置 -> IM 接入 -> 微信」中扫码绑定。')
return
}
enqueue(chatId, async () => {
if (text === '/help' || text === '帮助') {
await sendText(chatId, formatImHelp())
return
}
if (text === '/status' || text === '状态') {
await sendText(chatId, await buildStatusText(chatId))
return
}
if (text === '/projects' || text === '项目列表') {
await showProjectPicker(chatId)
return
}
if (text === '/new' || text === '新会话' || text.startsWith('/new ')) {
const arg = text.startsWith('/new ') ? text.slice(5).trim() : ''
await startNewSession(chatId, arg || undefined)
return
}
if (text === '/stop' || text === '停止') {
const stored = await ensureExistingSession(chatId)
if (!stored) {
await sendText(chatId, formatImStatus(null))
return
}
bridge.sendStopGeneration(chatId)
await sendText(chatId, '已发送停止信号。')
return
}
if (text === '/clear' || text === '清空') {
const stored = await ensureExistingSession(chatId)
if (!stored) {
await sendText(chatId, formatImStatus(null))
return
}
clearTransientChatState(chatId)
const sent = bridge.sendUserMessage(chatId, '/clear')
await sendText(chatId, sent ? '已清空当前会话上下文。' : '无法发送 /clear,请先发送 /new 重新连接会话。')
return
}
if (text.startsWith('/allow ') || text.startsWith('/deny ')) {
const requestId = text.split(/\s+/)[1]
if (!requestId) return
const allowed = text.startsWith('/allow ')
const sent = bridge.sendPermissionResponse(chatId, requestId, allowed)
const runtime = getRuntimeState(chatId)
runtime.pendingPermissionCount = Math.max(0, runtime.pendingPermissionCount - 1)
pendingPermissions.get(chatId)?.delete(requestId)
await sendText(chatId, sent ? (allowed ? '已允许。' : '已拒绝。') : '权限响应发送失败,请检查会话状态。')
return
}
if (pendingProjectSelection.has(chatId)) {
await startNewSession(chatId, text)
return
}
const ready = await ensureSession(chatId)
if (!ready) return
const sent = bridge.sendUserMessage(chatId, text)
if (!sent) await sendText(chatId, '消息发送失败,连接可能已断开。请发送 /new 重新开始。')
})
}
async function pollLoop(): Promise<void> {
while (!stopped) {
try {
const resp = await getWechatUpdates({
baseUrl,
token: botToken,
getUpdatesBuf,
timeoutMs: GET_UPDATES_TIMEOUT_MS,
})
if (resp.get_updates_buf) getUpdatesBuf = resp.get_updates_buf
const hasRetError = typeof resp.ret === 'number' && resp.ret !== 0
const hasErrCode = typeof resp.errcode === 'number' && resp.errcode !== 0
if (hasRetError || hasErrCode) {
console.warn(`[WeChat] getupdates error: ${resp.errcode ?? resp.ret} ${resp.errmsg ?? ''}`)
await sleep(3000)
continue
}
for (const msg of resp.msgs ?? []) {
await routeUserMessage(msg)
}
} catch (err) {
console.error('[WeChat] poll loop error:', err instanceof Error ? err.message : err)
await sleep(3000)
}
}
}
function sleep(ms: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms))
}
function redactChatId(chatId: string): string {
if (chatId.length <= 12) return chatId
return `${chatId.slice(0, 6)}...${chatId.slice(-6)}`
}
console.log('[WeChat] Starting adapter...')
console.log(`[WeChat] Account: ${accountId}`)
void pollLoop()
process.on('SIGINT', () => {
console.log('[WeChat] Shutting down...')
stopped = true
bridge.destroy()
dedup.destroy()
process.exit(0)
})
+371
View File
@@ -0,0 +1,371 @@
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)
}
}