This commit is contained in:
Alexander Bolshakov 2026-03-15 15:38:17 +01:00 committed by GitHub
commit f2739985f5
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
15 changed files with 520 additions and 14 deletions

View File

@ -424,10 +424,14 @@ curl "https://api.telegram.org/bot<bot_token>/getUpdates"
- `sendMessage` (`to`, `content`, optional `mediaUrl`, `replyToMessageId`, `messageThreadId`)
- `react` (`chatId`, `messageId`, `emoji`)
- `deleteMessage` (`chatId`, `messageId`)
- `deleteForumTopic` (`chatId`, `topicId`)
- `editMessage` (`chatId`, `messageId`, `content`)
- `createForumTopic` (`chatId`, `name`, optional `iconColor`, `iconCustomEmojiId`)
Channel message actions expose ergonomic aliases (`send`, `react`, `delete`, `edit`, `sticker`, `sticker-search`, `topic-create`).
Channel message actions expose ergonomic aliases (`send`, `react`, `delete`, `topic-delete`, `edit`, `sticker`, `sticker-search`, `topic-create`).
- `delete`: deletes a message and requires `messageId`.
- `topic-delete`: deletes a forum topic and requires explicit `threadId`/`topicId`.
Backward compatibility: `delete` with explicit `threadId`/`topicId` is still accepted for now.
Gating controls:
@ -969,7 +973,7 @@ Telegram-specific high-signal fields:
- formatting/delivery: `textChunkLimit`, `chunkMode`, `linkPreview`, `responsePrefix`
- media/network: `mediaMaxMb`, `timeoutSeconds`, `retry`, `network.autoSelectFamily`, `proxy`
- webhook: `webhookUrl`, `webhookSecret`, `webhookPath`, `webhookHost`
- actions/capabilities: `capabilities.inlineButtons`, `actions.sendMessage|editMessage|deleteMessage|reactions|sticker`
- actions/capabilities: `capabilities.inlineButtons`, `actions.sendMessage|editMessage|deleteMessage|reactions|sticker` (both `delete` and `topic-delete` route through `actions.deleteMessage`)
- reactions: `reactionNotifications`, `reactionLevel`
- writes/history: `configWrites`, `historyLimit`, `dmHistoryLimit`, `dms.*.historyLimit`

View File

