From 6e96fd13068c796848624c63bb5a8937b78677f1 Mon Sep 17 00:00:00 2001 From: Jiayuan Zhang Date: Fri, 13 Feb 2026 01:37:08 +0800 Subject: [PATCH 1/7] feat(channels): add outbound media types and sendMedia to channel adapter Add OutboundMedia interface and OutboundMediaType to the channel type system. Implement sendMedia in the Telegram plugin using grammy's InputFile API with HTML caption formatting and plain-text fallback. Co-Authored-By: Claude Opus 4.6 --- .../core/src/channels/plugins/telegram.ts | 65 ++++++++++++++++++- packages/core/src/channels/types.ts | 19 ++++++ 2 files changed, 82 insertions(+), 2 deletions(-) diff --git a/packages/core/src/channels/plugins/telegram.ts b/packages/core/src/channels/plugins/telegram.ts index fa6f103e..f7f31ce6 100644 --- a/packages/core/src/channels/plugins/telegram.ts +++ b/packages/core/src/channels/plugins/telegram.ts @@ -12,8 +12,8 @@ import { writeFile, mkdir } from "node:fs/promises"; import { join, extname } from "node:path"; import { v7 as uuidv7 } from "uuid"; -import { Bot, GrammyError } from "grammy"; -import type { ChannelPlugin, ChannelMessage, ChannelConfigAdapter, ChannelsConfig, DeliveryContext } from "../types.js"; +import { Bot, GrammyError, InputFile } from "grammy"; +import type { ChannelPlugin, ChannelMessage, ChannelConfigAdapter, ChannelsConfig, DeliveryContext, OutboundMedia } from "../types.js"; import { markdownToTelegramHtml } from "./telegram-format.js"; import { MEDIA_CACHE_DIR } from "@multica/utils"; @@ -321,6 +321,67 @@ export const telegramChannel: ChannelPlugin = { // Best-effort } }, + + async sendMedia(ctx: DeliveryContext, media: OutboundMedia): Promise { + const bot = bots.get(ctx.accountId); + if (!bot) throw new Error(`No Telegram bot for account ${ctx.accountId}`); + + const chatId = Number(ctx.conversationId); + const inputFile = new InputFile(media.source); + // Telegram caption limit: 1024 chars. Truncate if needed. + const caption = media.caption?.slice(0, 1024); + const captionHtml = caption ? markdownToTelegramHtml(caption) : undefined; + const extra = captionHtml ? { caption: captionHtml, parse_mode: "HTML" as const } : {}; + + console.log(`[Telegram] Sending ${media.type} to chatId=${chatId}`); + + try { + switch (media.type) { + case "photo": + await bot.api.sendPhoto(chatId, inputFile, extra); + break; + case "video": + await bot.api.sendVideo(chatId, inputFile, extra); + break; + case "audio": + await bot.api.sendAudio(chatId, inputFile, extra); + break; + case "voice": + await bot.api.sendVoice(chatId, inputFile, extra); + break; + case "document": + default: + await bot.api.sendDocument(chatId, inputFile, extra); + break; + } + } catch (err) { + // If HTML caption fails, retry without formatting + if (isParseError(err) && caption) { + console.warn("[Telegram] Media caption HTML parse failed, retrying as plain text"); + const plainExtra = { caption }; + switch (media.type) { + case "photo": + await bot.api.sendPhoto(chatId, inputFile, plainExtra); + break; + case "video": + await bot.api.sendVideo(chatId, inputFile, plainExtra); + break; + case "audio": + await bot.api.sendAudio(chatId, inputFile, plainExtra); + break; + case "voice": + await bot.api.sendVoice(chatId, inputFile, plainExtra); + break; + case "document": + default: + await bot.api.sendDocument(chatId, inputFile, plainExtra); + break; + } + } else { + throw err; + } + } + }, }, async downloadMedia(fileId: string, accountId: string): Promise { diff --git a/packages/core/src/channels/types.ts b/packages/core/src/channels/types.ts index 43967759..fd9fe2c1 100644 --- a/packages/core/src/channels/types.ts +++ b/packages/core/src/channels/types.ts @@ -88,6 +88,23 @@ export interface ChannelGatewayAdapter { ): Promise; } +// ─── Outbound Media ─── + +/** Media type for outbound messages */ +export type OutboundMediaType = "photo" | "document" | "video" | "audio" | "voice"; + +/** Media payload for sending files back to the platform */ +export interface OutboundMedia { + /** Media type (determines which API method to use) */ + type: OutboundMediaType; + /** Local file path */ + source: string; + /** Caption text (optional, may be truncated per platform limits) */ + caption?: string | undefined; + /** Filename hint (optional, used for documents) */ + filename?: string | undefined; +} + // ─── Outbound Adapter ─── /** Sends messages back to the platform */ @@ -96,6 +113,8 @@ export interface ChannelOutboundAdapter { sendText(ctx: DeliveryContext, text: string): Promise; /** Reply to a specific message */ replyText(ctx: DeliveryContext, text: string): Promise; + /** Send a media file (photo, document, video, audio, voice) to a conversation (optional) */ + sendMedia?(ctx: DeliveryContext, media: OutboundMedia): Promise; /** Send "typing" indicator (optional, not all platforms support it) */ sendTyping?(ctx: DeliveryContext): Promise; /** From a29366d7cfdc90920297ce3762a0946328c75927 Mon Sep 17 00:00:00 2001 From: Jiayuan Zhang Date: Fri, 13 Feb 2026 01:37:15 +0800 Subject: [PATCH 2/7] feat(agent): add send_file tool for channel file sending New send_file tool with TypeBox schema, auto-detect media type from file extension, and file validation. Wired through AgentOptions and resolveTools with conditional registration when callback is provided. Co-Authored-By: Claude Opus 4.6 --- packages/core/src/agent/tools.ts | 11 ++ packages/core/src/agent/tools/send-file.ts | 120 +++++++++++++++++++++ packages/core/src/agent/types.ts | 8 ++ 3 files changed, 139 insertions(+) create mode 100644 packages/core/src/agent/tools/send-file.ts diff --git a/packages/core/src/agent/tools.ts b/packages/core/src/agent/tools.ts index edc43dd2..551c8245 100644 --- a/packages/core/src/agent/tools.ts +++ b/packages/core/src/agent/tools.ts @@ -11,6 +11,8 @@ import { createSessionsListTool } from "./tools/sessions-list.js"; import { createMemorySearchTool } from "./tools/memory-search.js"; import { createCronTool } from "./tools/cron/index.js"; import { createDataTool } from "./tools/data/index.js"; +import { createSendFileTool } from "./tools/send-file.js"; +import type { SendFileCallback } from "./tools/send-file.js"; import { filterTools } from "./tools/policy.js"; import { isMulticaError, isRetryableError } from "@multica/utils"; import type { ExecApprovalCallback } from "./tools/exec-approval-types.js"; @@ -31,6 +33,8 @@ export interface CreateToolsOptions { provider?: string | undefined; /** Callback invoked when exec tool needs approval before running a command */ onExecApprovalNeeded?: ExecApprovalCallback | undefined; + /** Callback for sending files through messaging channels */ + onChannelSendFile?: SendFileCallback | undefined; } type ToolErrorPayload = { @@ -132,6 +136,12 @@ export function createAllTools(options: CreateToolsOptions | string): AgentTool< tools.push(memorySearchTool as AgentTool); } + // Add send_file tool if channel send callback is provided + if (opts.onChannelSendFile) { + const sendFileTool = createSendFileTool(opts.onChannelSendFile); + tools.push(sendFileTool as AgentTool); + } + // Add sessions_spawn tool (will be filtered by policy for subagents) const sessionsSpawnTool = createSessionsSpawnTool({ isSubagent: isSubagent ?? false, @@ -173,6 +183,7 @@ export function resolveTools(options: ResolveToolsOptions): AgentTool[] { sessionId: options.sessionId, provider: options.provider, onExecApprovalNeeded: options.onExecApprovalNeeded, + onChannelSendFile: options.onChannelSendFile, }); // Apply policy filtering diff --git a/packages/core/src/agent/tools/send-file.ts b/packages/core/src/agent/tools/send-file.ts new file mode 100644 index 00000000..3a9afdfc --- /dev/null +++ b/packages/core/src/agent/tools/send-file.ts @@ -0,0 +1,120 @@ +/** + * send_file tool — sends a file to the active messaging channel. + * + * Available when the agent is connected to a channel (Telegram, etc.). + * Auto-detects media type from file extension if not specified. + */ + +import { Type } from "@sinclair/typebox"; +import type { AgentTool } from "@mariozechner/pi-agent-core"; +import { stat } from "node:fs/promises"; +import { basename, extname } from "node:path"; + +const SendFileSchema = Type.Object({ + file_path: Type.String({ + description: "Absolute path to the file to send.", + }), + caption: Type.Optional( + Type.String({ + description: "Optional caption text to accompany the file.", + }), + ), + type: Type.Optional( + Type.Union( + [ + Type.Literal("auto"), + Type.Literal("photo"), + Type.Literal("document"), + Type.Literal("video"), + Type.Literal("audio"), + Type.Literal("voice"), + ], + { + description: + 'Media type. "auto" (default) detects from file extension. Use "document" to force file attachment.', + }, + ), + ), +}); + +type SendFileArgs = { + file_path: string; + caption?: string; + type?: "auto" | "photo" | "document" | "video" | "audio" | "voice"; +}; + +type SendFileResult = { + sent: boolean; + file_path: string; + detected_type: string; + error?: string; +}; + +/** Callback provided by the Hub to route files through channels or gateway. */ +export type SendFileCallback = ( + filePath: string, + caption: string | undefined, + type: string, +) => Promise; + +const PHOTO_EXTENSIONS = new Set([".jpg", ".jpeg", ".png", ".gif", ".webp", ".bmp"]); +const VIDEO_EXTENSIONS = new Set([".mp4", ".webm", ".mov", ".avi", ".mkv"]); +const AUDIO_EXTENSIONS = new Set([".mp3", ".ogg", ".wav", ".m4a", ".flac", ".aac"]); + +/** Detect outbound media type from file extension. */ +function detectMediaType(filePath: string): string { + const ext = extname(filePath).toLowerCase(); + if (PHOTO_EXTENSIONS.has(ext)) return "photo"; + if (VIDEO_EXTENSIONS.has(ext)) return "video"; + if (AUDIO_EXTENSIONS.has(ext)) return "audio"; + return "document"; +} + +export function createSendFileTool( + onSendFile: SendFileCallback, +): AgentTool { + return { + name: "send_file", + label: "Send File", + description: + "Send a file (photo, document, video, audio) to the active messaging channel (e.g. Telegram). " + + "The file must exist on the local filesystem. " + + 'Type is auto-detected from extension unless overridden. Use type="document" to force file attachment.', + parameters: SendFileSchema, + execute: async (_toolCallId, args) => { + const { file_path, caption, type } = args as SendFileArgs; + + // Validate file exists + try { + const fileStat = await stat(file_path); + if (!fileStat.isFile()) { + return { + content: [{ type: "text", text: `Error: ${file_path} is not a file` }], + details: { sent: false, file_path, detected_type: "unknown", error: "Not a file" }, + }; + } + } catch { + return { + content: [{ type: "text", text: `Error: File not found: ${file_path}` }], + details: { sent: false, file_path, detected_type: "unknown", error: "File not found" }, + }; + } + + const mediaType = type && type !== "auto" ? type : detectMediaType(file_path); + + const sent = await onSendFile(file_path, caption, mediaType); + if (!sent) { + return { + content: [{ type: "text", text: "No active channel conversation to send the file to." }], + details: { sent: false, file_path, detected_type: mediaType, error: "No active channel" }, + }; + } + + const filename = basename(file_path); + return { + content: [{ type: "text", text: `File sent: ${filename} (${mediaType})` }], + details: { sent: true, file_path, detected_type: mediaType }, + }; + }, + }; +} diff --git a/packages/core/src/agent/types.ts b/packages/core/src/agent/types.ts index 14d7a676..cc719f4d 100644 --- a/packages/core/src/agent/types.ts +++ b/packages/core/src/agent/types.ts @@ -2,6 +2,8 @@ import type { ThinkingLevel } from "@mariozechner/pi-agent-core"; import type { SkillsConfig } from "./skills/types.js"; import type { ToolsConfig } from "./tools/policy.js"; import type { ExecApprovalCallback, ExecApprovalConfig } from "./tools/exec-approval-types.js"; +import type { SendFileCallback } from "./tools/send-file.js"; +import type { ChannelInfo } from "./system-prompt/types.js"; /** Controls how reasoning/thinking content blocks are handled */ export type ReasoningMode = "off" | "on" | "stream"; @@ -82,6 +84,12 @@ export type AgentOptions = { onExecApprovalNeeded?: ExecApprovalCallback | undefined; /** Exec approval configuration (security level, ask mode, allowlist) */ execApproval?: ExecApprovalConfig | undefined; + + // === Channel Configuration === + /** Connected messaging channels (for system prompt awareness) */ + channels?: ChannelInfo[] | undefined; + /** Callback for sending files through messaging channels (Telegram, etc.) */ + onChannelSendFile?: SendFileCallback | undefined; }; export interface Message { From 01b89ed488d087c219efcc6a67c558ffaa76a425 Mon Sep 17 00:00:00 2001 From: Jiayuan Zhang Date: Fri, 13 Feb 2026 01:37:22 +0800 Subject: [PATCH 3/7] feat(channels): add message source prefix, sendFile, and channel info MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Prepend [ChannelName · private/group] prefix to debounced messages so the LLM knows the message source. Add sendFile() for outbound media routing and listChannelInfos() for system prompt channel awareness. Co-Authored-By: Claude Opus 4.6 --- packages/core/src/channels/manager.ts | 67 ++++++++++++++++++++++++++- 1 file changed, 66 insertions(+), 1 deletion(-) diff --git a/packages/core/src/channels/manager.ts b/packages/core/src/channels/manager.ts index 5cb21f4d..ad5af1b1 100644 --- a/packages/core/src/channels/manager.ts +++ b/packages/core/src/channels/manager.ts @@ -24,10 +24,12 @@ import { loadChannelsConfig } from "./config.js"; import { MessageAggregator, DEFAULT_CHUNKER_CONFIG } from "../hub/message-aggregator.js"; import { isHeartbeatAckEvent } from "../hub/heartbeat-filter.js"; import type { AsyncAgent } from "../agent/async-agent.js"; +import type { ChannelInfo } from "../agent/system-prompt/types.js"; import { transcribeAudio } from "../media/transcribe.js"; import { describeImage } from "../media/describe-image.js"; import { describeVideo } from "../media/describe-video.js"; import { InboundDebouncer } from "./inbound-debouncer.js"; +import { extname } from "node:path"; interface AccountHandle { channelId: string; @@ -40,6 +42,8 @@ interface AccountHandle { interface LastRoute { plugin: ChannelPlugin; deliveryCtx: DeliveryContext; + /** Chat type of the originating message (for source prefix) */ + chatType?: "direct" | "group" | undefined; } export class ChannelManager { @@ -348,6 +352,7 @@ export class ChannelManager { conversationId, replyToMessageId: messageId, }, + chatType: message.chatType, }; console.log(`[Channels] lastRoute updated → ${plugin.id}:${conversationId} replyTo=${messageId}`); console.log(`[Channels] Forwarding to agent ${agent.sessionId}`); @@ -477,15 +482,58 @@ export class ChannelManager { timestamp: Date.now(), }); } + // Prepend source context so the LLM knows which platform/chat type the message came from + const channelName = route?.plugin.meta.name ?? "Channel"; + const chatLabel = route?.chatType === "group" ? "group" : "private"; + const prefixedText = `[${channelName} · ${chatLabel}]\n${combinedText}`; + const replyTo = route?.deliveryCtx.replyToMessageId ?? "?"; console.log(`[Channels] Debouncer flushing ${combinedText.length} chars to agent (queued route replyTo=${replyTo}, acks=${acks.length})`); - agent.write(combinedText, { source }); + agent.write(prefixedText, { source }); }, ); } return this.debouncer; } + /** + * Send a file to the active channel conversation. + * Returns true if the file was sent, false if no active route or plugin doesn't support media. + */ + async sendFile(filePath: string, caption?: string, type?: string): Promise { + const route = this.activeRoute ?? this.lastRoute; + if (!route) return false; + + const { plugin, deliveryCtx } = route; + if (!plugin.outbound.sendMedia) return false; + + const mediaType = type || this.detectMediaType(filePath); + try { + await plugin.outbound.sendMedia(deliveryCtx, { + type: mediaType as import("./types.js").OutboundMediaType, + source: filePath, + caption, + }); + console.log(`[Channels] Sent ${mediaType} to ${deliveryCtx.channel}:${deliveryCtx.conversationId}`); + return true; + } catch (err) { + console.error(`[Channels] Failed to send file: ${err}`); + return false; + } + } + + /** Detect outbound media type from file extension */ + private detectMediaType(filePath: string): string { + const ext = extname(filePath).toLowerCase(); + const photoExts = new Set([".jpg", ".jpeg", ".png", ".gif", ".webp", ".bmp"]); + const videoExts = new Set([".mp4", ".webm", ".mov", ".avi", ".mkv"]); + const audioExts = new Set([".mp3", ".ogg", ".wav", ".m4a", ".flac", ".aac"]); + if (photoExts.has(ext)) return "photo"; + if (videoExts.has(ext)) return "video"; + if (audioExts.has(ext)) return "audio"; + return "document"; + } + /** Start sending typing indicators (repeats every 5s until stopped) */ private startTyping(): void { this.stopTyping(); @@ -577,4 +625,21 @@ export class ChannelManager { listAccountStates(): ChannelAccountState[] { return Array.from(this.accounts.values()).map((h) => ({ ...h.state })); } + + /** Get channel info for connected channels (for system prompt awareness) */ + listChannelInfos(): ChannelInfo[] { + const seen = new Set(); + const infos: ChannelInfo[] = []; + for (const handle of this.accounts.values()) { + if (handle.state.status !== "running" || seen.has(handle.channelId)) continue; + seen.add(handle.channelId); + const plugin = listChannels().find((p) => p.id === handle.channelId); + if (!plugin) continue; + infos.push({ + name: plugin.meta.name, + canSendMedia: typeof plugin.outbound.sendMedia === "function", + }); + } + return infos; + } } From 0ebd9d6b22f829ebda1c14f9bff09d6b9f6e17cc Mon Sep 17 00:00:00 2001 From: Jiayuan Zhang Date: Fri, 13 Feb 2026 01:37:29 +0800 Subject: [PATCH 4/7] feat(agent): add connected channels section to system prompt Add ChannelInfo type and buildChannelsSection() that informs the LLM about connected messaging channels and their capabilities (e.g. send files). Wire through SystemPromptOptions and runner.ts. Co-Authored-By: Claude Opus 4.6 --- packages/core/src/agent/runner.ts | 1 + .../core/src/agent/system-prompt/builder.ts | 3 ++ .../core/src/agent/system-prompt/index.ts | 1 + .../core/src/agent/system-prompt/sections.ts | 35 +++++++++++++++++++ .../core/src/agent/system-prompt/types.ts | 10 ++++++ 5 files changed, 50 insertions(+) diff --git a/packages/core/src/agent/runner.ts b/packages/core/src/agent/runner.ts index 3e7c2f22..92ce68dd 100644 --- a/packages/core/src/agent/runner.ts +++ b/packages/core/src/agent/runner.ts @@ -1192,6 +1192,7 @@ export class Agent { tools: toolNames, skillsPrompt, runtime, + channels: this.toolsOptions.channels, }); } } diff --git a/packages/core/src/agent/system-prompt/builder.ts b/packages/core/src/agent/system-prompt/builder.ts index 82d01860..f7120d72 100644 --- a/packages/core/src/agent/system-prompt/builder.ts +++ b/packages/core/src/agent/system-prompt/builder.ts @@ -10,6 +10,7 @@ import type { SystemPromptReport, } from "./types.js"; import { + buildChannelsSection, buildHeartbeatSection, buildConditionalToolSections, buildExtraPromptSection, @@ -51,6 +52,7 @@ export function buildSystemPromptWithReport(options: SystemPromptOptions): { skillsPrompt, runtime, subagent, + channels, extraSystemPrompt, includeSafety = true, } = options; @@ -70,6 +72,7 @@ export function buildSystemPromptWithReport(options: SystemPromptOptions): { { name: "runtime", lines: buildRuntimeSection(runtime, mode) }, { name: "time-awareness", lines: buildTimeAwarenessSection(tools, mode) }, { name: "profile-dir", lines: buildProfileDirSection(profileDir, mode) }, + { name: "channels", lines: buildChannelsSection(channels, mode) }, { name: "subagent", lines: buildSubagentSection(subagent, mode) }, { name: "extra", lines: buildExtraPromptSection(extraSystemPrompt, mode) }, ]; diff --git a/packages/core/src/agent/system-prompt/index.ts b/packages/core/src/agent/system-prompt/index.ts index 6c1a9ffe..5944eb39 100644 --- a/packages/core/src/agent/system-prompt/index.ts +++ b/packages/core/src/agent/system-prompt/index.ts @@ -8,6 +8,7 @@ export { formatPromptReport } from "./report.js"; export { SAFETY_CONSTITUTION } from "./constitution.js"; export type { + ChannelInfo, ProfileContent, PromptSection, RuntimeInfo, diff --git a/packages/core/src/agent/system-prompt/sections.ts b/packages/core/src/agent/system-prompt/sections.ts index 99573cbd..60caee95 100644 --- a/packages/core/src/agent/system-prompt/sections.ts +++ b/packages/core/src/agent/system-prompt/sections.ts @@ -8,6 +8,7 @@ import { SAFETY_CONSTITUTION } from "./constitution.js"; import { formatRuntimeLine } from "./runtime-info.js"; import { resolveHeartbeatPrompt } from "../../heartbeat/heartbeat-text.js"; import type { + ChannelInfo, ProfileContent, RuntimeInfo, SubagentContext, @@ -461,6 +462,40 @@ export function buildSubagentSection( return lines; } +/** + * Connected channels section — tells the agent which messaging channels are active + * and what capabilities they have (e.g. send files). Full mode only. + */ +export function buildChannelsSection( + channels: ChannelInfo[] | undefined, + mode: SystemPromptMode, +): string[] { + if (mode !== "full" || !channels || channels.length === 0) return []; + + const lines: string[] = ["## Connected Channels", ""]; + + for (const ch of channels) { + lines.push(`- **${ch.name}**`); + if (ch.canSendMedia) { + lines.push( + " Capabilities: receive text/voice/image/video/document, send text, send files (photo, document, video, audio)", + ); + lines.push(" Use the `send_file` tool to send files to channel users."); + } else { + lines.push(" Capabilities: receive text, send text"); + } + } + + lines.push( + "", + "Messages from channels are prefixed with `[ChannelName · private]` or `[ChannelName · group]`.", + "When responding to channel messages, adapt your formatting for messaging platforms (shorter paragraphs, no complex markdown).", + "", + ); + + return lines; +} + /** * Extra system prompt — appended at the end if provided. */ diff --git a/packages/core/src/agent/system-prompt/types.ts b/packages/core/src/agent/system-prompt/types.ts index cf1cc1ba..0741a80e 100644 --- a/packages/core/src/agent/system-prompt/types.ts +++ b/packages/core/src/agent/system-prompt/types.ts @@ -37,6 +37,14 @@ export interface RuntimeInfo { cwd?: string | undefined; } +/** Describes a connected messaging channel and its capabilities */ +export interface ChannelInfo { + /** Human-readable channel name (e.g. "Telegram") */ + name: string; + /** Whether the channel supports outbound media via send_file */ + canSendMedia: boolean; +} + /** Subagent context for minimal/none modes */ export interface SubagentContext { /** Parent session that spawned this subagent */ @@ -77,6 +85,8 @@ export interface SystemPromptOptions { subagent?: SubagentContext | undefined; /** Workspace directory path (for agent working directory info) */ workspaceDir?: string | undefined; + /** Connected messaging channels (for channel awareness section) */ + channels?: ChannelInfo[] | undefined; /** Extra system prompt to append */ extraSystemPrompt?: string | undefined; /** Whether to include the safety constitution (default: true) */ From c64616512ac31c099d23677414a4b364d66cef54 Mon Sep 17 00:00:00 2001 From: Jiayuan Zhang Date: Fri, 13 Feb 2026 01:37:35 +0800 Subject: [PATCH 5/7] feat(hub): wire send_file callback with dual-path routing Create onChannelSendFile callback in Hub.createAgent() that tries the channel plugin path first (local file), then falls back to the gateway path (base64 over RoutedMessage). Also pass channel info to agent. Co-Authored-By: Claude Opus 4.6 --- packages/core/src/hub/hub.ts | 39 +++++++++++++++++++++++++++++++++++- 1 file changed, 38 insertions(+), 1 deletion(-) diff --git a/packages/core/src/hub/hub.ts b/packages/core/src/hub/hub.ts index a45382fd..d55f482b 100644 --- a/packages/core/src/hub/hub.ts +++ b/packages/core/src/hub/hub.ts @@ -1,3 +1,5 @@ +import { readFile } from "node:fs/promises"; +import { basename } from "node:path"; import { v7 as uuidv7 } from "uuid"; import { GatewayClient, @@ -375,7 +377,9 @@ export class Hub { const profileId = options?.profileId ?? "default"; const sessionId = id ?? uuidv7(); const onExecApprovalNeeded = this.createExecApprovalCallback(sessionId, profileId); - const agent = new AsyncAgent({ sessionId, profileId, onExecApprovalNeeded }); + const onChannelSendFile = this.createChannelSendFileCallback(sessionId); + const channels = this.channelManager.listChannelInfos(); + const agent = new AsyncAgent({ sessionId, profileId, onExecApprovalNeeded, onChannelSendFile, channels }); this.agents.set(agent.sessionId, agent); // Persist to agent store (skip during restore to avoid duplicates) @@ -663,6 +667,39 @@ export class Hub { }; } + /** + * Create a callback for the send_file tool that routes files through + * the channel plugin (local) or gateway (remote) path. + */ + private createChannelSendFileCallback(sessionId: string): (filePath: string, caption: string | undefined, type: string) => Promise { + return async (filePath: string, caption: string | undefined, type: string): Promise => { + // Path 1: Channel plugin (local bot — file on same machine) + const sentViaChannel = await this.channelManager.sendFile(filePath, caption, type); + if (sentViaChannel) return true; + + // Path 2: Gateway (remote bot — read file, base64 encode, send via RoutedMessage) + const deviceId = this.agentSenders.get(sessionId); + if (deviceId) { + try { + const fileBuffer = await readFile(filePath); + this.client.send(deviceId, "send_file", { + data: fileBuffer.toString("base64"), + type, + caption, + filename: basename(filePath), + }); + console.log(`[Hub] Sent file via gateway: ${basename(filePath)} → ${deviceId}`); + return true; + } catch (err) { + console.error(`[Hub] Failed to send file via gateway: ${err}`); + return false; + } + } + + return false; + }; + } + getAgent(id: string): AsyncAgent | undefined { return this.agents.get(id); } From f5ac4b85e8dfd46f9858d0f016bb4c6d838fd09c Mon Sep 17 00:00:00 2001 From: Jiayuan Zhang Date: Fri, 13 Feb 2026 01:37:41 +0800 Subject: [PATCH 6/7] feat(gateway): handle send_file action in Telegram service Add sendFileToTelegram method that decodes base64 file data and sends it via grammy's InputFile API. Handle send_file RoutedMessage action in the virtual device sendCallback. Co-Authored-By: Claude Opus 4.6 --- apps/gateway/telegram/telegram.service.ts | 62 ++++++++++++++++++++++- 1 file changed, 61 insertions(+), 1 deletion(-) diff --git a/apps/gateway/telegram/telegram.service.ts b/apps/gateway/telegram/telegram.service.ts index 487738e0..eace14ea 100644 --- a/apps/gateway/telegram/telegram.service.ts +++ b/apps/gateway/telegram/telegram.service.ts @@ -9,7 +9,7 @@ import { Inject, Injectable, Logger } from "@nestjs/common"; import type { OnModuleInit } from "@nestjs/common"; -import { Bot, webhookCallback } from "grammy"; +import { Bot, InputFile, webhookCallback } from "grammy"; import type { Context } from "grammy"; import { v7 as uuidv7 } from "uuid"; import { parseConnectionCode } from "@multica/store/connection"; @@ -121,6 +121,46 @@ export class TelegramService implements OnModuleInit { } } + /** Send a file (photo/document/video/audio) to a Telegram user */ + private async sendFileToTelegram( + telegramUserId: string, + data: Buffer, + type: string, + caption?: string, + filename?: string, + ): Promise { + if (!this.bot) return; + + const chatId = Number(telegramUserId); + const inputFile = new InputFile(data, filename); + const extra = caption ? { caption: caption.slice(0, 1024) } : {}; + + try { + switch (type) { + case "photo": + await this.bot.api.sendPhoto(chatId, inputFile, extra); + break; + case "video": + await this.bot.api.sendVideo(chatId, inputFile, extra); + break; + case "audio": + await this.bot.api.sendAudio(chatId, inputFile, extra); + break; + case "voice": + await this.bot.api.sendVoice(chatId, inputFile, extra); + break; + case "document": + default: + await this.bot.api.sendDocument(chatId, inputFile, extra); + break; + } + this.logger.debug(`Sent ${type} to Telegram: telegramUserId=${telegramUserId}`); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + this.logger.error(`Failed to send ${type} to Telegram: telegramUserId=${telegramUserId}, error=${message}`); + } + } + /** Setup bot message handlers */ private setupHandlers(): void { if (!this.bot) return; @@ -390,6 +430,26 @@ export class TelegramService implements OnModuleInit { return; } + // Send file — Hub agent wants to send a file to the Telegram user + if (msg.action === "send_file") { + const payload = msg.payload as { + data?: string; + type?: string; + caption?: string; + filename?: string; + }; + if (payload?.data) { + void this.sendFileToTelegram( + telegramUserId, + Buffer.from(payload.data, "base64"), + payload.type ?? "document", + payload.caption, + payload.filename, + ); + } + return; + } + // Regular message (e.g., "message" action from Hub) if (msg.action === "message") { const payload = msg.payload as { content?: string; agentId?: string }; From 0c6cdb0d3ab444179f323ab95b4a6055dacc61a3 Mon Sep 17 00:00:00 2001 From: Jiayuan Zhang Date: Fri, 13 Feb 2026 01:40:16 +0800 Subject: [PATCH 7/7] fix(hub): initialize channelManager before restoreAgents Move channel plugin initialization (initChannels + ChannelManager constructor) before restoreAgents() to prevent TypeError when createAgent() calls channelManager.listChannelInfos() during restore. Co-Authored-By: Claude Opus 4.6 --- packages/core/src/hub/hub.ts | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/packages/core/src/hub/hub.ts b/packages/core/src/hub/hub.ts index d55f482b..30d58cfb 100644 --- a/packages/core/src/hub/hub.ts +++ b/packages/core/src/hub/hub.ts @@ -152,14 +152,16 @@ export class Hub { this.initCronService(); this.initHeartbeatService(); + // Initialize channel plugin system (before restoreAgents so channelManager is available) + console.log("[Hub] Initializing channel system..."); + initChannels(); + this.channelManager = new ChannelManager(this); + this.client = this.createClient(this.url); this.client.connect(); this.restoreAgents(); - // Initialize channel plugin system - console.log("[Hub] Initializing channel system..."); - initChannels(); - this.channelManager = new ChannelManager(this); + // Start channel accounts (async — bot connections happen in background) void this.channelManager.startAll().then(() => { console.log("[Hub] Channel system started"); }).catch((err) => {