feat: add remote channels and richer notes

This commit is contained in:
lofyer
2026-08-09 15:48:52 +08:00
parent 417a9fccb6
commit 6c891f3522
69 changed files with 13012 additions and 499 deletions
+624
View File
@@ -0,0 +1,624 @@
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'
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 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 }
}
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<string, ReplyContext>()
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<string, string> {
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<T>(input: {
baseUrl: string
endpoint: string
method: 'GET' | 'POST'
token?: string
body?: unknown
timeoutMs: number
signal?: AbortSignal
}): Promise<T> {
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 })
try {
try {
const response = await fetch(url, {
method: input.method,
headers: commonHeaders(input.token),
...(input.body === undefined
? {}
: { body: JSON.stringify(input.body) }),
signal: timeoutController.signal
})
const text = await response.text()
if (!response.ok) {
throw new Error(`微信服务请求失败(${response.status})`)
}
return JSON.parse(text) as T
} catch (error) {
if (timedOut) {
throw new RequestTimeoutError('微信请求等待超时')
}
throw error
}
} finally {
clearTimeout(timeout)
input.signal?.removeEventListener('abort', abort)
}
}
async function startLogin(): Promise<void> {
lifecycleController.abort()
lifecycleController = new AbortController()
activeQr = undefined
post({ type: 'status', status: 'starting' })
try {
const result = await requestJson<QrResponse>({
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<void> {
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<QrStatusResponse>({
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<void> {
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<void> {
let cursor = ''
let timeoutMs = LONG_POLL_TIMEOUT_MS
let failures = 0
while (!signal.aborted && account) {
try {
const result = await requestJson<UpdatesResponse>({
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 ?? []) {
handleInboundMessage(message)
}
} 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)
}
}
}
function handleInboundMessage(message: WeixinMessage): void {
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()
if (!senderId || !text) {
return
}
const eventId = stableEventId(message, senderId, text)
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)
}
post({
type: 'inbound_text',
eventId,
senderId,
conversationId: senderId,
text
})
}
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<WechatSidecarCommand, { type: 'reply' }>
): Promise<void> {
const currentAccount = account
const context = replyContexts.get(command.inReplyToEventId)
if (!currentAccount || !context) {
post({
type: 'reply_result',
replyId: command.replyId,
ok: false,
error: '微信回复上下文已失效'
})
return
}
try {
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: `goodbuddy-${randomUUID()}`,
context_token: context.contextToken,
message_type: 2,
message_state: 2,
item_list: [
{
type: 1,
text_item: { text: command.text }
}
]
},
base_info: baseInfo()
},
timeoutMs: API_TIMEOUT_MS,
signal: lifecycleController.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)
})
}
}
async function notifyLifecycle(
endpoint: 'notifystart' | 'notifystop'
): Promise<void> {
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<void> {
lifecycleController.abort()
try {
await notifyLifecycle('notifystop')
} catch {
// Best effort during local disconnect and shutdown.
}
activeQr = undefined
account = undefined
replyContexts.clear()
post({ type: 'status', status: 'stopped' })
}
function sleep(ms: number, signal: AbortSignal): Promise<void> {
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 'disconnect':
void disconnect()
break
case 'shutdown':
void disconnect().finally(() => process.exit(0))
break
}
})
post({ type: 'status', status: 'stopped' })