@ -62,15 +62,49 @@ function readTelegramChatIdParam(params: Record<string, unknown>): string | numb
);
}
function readTelegramMessageIdParam(params: Record<string, unknown>): number {
function readTelegramMessageIdParam(
params: Record<string, unknown>,
options?: { required?: boolean },
): number | undefined {
const required = options?.required ?? true;
const messageId = readNumberParam(params, "messageId", {
required: true,
required,
integer: true,
strict: true,
strictInteger: true,
});
if (typeof messageId !== "number") {
if (required && typeof messageId !== "number") {
throw new Error("messageId is required.");
}
return messageId;
return typeof messageId === "number" ? messageId : undefined;
}
function readTelegramTopicIdParam(params: Record<string, unknown>): number | undefined {
const hasTopicIdParam = Object.hasOwn(params, "topicId") || Object.hasOwn(params, "topic_id");
const topicId = readNumberParam(params, "topicId", {
integer: true,
strict: true,
strictInteger: true,
});
if (hasTopicIdParam && typeof topicId !== "number") {
throw new Error("topicId must be a valid integer when provided.");
}
if (typeof topicId === "number") {
return topicId;
}
const hasThreadIdParam =
Object.hasOwn(params, "threadId") || Object.hasOwn(params, "thread_id");
const threadId = readNumberParam(params, "threadId", {
integer: true,
strict: true,
strictInteger: true,
});
if (hasThreadIdParam && typeof threadId !== "number") {
throw new Error("threadId must be a valid integer when provided.");
}
return threadId;
}
export const telegramMessageActions: ChannelMessageActionAdapter = {
@ -104,6 +138,7 @@ export const telegramMessageActions: ChannelMessageActionAdapter = {
}
if (isEnabled("deleteMessage")) {
actions.add("delete");
actions.add("topic-delete");
}
if (isEnabled("editMessage")) {
actions.add("edit");
@ -202,7 +237,13 @@ export const telegramMessageActions: ChannelMessageActionAdapter = {
if (action === "delete") {
const chatId = readTelegramChatIdParam(params);
const messageId = readTelegramMessageIdParam(params);
const hasMessageIdParam =
Object.hasOwn(params, "messageId") || Object.hasOwn(params, "message_id");
const messageId = readTelegramMessageIdParam(params, { required: false });
if (hasMessageIdParam && typeof messageId !== "number") {
throw new Error("messageId must be a valid number for action=delete.");
}
if (typeof messageId === "number") {
return await handleTelegramAction(
{
action: "deleteMessage",
@ -215,6 +256,41 @@ export const telegramMessageActions: ChannelMessageActionAdapter = {
);
}
const topicId = readTelegramTopicIdParam(params);
if (typeof topicId === "number") {
return await handleTelegramAction(
{
action: "deleteForumTopic",
chatId,
topicId,
accountId: accountId ?? undefined,
},
cfg,
{ mediaLocalRoots },
);
}
throw new Error("messageId is required for action=delete.");
}
if (action === "topic-delete") {
const chatId = readTelegramChatIdParam(params);
const topicId = readTelegramTopicIdParam(params);
if (typeof topicId !== "number") {
throw new Error("threadId/topicId is required for action=topic-delete.");
}
return await handleTelegramAction(
{
action: "deleteForumTopic",
chatId,
topicId,
accountId: accountId ?? undefined,
},
cfg,
{ mediaLocalRoots },
);
}
if (action === "edit") {
const chatId = readTelegramChatIdParam(params);
const messageId = readTelegramMessageIdParam(params);

View File

@ -5,6 +5,7 @@ const { botApi, botCtorSpy } = vi.hoisted(() => ({
sendMessage: vi.fn(),
setMessageReaction: vi.fn(),
deleteMessage: vi.fn(),
deleteForumTopic: vi.fn(),
},
botCtorSpy: vi.fn(),
}));
@ -52,6 +53,7 @@ vi.mock("grammy", () => ({
}));
import {
deleteForumTopicTelegram,
deleteMessageTelegram,
reactMessageTelegram,
resetTelegramClientOptionsCacheForTests,
@ -86,6 +88,7 @@ describe("telegram proxy client", () => {
botApi.sendMessage.mockResolvedValue({ message_id: 1, chat: { id: "123" } });
botApi.setMessageReaction.mockResolvedValue(undefined);
botApi.deleteMessage.mockResolvedValue(true);
botApi.deleteForumTopic.mockResolvedValue(true);
botCtorSpy.mockClear();
loadConfig.mockReturnValue({
channels: { telegram: { accounts: { foo: { proxy: proxyUrl } } } },
@ -134,6 +137,10 @@ describe("telegram proxy client", () => {
name: "deleteMessage",
run: () => deleteMessageTelegram("123", "456", { token: "tok", accountId: "foo" }),
},
{
name: "deleteForumTopic",
run: () => deleteForumTopicTelegram("123", "456", { token: "tok", accountId: "foo" }),
},
])("uses proxy fetch for $name", async (testCase) => {
const { fetchImpl } = prepareProxyFetch();

View File

@ -4,6 +4,7 @@ import type { MockFn } from "../../../src/test-utils/vitest-mock-fn.js";
const { botApi, botCtorSpy } = vi.hoisted(() => ({
botApi: {
deleteMessage: vi.fn(),
deleteForumTopic: vi.fn(),
editMessageText: vi.fn(),
sendChatAction: vi.fn(),
sendMessage: vi.fn(),

View File

@ -15,6 +15,7 @@ const { botApi, botCtorSpy, loadConfig, loadWebMedia, maybePersistResolvedTelegr
const {
buildInlineKeyboard,
createForumTopicTelegram,
deleteForumTopicTelegram,
editMessageTelegram,
reactMessageTelegram,
sendMessageTelegram,
@ -1950,6 +1951,38 @@ describe("sendPollTelegram", () => {
});
});
describe("deleteForumTopicTelegram", () => {
const cases = [
{
name: "uses base chat id when target includes topic suffix",
target: "telegram:group:-1001234567890:topic:271",
topicId: 271,
expectedCall: ["-1001234567890", 271] as const,
},
{
name: "accepts plain chat ids",
target: "-1001234567890",
topicId: 300,
expectedCall: ["-1001234567890", 300] as const,
},
] as const;
for (const testCase of cases) {
it(testCase.name, async () => {
const deleteForumTopic = vi.fn().mockResolvedValue(true);
const api = { deleteForumTopic } as unknown as Bot["api"];
const result = await deleteForumTopicTelegram(testCase.target, testCase.topicId, {
token: "tok",
api,
});
expect(deleteForumTopic).toHaveBeenCalledWith(...testCase.expectedCall);
expect(result).toEqual({ ok: true });
});
}
});
describe("createForumTopicTelegram", () => {
const cases = [
{

View File

@ -1438,6 +1438,49 @@ export async function sendPollTelegram(
return { messageId: String(messageId), chatId: resolvedChatId, pollId };
}
// ---------------------------------------------------------------------------
// Forum topic deletion
// ---------------------------------------------------------------------------
type TelegramDeleteForumTopicOpts = {
cfg?: ReturnType<typeof loadConfig>;
token?: string;
accountId?: string;
verbose?: boolean;
api?: TelegramApiOverride;
retry?: RetryConfig;
};
export async function deleteForumTopicTelegram(
chatIdInput: string | number,
topicIdInput: string | number,
opts: TelegramDeleteForumTopicOpts = {},
): Promise<{ ok: true }> {
const { cfg, account, api } = resolveTelegramApiContext(opts);
const parsedTarget = parseTelegramTarget(String(chatIdInput));
const chatId = await resolveAndPersistChatId({
cfg,
api,
lookupTarget: parsedTarget.chatId,
persistTarget: String(chatIdInput),
verbose: opts.verbose,
});
const topicId = normalizeMessageId(topicIdInput);
const requestWithDiag = createTelegramRequestWithDiag({
cfg,
account,
retry: opts.retry,
verbose: opts.verbose,
shouldRetry: (err) => isRecoverableTelegramNetworkError(err, { context: "send" }),
});
if (typeof api.deleteForumTopic !== "function") {
throw new Error("Telegram forum topic deletion is unavailable in this bot API.");
}
await requestWithDiag(() => api.deleteForumTopic(chatId, topicId), "deleteForumTopic");
logVerbose(`[telegram] Deleted forum topic ${topicId} from chat ${chatId}`);
return { ok: true };
}
// ---------------------------------------------------------------------------
// Forum topic creation
// ---------------------------------------------------------------------------

View File

@ -63,6 +63,13 @@ describe("readNumberParam", () => {
expect(readNumberParam(params, "messageId", { integer: true })).toBe(42);
});
it("rejects fractional values when strictInteger is true", () => {
const params = { messageId: "42.9" };
expect(() =>
readNumberParam(params, "messageId", { integer: true, strictInteger: true }),
).toThrow(/messageId must be an integer/);
});
it("accepts snake_case aliases for camelCase keys", () => {
const params = { message_id: "42" };
expect(readNumberParam(params, "messageId")).toBe(42);

View File

@ -116,9 +116,21 @@ export function readStringOrNumberParam(
export function readNumberParam(
params: Record<string, unknown>,
key: string,
options: { required?: boolean; label?: string; integer?: boolean; strict?: boolean } = {},
options: {
required?: boolean;
label?: string;
integer?: boolean;
strict?: boolean;
strictInteger?: boolean;
} = {},
): number | undefined {
const { required = false, label = key, integer = false, strict = false } = options;
const {
required = false,
label = key,
integer = false,
strict = false,
strictInteger = false,
} = options;
const raw = readParamRaw(params, key);
let value: number | undefined;
if (typeof raw === "number" && Number.isFinite(raw)) {
@ -138,7 +150,13 @@ export function readNumberParam(
}
return undefined;
}
return integer ? Math.trunc(value) : value;
if (integer) {
if (strictInteger && !Number.isInteger(value)) {
throw new ToolInputError(`${label} must be an integer`);
}
return Math.trunc(value);
}
return value;
}
export function readStringArrayParam(

View File

@ -353,7 +353,7 @@ describe("message tool description", () => {
label: "Telegram",
docsPath: "/channels/telegram",
blurb: "Telegram test plugin.",
actions: ["send", "react", "delete", "edit", "topic-create"],
actions: ["send", "react", "delete", "edit", "topic-create", "topic-delete"],
});
setActivePluginRegistry(
@ -372,7 +372,9 @@ describe("message tool description", () => {
expect(tool.description).toContain("Current channel (signal) supports: react, send.");
// Other configured channels are also listed
expect(tool.description).toContain("Other configured channels:");
expect(tool.description).toContain("telegram (delete, edit, react, send, topic-create)");
expect(tool.description).toContain(
"telegram (delete, edit, react, send, topic-create, topic-delete)",
);
});
it("does not include 'Other configured channels' when only one channel is configured", () => {

View File

@ -194,6 +194,12 @@ function buildSendSchema(options: {
filePath: Type.Optional(Type.String()),
replyTo: Type.Optional(Type.String()),
threadId: Type.Optional(Type.String()),
topicId: Type.Optional(
Type.String({
description:
"Topic/thread id alias for forum-style channels (e.g., Telegram). Supported for topic deletion paths.",
}),
),
asVoice: Type.Optional(Type.Boolean()),
silent: Type.Optional(Type.Boolean()),
quoteText: Type.Optional(

View File

@ -28,6 +28,7 @@ const createForumTopicTelegram = vi.fn(async () => ({
name: "Topic",
chatId: "123",
}));
const deleteForumTopicTelegram = vi.fn(async () => ({ ok: true }));
let envSnapshot: ReturnType<typeof captureEnv>;
vi.mock("../../../extensions/telegram/src/send.js", () => ({
@ -44,6 +45,8 @@ vi.mock("../../../extensions/telegram/src/send.js", () => ({
editMessageTelegram(...args),
createForumTopicTelegram: (...args: Parameters<typeof createForumTopicTelegram>) =>
createForumTopicTelegram(...args),
deleteForumTopicTelegram: (...args: Parameters<typeof deleteForumTopicTelegram>) =>
deleteForumTopicTelegram(...args),
}));
describe("handleTelegramAction", () => {
@ -106,6 +109,7 @@ describe("handleTelegramAction", () => {
deleteMessageTelegram.mockClear();
editMessageTelegram.mockClear();
createForumTopicTelegram.mockClear();
deleteForumTopicTelegram.mockClear();
process.env.TELEGRAM_BOT_TOKEN = "tok";
});
@ -594,6 +598,75 @@ describe("handleTelegramAction", () => {
);
});
it("rejects malformed message ids for deleteMessage", async () => {
const cfg = {
channels: { telegram: { botToken: "tok" } },
} as OpenClawConfig;
await expect(
handleTelegramAction(
{
action: "deleteMessage",
chatId: "123",
messageId: "456oops",
},
cfg,
),
).rejects.toThrow(/messageId required/);
});
it("deletes a forum topic", async () => {
const cfg = {
channels: { telegram: { botToken: "tok" } },
} as OpenClawConfig;
await handleTelegramAction(
{
action: "deleteForumTopic",
chatId: "-100123",
topicId: 271,
},
cfg,
);
expect(deleteForumTopicTelegram).toHaveBeenCalledWith(
"-100123",
271,
expect.objectContaining({ cfg, token: "tok" }),
);
});
it("rejects malformed topic ids for deleteForumTopic", async () => {
const cfg = {
channels: { telegram: { botToken: "tok" } },
} as OpenClawConfig;
await expect(
handleTelegramAction(
{
action: "deleteForumTopic",
chatId: "-100123",
topicId: "271abc",
},
cfg,
),
).rejects.toThrow(/topicId required/);
});
it("respects deleteMessage gating for deleteForumTopic", async () => {
const cfg = {
channels: {
telegram: { botToken: "tok", actions: { deleteMessage: false } },
},
} as OpenClawConfig;
await expect(
handleTelegramAction(
{
action: "deleteForumTopic",
chatId: "-100123",
topicId: 271,
},
cfg,
),
).rejects.toThrow(/Telegram forum topic deletion is disabled/);
});
it("respects deleteMessage gating", async () => {
const cfg = {
channels: {

View File

@ -14,6 +14,7 @@ import {
import { resolveTelegramReactionLevel } from "../../../extensions/telegram/src/reaction-level.js";
import {
createForumTopicTelegram,
deleteForumTopicTelegram,
deleteMessageTelegram,
editMessageTelegram,
reactMessageTelegram,
@ -135,6 +136,8 @@ export async function handleTelegramAction(
});
const messageId = readNumberParam(params, "messageId", {
integer: true,
strict: true,
strictInteger: true,
});
if (typeof messageId !== "number" || !Number.isFinite(messageId) || messageId <= 0) {
return jsonResult({
@ -326,6 +329,8 @@ export async function handleTelegramAction(
const messageId = readNumberParam(params, "messageId", {
required: true,
integer: true,
strict: true,
strictInteger: true,
});
const token = resolveTelegramToken(cfg, { accountId }).token;
if (!token) {
@ -341,6 +346,35 @@ export async function handleTelegramAction(
return jsonResult({ ok: true, deleted: true });
}
if (action === "deleteForumTopic") {
if (!isActionEnabled("deleteMessage")) {
throw new Error(
"Telegram forum topic deletion is disabled. Set channels.telegram.actions.deleteMessage to true.",
);
}
const chatId = readStringOrNumberParam(params, "chatId", {
required: true,
});
const topicId = readNumberParam(params, "topicId", {
required: true,
integer: true,
strict: true,
strictInteger: 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 deleteForumTopicTelegram(chatId ?? "", topicId ?? 0, {
cfg,
token,
accountId: accountId ?? undefined,
});
return jsonResult({ ok: true, deleted: true, topicId });
}
if (action === "editMessage") {
if (!isActionEnabled("editMessage")) {
throw new Error("Telegram editMessage is disabled.");
@ -351,6 +385,8 @@ export async function handleTelegramAction(
const messageId = readNumberParam(params, "messageId", {
required: true,
integer: true,
strict: true,
strictInteger: true,
});
const content = readStringParam(params, "content", {
required: true,

View File

@ -777,6 +777,48 @@ describe("telegramMessageActions", () => {
accountId: undefined,
},
},
{
name: "delete maps to deleteMessage when messageId is provided",
action: "delete" as const,
params: {
to: "-1001234567890",
messageId: 42,
},
expectedPayload: {
action: "deleteMessage",
chatId: "-1001234567890",
messageId: 42,
accountId: undefined,
},
},
{
name: "topic-delete maps to deleteForumTopic when threadId is provided",
action: "topic-delete" as const,
params: {
to: "-1001234567890",
threadId: 271,
},
expectedPayload: {
action: "deleteForumTopic",
chatId: "-1001234567890",
topicId: 271,
accountId: undefined,
},
},
{
name: "delete keeps backward compatibility for explicit topic ids",
action: "delete" as const,
params: {
to: "-1001234567890",
topicId: 271,
},
expectedPayload: {
action: "deleteForumTopic",
chatId: "-1001234567890",
topicId: 271,
accountId: undefined,
},
},
{
name: "topic-create maps to createForumTopic",
action: "topic-create" as const,
@ -806,6 +848,162 @@ describe("telegramMessageActions", () => {
}
});
it("rejects delete when messageId is not provided", async () => {
const cfg = telegramCfg();
const handleAction = telegramMessageActions.handleAction;
if (!handleAction) {
throw new Error("telegram handleAction unavailable");
}
await expect(
handleAction({
channel: "telegram",
action: "delete",
params: {
to: "-1001234567890",
},
cfg,
}),
).rejects.toThrow(/messageId is required for action=delete/i);
expect(handleTelegramAction).not.toHaveBeenCalled();
});
it("rejects invalid delete messageId instead of falling back to topic deletion", async () => {
const cfg = telegramCfg();
const handleAction = telegramMessageActions.handleAction;
if (!handleAction) {
throw new Error("telegram handleAction unavailable");
}
await expect(
handleAction({
channel: "telegram",
action: "delete",
params: {
to: "-1001234567890",
messageId: "oops",
topicId: 271,
},
cfg,
}),
).rejects.toThrow(/messageId must be a valid number for action=delete/i);
expect(handleTelegramAction).not.toHaveBeenCalled();
});
it("rejects invalid snake_case delete message_id instead of falling back to topic deletion", async () => {
const cfg = telegramCfg();
const handleAction = telegramMessageActions.handleAction;
if (!handleAction) {
throw new Error("telegram handleAction unavailable");
}
await expect(
handleAction({
channel: "telegram",
action: "delete",
params: {
to: "-1001234567890",
message_id: "oops",
topicId: 271,
},
cfg,
}),
).rejects.toThrow(/messageId must be a valid number for action=delete/i);
expect(handleTelegramAction).not.toHaveBeenCalled();
});
it("rejects topic-delete when threadId/topicId is missing", async () => {
const cfg = telegramCfg();
const handleAction = telegramMessageActions.handleAction;
if (!handleAction) {
throw new Error("telegram handleAction unavailable");
}
await expect(
handleAction({
channel: "telegram",
action: "topic-delete",
params: {
to: "-1001234567890",
},
cfg,
}),
).rejects.toThrow(/threadId\/topicId is required for action=topic-delete/i);
expect(handleTelegramAction).not.toHaveBeenCalled();
});
it("rejects non-integer topic-delete ids before telegram-actions", async () => {
const cfg = telegramCfg();
const handleAction = telegramMessageActions.handleAction;
if (!handleAction) {
throw new Error("telegram handleAction unavailable");
}
await expect(
handleAction({
channel: "telegram",
action: "topic-delete",
params: {
to: "-1001234567890",
topicId: "271abc",
},
cfg,
}),
).rejects.toThrow(/topicId must be a valid integer when provided/i);
expect(handleTelegramAction).not.toHaveBeenCalled();
});
it("rejects malformed topicId even when threadId alias is valid", async () => {
const cfg = telegramCfg();
const handleAction = telegramMessageActions.handleAction;
if (!handleAction) {
throw new Error("telegram handleAction unavailable");
}
await expect(
handleAction({
channel: "telegram",
action: "topic-delete",
params: {
to: "-1001234567890",
topicId: "oops",
threadId: 271,
},
cfg,
}),
).rejects.toThrow(/topicId must be a valid integer when provided/i);
expect(handleTelegramAction).not.toHaveBeenCalled();
});
it("rejects malformed delete topicId even when threadId alias is valid", async () => {
const cfg = telegramCfg();
const handleAction = telegramMessageActions.handleAction;
if (!handleAction) {
throw new Error("telegram handleAction unavailable");
}
await expect(
handleAction({
channel: "telegram",
action: "delete",
params: {
to: "-1001234567890",
topicId: "oops",
threadId: 271,
},
cfg,
}),
).rejects.toThrow(/topicId must be a valid integer when provided/i);
expect(handleTelegramAction).not.toHaveBeenCalled();
});
it("forwards trusted mediaLocalRoots for send", async () => {
const cfg = telegramCfg();
await telegramMessageActions.handleAction?.({

View File

@ -44,6 +44,7 @@ export const CHANNEL_MESSAGE_ACTION_NAMES = [
"category-edit",
"category-delete",
"topic-create",
"topic-delete",
"voice-status",
"event-list",
"event-create",

View File

@ -49,6 +49,7 @@ export const MESSAGE_ACTION_TARGET_MODE: Record<ChannelMessageActionName, Messag
"category-edit": "none",
"category-delete": "none",
"topic-create": "to",
"topic-delete": "to",
"voice-status": "none",
"event-list": "none",
"event-create": "none",