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", {