Harden Telegram poll gating and schema consistency (#36547)

Merged via squash.

Prepared head SHA: f77824419e
Co-authored-by: gumadeiras <5599352+gumadeiras@users.noreply.github.com>
Co-authored-by: gumadeiras <5599352+gumadeiras@users.noreply.github.com>
Reviewed-by: @gumadeiras
This commit is contained in:
Gustavo Madeira Santana
2026-03-05 19:24:43 -05:00
committed by GitHub
parent f771ba8de9
commit 6dfd39c32f
27 changed files with 1129 additions and 65 deletions

View File

@@ -4,7 +4,11 @@ import type { OpenClawConfig } from "../config/config.js";
import { setActivePluginRegistry } from "../plugins/runtime.js";
import { defaultRuntime } from "../runtime.js";
import { createTestRegistry } from "../test-utils/channel-plugins.js";
import { __testing, listAllChannelSupportedActions } from "./channel-tools.js";
import {
__testing,
listAllChannelSupportedActions,
listChannelSupportedActions,
} from "./channel-tools.js";
describe("channel tools", () => {
const errorSpy = vi.spyOn(defaultRuntime, "error").mockImplementation(() => undefined);
@@ -49,4 +53,35 @@ describe("channel tools", () => {
expect(listAllChannelSupportedActions({ cfg })).toEqual([]);
expect(errorSpy).toHaveBeenCalledTimes(1);
});
it("does not infer poll actions from outbound adapters when action discovery omits them", () => {
const plugin: ChannelPlugin = {
id: "polltest",
meta: {
id: "polltest",
label: "Poll Test",
selectionLabel: "Poll Test",
docsPath: "/channels/polltest",
blurb: "poll plugin",
},
capabilities: { chatTypes: ["direct"], polls: true },
config: {
listAccountIds: () => [],
resolveAccount: () => ({}),
},
actions: {
listActions: () => [],
},
outbound: {
deliveryMode: "gateway",
sendPoll: async () => ({ channel: "polltest", messageId: "poll-1" }),
},
};
setActivePluginRegistry(createTestRegistry([{ pluginId: "polltest", source: "test", plugin }]));
const cfg = {} as OpenClawConfig;
expect(listChannelSupportedActions({ cfg, channel: "polltest" })).toEqual([]);
expect(listAllChannelSupportedActions({ cfg })).toEqual([]);
});
});

View File

@@ -48,6 +48,16 @@ describe("readNumberParam", () => {
expect(readNumberParam(params, "messageId")).toBe(42);
});
it("keeps partial parse behavior by default", () => {
const params = { messageId: "42abc" };
expect(readNumberParam(params, "messageId")).toBe(42);
});
it("rejects partial numeric strings when strict is enabled", () => {
const params = { messageId: "42abc" };
expect(readNumberParam(params, "messageId", { strict: true })).toBeUndefined();
});
it("truncates when integer is true", () => {
const params = { messageId: "42.9" };
expect(readNumberParam(params, "messageId", { integer: true })).toBe(42);

View File

@@ -129,9 +129,9 @@ export function readStringOrNumberParam(
export function readNumberParam(
params: Record<string, unknown>,
key: string,
options: { required?: boolean; label?: string; integer?: boolean } = {},
options: { required?: boolean; label?: string; integer?: boolean; strict?: boolean } = {},
): number | undefined {
const { required = false, label = key, integer = false } = options;
const { required = false, label = key, integer = false, strict = false } = options;
const raw = readParamRaw(params, key);
let value: number | undefined;
if (typeof raw === "number" && Number.isFinite(raw)) {
@@ -139,7 +139,7 @@ export function readNumberParam(
} else if (typeof raw === "string") {
const trimmed = raw.trim();
if (trimmed) {
const parsed = Number.parseFloat(trimmed);
const parsed = strict ? Number(trimmed) : Number.parseFloat(trimmed);
if (Number.isFinite(parsed)) {
value = parsed;
}

View File

@@ -26,11 +26,14 @@ import {
} from "../../discord/send.js";
import type { DiscordSendComponents, DiscordSendEmbeds } from "../../discord/send.shared.js";
import { resolveDiscordChannelId } from "../../discord/targets.js";
import { readBooleanParam } from "../../plugin-sdk/boolean-param.js";
import { resolvePollMaxSelections } from "../../polls.js";
import { withNormalizedTimestamp } from "../date-time.js";
import { assertMediaNotDataUrl } from "../sandbox-paths.js";
import {
type ActionGate,
jsonResult,
readNumberParam,
readReactionParams,
readStringArrayParam,
readStringParam,
@@ -126,9 +129,7 @@ export async function handleDiscordMessagingAction(
const messageId = readStringParam(params, "messageId", {
required: true,
});
const limitRaw = params.limit;
const limit =
typeof limitRaw === "number" && Number.isFinite(limitRaw) ? limitRaw : undefined;
const limit = readNumberParam(params, "limit");
const reactions = await fetchReactionsDiscord(channelId, messageId, {
...cfgOptions,
...(accountId ? { accountId } : {}),
@@ -166,13 +167,9 @@ export async function handleDiscordMessagingAction(
required: true,
label: "answers",
});
const allowMultiselectRaw = params.allowMultiselect;
const allowMultiselect =
typeof allowMultiselectRaw === "boolean" ? allowMultiselectRaw : undefined;
const durationRaw = params.durationHours;
const durationHours =
typeof durationRaw === "number" && Number.isFinite(durationRaw) ? durationRaw : undefined;
const maxSelections = allowMultiselect ? Math.max(2, answers.length) : 1;
const allowMultiselect = readBooleanParam(params, "allowMultiselect");
const durationHours = readNumberParam(params, "durationHours");
const maxSelections = resolvePollMaxSelections(answers.length, allowMultiselect);
await sendPollDiscord(
to,
{ question, options: answers, maxSelections, durationHours },
@@ -226,10 +223,7 @@ export async function handleDiscordMessagingAction(
}
const channelId = resolveChannelId();
const query = {
limit:
typeof params.limit === "number" && Number.isFinite(params.limit)
? params.limit
: undefined,
limit: readNumberParam(params, "limit"),
before: readStringParam(params, "before"),
after: readStringParam(params, "after"),
around: readStringParam(params, "around"),
@@ -372,11 +366,7 @@ export async function handleDiscordMessagingAction(
const name = readStringParam(params, "name", { required: true });
const messageId = readStringParam(params, "messageId");
const content = readStringParam(params, "content");
const autoArchiveMinutesRaw = params.autoArchiveMinutes;
const autoArchiveMinutes =
typeof autoArchiveMinutesRaw === "number" && Number.isFinite(autoArchiveMinutesRaw)
? autoArchiveMinutesRaw
: undefined;
const autoArchiveMinutes = readNumberParam(params, "autoArchiveMinutes");
const appliedTags = readStringArrayParam(params, "appliedTags");
const payload = {
name,
@@ -398,13 +388,9 @@ export async function handleDiscordMessagingAction(
required: true,
});
const channelId = readStringParam(params, "channelId");
const includeArchived =
typeof params.includeArchived === "boolean" ? params.includeArchived : undefined;
const includeArchived = readBooleanParam(params, "includeArchived");
const before = readStringParam(params, "before");
const limit =
typeof params.limit === "number" && Number.isFinite(params.limit)
? params.limit
: undefined;
const limit = readNumberParam(params, "limit");
const threads = accountId
? await listThreadsDiscord(
{
@@ -498,10 +484,7 @@ export async function handleDiscordMessagingAction(
const channelIds = readStringArrayParam(params, "channelIds");
const authorId = readStringParam(params, "authorId");
const authorIds = readStringArrayParam(params, "authorIds");
const limit =
typeof params.limit === "number" && Number.isFinite(params.limit)
? params.limit
: undefined;
const limit = readNumberParam(params, "limit");
const channelIdList = [...(channelIds ?? []), ...(channelId ? [channelId] : [])];
const authorIdList = [...(authorIds ?? []), ...(authorId ? [authorId] : [])];
const results = accountId

View File

@@ -61,6 +61,7 @@ const {
removeReactionDiscord,
searchMessagesDiscord,
sendMessageDiscord,
sendPollDiscord,
sendVoiceMessageDiscord,
setChannelPermissionDiscord,
timeoutMemberDiscord,
@@ -166,6 +167,31 @@ describe("handleDiscordMessagingAction", () => {
).rejects.toThrow(/Discord reactions are disabled/);
});
it("parses string booleans for poll options", async () => {
await handleDiscordMessagingAction(
"poll",
{
to: "channel:123",
question: "Lunch?",
answers: ["Pizza", "Sushi"],
allowMultiselect: "true",
durationHours: "24",
},
enableAllActions,
);
expect(sendPollDiscord).toHaveBeenCalledWith(
"channel:123",
{
question: "Lunch?",
options: ["Pizza", "Sushi"],
maxSelections: 2,
durationHours: 24,
},
expect.any(Object),
);
});
it("adds normalized timestamps to readMessages payloads", async () => {
readMessagesDiscord.mockResolvedValueOnce([
{ id: "1", timestamp: "2026-01-15T10:00:00.000Z" },

View File

@@ -1,5 +1,5 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import type { ChannelPlugin } from "../../channels/plugins/types.js";
import type { ChannelMessageActionName, ChannelPlugin } from "../../channels/plugins/types.js";
import type { MessageActionRunResult } from "../../infra/outbound/message-action-runner.js";
import { setActivePluginRegistry } from "../../plugins/runtime.js";
import { createTestRegistry } from "../../test-utils/channel-plugins.js";
@@ -45,7 +45,8 @@ function createChannelPlugin(params: {
label: string;
docsPath: string;
blurb: string;
actions: string[];
actions?: ChannelMessageActionName[];
listActions?: NonNullable<NonNullable<ChannelPlugin["actions"]>["listActions"]>;
supportsButtons?: boolean;
messaging?: ChannelPlugin["messaging"];
}): ChannelPlugin {
@@ -65,7 +66,11 @@ function createChannelPlugin(params: {
},
...(params.messaging ? { messaging: params.messaging } : {}),
actions: {
listActions: () => params.actions as never,
listActions:
params.listActions ??
(() => {
return (params.actions ?? []) as never;
}),
...(params.supportsButtons ? { supportsButtons: () => true } : {}),
},
};
@@ -139,7 +144,7 @@ describe("message tool schema scoping", () => {
label: "Telegram",
docsPath: "/channels/telegram",
blurb: "Telegram test plugin.",
actions: ["send", "react"],
actions: ["send", "react", "poll"],
supportsButtons: true,
});
@@ -161,6 +166,7 @@ describe("message tool schema scoping", () => {
expectComponents: false,
expectButtons: true,
expectButtonStyle: true,
expectTelegramPollExtras: true,
expectedActions: ["send", "react", "poll", "poll-vote"],
},
{
@@ -168,11 +174,19 @@ describe("message tool schema scoping", () => {
expectComponents: true,
expectButtons: false,
expectButtonStyle: false,
expectTelegramPollExtras: true,
expectedActions: ["send", "poll", "poll-vote", "react"],
},
])(
"scopes schema fields for $provider",
({ provider, expectComponents, expectButtons, expectButtonStyle, expectedActions }) => {
({
provider,
expectComponents,
expectButtons,
expectButtonStyle,
expectTelegramPollExtras,
expectedActions,
}) => {
setActivePluginRegistry(
createTestRegistry([
{ pluginId: "telegram", source: "test", plugin: telegramPlugin },
@@ -209,11 +223,75 @@ describe("message tool schema scoping", () => {
for (const action of expectedActions) {
expect(actionEnum).toContain(action);
}
if (expectTelegramPollExtras) {
expect(properties.pollDurationSeconds).toBeDefined();
expect(properties.pollAnonymous).toBeDefined();
expect(properties.pollPublic).toBeDefined();
} else {
expect(properties.pollDurationSeconds).toBeUndefined();
expect(properties.pollAnonymous).toBeUndefined();
expect(properties.pollPublic).toBeUndefined();
}
expect(properties.pollId).toBeDefined();
expect(properties.pollOptionIndex).toBeDefined();
expect(properties.pollOptionId).toBeDefined();
},
);
it("includes poll in the action enum when the current channel supports poll actions", () => {
setActivePluginRegistry(
createTestRegistry([{ pluginId: "telegram", source: "test", plugin: telegramPlugin }]),
);
const tool = createMessageTool({
config: {} as never,
currentChannelProvider: "telegram",
});
const actionEnum = getActionEnum(getToolProperties(tool));
expect(actionEnum).toContain("poll");
});
it("hides telegram poll extras when telegram polls are disabled in scoped mode", () => {
const telegramPluginWithConfig = createChannelPlugin({
id: "telegram",
label: "Telegram",
docsPath: "/channels/telegram",
blurb: "Telegram test plugin.",
listActions: ({ cfg }) => {
const telegramCfg = (cfg as { channels?: { telegram?: { actions?: { poll?: boolean } } } })
.channels?.telegram;
return telegramCfg?.actions?.poll === false ? ["send", "react"] : ["send", "react", "poll"];
},
supportsButtons: true,
});
setActivePluginRegistry(
createTestRegistry([
{ pluginId: "telegram", source: "test", plugin: telegramPluginWithConfig },
]),
);
const tool = createMessageTool({
config: {
channels: {
telegram: {
actions: {
poll: false,
},
},
},
} as never,
currentChannelProvider: "telegram",
});
const properties = getToolProperties(tool);
const actionEnum = getActionEnum(properties);
expect(actionEnum).not.toContain("poll");
expect(properties.pollDurationSeconds).toBeUndefined();
expect(properties.pollAnonymous).toBeUndefined();
expect(properties.pollPublic).toBeUndefined();
});
});
describe("message tool description", () => {

View File

@@ -17,6 +17,7 @@ import { loadConfig } from "../../config/config.js";
import { GATEWAY_CLIENT_IDS, GATEWAY_CLIENT_MODES } from "../../gateway/protocol/client-info.js";
import { getToolResult, runMessageAction } from "../../infra/outbound/message-action-runner.js";
import { normalizeTargetForProvider } from "../../infra/outbound/target-normalization.js";
import { POLL_CREATION_PARAM_DEFS, POLL_CREATION_PARAM_NAMES } from "../../poll-params.js";
import { normalizeAccountId } from "../../routing/session-key.js";
import { stripReasoningTagsFromText } from "../../shared/text/reasoning-tags.js";
import { normalizeMessageChannel } from "../../utils/message-channel.js";
@@ -271,12 +272,8 @@ function buildFetchSchema() {
};
}
function buildPollSchema() {
return {
pollQuestion: Type.Optional(Type.String()),
pollOption: Type.Optional(Type.Array(Type.String())),
pollDurationHours: Type.Optional(Type.Number()),
pollMulti: Type.Optional(Type.Boolean()),
function buildPollSchema(options?: { includeTelegramExtras?: boolean }) {
const props: Record<string, unknown> = {
pollId: Type.Optional(Type.String()),
pollOptionId: Type.Optional(
Type.String({
@@ -306,6 +303,27 @@ function buildPollSchema() {
),
),
};
for (const name of POLL_CREATION_PARAM_NAMES) {
const def = POLL_CREATION_PARAM_DEFS[name];
if (def.telegramOnly && !options?.includeTelegramExtras) {
continue;
}
switch (def.kind) {
case "string":
props[name] = Type.Optional(Type.String());
break;
case "stringArray":
props[name] = Type.Optional(Type.Array(Type.String()));
break;
case "number":
props[name] = Type.Optional(Type.Number());
break;
case "boolean":
props[name] = Type.Optional(Type.Boolean());
break;
}
}
return props;
}
function buildChannelTargetSchema() {
@@ -425,13 +443,14 @@ function buildMessageToolSchemaProps(options: {
includeButtons: boolean;
includeCards: boolean;
includeComponents: boolean;
includeTelegramPollExtras: boolean;
}) {
return {
...buildRoutingSchema(),
...buildSendSchema(options),
...buildReactionSchema(),
...buildFetchSchema(),
...buildPollSchema(),
...buildPollSchema({ includeTelegramExtras: options.includeTelegramPollExtras }),
...buildChannelTargetSchema(),
...buildStickerSchema(),
...buildThreadSchema(),
@@ -445,7 +464,12 @@ function buildMessageToolSchemaProps(options: {
function buildMessageToolSchemaFromActions(
actions: readonly string[],
options: { includeButtons: boolean; includeCards: boolean; includeComponents: boolean },
options: {
includeButtons: boolean;
includeCards: boolean;
includeComponents: boolean;
includeTelegramPollExtras: boolean;
},
) {
const props = buildMessageToolSchemaProps(options);
return Type.Object({
@@ -458,6 +482,7 @@ const MessageToolSchema = buildMessageToolSchemaFromActions(AllMessageActions, {
includeButtons: true,
includeCards: true,
includeComponents: true,
includeTelegramPollExtras: true,
});
type MessageToolOptions = {
@@ -519,6 +544,16 @@ function resolveIncludeComponents(params: {
return listChannelSupportedActions({ cfg: params.cfg, channel: "discord" }).length > 0;
}
function resolveIncludeTelegramPollExtras(params: {
cfg: OpenClawConfig;
currentChannelProvider?: string;
}): boolean {
return listChannelSupportedActions({
cfg: params.cfg,
channel: "telegram",
}).includes("poll");
}
function buildMessageToolSchema(params: {
cfg: OpenClawConfig;
currentChannelProvider?: string;
@@ -533,10 +568,12 @@ function buildMessageToolSchema(params: {
? supportsChannelMessageCardsForChannel({ cfg: params.cfg, channel: currentChannel })
: supportsChannelMessageCards(params.cfg);
const includeComponents = resolveIncludeComponents(params);
const includeTelegramPollExtras = resolveIncludeTelegramPollExtras(params);
return buildMessageToolSchemaFromActions(actions.length > 0 ? actions : ["send"], {
includeButtons,
includeCards,
includeComponents,
includeTelegramPollExtras,
});
}

View File

@@ -8,6 +8,11 @@ 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",
@@ -20,6 +25,7 @@ vi.mock("../../telegram/send.js", () => ({
reactMessageTelegram(...args),
sendMessageTelegram: (...args: Parameters<typeof sendMessageTelegram>) =>
sendMessageTelegram(...args),
sendPollTelegram: (...args: Parameters<typeof sendPollTelegram>) => sendPollTelegram(...args),
sendStickerTelegram: (...args: Parameters<typeof sendStickerTelegram>) =>
sendStickerTelegram(...args),
deleteMessageTelegram: (...args: Parameters<typeof deleteMessageTelegram>) =>
@@ -81,6 +87,7 @@ describe("handleTelegramAction", () => {
envSnapshot = captureEnv(["TELEGRAM_BOT_TOKEN"]);
reactMessageTelegram.mockClear();
sendMessageTelegram.mockClear();
sendPollTelegram.mockClear();
sendStickerTelegram.mockClear();
deleteMessageTelegram.mockClear();
process.env.TELEGRAM_BOT_TOKEN = "tok";
@@ -291,6 +298,70 @@ describe("handleTelegramAction", () => {
});
});
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(
{
@@ -390,6 +461,25 @@ describe("handleTelegramAction", () => {
).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" } },

View File

@@ -1,6 +1,11 @@
import type { AgentToolResult } from "@mariozechner/pi-agent-core";
import type { OpenClawConfig } from "../../config/config.js";
import { createTelegramActionGate } from "../../telegram/accounts.js";
import { readBooleanParam } from "../../plugin-sdk/boolean-param.js";
import { resolvePollMaxSelections } from "../../polls.js";
import {
createTelegramActionGate,
resolveTelegramPollActionGateState,
} from "../../telegram/accounts.js";
import type { TelegramButtonStyle, TelegramInlineButtons } from "../../telegram/button-types.js";
import {
resolveTelegramInlineButtonsScope,
@@ -13,6 +18,7 @@ import {
editMessageTelegram,
reactMessageTelegram,
sendMessageTelegram,
sendPollTelegram,
sendStickerTelegram,
} from "../../telegram/send.js";
import { getCacheStats, searchStickers } from "../../telegram/sticker-cache.js";
@@ -21,6 +27,7 @@ import {
jsonResult,
readNumberParam,
readReactionParams,
readStringArrayParam,
readStringOrNumberParam,
readStringParam,
} from "./common.js";
@@ -238,8 +245,8 @@ export async function handleTelegramAction(
replyToMessageId: replyToMessageId ?? undefined,
messageThreadId: messageThreadId ?? undefined,
quoteText: quoteText ?? undefined,
asVoice: typeof params.asVoice === "boolean" ? params.asVoice : undefined,
silent: typeof params.silent === "boolean" ? params.silent : undefined,
asVoice: readBooleanParam(params, "asVoice"),
silent: readBooleanParam(params, "silent"),
});
return jsonResult({
ok: true,
@@ -248,6 +255,60 @@ export async function handleTelegramAction(
});
}
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 sendPollTelegram(
to,
{
question,
options: answers,
maxSelections: resolvePollMaxSelections(answers.length, allowMultiselect),
durationSeconds: durationSeconds ?? undefined,
durationHours: durationHours ?? undefined,
},
{
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.");