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

@@ -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()}`,

View File

@@ -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
? (() => {

View File

@@ -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.<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)

View File

@@ -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(

View File

@@ -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<string, unknown>,
): 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.<id>.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", {

View File

@@ -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<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 = {
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;

View File

@@ -194,6 +194,7 @@ const FIELD_LABELS: Record<string, string> = {
"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)",

View File

@@ -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). */

View File

@@ -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(),

View File

@@ -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,

View File

@@ -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<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 () => {
const originalTz = process.env.TZ;
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";
}