feat: add remote channels and richer notes
This commit is contained in:
@@ -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' })
|
||||
Reference in New Issue
Block a user