diff --git a/CHANGELOG.md b/CHANGELOG.md index 0631d4d4e..c2ef0699d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -77,6 +77,7 @@ - Control UI: drop explicit `ui:install` step; `ui:build` now auto-installs UI deps (docs + update flow). - Telegram: retry long-polling conflicts with backoff to avoid fatal exits. - Telegram: fix grammY fetch type mismatch when injecting `fetch`. (#512) — thanks @YuriNachos +- Telegram: add inline keyboard buttons (capability-gated) and route callback query payloads as messages. (#491) — thanks @azade-c - WhatsApp: resolve @lid JIDs via Baileys mapping to unblock inbound messages. (#415) - Pairing: replies now include sender ids for Discord/Slack/Signal/iMessage/WhatsApp; pairing list labels them explicitly. - Messages: default inbound/outbound prefixes from the routed agent’s `identity.name` when set. (#578) — thanks @p6l-richard diff --git a/docs/cli/message.md b/docs/cli/message.md index bff1aa94e..44619d7e1 100644 --- a/docs/cli/message.md +++ b/docs/cli/message.md @@ -44,6 +44,7 @@ Target formats (`--to`): - `send` - Required: `--to`, `--message` - Optional: `--media`, `--reply-to`, `--thread-id`, `--gif-playback` + - Telegram only: `--buttons-json` (requires `"inlineButtons"` in `telegram.capabilities` or `telegram.accounts..capabilities`) - `poll` - Required: `--to`, `--poll-question`, `--poll-option` (repeat) @@ -174,3 +175,9 @@ React in Slack: clawdbot message react --provider slack \ --to C123 --message-id 456 --emoji "✅" ``` + +Send Telegram inline buttons: +``` +clawdbot message send --provider telegram --to @mychat --message "Choose:" \ + --buttons-json '[ [{"text":"Yes","callback_data":"cmd:yes"}], [{"text":"No","callback_data":"cmd:no"}] ]' +``` diff --git a/src/agents/pi-embedded-runner.ts b/src/agents/pi-embedded-runner.ts index 9fab11a0a..03c7bb36c 100644 --- a/src/agents/pi-embedded-runner.ts +++ b/src/agents/pi-embedded-runner.ts @@ -25,6 +25,7 @@ import type { import { formatToolAggregate } from "../auto-reply/tool-meta.js"; import { isCacheEnabled, resolveCacheTtlMs } from "../config/cache-utils.js"; import type { ClawdbotConfig } from "../config/config.js"; +import { resolveProviderCapabilities } from "../config/provider-capabilities.js"; import { getMachineDisplayName } from "../infra/machine-name.js"; import { createSubsystemLogger } from "../logging.js"; import { splitMediaFromOutput } from "../media/parse.js"; @@ -32,6 +33,7 @@ import { type enqueueCommand, enqueueCommandInLane, } from "../process/command-queue.js"; +import { normalizeMessageProvider } from "../utils/message-provider.js"; import { resolveUserPath } from "../utils.js"; import { resolveClawdbotAgentDir } from "./agent-paths.js"; import { @@ -580,6 +582,8 @@ function buildEmbeddedSystemPrompt(params: { arch: string; node: string; model: string; + provider?: string; + capabilities?: string[]; }; sandboxInfo?: EmbeddedSandboxInfo; tools: AgentTool[]; @@ -855,12 +859,24 @@ export async function compactEmbeddedPiSession(params: { config: params.config, }); const machineName = await getMachineDisplayName(); + const runtimeProvider = normalizeMessageProvider( + params.messageProvider, + ); + const runtimeCapabilities = runtimeProvider + ? (resolveProviderCapabilities({ + cfg: params.config, + provider: runtimeProvider, + accountId: params.agentAccountId, + }) ?? []) + : undefined; const runtimeInfo = { host: machineName, os: `${os.type()} ${os.release()}`, arch: os.arch(), node: process.version, model: `${provider}/${modelId}`, + provider: runtimeProvider, + capabilities: runtimeCapabilities, }; const sandboxInfo = buildEmbeddedSandboxInfo(sandbox); const reasoningTagHint = provider === "ollama"; diff --git a/src/agents/system-prompt.test.ts b/src/agents/system-prompt.test.ts index 17e38efc8..b2e813c90 100644 --- a/src/agents/system-prompt.test.ts +++ b/src/agents/system-prompt.test.ts @@ -109,4 +109,27 @@ describe("buildAgentSystemPrompt", () => { expect(prompt).toContain("## IDENTITY.md"); expect(prompt).toContain("Bravo"); }); + + it("summarizes the message tool when available", () => { + const prompt = buildAgentSystemPrompt({ + workspaceDir: "/tmp/clawd", + toolNames: ["message"], + }); + + expect(prompt).toContain("message: Send messages and provider actions"); + expect(prompt).toContain("### message tool"); + }); + + it("includes runtime provider capabilities when present", () => { + const prompt = buildAgentSystemPrompt({ + workspaceDir: "/tmp/clawd", + runtimeInfo: { + provider: "telegram", + capabilities: ["inlineButtons"], + }, + }); + + expect(prompt).toContain("provider=telegram"); + expect(prompt).toContain("capabilities=inlineButtons"); + }); }); diff --git a/src/agents/system-prompt.ts b/src/agents/system-prompt.ts index 9ec850872..0d8e1ade0 100644 --- a/src/agents/system-prompt.ts +++ b/src/agents/system-prompt.ts @@ -19,6 +19,8 @@ export function buildAgentSystemPrompt(params: { arch?: string; node?: string; model?: string; + provider?: string; + capabilities?: string[]; }; sandboxInfo?: { enabled: boolean; @@ -43,6 +45,7 @@ export function buildAgentSystemPrompt(params: { canvas: "Present/eval/snapshot the Canvas", nodes: "List/describe/notify/camera/screen on paired nodes", cron: "Manage cron jobs and wake events", + message: "Send messages and provider actions", gateway: "Restart, apply config, or run updates on the running Clawdbot process", agents_list: "List agent ids allowed for sessions_spawn", @@ -51,10 +54,6 @@ export function buildAgentSystemPrompt(params: { sessions_send: "Send a message to another session/sub-agent", sessions_spawn: "Spawn a sub-agent session", image: "Analyze an image with the configured image model", - discord: "Send Discord reactions/messages and manage threads", - slack: "Send Slack messages and manage channels", - telegram: "Send Telegram reactions", - whatsapp: "Send WhatsApp reactions", }; const toolOrder = [ @@ -71,16 +70,13 @@ export function buildAgentSystemPrompt(params: { "canvas", "nodes", "cron", + "message", "gateway", "agents_list", "sessions_list", "sessions_history", "sessions_send", "image", - "discord", - "slack", - "telegram", - "whatsapp", ]; const normalizedTools = (params.toolNames ?? []) @@ -127,6 +123,16 @@ export function buildAgentSystemPrompt(params: { ? `Heartbeat prompt: ${heartbeatPrompt}` : "Heartbeat prompt: (configured)"; const runtimeInfo = params.runtimeInfo; + const runtimeProvider = runtimeInfo?.provider?.trim().toLowerCase(); + const runtimeCapabilities = (runtimeInfo?.capabilities ?? []) + .map((cap) => String(cap).trim()) + .filter(Boolean); + const runtimeCapabilitiesLower = new Set( + runtimeCapabilities.map((cap) => cap.toLowerCase()), + ); + const telegramInlineButtonsEnabled = + runtimeProvider === "telegram" && + runtimeCapabilitiesLower.has("inlinebuttons"); const lines = [ "You are a personal assistant running inside Clawdbot.", @@ -229,6 +235,21 @@ export function buildAgentSystemPrompt(params: { "- Reply in current session → automatically routes to the source provider (Signal, Telegram, etc.)", "- Cross-session messaging → use sessions_send(sessionKey, message)", "- Never use bash/curl for provider messaging; Clawdbot handles all routing internally.", + availableTools.has("message") + ? [ + "", + "### message tool", + "- Use `message` for proactive sends + provider actions (polls, reactions, etc.).", + "- If multiple providers are configured, pass `provider` (whatsapp|telegram|discord|slack|signal|imessage|msteams).", + telegramInlineButtonsEnabled + ? "- Telegram: inline buttons supported. Use `action=send` with `buttons=[[{text,callback_data}]]` (callback_data routes back as a user message)." + : runtimeProvider === "telegram" + ? '- Telegram: inline buttons NOT enabled. If you need them, ask to add "inlineButtons" to telegram.capabilities or telegram.accounts..capabilities.' + : "", + ] + .filter(Boolean) + .join("\n") + : "", "", ]; @@ -270,6 +291,14 @@ export function buildAgentSystemPrompt(params: { : "", runtimeInfo?.node ? `node=${runtimeInfo.node}` : "", runtimeInfo?.model ? `model=${runtimeInfo.model}` : "", + runtimeProvider ? `provider=${runtimeProvider}` : "", + runtimeProvider + ? `capabilities=${ + runtimeCapabilities.length > 0 + ? runtimeCapabilities.join(",") + : "none" + }` + : "", `thinking=${params.defaultThinkLevel ?? "off"}`, ] .filter(Boolean) diff --git a/src/agents/tools/message-tool.ts b/src/agents/tools/message-tool.ts index 9875a54d9..d0d8d5d8f 100644 --- a/src/agents/tools/message-tool.ts +++ b/src/agents/tools/message-tool.ts @@ -2,6 +2,7 @@ import { Type } from "@sinclair/typebox"; import type { ClawdbotConfig } from "../../config/config.js"; import { loadConfig } from "../../config/config.js"; +import { listEnabledDiscordAccounts } from "../../discord/accounts.js"; import { type MessagePollResult, type MessageSendResult, @@ -9,9 +10,13 @@ import { sendPoll, } from "../../infra/outbound/message.js"; import { resolveMessageProviderSelection } from "../../infra/outbound/provider-selection.js"; +import { resolveMSTeamsCredentials } from "../../msteams/token.js"; import { normalizeAccountId } from "../../routing/session-key.js"; +import { listEnabledSlackAccounts } from "../../slack/accounts.js"; +import { listEnabledTelegramAccounts } from "../../telegram/accounts.js"; import type { AnyAgentTool } from "./common.js"; import { + createActionGate, jsonResult, readNumberParam, readStringArrayParam, @@ -62,6 +67,19 @@ const MessageToolSchema = Type.Object({ to: Type.Optional(Type.String()), message: Type.Optional(Type.String()), media: Type.Optional(Type.String()), + buttons: Type.Optional( + Type.Array( + Type.Array( + Type.Object({ + text: Type.String(), + callback_data: Type.String(), + }), + ), + { + description: "Telegram inline keyboard buttons (array of button rows)", + }, + ), + ), messageId: Type.Optional(Type.String()), replyTo: Type.Optional(Type.String()), threadId: Type.Optional(Type.String()), @@ -118,6 +136,164 @@ type MessageToolOptions = { config?: ClawdbotConfig; }; +function hasTelegramInlineButtons(cfg: ClawdbotConfig): boolean { + const caps = new Set(); + for (const entry of cfg.telegram?.capabilities ?? []) { + const trimmed = String(entry).trim(); + if (trimmed) caps.add(trimmed.toLowerCase()); + } + const accounts = cfg.telegram?.accounts; + if (accounts && typeof accounts === "object") { + for (const account of Object.values(accounts)) { + const accountCaps = (account as { capabilities?: unknown })?.capabilities; + if (!Array.isArray(accountCaps)) continue; + for (const entry of accountCaps) { + const trimmed = String(entry).trim(); + if (trimmed) caps.add(trimmed.toLowerCase()); + } + } + } + return caps.has("inlinebuttons"); +} + +function buildMessageActionSchema(cfg: ClawdbotConfig) { + const actions = new Set(["send"]); + + const discordAccounts = listEnabledDiscordAccounts(cfg).filter( + (account) => account.tokenSource !== "none", + ); + const discordEnabled = discordAccounts.length > 0; + const discordGate = createActionGate(cfg.discord?.actions); + + const slackAccounts = listEnabledSlackAccounts(cfg).filter( + (account) => account.botTokenSource !== "none", + ); + const slackEnabled = slackAccounts.length > 0; + const isSlackActionEnabled = (key: string, defaultValue = true) => { + if (!slackEnabled) return false; + for (const account of slackAccounts) { + const gate = createActionGate( + (account.actions ?? cfg.slack?.actions) as Record< + string, + boolean | undefined + >, + ); + if (gate(key, defaultValue)) return true; + } + return false; + }; + + const telegramAccounts = listEnabledTelegramAccounts(cfg).filter( + (account) => account.tokenSource !== "none", + ); + const telegramEnabled = telegramAccounts.length > 0; + const telegramGate = createActionGate(cfg.telegram?.actions); + + const whatsappGate = createActionGate(cfg.whatsapp?.actions); + + const canDiscordReactions = discordEnabled && discordGate("reactions"); + const canSlackReactions = isSlackActionEnabled("reactions"); + const canTelegramReactions = telegramEnabled && telegramGate("reactions"); + const canWhatsAppReactions = cfg.whatsapp ? whatsappGate("reactions") : false; + const canAnyReactions = + canDiscordReactions || + canSlackReactions || + canTelegramReactions || + canWhatsAppReactions; + if (canAnyReactions) actions.add("react"); + if (canDiscordReactions || canSlackReactions) actions.add("reactions"); + + const canDiscordMessages = discordEnabled && discordGate("messages"); + const canSlackMessages = isSlackActionEnabled("messages"); + if (canDiscordMessages || canSlackMessages) { + actions.add("read"); + actions.add("edit"); + actions.add("delete"); + } + + const canDiscordPins = discordEnabled && discordGate("pins"); + const canSlackPins = isSlackActionEnabled("pins"); + if (canDiscordPins || canSlackPins) { + actions.add("pin"); + actions.add("unpin"); + actions.add("list-pins"); + } + + const msteamsEnabled = + cfg.msteams?.enabled !== false && + Boolean(cfg.msteams && resolveMSTeamsCredentials(cfg.msteams)); + const canDiscordPolls = discordEnabled && discordGate("polls"); + const canWhatsAppPolls = cfg.whatsapp ? whatsappGate("polls") : false; + if (canDiscordPolls || canWhatsAppPolls || msteamsEnabled) + actions.add("poll"); + if (discordEnabled && discordGate("permissions")) actions.add("permissions"); + if (discordEnabled && discordGate("threads")) { + actions.add("thread-create"); + actions.add("thread-list"); + actions.add("thread-reply"); + } + if (discordEnabled && discordGate("search")) actions.add("search"); + if (discordEnabled && discordGate("stickers")) actions.add("sticker"); + if ( + (discordEnabled && discordGate("memberInfo")) || + isSlackActionEnabled("memberInfo") + ) { + actions.add("member-info"); + } + if (discordEnabled && discordGate("roleInfo")) actions.add("role-info"); + if ( + (discordEnabled && discordGate("reactions")) || + isSlackActionEnabled("emojiList") + ) { + actions.add("emoji-list"); + } + if (discordEnabled && discordGate("emojiUploads")) + actions.add("emoji-upload"); + if (discordEnabled && discordGate("stickerUploads")) + actions.add("sticker-upload"); + + const canDiscordRoles = discordEnabled && discordGate("roles", false); + if (canDiscordRoles) { + actions.add("role-add"); + actions.add("role-remove"); + } + + if (discordEnabled && discordGate("channelInfo")) { + actions.add("channel-info"); + actions.add("channel-list"); + } + if (discordEnabled && discordGate("voiceStatus")) actions.add("voice-status"); + if (discordEnabled && discordGate("events")) { + actions.add("event-list"); + actions.add("event-create"); + } + if (discordEnabled && discordGate("moderation", false)) { + actions.add("timeout"); + actions.add("kick"); + actions.add("ban"); + } + + return Type.Union(Array.from(actions).map((action) => Type.Literal(action))); +} + +function buildMessageToolSchema(cfg: ClawdbotConfig) { + const base = MessageToolSchema as unknown as Record; + const baseProps = (base.properties ?? {}) as Record; + const props: Record = { + ...baseProps, + action: buildMessageActionSchema(cfg), + }; + + const telegramEnabled = listEnabledTelegramAccounts(cfg).some( + (account) => account.tokenSource !== "none", + ); + if (!telegramEnabled || !hasTelegramInlineButtons(cfg)) { + delete props.buttons; + } + + return { ...base, properties: props }; +} + function resolveAgentAccountId(value?: string): string | undefined { const trimmed = value?.trim(); if (!trimmed) return undefined; @@ -126,12 +302,15 @@ function resolveAgentAccountId(value?: string): string | undefined { export function createMessageTool(options?: MessageToolOptions): AnyAgentTool { const agentAccountId = resolveAgentAccountId(options?.agentAccountId); + const schema = options?.config + ? buildMessageToolSchema(options.config) + : MessageToolSchema; return { label: "Message", name: "message", description: "Send messages and provider-specific actions (Discord/Slack/Telegram/WhatsApp/Signal/iMessage/MS Teams).", - parameters: MessageToolSchema, + parameters: schema, execute: async (_toolCallId, args) => { const params = args as Record; const cfg = options?.config ?? loadConfig(); @@ -160,6 +339,7 @@ export function createMessageTool(options?: MessageToolOptions): AnyAgentTool { const mediaUrl = readStringParam(params, "media", { trim: false }); const replyTo = readStringParam(params, "replyTo"); const threadId = readStringParam(params, "threadId"); + const buttons = params.buttons; const gifPlayback = typeof params.gifPlayback === "boolean" ? params.gifPlayback : false; const bestEffort = @@ -216,6 +396,8 @@ export function createMessageTool(options?: MessageToolOptions): AnyAgentTool { mediaUrl: mediaUrl ?? undefined, replyToMessageId: replyTo ?? undefined, messageThreadId: threadId ?? undefined, + accountId: accountId ?? undefined, + buttons, }, cfg, ); @@ -344,6 +526,7 @@ export function createMessageTool(options?: MessageToolOptions): AnyAgentTool { messageId, emoji, remove, + accountId: accountId ?? undefined, }, cfg, ); diff --git a/src/agents/tools/telegram-actions.test.ts b/src/agents/tools/telegram-actions.test.ts index 01ae945f5..2f7211076 100644 --- a/src/agents/tools/telegram-actions.test.ts +++ b/src/agents/tools/telegram-actions.test.ts @@ -1,7 +1,10 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import type { ClawdbotConfig } from "../../config/config.js"; -import { handleTelegramAction } from "./telegram-actions.js"; +import { + handleTelegramAction, + readTelegramButtons, +} from "./telegram-actions.js"; const reactMessageTelegram = vi.fn(async () => ({ ok: true })); const sendMessageTelegram = vi.fn(async () => ({ @@ -41,10 +44,12 @@ describe("handleTelegramAction", () => { }, cfg, ); - expect(reactMessageTelegram).toHaveBeenCalledWith("123", 456, "✅", { - token: "tok", - remove: false, - }); + expect(reactMessageTelegram).toHaveBeenCalledWith( + "123", + 456, + "✅", + expect.objectContaining({ token: "tok", remove: false }), + ); }); it("removes reactions on empty emoji", async () => { @@ -58,10 +63,12 @@ describe("handleTelegramAction", () => { }, cfg, ); - expect(reactMessageTelegram).toHaveBeenCalledWith("123", 456, "", { - token: "tok", - remove: false, - }); + expect(reactMessageTelegram).toHaveBeenCalledWith( + "123", + 456, + "", + expect.objectContaining({ token: "tok", remove: false }), + ); }); it("removes reactions when remove flag set", async () => { @@ -76,10 +83,12 @@ describe("handleTelegramAction", () => { }, cfg, ); - expect(reactMessageTelegram).toHaveBeenCalledWith("123", 456, "✅", { - token: "tok", - remove: true, - }); + expect(reactMessageTelegram).toHaveBeenCalledWith( + "123", + 456, + "✅", + expect.objectContaining({ token: "tok", remove: true }), + ); }); it("respects reaction gating", async () => { @@ -112,7 +121,7 @@ describe("handleTelegramAction", () => { expect(sendMessageTelegram).toHaveBeenCalledWith( "@testchannel", "Hello, Telegram!", - { token: "tok", mediaUrl: undefined }, + expect.objectContaining({ token: "tok", mediaUrl: undefined }), ); expect(result.content).toContainEqual({ type: "text", @@ -134,7 +143,10 @@ describe("handleTelegramAction", () => { expect(sendMessageTelegram).toHaveBeenCalledWith( "123456", "Check this image!", - { token: "tok", mediaUrl: "https://example.com/image.jpg" }, + expect.objectContaining({ + token: "tok", + mediaUrl: "https://example.com/image.jpg", + }), ); }); @@ -168,4 +180,50 @@ describe("handleTelegramAction", () => { ), ).rejects.toThrow(/Telegram bot token missing/); }); + + it("requires inlineButtons capability when buttons are provided", async () => { + const cfg = { telegram: { botToken: "tok" } } as ClawdbotConfig; + await expect( + handleTelegramAction( + { + action: "sendMessage", + to: "@testchannel", + content: "Choose", + buttons: [[{ text: "Ok", callback_data: "cmd:ok" }]], + }, + cfg, + ), + ).rejects.toThrow(/inlineButtons/i); + }); + + it("sends messages with inline keyboard buttons when enabled", async () => { + const cfg = { + telegram: { botToken: "tok", capabilities: ["inlineButtons"] }, + } as ClawdbotConfig; + await handleTelegramAction( + { + action: "sendMessage", + to: "@testchannel", + content: "Choose", + buttons: [[{ text: " Option A ", callback_data: " cmd:a " }]], + }, + cfg, + ); + expect(sendMessageTelegram).toHaveBeenCalledWith( + "@testchannel", + "Choose", + expect.objectContaining({ + buttons: [[{ text: "Option A", callback_data: "cmd:a" }]], + }), + ); + }); +}); + +describe("readTelegramButtons", () => { + it("returns trimmed button rows for valid input", () => { + const result = readTelegramButtons({ + buttons: [[{ text: " Option A ", callback_data: " cmd:a " }]], + }); + expect(result).toEqual([[{ text: "Option A", callback_data: "cmd:a" }]]); + }); }); diff --git a/src/agents/tools/telegram-actions.ts b/src/agents/tools/telegram-actions.ts index 8c75082aa..e6e95968d 100644 --- a/src/agents/tools/telegram-actions.ts +++ b/src/agents/tools/telegram-actions.ts @@ -1,6 +1,7 @@ import type { AgentToolResult } from "@mariozechner/pi-agent-core"; import type { ClawdbotConfig } from "../../config/config.js"; +import { resolveProviderCapabilities } from "../../config/provider-capabilities.js"; import { reactMessageTelegram, sendMessageTelegram, @@ -15,11 +16,74 @@ import { readStringParam, } from "./common.js"; +type TelegramButton = { + text: string; + callback_data: string; +}; + +function hasInlineButtonsCapability(params: { + cfg: ClawdbotConfig; + accountId?: string | undefined; +}): boolean { + const caps = + resolveProviderCapabilities({ + cfg: params.cfg, + provider: "telegram", + accountId: params.accountId, + }) ?? []; + return caps.some((cap) => cap.toLowerCase() === "inlinebuttons"); +} + +export function readTelegramButtons( + params: Record, +): TelegramButton[][] | undefined { + const raw = params.buttons; + if (raw == null) return undefined; + if (!Array.isArray(raw)) { + throw new Error("buttons must be an array of button rows"); + } + const rows = raw.map((row, rowIndex) => { + if (!Array.isArray(row)) { + throw new Error(`buttons[${rowIndex}] must be an array`); + } + return row.map((button, buttonIndex) => { + if (!button || typeof button !== "object") { + throw new Error( + `buttons[${rowIndex}][${buttonIndex}] must be an object`, + ); + } + const text = + typeof (button as { text?: unknown }).text === "string" + ? (button as { text: string }).text.trim() + : ""; + const callbackData = + typeof (button as { callback_data?: unknown }).callback_data === + "string" + ? (button as { callback_data: string }).callback_data.trim() + : ""; + if (!text || !callbackData) { + throw new Error( + `buttons[${rowIndex}][${buttonIndex}] requires text and callback_data`, + ); + } + if (callbackData.length > 64) { + throw new Error( + `buttons[${rowIndex}][${buttonIndex}] callback_data too long (max 64 chars)`, + ); + } + return { text, callback_data: callbackData }; + }); + }); + const filtered = rows.filter((row) => row.length > 0); + return filtered.length > 0 ? filtered : undefined; +} + export async function handleTelegramAction( params: Record, cfg: ClawdbotConfig, ): Promise> { const action = readStringParam(params, "action", { required: true }); + const accountId = readStringParam(params, "accountId"); const isActionEnabled = createActionGate(cfg.telegram?.actions); if (action === "react") { @@ -36,7 +100,7 @@ export async function handleTelegramAction( const { emoji, remove, isEmpty } = readReactionParams(params, { removeErrorMessage: "Emoji is required to remove a Telegram reaction.", }); - const token = resolveTelegramToken(cfg).token; + const token = resolveTelegramToken(cfg, { accountId }).token; if (!token) { throw new Error( "Telegram bot token missing. Set TELEGRAM_BOT_TOKEN or telegram.botToken.", @@ -45,6 +109,7 @@ export async function handleTelegramAction( await reactMessageTelegram(chatId ?? "", messageId ?? 0, emoji ?? "", { token, remove, + accountId: accountId ?? undefined, }); if (!remove && !isEmpty) { return jsonResult({ ok: true, added: emoji }); @@ -59,6 +124,15 @@ export async function handleTelegramAction( const to = readStringParam(params, "to", { required: true }); const content = readStringParam(params, "content", { required: true }); const mediaUrl = readStringParam(params, "mediaUrl"); + const buttons = readTelegramButtons(params); + if ( + buttons && + !hasInlineButtonsCapability({ cfg, accountId: accountId ?? undefined }) + ) { + throw new Error( + 'Telegram inline buttons requested but not enabled. Add "inlineButtons" to telegram.capabilities (or telegram.accounts..capabilities).', + ); + } // Optional threading parameters for forum topics and reply chains const replyToMessageId = readNumberParam(params, "replyToMessageId", { integer: true, @@ -66,7 +140,7 @@ export async function handleTelegramAction( const messageThreadId = readNumberParam(params, "messageThreadId", { integer: true, }); - const token = resolveTelegramToken(cfg).token; + const token = resolveTelegramToken(cfg, { accountId }).token; if (!token) { throw new Error( "Telegram bot token missing. Set TELEGRAM_BOT_TOKEN or telegram.botToken.", @@ -74,7 +148,9 @@ export async function handleTelegramAction( } const result = await sendMessageTelegram(to, content, { token, + accountId: accountId ?? undefined, mediaUrl: mediaUrl || undefined, + buttons, replyToMessageId: replyToMessageId ?? undefined, messageThreadId: messageThreadId ?? undefined, }); diff --git a/src/cli/program.ts b/src/cli/program.ts index 3b8292195..c056741a7 100644 --- a/src/cli/program.ts +++ b/src/cli/program.ts @@ -509,6 +509,10 @@ Examples: "--media ", "Attach media (image/audio/video/document). Accepts local paths or URLs.", ) + .option( + "--buttons-json ", + "Telegram inline keyboard buttons as JSON (array of button rows)", + ) .option("--reply-to ", "Reply-to message id") .option("--thread-id ", "Thread id (Telegram forum thread)") .option( diff --git a/src/commands/message.ts b/src/commands/message.ts index 1efadec31..aca886c85 100644 --- a/src/commands/message.ts +++ b/src/commands/message.ts @@ -65,6 +65,7 @@ type MessageCommandOpts = { to?: string; message?: string; media?: string; + buttonsJson?: string; messageId?: string; replyTo?: string; threadId?: string; @@ -354,6 +355,15 @@ export async function messageCommand( } if (provider === "telegram") { + const buttonsJson = optionalString(opts.buttonsJson); + let buttons: unknown; + if (buttonsJson) { + try { + buttons = JSON.parse(buttonsJson); + } catch { + throw new Error("buttons-json must be valid JSON"); + } + } const result = await handleTelegramAction( { action: "sendMessage", @@ -362,6 +372,8 @@ export async function messageCommand( mediaUrl: optionalString(opts.media), replyToMessageId: optionalString(opts.replyTo), messageThreadId: optionalString(opts.threadId), + accountId: optionalString(opts.account), + buttons, }, cfg, ); @@ -550,6 +562,7 @@ export async function messageCommand( messageId, emoji, remove: opts.remove, + accountId: optionalString(opts.account), }, cfg, ); diff --git a/src/config/provider-capabilities.test.ts b/src/config/provider-capabilities.test.ts new file mode 100644 index 000000000..ab14148e8 --- /dev/null +++ b/src/config/provider-capabilities.test.ts @@ -0,0 +1,87 @@ +import { describe, expect, it } from "vitest"; + +import type { ClawdbotConfig } from "./config.js"; +import { resolveProviderCapabilities } from "./provider-capabilities.js"; + +describe("resolveProviderCapabilities", () => { + it("returns undefined for missing inputs", () => { + expect(resolveProviderCapabilities({})).toBeUndefined(); + expect( + resolveProviderCapabilities({ cfg: {} as ClawdbotConfig }), + ).toBeUndefined(); + expect( + resolveProviderCapabilities({ cfg: {} as ClawdbotConfig, provider: "" }), + ).toBeUndefined(); + }); + + it("normalizes and prefers per-account capabilities", () => { + const cfg = { + telegram: { + capabilities: [" inlineButtons ", ""], + accounts: { + default: { + capabilities: [" perAccount ", " "], + }, + }, + }, + } satisfies Partial; + + expect( + resolveProviderCapabilities({ + cfg: cfg as ClawdbotConfig, + provider: "telegram", + accountId: "default", + }), + ).toEqual(["perAccount"]); + }); + + it("falls back to provider capabilities when account capabilities are missing", () => { + const cfg = { + telegram: { + capabilities: ["inlineButtons"], + accounts: { + default: {}, + }, + }, + } satisfies Partial; + + expect( + resolveProviderCapabilities({ + cfg: cfg as ClawdbotConfig, + provider: "telegram", + accountId: "default", + }), + ).toEqual(["inlineButtons"]); + }); + + it("matches account keys case-insensitively", () => { + const cfg = { + slack: { + accounts: { + Family: { capabilities: ["threads"] }, + }, + }, + } satisfies Partial; + + expect( + resolveProviderCapabilities({ + cfg: cfg as ClawdbotConfig, + provider: "slack", + accountId: "family", + }), + ).toEqual(["threads"]); + }); + + it("supports msteams capabilities", () => { + const cfg = { + msteams: { capabilities: [" polls ", ""] }, + } satisfies Partial; + + expect( + resolveProviderCapabilities({ + cfg: cfg as ClawdbotConfig, + provider: "msteams", + }), + ).toEqual(["polls"]); + }); +}); diff --git a/src/config/provider-capabilities.ts b/src/config/provider-capabilities.ts new file mode 100644 index 000000000..74695e434 --- /dev/null +++ b/src/config/provider-capabilities.ts @@ -0,0 +1,91 @@ +import { normalizeAccountId } from "../routing/session-key.js"; +import type { ClawdbotConfig } from "./config.js"; + +function normalizeCapabilities( + capabilities: string[] | undefined, +): string[] | undefined { + if (!capabilities) return undefined; + const normalized = capabilities.map((entry) => entry.trim()).filter(Boolean); + return normalized.length > 0 ? normalized : undefined; +} + +function resolveAccountCapabilities(params: { + cfg?: { accounts?: Record } & { + capabilities?: string[]; + }; + accountId?: string | null; +}): string[] | undefined { + const cfg = params.cfg; + if (!cfg) return undefined; + const normalizedAccountId = normalizeAccountId(params.accountId); + + const accounts = cfg.accounts; + if (accounts && typeof accounts === "object") { + const direct = accounts[normalizedAccountId]; + if (direct) { + return ( + normalizeCapabilities(direct.capabilities) ?? + normalizeCapabilities(cfg.capabilities) + ); + } + const matchKey = Object.keys(accounts).find( + (key) => key.toLowerCase() === normalizedAccountId.toLowerCase(), + ); + const match = matchKey ? accounts[matchKey] : undefined; + if (match) { + return ( + normalizeCapabilities(match.capabilities) ?? + normalizeCapabilities(cfg.capabilities) + ); + } + } + + return normalizeCapabilities(cfg.capabilities); +} + +export function resolveProviderCapabilities(params: { + cfg?: ClawdbotConfig; + provider?: string | null; + accountId?: string | null; +}): string[] | undefined { + const cfg = params.cfg; + const provider = params.provider?.trim().toLowerCase(); + if (!cfg || !provider) return undefined; + + switch (provider) { + case "whatsapp": + return resolveAccountCapabilities({ + cfg: cfg.whatsapp, + accountId: params.accountId, + }); + case "telegram": + return resolveAccountCapabilities({ + cfg: cfg.telegram, + accountId: params.accountId, + }); + case "discord": + return resolveAccountCapabilities({ + cfg: cfg.discord, + accountId: params.accountId, + }); + case "slack": + return resolveAccountCapabilities({ + cfg: cfg.slack, + accountId: params.accountId, + }); + case "signal": + return resolveAccountCapabilities({ + cfg: cfg.signal, + accountId: params.accountId, + }); + case "imessage": + return resolveAccountCapabilities({ + cfg: cfg.imessage, + accountId: params.accountId, + }); + case "msteams": + return normalizeCapabilities(cfg.msteams?.capabilities); + default: + return undefined; + } +} diff --git a/src/config/types.ts b/src/config/types.ts index c59d35a6e..30ab5f806 100644 --- a/src/config/types.ts +++ b/src/config/types.ts @@ -106,11 +106,14 @@ export type IdentityConfig = { export type WhatsAppActionConfig = { reactions?: boolean; sendMessage?: boolean; + polls?: boolean; }; export type WhatsAppConfig = { /** Optional per-account WhatsApp configuration (multi-account). */ accounts?: Record; + /** Optional provider capability tags used for agent/runtime guidance. */ + capabilities?: string[]; /** * Inbound message prefix (WhatsApp only). * Default: `[{agents.list[].identity.name}]` (or `[clawdbot]`) when allowFrom is empty, else `""`. @@ -155,6 +158,8 @@ export type WhatsAppConfig = { export type WhatsAppAccountConfig = { /** Optional display name for this account (used in CLI/UI lists). */ name?: string; + /** Optional provider capability tags used for agent/runtime guidance. */ + capabilities?: string[]; /** If false, do not start this WhatsApp account provider. Default: true. */ enabled?: boolean; /** Inbound message prefix override for this account (WhatsApp only). */ @@ -300,6 +305,8 @@ export type TelegramActionConfig = { export type TelegramAccountConfig = { /** Optional display name for this account (used in CLI/UI lists). */ name?: string; + /** Optional provider capability tags used for agent/runtime guidance. */ + capabilities?: string[]; /** * Controls how Telegram direct chats (DMs) are handled: * - "pairing" (default): unknown senders get a pairing code; owner must approve @@ -441,6 +448,8 @@ export type DiscordActionConfig = { export type DiscordAccountConfig = { /** Optional display name for this account (used in CLI/UI lists). */ name?: string; + /** Optional provider capability tags used for agent/runtime guidance. */ + capabilities?: string[]; /** If false, do not start this Discord account. Default: true. */ enabled?: boolean; token?: string; @@ -538,6 +547,8 @@ export type SlackSlashCommandConfig = { export type SlackAccountConfig = { /** Optional display name for this account (used in CLI/UI lists). */ name?: string; + /** Optional provider capability tags used for agent/runtime guidance. */ + capabilities?: string[]; /** If false, do not start this Slack account. Default: true. */ enabled?: boolean; botToken?: string; @@ -576,6 +587,8 @@ export type SlackConfig = { export type SignalAccountConfig = { /** Optional display name for this account (used in CLI/UI lists). */ name?: string; + /** Optional provider capability tags used for agent/runtime guidance. */ + capabilities?: string[]; /** If false, do not start this Signal account. Default: true. */ enabled?: boolean; /** Optional explicit E.164 account for signal-cli. */ @@ -650,6 +663,8 @@ export type MSTeamsTeamConfig = { export type MSTeamsConfig = { /** If false, do not start the MS Teams provider. Default: true. */ enabled?: boolean; + /** Optional provider capability tags used for agent/runtime guidance. */ + capabilities?: string[]; /** Azure Bot App ID (from Azure Bot registration). */ appId?: string; /** Azure Bot App Password / Client Secret. */ @@ -682,6 +697,8 @@ export type MSTeamsConfig = { export type IMessageAccountConfig = { /** Optional display name for this account (used in CLI/UI lists). */ name?: string; + /** Optional provider capability tags used for agent/runtime guidance. */ + capabilities?: string[]; /** If false, do not start this iMessage account. Default: true. */ enabled?: boolean; /** imsg CLI binary path (default: imsg). */ diff --git a/src/config/zod-schema.ts b/src/config/zod-schema.ts index 711f60643..ad279d679 100644 --- a/src/config/zod-schema.ts +++ b/src/config/zod-schema.ts @@ -187,6 +187,7 @@ const TelegramGroupSchema = z.object({ const TelegramAccountSchemaBase = z.object({ name: z.string().optional(), + capabilities: z.array(z.string()).optional(), enabled: z.boolean().optional(), dmPolicy: DmPolicySchema.optional().default("pairing"), botToken: z.string().optional(), @@ -279,6 +280,7 @@ const DiscordGuildSchema = z.object({ const DiscordAccountSchema = z.object({ name: z.string().optional(), + capabilities: z.array(z.string()).optional(), enabled: z.boolean().optional(), token: z.string().optional(), groupPolicy: GroupPolicySchema.optional().default("open"), @@ -348,6 +350,7 @@ const SlackChannelSchema = z.object({ const SlackAccountSchema = z.object({ name: z.string().optional(), + capabilities: z.array(z.string()).optional(), enabled: z.boolean().optional(), botToken: z.string().optional(), appToken: z.string().optional(), @@ -390,6 +393,7 @@ const SlackConfigSchema = SlackAccountSchema.extend({ const SignalAccountSchemaBase = z.object({ name: z.string().optional(), + capabilities: z.array(z.string()).optional(), enabled: z.boolean().optional(), account: z.string().optional(), httpUrl: z.string().optional(), @@ -438,6 +442,7 @@ const SignalConfigSchema = SignalAccountSchemaBase.extend({ const IMessageAccountSchemaBase = z.object({ name: z.string().optional(), + capabilities: z.array(z.string()).optional(), enabled: z.boolean().optional(), cliPath: z.string().optional(), dbPath: z.string().optional(), @@ -506,6 +511,7 @@ const MSTeamsTeamSchema = z.object({ const MSTeamsConfigSchema = z .object({ enabled: z.boolean().optional(), + capabilities: z.array(z.string()).optional(), appId: z.string().optional(), appPassword: z.string().optional(), tenantId: z.string().optional(), @@ -1228,6 +1234,7 @@ export const ClawdbotSchema = z.object({ z .object({ name: z.string().optional(), + capabilities: z.array(z.string()).optional(), enabled: z.boolean().optional(), messagePrefix: z.string().optional(), /** Override auth directory for this WhatsApp account (Baileys multi-file auth state). */ @@ -1268,6 +1275,7 @@ export const ClawdbotSchema = z.object({ .optional(), ) .optional(), + capabilities: z.array(z.string()).optional(), dmPolicy: DmPolicySchema.optional().default("pairing"), messagePrefix: z.string().optional(), selfChatMode: z.boolean().optional(), @@ -1281,6 +1289,8 @@ export const ClawdbotSchema = z.object({ actions: z .object({ reactions: z.boolean().optional(), + sendMessage: z.boolean().optional(), + polls: z.boolean().optional(), }) .optional(), groups: z diff --git a/src/telegram/bot.media.test.ts b/src/telegram/bot.media.test.ts index 791e25fdf..bdbd7d5ad 100644 --- a/src/telegram/bot.media.test.ts +++ b/src/telegram/bot.media.test.ts @@ -85,9 +85,10 @@ describe("telegram inbound media", () => { }, }, }); - const handler = onSpy.mock.calls[0]?.[1] as ( - ctx: Record, - ) => Promise; + const handler = onSpy.mock.calls.find( + (call) => call[0] === "message", + )?.[1] as (ctx: Record) => Promise; + expect(handler).toBeDefined(); const fetchSpy = vi .spyOn(globalThis, "fetch" as never) @@ -153,9 +154,10 @@ describe("telegram inbound media", () => { }, }, }); - const handler = onSpy.mock.calls[0]?.[1] as ( - ctx: Record, - ) => Promise; + const handler = onSpy.mock.calls.find( + (call) => call[0] === "message", + )?.[1] as (ctx: Record) => Promise; + expect(handler).toBeDefined(); await handler({ message: { @@ -199,9 +201,10 @@ describe("telegram inbound media", () => { }, }, }); - const handler = onSpy.mock.calls[0]?.[1] as ( - ctx: Record, - ) => Promise; + const handler = onSpy.mock.calls.find( + (call) => call[0] === "message", + )?.[1] as (ctx: Record) => Promise; + expect(handler).toBeDefined(); await handler({ message: { @@ -263,9 +266,10 @@ describe("telegram media groups", () => { }, }, }); - const handler = onSpy.mock.calls[0][1] as ( - ctx: Record, - ) => Promise; + const handler = onSpy.mock.calls.find( + (call) => call[0] === "message", + )?.[1] as (ctx: Record) => Promise; + expect(handler).toBeDefined(); await handler({ message: { @@ -323,9 +327,10 @@ describe("telegram media groups", () => { } as Response); createTelegramBot({ token: "tok" }); - const handler = onSpy.mock.calls[0][1] as ( - ctx: Record, - ) => Promise; + const handler = onSpy.mock.calls.find( + (call) => call[0] === "message", + )?.[1] as (ctx: Record) => Promise; + expect(handler).toBeDefined(); await handler({ message: { @@ -374,9 +379,10 @@ describe("telegram location parsing", () => { replySpy.mockReset(); createTelegramBot({ token: "tok" }); - const handler = onSpy.mock.calls[0]?.[1] as ( - ctx: Record, - ) => Promise; + const handler = onSpy.mock.calls.find( + (call) => call[0] === "message", + )?.[1] as (ctx: Record) => Promise; + expect(handler).toBeDefined(); await handler({ message: { @@ -415,9 +421,10 @@ describe("telegram location parsing", () => { replySpy.mockReset(); createTelegramBot({ token: "tok" }); - const handler = onSpy.mock.calls[0]?.[1] as ( - ctx: Record, - ) => Promise; + const handler = onSpy.mock.calls.find( + (call) => call[0] === "message", + )?.[1] as (ctx: Record) => Promise; + expect(handler).toBeDefined(); await handler({ message: { diff --git a/src/telegram/bot.test.ts b/src/telegram/bot.test.ts index 615ca095f..972210226 100644 --- a/src/telegram/bot.test.ts +++ b/src/telegram/bot.test.ts @@ -46,6 +46,7 @@ const onSpy = vi.fn(); const stopSpy = vi.fn(); const commandSpy = vi.fn(); const botCtorSpy = vi.fn(); +const answerCallbackQuerySpy = vi.fn(async () => undefined); const sendChatActionSpy = vi.fn(); const setMessageReactionSpy = vi.fn(async () => undefined); const setMyCommandsSpy = vi.fn(async () => undefined); @@ -54,6 +55,7 @@ const sendAnimationSpy = vi.fn(async () => ({ message_id: 78 })); const sendPhotoSpy = vi.fn(async () => ({ message_id: 79 })); type ApiStub = { config: { use: (arg: unknown) => void }; + answerCallbackQuery: typeof answerCallbackQuerySpy; sendChatAction: typeof sendChatActionSpy; setMessageReaction: typeof setMessageReactionSpy; setMyCommands: typeof setMyCommandsSpy; @@ -63,6 +65,7 @@ type ApiStub = { }; const apiStub: ApiStub = { config: { use: useSpy }, + answerCallbackQuery: answerCallbackQuerySpy, sendChatAction: sendChatActionSpy, setMessageReaction: setMessageReactionSpy, setMyCommands: setMyCommandsSpy, @@ -113,6 +116,12 @@ vi.mock("../auto-reply/reply.js", () => { return { getReplyFromConfig: replySpy, __replySpy: replySpy }; }); +const getOnHandler = (event: string) => { + const handler = onSpy.mock.calls.find((call) => call[0] === event)?.[1]; + if (!handler) throw new Error(`Missing handler for event: ${event}`); + return handler as (ctx: Record) => Promise; +}; + describe("createTelegramBot", () => { beforeEach(() => { loadConfig.mockReturnValue({ @@ -122,6 +131,7 @@ describe("createTelegramBot", () => { sendAnimationSpy.mockReset(); sendPhotoSpy.mockReset(); setMessageReactionSpy.mockReset(); + answerCallbackQuerySpy.mockReset(); setMyCommandsSpy.mockReset(); middlewareUseSpy.mockReset(); sequentializeSpy.mockReset(); @@ -206,6 +216,40 @@ describe("createTelegramBot", () => { ).toBe("telegram:555"); }); + it("routes callback_query payloads as messages and answers callbacks", async () => { + onSpy.mockReset(); + const replySpy = replyModule.__replySpy as unknown as ReturnType< + typeof vi.fn + >; + replySpy.mockReset(); + + createTelegramBot({ token: "tok" }); + const callbackHandler = onSpy.mock.calls.find( + (call) => call[0] === "callback_query", + )?.[1] as (ctx: Record) => Promise; + expect(callbackHandler).toBeDefined(); + + await callbackHandler({ + callbackQuery: { + id: "cbq-1", + data: "cmd:option_a", + from: { id: 9, first_name: "Ada", username: "ada_bot" }, + message: { + chat: { id: 1234, type: "private" }, + date: 1736380800, + message_id: 10, + }, + }, + me: { username: "clawdbot_bot" }, + getFile: async () => ({ download: async () => new Uint8Array() }), + }); + + expect(replySpy).toHaveBeenCalledTimes(1); + const payload = replySpy.mock.calls[0][0]; + expect(payload.Body).toContain("cmd:option_a"); + expect(answerCallbackQuerySpy).toHaveBeenCalledWith("cbq-1"); + }); + it("wraps inbound message with Telegram envelope", async () => { const originalTz = process.env.TZ; process.env.TZ = "Europe/Vienna"; @@ -219,7 +263,7 @@ describe("createTelegramBot", () => { createTelegramBot({ token: "tok" }); expect(onSpy).toHaveBeenCalledWith("message", expect.any(Function)); - const handler = onSpy.mock.calls[0][1] as ( + const handler = getOnHandler("message") as ( ctx: Record, ) => Promise; @@ -266,7 +310,7 @@ describe("createTelegramBot", () => { }); createTelegramBot({ token: "tok" }); - const handler = onSpy.mock.calls[0][1] as ( + const handler = getOnHandler("message") as ( ctx: Record, ) => Promise; @@ -308,7 +352,7 @@ describe("createTelegramBot", () => { .mockResolvedValueOnce({ code: "PAIRME12", created: false }); createTelegramBot({ token: "tok" }); - const handler = onSpy.mock.calls[0][1] as ( + const handler = getOnHandler("message") as ( ctx: Record, ) => Promise; @@ -339,7 +383,7 @@ describe("createTelegramBot", () => { sendChatActionSpy.mockReset(); createTelegramBot({ token: "tok" }); - const handler = onSpy.mock.calls[0][1] as ( + const handler = getOnHandler("message") as ( ctx: Record, ) => Promise; await handler({ @@ -365,7 +409,7 @@ describe("createTelegramBot", () => { }); createTelegramBot({ token: "tok" }); - const handler = onSpy.mock.calls[0][1] as ( + const handler = getOnHandler("message") as ( ctx: Record, ) => Promise; @@ -401,7 +445,7 @@ describe("createTelegramBot", () => { }); createTelegramBot({ token: "tok" }); - const handler = onSpy.mock.calls[0][1] as ( + const handler = getOnHandler("message") as ( ctx: Record, ) => Promise; @@ -447,7 +491,7 @@ describe("createTelegramBot", () => { }); createTelegramBot({ token: "tok" }); - const handler = onSpy.mock.calls[0][1] as ( + const handler = getOnHandler("message") as ( ctx: Record, ) => Promise; @@ -491,7 +535,7 @@ describe("createTelegramBot", () => { }); createTelegramBot({ token: "tok" }); - const handler = onSpy.mock.calls[0][1] as ( + const handler = getOnHandler("message") as ( ctx: Record, ) => Promise; @@ -523,7 +567,7 @@ describe("createTelegramBot", () => { }); createTelegramBot({ token: "tok" }); - const handler = onSpy.mock.calls[0][1] as ( + const handler = getOnHandler("message") as ( ctx: Record, ) => Promise; @@ -553,7 +597,7 @@ describe("createTelegramBot", () => { replySpy.mockReset(); createTelegramBot({ token: "tok" }); - const handler = onSpy.mock.calls[0][1] as ( + const handler = getOnHandler("message") as ( ctx: Record, ) => Promise; @@ -591,7 +635,7 @@ describe("createTelegramBot", () => { replySpy.mockResolvedValue({ text: "a".repeat(4500) }); createTelegramBot({ token: "tok" }); - const handler = onSpy.mock.calls[0][1] as ( + const handler = getOnHandler("message") as ( ctx: Record, ) => Promise; await handler({ @@ -624,7 +668,7 @@ describe("createTelegramBot", () => { }); createTelegramBot({ token: "tok", replyToMode: "first" }); - const handler = onSpy.mock.calls[0][1] as ( + const handler = getOnHandler("message") as ( ctx: Record, ) => Promise; await handler({ @@ -663,7 +707,7 @@ describe("createTelegramBot", () => { }); createTelegramBot({ token: "tok" }); - const handler = onSpy.mock.calls[0][1] as ( + const handler = getOnHandler("message") as ( ctx: Record, ) => Promise; await handler({ @@ -694,7 +738,7 @@ describe("createTelegramBot", () => { }); createTelegramBot({ token: "tok", replyToMode: "all" }); - const handler = onSpy.mock.calls[0][1] as ( + const handler = getOnHandler("message") as ( ctx: Record, ) => Promise; await handler({ @@ -729,7 +773,7 @@ describe("createTelegramBot", () => { }); createTelegramBot({ token: "tok" }); - const handler = onSpy.mock.calls[0][1] as ( + const handler = getOnHandler("message") as ( ctx: Record, ) => Promise; @@ -757,7 +801,7 @@ describe("createTelegramBot", () => { }); createTelegramBot({ token: "tok" }); - const handler = onSpy.mock.calls[0][1] as ( + const handler = getOnHandler("message") as ( ctx: Record, ) => Promise; @@ -806,7 +850,7 @@ describe("createTelegramBot", () => { }); createTelegramBot({ token: "tok" }); - const handler = onSpy.mock.calls[0][1] as ( + const handler = getOnHandler("message") as ( ctx: Record, ) => Promise; @@ -839,7 +883,7 @@ describe("createTelegramBot", () => { }); createTelegramBot({ token: "tok" }); - const handler = onSpy.mock.calls[0][1] as ( + const handler = getOnHandler("message") as ( ctx: Record, ) => Promise; @@ -877,7 +921,7 @@ describe("createTelegramBot", () => { }); createTelegramBot({ token: "tok" }); - const handler = onSpy.mock.calls[0][1] as ( + const handler = getOnHandler("message") as ( ctx: Record, ) => Promise; @@ -913,7 +957,7 @@ describe("createTelegramBot", () => { }); createTelegramBot({ token: "tok" }); - const handler = onSpy.mock.calls[0][1] as ( + const handler = getOnHandler("message") as ( ctx: Record, ) => Promise; @@ -941,7 +985,7 @@ describe("createTelegramBot", () => { }); createTelegramBot({ token: "tok" }); - const handler = onSpy.mock.calls[0][1] as ( + const handler = getOnHandler("message") as ( ctx: Record, ) => Promise; @@ -976,7 +1020,7 @@ describe("createTelegramBot", () => { }); createTelegramBot({ token: "tok" }); - const handler = onSpy.mock.calls[0][1] as ( + const handler = getOnHandler("message") as ( ctx: Record, ) => Promise; @@ -1015,7 +1059,7 @@ describe("createTelegramBot", () => { }); createTelegramBot({ token: "tok" }); - const handler = onSpy.mock.calls[0][1] as ( + const handler = getOnHandler("message") as ( ctx: Record, ) => Promise; @@ -1048,7 +1092,7 @@ describe("createTelegramBot", () => { }); createTelegramBot({ token: "tok" }); - const handler = onSpy.mock.calls[0][1] as ( + const handler = getOnHandler("message") as ( ctx: Record, ) => Promise; @@ -1081,7 +1125,7 @@ describe("createTelegramBot", () => { }); createTelegramBot({ token: "tok" }); - const handler = onSpy.mock.calls[0][1] as ( + const handler = getOnHandler("message") as ( ctx: Record, ) => Promise; @@ -1114,7 +1158,7 @@ describe("createTelegramBot", () => { }); createTelegramBot({ token: "tok" }); - const handler = onSpy.mock.calls[0][1] as ( + const handler = getOnHandler("message") as ( ctx: Record, ) => Promise; @@ -1147,7 +1191,7 @@ describe("createTelegramBot", () => { }); createTelegramBot({ token: "tok" }); - const handler = onSpy.mock.calls[0][1] as ( + const handler = getOnHandler("message") as ( ctx: Record, ) => Promise; @@ -1180,7 +1224,7 @@ describe("createTelegramBot", () => { }); createTelegramBot({ token: "tok" }); - const handler = onSpy.mock.calls[0][1] as ( + const handler = getOnHandler("message") as ( ctx: Record, ) => Promise; @@ -1212,7 +1256,7 @@ describe("createTelegramBot", () => { }); createTelegramBot({ token: "tok" }); - const handler = onSpy.mock.calls[0][1] as ( + const handler = getOnHandler("message") as ( ctx: Record, ) => Promise; @@ -1245,7 +1289,7 @@ describe("createTelegramBot", () => { }); createTelegramBot({ token: "tok" }); - const handler = onSpy.mock.calls[0][1] as ( + const handler = getOnHandler("message") as ( ctx: Record, ) => Promise; @@ -1277,7 +1321,7 @@ describe("createTelegramBot", () => { }); createTelegramBot({ token: "tok" }); - const handler = onSpy.mock.calls[0][1] as ( + const handler = getOnHandler("message") as ( ctx: Record, ) => Promise; @@ -1308,7 +1352,7 @@ describe("createTelegramBot", () => { }); createTelegramBot({ token: "tok" }); - const handler = onSpy.mock.calls[0][1] as ( + const handler = getOnHandler("message") as ( ctx: Record, ) => Promise; @@ -1339,7 +1383,7 @@ describe("createTelegramBot", () => { }); createTelegramBot({ token: "tok" }); - const handler = onSpy.mock.calls[0][1] as ( + const handler = getOnHandler("message") as ( ctx: Record, ) => Promise; @@ -1372,7 +1416,7 @@ describe("createTelegramBot", () => { }); createTelegramBot({ token: "tok" }); - const handler = onSpy.mock.calls[0][1] as ( + const handler = getOnHandler("message") as ( ctx: Record, ) => Promise; @@ -1404,7 +1448,7 @@ describe("createTelegramBot", () => { }); createTelegramBot({ token: "tok" }); - const handler = onSpy.mock.calls[0][1] as ( + const handler = getOnHandler("message") as ( ctx: Record, ) => Promise; @@ -1437,7 +1481,7 @@ describe("createTelegramBot", () => { }); createTelegramBot({ token: "tok" }); - const handler = onSpy.mock.calls[0][1] as ( + const handler = getOnHandler("message") as ( ctx: Record, ) => Promise; @@ -1471,7 +1515,7 @@ describe("createTelegramBot", () => { }); createTelegramBot({ token: "tok" }); - const handler = onSpy.mock.calls[0][1] as ( + const handler = getOnHandler("message") as ( ctx: Record, ) => Promise; @@ -1504,7 +1548,7 @@ describe("createTelegramBot", () => { }); createTelegramBot({ token: "tok" }); - const handler = onSpy.mock.calls[0][1] as ( + const handler = getOnHandler("message") as ( ctx: Record, ) => Promise; @@ -1537,7 +1581,7 @@ describe("createTelegramBot", () => { }); createTelegramBot({ token: "tok" }); - const handler = onSpy.mock.calls[0][1] as ( + const handler = getOnHandler("message") as ( ctx: Record, ) => Promise; @@ -1568,7 +1612,7 @@ describe("createTelegramBot", () => { }); createTelegramBot({ token: "tok" }); - const handler = onSpy.mock.calls[0][1] as ( + const handler = getOnHandler("message") as ( ctx: Record, ) => Promise; @@ -1629,7 +1673,7 @@ describe("createTelegramBot", () => { }); createTelegramBot({ token: "tok" }); - const handler = onSpy.mock.calls[0][1] as ( + const handler = getOnHandler("message") as ( ctx: Record, ) => Promise; @@ -1673,7 +1717,7 @@ describe("createTelegramBot", () => { }); createTelegramBot({ token: "tok" }); - const handler = onSpy.mock.calls[0][1] as ( + const handler = getOnHandler("message") as ( ctx: Record, ) => Promise; diff --git a/src/telegram/bot.ts b/src/telegram/bot.ts index dd3fbcba0..7397f6ab1 100644 --- a/src/telegram/bot.ts +++ b/src/telegram/bot.ts @@ -307,6 +307,7 @@ export function createTelegramBot(opts: TelegramBotOptions) { primaryCtx: TelegramContext, allMedia: Array<{ path: string; contentType?: string }>, storeAllowFrom: string[], + options?: { forceWasMentioned?: boolean }, ) => { const msg = primaryCtx.message; recordProviderActivity({ @@ -468,9 +469,11 @@ export function createTelegramBot(opts: TelegramBotOptions) { senderId, senderUsername, }); - const wasMentioned = + const computedWasMentioned = (Boolean(botUsername) && hasBotMention(msg, botUsername)) || matchesMentionPatterns(msg.text ?? msg.caption ?? "", mentionRegexes); + const wasMentioned = + options?.forceWasMentioned === true ? true : computedWasMentioned; const hasAnyMention = (msg.entities ?? msg.caption_entities ?? []).some( (ent) => ent.type === "mention", ); @@ -991,6 +994,40 @@ export function createTelegramBot(opts: TelegramBotOptions) { }); } + bot.on("callback_query", async (ctx) => { + const callback = ctx.callbackQuery; + if (!callback) return; + try { + const data = (callback.data ?? "").trim(); + const callbackMessage = callback.message; + if (!data || !callbackMessage) return; + + const syntheticMessage: TelegramMessage = { + ...callbackMessage, + from: callback.from, + text: data, + caption: undefined, + caption_entities: undefined, + entities: undefined, + }; + const storeAllowFrom = await readTelegramAllowFromStore().catch(() => []); + const getFile = + typeof ctx.getFile === "function" + ? ctx.getFile.bind(ctx) + : async () => ({}); + await processMessage( + { message: syntheticMessage, me: ctx.me, getFile }, + [], + storeAllowFrom, + { forceWasMentioned: true }, + ); + } catch (err) { + runtime.error?.(danger(`callback handler failed: ${String(err)}`)); + } finally { + await bot.api.answerCallbackQuery(callback.id).catch(() => {}); + } + }); + bot.on("message", async (ctx) => { try { const msg = ctx.message; diff --git a/src/telegram/send.test.ts b/src/telegram/send.test.ts index dfcb5c7f6..115f88851 100644 --- a/src/telegram/send.test.ts +++ b/src/telegram/send.test.ts @@ -29,7 +29,51 @@ vi.mock("grammy", () => ({ InputFile: class {}, })); -import { reactMessageTelegram, sendMessageTelegram } from "./send.js"; +import { + buildInlineKeyboard, + reactMessageTelegram, + sendMessageTelegram, +} from "./send.js"; + +describe("buildInlineKeyboard", () => { + it("returns undefined for empty input", () => { + expect(buildInlineKeyboard()).toBeUndefined(); + expect(buildInlineKeyboard([])).toBeUndefined(); + }); + + it("builds inline keyboards for valid input", () => { + const result = buildInlineKeyboard([ + [{ text: "Option A", callback_data: "cmd:a" }], + [ + { text: "Option B", callback_data: "cmd:b" }, + { text: "Option C", callback_data: "cmd:c" }, + ], + ]); + expect(result).toEqual({ + inline_keyboard: [ + [{ text: "Option A", callback_data: "cmd:a" }], + [ + { text: "Option B", callback_data: "cmd:b" }, + { text: "Option C", callback_data: "cmd:c" }, + ], + ], + }); + }); + + it("filters invalid buttons and empty rows", () => { + const result = buildInlineKeyboard([ + [ + { text: "", callback_data: "cmd:skip" }, + { text: "Ok", callback_data: "cmd:ok" }, + ], + [{ text: "Missing data", callback_data: "" }], + [], + ]); + expect(result).toEqual({ + inline_keyboard: [[{ text: "Ok", callback_data: "cmd:ok" }]], + }); + }); +}); describe("sendMessageTelegram", () => { beforeEach(() => { diff --git a/src/telegram/send.ts b/src/telegram/send.ts index 5309a5f89..8799e32f7 100644 --- a/src/telegram/send.ts +++ b/src/telegram/send.ts @@ -1,4 +1,9 @@ -import type { ReactionType, ReactionTypeEmoji } from "@grammyjs/types"; +import type { + InlineKeyboardButton, + InlineKeyboardMarkup, + ReactionType, + ReactionTypeEmoji, +} from "@grammyjs/types"; import { type ApiClientOptions, Bot, InputFile } from "grammy"; import { loadConfig } from "../config/config.js"; import { formatErrorMessage } from "../infra/errors.js"; @@ -30,6 +35,8 @@ type TelegramSendOpts = { replyToMessageId?: number; /** Forum topic thread ID (for forum supergroups) */ messageThreadId?: number; + /** Inline keyboard buttons (reply markup). */ + buttons?: Array>; }; type TelegramSendResult = { @@ -103,6 +110,26 @@ function normalizeMessageId(raw: string | number): number { throw new Error("Message id is required for Telegram reactions"); } +export function buildInlineKeyboard( + buttons?: TelegramSendOpts["buttons"], +): InlineKeyboardMarkup | undefined { + if (!buttons?.length) return undefined; + const rows = buttons + .map((row) => + row + .filter((button) => button?.text && button?.callback_data) + .map( + (button): InlineKeyboardButton => ({ + text: button.text, + callback_data: button.callback_data, + }), + ), + ) + .filter((row) => row.length > 0); + if (rows.length === 0) return undefined; + return { inline_keyboard: rows }; +} + export async function sendMessageTelegram( to: string, text: string, @@ -124,6 +151,7 @@ export async function sendMessageTelegram( : undefined; const api = opts.api ?? new Bot(token, client ? { client } : undefined).api; const mediaUrl = opts.mediaUrl?.trim(); + const replyMarkup = buildInlineKeyboard(opts.buttons); // Build optional params for forum topics and reply threading. // Only include these if actually provided to keep API calls clean. @@ -171,8 +199,15 @@ export async function sendMessageTelegram( const file = new InputFile(media.buffer, fileName); const caption = text?.trim() || undefined; const mediaParams = hasThreadParams - ? { caption, ...threadParams } - : { caption }; + ? { + caption, + ...threadParams, + ...(replyMarkup ? { reply_markup: replyMarkup } : {}), + } + : { + caption, + ...(replyMarkup ? { reply_markup: replyMarkup } : {}), + }; let result: | Awaited> | Awaited> @@ -240,8 +275,15 @@ export async function sendMessageTelegram( } const htmlText = markdownToTelegramHtml(text); const textParams = hasThreadParams - ? { parse_mode: "HTML" as const, ...threadParams } - : { parse_mode: "HTML" as const }; + ? { + parse_mode: "HTML" as const, + ...threadParams, + ...(replyMarkup ? { reply_markup: replyMarkup } : {}), + } + : { + parse_mode: "HTML" as const, + ...(replyMarkup ? { reply_markup: replyMarkup } : {}), + }; const res = await request( () => api.sendMessage(chatId, htmlText, textParams), "message", @@ -255,10 +297,17 @@ export async function sendMessageTelegram( `telegram HTML parse failed, retrying as plain text: ${errText}`, ); } + const plainParams = + hasThreadParams || replyMarkup + ? { + ...threadParams, + ...(replyMarkup ? { reply_markup: replyMarkup } : {}), + } + : undefined; return await request( () => - hasThreadParams - ? api.sendMessage(chatId, text, threadParams) + plainParams + ? api.sendMessage(chatId, text, plainParams) : api.sendMessage(chatId, text), "message-plain", ).catch((err2) => {