Files
goodbuddy/src/main/channels/wechat-sidecar.ts
T

817 lines
21 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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'
import {
downloadWechatFile,
downloadWechatImage,
uploadWechatAttachment
} from './wechat-media'
import { CHANNEL_LIMITS } from '../../shared/channel-contracts'
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 MAX_API_REDIRECTS = 3
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 }
image_item?: {
media?: {
encrypt_query_param?: string
aes_key?: string
full_url?: string
}
aeskey?: string
mid_size?: number
hd_size?: number
}
file_item?: {
media?: {
encrypt_query_param?: string
aes_key?: string
full_url?: string
}
file_name?: string
len?: 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>()
const replyControllers = new Map<string, AbortController>()
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 })
if (input.signal?.aborted) {
abort()
}
try {
try {
const body =
input.body === undefined
? undefined
: JSON.stringify(input.body)
let requestUrl = url
for (
let redirectCount = 0;
redirectCount <= MAX_API_REDIRECTS;
redirectCount += 1
) {
const response = await fetch(requestUrl, {
method: input.method,
headers: commonHeaders(input.token),
...(body === undefined ? {} : { body }),
redirect: 'manual',
signal: timeoutController.signal
})
if (response.status >= 300 && response.status < 400) {
const location = response.headers.get('location')
if (
!location ||
redirectCount === MAX_API_REDIRECTS
) {
throw new Error('微信服务重定向无效')
}
requestUrl = assertTencentUrl(
new URL(location, requestUrl).toString()
)
continue
}
const text = await response.text()
if (!response.ok) {
throw new Error(
`微信服务请求失败(${response.status})`
)
}
return JSON.parse(text) as T
}
throw new Error('微信服务重定向过多')
} 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 ?? []) {
await handleInboundMessage(message, signal)
}
} 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)
}
}
}
async function handleInboundMessage(
message: WeixinMessage,
signal: AbortSignal
): Promise<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() ?? ''
const mediaItems = (message.item_list ?? [])
.filter((item) => item.type === 2 || item.type === 4)
.slice(0, CHANNEL_LIMITS.maximumAttachmentCount)
if (!senderId || (!text && mediaItems.length === 0)) {
return
}
const eventId = stableEventId(
message,
senderId,
text || `media:${mediaItems.length}`
)
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)
}
const results = await Promise.all(
mediaItems.map(async (item, index) => {
try {
if (item.type === 2 && item.image_item) {
return {
attachment: await downloadWechatImage(
item.image_item,
`微信图片-${message.message_id ?? message.seq ?? index + 1}`,
signal
)
}
}
if (item.type === 4 && item.file_item) {
return {
attachment: await downloadWechatFile(
item.file_item,
signal
)
}
}
return {}
} catch (error) {
return { error: safeDetail(error) }
}
})
)
const attachments = []
let attachmentError: string | undefined
for (const result of results) {
attachmentError ??= result.error
if (!result.attachment) {
continue
}
const total = attachments.reduce(
(sum, candidate) => sum + candidate.size,
0
)
if (
total + result.attachment.size >
CHANNEL_LIMITS.maximumAttachmentBytes
) {
attachmentError = '微信附件总大小超过 12MB 限制'
break
}
attachments.push(result.attachment)
}
post({
type: 'inbound_message',
eventId,
senderId,
conversationId: senderId,
text,
...(attachments.length > 0 ? { attachments } : {}),
...(attachmentError ? { attachmentError } : {})
})
}
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
}
const controller = new AbortController()
const lifecycleSignal = lifecycleController.signal
const abortFromLifecycle = (): void =>
controller.abort(lifecycleSignal.reason)
lifecycleSignal.addEventListener(
'abort',
abortFromLifecycle,
{ once: true }
)
if (lifecycleSignal.aborted) {
abortFromLifecycle()
}
replyControllers.set(command.replyId, controller)
try {
const items: Array<{
item: WeixinMessageItem
stableKey: string
}> = [
{
item: {
type: 1,
text_item: { text: command.text }
},
stableKey: `text\u0000${command.text}`
}
]
for (const [index, attachment] of (
command.attachments ?? []
).entries()) {
items.push(
{
item: await uploadWechatAttachment({
attachment,
recipientId: context.recipientId,
signal: controller.signal,
getUploadUrl: (request) =>
requestJson({
baseUrl: currentAccount.baseUrl,
endpoint: 'ilink/bot/getuploadurl',
method: 'POST',
token: currentAccount.token,
body: {
...request,
base_info: baseInfo()
},
timeoutMs: API_TIMEOUT_MS,
signal: controller.signal
})
}),
stableKey: `attachment\u0000${index}\u0000${attachment.kind}\u0000${createHash(
'sha256'
)
.update(attachment.dataBase64, 'ascii')
.digest('hex')}`
}
)
}
for (const [index, entry] of items.entries()) {
const clientId = `goodbuddy-${createHash('sha256')
.update(
`${command.inReplyToEventId}\u0000${index}\u0000${entry.stableKey}`
)
.digest('hex')
.slice(0, 32)}`
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: clientId,
context_token: context.contextToken,
message_type: 2,
message_state: 2,
item_list: [entry.item]
},
base_info: baseInfo()
},
timeoutMs: API_TIMEOUT_MS,
signal: controller.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)
})
} finally {
lifecycleSignal.removeEventListener(
'abort',
abortFromLifecycle
)
replyControllers.delete(command.replyId)
}
}
function cancelReply(replyId: string): void {
replyControllers
.get(replyId)
?.abort(new 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()
replyControllers.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 'cancel_reply':
cancelReply(command.data.replyId)
break
case 'disconnect':
void disconnect()
break
case 'shutdown':
void disconnect().finally(() => process.exit(0))
break
}
})
post({ type: 'status', status: 'stopped' })