mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-05 06:00:23 +00:00
Telegram: move action runtime into extension
This commit is contained in:
@@ -1,4 +1,5 @@
|
||||
export * from "./src/audit.js";
|
||||
export * from "./src/action-runtime.js";
|
||||
export * from "./src/channel-actions.js";
|
||||
export * from "./src/monitor.js";
|
||||
export * from "./src/probe.js";
|
||||
|
||||
913
extensions/telegram/src/action-runtime.test.ts
Normal file
913
extensions/telegram/src/action-runtime.test.ts
Normal file
@@ -0,0 +1,913 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import type { OpenClawConfig } from "../../../src/config/config.js";
|
||||
import { captureEnv } from "../../../test/helpers/extensions/env.js";
|
||||
import {
|
||||
handleTelegramAction,
|
||||
readTelegramButtons,
|
||||
telegramActionRuntime,
|
||||
} from "./action-runtime.js";
|
||||
|
||||
const originalTelegramActionRuntime = { ...telegramActionRuntime };
|
||||
const reactMessageTelegram = vi.fn(async () => ({ ok: true }));
|
||||
const sendMessageTelegram = vi.fn(async () => ({
|
||||
messageId: "789",
|
||||
chatId: "123",
|
||||
}));
|
||||
const sendPollTelegram = vi.fn(async () => ({
|
||||
messageId: "790",
|
||||
chatId: "123",
|
||||
pollId: "poll-1",
|
||||
}));
|
||||
const sendStickerTelegram = vi.fn(async () => ({
|
||||
messageId: "456",
|
||||
chatId: "123",
|
||||
}));
|
||||
const deleteMessageTelegram = vi.fn(async () => ({ ok: true }));
|
||||
const editMessageTelegram = vi.fn(async () => ({
|
||||
ok: true,
|
||||
messageId: "456",
|
||||
chatId: "123",
|
||||
}));
|
||||
const editForumTopicTelegram = vi.fn(async () => ({
|
||||
ok: true,
|
||||
chatId: "123",
|
||||
messageThreadId: 42,
|
||||
name: "Renamed",
|
||||
}));
|
||||
const createForumTopicTelegram = vi.fn(async () => ({
|
||||
topicId: 99,
|
||||
name: "Topic",
|
||||
chatId: "123",
|
||||
}));
|
||||
let envSnapshot: ReturnType<typeof captureEnv>;
|
||||
|
||||
describe("handleTelegramAction", () => {
|
||||
const defaultReactionAction = {
|
||||
action: "react",
|
||||
chatId: "123",
|
||||
messageId: "456",
|
||||
emoji: "✅",
|
||||
} as const;
|
||||
|
||||
function reactionConfig(reactionLevel: "minimal" | "extensive" | "off" | "ack"): OpenClawConfig {
|
||||
return {
|
||||
channels: { telegram: { botToken: "tok", reactionLevel } },
|
||||
} as OpenClawConfig;
|
||||
}
|
||||
|
||||
function telegramConfig(overrides?: Record<string, unknown>): OpenClawConfig {
|
||||
return {
|
||||
channels: {
|
||||
telegram: {
|
||||
botToken: "tok",
|
||||
...overrides,
|
||||
},
|
||||
},
|
||||
} as OpenClawConfig;
|
||||
}
|
||||
|
||||
async function sendInlineButtonsMessage(params: {
|
||||
to: string;
|
||||
buttons: Array<Array<{ text: string; callback_data: string; style?: string }>>;
|
||||
inlineButtons: "dm" | "group" | "all";
|
||||
}) {
|
||||
await handleTelegramAction(
|
||||
{
|
||||
action: "sendMessage",
|
||||
to: params.to,
|
||||
content: "Choose",
|
||||
buttons: params.buttons,
|
||||
},
|
||||
telegramConfig({ capabilities: { inlineButtons: params.inlineButtons } }),
|
||||
);
|
||||
}
|
||||
|
||||
async function expectReactionAdded(reactionLevel: "minimal" | "extensive") {
|
||||
await handleTelegramAction(defaultReactionAction, reactionConfig(reactionLevel));
|
||||
expect(reactMessageTelegram).toHaveBeenCalledWith(
|
||||
"123",
|
||||
456,
|
||||
"✅",
|
||||
expect.objectContaining({ token: "tok", remove: false }),
|
||||
);
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
envSnapshot = captureEnv(["TELEGRAM_BOT_TOKEN"]);
|
||||
Object.assign(telegramActionRuntime, originalTelegramActionRuntime, {
|
||||
reactMessageTelegram,
|
||||
sendMessageTelegram,
|
||||
sendPollTelegram,
|
||||
sendStickerTelegram,
|
||||
deleteMessageTelegram,
|
||||
editMessageTelegram,
|
||||
editForumTopicTelegram,
|
||||
createForumTopicTelegram,
|
||||
});
|
||||
reactMessageTelegram.mockClear();
|
||||
sendMessageTelegram.mockClear();
|
||||
sendPollTelegram.mockClear();
|
||||
sendStickerTelegram.mockClear();
|
||||
deleteMessageTelegram.mockClear();
|
||||
editMessageTelegram.mockClear();
|
||||
editForumTopicTelegram.mockClear();
|
||||
createForumTopicTelegram.mockClear();
|
||||
process.env.TELEGRAM_BOT_TOKEN = "tok";
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
envSnapshot.restore();
|
||||
});
|
||||
|
||||
it("adds reactions when reactionLevel is minimal", async () => {
|
||||
await expectReactionAdded("minimal");
|
||||
});
|
||||
|
||||
it("surfaces non-fatal reaction warnings", async () => {
|
||||
reactMessageTelegram.mockResolvedValueOnce({
|
||||
ok: false,
|
||||
warning: "Reaction unavailable: ✅",
|
||||
} as unknown as Awaited<ReturnType<typeof reactMessageTelegram>>);
|
||||
const result = await handleTelegramAction(defaultReactionAction, reactionConfig("minimal"));
|
||||
const textPayload = result.content.find((item) => item.type === "text");
|
||||
expect(textPayload?.type).toBe("text");
|
||||
const parsed = JSON.parse((textPayload as { type: "text"; text: string }).text) as {
|
||||
ok: boolean;
|
||||
warning?: string;
|
||||
added?: string;
|
||||
};
|
||||
expect(parsed).toMatchObject({
|
||||
ok: false,
|
||||
warning: "Reaction unavailable: ✅",
|
||||
added: "✅",
|
||||
});
|
||||
});
|
||||
|
||||
it("adds reactions when reactionLevel is extensive", async () => {
|
||||
await expectReactionAdded("extensive");
|
||||
});
|
||||
|
||||
it("accepts snake_case message_id for reactions", async () => {
|
||||
await handleTelegramAction(
|
||||
{
|
||||
action: "react",
|
||||
chatId: "123",
|
||||
message_id: "456",
|
||||
emoji: "✅",
|
||||
},
|
||||
reactionConfig("minimal"),
|
||||
);
|
||||
expect(reactMessageTelegram).toHaveBeenCalledWith(
|
||||
"123",
|
||||
456,
|
||||
"✅",
|
||||
expect.objectContaining({ token: "tok", remove: false }),
|
||||
);
|
||||
});
|
||||
|
||||
it("soft-fails when messageId is missing", async () => {
|
||||
const cfg = {
|
||||
channels: { telegram: { botToken: "tok", reactionLevel: "minimal" } },
|
||||
} as OpenClawConfig;
|
||||
const result = await handleTelegramAction(
|
||||
{
|
||||
action: "react",
|
||||
chatId: "123",
|
||||
emoji: "✅",
|
||||
},
|
||||
cfg,
|
||||
);
|
||||
expect(result.details).toMatchObject({
|
||||
ok: false,
|
||||
reason: "missing_message_id",
|
||||
});
|
||||
expect(reactMessageTelegram).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("removes reactions on empty emoji", async () => {
|
||||
await handleTelegramAction(
|
||||
{
|
||||
action: "react",
|
||||
chatId: "123",
|
||||
messageId: "456",
|
||||
emoji: "",
|
||||
},
|
||||
reactionConfig("minimal"),
|
||||
);
|
||||
expect(reactMessageTelegram).toHaveBeenCalledWith(
|
||||
"123",
|
||||
456,
|
||||
"",
|
||||
expect.objectContaining({ token: "tok", remove: false }),
|
||||
);
|
||||
});
|
||||
|
||||
it("rejects sticker actions when disabled by default", async () => {
|
||||
const cfg = { channels: { telegram: { botToken: "tok" } } } as OpenClawConfig;
|
||||
await expect(
|
||||
handleTelegramAction(
|
||||
{
|
||||
action: "sendSticker",
|
||||
to: "123",
|
||||
fileId: "sticker",
|
||||
},
|
||||
cfg,
|
||||
),
|
||||
).rejects.toThrow(/sticker actions are disabled/i);
|
||||
expect(sendStickerTelegram).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("sends stickers when enabled", async () => {
|
||||
const cfg = {
|
||||
channels: { telegram: { botToken: "tok", actions: { sticker: true } } },
|
||||
} as OpenClawConfig;
|
||||
await handleTelegramAction(
|
||||
{
|
||||
action: "sendSticker",
|
||||
to: "123",
|
||||
fileId: "sticker",
|
||||
},
|
||||
cfg,
|
||||
);
|
||||
expect(sendStickerTelegram).toHaveBeenCalledWith(
|
||||
"123",
|
||||
"sticker",
|
||||
expect.objectContaining({ token: "tok" }),
|
||||
);
|
||||
});
|
||||
|
||||
it("removes reactions when remove flag set", async () => {
|
||||
const cfg = reactionConfig("extensive");
|
||||
await handleTelegramAction(
|
||||
{
|
||||
action: "react",
|
||||
chatId: "123",
|
||||
messageId: "456",
|
||||
emoji: "✅",
|
||||
remove: true,
|
||||
},
|
||||
cfg,
|
||||
);
|
||||
expect(reactMessageTelegram).toHaveBeenCalledWith(
|
||||
"123",
|
||||
456,
|
||||
"✅",
|
||||
expect.objectContaining({ token: "tok", remove: true }),
|
||||
);
|
||||
});
|
||||
|
||||
it.each(["off", "ack"] as const)(
|
||||
"soft-fails reactions when reactionLevel is %s",
|
||||
async (level) => {
|
||||
const result = await handleTelegramAction(
|
||||
{
|
||||
action: "react",
|
||||
chatId: "123",
|
||||
messageId: "456",
|
||||
emoji: "✅",
|
||||
},
|
||||
reactionConfig(level),
|
||||
);
|
||||
expect(result.details).toMatchObject({
|
||||
ok: false,
|
||||
reason: "disabled",
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
it("soft-fails when reactions are disabled via actions.reactions", async () => {
|
||||
const cfg = {
|
||||
channels: {
|
||||
telegram: {
|
||||
botToken: "tok",
|
||||
reactionLevel: "minimal",
|
||||
actions: { reactions: false },
|
||||
},
|
||||
},
|
||||
} as OpenClawConfig;
|
||||
const result = await handleTelegramAction(
|
||||
{
|
||||
action: "react",
|
||||
chatId: "123",
|
||||
messageId: "456",
|
||||
emoji: "✅",
|
||||
},
|
||||
cfg,
|
||||
);
|
||||
expect(result.details).toMatchObject({
|
||||
ok: false,
|
||||
reason: "disabled",
|
||||
});
|
||||
});
|
||||
|
||||
it("sends a text message", async () => {
|
||||
const result = await handleTelegramAction(
|
||||
{
|
||||
action: "sendMessage",
|
||||
to: "@testchannel",
|
||||
content: "Hello, Telegram!",
|
||||
},
|
||||
telegramConfig(),
|
||||
);
|
||||
expect(sendMessageTelegram).toHaveBeenCalledWith(
|
||||
"@testchannel",
|
||||
"Hello, Telegram!",
|
||||
expect.objectContaining({ token: "tok", mediaUrl: undefined }),
|
||||
);
|
||||
expect(result.content).toContainEqual({
|
||||
type: "text",
|
||||
text: expect.stringContaining('"ok": true'),
|
||||
});
|
||||
});
|
||||
|
||||
it("sends a poll", async () => {
|
||||
const result = await handleTelegramAction(
|
||||
{
|
||||
action: "poll",
|
||||
to: "@testchannel",
|
||||
question: "Ready?",
|
||||
answers: ["Yes", "No"],
|
||||
allowMultiselect: true,
|
||||
durationSeconds: 60,
|
||||
isAnonymous: false,
|
||||
silent: true,
|
||||
},
|
||||
telegramConfig(),
|
||||
);
|
||||
expect(sendPollTelegram).toHaveBeenCalledWith(
|
||||
"@testchannel",
|
||||
{
|
||||
question: "Ready?",
|
||||
options: ["Yes", "No"],
|
||||
maxSelections: 2,
|
||||
durationSeconds: 60,
|
||||
durationHours: undefined,
|
||||
},
|
||||
expect.objectContaining({
|
||||
token: "tok",
|
||||
isAnonymous: false,
|
||||
silent: true,
|
||||
}),
|
||||
);
|
||||
expect(result.details).toMatchObject({
|
||||
ok: true,
|
||||
messageId: "790",
|
||||
chatId: "123",
|
||||
pollId: "poll-1",
|
||||
});
|
||||
});
|
||||
|
||||
it("parses string booleans for poll flags", async () => {
|
||||
await handleTelegramAction(
|
||||
{
|
||||
action: "poll",
|
||||
to: "@testchannel",
|
||||
question: "Ready?",
|
||||
answers: ["Yes", "No"],
|
||||
allowMultiselect: "true",
|
||||
isAnonymous: "false",
|
||||
silent: "true",
|
||||
},
|
||||
telegramConfig(),
|
||||
);
|
||||
expect(sendPollTelegram).toHaveBeenCalledWith(
|
||||
"@testchannel",
|
||||
expect.objectContaining({
|
||||
question: "Ready?",
|
||||
options: ["Yes", "No"],
|
||||
maxSelections: 2,
|
||||
}),
|
||||
expect.objectContaining({
|
||||
isAnonymous: false,
|
||||
silent: true,
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("forwards trusted mediaLocalRoots into sendMessageTelegram", async () => {
|
||||
await handleTelegramAction(
|
||||
{
|
||||
action: "sendMessage",
|
||||
to: "@testchannel",
|
||||
content: "Hello with local media",
|
||||
},
|
||||
telegramConfig(),
|
||||
{ mediaLocalRoots: ["/tmp/agent-root"] },
|
||||
);
|
||||
expect(sendMessageTelegram).toHaveBeenCalledWith(
|
||||
"@testchannel",
|
||||
"Hello with local media",
|
||||
expect.objectContaining({ mediaLocalRoots: ["/tmp/agent-root"] }),
|
||||
);
|
||||
});
|
||||
|
||||
it.each([
|
||||
{
|
||||
name: "react",
|
||||
params: { action: "react", chatId: "123", messageId: 456, emoji: "✅" },
|
||||
cfg: reactionConfig("minimal"),
|
||||
assertCall: (
|
||||
readCallOpts: (calls: unknown[][], argIndex: number) => Record<string, unknown>,
|
||||
) => readCallOpts(reactMessageTelegram.mock.calls as unknown[][], 3),
|
||||
},
|
||||
{
|
||||
name: "sendMessage",
|
||||
params: { action: "sendMessage", to: "123", content: "hello" },
|
||||
cfg: telegramConfig(),
|
||||
assertCall: (
|
||||
readCallOpts: (calls: unknown[][], argIndex: number) => Record<string, unknown>,
|
||||
) => readCallOpts(sendMessageTelegram.mock.calls as unknown[][], 2),
|
||||
},
|
||||
{
|
||||
name: "poll",
|
||||
params: {
|
||||
action: "poll",
|
||||
to: "123",
|
||||
question: "Q?",
|
||||
answers: ["A", "B"],
|
||||
},
|
||||
cfg: telegramConfig(),
|
||||
assertCall: (
|
||||
readCallOpts: (calls: unknown[][], argIndex: number) => Record<string, unknown>,
|
||||
) => readCallOpts(sendPollTelegram.mock.calls as unknown[][], 2),
|
||||
},
|
||||
{
|
||||
name: "deleteMessage",
|
||||
params: { action: "deleteMessage", chatId: "123", messageId: 1 },
|
||||
cfg: telegramConfig(),
|
||||
assertCall: (
|
||||
readCallOpts: (calls: unknown[][], argIndex: number) => Record<string, unknown>,
|
||||
) => readCallOpts(deleteMessageTelegram.mock.calls as unknown[][], 2),
|
||||
},
|
||||
{
|
||||
name: "editMessage",
|
||||
params: { action: "editMessage", chatId: "123", messageId: 1, content: "updated" },
|
||||
cfg: telegramConfig(),
|
||||
assertCall: (
|
||||
readCallOpts: (calls: unknown[][], argIndex: number) => Record<string, unknown>,
|
||||
) => readCallOpts(editMessageTelegram.mock.calls as unknown[][], 3),
|
||||
},
|
||||
{
|
||||
name: "sendSticker",
|
||||
params: { action: "sendSticker", to: "123", fileId: "sticker-1" },
|
||||
cfg: telegramConfig({ actions: { sticker: true } }),
|
||||
assertCall: (
|
||||
readCallOpts: (calls: unknown[][], argIndex: number) => Record<string, unknown>,
|
||||
) => readCallOpts(sendStickerTelegram.mock.calls as unknown[][], 2),
|
||||
},
|
||||
{
|
||||
name: "createForumTopic",
|
||||
params: { action: "createForumTopic", chatId: "123", name: "Topic" },
|
||||
cfg: telegramConfig({ actions: { createForumTopic: true } }),
|
||||
assertCall: (
|
||||
readCallOpts: (calls: unknown[][], argIndex: number) => Record<string, unknown>,
|
||||
) => readCallOpts(createForumTopicTelegram.mock.calls as unknown[][], 2),
|
||||
},
|
||||
{
|
||||
name: "editForumTopic",
|
||||
params: { action: "editForumTopic", chatId: "123", messageThreadId: 42, name: "New" },
|
||||
cfg: telegramConfig({ actions: { editForumTopic: true } }),
|
||||
assertCall: (
|
||||
readCallOpts: (calls: unknown[][], argIndex: number) => Record<string, unknown>,
|
||||
) => readCallOpts(editForumTopicTelegram.mock.calls as unknown[][], 2),
|
||||
},
|
||||
])("forwards resolved cfg for $name action", async ({ params, cfg, assertCall }) => {
|
||||
const readCallOpts = (calls: unknown[][], argIndex: number): Record<string, unknown> => {
|
||||
const args = calls[0];
|
||||
if (!Array.isArray(args)) {
|
||||
throw new Error("Expected Telegram action call args");
|
||||
}
|
||||
const opts = args[argIndex];
|
||||
if (!opts || typeof opts !== "object") {
|
||||
throw new Error("Expected Telegram action options object");
|
||||
}
|
||||
return opts as Record<string, unknown>;
|
||||
};
|
||||
await handleTelegramAction(params as Record<string, unknown>, cfg);
|
||||
const opts = assertCall(readCallOpts);
|
||||
expect(opts.cfg).toBe(cfg);
|
||||
});
|
||||
|
||||
it.each([
|
||||
{
|
||||
name: "media",
|
||||
params: {
|
||||
action: "sendMessage",
|
||||
to: "123456",
|
||||
content: "Check this image!",
|
||||
mediaUrl: "https://example.com/image.jpg",
|
||||
},
|
||||
expectedTo: "123456",
|
||||
expectedContent: "Check this image!",
|
||||
expectedOptions: { mediaUrl: "https://example.com/image.jpg" },
|
||||
},
|
||||
{
|
||||
name: "quoteText",
|
||||
params: {
|
||||
action: "sendMessage",
|
||||
to: "123456",
|
||||
content: "Replying now",
|
||||
replyToMessageId: 144,
|
||||
quoteText: "The text you want to quote",
|
||||
},
|
||||
expectedTo: "123456",
|
||||
expectedContent: "Replying now",
|
||||
expectedOptions: {
|
||||
replyToMessageId: 144,
|
||||
quoteText: "The text you want to quote",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "media-only",
|
||||
params: {
|
||||
action: "sendMessage",
|
||||
to: "123456",
|
||||
mediaUrl: "https://example.com/note.ogg",
|
||||
},
|
||||
expectedTo: "123456",
|
||||
expectedContent: "",
|
||||
expectedOptions: { mediaUrl: "https://example.com/note.ogg" },
|
||||
},
|
||||
] as const)("maps sendMessage params for $name", async (testCase) => {
|
||||
await handleTelegramAction(testCase.params, telegramConfig());
|
||||
expect(sendMessageTelegram).toHaveBeenCalledWith(
|
||||
testCase.expectedTo,
|
||||
testCase.expectedContent,
|
||||
expect.objectContaining({
|
||||
token: "tok",
|
||||
...testCase.expectedOptions,
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("requires content when no mediaUrl is provided", async () => {
|
||||
await expect(
|
||||
handleTelegramAction(
|
||||
{
|
||||
action: "sendMessage",
|
||||
to: "123456",
|
||||
},
|
||||
telegramConfig(),
|
||||
),
|
||||
).rejects.toThrow(/content required/i);
|
||||
});
|
||||
|
||||
it("respects sendMessage gating", async () => {
|
||||
const cfg = {
|
||||
channels: {
|
||||
telegram: { botToken: "tok", actions: { sendMessage: false } },
|
||||
},
|
||||
} as OpenClawConfig;
|
||||
await expect(
|
||||
handleTelegramAction(
|
||||
{
|
||||
action: "sendMessage",
|
||||
to: "@testchannel",
|
||||
content: "Hello!",
|
||||
},
|
||||
cfg,
|
||||
),
|
||||
).rejects.toThrow(/Telegram sendMessage is disabled/);
|
||||
});
|
||||
|
||||
it("respects poll gating", async () => {
|
||||
const cfg = {
|
||||
channels: {
|
||||
telegram: { botToken: "tok", actions: { poll: false } },
|
||||
},
|
||||
} as OpenClawConfig;
|
||||
await expect(
|
||||
handleTelegramAction(
|
||||
{
|
||||
action: "poll",
|
||||
to: "@testchannel",
|
||||
question: "Lunch?",
|
||||
answers: ["Pizza", "Sushi"],
|
||||
},
|
||||
cfg,
|
||||
),
|
||||
).rejects.toThrow(/Telegram polls are disabled/);
|
||||
});
|
||||
|
||||
it("deletes a message", async () => {
|
||||
const cfg = {
|
||||
channels: { telegram: { botToken: "tok" } },
|
||||
} as OpenClawConfig;
|
||||
await handleTelegramAction(
|
||||
{
|
||||
action: "deleteMessage",
|
||||
chatId: "123",
|
||||
messageId: 456,
|
||||
},
|
||||
cfg,
|
||||
);
|
||||
expect(deleteMessageTelegram).toHaveBeenCalledWith(
|
||||
"123",
|
||||
456,
|
||||
expect.objectContaining({ token: "tok" }),
|
||||
);
|
||||
});
|
||||
|
||||
it("respects deleteMessage gating", async () => {
|
||||
const cfg = {
|
||||
channels: {
|
||||
telegram: { botToken: "tok", actions: { deleteMessage: false } },
|
||||
},
|
||||
} as OpenClawConfig;
|
||||
await expect(
|
||||
handleTelegramAction(
|
||||
{
|
||||
action: "deleteMessage",
|
||||
chatId: "123",
|
||||
messageId: 456,
|
||||
},
|
||||
cfg,
|
||||
),
|
||||
).rejects.toThrow(/Telegram deleteMessage is disabled/);
|
||||
});
|
||||
|
||||
it("throws on missing bot token for sendMessage", async () => {
|
||||
delete process.env.TELEGRAM_BOT_TOKEN;
|
||||
const cfg = {} as OpenClawConfig;
|
||||
await expect(
|
||||
handleTelegramAction(
|
||||
{
|
||||
action: "sendMessage",
|
||||
to: "@testchannel",
|
||||
content: "Hello!",
|
||||
},
|
||||
cfg,
|
||||
),
|
||||
).rejects.toThrow(/Telegram bot token missing/);
|
||||
});
|
||||
|
||||
it("allows inline buttons by default (allowlist)", async () => {
|
||||
const cfg = {
|
||||
channels: { telegram: { botToken: "tok" } },
|
||||
} as OpenClawConfig;
|
||||
await handleTelegramAction(
|
||||
{
|
||||
action: "sendMessage",
|
||||
to: "@testchannel",
|
||||
content: "Choose",
|
||||
buttons: [[{ text: "Ok", callback_data: "cmd:ok" }]],
|
||||
},
|
||||
cfg,
|
||||
);
|
||||
expect(sendMessageTelegram).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it.each([
|
||||
{
|
||||
name: "scope is off",
|
||||
to: "@testchannel",
|
||||
inlineButtons: "off" as const,
|
||||
expectedMessage: /inline buttons are disabled/i,
|
||||
},
|
||||
{
|
||||
name: "scope is dm and target is group",
|
||||
to: "-100123456",
|
||||
inlineButtons: "dm" as const,
|
||||
expectedMessage: /inline buttons are limited to DMs/i,
|
||||
},
|
||||
])("blocks inline buttons when $name", async ({ to, inlineButtons, expectedMessage }) => {
|
||||
await expect(
|
||||
handleTelegramAction(
|
||||
{
|
||||
action: "sendMessage",
|
||||
to,
|
||||
content: "Choose",
|
||||
buttons: [[{ text: "Ok", callback_data: "cmd:ok" }]],
|
||||
},
|
||||
telegramConfig({ capabilities: { inlineButtons } }),
|
||||
),
|
||||
).rejects.toThrow(expectedMessage);
|
||||
});
|
||||
|
||||
it("allows inline buttons in DMs with tg: prefixed targets", async () => {
|
||||
await sendInlineButtonsMessage({
|
||||
to: "tg:5232990709",
|
||||
buttons: [[{ text: "Ok", callback_data: "cmd:ok" }]],
|
||||
inlineButtons: "dm",
|
||||
});
|
||||
expect(sendMessageTelegram).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("allows inline buttons in groups with topic targets", async () => {
|
||||
await sendInlineButtonsMessage({
|
||||
to: "telegram:group:-1001234567890:topic:456",
|
||||
buttons: [[{ text: "Ok", callback_data: "cmd:ok" }]],
|
||||
inlineButtons: "group",
|
||||
});
|
||||
expect(sendMessageTelegram).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("sends messages with inline keyboard buttons when enabled", async () => {
|
||||
await sendInlineButtonsMessage({
|
||||
to: "@testchannel",
|
||||
buttons: [[{ text: " Option A ", callback_data: " cmd:a " }]],
|
||||
inlineButtons: "all",
|
||||
});
|
||||
expect(sendMessageTelegram).toHaveBeenCalledWith(
|
||||
"@testchannel",
|
||||
"Choose",
|
||||
expect.objectContaining({
|
||||
buttons: [[{ text: "Option A", callback_data: "cmd:a" }]],
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("forwards optional button style", async () => {
|
||||
await sendInlineButtonsMessage({
|
||||
to: "@testchannel",
|
||||
inlineButtons: "all",
|
||||
buttons: [
|
||||
[
|
||||
{
|
||||
text: "Option A",
|
||||
callback_data: "cmd:a",
|
||||
style: "primary",
|
||||
},
|
||||
],
|
||||
],
|
||||
});
|
||||
expect(sendMessageTelegram).toHaveBeenCalledWith(
|
||||
"@testchannel",
|
||||
"Choose",
|
||||
expect.objectContaining({
|
||||
buttons: [
|
||||
[
|
||||
{
|
||||
text: "Option A",
|
||||
callback_data: "cmd:a",
|
||||
style: "primary",
|
||||
},
|
||||
],
|
||||
],
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
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" }]]);
|
||||
});
|
||||
|
||||
it("normalizes optional style", () => {
|
||||
const result = readTelegramButtons({
|
||||
buttons: [
|
||||
[
|
||||
{
|
||||
text: "Option A",
|
||||
callback_data: "cmd:a",
|
||||
style: " PRIMARY ",
|
||||
},
|
||||
],
|
||||
],
|
||||
});
|
||||
expect(result).toEqual([
|
||||
[
|
||||
{
|
||||
text: "Option A",
|
||||
callback_data: "cmd:a",
|
||||
style: "primary",
|
||||
},
|
||||
],
|
||||
]);
|
||||
});
|
||||
|
||||
it("rejects unsupported button style", () => {
|
||||
expect(() =>
|
||||
readTelegramButtons({
|
||||
buttons: [[{ text: "Option A", callback_data: "cmd:a", style: "secondary" }]],
|
||||
}),
|
||||
).toThrow(/style must be one of danger, success, primary/i);
|
||||
});
|
||||
});
|
||||
|
||||
describe("handleTelegramAction per-account gating", () => {
|
||||
function accountTelegramConfig(params: {
|
||||
accounts: Record<
|
||||
string,
|
||||
{ botToken: string; actions?: { sticker?: boolean; reactions?: boolean } }
|
||||
>;
|
||||
topLevelBotToken?: string;
|
||||
topLevelActions?: { reactions?: boolean };
|
||||
}): OpenClawConfig {
|
||||
return {
|
||||
channels: {
|
||||
telegram: {
|
||||
...(params.topLevelBotToken ? { botToken: params.topLevelBotToken } : {}),
|
||||
...(params.topLevelActions ? { actions: params.topLevelActions } : {}),
|
||||
accounts: params.accounts,
|
||||
},
|
||||
},
|
||||
} as OpenClawConfig;
|
||||
}
|
||||
|
||||
async function expectAccountStickerSend(cfg: OpenClawConfig, accountId = "media") {
|
||||
await handleTelegramAction(
|
||||
{ action: "sendSticker", to: "123", fileId: "sticker-id", accountId },
|
||||
cfg,
|
||||
);
|
||||
expect(sendStickerTelegram).toHaveBeenCalledWith(
|
||||
"123",
|
||||
"sticker-id",
|
||||
expect.objectContaining({ token: "tok-media" }),
|
||||
);
|
||||
}
|
||||
|
||||
it("allows sticker when account config enables it", async () => {
|
||||
const cfg = accountTelegramConfig({
|
||||
accounts: {
|
||||
media: { botToken: "tok-media", actions: { sticker: true } },
|
||||
},
|
||||
});
|
||||
await expectAccountStickerSend(cfg);
|
||||
});
|
||||
|
||||
it("blocks sticker when account omits it", async () => {
|
||||
const cfg = {
|
||||
channels: {
|
||||
telegram: {
|
||||
accounts: {
|
||||
chat: { botToken: "tok-chat" },
|
||||
},
|
||||
},
|
||||
},
|
||||
} as OpenClawConfig;
|
||||
|
||||
await expect(
|
||||
handleTelegramAction(
|
||||
{ action: "sendSticker", to: "123", fileId: "sticker-id", accountId: "chat" },
|
||||
cfg,
|
||||
),
|
||||
).rejects.toThrow(/sticker actions are disabled/i);
|
||||
});
|
||||
|
||||
it("uses account-merged config, not top-level config", async () => {
|
||||
// Top-level has no sticker enabled, but the account does
|
||||
const cfg = accountTelegramConfig({
|
||||
topLevelBotToken: "tok-base",
|
||||
accounts: {
|
||||
media: { botToken: "tok-media", actions: { sticker: true } },
|
||||
},
|
||||
});
|
||||
await expectAccountStickerSend(cfg);
|
||||
});
|
||||
|
||||
it("inherits top-level reaction gate when account overrides sticker only", async () => {
|
||||
const cfg = accountTelegramConfig({
|
||||
topLevelActions: { reactions: false },
|
||||
accounts: {
|
||||
media: { botToken: "tok-media", actions: { sticker: true } },
|
||||
},
|
||||
});
|
||||
|
||||
const result = await handleTelegramAction(
|
||||
{
|
||||
action: "react",
|
||||
chatId: "123",
|
||||
messageId: 1,
|
||||
emoji: "👀",
|
||||
accountId: "media",
|
||||
},
|
||||
cfg,
|
||||
);
|
||||
expect(result.details).toMatchObject({
|
||||
ok: false,
|
||||
reason: "disabled",
|
||||
});
|
||||
});
|
||||
|
||||
it("allows account to explicitly re-enable top-level disabled reaction gate", async () => {
|
||||
const cfg = accountTelegramConfig({
|
||||
topLevelActions: { reactions: false },
|
||||
accounts: {
|
||||
media: { botToken: "tok-media", actions: { sticker: true, reactions: true } },
|
||||
},
|
||||
});
|
||||
|
||||
await handleTelegramAction(
|
||||
{
|
||||
action: "react",
|
||||
chatId: "123",
|
||||
messageId: 1,
|
||||
emoji: "👀",
|
||||
accountId: "media",
|
||||
},
|
||||
cfg,
|
||||
);
|
||||
|
||||
expect(reactMessageTelegram).toHaveBeenCalledWith(
|
||||
"123",
|
||||
1,
|
||||
"👀",
|
||||
expect.objectContaining({ token: "tok-media", accountId: "media" }),
|
||||
);
|
||||
});
|
||||
});
|
||||
536
extensions/telegram/src/action-runtime.ts
Normal file
536
extensions/telegram/src/action-runtime.ts
Normal file
@@ -0,0 +1,536 @@
|
||||
import type { AgentToolResult } from "@mariozechner/pi-agent-core";
|
||||
import { readBooleanParam } from "openclaw/plugin-sdk/boolean-param";
|
||||
import {
|
||||
jsonResult,
|
||||
readNumberParam,
|
||||
readReactionParams,
|
||||
readStringArrayParam,
|
||||
readStringOrNumberParam,
|
||||
readStringParam,
|
||||
} from "../../../src/agents/tools/common.js";
|
||||
import type { OpenClawConfig } from "../../../src/config/config.js";
|
||||
import type { TelegramActionConfig } from "../../../src/config/types.telegram.js";
|
||||
import { resolvePollMaxSelections } from "../../../src/polls.js";
|
||||
import { createTelegramActionGate, resolveTelegramPollActionGateState } from "./accounts.js";
|
||||
import type { TelegramButtonStyle, TelegramInlineButtons } from "./button-types.js";
|
||||
import {
|
||||
resolveTelegramInlineButtonsScope,
|
||||
resolveTelegramTargetChatType,
|
||||
} from "./inline-buttons.js";
|
||||
import { resolveTelegramReactionLevel } from "./reaction-level.js";
|
||||
import {
|
||||
createForumTopicTelegram,
|
||||
deleteMessageTelegram,
|
||||
editForumTopicTelegram,
|
||||
editMessageTelegram,
|
||||
reactMessageTelegram,
|
||||
sendMessageTelegram,
|
||||
sendPollTelegram,
|
||||
sendStickerTelegram,
|
||||
} from "./send.js";
|
||||
import { getCacheStats, searchStickers } from "./sticker-cache.js";
|
||||
import { resolveTelegramToken } from "./token.js";
|
||||
|
||||
export const telegramActionRuntime = {
|
||||
createForumTopicTelegram,
|
||||
deleteMessageTelegram,
|
||||
editForumTopicTelegram,
|
||||
editMessageTelegram,
|
||||
getCacheStats,
|
||||
reactMessageTelegram,
|
||||
searchStickers,
|
||||
sendMessageTelegram,
|
||||
sendPollTelegram,
|
||||
sendStickerTelegram,
|
||||
};
|
||||
|
||||
const TELEGRAM_BUTTON_STYLES: readonly TelegramButtonStyle[] = ["danger", "success", "primary"];
|
||||
|
||||
export function readTelegramButtons(
|
||||
params: Record<string, unknown>,
|
||||
): TelegramInlineButtons | 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)`,
|
||||
);
|
||||
}
|
||||
const styleRaw = (button as { style?: unknown }).style;
|
||||
const style = typeof styleRaw === "string" ? styleRaw.trim().toLowerCase() : undefined;
|
||||
if (styleRaw !== undefined && !style) {
|
||||
throw new Error(`buttons[${rowIndex}][${buttonIndex}] style must be string`);
|
||||
}
|
||||
if (style && !TELEGRAM_BUTTON_STYLES.includes(style as TelegramButtonStyle)) {
|
||||
throw new Error(
|
||||
`buttons[${rowIndex}][${buttonIndex}] style must be one of ${TELEGRAM_BUTTON_STYLES.join(", ")}`,
|
||||
);
|
||||
}
|
||||
return {
|
||||
text,
|
||||
callback_data: callbackData,
|
||||
...(style ? { style: style as TelegramButtonStyle } : {}),
|
||||
};
|
||||
});
|
||||
});
|
||||
const filtered = rows.filter((row) => row.length > 0);
|
||||
return filtered.length > 0 ? filtered : undefined;
|
||||
}
|
||||
|
||||
export async function handleTelegramAction(
|
||||
params: Record<string, unknown>,
|
||||
cfg: OpenClawConfig,
|
||||
options?: {
|
||||
mediaLocalRoots?: readonly string[];
|
||||
},
|
||||
): Promise<AgentToolResult<unknown>> {
|
||||
const { action, accountId } = {
|
||||
action: readStringParam(params, "action", { required: true }),
|
||||
accountId: readStringParam(params, "accountId"),
|
||||
};
|
||||
const isActionEnabled = createTelegramActionGate({
|
||||
cfg,
|
||||
accountId,
|
||||
});
|
||||
|
||||
if (action === "react") {
|
||||
// All react failures return soft results (jsonResult with ok:false) instead
|
||||
// of throwing, because hard tool errors can trigger model re-generation
|
||||
// loops and duplicate content.
|
||||
const reactionLevelInfo = resolveTelegramReactionLevel({
|
||||
cfg,
|
||||
accountId: accountId ?? undefined,
|
||||
});
|
||||
if (!reactionLevelInfo.agentReactionsEnabled) {
|
||||
return jsonResult({
|
||||
ok: false,
|
||||
reason: "disabled",
|
||||
hint: `Telegram agent reactions disabled (reactionLevel="${reactionLevelInfo.level}"). Do not retry.`,
|
||||
});
|
||||
}
|
||||
if (!isActionEnabled("reactions")) {
|
||||
return jsonResult({
|
||||
ok: false,
|
||||
reason: "disabled",
|
||||
hint: "Telegram reactions are disabled via actions.reactions. Do not retry.",
|
||||
});
|
||||
}
|
||||
const chatId = readStringOrNumberParam(params, "chatId", {
|
||||
required: true,
|
||||
});
|
||||
const messageId = readNumberParam(params, "messageId", {
|
||||
integer: true,
|
||||
});
|
||||
if (typeof messageId !== "number" || !Number.isFinite(messageId) || messageId <= 0) {
|
||||
return jsonResult({
|
||||
ok: false,
|
||||
reason: "missing_message_id",
|
||||
hint: "Telegram reaction requires a valid messageId (or inbound context fallback). Do not retry.",
|
||||
});
|
||||
}
|
||||
const { emoji, remove, isEmpty } = readReactionParams(params, {
|
||||
removeErrorMessage: "Emoji is required to remove a Telegram reaction.",
|
||||
});
|
||||
const token = resolveTelegramToken(cfg, { accountId }).token;
|
||||
if (!token) {
|
||||
return jsonResult({
|
||||
ok: false,
|
||||
reason: "missing_token",
|
||||
hint: "Telegram bot token missing. Do not retry.",
|
||||
});
|
||||
}
|
||||
let reactionResult: Awaited<ReturnType<typeof telegramActionRuntime.reactMessageTelegram>>;
|
||||
try {
|
||||
reactionResult = await telegramActionRuntime.reactMessageTelegram(
|
||||
chatId ?? "",
|
||||
messageId ?? 0,
|
||||
emoji ?? "",
|
||||
{
|
||||
cfg,
|
||||
token,
|
||||
remove,
|
||||
accountId: accountId ?? undefined,
|
||||
},
|
||||
);
|
||||
} catch (err) {
|
||||
const isInvalid = String(err).includes("REACTION_INVALID");
|
||||
return jsonResult({
|
||||
ok: false,
|
||||
reason: isInvalid ? "REACTION_INVALID" : "error",
|
||||
emoji,
|
||||
hint: isInvalid
|
||||
? "This emoji is not supported for Telegram reactions. Add it to your reaction disallow list so you do not try it again."
|
||||
: "Reaction failed. Do not retry.",
|
||||
});
|
||||
}
|
||||
if (!reactionResult.ok) {
|
||||
return jsonResult({
|
||||
ok: false,
|
||||
warning: reactionResult.warning,
|
||||
...(remove || isEmpty ? { removed: true } : { added: emoji }),
|
||||
});
|
||||
}
|
||||
if (!remove && !isEmpty) {
|
||||
return jsonResult({ ok: true, added: emoji });
|
||||
}
|
||||
return jsonResult({ ok: true, removed: true });
|
||||
}
|
||||
|
||||
if (action === "sendMessage") {
|
||||
if (!isActionEnabled("sendMessage")) {
|
||||
throw new Error("Telegram sendMessage is disabled.");
|
||||
}
|
||||
const to = readStringParam(params, "to", { required: true });
|
||||
const mediaUrl = readStringParam(params, "mediaUrl");
|
||||
// Allow content to be omitted when sending media-only (e.g., voice notes)
|
||||
const content =
|
||||
readStringParam(params, "content", {
|
||||
required: !mediaUrl,
|
||||
allowEmpty: true,
|
||||
}) ?? "";
|
||||
const buttons = readTelegramButtons(params);
|
||||
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", {
|
||||
integer: true,
|
||||
});
|
||||
const messageThreadId = readNumberParam(params, "messageThreadId", {
|
||||
integer: true,
|
||||
});
|
||||
const quoteText = readStringParam(params, "quoteText");
|
||||
const token = resolveTelegramToken(cfg, { accountId }).token;
|
||||
if (!token) {
|
||||
throw new Error(
|
||||
"Telegram bot token missing. Set TELEGRAM_BOT_TOKEN or channels.telegram.botToken.",
|
||||
);
|
||||
}
|
||||
const result = await telegramActionRuntime.sendMessageTelegram(to, content, {
|
||||
cfg,
|
||||
token,
|
||||
accountId: accountId ?? undefined,
|
||||
mediaUrl: mediaUrl || undefined,
|
||||
mediaLocalRoots: options?.mediaLocalRoots,
|
||||
buttons,
|
||||
replyToMessageId: replyToMessageId ?? undefined,
|
||||
messageThreadId: messageThreadId ?? undefined,
|
||||
quoteText: quoteText ?? undefined,
|
||||
asVoice: readBooleanParam(params, "asVoice"),
|
||||
silent: readBooleanParam(params, "silent"),
|
||||
forceDocument: readBooleanParam(params, "forceDocument") ?? false,
|
||||
});
|
||||
return jsonResult({
|
||||
ok: true,
|
||||
messageId: result.messageId,
|
||||
chatId: result.chatId,
|
||||
});
|
||||
}
|
||||
|
||||
if (action === "poll") {
|
||||
const pollActionState = resolveTelegramPollActionGateState(isActionEnabled);
|
||||
if (!pollActionState.sendMessageEnabled) {
|
||||
throw new Error("Telegram sendMessage is disabled.");
|
||||
}
|
||||
if (!pollActionState.pollEnabled) {
|
||||
throw new Error("Telegram polls are disabled.");
|
||||
}
|
||||
const to = readStringParam(params, "to", { required: true });
|
||||
const question = readStringParam(params, "question", { required: true });
|
||||
const answers = readStringArrayParam(params, "answers", { required: true });
|
||||
const allowMultiselect = readBooleanParam(params, "allowMultiselect") ?? false;
|
||||
const durationSeconds = readNumberParam(params, "durationSeconds", { integer: true });
|
||||
const durationHours = readNumberParam(params, "durationHours", { integer: true });
|
||||
const replyToMessageId = readNumberParam(params, "replyToMessageId", {
|
||||
integer: true,
|
||||
});
|
||||
const messageThreadId = readNumberParam(params, "messageThreadId", {
|
||||
integer: true,
|
||||
});
|
||||
const isAnonymous = readBooleanParam(params, "isAnonymous");
|
||||
const silent = readBooleanParam(params, "silent");
|
||||
const token = resolveTelegramToken(cfg, { accountId }).token;
|
||||
if (!token) {
|
||||
throw new Error(
|
||||
"Telegram bot token missing. Set TELEGRAM_BOT_TOKEN or channels.telegram.botToken.",
|
||||
);
|
||||
}
|
||||
const result = await telegramActionRuntime.sendPollTelegram(
|
||||
to,
|
||||
{
|
||||
question,
|
||||
options: answers,
|
||||
maxSelections: resolvePollMaxSelections(answers.length, allowMultiselect),
|
||||
durationSeconds: durationSeconds ?? undefined,
|
||||
durationHours: durationHours ?? undefined,
|
||||
},
|
||||
{
|
||||
cfg,
|
||||
token,
|
||||
accountId: accountId ?? undefined,
|
||||
replyToMessageId: replyToMessageId ?? undefined,
|
||||
messageThreadId: messageThreadId ?? undefined,
|
||||
isAnonymous: isAnonymous ?? undefined,
|
||||
silent: silent ?? undefined,
|
||||
},
|
||||
);
|
||||
return jsonResult({
|
||||
ok: true,
|
||||
messageId: result.messageId,
|
||||
chatId: result.chatId,
|
||||
pollId: result.pollId,
|
||||
});
|
||||
}
|
||||
|
||||
if (action === "deleteMessage") {
|
||||
if (!isActionEnabled("deleteMessage")) {
|
||||
throw new Error("Telegram deleteMessage is disabled.");
|
||||
}
|
||||
const chatId = readStringOrNumberParam(params, "chatId", {
|
||||
required: true,
|
||||
});
|
||||
const messageId = readNumberParam(params, "messageId", {
|
||||
required: true,
|
||||
integer: true,
|
||||
});
|
||||
const token = resolveTelegramToken(cfg, { accountId }).token;
|
||||
if (!token) {
|
||||
throw new Error(
|
||||
"Telegram bot token missing. Set TELEGRAM_BOT_TOKEN or channels.telegram.botToken.",
|
||||
);
|
||||
}
|
||||
await telegramActionRuntime.deleteMessageTelegram(chatId ?? "", messageId ?? 0, {
|
||||
cfg,
|
||||
token,
|
||||
accountId: accountId ?? undefined,
|
||||
});
|
||||
return jsonResult({ ok: true, deleted: true });
|
||||
}
|
||||
|
||||
if (action === "editMessage") {
|
||||
if (!isActionEnabled("editMessage")) {
|
||||
throw new Error("Telegram editMessage is disabled.");
|
||||
}
|
||||
const chatId = readStringOrNumberParam(params, "chatId", {
|
||||
required: true,
|
||||
});
|
||||
const messageId = readNumberParam(params, "messageId", {
|
||||
required: true,
|
||||
integer: true,
|
||||
});
|
||||
const content = readStringParam(params, "content", {
|
||||
required: true,
|
||||
allowEmpty: false,
|
||||
});
|
||||
const buttons = readTelegramButtons(params);
|
||||
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".',
|
||||
);
|
||||
}
|
||||
}
|
||||
const token = resolveTelegramToken(cfg, { accountId }).token;
|
||||
if (!token) {
|
||||
throw new Error(
|
||||
"Telegram bot token missing. Set TELEGRAM_BOT_TOKEN or channels.telegram.botToken.",
|
||||
);
|
||||
}
|
||||
const result = await telegramActionRuntime.editMessageTelegram(
|
||||
chatId ?? "",
|
||||
messageId ?? 0,
|
||||
content,
|
||||
{
|
||||
cfg,
|
||||
token,
|
||||
accountId: accountId ?? undefined,
|
||||
buttons,
|
||||
},
|
||||
);
|
||||
return jsonResult({
|
||||
ok: true,
|
||||
messageId: result.messageId,
|
||||
chatId: result.chatId,
|
||||
});
|
||||
}
|
||||
|
||||
if (action === "sendSticker") {
|
||||
if (!isActionEnabled("sticker", false)) {
|
||||
throw new Error(
|
||||
"Telegram sticker actions are disabled. Set channels.telegram.actions.sticker to true.",
|
||||
);
|
||||
}
|
||||
const to = readStringParam(params, "to", { required: true });
|
||||
const fileId = readStringParam(params, "fileId", { required: true });
|
||||
const replyToMessageId = readNumberParam(params, "replyToMessageId", {
|
||||
integer: true,
|
||||
});
|
||||
const messageThreadId = readNumberParam(params, "messageThreadId", {
|
||||
integer: true,
|
||||
});
|
||||
const token = resolveTelegramToken(cfg, { accountId }).token;
|
||||
if (!token) {
|
||||
throw new Error(
|
||||
"Telegram bot token missing. Set TELEGRAM_BOT_TOKEN or channels.telegram.botToken.",
|
||||
);
|
||||
}
|
||||
const result = await telegramActionRuntime.sendStickerTelegram(to, fileId, {
|
||||
cfg,
|
||||
token,
|
||||
accountId: accountId ?? undefined,
|
||||
replyToMessageId: replyToMessageId ?? undefined,
|
||||
messageThreadId: messageThreadId ?? undefined,
|
||||
});
|
||||
return jsonResult({
|
||||
ok: true,
|
||||
messageId: result.messageId,
|
||||
chatId: result.chatId,
|
||||
});
|
||||
}
|
||||
|
||||
if (action === "searchSticker") {
|
||||
if (!isActionEnabled("sticker", false)) {
|
||||
throw new Error(
|
||||
"Telegram sticker actions are disabled. Set channels.telegram.actions.sticker to true.",
|
||||
);
|
||||
}
|
||||
const query = readStringParam(params, "query", { required: true });
|
||||
const limit = readNumberParam(params, "limit", { integer: true }) ?? 5;
|
||||
const results = telegramActionRuntime.searchStickers(query, limit);
|
||||
return jsonResult({
|
||||
ok: true,
|
||||
count: results.length,
|
||||
stickers: results.map((s) => ({
|
||||
fileId: s.fileId,
|
||||
emoji: s.emoji,
|
||||
description: s.description,
|
||||
setName: s.setName,
|
||||
})),
|
||||
});
|
||||
}
|
||||
|
||||
if (action === "stickerCacheStats") {
|
||||
const stats = telegramActionRuntime.getCacheStats();
|
||||
return jsonResult({ ok: true, ...stats });
|
||||
}
|
||||
|
||||
if (action === "createForumTopic") {
|
||||
if (!isActionEnabled("createForumTopic")) {
|
||||
throw new Error("Telegram createForumTopic is disabled.");
|
||||
}
|
||||
const chatId = readStringOrNumberParam(params, "chatId", {
|
||||
required: true,
|
||||
});
|
||||
const name = readStringParam(params, "name", { required: true });
|
||||
const iconColor = readNumberParam(params, "iconColor", { integer: true });
|
||||
const iconCustomEmojiId = readStringParam(params, "iconCustomEmojiId");
|
||||
const token = resolveTelegramToken(cfg, { accountId }).token;
|
||||
if (!token) {
|
||||
throw new Error(
|
||||
"Telegram bot token missing. Set TELEGRAM_BOT_TOKEN or channels.telegram.botToken.",
|
||||
);
|
||||
}
|
||||
const result = await telegramActionRuntime.createForumTopicTelegram(chatId ?? "", name, {
|
||||
cfg,
|
||||
token,
|
||||
accountId: accountId ?? undefined,
|
||||
iconColor: iconColor ?? undefined,
|
||||
iconCustomEmojiId: iconCustomEmojiId ?? undefined,
|
||||
});
|
||||
return jsonResult({
|
||||
ok: true,
|
||||
topicId: result.topicId,
|
||||
name: result.name,
|
||||
chatId: result.chatId,
|
||||
});
|
||||
}
|
||||
|
||||
if (action === "editForumTopic") {
|
||||
if (!isActionEnabled("editForumTopic")) {
|
||||
throw new Error("Telegram editForumTopic is disabled.");
|
||||
}
|
||||
const chatId = readStringOrNumberParam(params, "chatId", {
|
||||
required: true,
|
||||
});
|
||||
const messageThreadId =
|
||||
readNumberParam(params, "messageThreadId", { integer: true }) ??
|
||||
readNumberParam(params, "threadId", { integer: true });
|
||||
if (typeof messageThreadId !== "number") {
|
||||
throw new Error("messageThreadId or threadId is required.");
|
||||
}
|
||||
const name = readStringParam(params, "name");
|
||||
const iconCustomEmojiId = readStringParam(params, "iconCustomEmojiId");
|
||||
const token = resolveTelegramToken(cfg, { accountId }).token;
|
||||
if (!token) {
|
||||
throw new Error(
|
||||
"Telegram bot token missing. Set TELEGRAM_BOT_TOKEN or channels.telegram.botToken.",
|
||||
);
|
||||
}
|
||||
const result = await telegramActionRuntime.editForumTopicTelegram(
|
||||
chatId ?? "",
|
||||
messageThreadId,
|
||||
{
|
||||
cfg,
|
||||
token,
|
||||
accountId: accountId ?? undefined,
|
||||
name: name ?? undefined,
|
||||
iconCustomEmojiId: iconCustomEmojiId ?? undefined,
|
||||
},
|
||||
);
|
||||
return jsonResult(result);
|
||||
}
|
||||
|
||||
throw new Error(`Unsupported Telegram action: ${action}`);
|
||||
}
|
||||
@@ -4,10 +4,10 @@ import {
|
||||
readStringOrNumberParam,
|
||||
readStringParam,
|
||||
} from "openclaw/plugin-sdk/agent-runtime";
|
||||
import { handleTelegramAction } from "openclaw/plugin-sdk/agent-runtime";
|
||||
import { readBooleanParam } from "openclaw/plugin-sdk/boolean-param";
|
||||
import { resolveReactionMessageId } from "openclaw/plugin-sdk/channel-runtime";
|
||||
import {
|
||||
createLegacyMessageToolDiscoveryMethods,
|
||||
createMessageToolButtonsSchema,
|
||||
createTelegramPollExtraToolSchemas,
|
||||
createUnionActionGate,
|
||||
@@ -27,6 +27,7 @@ import {
|
||||
listEnabledTelegramAccounts,
|
||||
resolveTelegramPollActionGateState,
|
||||
} from "./accounts.js";
|
||||
import { handleTelegramAction } from "./action-runtime.js";
|
||||
import { resolveTelegramInlineButtons } from "./button-types.js";
|
||||
import { isTelegramInlineButtonsEnabled } from "./inline-buttons.js";
|
||||
|
||||
@@ -177,6 +178,7 @@ function readTelegramMessageIdParam(params: Record<string, unknown>): number {
|
||||
|
||||
export const telegramMessageActions: ChannelMessageActionAdapter = {
|
||||
describeMessageTool: describeTelegramMessageTool,
|
||||
...createLegacyMessageToolDiscoveryMethods(describeTelegramMessageTool),
|
||||
extractToolSend: ({ args }) => {
|
||||
return extractToolSend(args, "sendMessage");
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user