diff --git a/packages/app/src/app/pages/identities.tsx b/packages/app/src/app/pages/identities.tsx index 7663bdd8..44d9bc44 100644 --- a/packages/app/src/app/pages/identities.tsx +++ b/packages/app/src/app/pages/identities.tsx @@ -42,10 +42,10 @@ Use this file to define how the assistant responds in Slack/Telegram for this wo Examples: - Keep responses concise and action-oriented. -- Ask one clarifying question when requirements are ambiguous. -- Prefer concrete tool use over speculation when troubleshooting. -- For outbound delivery, first call opencode_router_status to confirm channel/identity/bindings. -- Then call opencode_router_send with peerId for direct sends, or directory for binding fan-out. +- Use tools directly; never ask end users to run router commands. +- Never expose raw peer IDs or Telegram chat IDs unless the user explicitly asks for debug output. +- For outbound delivery, call opencode_router_status and opencode_router_send yourself. +- If Telegram says chat not found, tell the user the recipient must message the bot first (for example /start), then retry. `; function formatRequestError(error: unknown): string { diff --git a/packages/opencode-router/README.md b/packages/opencode-router/README.md index 9bcd1897..c3e2f907 100644 --- a/packages/opencode-router/README.md +++ b/packages/opencode-router/README.md @@ -50,13 +50,18 @@ opencode-router Telegram support is configured via identities. You can either: - Use env vars for a single bot: `TELEGRAM_BOT_TOKEN=...` - - Or add multiple bots to the config file (`opencode-router.json`) using the CLI: + - Or add multiple bots to the config file (`opencode-router.json`) using the CLI: ```bash opencode-router telegram add --id default opencode-router telegram list ``` +Important for direct sends and bindings: +- Telegram targets must use numeric `chat_id` values. +- `@username` values are not valid direct `peerId` targets for router sends. +- If a user has not started a chat with the bot yet, Telegram may return `chat not found`. + ## Slack (Socket Mode) Slack support uses Socket Mode and replies in threads when @mentioned in channels. diff --git a/packages/opencode-router/src/bridge.ts b/packages/opencode-router/src/bridge.ts index 2fd76bf0..084af512 100644 --- a/packages/opencode-router/src/bridge.ts +++ b/packages/opencode-router/src/bridge.ts @@ -13,7 +13,7 @@ import { startHealthServer, type HealthSnapshot } from "./health.js"; import { buildPermissionRules, createClient } from "./opencode.js"; import { chunkText, formatInputSummary, truncateText } from "./text.js"; import { createSlackAdapter } from "./slack.js"; -import { createTelegramAdapter } from "./telegram.js"; +import { createTelegramAdapter, isTelegramPeerId } from "./telegram.js"; type Adapter = { key: string; @@ -138,6 +138,14 @@ const CHANNEL_LABELS: Record = { const TYPING_INTERVAL_MS = 6000; const OPENCODE_ROUTER_AGENT_FILE_RELATIVE_PATH = ".opencode/agents/opencode-router.md"; const OPENCODE_ROUTER_AGENT_MAX_CHARS = 16_000; +const DEFAULT_MESSAGING_AGENT_INSTRUCTIONS = [ + "Respond for non-technical users first.", + "Do not tell users to run router commands; use tools on their behalf.", + "Never expose raw peer IDs or Telegram chat IDs unless the user explicitly asks for debug details.", + "For Telegram send requests, try delivery immediately using existing bindings or direct tool calls.", + "If Telegram returns 'chat not found', explain that the recipient must message the bot first (for example with /start), then ask the user to retry.", + "Keep status updates concise and action-oriented.", +].join("\n"); type MessagingAgentConfig = { filePath: string; @@ -177,6 +185,14 @@ function adapterKey(channel: ChannelName, identityId: string): string { return `${channel}:${identityId}`; } +function invalidTelegramPeerIdError(): Error & { status?: number } { + const error = new Error( + "Telegram requires a numeric chat_id for direct targets. Usernames like @name cannot be used as peerId.", + ) as Error & { status?: number }; + error.status = 400; + return error; +} + function normalizeIdentityId(value: string | undefined): string { const trimmed = (value ?? "").trim(); if (!trimmed) return "default"; @@ -938,6 +954,9 @@ export async function startBridge(config: Config, logger: Logger, reporter?: Bri if (!peerKey || !directory) { throw new Error("peerId and directory are required"); } + if (channel === "telegram" && !isTelegramPeerId(peerKey)) { + throw invalidTelegramPeerIdError(); + } const scoped = resolveScopedDirectory(directory); if (!scoped.ok) { const error = new Error(scoped.error) as Error & { status?: number }; @@ -988,6 +1007,9 @@ export async function startBridge(config: Config, logger: Logger, reporter?: Bri if (!directoryInput && !peerId) { throw new Error("directory or peerId is required"); } + if (channel === "telegram" && peerId && !isTelegramPeerId(peerId)) { + throw invalidTelegramPeerIdError(); + } const normalizedDir = directoryInput ? (() => { const scoped = resolveScopedDirectory(directoryInput); @@ -1093,6 +1115,16 @@ export async function startBridge(config: Config, logger: Logger, reporter?: Bri let sent = 0; for (const binding of bindings) { attempted += 1; + if (channel === "telegram" && !isTelegramPeerId(binding.peer_id)) { + store.deleteBinding(channel, binding.identity_id, binding.peer_id); + store.deleteSession(channel, binding.identity_id, binding.peer_id); + failures.push({ + identityId: binding.identity_id, + peerId: binding.peer_id, + error: "Invalid Telegram peerId binding removed (expected numeric chat_id)", + }); + continue; + } const adapter = adapters.get(adapterKey(channel, binding.identity_id)); if (!adapter) { failures.push({ @@ -1389,18 +1421,20 @@ export async function startBridge(config: Config, logger: Logger, reporter?: Bri try { const effectiveModel = getUserModel(inbound.channel, inbound.identityId, peerKey, config.model); const messagingAgent = await loadMessagingAgentConfig(); - const promptText = messagingAgent.instructions - ? [ - "You are handling a Slack/Telegram message via OpenWork.", - `Workspace agent file: ${messagingAgent.filePath}`, - ...(messagingAgent.selectedAgent ? [`Selected OpenCode agent: ${messagingAgent.selectedAgent}`] : []), - "Follow these workspace messaging instructions:", - messagingAgent.instructions, - "", - "Incoming user message:", - inbound.text, - ].join("\n") - : inbound.text; + const effectiveInstructions = [DEFAULT_MESSAGING_AGENT_INSTRUCTIONS, messagingAgent.instructions] + .map((value) => value.trim()) + .filter(Boolean) + .join("\n\n"); + const promptText = [ + "You are handling a Slack/Telegram message via OpenWork.", + `Workspace agent file: ${messagingAgent.filePath}`, + ...(messagingAgent.selectedAgent ? [`Selected OpenCode agent: ${messagingAgent.selectedAgent}`] : []), + "Follow these workspace messaging instructions:", + effectiveInstructions, + "", + "Incoming user message:", + inbound.text, + ].join("\n"); logger.debug( { sessionID, diff --git a/packages/opencode-router/src/telegram.ts b/packages/opencode-router/src/telegram.ts index fa75dc4c..b086ef3d 100644 --- a/packages/opencode-router/src/telegram.ts +++ b/packages/opencode-router/src/telegram.ts @@ -24,6 +24,20 @@ export type TelegramAdapter = { const MAX_TEXT_LENGTH = 4096; +const TELEGRAM_CHAT_ID_PATTERN = /^-?\d+$/; + +export function isTelegramPeerId(peerId: string): boolean { + return TELEGRAM_CHAT_ID_PATTERN.test(peerId.trim()); +} + +export function parseTelegramPeerId(peerId: string): number | null { + const trimmed = peerId.trim(); + if (!isTelegramPeerId(trimmed)) return null; + const parsed = Number(trimmed); + if (!Number.isFinite(parsed)) return null; + return parsed; +} + export function createTelegramAdapter( identity: TelegramIdentity, config: Config, @@ -113,7 +127,15 @@ export function createTelegramAdapter( log.info("telegram adapter stopped"); }, async sendText(peerId: string, text: string) { - await bot.api.sendMessage(Number(peerId), text); + const chatId = parseTelegramPeerId(peerId); + if (chatId === null) { + const error = new Error( + "Telegram peerId must be a numeric chat_id. Usernames like @name are not valid direct targets.", + ) as Error & { status?: number }; + error.status = 400; + throw error; + } + await bot.api.sendMessage(chatId, text); }, }; } diff --git a/packages/opencode-router/test/health-send.test.js b/packages/opencode-router/test/health-send.test.js index a42c402d..708da499 100644 --- a/packages/opencode-router/test/health-send.test.js +++ b/packages/opencode-router/test/health-send.test.js @@ -222,3 +222,135 @@ test("health /send can deliver directly with peerId", async () => { await bridge.stop(); store.close(); }); + +test("health /send rejects invalid telegram direct peerId", async () => { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), "opencodeRouter-health-send-")); + const dbPath = path.join(dir, "opencode-router.db"); + const store = new BridgeStore(dbPath); + const healthPort = await freePort(); + + const bridge = await startBridge( + { + configPath: path.join(dir, "opencode-router.json"), + configFile: { version: 1 }, + opencodeUrl: "http://127.0.0.1:4096", + opencodeDirectory: dir, + telegramBots: [], + slackApps: [], + dataDir: dir, + dbPath, + logFile: path.join(dir, "opencode-router.log"), + toolUpdatesEnabled: false, + groupsEnabled: false, + permissionMode: "allow", + toolOutputLimit: 1200, + healthPort, + logLevel: "silent", + }, + createLoggerStub(), + undefined, + { + client: { + global: { + health: async () => ({ healthy: true, version: "test" }), + }, + }, + store, + adapters: new Map(), + disableEventStream: true, + }, + ); + + const response = await fetch(`http://127.0.0.1:${healthPort}/send`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ channel: "telegram", peerId: "@hotkartoffel", text: "hello-direct" }), + }); + assert.equal(response.status, 400); + const json = await response.json(); + assert.equal(json.ok, false); + assert.match(String(json.error || ""), /numeric chat_id/i); + + await bridge.stop(); + store.close(); +}); + +test("health /send removes invalid telegram bindings and still sends valid ones", async () => { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), "opencodeRouter-health-send-")); + const dbPath = path.join(dir, "opencode-router.db"); + const store = new BridgeStore(dbPath); + const healthPort = await freePort(); + + const sent = []; + const telegramAdapter = { + key: "telegram:default", + name: "telegram", + identityId: "default", + maxTextLength: 39_000, + async start() {}, + async stop() {}, + async sendText(peerId, text) { + sent.push({ peerId, text }); + }, + }; + + const normalizedDir = dir.replace(/\\/g, "/").replace(/\/+$/, "") || "/"; + store.upsertBinding("telegram", "default", "@bad-target", normalizedDir); + store.upsertBinding("telegram", "default", "351743020", normalizedDir); + + const bridge = await startBridge( + { + configPath: path.join(dir, "opencode-router.json"), + configFile: { version: 1 }, + opencodeUrl: "http://127.0.0.1:4096", + opencodeDirectory: dir, + telegramBots: [], + slackApps: [], + dataDir: dir, + dbPath, + logFile: path.join(dir, "opencode-router.log"), + toolUpdatesEnabled: false, + groupsEnabled: false, + permissionMode: "allow", + toolOutputLimit: 1200, + healthPort, + logLevel: "silent", + }, + createLoggerStub(), + undefined, + { + client: { + global: { + health: async () => ({ healthy: true, version: "test" }), + }, + }, + store, + adapters: new Map([["telegram:default", telegramAdapter]]), + disableEventStream: true, + }, + ); + + const response = await fetch(`http://127.0.0.1:${healthPort}/send`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ channel: "telegram", directory: dir, text: "hello" }), + }); + assert.equal(response.status, 200); + const json = await response.json(); + assert.equal(json.ok, true); + assert.equal(json.attempted, 2); + assert.equal(json.sent, 1); + assert.equal(Array.isArray(json.failures), true); + assert.equal(json.failures.length, 1); + assert.match(String(json.failures[0].error || ""), /invalid telegram peerid binding removed/i); + + assert.equal(sent.length, 1); + assert.equal(sent[0].peerId, "351743020"); + assert.equal(sent[0].text, "hello"); + + assert.equal(store.getBinding("telegram", "default", "@bad-target"), null); + assert.notEqual(store.getBinding("telegram", "default", "351743020"), null); + + await bridge.stop(); + store.close(); +}); diff --git a/packages/orchestrator/src/cli.ts b/packages/orchestrator/src/cli.ts index b1e55011..68e185b1 100644 --- a/packages/orchestrator/src/cli.ts +++ b/packages/orchestrator/src/cli.ts @@ -1960,6 +1960,35 @@ function opencodeRouterSendToolSource(): string { return [ 'import { tool } from "@opencode-ai/plugin"', "", + "const redactTarget = (value) => {", + " const text = String(value || '').trim()", + " if (!text) return ''", + " if (text.length <= 6) return 'hidden'", + " return `${text.slice(0, 2)}…${text.slice(-2)}`", + "}", + "", + "const buildGuidance = (result) => {", + " const sent = Number(result?.sent || 0)", + " const attempted = Number(result?.attempted || 0)", + " const reason = String(result?.reason || '')", + " const failures = Array.isArray(result?.failures) ? result.failures : []", + "", + " if (sent > 0 && failures.length === 0) return 'Delivered successfully.'", + " if (sent > 0) return 'Delivered to at least one conversation, but some targets failed.'", + "", + " const chatNotFound = failures.some((item) => /chat not found/i.test(String(item?.error || '')))", + " if (chatNotFound) {", + " return 'Delivery failed because the recipient has not started a chat with the bot yet. Ask them to send /start, then retry.'", + " }", + "", + " if (/No bound conversations/i.test(reason)) {", + " return 'No linked conversation found for this workspace yet. Ask the recipient to message the bot first, then retry.'", + " }", + "", + " if (attempted === 0) return 'No eligible delivery target found.'", + " return 'Delivery failed. Retry after confirming the recipient and bot linkage.'", + "}", + "", "export default tool({", ' description: "Send a message via opencodeRouter (Telegram/Slack) to a peer or directory bindings.",', " args: {", @@ -1999,10 +2028,36 @@ function opencodeRouterSendToolSource(): string { " body: JSON.stringify(payload),", " })", " const body = await response.text()", + " let json = null", + " try {", + " json = JSON.parse(body)", + " } catch {", + " json = null", + " }", " if (!response.ok) {", ' throw new Error(`opencodeRouter /send failed (${response.status}): ${body}`)', " }", - " return body", + "", + " const sent = Number(json?.sent || 0)", + " const attempted = Number(json?.attempted || 0)", + " const reason = typeof json?.reason === 'string' ? json.reason : ''", + " const failuresRaw = Array.isArray(json?.failures) ? json.failures : []", + " const failures = failuresRaw.map((item) => ({", + " identityId: String(item?.identityId || ''),", + " error: String(item?.error || 'delivery failed'),", + " ...(item?.peerId ? { target: redactTarget(item.peerId) } : {}),", + " }))", + "", + " const result = {", + " ok: true,", + " channel,", + " sent,", + " attempted,", + " guidance: buildGuidance({ sent, attempted, reason, failures }),", + " ...(reason ? { reason } : {}),", + " ...(failures.length ? { failures } : {}),", + " }", + " return JSON.stringify(result, null, 2)", " },", "})", "", @@ -2013,6 +2068,13 @@ function opencodeRouterStatusToolSource(): string { return [ 'import { tool } from "@opencode-ai/plugin"', "", + "const redactTarget = (value) => {", + " const text = String(value || '').trim()", + " if (!text) return ''", + " if (text.length <= 6) return 'hidden'", + " return `${text.slice(0, 2)}…${text.slice(-2)}`", + "}", + "", "export default tool({", ' description: "Check opencodeRouter messaging readiness (health, identities, bindings).",', " args: {", @@ -2020,7 +2082,7 @@ function opencodeRouterStatusToolSource(): string { ' identityId: tool.schema.string().optional().describe("Identity id to scope checks"),', ' directory: tool.schema.string().optional().describe("Directory to inspect bindings for (default: current session directory)"),', ' peerId: tool.schema.string().optional().describe("Peer id to inspect bindings for"),', - ' includeBindings: tool.schema.boolean().optional().describe("Include binding details (default: true)"),', + ' includeBindings: tool.schema.boolean().optional().describe("Include binding details (default: false)"),', " },", " async execute(args, context) {", ' const rawPort = (process.env.OPENCODE_ROUTER_HEALTH_PORT || "3005").trim()', @@ -2035,7 +2097,7 @@ function opencodeRouterStatusToolSource(): string { ' const identityId = String(args.identityId || "").trim()', ' const directory = (args.directory || context.directory || "").trim()', ' const peerId = String(args.peerId || "").trim()', - ' const includeBindings = args.includeBindings !== false', + ' const includeBindings = args.includeBindings === true', "", " const fetchJson = async (path) => {", " const response = await fetch(`http://127.0.0.1:${port}${path}`)", @@ -2076,6 +2138,13 @@ function opencodeRouterStatusToolSource(): string { " if (peerId && String(item.peerId || '').trim() !== peerId) return false", " return true", " })", + " const publicBindings = filteredBindings.map((item) => ({", + " channel: String(item.channel || channel),", + " identityId: String(item.identityId || ''),", + " directory: String(item.directory || ''),", + " ...(item?.peerId ? { target: redactTarget(item.peerId) } : {}),", + " updatedAt: item?.updatedAt,", + " }))", "", " let ready = false", " let guidance = ''", @@ -2087,15 +2156,15 @@ function opencodeRouterStatusToolSource(): string { " guidance = `No running ${channel} identity`", " } else if (peerId) {", " ready = true", - " guidance = 'Ready for direct send via peerId'", + " guidance = 'Ready for direct send'", " } else if (directory) {", " ready = filteredBindings.length > 0", " guidance = ready", " ? 'Ready for directory fan-out send'", - " : `No bound conversations for directory ${directory}`", + " : 'No linked conversations found for this directory yet'", " } else {", " ready = true", - " guidance = 'Ready, but include directory for fan-out or peerId for direct send'", + " guidance = 'Ready. Provide a message target (peer or directory).'", " }", "", " const result = {", @@ -2105,7 +2174,7 @@ function opencodeRouterStatusToolSource(): string { " channel,", " ...(identityId ? { identityId } : {}),", " ...(directory ? { directory } : {}),", - " ...(peerId ? { peerId } : {}),", + " ...(peerId ? { targetProvided: true } : {}),", " health: {", " ok: health.ok,", " status: health.status,", @@ -2128,7 +2197,7 @@ function opencodeRouterStatusToolSource(): string { " status: bindings?.status,", " error: bindings?.ok ? undefined : bindings?.error,", " count: filteredBindings.length,", - " items: filteredBindings,", + " items: publicBindings,", " },", " }", " : {}),", diff --git a/pr/telegram-recipient-ux-messaging-agent-template.png b/pr/telegram-recipient-ux-messaging-agent-template.png new file mode 100644 index 00000000..a99e7e39 Binary files /dev/null and b/pr/telegram-recipient-ux-messaging-agent-template.png differ