mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-08 04:11:22 +00:00
feat(telegram): inline keyboard buttons (#491)
Co-authored-by: Azade <azade@hey.com>
This commit is contained in:
@@ -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";
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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.<id>.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)
|
||||
|
||||
@@ -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<string>();
|
||||
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<string>(["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<string, unknown>;
|
||||
const baseProps = (base.properties ?? {}) as Record<string, unknown>;
|
||||
const props: Record<string, unknown> = {
|
||||
...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<string, unknown>;
|
||||
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,
|
||||
);
|
||||
|
||||
@@ -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" }]]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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<string, unknown>,
|
||||
): 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<string, unknown>,
|
||||
cfg: ClawdbotConfig,
|
||||
): Promise<AgentToolResult<unknown>> {
|
||||
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.<id>.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,
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user