feat: admit photos and image documents into the bound session

Telegram 图片消息此前一律回复「暂只支持文本消息」。本改动:

- photo 取最大尺寸(TG 压成 jpeg),document 仅接受 image/png|jpeg|webp|gif
- caption 作为伴随文本块进入同一条消息(不解析为命令)
- 相册(同一 media_group_id)去抖 1.2s 合并为一条多图消息,避免 N 连回合
- 派发走宿主 sessions.prompt RPC(Web 端同通道):模型图像能力校验 +
  附件服务持久化 + mode queue 排队,与纯文本路径的 agent.followup 语义一致
- 错误映射:MODEL_DOES_NOT_SUPPORT_IMAGES → 提示换多模态模型;其余回退
  图片发送失败 + [code] 详情;下载失败即时提示
- 客户端新增 getFile/downloadFile(GET file 端点,复用代理感知 fetch)

tests +7(58/58):photo+caption、png document、pdf 拒绝、相册合并、
模型不支持、下载失败、未绑定。无 emoji 文案。
This commit is contained in:
RRRosmontis
2026-09-05 17:51:21 +08:00
parent 8600835436
commit f3f6a3ebaf
13 changed files with 723 additions and 4 deletions
+256
View File
@@ -28,6 +28,8 @@ function fakeClient(
sendChatAction: async () => true,
answerCallbackQuery: async () => true,
setMyCommands: async () => true,
getFile: async (fileId: string) => ({ file_id: fileId, file_unique_id: fileId, file_path: `path/${fileId}` }),
downloadFile: async () => new Uint8Array([1, 2, 3]),
...overrides,
}
}
@@ -1053,3 +1055,257 @@ test('/compact without bind prompts NEED_BIND', async () => {
await bridge.processUpdate(messageUpdate(10, 1, '/compact'))
assert.equal(sent[0]?.text, MSG.NEED_BIND)
})
// ── 图片消息 ──
type PromptPayload = { sessionId: string; mode: string; content: Array<{ type: string; text?: string; mediaType?: string; data?: string; name?: string }> }
function photoUpdate(chatId: number, userId: number, fileId: string, opts?: {
caption?: string
mediaGroupId?: string
updateId?: number
}): TelegramUpdate {
const updateId = opts?.updateId ?? 1
return {
update_id: updateId,
message: {
message_id: updateId,
date: 0,
chat: { id: chatId, type: 'private' },
from: { id: userId },
caption: opts?.caption,
media_group_id: opts?.mediaGroupId,
photo: [
{ file_id: `${fileId}-small`, file_unique_id: `${fileId}-small`, width: 90, height: 90 },
{ file_id: fileId, file_unique_id: fileId, width: 1280, height: 960, file_size: 300000 },
],
},
}
}
function bindUpdate(chatId: number, userId: number, sessionId: string): TelegramUpdate {
return {
update_id: 1,
callback_query: {
id: 'bind',
from: { id: userId },
message: { message_id: 1, date: 0, chat: { id: chatId, type: 'private' } },
data: `${BIND_CB_PREFIX}${sessionId}`,
},
}
}
function imageBridgeCtx(sessionId: string, prompts: PromptPayload[], promptResult?: unknown) {
const agent = makeAgent(sessionId, [])
return {
agent,
ctx: {
logger: { info() {}, warn() {}, error() {} },
agents: {
list: () => [agent],
roots: () => [agent],
get: (id: ReturnType<typeof SessionId>) => (String(id) === sessionId ? agent : undefined),
},
apiProxy: {
sessions: {
prompt: async (req: { payload: unknown }) => {
prompts.push(req.payload as PromptPayload)
return promptResult ?? rpcOk({ accepted: true })
},
},
},
on() { return () => {} },
},
}
}
test('photo with caption is admitted via sessions.prompt (largest size, jpeg, base64)', async () => {
const sent: SentMessage[] = []
const prompts: PromptPayload[] = []
const sessionId = 'live-img'
const { ctx } = imageBridgeCtx(sessionId, prompts)
const jpegBytes = new Uint8Array([0xff, 0xd8, 1, 2, 3])
const bridge = new TelegramBridge(ctx as any, {
token: 't',
allowedUserIds: [1],
allowAllUsers: false,
client: fakeClient(sent, {
getFile: async (fileId: string) => ({ file_id: fileId, file_unique_id: fileId, file_path: `dir/${fileId}` }),
downloadFile: async (filePath: string) => {
assert.ok(filePath.includes('big-photo'), 'downloads the largest photo size')
return jpegBytes
},
}),
sleep: async () => {},
})
await bridge.processUpdate(bindUpdate(10, 1, sessionId))
await bridge.processUpdate(photoUpdate(10, 1, 'big-photo', { caption: '看看这张图', updateId: 2 }))
assert.equal(prompts.length, 1, 'one prompt RPC')
const content = prompts[0]!.content
assert.deepEqual(content[0], { type: 'text', text: '看看这张图' })
assert.equal(content[1]?.type, 'image')
assert.equal(content[1]?.mediaType, 'image/jpeg')
assert.equal(content[1]?.data, Buffer.from(jpegBytes).toString('base64'))
assert.ok(!sent.some((m) => m.text === MSG.MEDIA_UNSUPPORTED), 'no unsupported notice')
})
test('image document (png) is admitted with its file name', async () => {
const sent: SentMessage[] = []
const prompts: PromptPayload[] = []
const sessionId = 'live-img-doc'
const { ctx } = imageBridgeCtx(sessionId, prompts)
const bridge = new TelegramBridge(ctx as any, {
token: 't',
allowedUserIds: [1],
allowAllUsers: false,
client: fakeClient(sent),
sleep: async () => {},
})
await bridge.processUpdate(bindUpdate(10, 1, sessionId))
await bridge.processUpdate({
update_id: 2,
message: {
message_id: 2,
date: 0,
chat: { id: 10, type: 'private' },
from: { id: 1 },
document: {
file_id: 'doc-png',
file_unique_id: 'doc-png',
file_name: 'screenshot.png',
mime_type: 'image/png',
file_size: 5000,
},
},
})
assert.equal(prompts.length, 1)
const image = prompts[0]!.content.find((p) => p.type === 'image')
assert.equal(image?.mediaType, 'image/png')
assert.equal(image?.name, 'screenshot.png')
})
test('non-image document gets MEDIA_UNSUPPORTED and no prompt', async () => {
const sent: SentMessage[] = []
const prompts: PromptPayload[] = []
const sessionId = 'live-img-pdf'
const { ctx } = imageBridgeCtx(sessionId, prompts)
const bridge = new TelegramBridge(ctx as any, {
token: 't',
allowedUserIds: [1],
allowAllUsers: false,
client: fakeClient(sent),
sleep: async () => {},
})
await bridge.processUpdate(bindUpdate(10, 1, sessionId))
sent.length = 0
await bridge.processUpdate({
update_id: 2,
message: {
message_id: 2,
date: 0,
chat: { id: 10, type: 'private' },
from: { id: 1 },
document: { file_id: 'doc-pdf', file_unique_id: 'doc-pdf', file_name: 'report.pdf', mime_type: 'application/pdf' },
},
})
assert.equal(prompts.length, 0, 'no prompt dispatched')
assert.ok(sent.some((m) => m.text === MSG.MEDIA_UNSUPPORTED), 'unsupported notice sent')
})
test('album photos collapse into a single prompt with all images', async () => {
const sent: SentMessage[] = []
const prompts: PromptPayload[] = []
const sessionId = 'live-img-album'
const { ctx } = imageBridgeCtx(sessionId, prompts)
const bridge = new TelegramBridge(ctx as any, {
token: 't',
allowedUserIds: [1],
allowAllUsers: false,
client: fakeClient(sent),
sleep: async () => {},
})
await bridge.processUpdate(bindUpdate(10, 1, sessionId))
await bridge.processUpdate(photoUpdate(10, 1, 'album-1', { mediaGroupId: 'g1', updateId: 2 }))
await bridge.processUpdate(photoUpdate(10, 1, 'album-2', { mediaGroupId: 'g1', updateId: 3 }))
for (let i = 0; i < 500 && prompts.length === 0; i++) {
await new Promise((r) => setTimeout(r, 10))
}
assert.equal(prompts.length, 1, 'album debounced into one prompt')
assert.equal(prompts[0]!.content.filter((p) => p.type === 'image').length, 2)
})
test('model without image support replies IMAGE_MODEL_UNSUPPORTED', async () => {
const sent: SentMessage[] = []
const prompts: PromptPayload[] = []
const sessionId = 'live-img-nv'
const { ctx } = imageBridgeCtx(sessionId, prompts, {
rpcId: 'x',
result: {
ok: false,
error: {
code: 'attachment-error',
message: 'Model "deepseek-chat" does not support image input.',
details: { reason: 'MODEL_DOES_NOT_SUPPORT_IMAGES' },
},
},
})
const bridge = new TelegramBridge(ctx as any, {
token: 't',
allowedUserIds: [1],
allowAllUsers: false,
client: fakeClient(sent),
sleep: async () => {},
})
await bridge.processUpdate(bindUpdate(10, 1, sessionId))
sent.length = 0
await bridge.processUpdate(photoUpdate(10, 1, 'nv-photo', { updateId: 2 }))
for (let i = 0; i < 100 && sent.length === 0; i++) {
await new Promise((r) => setTimeout(r, 5))
}
assert.ok(sent.some((m) => m.text === MSG.IMAGE_MODEL_UNSUPPORTED), 'model unsupported notice')
})
test('photo download failure replies IMAGE_FAILED', async () => {
const sent: SentMessage[] = []
const prompts: PromptPayload[] = []
const sessionId = 'live-img-dl'
const { ctx } = imageBridgeCtx(sessionId, prompts)
const bridge = new TelegramBridge(ctx as any, {
token: 't',
allowedUserIds: [1],
allowAllUsers: false,
client: fakeClient(sent, {
getFile: async () => { throw new Error('telegram timeout') },
}),
sleep: async () => {},
})
await bridge.processUpdate(bindUpdate(10, 1, sessionId))
sent.length = 0
await bridge.processUpdate(photoUpdate(10, 1, 'dl-photo', { updateId: 2 }))
for (let i = 0; i < 100 && sent.length === 0; i++) {
await new Promise((r) => setTimeout(r, 5))
}
assert.equal(prompts.length, 0, 'no prompt dispatched')
assert.ok(sent.some((m) => m.text.startsWith('图片发送失败。')), 'failure notice')
})
test('photo without bind prompts NEED_BIND and no prompt', async () => {
const sent: SentMessage[] = []
const prompts: PromptPayload[] = []
const ctx = {
logger: { info() {}, warn() {}, error() {} },
agents: { list: () => [], roots: () => [], get: () => undefined },
apiProxy: { sessions: { prompt: async (req: { payload: unknown }) => { prompts.push(req.payload as PromptPayload); return rpcOk({ accepted: true }) } } },
on() { return () => {} },
}
const bridge = new TelegramBridge(ctx as any, {
token: 't',
allowedUserIds: [1],
allowAllUsers: false,
client: fakeClient(sent),
sleep: async () => {},
})
await bridge.processUpdate(photoUpdate(10, 1, 'unbound-photo', { updateId: 1 }))
assert.equal(prompts.length, 0)
assert.ok(sent.some((m) => m.text === MSG.NEED_BIND), 'NEED_BIND sent')
})