mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-02 01:30:21 +00:00
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:
committed by
GitHub
parent
f771ba8de9
commit
6dfd39c32f
@@ -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([]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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" },
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -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" } },
|
||||
|
||||
@@ -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.");
|
||||
|
||||
Reference in New Issue
Block a user