mirror of
https://github.com/NanmiCoder/cc-haha
synced 2026-07-19 13:33:35 +08:00
feat(telegram): accept photo/document/video/audio/voice and forward attachments
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
2e78d3f885
commit
eda97e5ad9
@ -21,6 +21,10 @@ import {
|
|||||||
import { SessionStore } from '../common/session-store.js'
|
import { SessionStore } from '../common/session-store.js'
|
||||||
import { AdapterHttpClient } from '../common/http-client.js'
|
import { AdapterHttpClient } from '../common/http-client.js'
|
||||||
import { isAllowedUser, tryPair } from '../common/pairing.js'
|
import { isAllowedUser, tryPair } from '../common/pairing.js'
|
||||||
|
import { TelegramMediaService } from './media.js'
|
||||||
|
import { AttachmentStore } from '../common/attachment/attachment-store.js'
|
||||||
|
import { checkAttachmentLimit } from '../common/attachment/attachment-limits.js'
|
||||||
|
import type { AttachmentRef } from '../common/ws-bridge.js'
|
||||||
|
|
||||||
const TELEGRAM_TEXT_LIMIT = 4000 // leave margin below 4096
|
const TELEGRAM_TEXT_LIMIT = 4000 // leave margin below 4096
|
||||||
|
|
||||||
@ -37,6 +41,11 @@ const bridge = new WsBridge(config.serverUrl, 'tg')
|
|||||||
const dedup = new MessageDedup()
|
const dedup = new MessageDedup()
|
||||||
const sessionStore = new SessionStore()
|
const sessionStore = new SessionStore()
|
||||||
const httpClient = new AdapterHttpClient(config.serverUrl)
|
const httpClient = new AdapterHttpClient(config.serverUrl)
|
||||||
|
const attachmentStore = new AttachmentStore()
|
||||||
|
const media = new TelegramMediaService(bot, attachmentStore)
|
||||||
|
attachmentStore.gc().catch((err) => {
|
||||||
|
console.warn('[Telegram] AttachmentStore.gc failed:', err instanceof Error ? err.message : err)
|
||||||
|
})
|
||||||
|
|
||||||
// Track placeholder messages for streaming updates
|
// Track placeholder messages for streaming updates
|
||||||
const placeholders = new Map<string, { chatId: string; messageId: number }>()
|
const placeholders = new Map<string, { chatId: string; messageId: number }>()
|
||||||
@ -480,49 +489,135 @@ bot.command('clear', (ctx) => {
|
|||||||
})()
|
})()
|
||||||
})
|
})
|
||||||
|
|
||||||
bot.on('message:text', (ctx) => {
|
/** Shared per-user-message pipeline: dedup, pairing check, project-pick
|
||||||
if (!ctx.from) return
|
* routing, enqueue, ensureSession, sendUserMessage with attachments.
|
||||||
|
* Caller has already extracted text and attachments from the context. */
|
||||||
// 只处理私聊
|
async function routeUserMessage(
|
||||||
if (ctx.chat.type !== 'private') return
|
ctx: Context,
|
||||||
|
text: string,
|
||||||
if (!dedup.tryRecord(String(ctx.message.message_id))) return
|
attachments: AttachmentRef[],
|
||||||
|
): Promise<void> {
|
||||||
|
if (!ctx.from || ctx.chat?.type !== 'private') return
|
||||||
|
if (!dedup.tryRecord(String(ctx.message?.message_id))) return
|
||||||
|
|
||||||
const chatId = String(ctx.chat.id)
|
const chatId = String(ctx.chat.id)
|
||||||
const userId = ctx.from.id
|
const userId = ctx.from.id
|
||||||
const text = ctx.message.text
|
|
||||||
|
|
||||||
// 检查配对状态
|
|
||||||
if (!isAllowedUser('telegram', userId)) {
|
if (!isAllowedUser('telegram', userId)) {
|
||||||
// 尝试配对
|
|
||||||
const displayName = [ctx.from.first_name, ctx.from.last_name].filter(Boolean).join(' ')
|
const displayName = [ctx.from.first_name, ctx.from.last_name].filter(Boolean).join(' ')
|
||||||
const success = tryPair(text.trim(), { userId, displayName }, 'telegram')
|
const success = tryPair(text.trim(), { userId, displayName }, 'telegram')
|
||||||
if (success) {
|
if (success) {
|
||||||
ctx.reply('✅ 配对成功!现在可以开始聊天了。\n\n发送消息即可与 Claude 对话。')
|
await ctx.reply('✅ 配对成功!现在可以开始聊天了。\n\n发送消息即可与 Claude 对话。')
|
||||||
} else {
|
} else {
|
||||||
ctx.reply('🔒 未授权。请在 Claude Code 桌面端生成配对码后发送给我。')
|
await ctx.reply('🔒 未授权。请在 Claude Code 桌面端生成配对码后发送给我。')
|
||||||
}
|
}
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
enqueue(chatId, async () => {
|
enqueue(chatId, async () => {
|
||||||
// Project selection pending — treat input as /new <query>
|
|
||||||
if (pendingProjectSelection.has(chatId)) {
|
if (pendingProjectSelection.has(chatId)) {
|
||||||
await startNewSession(chatId, text.trim())
|
if (text.trim()) await startNewSession(chatId, text.trim())
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// Normal message flow
|
|
||||||
const ready = await ensureSession(chatId)
|
const ready = await ensureSession(chatId)
|
||||||
if (ready) {
|
if (!ready) return
|
||||||
const sent = bridge.sendUserMessage(chatId, text)
|
const effective =
|
||||||
if (!sent) {
|
text || (attachments.length > 0 ? '(用户发送了附件)' : '')
|
||||||
await bot.api.sendMessage(Number(chatId), '⚠️ 消息发送失败,连接可能已断开。请发送 /new 重新开始。')
|
if (!effective && attachments.length === 0) return
|
||||||
}
|
const sent = bridge.sendUserMessage(chatId, effective, attachments.length ? attachments : undefined)
|
||||||
|
if (!sent) {
|
||||||
|
await bot.api.sendMessage(Number(chatId), '⚠️ 消息发送失败,连接可能已断开。请发送 /new 重新开始。')
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Scan ctx.message for photo/document/video/audio/voice, download
|
||||||
|
* each via TelegramMediaService, apply size/mime limits, and produce
|
||||||
|
* a ready-to-send AttachmentRef[] plus any rejection hints. */
|
||||||
|
async function collectAttachmentsFromCtx(
|
||||||
|
ctx: Context,
|
||||||
|
): Promise<{ attachments: AttachmentRef[]; rejections: string[] }> {
|
||||||
|
const msg = ctx.message
|
||||||
|
if (!msg || !ctx.chat) return { attachments: [], rejections: [] }
|
||||||
|
const sessionId = sessionStore.get(String(ctx.chat.id))?.sessionId ?? String(ctx.chat.id)
|
||||||
|
const attachments: AttachmentRef[] = []
|
||||||
|
const rejections: string[] = []
|
||||||
|
|
||||||
|
const runOne = async (
|
||||||
|
fileId: string,
|
||||||
|
fileName?: string,
|
||||||
|
mimeType?: string,
|
||||||
|
): Promise<void> => {
|
||||||
|
try {
|
||||||
|
const local = await media.downloadFile(fileId, sessionId, { fileName, mimeType })
|
||||||
|
const check = checkAttachmentLimit(local.kind, local.size, local.mimeType)
|
||||||
|
if (!check.ok) {
|
||||||
|
rejections.push(check.hint)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (local.kind === 'image') {
|
||||||
|
attachments.push({
|
||||||
|
type: 'image',
|
||||||
|
name: local.name,
|
||||||
|
data: local.buffer.toString('base64'),
|
||||||
|
mimeType: local.mimeType,
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
attachments.push({
|
||||||
|
type: 'file',
|
||||||
|
name: local.name,
|
||||||
|
path: local.path,
|
||||||
|
mimeType: local.mimeType,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error('[Telegram] downloadFile failed:', err)
|
||||||
|
rejections.push('📎 附件下载失败')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Photos: grammY exposes an array of sizes, largest last.
|
||||||
|
if (msg.photo && msg.photo.length > 0) {
|
||||||
|
const largest = msg.photo[msg.photo.length - 1]!
|
||||||
|
await runOne(largest.file_id, `photo-${largest.file_unique_id}.jpg`, 'image/jpeg')
|
||||||
|
}
|
||||||
|
if (msg.document) {
|
||||||
|
await runOne(msg.document.file_id, msg.document.file_name, msg.document.mime_type)
|
||||||
|
}
|
||||||
|
if (msg.video) {
|
||||||
|
await runOne(msg.video.file_id, msg.video.file_name, msg.video.mime_type)
|
||||||
|
}
|
||||||
|
if (msg.audio) {
|
||||||
|
await runOne(msg.audio.file_id, msg.audio.file_name, msg.audio.mime_type)
|
||||||
|
}
|
||||||
|
if (msg.voice) {
|
||||||
|
await runOne(
|
||||||
|
msg.voice.file_id,
|
||||||
|
`voice-${msg.voice.file_unique_id}.ogg`,
|
||||||
|
msg.voice.mime_type ?? 'audio/ogg',
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return { attachments, rejections }
|
||||||
|
}
|
||||||
|
|
||||||
|
bot.on('message:text', async (ctx) => {
|
||||||
|
await routeUserMessage(ctx, ctx.message.text, [])
|
||||||
})
|
})
|
||||||
|
|
||||||
|
bot.on(
|
||||||
|
['message:photo', 'message:document', 'message:video', 'message:audio', 'message:voice'],
|
||||||
|
async (ctx) => {
|
||||||
|
const caption = ctx.message.caption ?? ''
|
||||||
|
const { attachments, rejections } = await collectAttachmentsFromCtx(ctx)
|
||||||
|
for (const r of rejections) {
|
||||||
|
await ctx.reply(r).catch(() => {})
|
||||||
|
}
|
||||||
|
if (attachments.length === 0 && !caption.trim()) return
|
||||||
|
await routeUserMessage(ctx, caption, attachments)
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
bot.on('callback_query:data', async (ctx) => {
|
bot.on('callback_query:data', async (ctx) => {
|
||||||
const data = ctx.callbackQuery.data
|
const data = ctx.callbackQuery.data
|
||||||
if (!data.startsWith('permit:')) return
|
if (!data.startsWith('permit:')) return
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user