feat: add secure remote channel media

This commit is contained in:
lofyer
2026-08-09 20:33:35 +08:00
parent 6c891f3522
commit 66b098ae36
46 changed files with 4403 additions and 1594 deletions
+237 -45
View File
@@ -11,6 +11,12 @@ 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
@@ -18,6 +24,7 @@ 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
@@ -49,6 +56,25 @@ type QrStatusResponse = {
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 = {
@@ -76,6 +102,7 @@ type ReplyContext = {
}
const replyContexts = new Map<string, ReplyContext>()
const replyControllers = new Map<string, AbortController>()
let activeQr:
| {
qrcode: string
@@ -152,21 +179,50 @@ async function requestJson<T>(input: {
}, 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 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})`)
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
}
return JSON.parse(text) as T
throw new Error('微信服务重定向过多')
} catch (error) {
if (timedOut) {
throw new RequestTimeoutError('微信请求等待超时')
@@ -405,7 +461,7 @@ async function pollMessages(signal: AbortSignal): Promise<void> {
timeoutMs = Math.min(result.longpolling_timeout_ms, 60_000)
}
for (const message of result.msgs ?? []) {
handleInboundMessage(message)
await handleInboundMessage(message, signal)
}
} catch (error) {
if (signal.aborted) {
@@ -430,18 +486,28 @@ async function pollMessages(signal: AbortSignal): Promise<void> {
}
}
function handleInboundMessage(message: WeixinMessage): void {
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()
if (!senderId || !text) {
?.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)
const eventId = stableEventId(
message,
senderId,
text || `media:${mediaItems.length}`
)
replyContexts.set(eventId, {
recipientId: senderId,
...(message.context_token
@@ -455,12 +521,60 @@ function handleInboundMessage(message: WeixinMessage): void {
}
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_text',
type: 'inbound_message',
eventId,
senderId,
conversationId: senderId,
text
text,
...(attachments.length > 0 ? { attachments } : {}),
...(attachmentError ? { attachmentError } : {})
})
}
@@ -497,34 +611,96 @@ async function sendReply(
})
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 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 }
}
]
const items: Array<{
item: WeixinMessageItem
stableKey: string
}> = [
{
item: {
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 || '微信消息发送失败')
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) {
@@ -534,9 +710,21 @@ async function sendReply(
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> {
@@ -563,6 +751,7 @@ async function disconnect(): Promise<void> {
activeQr = undefined
account = undefined
replyContexts.clear()
replyControllers.clear()
post({ type: 'status', status: 'stopped' })
}
@@ -612,6 +801,9 @@ parentPort.on('message', (event) => {
case 'reply':
void sendReply(command.data)
break
case 'cancel_reply':
cancelReply(command.data.replyId)
break
case 'disconnect':
void disconnect()
break