mirror of https://github.com/openclaw/openclaw.git
fix: restore telegram poll actions
This commit is contained in:
parent
f771ba8de9
commit
47f3883419
|
|
@ -10,6 +10,7 @@ title: "Polls"
|
|||
|
||||
## Supported channels
|
||||
|
||||
- Telegram
|
||||
- WhatsApp (web channel)
|
||||
- Discord
|
||||
- MS Teams (Adaptive Cards)
|
||||
|
|
@ -17,6 +18,13 @@ title: "Polls"
|
|||
## CLI
|
||||
|
||||
```bash
|
||||
# Telegram
|
||||
openclaw message poll --channel telegram --target 123456789 \
|
||||
--poll-question "Ship it?" --poll-option "Yes" --poll-option "No"
|
||||
openclaw message poll --channel telegram --target -1001234567890:topic:42 \
|
||||
--poll-question "Pick a time" --poll-option "10am" --poll-option "2pm" \
|
||||
--poll-duration-seconds 300
|
||||
|
||||
# WhatsApp
|
||||
openclaw message poll --target +15555550123 \
|
||||
--poll-question "Lunch today?" --poll-option "Yes" --poll-option "No" --poll-option "Maybe"
|
||||
|
|
@ -36,9 +44,11 @@ openclaw message poll --channel msteams --target conversation:19:abc@thread.tacv
|
|||
|
||||
Options:
|
||||
|
||||
- `--channel`: `whatsapp` (default), `discord`, or `msteams`
|
||||
- `--channel`: `whatsapp` (default), `telegram`, `discord`, or `msteams`
|
||||
- `--poll-multi`: allow selecting multiple options
|
||||
- `--poll-duration-hours`: Discord-only (defaults to 24 when omitted)
|
||||
- `--poll-duration-seconds`: Telegram-only (5-600 seconds)
|
||||
- `--poll-anonymous` / `--poll-public`: Telegram-only poll visibility
|
||||
|
||||
## Gateway RPC
|
||||
|
||||
|
|
@ -51,11 +61,14 @@ Params:
|
|||
- `options` (string[], required)
|
||||
- `maxSelections` (number, optional)
|
||||
- `durationHours` (number, optional)
|
||||
- `durationSeconds` (number, optional, Telegram-only)
|
||||
- `isAnonymous` (boolean, optional, Telegram-only)
|
||||
- `channel` (string, optional, default: `whatsapp`)
|
||||
- `idempotencyKey` (string, required)
|
||||
|
||||
## Channel differences
|
||||
|
||||
- Telegram: 2-10 options. Supports forum topics via `threadId` or `:topic:` targets. Uses `durationSeconds` instead of `durationHours`, limited to 5-600 seconds. Supports anonymous and public polls.
|
||||
- WhatsApp: 2-12 options, `maxSelections` must be within option count, ignores `durationHours`.
|
||||
- Discord: 2-10 options, `durationHours` clamped to 1-768 hours (default 24). `maxSelections > 1` enables multi-select; Discord does not support a strict selection count.
|
||||
- MS Teams: Adaptive Card polls (OpenClaw-managed). No native poll API; `durationHours` is ignored.
|
||||
|
|
@ -64,6 +77,10 @@ Params:
|
|||
|
||||
Use the `message` tool with `poll` action (`to`, `pollQuestion`, `pollOption`, optional `pollMulti`, `pollDurationHours`, `channel`).
|
||||
|
||||
For Telegram, the tool also accepts `pollDurationSeconds`, `pollAnonymous`, and `pollPublic`.
|
||||
|
||||
Use `action: "poll"` for poll creation. Poll fields passed with `action: "send"` are rejected.
|
||||
|
||||
Note: Discord has no “pick exactly N” mode; `pollMulti` maps to multi-select.
|
||||
Teams polls are rendered as Adaptive Cards and require the gateway to stay online
|
||||
to record votes in `~/.openclaw/msteams-polls.json`.
|
||||
|
|
|
|||
|
|
@ -732,6 +732,28 @@ openclaw message send --channel telegram --target 123456789 --message "hi"
|
|||
openclaw message send --channel telegram --target @name --message "hi"
|
||||
```
|
||||
|
||||
Telegram polls use `openclaw message poll` and support forum topics:
|
||||
|
||||
```bash
|
||||
openclaw message poll --channel telegram --target 123456789 \
|
||||
--poll-question "Ship it?" --poll-option "Yes" --poll-option "No"
|
||||
openclaw message poll --channel telegram --target -1001234567890:topic:42 \
|
||||
--poll-question "Pick a time" --poll-option "10am" --poll-option "2pm" \
|
||||
--poll-duration-seconds 300 --poll-public
|
||||
```
|
||||
|
||||
Telegram-only poll flags:
|
||||
|
||||
- `--poll-duration-seconds` (5-600)
|
||||
- `--poll-anonymous`
|
||||
- `--poll-public`
|
||||
- `--thread-id` for forum topics (or use a `:topic:` target)
|
||||
|
||||
Action gating:
|
||||
|
||||
- `channels.telegram.actions.sendMessage=false` disables outbound Telegram messages, including polls
|
||||
- `channels.telegram.actions.poll=false` disables Telegram poll creation while leaving regular sends enabled
|
||||
|
||||
</Accordion>
|
||||
</AccordionGroup>
|
||||
|
||||
|
|
@ -813,6 +835,7 @@ Primary reference:
|
|||
- `channels.telegram.tokenFile`: read token from file path.
|
||||
- `channels.telegram.dmPolicy`: `pairing | allowlist | open | disabled` (default: pairing).
|
||||
- `channels.telegram.allowFrom`: DM allowlist (numeric Telegram user IDs). `allowlist` requires at least one sender ID. `open` requires `"*"`. `openclaw doctor --fix` can resolve legacy `@username` entries to IDs and can recover allowlist entries from pairing-store files in allowlist migration flows.
|
||||
- `channels.telegram.actions.poll`: enable or disable Telegram poll creation (default: enabled; still requires `sendMessage`).
|
||||
- `channels.telegram.defaultTo`: default Telegram target used by CLI `--deliver` when no explicit `--reply-to` is provided.
|
||||
- `channels.telegram.groupPolicy`: `open | allowlist | disabled` (default: allowlist).
|
||||
- `channels.telegram.groupAllowFrom`: group sender allowlist (numeric Telegram user IDs). `openclaw doctor --fix` can resolve legacy `@username` entries to IDs. Non-numeric entries are ignored at auth time. Group auth does not use DM pairing-store fallback (`2026.2.25+`).
|
||||
|
|
|
|||
|
|
@ -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([]);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -26,6 +26,7 @@ import {
|
|||
} from "../../discord/send.js";
|
||||
import type { DiscordSendComponents, DiscordSendEmbeds } from "../../discord/send.shared.js";
|
||||
import { resolveDiscordChannelId } from "../../discord/targets.js";
|
||||
import { resolvePollMaxSelections } from "../../polls.js";
|
||||
import { withNormalizedTimestamp } from "../date-time.js";
|
||||
import { assertMediaNotDataUrl } from "../sandbox-paths.js";
|
||||
import {
|
||||
|
|
@ -172,7 +173,7 @@ export async function handleDiscordMessagingAction(
|
|||
const durationRaw = params.durationHours;
|
||||
const durationHours =
|
||||
typeof durationRaw === "number" && Number.isFinite(durationRaw) ? durationRaw : undefined;
|
||||
const maxSelections = allowMultiselect ? Math.max(2, answers.length) : 1;
|
||||
const maxSelections = resolvePollMaxSelections(answers.length, allowMultiselect);
|
||||
await sendPollDiscord(
|
||||
to,
|
||||
{ question, options: answers, maxSelections, durationHours },
|
||||
|
|
|
|||
|
|
@ -139,7 +139,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 +161,7 @@ describe("message tool schema scoping", () => {
|
|||
expectComponents: false,
|
||||
expectButtons: true,
|
||||
expectButtonStyle: true,
|
||||
expectTelegramPollExtras: true,
|
||||
expectedActions: ["send", "react", "poll", "poll-vote"],
|
||||
},
|
||||
{
|
||||
|
|
@ -168,11 +169,19 @@ describe("message tool schema scoping", () => {
|
|||
expectComponents: true,
|
||||
expectButtons: false,
|
||||
expectButtonStyle: false,
|
||||
expectTelegramPollExtras: false,
|
||||
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 +218,34 @@ 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");
|
||||
});
|
||||
});
|
||||
|
||||
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,17 @@ function resolveIncludeComponents(params: {
|
|||
return listChannelSupportedActions({ cfg: params.cfg, channel: "discord" }).length > 0;
|
||||
}
|
||||
|
||||
function resolveIncludeTelegramPollExtras(params: {
|
||||
cfg: OpenClawConfig;
|
||||
currentChannelProvider?: string;
|
||||
}): boolean {
|
||||
const currentChannel = normalizeMessageChannel(params.currentChannelProvider);
|
||||
if (currentChannel) {
|
||||
return currentChannel === "telegram";
|
||||
}
|
||||
return listChannelSupportedActions({ cfg: params.cfg, channel: "telegram" }).includes("poll");
|
||||
}
|
||||
|
||||
function buildMessageToolSchema(params: {
|
||||
cfg: OpenClawConfig;
|
||||
currentChannelProvider?: string;
|
||||
|
|
@ -533,10 +569,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,43 @@ 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("forwards trusted mediaLocalRoots into sendMessageTelegram", async () => {
|
||||
await handleTelegramAction(
|
||||
{
|
||||
|
|
@ -390,6 +434,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,5 +1,6 @@
|
|||
import type { AgentToolResult } from "@mariozechner/pi-agent-core";
|
||||
import type { OpenClawConfig } from "../../config/config.js";
|
||||
import { resolvePollMaxSelections } from "../../polls.js";
|
||||
import { createTelegramActionGate } from "../../telegram/accounts.js";
|
||||
import type { TelegramButtonStyle, TelegramInlineButtons } from "../../telegram/button-types.js";
|
||||
import {
|
||||
|
|
@ -13,6 +14,7 @@ import {
|
|||
editMessageTelegram,
|
||||
reactMessageTelegram,
|
||||
sendMessageTelegram,
|
||||
sendPollTelegram,
|
||||
sendStickerTelegram,
|
||||
} from "../../telegram/send.js";
|
||||
import { getCacheStats, searchStickers } from "../../telegram/sticker-cache.js";
|
||||
|
|
@ -21,6 +23,7 @@ import {
|
|||
jsonResult,
|
||||
readNumberParam,
|
||||
readReactionParams,
|
||||
readStringArrayParam,
|
||||
readStringOrNumberParam,
|
||||
readStringParam,
|
||||
} from "./common.js";
|
||||
|
|
@ -248,6 +251,60 @@ export async function handleTelegramAction(
|
|||
});
|
||||
}
|
||||
|
||||
if (action === "poll") {
|
||||
if (!isActionEnabled("sendMessage")) {
|
||||
throw new Error("Telegram sendMessage is disabled.");
|
||||
}
|
||||
if (!isActionEnabled("poll")) {
|
||||
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 =
|
||||
typeof params.allowMultiselect === "boolean" ? 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 = typeof params.isAnonymous === "boolean" ? params.isAnonymous : undefined;
|
||||
const silent = typeof params.silent === "boolean" ? params.silent : undefined;
|
||||
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.");
|
||||
|
|
|
|||
|
|
@ -496,6 +496,42 @@ describe("handleDiscordMessageAction", () => {
|
|||
});
|
||||
|
||||
describe("telegramMessageActions", () => {
|
||||
it("lists poll when telegram is configured", () => {
|
||||
const actions = telegramMessageActions.listActions?.({ cfg: telegramCfg() }) ?? [];
|
||||
|
||||
expect(actions).toContain("poll");
|
||||
});
|
||||
|
||||
it("omits poll when sendMessage is disabled", () => {
|
||||
const cfg = {
|
||||
channels: {
|
||||
telegram: {
|
||||
botToken: "tok",
|
||||
actions: { sendMessage: false },
|
||||
},
|
||||
},
|
||||
} as OpenClawConfig;
|
||||
|
||||
const actions = telegramMessageActions.listActions?.({ cfg }) ?? [];
|
||||
|
||||
expect(actions).not.toContain("poll");
|
||||
});
|
||||
|
||||
it("omits poll when poll actions are disabled", () => {
|
||||
const cfg = {
|
||||
channels: {
|
||||
telegram: {
|
||||
botToken: "tok",
|
||||
actions: { poll: false },
|
||||
},
|
||||
},
|
||||
} as OpenClawConfig;
|
||||
|
||||
const actions = telegramMessageActions.listActions?.({ cfg }) ?? [];
|
||||
|
||||
expect(actions).not.toContain("poll");
|
||||
});
|
||||
|
||||
it("lists sticker actions only when enabled by config", () => {
|
||||
const cases = [
|
||||
{
|
||||
|
|
@ -595,6 +631,35 @@ describe("telegramMessageActions", () => {
|
|||
accountId: undefined,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "poll maps to telegram poll action",
|
||||
action: "poll" as const,
|
||||
params: {
|
||||
to: "123",
|
||||
pollQuestion: "Ready?",
|
||||
pollOption: ["Yes", "No"],
|
||||
pollMulti: true,
|
||||
pollDurationSeconds: 60,
|
||||
pollPublic: true,
|
||||
replyTo: 55,
|
||||
threadId: 77,
|
||||
silent: true,
|
||||
},
|
||||
expectedPayload: {
|
||||
action: "poll",
|
||||
to: "123",
|
||||
question: "Ready?",
|
||||
answers: ["Yes", "No"],
|
||||
allowMultiselect: true,
|
||||
durationHours: undefined,
|
||||
durationSeconds: 60,
|
||||
replyToMessageId: 55,
|
||||
messageThreadId: 77,
|
||||
isAnonymous: false,
|
||||
silent: true,
|
||||
accountId: undefined,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "topic-create maps to createForumTopic",
|
||||
action: "topic-create" as const,
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@ import {
|
|||
import { handleTelegramAction } from "../../../agents/tools/telegram-actions.js";
|
||||
import type { TelegramActionConfig } from "../../../config/types.telegram.js";
|
||||
import { extractToolSend } from "../../../plugin-sdk/tool-send.js";
|
||||
import { resolveTelegramPollVisibility } from "../../../poll-params.js";
|
||||
import {
|
||||
createTelegramActionGate,
|
||||
listEnabledTelegramAccounts,
|
||||
|
|
@ -78,6 +79,9 @@ export const telegramMessageActions: ChannelMessageActionAdapter = {
|
|||
const isEnabled = (key: keyof TelegramActionConfig, defaultValue = true) =>
|
||||
gate(key, defaultValue);
|
||||
const actions = new Set<ChannelMessageActionName>(["send"]);
|
||||
if (isEnabled("sendMessage") && isEnabled("poll")) {
|
||||
actions.add("poll");
|
||||
}
|
||||
if (isEnabled("reactions")) {
|
||||
actions.add("react");
|
||||
}
|
||||
|
|
@ -140,6 +144,44 @@ export const telegramMessageActions: ChannelMessageActionAdapter = {
|
|||
);
|
||||
}
|
||||
|
||||
if (action === "poll") {
|
||||
const to = readStringParam(params, "to", { required: true });
|
||||
const question = readStringParam(params, "pollQuestion", { required: true });
|
||||
const answers = readStringArrayParam(params, "pollOption", { required: true }) ?? [];
|
||||
const durationHours = readNumberParam(params, "pollDurationHours", {
|
||||
integer: true,
|
||||
});
|
||||
const durationSeconds = readNumberParam(params, "pollDurationSeconds", {
|
||||
integer: true,
|
||||
});
|
||||
const replyToMessageId = readNumberParam(params, "replyTo", { integer: true });
|
||||
const messageThreadId = readNumberParam(params, "threadId", { integer: true });
|
||||
const allowMultiselect = typeof params.pollMulti === "boolean" ? params.pollMulti : undefined;
|
||||
const pollAnonymous =
|
||||
typeof params.pollAnonymous === "boolean" ? params.pollAnonymous : undefined;
|
||||
const pollPublic = typeof params.pollPublic === "boolean" ? params.pollPublic : undefined;
|
||||
const isAnonymous = resolveTelegramPollVisibility({ pollAnonymous, pollPublic });
|
||||
const silent = typeof params.silent === "boolean" ? params.silent : undefined;
|
||||
return await handleTelegramAction(
|
||||
{
|
||||
action: "poll",
|
||||
to,
|
||||
question,
|
||||
answers,
|
||||
allowMultiselect,
|
||||
durationHours: durationHours ?? undefined,
|
||||
durationSeconds: durationSeconds ?? undefined,
|
||||
replyToMessageId: replyToMessageId ?? undefined,
|
||||
messageThreadId: messageThreadId ?? undefined,
|
||||
isAnonymous,
|
||||
silent,
|
||||
accountId: accountId ?? undefined,
|
||||
},
|
||||
cfg,
|
||||
{ mediaLocalRoots },
|
||||
);
|
||||
}
|
||||
|
||||
if (action === "delete") {
|
||||
const chatId = readTelegramChatIdParam(params);
|
||||
const messageId = readTelegramMessageIdParam(params);
|
||||
|
|
|
|||
|
|
@ -336,6 +336,12 @@ export type ChannelToolSend = {
|
|||
};
|
||||
|
||||
export type ChannelMessageActionAdapter = {
|
||||
/**
|
||||
* Advertise agent-discoverable actions for this channel.
|
||||
* Keep this aligned with any gated capability checks. Poll discovery is
|
||||
* not inferred from `outbound.sendPoll`, so channels that want agents to
|
||||
* create polls should include `"poll"` here when enabled.
|
||||
*/
|
||||
listActions?: (params: { cfg: OpenClawConfig }) => ChannelMessageActionName[];
|
||||
supportsAction?: (params: { action: ChannelMessageActionName }) => boolean;
|
||||
supportsButtons?: (params: { cfg: OpenClawConfig }) => boolean;
|
||||
|
|
|
|||
|
|
@ -166,6 +166,24 @@ const createTelegramSendPluginRegistration = () => ({
|
|||
}),
|
||||
});
|
||||
|
||||
const createTelegramPollPluginRegistration = () => ({
|
||||
pluginId: "telegram",
|
||||
source: "test",
|
||||
plugin: createStubPlugin({
|
||||
id: "telegram",
|
||||
label: "Telegram",
|
||||
actions: {
|
||||
listActions: () => ["poll"],
|
||||
handleAction: (async ({ action, params, cfg, accountId }: ChannelActionParams) => {
|
||||
return await handleTelegramAction(
|
||||
{ action, to: params.to, accountId: accountId ?? undefined },
|
||||
cfg,
|
||||
);
|
||||
}) as unknown as NonNullable<ChannelPlugin["actions"]>["handleAction"],
|
||||
},
|
||||
}),
|
||||
});
|
||||
|
||||
const { messageCommand } = await import("./message.js");
|
||||
|
||||
describe("messageCommand", () => {
|
||||
|
|
@ -468,4 +486,34 @@ describe("messageCommand", () => {
|
|||
expect.any(Object),
|
||||
);
|
||||
});
|
||||
|
||||
it("routes telegram polls through message action", async () => {
|
||||
await setRegistry(
|
||||
createTestRegistry([
|
||||
{
|
||||
...createTelegramPollPluginRegistration(),
|
||||
},
|
||||
]),
|
||||
);
|
||||
const deps = makeDeps();
|
||||
await messageCommand(
|
||||
{
|
||||
action: "poll",
|
||||
channel: "telegram",
|
||||
target: "123456789",
|
||||
pollQuestion: "Ship it?",
|
||||
pollOption: ["Yes", "No"],
|
||||
pollDurationSeconds: 120,
|
||||
},
|
||||
deps,
|
||||
runtime,
|
||||
);
|
||||
expect(handleTelegramAction).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
action: "poll",
|
||||
to: "123456789",
|
||||
}),
|
||||
expect.any(Object),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -14,6 +14,8 @@ import type { GroupToolPolicyBySenderConfig, GroupToolPolicyConfig } from "./typ
|
|||
export type TelegramActionConfig = {
|
||||
reactions?: boolean;
|
||||
sendMessage?: boolean;
|
||||
/** Enable poll creation. Requires sendMessage to also be enabled. */
|
||||
poll?: boolean;
|
||||
deleteMessage?: boolean;
|
||||
editMessage?: boolean;
|
||||
/** Enable sticker actions (send and search). */
|
||||
|
|
|
|||
|
|
@ -236,6 +236,22 @@ describe("runMessageAction context isolation", () => {
|
|||
).rejects.toThrow(/message required/i);
|
||||
});
|
||||
|
||||
it("rejects send actions that include poll creation params", async () => {
|
||||
await expect(
|
||||
runDrySend({
|
||||
cfg: slackConfig,
|
||||
actionParams: {
|
||||
channel: "slack",
|
||||
target: "#C12345678",
|
||||
message: "hi",
|
||||
pollQuestion: "Ready?",
|
||||
pollOption: ["Yes", "No"],
|
||||
},
|
||||
toolContext: { currentChannelId: "C12345678" },
|
||||
}),
|
||||
).rejects.toThrow(/use action "poll" instead of "send"/i);
|
||||
});
|
||||
|
||||
it("blocks send when target differs from current channel", async () => {
|
||||
const result = await runDrySend({
|
||||
cfg: slackConfig,
|
||||
|
|
@ -902,6 +918,114 @@ describe("runMessageAction card-only send behavior", () => {
|
|||
});
|
||||
});
|
||||
|
||||
describe("runMessageAction telegram plugin poll forwarding", () => {
|
||||
const handleAction = vi.fn(async ({ params }: { params: Record<string, unknown> }) =>
|
||||
jsonResult({
|
||||
ok: true,
|
||||
forwarded: {
|
||||
to: params.to ?? null,
|
||||
pollQuestion: params.pollQuestion ?? null,
|
||||
pollOption: params.pollOption ?? null,
|
||||
pollDurationSeconds: params.pollDurationSeconds ?? null,
|
||||
pollPublic: params.pollPublic ?? null,
|
||||
threadId: params.threadId ?? null,
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
const telegramPollPlugin: ChannelPlugin = {
|
||||
id: "telegram",
|
||||
meta: {
|
||||
id: "telegram",
|
||||
label: "Telegram",
|
||||
selectionLabel: "Telegram",
|
||||
docsPath: "/channels/telegram",
|
||||
blurb: "Telegram poll forwarding test plugin.",
|
||||
},
|
||||
capabilities: { chatTypes: ["direct"] },
|
||||
config: createAlwaysConfiguredPluginConfig(),
|
||||
messaging: {
|
||||
targetResolver: {
|
||||
looksLikeId: () => true,
|
||||
},
|
||||
},
|
||||
actions: {
|
||||
listActions: () => ["poll"],
|
||||
supportsAction: ({ action }) => action === "poll",
|
||||
handleAction,
|
||||
},
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
setActivePluginRegistry(
|
||||
createTestRegistry([
|
||||
{
|
||||
pluginId: "telegram",
|
||||
source: "test",
|
||||
plugin: telegramPollPlugin,
|
||||
},
|
||||
]),
|
||||
);
|
||||
handleAction.mockClear();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
setActivePluginRegistry(createTestRegistry([]));
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it("forwards telegram poll params through plugin dispatch", async () => {
|
||||
const result = await runMessageAction({
|
||||
cfg: {
|
||||
channels: {
|
||||
telegram: {
|
||||
botToken: "tok",
|
||||
},
|
||||
},
|
||||
} as OpenClawConfig,
|
||||
action: "poll",
|
||||
params: {
|
||||
channel: "telegram",
|
||||
target: "telegram:123",
|
||||
pollQuestion: "Lunch?",
|
||||
pollOption: ["Pizza", "Sushi"],
|
||||
pollDurationSeconds: 120,
|
||||
pollPublic: true,
|
||||
threadId: "42",
|
||||
},
|
||||
dryRun: false,
|
||||
});
|
||||
|
||||
expect(result.kind).toBe("poll");
|
||||
expect(result.handledBy).toBe("plugin");
|
||||
expect(handleAction).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
action: "poll",
|
||||
channel: "telegram",
|
||||
params: expect.objectContaining({
|
||||
to: "telegram:123",
|
||||
pollQuestion: "Lunch?",
|
||||
pollOption: ["Pizza", "Sushi"],
|
||||
pollDurationSeconds: 120,
|
||||
pollPublic: true,
|
||||
threadId: "42",
|
||||
}),
|
||||
}),
|
||||
);
|
||||
expect(result.payload).toMatchObject({
|
||||
ok: true,
|
||||
forwarded: {
|
||||
to: "telegram:123",
|
||||
pollQuestion: "Lunch?",
|
||||
pollOption: ["Pizza", "Sushi"],
|
||||
pollDurationSeconds: 120,
|
||||
pollPublic: true,
|
||||
threadId: "42",
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("runMessageAction components parsing", () => {
|
||||
const handleAction = vi.fn(async ({ params }: { params: Record<string, unknown> }) =>
|
||||
jsonResult({
|
||||
|
|
|
|||
|
|
@ -14,6 +14,9 @@ import type {
|
|||
} from "../../channels/plugins/types.js";
|
||||
import type { OpenClawConfig } from "../../config/config.js";
|
||||
import { getAgentScopedMediaLocalRoots } from "../../media/local-roots.js";
|
||||
import { hasPollCreationParams } from "../../poll-params.js";
|
||||
import { resolveTelegramPollVisibility } from "../../poll-params.js";
|
||||
import { resolvePollMaxSelections } from "../../polls.js";
|
||||
import { buildChannelAccountBindings } from "../../routing/bindings.js";
|
||||
import { normalizeAgentId } from "../../routing/session-key.js";
|
||||
import { type GatewayClientMode, type GatewayClientName } from "../../utils/message-channel.js";
|
||||
|
|
@ -579,17 +582,14 @@ async function handlePollAction(ctx: ResolvedActionContext): Promise<MessageActi
|
|||
const allowMultiselect = readBooleanParam(params, "pollMulti") ?? false;
|
||||
const pollAnonymous = readBooleanParam(params, "pollAnonymous");
|
||||
const pollPublic = readBooleanParam(params, "pollPublic");
|
||||
if (pollAnonymous && pollPublic) {
|
||||
throw new Error("pollAnonymous and pollPublic are mutually exclusive");
|
||||
}
|
||||
const isAnonymous = pollAnonymous ? true : pollPublic ? false : undefined;
|
||||
const isAnonymous = resolveTelegramPollVisibility({ pollAnonymous, pollPublic });
|
||||
const durationHours = readNumberParam(params, "pollDurationHours", {
|
||||
integer: true,
|
||||
});
|
||||
const durationSeconds = readNumberParam(params, "pollDurationSeconds", {
|
||||
integer: true,
|
||||
});
|
||||
const maxSelections = allowMultiselect ? Math.max(2, options.length) : 1;
|
||||
const maxSelections = resolvePollMaxSelections(options.length, allowMultiselect);
|
||||
|
||||
if (durationSeconds !== undefined && channel !== "telegram") {
|
||||
throw new Error("pollDurationSeconds is only supported for Telegram polls");
|
||||
|
|
@ -766,6 +766,10 @@ export async function runMessageAction(
|
|||
cfg,
|
||||
});
|
||||
|
||||
if (action === "send" && hasPollCreationParams(params)) {
|
||||
throw new Error('Poll fields require action "poll"; use action "poll" instead of "send".');
|
||||
}
|
||||
|
||||
const gateway = resolveGateway(input);
|
||||
|
||||
if (action === "send") {
|
||||
|
|
|
|||
|
|
@ -0,0 +1,51 @@
|
|||
export type PollCreationParamKind = "string" | "stringArray" | "number" | "boolean";
|
||||
|
||||
export type PollCreationParamDef = {
|
||||
kind: PollCreationParamKind;
|
||||
telegramOnly?: boolean;
|
||||
};
|
||||
|
||||
export const POLL_CREATION_PARAM_DEFS: Record<string, PollCreationParamDef> = {
|
||||
pollQuestion: { kind: "string" },
|
||||
pollOption: { kind: "stringArray" },
|
||||
pollDurationHours: { kind: "number" },
|
||||
pollMulti: { kind: "boolean" },
|
||||
pollDurationSeconds: { kind: "number", telegramOnly: true },
|
||||
pollAnonymous: { kind: "boolean", telegramOnly: true },
|
||||
pollPublic: { kind: "boolean", telegramOnly: true },
|
||||
};
|
||||
|
||||
export type PollCreationParamName = keyof typeof POLL_CREATION_PARAM_DEFS;
|
||||
|
||||
export const POLL_CREATION_PARAM_NAMES = Object.keys(POLL_CREATION_PARAM_DEFS);
|
||||
|
||||
export const TELEGRAM_POLL_CREATION_PARAM_NAMES = POLL_CREATION_PARAM_NAMES.filter(
|
||||
(name) => POLL_CREATION_PARAM_DEFS[name].telegramOnly === true,
|
||||
);
|
||||
|
||||
export function resolveTelegramPollVisibility(params: {
|
||||
pollAnonymous?: boolean;
|
||||
pollPublic?: boolean;
|
||||
}): boolean | undefined {
|
||||
if (params.pollAnonymous && params.pollPublic) {
|
||||
throw new Error("pollAnonymous and pollPublic are mutually exclusive");
|
||||
}
|
||||
return params.pollAnonymous ? true : params.pollPublic ? false : undefined;
|
||||
}
|
||||
|
||||
export function hasPollCreationParams(params: Record<string, unknown>): boolean {
|
||||
for (const key of POLL_CREATION_PARAM_NAMES) {
|
||||
const def = POLL_CREATION_PARAM_DEFS[key];
|
||||
const value = params[key];
|
||||
if (def.kind === "string" && typeof value === "string" && value.trim().length > 0) {
|
||||
return true;
|
||||
}
|
||||
if (def.kind === "stringArray" && Array.isArray(value) && value.length > 0) {
|
||||
return true;
|
||||
}
|
||||
if ((def.kind === "number" || def.kind === "boolean") && value !== undefined) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
|
@ -26,6 +26,13 @@ type NormalizePollOptions = {
|
|||
maxOptions?: number;
|
||||
};
|
||||
|
||||
export function resolvePollMaxSelections(
|
||||
optionCount: number,
|
||||
allowMultiselect: boolean | undefined,
|
||||
): number {
|
||||
return allowMultiselect ? Math.max(2, optionCount) : 1;
|
||||
}
|
||||
|
||||
export function normalizePollInput(
|
||||
input: PollInput,
|
||||
options: NormalizePollOptions = {},
|
||||
|
|
|
|||
Loading…
Reference in New Issue