feat: scope telegram inline buttons

This commit is contained in:
Peter Steinberger
2026-01-16 20:16:35 +00:00
parent 3431d3d115
commit 69761e8a51
15 changed files with 400 additions and 59 deletions

View File

@@ -15,6 +15,7 @@
### Changes ### Changes
- CLI: set process titles to `clawdbot-<command>` for clearer process listings. - CLI: set process titles to `clawdbot-<command>` for clearer process listings.
- CLI/macOS: sync remote SSH target/identity to config and let `gateway status` auto-infer SSH targets (ssh-config aware). - 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. - Heartbeat: tighten prompt guidance + suppress duplicate alerts for 24h. (#980) — thanks @voidserf.
- Repo: ignore local identity files to avoid accidental commits. (#1001) — thanks @gerardward2007. - 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. - Sessions/Security: add `session.dmScope` for multi-user DM isolation and audit warnings. (#948) — thanks @Alphonse-arianee.

View File

@@ -219,13 +219,15 @@ Private chats can include `message_thread_id` in some edge cases. Clawdbot keeps
## Inline Buttons ## Inline Buttons
Telegram supports inline keyboards with callback buttons. Enable this feature via capabilities: Telegram supports inline keyboards with callback buttons.
```json5 ```json5
{ {
"channels": { "channels": {
"telegram": { "telegram": {
"capabilities": ["inlineButtons"] "capabilities": {
"inlineButtons": "allowlist"
}
} }
} }
} }
@@ -238,7 +240,9 @@ For per-account configuration:
"telegram": { "telegram": {
"accounts": { "accounts": {
"main": { "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 ### Sending buttons
Use the message tool with the `buttons` parameter: 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 ### 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.capabilities`: Global default capability config applied to all Telegram accounts unless overridden.
- `channels.telegram.accounts.<account>.capabilities`: Per-account capabilities that override or extend the global defaults for that specific account. - `channels.telegram.accounts.<account>.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) ## Access control (DMs + groups)
### DM access ### DM access
@@ -477,8 +491,8 @@ Provider options:
- `channels.telegram.groups.<id>.enabled`: disable the group when `false`. - `channels.telegram.groups.<id>.enabled`: disable the group when `false`.
- `channels.telegram.groups.<id>.topics.<threadId>.*`: per-topic overrides (same fields as group). - `channels.telegram.groups.<id>.topics.<threadId>.*`: per-topic overrides (same fields as group).
- `channels.telegram.groups.<id>.topics.<threadId>.requireMention`: per-topic mention gating override. - `channels.telegram.groups.<id>.topics.<threadId>.requireMention`: per-topic mention gating override.
- `channels.telegram.capabilities`: Enable channel features (e.g., "inlineButtons"). - `channels.telegram.capabilities.inlineButtons`: `off | dm | group | all | allowlist` (default: allowlist).
- `channels.telegram.accounts.<account>.capabilities`: Per-account capabilities. - `channels.telegram.accounts.<account>.capabilities.inlineButtons`: per-account override.
- `channels.telegram.replyToMode`: `off | first | all` (default: `first`). - `channels.telegram.replyToMode`: `off | first | all` (default: `first`).
- `channels.telegram.textChunkLimit`: outbound chunk size (chars). - `channels.telegram.textChunkLimit`: outbound chunk size (chars).
- `channels.telegram.streamMode`: `off | partial | block` (draft streaming). - `channels.telegram.streamMode`: `off | partial | block` (draft streaming).

View File

@@ -46,7 +46,7 @@ Target formats (`--to`):
- Channels: WhatsApp/Telegram/Discord/Slack/Signal/iMessage/MS Teams - Channels: WhatsApp/Telegram/Discord/Slack/Signal/iMessage/MS Teams
- Required: `--to`, plus `--message` or `--media` - Required: `--to`, plus `--message` or `--media`
- Optional: `--media`, `--reply-to`, `--thread-id`, `--gif-playback` - Optional: `--media`, `--reply-to`, `--thread-id`, `--gif-playback`
- Telegram only: `--buttons` (requires `"inlineButtons"` in `channels.telegram.capabilities` or `channels.telegram.accounts.<id>.capabilities`) - Telegram only: `--buttons` (requires `channels.telegram.capabilities.inlineButtons` to allow it)
- Telegram only: `--thread-id` (forum topic id) - Telegram only: `--thread-id` (forum topic id)
- Slack only: `--thread-id` (thread timestamp; `--reply-to` uses the same field) - Slack only: `--thread-id` (thread timestamp; `--reply-to` uses the same field)
- WhatsApp only: `--gif-playback` - WhatsApp only: `--gif-playback`

View File

@@ -8,6 +8,7 @@ import type { ReasoningLevel, ThinkLevel } from "../../auto-reply/thinking.js";
import { resolveChannelCapabilities } from "../../config/channel-capabilities.js"; import { resolveChannelCapabilities } from "../../config/channel-capabilities.js";
import type { ClawdbotConfig } from "../../config/config.js"; import type { ClawdbotConfig } from "../../config/config.js";
import { getMachineDisplayName } from "../../infra/machine-name.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 { type enqueueCommand, enqueueCommandInLane } from "../../process/command-queue.js";
import { normalizeMessageChannel } from "../../utils/message-channel.js"; import { normalizeMessageChannel } from "../../utils/message-channel.js";
import { isSubagentSessionKey } from "../../routing/session-key.js"; import { isSubagentSessionKey } from "../../routing/session-key.js";
@@ -210,13 +211,29 @@ export async function compactEmbeddedPiSession(params: {
const runtimeChannel = normalizeMessageChannel( const runtimeChannel = normalizeMessageChannel(
params.messageChannel ?? params.messageProvider, params.messageChannel ?? params.messageProvider,
); );
const runtimeCapabilities = runtimeChannel let runtimeCapabilities = runtimeChannel
? (resolveChannelCapabilities({ ? (resolveChannelCapabilities({
cfg: params.config, cfg: params.config,
channel: runtimeChannel, channel: runtimeChannel,
accountId: params.agentAccountId, accountId: params.agentAccountId,
}) ?? []) }) ?? [])
: undefined; : 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 = { const runtimeInfo = {
host: machineName, host: machineName,
os: `${os.type()} ${os.release()}`, os: `${os.type()} ${os.release()}`,

View File

@@ -9,6 +9,7 @@ import { createAgentSession, SessionManager, SettingsManager } from "@mariozechn
import { resolveHeartbeatPrompt } from "../../../auto-reply/heartbeat.js"; import { resolveHeartbeatPrompt } from "../../../auto-reply/heartbeat.js";
import { resolveChannelCapabilities } from "../../../config/channel-capabilities.js"; import { resolveChannelCapabilities } from "../../../config/channel-capabilities.js";
import { getMachineDisplayName } from "../../../infra/machine-name.js"; import { getMachineDisplayName } from "../../../infra/machine-name.js";
import { resolveTelegramInlineButtonsScope } from "../../../telegram/inline-buttons.js";
import { resolveTelegramReactionLevel } from "../../../telegram/reaction-level.js"; import { resolveTelegramReactionLevel } from "../../../telegram/reaction-level.js";
import { normalizeMessageChannel } from "../../../utils/message-channel.js"; import { normalizeMessageChannel } from "../../../utils/message-channel.js";
import { isReasoningTagProvider } from "../../../utils/provider-utils.js"; import { isReasoningTagProvider } from "../../../utils/provider-utils.js";
@@ -157,13 +158,27 @@ export async function runEmbeddedAttempt(
const machineName = await getMachineDisplayName(); const machineName = await getMachineDisplayName();
const runtimeChannel = normalizeMessageChannel(params.messageChannel ?? params.messageProvider); const runtimeChannel = normalizeMessageChannel(params.messageChannel ?? params.messageProvider);
const runtimeCapabilities = runtimeChannel let runtimeCapabilities = runtimeChannel
? (resolveChannelCapabilities({ ? (resolveChannelCapabilities({
cfg: params.config, cfg: params.config,
channel: runtimeChannel, channel: runtimeChannel,
accountId: params.agentAccountId, accountId: params.agentAccountId,
}) ?? []) }) ?? [])
: undefined; : 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 = const reactionGuidance =
runtimeChannel === "telegram" && params.config runtimeChannel === "telegram" && params.config
? (() => { ? (() => {

View File

@@ -98,7 +98,7 @@ function buildMessagingSection(params: {
params.inlineButtonsEnabled params.inlineButtonsEnabled
? "- Inline buttons supported. Use `action=send` with `buttons=[[{text,callback_data}]]` (callback_data routes back as a user message)." ? "- Inline buttons supported. Use `action=send` with `buttons=[[{text,callback_data}]]` (callback_data routes back as a user message)."
: params.runtimeChannel : 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.<id>.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) .filter(Boolean)

View File

@@ -328,10 +328,28 @@ describe("handleTelegramAction", () => {
).rejects.toThrow(/Telegram bot token missing/); ).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 = { const cfg = {
channels: { telegram: { botToken: "tok" } }, channels: { telegram: { botToken: "tok" } },
} as ClawdbotConfig; } 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( await expect(
handleTelegramAction( handleTelegramAction(
{ {
@@ -342,13 +360,32 @@ describe("handleTelegramAction", () => {
}, },
cfg, 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 () => { it("sends messages with inline keyboard buttons when enabled", async () => {
const cfg = { const cfg = {
channels: { channels: {
telegram: { botToken: "tok", capabilities: ["inlineButtons"] }, telegram: { botToken: "tok", capabilities: { inlineButtons: "all" } },
}, },
} as ClawdbotConfig; } as ClawdbotConfig;
await handleTelegramAction( await handleTelegramAction(

View File

@@ -1,5 +1,4 @@
import type { AgentToolResult } from "@mariozechner/pi-agent-core"; import type { AgentToolResult } from "@mariozechner/pi-agent-core";
import { resolveChannelCapabilities } from "../../config/channel-capabilities.js";
import type { ClawdbotConfig } from "../../config/config.js"; import type { ClawdbotConfig } from "../../config/config.js";
import { resolveTelegramReactionLevel } from "../../telegram/reaction-level.js"; import { resolveTelegramReactionLevel } from "../../telegram/reaction-level.js";
import { import {
@@ -8,6 +7,10 @@ import {
sendMessageTelegram, sendMessageTelegram,
} from "../../telegram/send.js"; } from "../../telegram/send.js";
import { resolveTelegramToken } from "../../telegram/token.js"; import { resolveTelegramToken } from "../../telegram/token.js";
import {
resolveTelegramInlineButtonsScope,
resolveTelegramTargetChatType,
} from "../../telegram/inline-buttons.js";
import { import {
createActionGate, createActionGate,
jsonResult, jsonResult,
@@ -22,19 +25,6 @@ type TelegramButton = {
callback_data: string; 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( export function readTelegramButtons(
params: Record<string, unknown>, params: Record<string, unknown>,
): TelegramButton[][] | undefined { ): TelegramButton[][] | undefined {
@@ -138,10 +128,32 @@ export async function handleTelegramAction(
allowEmpty: true, allowEmpty: true,
}) ?? ""; }) ?? "";
const buttons = readTelegramButtons(params); const buttons = readTelegramButtons(params);
if (buttons && !hasInlineButtonsCapability({ cfg, accountId: accountId ?? undefined })) { if (buttons) {
throw new Error( const inlineButtonsScope = resolveTelegramInlineButtonsScope({
'Telegram inline buttons requested but not enabled. Add "inlineButtons" to channels.telegram.capabilities (or channels.telegram.accounts.<id>.capabilities).', 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 // Optional threading parameters for forum topics and reply chains
const replyToMessageId = readNumberParam(params, "replyToMessageId", { const replyToMessageId = readNumberParam(params, "replyToMessageId", {

View File

@@ -4,32 +4,12 @@ import {
readStringParam, readStringParam,
} from "../../../agents/tools/common.js"; } from "../../../agents/tools/common.js";
import { handleTelegramAction } from "../../../agents/tools/telegram-actions.js"; import { handleTelegramAction } from "../../../agents/tools/telegram-actions.js";
import type { ClawdbotConfig } from "../../../config/config.js";
import { listEnabledTelegramAccounts } from "../../../telegram/accounts.js"; import { listEnabledTelegramAccounts } from "../../../telegram/accounts.js";
import { isTelegramInlineButtonsEnabled } from "../../../telegram/inline-buttons.js";
import type { ChannelMessageActionAdapter, ChannelMessageActionName } from "../types.js"; import type { ChannelMessageActionAdapter, ChannelMessageActionName } from "../types.js";
const providerId = "telegram"; const providerId = "telegram";
function hasTelegramInlineButtons(cfg: ClawdbotConfig): boolean {
const caps = new Set<string>();
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 = { export const telegramMessageActions: ChannelMessageActionAdapter = {
listActions: ({ cfg }) => { listActions: ({ cfg }) => {
const accounts = listEnabledTelegramAccounts(cfg).filter( const accounts = listEnabledTelegramAccounts(cfg).filter(
@@ -42,7 +22,15 @@ export const telegramMessageActions: ChannelMessageActionAdapter = {
if (gate("deleteMessage")) actions.add("delete"); if (gate("deleteMessage")) actions.add("delete");
return Array.from(actions); 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 }) => { extractToolSend: ({ args }) => {
const action = typeof args.action === "string" ? args.action.trim() : ""; const action = typeof args.action === "string" ? args.action.trim() : "";
if (action !== "sendMessage") return null; if (action !== "sendMessage") return null;

View File

@@ -194,6 +194,7 @@ const FIELD_LABELS: Record<string, string> = {
"channels.telegram.retry.maxDelayMs": "Telegram Retry Max Delay (ms)", "channels.telegram.retry.maxDelayMs": "Telegram Retry Max Delay (ms)",
"channels.telegram.retry.jitter": "Telegram Retry Jitter", "channels.telegram.retry.jitter": "Telegram Retry Jitter",
"channels.telegram.timeoutSeconds": "Telegram API Timeout (seconds)", "channels.telegram.timeoutSeconds": "Telegram API Timeout (seconds)",
"channels.telegram.capabilities.inlineButtons": "Telegram Inline Buttons",
"channels.whatsapp.dmPolicy": "WhatsApp DM Policy", "channels.whatsapp.dmPolicy": "WhatsApp DM Policy",
"channels.whatsapp.selfChatMode": "WhatsApp Self-Phone Mode", "channels.whatsapp.selfChatMode": "WhatsApp Self-Phone Mode",
"channels.whatsapp.debounceMs": "WhatsApp Message Debounce (ms)", "channels.whatsapp.debounceMs": "WhatsApp Message Debounce (ms)",

View File

@@ -14,6 +14,14 @@ export type TelegramActionConfig = {
deleteMessage?: boolean; deleteMessage?: boolean;
}; };
export type TelegramInlineButtonsScope = "off" | "dm" | "group" | "all" | "allowlist";
export type TelegramCapabilitiesConfig =
| string[]
| {
inlineButtons?: TelegramInlineButtonsScope;
};
/** Custom command definition for Telegram bot menu. */ /** Custom command definition for Telegram bot menu. */
export type TelegramCustomCommand = { export type TelegramCustomCommand = {
/** Command name (without leading /). */ /** Command name (without leading /). */
@@ -26,7 +34,7 @@ export type TelegramAccountConfig = {
/** Optional display name for this account (used in CLI/UI lists). */ /** Optional display name for this account (used in CLI/UI lists). */
name?: string; name?: string;
/** Optional provider capability tags used for agent/runtime guidance. */ /** Optional provider capability tags used for agent/runtime guidance. */
capabilities?: string[]; capabilities?: TelegramCapabilitiesConfig;
/** Override native command registration for Telegram (bool or "auto"). */ /** Override native command registration for Telegram (bool or "auto"). */
commands?: ProviderCommandsConfig; commands?: ProviderCommandsConfig;
/** Custom commands to register in Telegram's command menu (merged with native). */ /** Custom commands to register in Telegram's command menu (merged with native). */

View File

@@ -19,6 +19,15 @@ import {
resolveTelegramCustomCommands, resolveTelegramCustomCommands,
} from "./telegram-custom-commands.js"; } 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({ export const TelegramTopicSchema = z.object({
requireMention: z.boolean().optional(), requireMention: z.boolean().optional(),
skills: z.array(z.string()).optional(), skills: z.array(z.string()).optional(),
@@ -62,7 +71,7 @@ const validateTelegramCustomCommands = (
export const TelegramAccountSchemaBase = z.object({ export const TelegramAccountSchemaBase = z.object({
name: z.string().optional(), name: z.string().optional(),
capabilities: z.array(z.string()).optional(), capabilities: TelegramCapabilitiesSchema.optional(),
enabled: z.boolean().optional(), enabled: z.boolean().optional(),
commands: ProviderCommandsSchema, commands: ProviderCommandsSchema,
customCommands: z.array(TelegramCustomCommandSchema).optional(), customCommands: z.array(TelegramCustomCommandSchema).optional(),

View File

@@ -13,6 +13,7 @@ import type { TelegramMessage } from "./bot/types.js";
import { firstDefined, isSenderAllowed, normalizeAllowFrom } from "./bot-access.js"; import { firstDefined, isSenderAllowed, normalizeAllowFrom } from "./bot-access.js";
import { MEDIA_GROUP_TIMEOUT_MS, type MediaGroupEntry } from "./bot-updates.js"; import { MEDIA_GROUP_TIMEOUT_MS, type MediaGroupEntry } from "./bot-updates.js";
import { migrateTelegramGroupConfig } from "./group-migration.js"; import { migrateTelegramGroupConfig } from "./group-migration.js";
import { resolveTelegramInlineButtonsScope } from "./inline-buttons.js";
import { readTelegramAllowFromStore } from "./pairing-store.js"; import { readTelegramAllowFromStore } from "./pairing-store.js";
import { resolveChannelConfigWrites } from "../channels/plugins/config-writes.js"; import { resolveChannelConfigWrites } from "../channels/plugins/config-writes.js";
@@ -183,6 +184,131 @@ export const registerTelegramHandlers = ({
const callbackMessage = callback.message; const callbackMessage = callback.message;
if (!data || !callbackMessage) return; 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 = { const syntheticMessage: TelegramMessage = {
...callbackMessage, ...callbackMessage,
from: callback.from, from: callback.from,
@@ -191,7 +317,6 @@ export const registerTelegramHandlers = ({
caption_entities: undefined, caption_entities: undefined,
entities: undefined, entities: undefined,
}; };
const storeAllowFrom = await readTelegramAllowFromStore().catch(() => []);
const getFile = typeof ctx.getFile === "function" ? ctx.getFile.bind(ctx) : async () => ({}); const getFile = typeof ctx.getFile === "function" ? ctx.getFile.bind(ctx) : async () => ({});
await processMessage({ message: syntheticMessage, me: ctx.me, getFile }, [], storeAllowFrom, { await processMessage({ message: syntheticMessage, me: ctx.me, getFile }, [], storeAllowFrom, {
forceWasMentioned: true, forceWasMentioned: true,

View File

@@ -382,6 +382,47 @@ describe("createTelegramBot", () => {
expect(answerCallbackQuerySpy).toHaveBeenCalledWith("cbq-1"); 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<typeof vi.fn>;
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<string, unknown>,
) => Promise<void>;
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 () => { it("wraps inbound message with Telegram envelope", async () => {
const originalTz = process.env.TZ; const originalTz = process.env.TZ;
process.env.TZ = "Europe/Vienna"; process.env.TZ = "Europe/Vienna";

View File

@@ -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";
}