diff --git a/CHANGELOG.md b/CHANGELOG.md index a259970ae..16c9007fb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -15,6 +15,7 @@ ### Changes - CLI: set process titles to `clawdbot-` for clearer process listings. - CLI/macOS: sync remote SSH target/identity to config and let `gateway status` auto-infer SSH targets (ssh-config aware). +- Telegram: scope inline buttons with allowlist default + callback gating in DMs/groups. - Heartbeat: tighten prompt guidance + suppress duplicate alerts for 24h. (#980) — thanks @voidserf. - Repo: ignore local identity files to avoid accidental commits. (#1001) — thanks @gerardward2007. - Sessions/Security: add `session.dmScope` for multi-user DM isolation and audit warnings. (#948) — thanks @Alphonse-arianee. diff --git a/docs/channels/telegram.md b/docs/channels/telegram.md index a437d933a..b549abebb 100644 --- a/docs/channels/telegram.md +++ b/docs/channels/telegram.md @@ -219,13 +219,15 @@ Private chats can include `message_thread_id` in some edge cases. Clawdbot keeps ## Inline Buttons -Telegram supports inline keyboards with callback buttons. Enable this feature via capabilities: +Telegram supports inline keyboards with callback buttons. ```json5 { "channels": { "telegram": { - "capabilities": ["inlineButtons"] + "capabilities": { + "inlineButtons": "allowlist" + } } } } @@ -238,7 +240,9 @@ For per-account configuration: "telegram": { "accounts": { "main": { - "capabilities": ["inlineButtons"] + "capabilities": { + "inlineButtons": "allowlist" + } } } } @@ -246,6 +250,16 @@ For per-account configuration: } ``` +Scopes: +- `off` — inline buttons disabled +- `dm` — only DMs (group targets blocked) +- `group` — only groups (DM targets blocked) +- `all` — DMs + groups +- `allowlist` — DMs + groups, but only senders allowed by `allowFrom`/`groupAllowFrom` (same rules as control commands) + +Default: `allowlist`. +Legacy: `capabilities: ["inlineButtons"]` = `inlineButtons: "all"`. + ### Sending buttons Use the message tool with the `buttons` parameter: @@ -273,12 +287,12 @@ When a user clicks a button, the callback data is sent back to the agent as a me ### Configuration options -Telegram capabilities can be configured at two levels: +Telegram capabilities can be configured at two levels (object form shown above; legacy string arrays still supported): -- `channels.telegram.capabilities`: Global default capability list applied to all Telegram accounts unless overridden. -- `channels.telegram.accounts..capabilities`: Per-account capabilities that override or extend the global defaults for that specific account. +- `channels.telegram.capabilities`: Global default capability config applied to all Telegram accounts unless overridden. +- `channels.telegram.accounts..capabilities`: Per-account capabilities that override the global defaults for that specific account. -Use the global setting when all Telegram bots/accounts should behave the same. Use per-account configuration when different bots need different behaviors (for example, one account only handles DMs while another is allowed in groups or has extra capabilities). +Use the global setting when all Telegram bots/accounts should behave the same. Use per-account configuration when different bots need different behaviors (for example, one account only handles DMs while another is allowed in groups). ## Access control (DMs + groups) ### DM access @@ -477,8 +491,8 @@ Provider options: - `channels.telegram.groups..enabled`: disable the group when `false`. - `channels.telegram.groups..topics..*`: per-topic overrides (same fields as group). - `channels.telegram.groups..topics..requireMention`: per-topic mention gating override. -- `channels.telegram.capabilities`: Enable channel features (e.g., "inlineButtons"). -- `channels.telegram.accounts..capabilities`: Per-account capabilities. +- `channels.telegram.capabilities.inlineButtons`: `off | dm | group | all | allowlist` (default: allowlist). +- `channels.telegram.accounts..capabilities.inlineButtons`: per-account override. - `channels.telegram.replyToMode`: `off | first | all` (default: `first`). - `channels.telegram.textChunkLimit`: outbound chunk size (chars). - `channels.telegram.streamMode`: `off | partial | block` (draft streaming). diff --git a/docs/cli/message.md b/docs/cli/message.md index f887602c8..432db4b66 100644 --- a/docs/cli/message.md +++ b/docs/cli/message.md @@ -46,7 +46,7 @@ Target formats (`--to`): - Channels: WhatsApp/Telegram/Discord/Slack/Signal/iMessage/MS Teams - Required: `--to`, plus `--message` or `--media` - Optional: `--media`, `--reply-to`, `--thread-id`, `--gif-playback` - - Telegram only: `--buttons` (requires `"inlineButtons"` in `channels.telegram.capabilities` or `channels.telegram.accounts..capabilities`) + - Telegram only: `--buttons` (requires `channels.telegram.capabilities.inlineButtons` to allow it) - Telegram only: `--thread-id` (forum topic id) - Slack only: `--thread-id` (thread timestamp; `--reply-to` uses the same field) - WhatsApp only: `--gif-playback` diff --git a/src/agents/pi-embedded-runner/compact.ts b/src/agents/pi-embedded-runner/compact.ts index 6d9e11461..7c40517c6 100644 --- a/src/agents/pi-embedded-runner/compact.ts +++ b/src/agents/pi-embedded-runner/compact.ts @@ -8,6 +8,7 @@ import type { ReasoningLevel, ThinkLevel } from "../../auto-reply/thinking.js"; import { resolveChannelCapabilities } from "../../config/channel-capabilities.js"; import type { ClawdbotConfig } from "../../config/config.js"; import { getMachineDisplayName } from "../../infra/machine-name.js"; +import { resolveTelegramInlineButtonsScope } from "../../telegram/inline-buttons.js"; import { type enqueueCommand, enqueueCommandInLane } from "../../process/command-queue.js"; import { normalizeMessageChannel } from "../../utils/message-channel.js"; import { isSubagentSessionKey } from "../../routing/session-key.js"; @@ -210,13 +211,29 @@ export async function compactEmbeddedPiSession(params: { const runtimeChannel = normalizeMessageChannel( params.messageChannel ?? params.messageProvider, ); - const runtimeCapabilities = runtimeChannel + let runtimeCapabilities = runtimeChannel ? (resolveChannelCapabilities({ cfg: params.config, channel: runtimeChannel, accountId: params.agentAccountId, }) ?? []) : undefined; + if (runtimeChannel === "telegram" && params.config) { + const inlineButtonsScope = resolveTelegramInlineButtonsScope({ + cfg: params.config, + accountId: params.agentAccountId ?? undefined, + }); + if (inlineButtonsScope !== "off") { + if (!runtimeCapabilities) runtimeCapabilities = []; + if ( + !runtimeCapabilities.some( + (cap) => String(cap).trim().toLowerCase() === "inlinebuttons", + ) + ) { + runtimeCapabilities.push("inlineButtons"); + } + } + } const runtimeInfo = { host: machineName, os: `${os.type()} ${os.release()}`, diff --git a/src/agents/pi-embedded-runner/run/attempt.ts b/src/agents/pi-embedded-runner/run/attempt.ts index 40c11de2f..b58ceeea4 100644 --- a/src/agents/pi-embedded-runner/run/attempt.ts +++ b/src/agents/pi-embedded-runner/run/attempt.ts @@ -9,6 +9,7 @@ import { createAgentSession, SessionManager, SettingsManager } from "@mariozechn import { resolveHeartbeatPrompt } from "../../../auto-reply/heartbeat.js"; import { resolveChannelCapabilities } from "../../../config/channel-capabilities.js"; import { getMachineDisplayName } from "../../../infra/machine-name.js"; +import { resolveTelegramInlineButtonsScope } from "../../../telegram/inline-buttons.js"; import { resolveTelegramReactionLevel } from "../../../telegram/reaction-level.js"; import { normalizeMessageChannel } from "../../../utils/message-channel.js"; import { isReasoningTagProvider } from "../../../utils/provider-utils.js"; @@ -157,13 +158,27 @@ export async function runEmbeddedAttempt( const machineName = await getMachineDisplayName(); const runtimeChannel = normalizeMessageChannel(params.messageChannel ?? params.messageProvider); - const runtimeCapabilities = runtimeChannel + let runtimeCapabilities = runtimeChannel ? (resolveChannelCapabilities({ cfg: params.config, channel: runtimeChannel, accountId: params.agentAccountId, }) ?? []) : undefined; + if (runtimeChannel === "telegram" && params.config) { + const inlineButtonsScope = resolveTelegramInlineButtonsScope({ + cfg: params.config, + accountId: params.agentAccountId ?? undefined, + }); + if (inlineButtonsScope !== "off") { + if (!runtimeCapabilities) runtimeCapabilities = []; + if ( + !runtimeCapabilities.some((cap) => String(cap).trim().toLowerCase() === "inlinebuttons") + ) { + runtimeCapabilities.push("inlineButtons"); + } + } + } const reactionGuidance = runtimeChannel === "telegram" && params.config ? (() => { diff --git a/src/agents/system-prompt.ts b/src/agents/system-prompt.ts index 544f2e2d5..adeb2ae9c 100644 --- a/src/agents/system-prompt.ts +++ b/src/agents/system-prompt.ts @@ -98,7 +98,7 @@ function buildMessagingSection(params: { params.inlineButtonsEnabled ? "- Inline buttons supported. Use `action=send` with `buttons=[[{text,callback_data}]]` (callback_data routes back as a user message)." : params.runtimeChannel - ? `- Inline buttons not enabled for ${params.runtimeChannel}. If you need them, ask to add "inlineButtons" to ${params.runtimeChannel}.capabilities or ${params.runtimeChannel}.accounts..capabilities.` + ? `- Inline buttons not enabled for ${params.runtimeChannel}. If you need them, ask to set ${params.runtimeChannel}.capabilities.inlineButtons ("dm"|"group"|"all"|"allowlist").` : "", ] .filter(Boolean) diff --git a/src/agents/tools/telegram-actions.test.ts b/src/agents/tools/telegram-actions.test.ts index 1465b932f..1293ab364 100644 --- a/src/agents/tools/telegram-actions.test.ts +++ b/src/agents/tools/telegram-actions.test.ts @@ -328,10 +328,28 @@ describe("handleTelegramAction", () => { ).rejects.toThrow(/Telegram bot token missing/); }); - it("requires inlineButtons capability when buttons are provided", async () => { + it("allows inline buttons by default (allowlist)", async () => { const cfg = { channels: { telegram: { botToken: "tok" } }, } as ClawdbotConfig; + await handleTelegramAction( + { + action: "sendMessage", + to: "@testchannel", + content: "Choose", + buttons: [[{ text: "Ok", callback_data: "cmd:ok" }]], + }, + cfg, + ); + expect(sendMessageTelegram).toHaveBeenCalled(); + }); + + it("blocks inline buttons when scope is off", async () => { + const cfg = { + channels: { + telegram: { botToken: "tok", capabilities: { inlineButtons: "off" } }, + }, + } as ClawdbotConfig; await expect( handleTelegramAction( { @@ -342,13 +360,32 @@ describe("handleTelegramAction", () => { }, cfg, ), - ).rejects.toThrow(/inlineButtons/i); + ).rejects.toThrow(/inline buttons are disabled/i); + }); + + it("blocks inline buttons in groups when scope is dm", async () => { + const cfg = { + channels: { + telegram: { botToken: "tok", capabilities: { inlineButtons: "dm" } }, + }, + } as ClawdbotConfig; + await expect( + handleTelegramAction( + { + action: "sendMessage", + to: "-100123456", + content: "Choose", + buttons: [[{ text: "Ok", callback_data: "cmd:ok" }]], + }, + cfg, + ), + ).rejects.toThrow(/inline buttons are limited to DMs/i); }); it("sends messages with inline keyboard buttons when enabled", async () => { const cfg = { channels: { - telegram: { botToken: "tok", capabilities: ["inlineButtons"] }, + telegram: { botToken: "tok", capabilities: { inlineButtons: "all" } }, }, } as ClawdbotConfig; await handleTelegramAction( diff --git a/src/agents/tools/telegram-actions.ts b/src/agents/tools/telegram-actions.ts index 47d617990..7cec1b1a1 100644 --- a/src/agents/tools/telegram-actions.ts +++ b/src/agents/tools/telegram-actions.ts @@ -1,5 +1,4 @@ import type { AgentToolResult } from "@mariozechner/pi-agent-core"; -import { resolveChannelCapabilities } from "../../config/channel-capabilities.js"; import type { ClawdbotConfig } from "../../config/config.js"; import { resolveTelegramReactionLevel } from "../../telegram/reaction-level.js"; import { @@ -8,6 +7,10 @@ import { sendMessageTelegram, } from "../../telegram/send.js"; import { resolveTelegramToken } from "../../telegram/token.js"; +import { + resolveTelegramInlineButtonsScope, + resolveTelegramTargetChatType, +} from "../../telegram/inline-buttons.js"; import { createActionGate, jsonResult, @@ -22,19 +25,6 @@ type TelegramButton = { callback_data: string; }; -function hasInlineButtonsCapability(params: { - cfg: ClawdbotConfig; - accountId?: string | undefined; -}): boolean { - const caps = - resolveChannelCapabilities({ - cfg: params.cfg, - channel: "telegram", - accountId: params.accountId, - }) ?? []; - return caps.some((cap) => cap.toLowerCase() === "inlinebuttons"); -} - export function readTelegramButtons( params: Record, ): TelegramButton[][] | undefined { @@ -138,10 +128,32 @@ export async function handleTelegramAction( allowEmpty: true, }) ?? ""; const buttons = readTelegramButtons(params); - if (buttons && !hasInlineButtonsCapability({ cfg, accountId: accountId ?? undefined })) { - throw new Error( - 'Telegram inline buttons requested but not enabled. Add "inlineButtons" to channels.telegram.capabilities (or channels.telegram.accounts..capabilities).', - ); + if (buttons) { + const inlineButtonsScope = resolveTelegramInlineButtonsScope({ + cfg, + accountId: accountId ?? undefined, + }); + if (inlineButtonsScope === "off") { + throw new Error( + 'Telegram inline buttons are disabled. Set channels.telegram.capabilities.inlineButtons to "dm", "group", "all", or "allowlist".', + ); + } + if (inlineButtonsScope === "dm" || inlineButtonsScope === "group") { + const targetType = resolveTelegramTargetChatType(to); + if (targetType === "unknown") { + throw new Error( + `Telegram inline buttons require a numeric chat id when inlineButtons="${inlineButtonsScope}".`, + ); + } + if (inlineButtonsScope === "dm" && targetType !== "direct") { + throw new Error('Telegram inline buttons are limited to DMs when inlineButtons="dm".'); + } + if (inlineButtonsScope === "group" && targetType !== "group") { + throw new Error( + 'Telegram inline buttons are limited to groups when inlineButtons="group".', + ); + } + } } // Optional threading parameters for forum topics and reply chains const replyToMessageId = readNumberParam(params, "replyToMessageId", { diff --git a/src/channels/plugins/actions/telegram.ts b/src/channels/plugins/actions/telegram.ts index 0f490ec89..bb65baf0f 100644 --- a/src/channels/plugins/actions/telegram.ts +++ b/src/channels/plugins/actions/telegram.ts @@ -4,32 +4,12 @@ import { readStringParam, } from "../../../agents/tools/common.js"; import { handleTelegramAction } from "../../../agents/tools/telegram-actions.js"; -import type { ClawdbotConfig } from "../../../config/config.js"; import { listEnabledTelegramAccounts } from "../../../telegram/accounts.js"; +import { isTelegramInlineButtonsEnabled } from "../../../telegram/inline-buttons.js"; import type { ChannelMessageActionAdapter, ChannelMessageActionName } from "../types.js"; const providerId = "telegram"; -function hasTelegramInlineButtons(cfg: ClawdbotConfig): boolean { - const caps = new Set(); - for (const entry of cfg.channels?.telegram?.capabilities ?? []) { - const trimmed = String(entry).trim(); - if (trimmed) caps.add(trimmed.toLowerCase()); - } - const accounts = cfg.channels?.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"); -} - export const telegramMessageActions: ChannelMessageActionAdapter = { listActions: ({ cfg }) => { const accounts = listEnabledTelegramAccounts(cfg).filter( @@ -42,7 +22,15 @@ export const telegramMessageActions: ChannelMessageActionAdapter = { if (gate("deleteMessage")) actions.add("delete"); return Array.from(actions); }, - supportsButtons: ({ cfg }) => hasTelegramInlineButtons(cfg), + supportsButtons: ({ cfg }) => { + const accounts = listEnabledTelegramAccounts(cfg).filter( + (account) => account.tokenSource !== "none", + ); + if (accounts.length === 0) return false; + return accounts.some((account) => + isTelegramInlineButtonsEnabled({ cfg, accountId: account.accountId }), + ); + }, extractToolSend: ({ args }) => { const action = typeof args.action === "string" ? args.action.trim() : ""; if (action !== "sendMessage") return null; diff --git a/src/config/schema.ts b/src/config/schema.ts index b65c6807c..923ed4455 100644 --- a/src/config/schema.ts +++ b/src/config/schema.ts @@ -194,6 +194,7 @@ const FIELD_LABELS: Record = { "channels.telegram.retry.maxDelayMs": "Telegram Retry Max Delay (ms)", "channels.telegram.retry.jitter": "Telegram Retry Jitter", "channels.telegram.timeoutSeconds": "Telegram API Timeout (seconds)", + "channels.telegram.capabilities.inlineButtons": "Telegram Inline Buttons", "channels.whatsapp.dmPolicy": "WhatsApp DM Policy", "channels.whatsapp.selfChatMode": "WhatsApp Self-Phone Mode", "channels.whatsapp.debounceMs": "WhatsApp Message Debounce (ms)", diff --git a/src/config/types.telegram.ts b/src/config/types.telegram.ts index 69f273706..3533d6d4f 100644 --- a/src/config/types.telegram.ts +++ b/src/config/types.telegram.ts @@ -14,6 +14,14 @@ export type TelegramActionConfig = { deleteMessage?: boolean; }; +export type TelegramInlineButtonsScope = "off" | "dm" | "group" | "all" | "allowlist"; + +export type TelegramCapabilitiesConfig = + | string[] + | { + inlineButtons?: TelegramInlineButtonsScope; + }; + /** Custom command definition for Telegram bot menu. */ export type TelegramCustomCommand = { /** Command name (without leading /). */ @@ -26,7 +34,7 @@ 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[]; + capabilities?: TelegramCapabilitiesConfig; /** Override native command registration for Telegram (bool or "auto"). */ commands?: ProviderCommandsConfig; /** Custom commands to register in Telegram's command menu (merged with native). */ diff --git a/src/config/zod-schema.providers-core.ts b/src/config/zod-schema.providers-core.ts index f61e425d6..fc97e1b24 100644 --- a/src/config/zod-schema.providers-core.ts +++ b/src/config/zod-schema.providers-core.ts @@ -19,6 +19,15 @@ import { resolveTelegramCustomCommands, } from "./telegram-custom-commands.js"; +const TelegramInlineButtonsScopeSchema = z.enum(["off", "dm", "group", "all", "allowlist"]); + +const TelegramCapabilitiesSchema = z.union([ + z.array(z.string()), + z.object({ + inlineButtons: TelegramInlineButtonsScopeSchema.optional(), + }), +]); + export const TelegramTopicSchema = z.object({ requireMention: z.boolean().optional(), skills: z.array(z.string()).optional(), @@ -62,7 +71,7 @@ const validateTelegramCustomCommands = ( export const TelegramAccountSchemaBase = z.object({ name: z.string().optional(), - capabilities: z.array(z.string()).optional(), + capabilities: TelegramCapabilitiesSchema.optional(), enabled: z.boolean().optional(), commands: ProviderCommandsSchema, customCommands: z.array(TelegramCustomCommandSchema).optional(), diff --git a/src/telegram/bot-handlers.ts b/src/telegram/bot-handlers.ts index 3e2c421a1..26712ec61 100644 --- a/src/telegram/bot-handlers.ts +++ b/src/telegram/bot-handlers.ts @@ -13,6 +13,7 @@ import type { TelegramMessage } from "./bot/types.js"; import { firstDefined, isSenderAllowed, normalizeAllowFrom } from "./bot-access.js"; import { MEDIA_GROUP_TIMEOUT_MS, type MediaGroupEntry } from "./bot-updates.js"; import { migrateTelegramGroupConfig } from "./group-migration.js"; +import { resolveTelegramInlineButtonsScope } from "./inline-buttons.js"; import { readTelegramAllowFromStore } from "./pairing-store.js"; import { resolveChannelConfigWrites } from "../channels/plugins/config-writes.js"; @@ -183,6 +184,131 @@ export const registerTelegramHandlers = ({ const callbackMessage = callback.message; if (!data || !callbackMessage) return; + const inlineButtonsScope = resolveTelegramInlineButtonsScope({ + cfg, + accountId, + }); + if (inlineButtonsScope === "off") return; + + const chatId = callbackMessage.chat.id; + const isGroup = + callbackMessage.chat.type === "group" || callbackMessage.chat.type === "supergroup"; + if (inlineButtonsScope === "dm" && isGroup) return; + if (inlineButtonsScope === "group" && !isGroup) return; + + const messageThreadId = (callbackMessage as { message_thread_id?: number }).message_thread_id; + const isForum = (callbackMessage.chat as { is_forum?: boolean }).is_forum === true; + const resolvedThreadId = resolveTelegramForumThreadId({ + isForum, + messageThreadId, + }); + const { groupConfig, topicConfig } = resolveTelegramGroupConfig(chatId, resolvedThreadId); + const storeAllowFrom = await readTelegramAllowFromStore().catch(() => []); + const groupAllowOverride = firstDefined(topicConfig?.allowFrom, groupConfig?.allowFrom); + const effectiveGroupAllow = normalizeAllowFrom([ + ...(groupAllowOverride ?? groupAllowFrom ?? []), + ...storeAllowFrom, + ]); + const effectiveDmAllow = normalizeAllowFrom([ + ...(telegramCfg.allowFrom ?? []), + ...storeAllowFrom, + ]); + const dmPolicy = telegramCfg.dmPolicy ?? "pairing"; + const senderId = callback.from?.id ? String(callback.from.id) : ""; + const senderUsername = callback.from?.username ?? ""; + + if (isGroup) { + if (groupConfig?.enabled === false) { + logVerbose(`Blocked telegram group ${chatId} (group disabled)`); + return; + } + if (topicConfig?.enabled === false) { + logVerbose( + `Blocked telegram topic ${chatId} (${resolvedThreadId ?? "unknown"}) (topic disabled)`, + ); + return; + } + if (typeof groupAllowOverride !== "undefined") { + const allowed = + senderId && + isSenderAllowed({ + allow: effectiveGroupAllow, + senderId, + senderUsername, + }); + if (!allowed) { + logVerbose( + `Blocked telegram group sender ${senderId || "unknown"} (group allowFrom override)`, + ); + return; + } + } + const groupPolicy = telegramCfg.groupPolicy ?? "open"; + if (groupPolicy === "disabled") { + logVerbose(`Blocked telegram group message (groupPolicy: disabled)`); + return; + } + if (groupPolicy === "allowlist") { + if (!senderId) { + logVerbose(`Blocked telegram group message (no sender ID, groupPolicy: allowlist)`); + return; + } + if (!effectiveGroupAllow.hasEntries) { + logVerbose( + "Blocked telegram group message (groupPolicy: allowlist, no group allowlist entries)", + ); + return; + } + if ( + !isSenderAllowed({ + allow: effectiveGroupAllow, + senderId, + senderUsername, + }) + ) { + logVerbose( + `Blocked telegram group message from ${senderId} (groupPolicy: allowlist)`, + ); + return; + } + } + const groupAllowlist = resolveGroupPolicy(chatId); + if (groupAllowlist.allowlistEnabled && !groupAllowlist.allowed) { + logger.info( + { chatId, title: callbackMessage.chat.title, reason: "not-allowed" }, + "skipping group message", + ); + return; + } + } + + if (inlineButtonsScope === "allowlist") { + if (!isGroup) { + if (dmPolicy === "disabled") return; + if (dmPolicy !== "open") { + const allowed = + effectiveDmAllow.hasWildcard || + (effectiveDmAllow.hasEntries && + isSenderAllowed({ + allow: effectiveDmAllow, + senderId, + senderUsername, + })); + if (!allowed) return; + } + } else { + const allowed = + effectiveGroupAllow.hasWildcard || + (effectiveGroupAllow.hasEntries && + isSenderAllowed({ + allow: effectiveGroupAllow, + senderId, + senderUsername, + })); + if (!allowed) return; + } + } + const syntheticMessage: TelegramMessage = { ...callbackMessage, from: callback.from, @@ -191,7 +317,6 @@ export const registerTelegramHandlers = ({ 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, diff --git a/src/telegram/bot.test.ts b/src/telegram/bot.test.ts index bf72c800b..67a9448c0 100644 --- a/src/telegram/bot.test.ts +++ b/src/telegram/bot.test.ts @@ -382,6 +382,47 @@ describe("createTelegramBot", () => { expect(answerCallbackQuerySpy).toHaveBeenCalledWith("cbq-1"); }); + it("blocks callback_query when inline buttons are allowlist-only and sender not authorized", async () => { + onSpy.mockReset(); + const replySpy = replyModule.__replySpy as unknown as ReturnType; + replySpy.mockReset(); + + createTelegramBot({ + token: "tok", + config: { + channels: { + telegram: { + dmPolicy: "pairing", + capabilities: { inlineButtons: "allowlist" }, + allowFrom: [], + }, + }, + }, + }); + const callbackHandler = onSpy.mock.calls.find((call) => call[0] === "callback_query")?.[1] as ( + ctx: Record, + ) => Promise; + expect(callbackHandler).toBeDefined(); + + await callbackHandler({ + callbackQuery: { + id: "cbq-2", + data: "cmd:option_b", + from: { id: 9, first_name: "Ada", username: "ada_bot" }, + message: { + chat: { id: 1234, type: "private" }, + date: 1736380800, + message_id: 11, + }, + }, + me: { username: "clawdbot_bot" }, + getFile: async () => ({ download: async () => new Uint8Array() }), + }); + + expect(replySpy).not.toHaveBeenCalled(); + expect(answerCallbackQuerySpy).toHaveBeenCalledWith("cbq-2"); + }); + it("wraps inbound message with Telegram envelope", async () => { const originalTz = process.env.TZ; process.env.TZ = "Europe/Vienna"; diff --git a/src/telegram/inline-buttons.ts b/src/telegram/inline-buttons.ts new file mode 100644 index 000000000..f73eed6af --- /dev/null +++ b/src/telegram/inline-buttons.ts @@ -0,0 +1,73 @@ +import type { ClawdbotConfig } from "../config/config.js"; +import type { TelegramInlineButtonsScope } from "../config/types.telegram.js"; +import { listTelegramAccountIds, resolveTelegramAccount } from "./accounts.js"; + +const DEFAULT_INLINE_BUTTONS_SCOPE: TelegramInlineButtonsScope = "allowlist"; + +function normalizeInlineButtonsScope(value: unknown): TelegramInlineButtonsScope | undefined { + if (typeof value !== "string") return undefined; + const trimmed = value.trim().toLowerCase(); + if ( + trimmed === "off" || + trimmed === "dm" || + trimmed === "group" || + trimmed === "all" || + trimmed === "allowlist" + ) { + return trimmed as TelegramInlineButtonsScope; + } + return undefined; +} + +function resolveInlineButtonsScopeFromCapabilities( + capabilities: unknown, +): TelegramInlineButtonsScope { + if (!capabilities) return DEFAULT_INLINE_BUTTONS_SCOPE; + if (Array.isArray(capabilities)) { + const enabled = capabilities.some( + (entry) => String(entry).trim().toLowerCase() === "inlinebuttons", + ); + return enabled ? "all" : "off"; + } + if (typeof capabilities === "object") { + const inlineButtons = (capabilities as { inlineButtons?: unknown }).inlineButtons; + return normalizeInlineButtonsScope(inlineButtons) ?? DEFAULT_INLINE_BUTTONS_SCOPE; + } + return DEFAULT_INLINE_BUTTONS_SCOPE; +} + +export function resolveTelegramInlineButtonsScope(params: { + cfg: ClawdbotConfig; + accountId?: string | null; +}): TelegramInlineButtonsScope { + const account = resolveTelegramAccount({ cfg: params.cfg, accountId: params.accountId }); + return resolveInlineButtonsScopeFromCapabilities(account.config.capabilities); +} + +export function isTelegramInlineButtonsEnabled(params: { + cfg: ClawdbotConfig; + accountId?: string | null; +}): boolean { + if (params.accountId) { + return resolveTelegramInlineButtonsScope(params) !== "off"; + } + const accountIds = listTelegramAccountIds(params.cfg); + if (accountIds.length === 0) { + return resolveTelegramInlineButtonsScope(params) !== "off"; + } + return accountIds.some( + (accountId) => + resolveTelegramInlineButtonsScope({ cfg: params.cfg, accountId }) !== "off", + ); +} + +export function resolveTelegramTargetChatType( + target: string, +): "direct" | "group" | "unknown" { + const trimmed = target.trim(); + if (!trimmed) return "unknown"; + if (/^-?\d+$/.test(trimmed)) { + return trimmed.startsWith("-") ? "group" : "direct"; + } + return "unknown"; +}