From 05e59850a4ee04773d7362b5a46eb46f0d8cb122 Mon Sep 17 00:00:00 2001 From: dorianzheng Date: Sun, 26 Apr 2026 07:50:19 +0800 Subject: [PATCH] feat: add Telegram send-side media support (photos, documents, videos) --- package.json | 1 + pnpm-lock.yaml | 3 + .../main/runtime/agent-message-attachments.ts | 26 +- .../main/runtime/agent-runtime.test.ts | 96 +++++++- .../main/runtime/agent-runtime/index.ts | 24 +- src/electron/main/runtime/dune-agent.ts | 11 +- .../main/runtime/dune-channel.test.ts | 59 +++++ src/electron/main/runtime/dune-channel.ts | 75 +++++- .../runtime/telegram-media-driver.test.ts | 141 +++++++++++ .../main/runtime/telegram-media-driver.ts | 228 ++++++++++++++++++ 10 files changed, 640 insertions(+), 24 deletions(-) create mode 100644 src/electron/main/runtime/telegram-media-driver.test.ts create mode 100644 src/electron/main/runtime/telegram-media-driver.ts diff --git a/package.json b/package.json index a101cda..7851e8b 100644 --- a/package.json +++ b/package.json @@ -90,6 +90,7 @@ "cmdk": "^1.1.1", "electron-squirrel-startup": "^1.0.1", "fix-path": "^5.0.0", + "grammy": "^1.42.0", "global-agent": "^3.0.0", "lucide-react": "^0.511.0", "nanoid": "^5.1.7", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 7030c01..b60f280 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -66,6 +66,9 @@ importers: global-agent: specifier: ^3.0.0 version: 3.0.0 + grammy: + specifier: ^1.42.0 + version: 1.42.0(encoding@0.1.13) lucide-react: specifier: ^0.511.0 version: 0.511.0(react@19.2.4) diff --git a/src/electron/main/runtime/agent-message-attachments.ts b/src/electron/main/runtime/agent-message-attachments.ts index 9508ef6..1b87fb0 100644 --- a/src/electron/main/runtime/agent-message-attachments.ts +++ b/src/electron/main/runtime/agent-message-attachments.ts @@ -8,6 +8,11 @@ import { inferAttachmentKind } from '@/shared/agents/message-content'; const WORKSPACE_GROUP_PREFIX = '/workspace/group/'; +interface AttachmentCandidate extends Omit, 'kind'> { + kind?: AgentAttachment['kind'] | 'document'; + path?: string; +} + /** Converts to local attachment path. */ function toLocalAttachmentPath( source: string, @@ -81,24 +86,31 @@ export function normalizeAgentAttachments( continue; } - const candidate = attachment as Partial; - const rawUrl = typeof candidate.url === 'string' ? candidate.url.trim() : ''; + const candidate = attachment as AttachmentCandidate; + const rawSource = + typeof candidate.path === 'string' && candidate.path.trim() + ? candidate.path.trim() + : typeof candidate.url === 'string' + ? candidate.url.trim() + : ''; const sourceBackedUrl = - rawUrl && (rawUrl.startsWith('https://') || rawUrl.startsWith('file://')) - ? rawUrl - : rawUrl - ? createAgentAttachmentFromSource(rawUrl, options)?.url ?? '' + rawSource && (rawSource.startsWith('https://') || rawSource.startsWith('file://')) + ? rawSource + : rawSource + ? createAgentAttachmentFromSource(rawSource, options)?.url ?? '' : ''; if (!sourceBackedUrl || seenUrls.has(sourceBackedUrl)) { continue; } - const normalizedKind = inferAttachmentKind({ + const inferredKind = inferAttachmentKind({ ...(typeof candidate.mimeType === 'string' ? { mimeType: candidate.mimeType } : {}), ...(typeof candidate.name === 'string' ? { name: candidate.name } : {}), url: sourceBackedUrl, }); + const normalizedKind = + candidate.kind === 'document' ? 'file' : candidate.kind ?? inferredKind; const normalizedName = typeof candidate.name === 'string' && candidate.name.trim() ? candidate.name diff --git a/src/electron/main/runtime/agent-runtime.test.ts b/src/electron/main/runtime/agent-runtime.test.ts index e4e9db3..2df4710 100644 --- a/src/electron/main/runtime/agent-runtime.test.ts +++ b/src/electron/main/runtime/agent-runtime.test.ts @@ -28,6 +28,7 @@ import { resolveProjectDuneDir, } from '@/electron/main/dune-paths'; import type { DuneChannel } from './dune-channel'; +import type { OutboundMessageAttachmentSource } from './dune-channel'; import { toAgentChatJid } from '@/shared/agents/agent-id'; import { createProjectMainAgentName } from '@/shared/agents/project-main-name'; @@ -290,7 +291,11 @@ function createTelegramChannelFactoryHarness( connect?: () => Promise | void; disconnect?: () => Promise | void; isConnected?: () => boolean; - sendMessage?: (jid: string, text: string) => Promise | void; + sendMessage?: ( + jid: string, + text: string, + attachments?: OutboundMessageAttachmentSource[], + ) => Promise | void; } = {}, ) { const connectedTokens = new Set(); @@ -309,8 +314,12 @@ function createTelegramChannelFactoryHarness( connectedTokens.delete(currentToken); } }); - const sendMessage = vi.fn(async (jid: string, text: string) => { - await options.sendMessage?.(jid, text); + const sendMessage = vi.fn(async ( + jid: string, + text: string, + attachments?: OutboundMessageAttachmentSource[], + ) => { + await options.sendMessage?.(jid, text, attachments); }); const resolveConfig = ( @@ -1675,6 +1684,87 @@ describe('AgentRuntime', () => { expect(telegramHarness.sendMessage).toHaveBeenCalledTimes(1); }); + it('passes outbound assistant attachments through to Telegram and stores them on the assistant message', async () => { + const homeDir = createTempHome(); + const harness = createAgentLiteModuleHarness(); + const telegramHarness = createTelegramChannelFactoryHarness(); + const telegramSecretsStore = createMemorySecretsStore(); + + tempDirs.push(homeDir); + + const host = new AgentRuntime({ + agentStore: createMemoryStore(), + createTelegramChannelFactory: telegramHarness.createTelegramChannelFactory, + homeDir, + loadAgentLiteModule: harness.loadAgentLiteModule, + resolveModelCredentials: async () => ({}), + resolveTelegramBotUsername: async () => 'agentlite_test_bot', + telegramSecretsStore, + }); + + await host.start(); + + const agentId = await host.service.createAgent({ + channelId: 'dune-chat', + name: 'Release triage', + projectId: 'project-1', + }); + const sessionId = await host.service.startTelegramSetupSession({ + agentId, + token: 'telegram-bot-token', + }); + const pairCode = host.getSnapshot().telegramSetupSessions + .find((session) => session.id === sessionId)?.pairCode ?? ''; + + telegramHarness.emitChatMetadata('tg:123', { + isGroup: true, + name: 'Product QA', + }); + telegramHarness.emitIncomingMessage('tg:123', { + content: `/pair ${pairCode}`, + sender: 'alice', + senderName: 'Alice', + }); + await flushMicrotasks(); + + await host.service.updateAgentChannel({ + agentId, + channelId: 'telegram', + telegramSetupSessionId: sessionId, + }); + + const attachments = [ + { + kind: 'video' as const, + name: 'demo.mp4', + url: 'file:///tmp/demo.mp4', + }, + ]; + + await harness.duneChannel('release-triage').sendMessage( + toAgentChatJid(agentId), + 'Demo attached.', + attachments, + ); + + expect(telegramHarness.sendMessage).toHaveBeenLastCalledWith( + 'tg:123', + 'Demo attached.', + attachments, + ); + + const agent = host.getSnapshot().agents.find((item) => item.id === agentId); + const assistantMessage = agent?.messages.find((message) => message.role === 'assistant'); + + expect(assistantMessage?.attachments).toEqual([ + { + kind: 'video', + name: 'demo.mp4', + url: 'file:///tmp/demo.mp4', + }, + ]); + }); + it('appends token usage summaries to outbound Telegram responses and keeps per-session totals', async () => { vi.useFakeTimers(); diff --git a/src/electron/main/runtime/agent-runtime/index.ts b/src/electron/main/runtime/agent-runtime/index.ts index cd67805..964a482 100644 --- a/src/electron/main/runtime/agent-runtime/index.ts +++ b/src/electron/main/runtime/agent-runtime/index.ts @@ -63,6 +63,8 @@ import { } from '@/electron/main/dune-paths'; import { auto as autoAcpPeers } from '@boxlite-ai/agentlite/acp/peers'; import { DuneAgent, type DuneAcpOptions } from '../dune-agent'; +import type { OutboundMessageAttachmentSource } from '../dune-channel'; +import { withTelegramOutboundMedia } from '../telegram-media-driver'; import { registerDuneActions, type ActionHostServices, @@ -370,7 +372,7 @@ export class AgentRuntime implements AgentRuntimeContract { const { telegram } = await importTelegramModule( '@boxlite-ai/agentlite/channels/telegram', ); - return telegram({ token }); + return withTelegramOutboundMedia(telegram({ token })); }); this.telegram = new TelegramBridge({ callbacks: { @@ -1337,13 +1339,24 @@ export class AgentRuntime implements AgentRuntimeContract { return appendAgentTokenUsageSummary(text, summary); } - private handleOutboundMessage(chatJid: string, text: string) { + private handleOutboundMessage( + chatJid: string, + text: string, + attachmentSources: OutboundMessageAttachmentSource[] = [], + ) { const agentId = this.resolveAgentIdByChatJid(chatJid); if (!agentId) { return; } + const record = this.records.get(agentId); + const attachments = record + ? normalizeAgentAttachments(attachmentSources, { + groupFolder: record.groupFolder, + runtimeRoot: this.runtimeRoot, + }) + : []; const pending = this.messageStream.get(agentId); const now = this.now(); @@ -1369,7 +1382,7 @@ export class AgentRuntime implements AgentRuntimeContract { messages: [ ...agent.messages, { - attachments: [], + attachments, content: text, createdAt: now, format: 'markdown', @@ -1390,6 +1403,7 @@ export class AgentRuntime implements AgentRuntimeContract { message.id === pending.messageId ? { ...message, + attachments: attachments.length > 0 ? attachments : message.attachments, content: text.startsWith(message.content) ? text @@ -1783,8 +1797,8 @@ export class AgentRuntime implements AgentRuntimeContract { onExternalInbound: (text, senderName, attachments) => { this.handleExternalInboundMessage(agentId, text, senderName, attachments); }, - onOutboundMessage: (chatJid, text) => { - this.handleOutboundMessage(chatJid, text); + onOutboundMessage: (chatJid, text, attachments) => { + this.handleOutboundMessage(chatJid, text, attachments); }, primaryChatJid: toAgentChatJid(agentId), ...(actionServicesForAgent && ownerProjectId ? { diff --git a/src/electron/main/runtime/dune-agent.ts b/src/electron/main/runtime/dune-agent.ts index ad4999e..abb9242 100644 --- a/src/electron/main/runtime/dune-agent.ts +++ b/src/electron/main/runtime/dune-agent.ts @@ -9,6 +9,7 @@ import type { } from '@boxlite-ai/agentlite'; import { DuneChannel } from './dune-channel'; +import type { OutboundMessageAttachmentSource } from './dune-channel'; /** ACP peer config understood by newer AgentLite runtimes. */ export interface DuneAcpPeerConfig { @@ -42,7 +43,11 @@ export interface DuneAgentOptions { }>; name: string; onExternalInbound?: (text: string, senderName: string, attachments?: string[]) => void; - onOutboundMessage: (chatJid: string, text: string) => void; + onOutboundMessage: ( + chatJid: string, + text: string, + attachments?: OutboundMessageAttachmentSource[], + ) => void; primaryChatJid: string; /** * Called immediately after `agentLite.getOrCreateAgent(...)` returns, with @@ -106,8 +111,8 @@ export class DuneAgent { decorateOutboundMessage: options.decorateOutboundMessage, externalChannelFactory: options.externalChannelFactory, onExternalInbound: options.onExternalInbound, - onOutboundMessage: (jid, text) => { - options.onOutboundMessage(jid, text); + onOutboundMessage: (jid, text, attachments) => { + options.onOutboundMessage(jid, text, attachments); }, primaryJid: options.primaryChatJid, }); diff --git a/src/electron/main/runtime/dune-channel.test.ts b/src/electron/main/runtime/dune-channel.test.ts index c8f7949..54dc8f1 100644 --- a/src/electron/main/runtime/dune-channel.test.ts +++ b/src/electron/main/runtime/dune-channel.test.ts @@ -88,4 +88,63 @@ describe('DuneChannel', () => { 'dune credentials ok\n\nšŸ“Š dune:agent:test', ); }); + + it('forwards outbound attachments to the external channel and local callbacks', async () => { + const onOutboundMessage = vi.fn().mockResolvedValue(undefined); + const onChatMetadata = vi.fn(); + const onMessage = vi.fn(); + const externalSendMessage = vi.fn().mockResolvedValue(undefined); + const duneChannel = new DuneChannel({ + boundExternalJid: 'tg:123', + config: { + onChatMetadata, + onMessage, + registeredGroups: () => ({ + 'dune:agent:test': { + added_at: new Date('2026-04-04T00:00:00.000Z').toISOString(), + folder: 'release-coordinator', + name: 'Release coordinator', + trigger: '@Dune', + }, + }), + }, + externalChannelFactory: () => ({ + connect: vi.fn(() => Promise.resolve()), + disconnect: vi.fn(() => Promise.resolve()), + isConnected: vi.fn(() => true), + ownsJid: (jid: string) => jid.startsWith('tg:'), + sendMessage: externalSendMessage, + }), + onOutboundMessage, + primaryJid: 'dune:agent:test', + }); + const attachments = [ + { + kind: 'image' as const, + name: 'chart.png', + url: 'file:///tmp/chart.png', + }, + ]; + + await duneChannel.connect(); + await duneChannel.sendMessage('dune:agent:test', 'chart attached', attachments); + + expect(onMessage).toHaveBeenCalledWith( + 'dune:agent:test', + expect.objectContaining({ + attachments, + content: 'chart attached', + }), + ); + expect(externalSendMessage).toHaveBeenCalledWith( + 'tg:123', + 'chart attached', + attachments, + ); + expect(onOutboundMessage).toHaveBeenCalledWith( + 'dune:agent:test', + 'chart attached', + attachments, + ); + }); }); diff --git a/src/electron/main/runtime/dune-channel.ts b/src/electron/main/runtime/dune-channel.ts index d0e28c6..f380ef7 100644 --- a/src/electron/main/runtime/dune-channel.ts +++ b/src/electron/main/runtime/dune-channel.ts @@ -8,6 +8,26 @@ import type { import { isDuneAgentChatJid } from '@/shared/agents/agent-id'; +/** Attachment metadata that can be forwarded to external channel drivers. */ +export interface OutboundMessageAttachment { + caption?: string; + kind?: 'audio' | 'document' | 'file' | 'image' | 'video'; + mimeType?: string; + name?: string; + path?: string; + url?: string; +} + +export type OutboundMessageAttachmentSource = OutboundMessageAttachment | string; + +interface AttachmentAwareChannelDriver extends ChannelDriver { + sendMessage( + jid: string, + text: string, + attachments?: OutboundMessageAttachmentSource[], + ): Promise; +} + /** Dune channel options. */ export interface DuneChannelOptions { boundExternalJid?: string | undefined; @@ -15,10 +35,31 @@ export interface DuneChannelOptions { decorateOutboundMessage?: ((chatJid: string, text: string) => Promise | string) | undefined; externalChannelFactory?: ChannelDriverFactory | undefined; onExternalInbound?: ((text: string, senderName: string, attachments?: string[]) => Promise | void) | undefined; - onOutboundMessage: (chatJid: string, text: string) => Promise | void; + onOutboundMessage: ( + chatJid: string, + text: string, + attachments?: OutboundMessageAttachmentSource[], + ) => Promise | void; primaryJid: string; } +/** Normalizes outbound attachments. */ +function normalizeOutboundAttachments( + attachments: unknown, +): OutboundMessageAttachmentSource[] { + if (!Array.isArray(attachments)) { + return []; + } + + return attachments.filter((attachment): attachment is OutboundMessageAttachmentSource => { + if (typeof attachment === 'string') { + return attachment.trim().length > 0; + } + + return Boolean(attachment && typeof attachment === 'object'); + }); +} + /** Implements Dune channel behavior. */ export class DuneChannel implements ChannelDriver { private connected = false; @@ -135,10 +176,15 @@ export class DuneChannel implements ChannelDriver { } /** Sends message. */ - async sendMessage(jid: string, text: string) { + async sendMessage( + jid: string, + text: string, + attachments?: OutboundMessageAttachmentSource[], + ) { const outboundText = this.decorateOutboundMessage ? await this.decorateOutboundMessage(jid, text) : text; + const outboundAttachments = normalizeOutboundAttachments(attachments); const timestamp = new Date().toISOString(); const group = this.config.registeredGroups()[jid]; @@ -150,7 +196,7 @@ export class DuneChannel implements ChannelDriver { true, ); - this.config.onMessage(jid, { + const outboundMessage = { chat_jid: jid, content: outboundText, is_bot_message: true, @@ -158,13 +204,30 @@ export class DuneChannel implements ChannelDriver { sender: 'dune-assistant', sender_name: 'Dune', timestamp, - }); + ...(outboundAttachments.length > 0 ? { attachments: outboundAttachments } : {}), + } as Parameters[1] & { + attachments?: OutboundMessageAttachmentSource[]; + }; + + this.config.onMessage(jid, outboundMessage as Parameters[1]); if (this.externalDriver && this.boundExternalJid) { - await this.externalDriver.sendMessage(this.boundExternalJid, outboundText); + if (outboundAttachments.length > 0) { + await (this.externalDriver as AttachmentAwareChannelDriver).sendMessage( + this.boundExternalJid, + outboundText, + outboundAttachments, + ); + } else { + await this.externalDriver.sendMessage(this.boundExternalJid, outboundText); + } } - await this.onOutboundMessage(jid, outboundText); + if (outboundAttachments.length > 0) { + await this.onOutboundMessage(jid, outboundText, outboundAttachments); + } else { + await this.onOutboundMessage(jid, outboundText); + } } /** Pushes inbound message. */ diff --git a/src/electron/main/runtime/telegram-media-driver.test.ts b/src/electron/main/runtime/telegram-media-driver.test.ts new file mode 100644 index 0000000..a8316e5 --- /dev/null +++ b/src/electron/main/runtime/telegram-media-driver.test.ts @@ -0,0 +1,141 @@ +// Telegram media driver tests. + +import type { + ChannelDriver, + ChannelDriverConfig, + ChannelDriverFactory, +} from '@boxlite-ai/agentlite'; +import { describe, expect, it, vi } from 'vitest'; + +import { withTelegramOutboundMedia } from './telegram-media-driver'; + +/** Creates a wrapped Telegram driver test harness. */ +async function createHarness() { + const api = { + sendDocument: vi.fn(() => Promise.resolve({})), + sendPhoto: vi.fn(() => Promise.resolve({})), + sendVideo: vi.fn(() => Promise.resolve({})), + }; + const textSendMessage = vi.fn(() => Promise.resolve()); + const baseDriver = { + bot: { api }, + connect: vi.fn(() => Promise.resolve()), + disconnect: vi.fn(() => Promise.resolve()), + isConnected: vi.fn(() => true), + ownsJid: (jid: string) => jid.startsWith('tg:'), + sendMessage: textSendMessage, + } as unknown as ChannelDriver; + const factory = vi.fn(() => Promise.resolve(baseDriver)) as ChannelDriverFactory; + const wrappedFactory = withTelegramOutboundMedia(factory); + const driver = await wrappedFactory({ + onChatMetadata: vi.fn(), + onMessage: vi.fn(), + registeredGroups: () => ({}), + } satisfies ChannelDriverConfig); + + return { + api, + driver, + textSendMessage, + }; +} + +describe('withTelegramOutboundMedia', () => { + it('sends image attachments with bot.api.sendPhoto', async () => { + const { api, driver, textSendMessage } = await createHarness(); + + await (driver.sendMessage as unknown as ( + jid: string, + text: string, + attachments: unknown[], + ) => Promise)('tg:123', 'chart attached', [ + { + kind: 'image', + name: 'chart.png', + url: 'https://example.test/chart.png', + }, + ]); + + expect(api.sendPhoto).toHaveBeenCalledWith( + '123', + 'https://example.test/chart.png', + { caption: 'chart attached' }, + ); + expect(api.sendDocument).not.toHaveBeenCalled(); + expect(api.sendVideo).not.toHaveBeenCalled(); + expect(textSendMessage).not.toHaveBeenCalled(); + }); + + it('sends document attachments with bot.api.sendDocument', async () => { + const { api, driver, textSendMessage } = await createHarness(); + + await (driver.sendMessage as unknown as ( + jid: string, + text: string, + attachments: unknown[], + ) => Promise)('tg:123', 'report attached', [ + { + kind: 'document', + name: 'report.pdf', + url: 'https://example.test/report.pdf', + }, + ]); + + expect(api.sendDocument).toHaveBeenCalledWith( + '123', + 'https://example.test/report.pdf', + { caption: 'report attached' }, + ); + expect(api.sendPhoto).not.toHaveBeenCalled(); + expect(api.sendVideo).not.toHaveBeenCalled(); + expect(textSendMessage).not.toHaveBeenCalled(); + }); + + it('sends media attachments when there is no text content', async () => { + const { api, driver, textSendMessage } = await createHarness(); + + await (driver.sendMessage as unknown as ( + jid: string, + text: string, + attachments: unknown[], + ) => Promise)('tg:123', '', [ + { + kind: 'image', + name: 'chart.png', + url: 'https://example.test/chart.png', + }, + ]); + + expect(api.sendPhoto).toHaveBeenCalledWith( + '123', + 'https://example.test/chart.png', + undefined, + ); + expect(textSendMessage).not.toHaveBeenCalled(); + }); + + it('sends video attachments with bot.api.sendVideo', async () => { + const { api, driver, textSendMessage } = await createHarness(); + + await (driver.sendMessage as unknown as ( + jid: string, + text: string, + attachments: unknown[], + ) => Promise)('tg:123', 'demo attached', [ + { + kind: 'video', + name: 'demo.mp4', + url: 'https://example.test/demo.mp4', + }, + ]); + + expect(api.sendVideo).toHaveBeenCalledWith( + '123', + 'https://example.test/demo.mp4', + { caption: 'demo attached' }, + ); + expect(api.sendDocument).not.toHaveBeenCalled(); + expect(api.sendPhoto).not.toHaveBeenCalled(); + expect(textSendMessage).not.toHaveBeenCalled(); + }); +}); diff --git a/src/electron/main/runtime/telegram-media-driver.ts b/src/electron/main/runtime/telegram-media-driver.ts new file mode 100644 index 0000000..dcc2255 --- /dev/null +++ b/src/electron/main/runtime/telegram-media-driver.ts @@ -0,0 +1,228 @@ +// Telegram outbound media support for AgentLite's Grammy-backed channel. + +import fs from 'node:fs'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; + +import type { + ChannelDriver, + ChannelDriverFactory, +} from '@boxlite-ai/agentlite'; + +import { inferAttachmentKind } from '@/shared/agents/message-content'; +import type { OutboundMessageAttachmentSource } from './dune-channel'; + +const TELEGRAM_CAPTION_LIMIT = 1024; + +interface TelegramApi { + sendDocument(chatId: string, document: unknown, options?: Record): Promise; + sendPhoto(chatId: string, photo: unknown, options?: Record): Promise; + sendVideo(chatId: string, video: unknown, options?: Record): Promise; +} + +interface TelegramBackedDriver extends ChannelDriver { + bot?: { + api?: TelegramApi; + } | null; +} + +interface NormalizedOutboundAttachment { + caption?: string; + kind: 'document' | 'image' | 'video'; + name: string; + source: string; +} + +/** Returns source text from an outbound attachment. */ +function attachmentSourceValue(attachment: OutboundMessageAttachmentSource) { + if (typeof attachment === 'string') { + return attachment.trim(); + } + + return (attachment.path ?? attachment.url ?? '').trim(); +} + +/** Normalizes file URL or path for Grammy upload/URL forwarding. */ +function normalizeMediaSource(source: string) { + if (!source) { + return null; + } + + if (source.startsWith('file://')) { + return fileURLToPath(source); + } + + if (source.startsWith('https://') || source.startsWith('http://')) { + return source; + } + + if (path.isAbsolute(source)) { + return source; + } + + return null; +} + +/** Normalizes one attachment for Telegram's supported outbound media APIs. */ +function normalizeAttachment( + attachment: OutboundMessageAttachmentSource, +): NormalizedOutboundAttachment | null { + const rawSource = attachmentSourceValue(attachment); + const source = normalizeMediaSource(rawSource); + + if (!source) { + return null; + } + + const metadata = typeof attachment === 'string' ? {} : attachment; + const name = metadata.name?.trim() || path.basename(source) || 'attachment'; + const inferredKind = inferAttachmentKind({ + name, + url: source, + ...(metadata.mimeType ? { mimeType: metadata.mimeType } : {}), + }); + + if (metadata.kind === 'image' || metadata.kind === 'video') { + return { + ...(metadata.caption ? { caption: metadata.caption } : {}), + kind: metadata.kind, + name, + source, + }; + } + + if (metadata.kind === 'document' || metadata.kind === 'file') { + return { + ...(metadata.caption ? { caption: metadata.caption } : {}), + kind: 'document', + name, + source, + }; + } + + if (inferredKind === 'audio') { + return null; + } + + return { + ...(metadata.caption ? { caption: metadata.caption } : {}), + kind: inferredKind === 'image' || inferredKind === 'video' ? inferredKind : 'document', + name, + source, + }; +} + +/** Creates a Grammy media value from a URL or local file path. */ +async function createTelegramMediaValue(source: string) { + if (source.startsWith('https://') || source.startsWith('http://')) { + return source; + } + + const { InputFile } = await import('grammy'); + return new InputFile(source); +} + +/** Builds Telegram media options. */ +function createMediaOptions(text: string, attachment: NormalizedOutboundAttachment, includeText: boolean) { + const caption = includeText + ? text.trim() || attachment.caption?.trim() || '' + : attachment.caption?.trim() || ''; + + return caption + ? { caption: caption.slice(0, TELEGRAM_CAPTION_LIMIT) } + : undefined; +} + +/** Sends one attachment through the matching Telegram Bot API method. */ +async function sendTelegramAttachment( + api: TelegramApi, + chatId: string, + attachment: NormalizedOutboundAttachment, + text: string, + includeText: boolean, +) { + const media = await createTelegramMediaValue(attachment.source); + const options = createMediaOptions(text, attachment, includeText); + + if (attachment.kind === 'image') { + await api.sendPhoto(chatId, media, options); + return; + } + + if (attachment.kind === 'video') { + await api.sendVideo(chatId, media, options); + return; + } + + await api.sendDocument(chatId, media, options); +} + +/** + * Wraps AgentLite's Telegram driver and adds outbound media support while + * leaving connection, receive handling, and discovery behavior unchanged. + */ +export function withTelegramOutboundMedia( + factory: ChannelDriverFactory, +): ChannelDriverFactory { + return async (config) => { + const driver = await factory(config) as TelegramBackedDriver; + const wrappedDriver: ChannelDriver = { + connect: () => driver.connect(), + disconnect: () => driver.disconnect(), + isConnected: () => driver.isConnected(), + ownsJid: (jid: string) => driver.ownsJid(jid), + sendMessage: async ( + jid: string, + text: string, + attachments?: OutboundMessageAttachmentSource[], + ) => { + const normalizedAttachments = Array.isArray(attachments) + ? attachments.map(normalizeAttachment).filter((item): item is NormalizedOutboundAttachment => Boolean(item)) + : []; + const api = driver.bot?.api; + + if (!api || normalizedAttachments.length === 0) { + await driver.sendMessage(jid, text); + return; + } + + const chatId = jid.replace(/^tg:/, ''); + const canUseTextAsCaption = text.trim().length <= TELEGRAM_CAPTION_LIMIT; + let sentAnyMedia = false; + + for (const [index, attachment] of normalizedAttachments.entries()) { + if ( + !attachment.source.startsWith('http://') && + !attachment.source.startsWith('https://') && + !fs.existsSync(attachment.source) + ) { + continue; + } + + await sendTelegramAttachment( + api, + chatId, + attachment, + text, + index === 0 && canUseTextAsCaption, + ); + sentAnyMedia = true; + } + + if ((!sentAnyMedia || !canUseTextAsCaption) && text.trim()) { + await driver.sendMessage(jid, text); + } + }, + }; + + if (driver.setTyping) { + wrappedDriver.setTyping = (jid, isTyping) => driver.setTyping?.(jid, isTyping) ?? Promise.resolve(); + } + + if (driver.syncGroups) { + wrappedDriver.syncGroups = (force) => driver.syncGroups?.(force) ?? Promise.resolve(); + } + + return wrappedDriver; + }; +}