feat: add secure remote channel media
This commit is contained in:
@@ -1,5 +1,6 @@
|
||||
import type {
|
||||
ChannelInboundText,
|
||||
ChannelMediaAttachment,
|
||||
ChannelResultMessage
|
||||
} from '../../shared/channel-contracts'
|
||||
|
||||
@@ -109,6 +110,7 @@ export class MemoryOutbox implements Outbox {
|
||||
}
|
||||
entry.state = 'delivered'
|
||||
entry.attempts += 1
|
||||
entry.message = this.withoutAttachments(entry.message)
|
||||
}
|
||||
|
||||
markFailed(id: string): void {
|
||||
@@ -118,6 +120,9 @@ export class MemoryOutbox implements Outbox {
|
||||
}
|
||||
entry.state = 'failed'
|
||||
entry.attempts += 1
|
||||
if (entry.attempts >= 5) {
|
||||
entry.message = this.withoutAttachments(entry.message)
|
||||
}
|
||||
}
|
||||
|
||||
listUndelivered(
|
||||
@@ -158,6 +163,14 @@ export class MemoryOutbox implements Outbox {
|
||||
message: structuredClone(entry.message)
|
||||
}
|
||||
}
|
||||
|
||||
private withoutAttachments(
|
||||
message: ChannelResultMessage
|
||||
): ChannelResultMessage {
|
||||
const sanitized = structuredClone(message)
|
||||
delete sanitized.attachments
|
||||
return sanitized
|
||||
}
|
||||
}
|
||||
|
||||
export type ChannelExecutor = (
|
||||
@@ -172,4 +185,5 @@ export type ChannelExecutor = (
|
||||
status: string
|
||||
output?: string
|
||||
error?: string
|
||||
attachments?: ChannelMediaAttachment[]
|
||||
}>
|
||||
|
||||
@@ -98,6 +98,27 @@ describe('channel contracts', () => {
|
||||
workMode: 'execute'
|
||||
}).success
|
||||
).toBe(false)
|
||||
expect(
|
||||
channelInboundTextSchema.parse({
|
||||
channel: 'fake',
|
||||
eventId: 'media-event',
|
||||
senderId: 'user-1',
|
||||
conversationId: 'direct-1',
|
||||
conversationType: 'direct',
|
||||
attachments: [
|
||||
{
|
||||
name: 'photo.png',
|
||||
mimeType: 'image/png',
|
||||
size: 4,
|
||||
kind: 'image',
|
||||
dataBase64: 'iVBORw=='
|
||||
}
|
||||
]
|
||||
})
|
||||
).toMatchObject({
|
||||
text: '',
|
||||
attachments: [expect.objectContaining({ name: 'photo.png' })]
|
||||
})
|
||||
expect(
|
||||
channelInboundTextSchema.safeParse({
|
||||
...inbound(),
|
||||
@@ -350,6 +371,48 @@ describe('ChannelService', () => {
|
||||
await service.stop()
|
||||
})
|
||||
|
||||
it('delivers media results and removes binary payloads after delivery', async () => {
|
||||
const driver = new FakeChannelDriver()
|
||||
const outbox = new MemoryOutbox()
|
||||
const service = new ChannelService(
|
||||
driver,
|
||||
async () => ({
|
||||
status: 'completed',
|
||||
output: '文件已生成',
|
||||
attachments: [
|
||||
{
|
||||
name: 'result.txt',
|
||||
mimeType: 'text/plain',
|
||||
size: 2,
|
||||
kind: 'file' as const,
|
||||
dataBase64: 'b2s='
|
||||
}
|
||||
]
|
||||
}),
|
||||
{
|
||||
allowedSenderIds: ['allowed-user'],
|
||||
outbox
|
||||
}
|
||||
)
|
||||
await service.start()
|
||||
await driver.emit(inbound({ eventId: 'media-result' }))
|
||||
await waitForSent(driver, 1)
|
||||
|
||||
expect(driver.sent[0]?.attachments).toEqual([
|
||||
expect.objectContaining({ name: 'result.txt' })
|
||||
])
|
||||
expect(await outbox.listUndelivered()).toEqual([])
|
||||
const storedEntries = (
|
||||
outbox as unknown as {
|
||||
entries: Map<string, { message: ChannelResultMessage }>
|
||||
}
|
||||
).entries
|
||||
expect(
|
||||
[...storedEntries.values()][0]?.message.attachments
|
||||
).toBeUndefined()
|
||||
await service.stop()
|
||||
})
|
||||
|
||||
it('cancels an active executor and stops the driver', async () => {
|
||||
const driver = new FakeChannelDriver()
|
||||
let receivedSignal: AbortSignal | undefined
|
||||
|
||||
@@ -389,6 +389,7 @@ export class ChannelService {
|
||||
status: string
|
||||
output?: string
|
||||
error?: string
|
||||
attachments?: ChannelResultMessage['attachments']
|
||||
}
|
||||
): ChannelResultMessage {
|
||||
return channelResultMessageSchema.parse({
|
||||
@@ -409,7 +410,10 @@ export class ChannelService {
|
||||
redactChannelError(result.error),
|
||||
CHANNEL_LIMITS.maximumErrorLength
|
||||
)
|
||||
})
|
||||
}),
|
||||
...(result.attachments?.length
|
||||
? { attachments: result.attachments }
|
||||
: {})
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -1,76 +0,0 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { RemoteChannelApprovalBroker } from './remote-channel-approval-broker'
|
||||
|
||||
const request = {
|
||||
requestId: '00000000-0000-4000-8000-000000000001',
|
||||
kind: 'request' as const,
|
||||
channel: 'weixin' as const,
|
||||
channelLabel: '微信 ClawBot',
|
||||
senderDisplay: '发送者 ****1234',
|
||||
projectName: '微信 ClawBot',
|
||||
rootPath: 'C:\\Users\\tester',
|
||||
title: '请求执行任务',
|
||||
description: '创建一份报告'
|
||||
}
|
||||
|
||||
describe('RemoteChannelApprovalBroker', () => {
|
||||
it('accepts only a local one-time response for the matching request', async () => {
|
||||
const published: Array<{ approvalId: string }> = []
|
||||
const broker = new RemoteChannelApprovalBroker(
|
||||
(approval) => published.push(approval),
|
||||
10_000
|
||||
)
|
||||
const controller = new AbortController()
|
||||
const result = broker.request(request, controller.signal)
|
||||
|
||||
expect(published).toHaveLength(1)
|
||||
expect(broker.listPending()).toEqual([
|
||||
expect.objectContaining({
|
||||
approvalId: published[0]!.approvalId,
|
||||
channel: 'weixin'
|
||||
})
|
||||
])
|
||||
expect(
|
||||
broker.respond(published[0]!.approvalId, 'once')
|
||||
).toBe(true)
|
||||
await expect(result).resolves.toBe('once')
|
||||
expect(broker.listPending()).toEqual([])
|
||||
expect(
|
||||
broker.respond(published[0]!.approvalId, 'deny')
|
||||
).toBe(false)
|
||||
})
|
||||
|
||||
it('denies pending approvals when aborted or cleared', async () => {
|
||||
const published: Array<{ approvalId: string }> = []
|
||||
const broker = new RemoteChannelApprovalBroker(
|
||||
(approval) => published.push(approval),
|
||||
10_000
|
||||
)
|
||||
const firstController = new AbortController()
|
||||
const first = broker.request(request, firstController.signal)
|
||||
firstController.abort()
|
||||
await expect(first).resolves.toBe('deny')
|
||||
|
||||
const second = broker.request(
|
||||
{ ...request, requestId: crypto.randomUUID() },
|
||||
new AbortController().signal
|
||||
)
|
||||
broker.clear()
|
||||
await expect(second).resolves.toBe('deny')
|
||||
})
|
||||
|
||||
it('denies an approval after its bounded timeout', async () => {
|
||||
vi.useFakeTimers()
|
||||
try {
|
||||
const broker = new RemoteChannelApprovalBroker(() => undefined, 500)
|
||||
const result = broker.request(
|
||||
request,
|
||||
new AbortController().signal
|
||||
)
|
||||
await vi.advanceTimersByTimeAsync(500)
|
||||
await expect(result).resolves.toBe('deny')
|
||||
} finally {
|
||||
vi.useRealTimers()
|
||||
}
|
||||
})
|
||||
})
|
||||
@@ -1,81 +0,0 @@
|
||||
import type {
|
||||
RemoteChannelApproval,
|
||||
RemoteChannelApprovalDecision
|
||||
} from '../../shared/remote-channel-contracts'
|
||||
|
||||
type PendingApproval = {
|
||||
approval: RemoteChannelApproval
|
||||
resolve: (decision: RemoteChannelApprovalDecision) => void
|
||||
timeout: ReturnType<typeof setTimeout>
|
||||
abort: () => void
|
||||
}
|
||||
|
||||
export class RemoteChannelApprovalBroker {
|
||||
private readonly pending = new Map<string, PendingApproval>()
|
||||
|
||||
constructor(
|
||||
private readonly publish: (approval: RemoteChannelApproval) => void,
|
||||
private readonly timeoutMs = 120_000
|
||||
) {}
|
||||
|
||||
request(
|
||||
input: Omit<RemoteChannelApproval, 'approvalId' | 'expiresAt'>,
|
||||
signal: AbortSignal
|
||||
): Promise<RemoteChannelApprovalDecision> {
|
||||
if (signal.aborted) {
|
||||
return Promise.resolve('deny')
|
||||
}
|
||||
const approvalId = crypto.randomUUID()
|
||||
const approval: RemoteChannelApproval = {
|
||||
...input,
|
||||
approvalId,
|
||||
expiresAt: new Date(Date.now() + this.timeoutMs).toISOString()
|
||||
}
|
||||
return new Promise((resolve) => {
|
||||
const finish = (
|
||||
decision: RemoteChannelApprovalDecision
|
||||
): void => {
|
||||
signal.removeEventListener('abort', abort)
|
||||
resolve(decision)
|
||||
}
|
||||
const abort = (): void => {
|
||||
this.respond(approvalId, 'deny')
|
||||
}
|
||||
const timeout = setTimeout(abort, this.timeoutMs)
|
||||
this.pending.set(approvalId, {
|
||||
approval,
|
||||
resolve: finish,
|
||||
timeout,
|
||||
abort
|
||||
})
|
||||
signal.addEventListener('abort', abort, { once: true })
|
||||
this.publish(approval)
|
||||
})
|
||||
}
|
||||
|
||||
respond(
|
||||
approvalId: string,
|
||||
decision: RemoteChannelApprovalDecision
|
||||
): boolean {
|
||||
const pending = this.pending.get(approvalId)
|
||||
if (!pending) {
|
||||
return false
|
||||
}
|
||||
clearTimeout(pending.timeout)
|
||||
this.pending.delete(approvalId)
|
||||
pending.resolve(decision)
|
||||
return true
|
||||
}
|
||||
|
||||
clear(): void {
|
||||
for (const approvalId of [...this.pending.keys()]) {
|
||||
this.respond(approvalId, 'deny')
|
||||
}
|
||||
}
|
||||
|
||||
listPending(): RemoteChannelApproval[] {
|
||||
return [...this.pending.values()].map((pending) =>
|
||||
structuredClone(pending.approval)
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import {
|
||||
parseRemoteChannelPrompt
|
||||
parseRemoteChannelPrompt,
|
||||
requestsRemoteResultFile
|
||||
} from './remote-channel-routing'
|
||||
import { projectChannelLabels } from '../../shared/assistant-contracts'
|
||||
|
||||
@@ -40,6 +41,16 @@ describe('parseRemoteChannelPrompt', () => {
|
||||
)
|
||||
})
|
||||
|
||||
it('requires an explicit downloadable file request', () => {
|
||||
expect(requestsRemoteResultFile('请生成一个文件,总结今天的进展')).toBe(
|
||||
true
|
||||
)
|
||||
expect(
|
||||
requestsRemoteResultFile('Please export the result as a file')
|
||||
).toBe(true)
|
||||
expect(requestsRemoteResultFile('请总结今天的进展')).toBe(false)
|
||||
})
|
||||
|
||||
it('defines a stable product label for every managed channel', () => {
|
||||
expect(projectChannelLabels).toEqual({
|
||||
weixin: '微信 ClawBot',
|
||||
|
||||
@@ -35,3 +35,15 @@ export function parseRemoteChannelPrompt(
|
||||
}
|
||||
return { workMode, prompt }
|
||||
}
|
||||
|
||||
export function requestsRemoteResultFile(text: string): boolean {
|
||||
const value = text.trim()
|
||||
return (
|
||||
/(?:生成|导出|整理|制作|写成|发送|发我).{0,12}(?:文件|附件|可下载文档)|(?:以|用)(?:文件|附件|可下载文档)(?:形式|格式)/u.test(
|
||||
value
|
||||
) ||
|
||||
/\b(?:create|generate|export|send|return|provide)\b.{0,40}\b(?:file|attachment|downloadable document)\b/iu.test(
|
||||
value
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
@@ -58,11 +58,20 @@ describe('WechatChannelDriver', () => {
|
||||
await starting
|
||||
|
||||
child.emit('message', {
|
||||
type: 'inbound_text',
|
||||
type: 'inbound_message',
|
||||
eventId: 'event-1',
|
||||
senderId: 'sender-1',
|
||||
conversationId: 'sender-1',
|
||||
text: '你好'
|
||||
text: '你好',
|
||||
attachments: [
|
||||
{
|
||||
name: '说明.txt',
|
||||
mimeType: 'text/plain',
|
||||
size: 2,
|
||||
kind: 'file',
|
||||
dataBase64: 'b2s='
|
||||
}
|
||||
]
|
||||
})
|
||||
await vi.waitFor(() => expect(handler).toHaveBeenCalledOnce())
|
||||
expect(handler).toHaveBeenCalledWith(
|
||||
@@ -70,7 +79,10 @@ describe('WechatChannelDriver', () => {
|
||||
channel: 'weixin',
|
||||
eventId: 'event-1',
|
||||
senderId: 'sender-1',
|
||||
workMode: 'ask'
|
||||
workMode: 'ask',
|
||||
attachments: [
|
||||
expect.objectContaining({ name: '说明.txt' })
|
||||
]
|
||||
}),
|
||||
expect.any(Function)
|
||||
)
|
||||
@@ -82,7 +94,16 @@ describe('WechatChannelDriver', () => {
|
||||
conversationId: 'sender-1',
|
||||
recipientId: 'sender-1',
|
||||
status: 'completed',
|
||||
output: '收到'
|
||||
output: '收到',
|
||||
attachments: [
|
||||
{
|
||||
name: '结果.txt',
|
||||
mimeType: 'text/plain',
|
||||
size: 2,
|
||||
kind: 'file',
|
||||
dataBase64: 'b2s='
|
||||
}
|
||||
]
|
||||
},
|
||||
new AbortController().signal
|
||||
)
|
||||
@@ -99,6 +120,9 @@ describe('WechatChannelDriver', () => {
|
||||
message.type === 'reply'
|
||||
)
|
||||
expect(reply).toBeDefined()
|
||||
expect(reply).toMatchObject({
|
||||
attachments: [expect.objectContaining({ name: '结果.txt' })]
|
||||
})
|
||||
child.emit('message', {
|
||||
type: 'reply_result',
|
||||
replyId: reply!.replyId,
|
||||
@@ -159,4 +183,55 @@ describe('WechatChannelDriver', () => {
|
||||
await expect(sending).rejects.toThrow('Sidecar 已退出')
|
||||
driver.stop()
|
||||
})
|
||||
|
||||
it('cancels in-flight sidecar media work when delivery is aborted', async () => {
|
||||
const child = new FakeSidecar()
|
||||
const driver = new WechatChannelDriver(
|
||||
settings,
|
||||
() => child as unknown as WechatSidecarChild
|
||||
)
|
||||
const starting = driver.start(vi.fn())
|
||||
await vi.waitFor(() =>
|
||||
expect(child.posted).toContainEqual(
|
||||
expect.objectContaining({ type: 'start_account' })
|
||||
)
|
||||
)
|
||||
child.emit('message', {
|
||||
type: 'status',
|
||||
status: 'connected'
|
||||
})
|
||||
await starting
|
||||
const controller = new AbortController()
|
||||
const sending = driver.send(
|
||||
{
|
||||
channel: 'weixin',
|
||||
eventId: 'event-cancel',
|
||||
conversationId: 'sender-1',
|
||||
recipientId: 'sender-1',
|
||||
status: 'completed',
|
||||
output: '结果'
|
||||
},
|
||||
controller.signal
|
||||
)
|
||||
const reply = child.posted.find(
|
||||
(
|
||||
message
|
||||
): message is {
|
||||
type: 'reply'
|
||||
replyId: string
|
||||
} =>
|
||||
typeof message === 'object' &&
|
||||
message !== null &&
|
||||
'type' in message &&
|
||||
message.type === 'reply'
|
||||
)
|
||||
controller.abort()
|
||||
|
||||
await expect(sending).rejects.toThrow('已取消')
|
||||
expect(child.posted).toContainEqual({
|
||||
type: 'cancel_reply',
|
||||
replyId: reply!.replyId
|
||||
})
|
||||
driver.stop()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -23,7 +23,7 @@ type PendingReply = {
|
||||
reject: (error: Error) => void
|
||||
}
|
||||
|
||||
const REPLY_TIMEOUT_MS = 20_000
|
||||
const REPLY_TIMEOUT_MS = 6 * 60_000
|
||||
|
||||
export class WechatChannelDriver implements ChannelDriver {
|
||||
readonly channel = 'weixin'
|
||||
@@ -91,7 +91,15 @@ export class WechatChannelDriver implements ChannelDriver {
|
||||
`任务状态:${message.status}`
|
||||
const replyId = crypto.randomUUID()
|
||||
return new Promise<void>((resolve, reject) => {
|
||||
const cancelSidecarReply = (): void => {
|
||||
try {
|
||||
this.client.send({ type: 'cancel_reply', replyId })
|
||||
} catch {
|
||||
// A dead sidecar no longer has in-flight network work.
|
||||
}
|
||||
}
|
||||
const timeout = setTimeout(() => {
|
||||
cancelSidecarReply()
|
||||
finish(() => reject(new Error('微信回复超时')))
|
||||
}, REPLY_TIMEOUT_MS)
|
||||
const finish = (callback: () => void): void => {
|
||||
@@ -101,6 +109,7 @@ export class WechatChannelDriver implements ChannelDriver {
|
||||
callback()
|
||||
}
|
||||
const abort = (): void => {
|
||||
cancelSidecarReply()
|
||||
finish(() => reject(new Error('微信回复已取消')))
|
||||
}
|
||||
this.pendingReplies.set(replyId, {
|
||||
@@ -118,7 +127,8 @@ export class WechatChannelDriver implements ChannelDriver {
|
||||
replyId,
|
||||
inReplyToEventId: message.eventId,
|
||||
conversationId: message.conversationId,
|
||||
text
|
||||
text,
|
||||
attachments: message.attachments
|
||||
})
|
||||
} catch (error) {
|
||||
finish(() =>
|
||||
@@ -171,7 +181,7 @@ export class WechatChannelDriver implements ChannelDriver {
|
||||
private handleMessage(
|
||||
message: WechatSidecarMessage | WechatSidecarCredentialMessage
|
||||
): void {
|
||||
if (message.type === 'inbound_text') {
|
||||
if (message.type === 'inbound_message') {
|
||||
void Promise.resolve(
|
||||
this.handler?.(
|
||||
{
|
||||
@@ -181,6 +191,8 @@ export class WechatChannelDriver implements ChannelDriver {
|
||||
conversationId: message.conversationId,
|
||||
conversationType: 'direct',
|
||||
text: message.text,
|
||||
attachments: message.attachments,
|
||||
attachmentError: message.attachmentError,
|
||||
mentioned: false,
|
||||
workMode: 'ask',
|
||||
receivedAt: Date.now()
|
||||
|
||||
@@ -0,0 +1,163 @@
|
||||
import {
|
||||
createCipheriv,
|
||||
createDecipheriv
|
||||
} from 'node:crypto'
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import {
|
||||
downloadWechatFile,
|
||||
uploadWechatAttachment
|
||||
} from './wechat-media'
|
||||
|
||||
const originalFetch = global.fetch
|
||||
|
||||
afterEach(() => {
|
||||
global.fetch = originalFetch
|
||||
vi.restoreAllMocks()
|
||||
})
|
||||
|
||||
function encrypt(data: Buffer, key: Buffer): Buffer {
|
||||
const cipher = createCipheriv('aes-128-ecb', key, null)
|
||||
return Buffer.concat([cipher.update(data), cipher.final()])
|
||||
}
|
||||
|
||||
describe('Weixin media transport', () => {
|
||||
it('downloads from an allowed CDN host and decrypts official file keys', async () => {
|
||||
const data = Buffer.from('remote file content', 'utf8')
|
||||
const key = Buffer.from('0123456789abcdef', 'utf8')
|
||||
const encodedHexKey = Buffer.from(
|
||||
key.toString('hex'),
|
||||
'ascii'
|
||||
).toString('base64')
|
||||
global.fetch = vi.fn(async () =>
|
||||
new Response(encrypt(data, key), {
|
||||
status: 200,
|
||||
headers: {
|
||||
'content-length': String(encrypt(data, key).byteLength)
|
||||
}
|
||||
})
|
||||
) as typeof fetch
|
||||
|
||||
await expect(
|
||||
downloadWechatFile(
|
||||
{
|
||||
media: {
|
||||
full_url:
|
||||
'https://novac2c.cdn.weixin.qq.com/c2c/download?opaque=1',
|
||||
aes_key: encodedHexKey
|
||||
},
|
||||
file_name: '..\\报告.txt',
|
||||
len: String(data.byteLength)
|
||||
},
|
||||
new AbortController().signal
|
||||
)
|
||||
).resolves.toEqual({
|
||||
name: '.._报告.txt',
|
||||
mimeType: 'text/plain',
|
||||
size: data.byteLength,
|
||||
kind: 'file',
|
||||
dataBase64: data.toString('base64')
|
||||
})
|
||||
expect(fetch).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
hostname: 'novac2c.cdn.weixin.qq.com'
|
||||
}),
|
||||
expect.objectContaining({ redirect: 'manual' })
|
||||
)
|
||||
})
|
||||
|
||||
it('rejects redirects outside Tencent Weixin hosts', async () => {
|
||||
global.fetch = vi.fn(async () =>
|
||||
new Response(null, {
|
||||
status: 302,
|
||||
headers: { location: 'https://attacker.example/media' }
|
||||
})
|
||||
) as typeof fetch
|
||||
|
||||
await expect(
|
||||
downloadWechatFile(
|
||||
{
|
||||
media: {
|
||||
full_url:
|
||||
'https://novac2c.cdn.weixin.qq.com/c2c/download?opaque=1',
|
||||
aes_key: Buffer.from(
|
||||
'0123456789abcdef',
|
||||
'utf8'
|
||||
).toString('base64')
|
||||
},
|
||||
file_name: '报告.txt',
|
||||
len: '16'
|
||||
},
|
||||
new AbortController().signal
|
||||
)
|
||||
).rejects.toThrow('地址不受信任')
|
||||
expect(fetch).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('encrypts bounded output and builds the official file message item', async () => {
|
||||
const data = Buffer.from('generated report', 'utf8')
|
||||
let uploadedCiphertext: Buffer | undefined
|
||||
global.fetch = vi.fn(async (_url, init) => {
|
||||
uploadedCiphertext = Buffer.from(
|
||||
await new Response(init?.body).arrayBuffer()
|
||||
)
|
||||
return new Response(null, {
|
||||
status: 200,
|
||||
headers: { 'x-encrypted-param': 'download-opaque' }
|
||||
})
|
||||
}) as typeof fetch
|
||||
const getUploadUrl = vi.fn(async () => ({
|
||||
upload_full_url:
|
||||
'https://novac2c.cdn.weixin.qq.com/c2c/upload?opaque=1'
|
||||
}))
|
||||
|
||||
const result = await uploadWechatAttachment({
|
||||
attachment: {
|
||||
name: '报告.txt',
|
||||
mimeType: 'text/plain',
|
||||
size: data.byteLength,
|
||||
kind: 'file',
|
||||
dataBase64: data.toString('base64')
|
||||
},
|
||||
recipientId: 'recipient-1',
|
||||
signal: new AbortController().signal,
|
||||
getUploadUrl
|
||||
})
|
||||
|
||||
expect(getUploadUrl).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
media_type: 3,
|
||||
to_user_id: 'recipient-1',
|
||||
rawsize: data.byteLength,
|
||||
no_need_thumb: true,
|
||||
aeskey: expect.stringMatching(/^[a-f0-9]{32}$/u)
|
||||
})
|
||||
)
|
||||
expect(result).toMatchObject({
|
||||
type: 4,
|
||||
file_item: {
|
||||
media: {
|
||||
encrypt_query_param: 'download-opaque',
|
||||
encrypt_type: 1
|
||||
},
|
||||
file_name: '报告.txt',
|
||||
len: String(data.byteLength)
|
||||
}
|
||||
})
|
||||
const encodedKey =
|
||||
result.type === 4
|
||||
? result.file_item.media.aes_key
|
||||
: ''
|
||||
const keyHex = Buffer.from(encodedKey, 'base64').toString('ascii')
|
||||
const decipher = createDecipheriv(
|
||||
'aes-128-ecb',
|
||||
Buffer.from(keyHex, 'hex'),
|
||||
null
|
||||
)
|
||||
expect(
|
||||
Buffer.concat([
|
||||
decipher.update(uploadedCiphertext!),
|
||||
decipher.final()
|
||||
])
|
||||
).toEqual(data)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,446 @@
|
||||
import {
|
||||
createCipheriv,
|
||||
createDecipheriv,
|
||||
createHash,
|
||||
randomBytes
|
||||
} from 'node:crypto'
|
||||
import type { ChannelMediaAttachment } from '../../shared/channel-contracts'
|
||||
import { CHANNEL_LIMITS } from '../../shared/channel-contracts'
|
||||
import {
|
||||
detectSupportedImage,
|
||||
mimeTypeFromFileName
|
||||
} from '../file-media-type'
|
||||
import { isAllowedWechatUrl } from './wechat-sidecar-security'
|
||||
|
||||
const CDN_BASE_URL = 'https://novac2c.cdn.weixin.qq.com/c2c'
|
||||
const MEDIA_TIMEOUT_MS = 30_000
|
||||
const MAX_REDIRECTS = 3
|
||||
const MAX_ENCRYPTED_BYTES =
|
||||
CHANNEL_LIMITS.maximumAttachmentBytes + 16
|
||||
|
||||
type CdnMedia = {
|
||||
encrypt_query_param?: string
|
||||
aes_key?: string
|
||||
full_url?: string
|
||||
}
|
||||
|
||||
export type WechatImageItem = {
|
||||
media?: CdnMedia
|
||||
aeskey?: string
|
||||
mid_size?: number
|
||||
hd_size?: number
|
||||
}
|
||||
|
||||
export type WechatFileItem = {
|
||||
media?: CdnMedia
|
||||
file_name?: string
|
||||
len?: string
|
||||
}
|
||||
|
||||
export type WechatUploadUrlResponse = {
|
||||
upload_param?: string
|
||||
upload_full_url?: string
|
||||
}
|
||||
|
||||
export type WechatOutboundMediaItem =
|
||||
| {
|
||||
type: 2
|
||||
image_item: {
|
||||
media: {
|
||||
encrypt_query_param: string
|
||||
aes_key: string
|
||||
encrypt_type: 1
|
||||
}
|
||||
mid_size: number
|
||||
}
|
||||
}
|
||||
| {
|
||||
type: 4
|
||||
file_item: {
|
||||
media: {
|
||||
encrypt_query_param: string
|
||||
aes_key: string
|
||||
encrypt_type: 1
|
||||
}
|
||||
file_name: string
|
||||
len: string
|
||||
}
|
||||
}
|
||||
|
||||
function assertAllowedUrl(raw: string): URL {
|
||||
if (!isAllowedWechatUrl(raw)) {
|
||||
throw new Error('微信媒体地址不受信任')
|
||||
}
|
||||
return new URL(raw)
|
||||
}
|
||||
|
||||
function safeFileName(value: string | undefined, fallback: string): string {
|
||||
const candidate = [...(value ?? '')]
|
||||
.map((character) => {
|
||||
const code = character.codePointAt(0)
|
||||
return code !== undefined && (code <= 31 || code === 127)
|
||||
? '_'
|
||||
: character
|
||||
})
|
||||
.join('')
|
||||
.replace(/[\\/:*?"<>|]/gu, '_')
|
||||
.trim()
|
||||
.slice(0, CHANNEL_LIMITS.maximumAttachmentNameLength)
|
||||
return candidate && candidate !== '.' && candidate !== '..'
|
||||
? candidate
|
||||
: fallback
|
||||
}
|
||||
|
||||
function parseAesKey(value: string, encoding: 'hex' | 'base64'): Buffer {
|
||||
if (value.length > 128) {
|
||||
throw new Error('微信媒体密钥无效')
|
||||
}
|
||||
const decoded = Buffer.from(value, encoding)
|
||||
if (decoded.byteLength === 16) {
|
||||
return decoded
|
||||
}
|
||||
if (
|
||||
encoding === 'base64' &&
|
||||
decoded.byteLength === 32 &&
|
||||
/^[0-9a-f]{32}$/iu.test(decoded.toString('ascii'))
|
||||
) {
|
||||
return Buffer.from(decoded.toString('ascii'), 'hex')
|
||||
}
|
||||
throw new Error('微信媒体密钥无效')
|
||||
}
|
||||
|
||||
function resolveDownloadUrl(media: CdnMedia): URL {
|
||||
if (media.full_url?.trim()) {
|
||||
return assertAllowedUrl(media.full_url.trim())
|
||||
}
|
||||
const parameter = media.encrypt_query_param?.trim()
|
||||
if (!parameter || parameter.length > 8_192) {
|
||||
throw new Error('微信媒体下载参数无效')
|
||||
}
|
||||
const url = new URL('/c2c/download', `${CDN_BASE_URL}/`)
|
||||
url.searchParams.set('encrypted_query_param', parameter)
|
||||
return assertAllowedUrl(url.toString())
|
||||
}
|
||||
|
||||
function withTimeout(
|
||||
inputSignal: AbortSignal,
|
||||
timeoutMs = MEDIA_TIMEOUT_MS
|
||||
): {
|
||||
signal: AbortSignal
|
||||
dispose: () => void
|
||||
} {
|
||||
const controller = new AbortController()
|
||||
const abort = (): void => controller.abort(inputSignal.reason)
|
||||
inputSignal.addEventListener('abort', abort, { once: true })
|
||||
if (inputSignal.aborted) {
|
||||
abort()
|
||||
}
|
||||
const timeout = setTimeout(
|
||||
() => controller.abort(new Error('微信媒体传输超时')),
|
||||
timeoutMs
|
||||
)
|
||||
return {
|
||||
signal: controller.signal,
|
||||
dispose: () => {
|
||||
clearTimeout(timeout)
|
||||
inputSignal.removeEventListener('abort', abort)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function readBoundedBody(
|
||||
response: Response,
|
||||
maximumBytes: number
|
||||
): Promise<Buffer> {
|
||||
const declaredLength = Number(response.headers.get('content-length'))
|
||||
if (
|
||||
Number.isFinite(declaredLength) &&
|
||||
declaredLength > maximumBytes
|
||||
) {
|
||||
throw new Error('微信媒体超过 12MB 限制')
|
||||
}
|
||||
if (!response.body) {
|
||||
return Buffer.alloc(0)
|
||||
}
|
||||
const reader = response.body.getReader()
|
||||
const chunks: Buffer[] = []
|
||||
let total = 0
|
||||
try {
|
||||
while (true) {
|
||||
const chunk = await reader.read()
|
||||
if (chunk.done) {
|
||||
break
|
||||
}
|
||||
total += chunk.value.byteLength
|
||||
if (total > maximumBytes) {
|
||||
await reader.cancel()
|
||||
throw new Error('微信媒体超过 12MB 限制')
|
||||
}
|
||||
chunks.push(Buffer.from(chunk.value))
|
||||
}
|
||||
} finally {
|
||||
reader.releaseLock()
|
||||
}
|
||||
return Buffer.concat(chunks, total)
|
||||
}
|
||||
|
||||
async function fetchWechatBytes(
|
||||
initialUrl: URL,
|
||||
signal: AbortSignal
|
||||
): Promise<Buffer> {
|
||||
let url = initialUrl
|
||||
for (let redirectCount = 0; redirectCount <= MAX_REDIRECTS; redirectCount += 1) {
|
||||
const response = await fetch(url, {
|
||||
method: 'GET',
|
||||
redirect: 'manual',
|
||||
signal
|
||||
})
|
||||
if (response.status >= 300 && response.status < 400) {
|
||||
const location = response.headers.get('location')
|
||||
if (!location || redirectCount === MAX_REDIRECTS) {
|
||||
throw new Error('微信媒体重定向无效')
|
||||
}
|
||||
url = assertAllowedUrl(new URL(location, url).toString())
|
||||
continue
|
||||
}
|
||||
if (!response.ok) {
|
||||
throw new Error(`微信媒体下载失败(${response.status})`)
|
||||
}
|
||||
return readBoundedBody(response, MAX_ENCRYPTED_BYTES)
|
||||
}
|
||||
throw new Error('微信媒体重定向过多')
|
||||
}
|
||||
|
||||
async function downloadMedia(
|
||||
media: CdnMedia,
|
||||
key: Buffer | undefined,
|
||||
signal: AbortSignal
|
||||
): Promise<Buffer> {
|
||||
const timed = withTimeout(signal)
|
||||
try {
|
||||
const encrypted = await fetchWechatBytes(
|
||||
resolveDownloadUrl(media),
|
||||
timed.signal
|
||||
)
|
||||
timed.signal.throwIfAborted()
|
||||
if (!key) {
|
||||
if (encrypted.byteLength > CHANNEL_LIMITS.maximumAttachmentBytes) {
|
||||
throw new Error('微信媒体超过 12MB 限制')
|
||||
}
|
||||
return encrypted
|
||||
}
|
||||
if (encrypted.byteLength === 0 || encrypted.byteLength % 16 !== 0) {
|
||||
throw new Error('微信媒体密文无效')
|
||||
}
|
||||
const decipher = createDecipheriv('aes-128-ecb', key, null)
|
||||
const decrypted = Buffer.concat([
|
||||
decipher.update(encrypted),
|
||||
decipher.final()
|
||||
])
|
||||
if (
|
||||
decrypted.byteLength === 0 ||
|
||||
decrypted.byteLength > CHANNEL_LIMITS.maximumAttachmentBytes
|
||||
) {
|
||||
throw new Error('微信媒体超过 12MB 限制')
|
||||
}
|
||||
return decrypted
|
||||
} finally {
|
||||
timed.dispose()
|
||||
}
|
||||
}
|
||||
|
||||
export async function downloadWechatImage(
|
||||
item: WechatImageItem,
|
||||
fallbackName: string,
|
||||
signal: AbortSignal
|
||||
): Promise<ChannelMediaAttachment> {
|
||||
if (!item.media) {
|
||||
throw new Error('微信图片缺少媒体引用')
|
||||
}
|
||||
const claimedCipherSize = item.hd_size ?? item.mid_size
|
||||
if (
|
||||
claimedCipherSize !== undefined &&
|
||||
(!Number.isSafeInteger(claimedCipherSize) ||
|
||||
claimedCipherSize < 1 ||
|
||||
claimedCipherSize > MAX_ENCRYPTED_BYTES)
|
||||
) {
|
||||
throw new Error('微信图片超过 12MB 限制')
|
||||
}
|
||||
const key = item.aeskey
|
||||
? parseAesKey(item.aeskey, 'hex')
|
||||
: item.media.aes_key
|
||||
? parseAesKey(item.media.aes_key, 'base64')
|
||||
: undefined
|
||||
const data = await downloadMedia(item.media, key, signal)
|
||||
const format = detectSupportedImage(data)
|
||||
return {
|
||||
name: safeFileName(
|
||||
`${fallbackName}.${format.extension}`,
|
||||
`微信图片.${format.extension}`
|
||||
),
|
||||
mimeType: format.mimeType,
|
||||
size: data.byteLength,
|
||||
kind: 'image',
|
||||
dataBase64: data.toString('base64')
|
||||
}
|
||||
}
|
||||
|
||||
export async function downloadWechatFile(
|
||||
item: WechatFileItem,
|
||||
signal: AbortSignal
|
||||
): Promise<ChannelMediaAttachment> {
|
||||
if (!item.media?.aes_key) {
|
||||
throw new Error('微信文件缺少加密信息')
|
||||
}
|
||||
const claimedSize = Number(item.len)
|
||||
if (
|
||||
item.len !== undefined &&
|
||||
(!Number.isSafeInteger(claimedSize) ||
|
||||
claimedSize < 1 ||
|
||||
claimedSize > CHANNEL_LIMITS.maximumAttachmentBytes)
|
||||
) {
|
||||
throw new Error('微信文件超过 12MB 限制')
|
||||
}
|
||||
const data = await downloadMedia(
|
||||
item.media,
|
||||
parseAesKey(item.media.aes_key, 'base64'),
|
||||
signal
|
||||
)
|
||||
if (item.len !== undefined && data.byteLength !== claimedSize) {
|
||||
throw new Error('微信文件大小校验失败')
|
||||
}
|
||||
const name = safeFileName(item.file_name, '微信文件.bin')
|
||||
return {
|
||||
name,
|
||||
mimeType: mimeTypeFromFileName(name),
|
||||
size: data.byteLength,
|
||||
kind: 'file',
|
||||
dataBase64: data.toString('base64')
|
||||
}
|
||||
}
|
||||
|
||||
function encryptedSize(plaintextSize: number): number {
|
||||
return Math.ceil((plaintextSize + 1) / 16) * 16
|
||||
}
|
||||
|
||||
async function uploadWechatBytes(
|
||||
url: URL,
|
||||
body: Buffer,
|
||||
signal: AbortSignal
|
||||
): Promise<string> {
|
||||
const timed = withTimeout(signal)
|
||||
try {
|
||||
const response = await fetch(url, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/octet-stream' },
|
||||
body,
|
||||
redirect: 'manual',
|
||||
signal: timed.signal
|
||||
})
|
||||
if (response.status >= 300 && response.status < 400) {
|
||||
throw new Error('微信媒体上传重定向无效')
|
||||
}
|
||||
if (!response.ok) {
|
||||
throw new Error(`微信媒体上传失败(${response.status})`)
|
||||
}
|
||||
const parameter = response.headers.get('x-encrypted-param')?.trim()
|
||||
if (!parameter || parameter.length > 8_192) {
|
||||
throw new Error('微信媒体上传结果无效')
|
||||
}
|
||||
return parameter
|
||||
} finally {
|
||||
timed.dispose()
|
||||
}
|
||||
}
|
||||
|
||||
export async function uploadWechatAttachment(input: {
|
||||
attachment: ChannelMediaAttachment
|
||||
recipientId: string
|
||||
signal: AbortSignal
|
||||
getUploadUrl: (request: {
|
||||
filekey: string
|
||||
media_type: 1 | 3
|
||||
to_user_id: string
|
||||
rawsize: number
|
||||
rawfilemd5: string
|
||||
filesize: number
|
||||
no_need_thumb: true
|
||||
aeskey: string
|
||||
}) => Promise<WechatUploadUrlResponse>
|
||||
}): Promise<WechatOutboundMediaItem> {
|
||||
const attachment = input.attachment
|
||||
const plaintext = Buffer.from(attachment.dataBase64, 'base64')
|
||||
if (
|
||||
plaintext.byteLength !== attachment.size ||
|
||||
plaintext.byteLength === 0 ||
|
||||
plaintext.byteLength > CHANNEL_LIMITS.maximumAttachmentBytes
|
||||
) {
|
||||
throw new Error('待发送附件大小无效')
|
||||
}
|
||||
if (attachment.kind === 'image') {
|
||||
detectSupportedImage(plaintext)
|
||||
}
|
||||
const filekey = randomBytes(16).toString('hex')
|
||||
const key = randomBytes(16)
|
||||
const response = await input.getUploadUrl({
|
||||
filekey,
|
||||
media_type: attachment.kind === 'image' ? 1 : 3,
|
||||
to_user_id: input.recipientId,
|
||||
rawsize: plaintext.byteLength,
|
||||
rawfilemd5: createHash('md5').update(plaintext).digest('hex'),
|
||||
filesize: encryptedSize(plaintext.byteLength),
|
||||
no_need_thumb: true,
|
||||
aeskey: key.toString('hex')
|
||||
})
|
||||
const fullUrl = response.upload_full_url?.trim()
|
||||
const uploadParameter = response.upload_param?.trim()
|
||||
const uploadUrl = fullUrl
|
||||
? assertAllowedUrl(fullUrl)
|
||||
: (() => {
|
||||
if (!uploadParameter || uploadParameter.length > 8_192) {
|
||||
throw new Error('微信媒体上传地址缺失')
|
||||
}
|
||||
const url = new URL('/c2c/upload', `${CDN_BASE_URL}/`)
|
||||
url.searchParams.set('encrypted_query_param', uploadParameter)
|
||||
url.searchParams.set('filekey', filekey)
|
||||
return assertAllowedUrl(url.toString())
|
||||
})()
|
||||
const cipher = createCipheriv('aes-128-ecb', key, null)
|
||||
const encrypted = Buffer.concat([
|
||||
cipher.update(plaintext),
|
||||
cipher.final()
|
||||
])
|
||||
const downloadParameter = await uploadWechatBytes(
|
||||
uploadUrl,
|
||||
encrypted,
|
||||
input.signal
|
||||
)
|
||||
const aesKey = Buffer.from(key.toString('hex'), 'ascii').toString(
|
||||
'base64'
|
||||
)
|
||||
if (attachment.kind === 'image') {
|
||||
return {
|
||||
type: 2,
|
||||
image_item: {
|
||||
media: {
|
||||
encrypt_query_param: downloadParameter,
|
||||
aes_key: aesKey,
|
||||
encrypt_type: 1
|
||||
},
|
||||
mid_size: encrypted.byteLength
|
||||
}
|
||||
}
|
||||
}
|
||||
return {
|
||||
type: 4,
|
||||
file_item: {
|
||||
media: {
|
||||
encrypt_query_param: downloadParameter,
|
||||
aes_key: aesKey,
|
||||
encrypt_type: 1
|
||||
},
|
||||
file_name: safeFileName(attachment.name, 'GoodBuddy 文件.bin'),
|
||||
len: String(plaintext.byteLength)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -13,6 +13,7 @@ const originalParentPort = Object.getOwnPropertyDescriptor(
|
||||
process,
|
||||
'parentPort'
|
||||
)
|
||||
const originalFetch = global.fetch
|
||||
|
||||
afterEach(() => {
|
||||
if (originalParentPort) {
|
||||
@@ -20,6 +21,7 @@ afterEach(() => {
|
||||
} else {
|
||||
delete (process as Partial<NodeJS.Process>).parentPort
|
||||
}
|
||||
global.fetch = originalFetch
|
||||
vi.resetModules()
|
||||
})
|
||||
|
||||
@@ -38,4 +40,36 @@ describe('Weixin utility-process entry', () => {
|
||||
{ type: 'status', status: 'stopped' }
|
||||
])
|
||||
})
|
||||
|
||||
it('does not follow API redirects outside Tencent Weixin hosts', async () => {
|
||||
const parentPort = new FakeParentPort()
|
||||
Object.defineProperty(process, 'parentPort', {
|
||||
configurable: true,
|
||||
value: parentPort
|
||||
})
|
||||
global.fetch = vi.fn(async () =>
|
||||
new Response(null, {
|
||||
status: 307,
|
||||
headers: {
|
||||
location: 'https://attacker.example/collect'
|
||||
}
|
||||
})
|
||||
) as typeof fetch
|
||||
await import('./wechat-sidecar')
|
||||
|
||||
parentPort.emit('message', {
|
||||
data: { type: 'start_login' }
|
||||
})
|
||||
|
||||
await vi.waitFor(() =>
|
||||
expect(parentPort.messages).toContainEqual(
|
||||
expect.objectContaining({
|
||||
type: 'status',
|
||||
status: 'failed',
|
||||
detail: expect.stringContaining('不受信任')
|
||||
})
|
||||
)
|
||||
)
|
||||
expect(fetch).toHaveBeenCalledOnce()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { buildWechatSidecarEnvironment } from './wechat-sidecar-environment'
|
||||
|
||||
describe('buildWechatSidecarEnvironment', () => {
|
||||
it('enforces TLS verification without inheriting secrets or proxy hooks', () => {
|
||||
expect(
|
||||
buildWechatSidecarEnvironment({
|
||||
SystemRoot: 'C:\\Windows',
|
||||
TEMP: 'C:\\Temp',
|
||||
LANG: 'zh_CN.UTF-8',
|
||||
NODE_TLS_REJECT_UNAUTHORIZED: '0',
|
||||
NODE_OPTIONS: '--require C:\\inject.js',
|
||||
HTTPS_PROXY: 'http://proxy.invalid',
|
||||
API_KEY: 'secret'
|
||||
})
|
||||
).toEqual({
|
||||
SystemRoot: 'C:\\Windows',
|
||||
TEMP: 'C:\\Temp',
|
||||
LANG: 'zh_CN.UTF-8',
|
||||
NODE_TLS_REJECT_UNAUTHORIZED: '1'
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,26 @@
|
||||
const wechatSidecarEnvironmentNames = [
|
||||
'SystemRoot',
|
||||
'WINDIR',
|
||||
'TEMP',
|
||||
'TMP',
|
||||
'TMPDIR',
|
||||
'HOME',
|
||||
'USERPROFILE',
|
||||
'LANG',
|
||||
'LC_ALL',
|
||||
'LC_CTYPE'
|
||||
] as const
|
||||
|
||||
export function buildWechatSidecarEnvironment(
|
||||
source: NodeJS.ProcessEnv = process.env
|
||||
): NodeJS.ProcessEnv {
|
||||
const environment: NodeJS.ProcessEnv = {
|
||||
NODE_TLS_REJECT_UNAUTHORIZED: '1'
|
||||
}
|
||||
for (const name of wechatSidecarEnvironmentNames) {
|
||||
if (source[name] !== undefined) {
|
||||
environment[name] = source[name]
|
||||
}
|
||||
}
|
||||
return environment
|
||||
}
|
||||
@@ -34,13 +34,25 @@ describe('wechatSidecarMessageSchema', () => {
|
||||
|
||||
expect(
|
||||
wechatSidecarMessageSchema.parse({
|
||||
type: 'inbound_text',
|
||||
type: 'inbound_message',
|
||||
eventId: 'event-1',
|
||||
senderId: 'sender-1',
|
||||
conversationId: 'conversation-1',
|
||||
text: '你好'
|
||||
text: '',
|
||||
attachments: [
|
||||
{
|
||||
name: '截图.png',
|
||||
mimeType: 'image/png',
|
||||
size: 4,
|
||||
kind: 'image',
|
||||
dataBase64: 'iVBORw=='
|
||||
}
|
||||
]
|
||||
})
|
||||
).toMatchObject({ eventId: 'event-1', text: '你好' })
|
||||
).toMatchObject({
|
||||
eventId: 'event-1',
|
||||
attachments: [expect.objectContaining({ name: '截图.png' })]
|
||||
})
|
||||
|
||||
expect(
|
||||
wechatSidecarCommandSchema.parse({
|
||||
@@ -48,12 +60,30 @@ describe('wechatSidecarMessageSchema', () => {
|
||||
replyId: 'reply-1',
|
||||
inReplyToEventId: 'event-1',
|
||||
conversationId: 'conversation-1',
|
||||
text: '收到'
|
||||
text: '收到',
|
||||
attachments: [
|
||||
{
|
||||
name: '结果.txt',
|
||||
mimeType: 'text/plain',
|
||||
size: 2,
|
||||
kind: 'file',
|
||||
dataBase64: 'b2s='
|
||||
}
|
||||
]
|
||||
})
|
||||
).toMatchObject({
|
||||
replyId: 'reply-1',
|
||||
inReplyToEventId: 'event-1'
|
||||
})
|
||||
expect(
|
||||
wechatSidecarCommandSchema.parse({
|
||||
type: 'cancel_reply',
|
||||
replyId: 'reply-1'
|
||||
})
|
||||
).toEqual({
|
||||
type: 'cancel_reply',
|
||||
replyId: 'reply-1'
|
||||
})
|
||||
|
||||
expect(
|
||||
wechatSidecarMessageSchema.parse({
|
||||
@@ -84,7 +114,7 @@ describe('wechatSidecarMessageSchema', () => {
|
||||
it('rejects unknown, malicious, and oversized payloads', () => {
|
||||
expect(() =>
|
||||
wechatSidecarMessageSchema.parse({
|
||||
type: 'inbound_text',
|
||||
type: 'inbound_message',
|
||||
eventId: 'event-1',
|
||||
senderId: 'sender-1',
|
||||
conversationId: 'conversation-1',
|
||||
@@ -95,7 +125,7 @@ describe('wechatSidecarMessageSchema', () => {
|
||||
|
||||
expect(() =>
|
||||
wechatSidecarMessageSchema.parse({
|
||||
type: 'inbound_text',
|
||||
type: 'inbound_message',
|
||||
eventId: 'event-1\nforged',
|
||||
senderId: 'sender-1',
|
||||
conversationId: 'conversation-1',
|
||||
@@ -105,7 +135,7 @@ describe('wechatSidecarMessageSchema', () => {
|
||||
|
||||
expect(() =>
|
||||
wechatSidecarMessageSchema.parse({
|
||||
type: 'inbound_text',
|
||||
type: 'inbound_message',
|
||||
eventId: 'event-1',
|
||||
senderId: 'sender-1',
|
||||
conversationId: 'conversation-1',
|
||||
|
||||
@@ -3,11 +3,14 @@ import {
|
||||
weixinBindingStatusSchema,
|
||||
weixinVerificationInputSchema
|
||||
} from '../../shared/weixin-channel-contracts'
|
||||
import {
|
||||
channelAttachmentsSchema
|
||||
} from '../../shared/channel-contracts'
|
||||
|
||||
export const WECHAT_SIDECAR_MAX_TEXT_LENGTH = 8_000
|
||||
export const WECHAT_SIDECAR_MAX_QR_PAYLOAD_LENGTH = 4_096
|
||||
export const WECHAT_SIDECAR_MAX_QR_TTL_MS = 5 * 60 * 1_000
|
||||
export const WECHAT_SIDECAR_PROTOCOL_VERSION = 1
|
||||
export const WECHAT_SIDECAR_PROTOCOL_VERSION = 2
|
||||
|
||||
function containsControlCharacter(value: string): boolean {
|
||||
for (const character of value) {
|
||||
@@ -69,15 +72,30 @@ export const wechatSidecarQrMessageSchema = z
|
||||
})
|
||||
.strict()
|
||||
|
||||
export const wechatSidecarInboundTextMessageSchema = z
|
||||
export const wechatSidecarInboundMessageSchema = z
|
||||
.object({
|
||||
type: z.literal('inbound_text'),
|
||||
type: z.literal('inbound_message'),
|
||||
eventId: identifierSchema,
|
||||
senderId: identifierSchema,
|
||||
conversationId: identifierSchema,
|
||||
text: textSchema
|
||||
text: z.string().max(WECHAT_SIDECAR_MAX_TEXT_LENGTH),
|
||||
attachments: channelAttachmentsSchema.optional(),
|
||||
attachmentError: z.string().trim().min(1).max(512).optional()
|
||||
})
|
||||
.strict()
|
||||
.superRefine((message, context) => {
|
||||
if (
|
||||
message.text.trim().length === 0 &&
|
||||
!message.attachments?.length &&
|
||||
!message.attachmentError
|
||||
) {
|
||||
context.addIssue({
|
||||
code: 'custom',
|
||||
path: ['text'],
|
||||
message: '消息内容不能为空'
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
export const wechatSidecarVerificationRequiredMessageSchema = z
|
||||
.object({
|
||||
@@ -120,14 +138,22 @@ export const wechatSidecarReplyCommandSchema = z
|
||||
replyId: identifierSchema,
|
||||
inReplyToEventId: identifierSchema,
|
||||
conversationId: identifierSchema,
|
||||
text: textSchema
|
||||
text: textSchema,
|
||||
attachments: channelAttachmentsSchema.optional()
|
||||
})
|
||||
.strict()
|
||||
|
||||
export const wechatSidecarCancelReplyCommandSchema = z
|
||||
.object({
|
||||
type: z.literal('cancel_reply'),
|
||||
replyId: identifierSchema
|
||||
})
|
||||
.strict()
|
||||
|
||||
export const wechatSidecarMessageSchema = z.discriminatedUnion('type', [
|
||||
wechatSidecarStatusMessageSchema,
|
||||
wechatSidecarQrMessageSchema,
|
||||
wechatSidecarInboundTextMessageSchema,
|
||||
wechatSidecarInboundMessageSchema,
|
||||
wechatSidecarVerificationRequiredMessageSchema,
|
||||
wechatSidecarConnectedMessageSchema,
|
||||
wechatSidecarReplyResultMessageSchema
|
||||
@@ -169,6 +195,7 @@ export const wechatSidecarCommandSchema = z.discriminatedUnion('type', [
|
||||
wechatSidecarStartLoginCommandSchema,
|
||||
wechatSidecarSubmitVerificationCommandSchema,
|
||||
wechatSidecarReplyCommandSchema,
|
||||
wechatSidecarCancelReplyCommandSchema,
|
||||
wechatSidecarDisconnectCommandSchema,
|
||||
wechatSidecarShutdownCommandSchema
|
||||
])
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user