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
+51
View File
@@ -14,12 +14,41 @@ export interface TelegramChat {
username?: string
}
export interface TelegramPhotoSize {
file_id: string
file_unique_id: string
width: number
height: number
file_size?: number
}
export interface TelegramDocument {
file_id: string
file_unique_id: string
file_name?: string
mime_type?: string
file_size?: number
}
export interface TelegramMessage {
message_id: number
date: number
chat: TelegramChat
from?: TelegramUser
text?: string
/** Media caption (photos/documents); TG puts it here, not in `text`. */
caption?: string
photo?: TelegramPhotoSize[]
document?: TelegramDocument
/** Album grouping id: several photo updates share one media_group_id. */
media_group_id?: string
}
export interface TelegramFile {
file_id: string
file_unique_id: string
file_size?: number
file_path?: string
}
export interface TelegramCallbackQuery {
@@ -73,6 +102,10 @@ export interface TelegramClientLike {
sendChatAction(chatId: number, action: string): Promise<boolean>
answerCallbackQuery(callbackQueryId: string, text?: string): Promise<boolean>
setMyCommands(commands: TelegramBotCommand[]): Promise<boolean>
/** Resolve a file_id to a file_path (downloadable URL suffix). */
getFile(fileId: string): Promise<TelegramFile>
/** Download file bytes from the Bot API file endpoint (GET, proxy-aware). */
downloadFile(filePath: string): Promise<Uint8Array>
}
function resolveProxyUrl(): string | undefined {
@@ -197,4 +230,22 @@ export class TelegramClient implements TelegramClientLike {
async setMyCommands(commands: TelegramBotCommand[]): Promise<boolean> {
return this.call<boolean>('setMyCommands', { commands })
}
async getFile(fileId: string): Promise<TelegramFile> {
return this.call<TelegramFile>('getFile', { file_id: fileId })
}
async downloadFile(filePath: string): Promise<Uint8Array> {
const url = `${this.baseUrl}/file/bot${this.token}/${filePath}`
try {
const response = await this.fetchImpl(url)
if (!response.ok) {
throw new Error(`Telegram file download failed: HTTP ${String(response.status)}`)
}
return new Uint8Array(await response.arrayBuffer())
} catch (err) {
const message = err instanceof Error ? err.message : String(err)
throw new Error(this.redact(message))
}
}
}