Harden Telegram poll gating and schema consistency (#36547)

Merged via squash.

Prepared head SHA: f77824419e3d166f727474a9953a063a2b4547f2
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

@@ -141,6 +141,7 @@ Docs: https://docs.openclaw.ai
- Feishu/groupPolicy legacy alias compatibility: treat legacy `groupPolicy: "allowall"` as `open` in both schema parsing and runtime policy checks so intended open-group configs no longer silently drop group messages when `groupAllowFrom` is empty. (from #36358) Thanks @Sid-Qin. - Feishu/groupPolicy legacy alias compatibility: treat legacy `groupPolicy: "allowall"` as `open` in both schema parsing and runtime policy checks so intended open-group configs no longer silently drop group messages when `groupAllowFrom` is empty. (from #36358) Thanks @Sid-Qin.
- Mattermost/plugin SDK import policy: replace remaining monolithic `openclaw/plugin-sdk` imports in Mattermost mention-gating paths/tests with scoped subpaths (`openclaw/plugin-sdk/compat` and `openclaw/plugin-sdk/mattermost`) so `pnpm check` passes `lint:plugins:no-monolithic-plugin-sdk-entry-imports` on baseline. (#36480) Thanks @Takhoffman. - Mattermost/plugin SDK import policy: replace remaining monolithic `openclaw/plugin-sdk` imports in Mattermost mention-gating paths/tests with scoped subpaths (`openclaw/plugin-sdk/compat` and `openclaw/plugin-sdk/mattermost`) so `pnpm check` passes `lint:plugins:no-monolithic-plugin-sdk-entry-imports` on baseline. (#36480) Thanks @Takhoffman.
- Telegram/polls: add Telegram poll action support to channel action discovery and tool/CLI poll flows, with multi-account discoverability gated to accounts that can actually execute polls (`sendMessage` + `poll`). (#36547) thanks @gumadeiras.
- Agents/failover cooldown classification: stop treating generic `cooling down` text as provider `rate_limit` so healthy models no longer show false global cooldown/rate-limit warnings while explicit `model_cooldown` markers still trigger failover. (#32972) thanks @stakeswky. - Agents/failover cooldown classification: stop treating generic `cooling down` text as provider `rate_limit` so healthy models no longer show false global cooldown/rate-limit warnings while explicit `model_cooldown` markers still trigger failover. (#32972) thanks @stakeswky.
- Agents/failover service-unavailable handling: stop treating bare proxy/CDN `service unavailable` errors as provider overload while keeping them retryable via the timeout/failover path, so transient outages no longer show false rate-limit warnings or block fallback. (#36646) thanks @jnMetaCode. - Agents/failover service-unavailable handling: stop treating bare proxy/CDN `service unavailable` errors as provider overload while keeping them retryable via the timeout/failover path, so transient outages no longer show false rate-limit warnings or block fallback. (#36646) thanks @jnMetaCode.

View File

@@ -10,6 +10,7 @@ title: "Polls"
## Supported channels ## Supported channels
- Telegram
- WhatsApp (web channel) - WhatsApp (web channel)
- Discord - Discord
- MS Teams (Adaptive Cards) - MS Teams (Adaptive Cards)
@@ -17,6 +18,13 @@ title: "Polls"
## CLI ## CLI
```bash ```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 # WhatsApp
openclaw message poll --target +15555550123 \ openclaw message poll --target +15555550123 \
--poll-question "Lunch today?" --poll-option "Yes" --poll-option "No" --poll-option "Maybe" --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: Options:
- `--channel`: `whatsapp` (default), `discord`, or `msteams` - `--channel`: `whatsapp` (default), `telegram`, `discord`, or `msteams`
- `--poll-multi`: allow selecting multiple options - `--poll-multi`: allow selecting multiple options
- `--poll-duration-hours`: Discord-only (defaults to 24 when omitted) - `--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 ## Gateway RPC
@@ -51,11 +61,14 @@ Params:
- `options` (string[], required) - `options` (string[], required)
- `maxSelections` (number, optional) - `maxSelections` (number, optional)
- `durationHours` (number, optional) - `durationHours` (number, optional)
- `durationSeconds` (number, optional, Telegram-only)
- `isAnonymous` (boolean, optional, Telegram-only)
- `channel` (string, optional, default: `whatsapp`) - `channel` (string, optional, default: `whatsapp`)
- `idempotencyKey` (string, required) - `idempotencyKey` (string, required)
## Channel differences ## 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`. - 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. - 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. - 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`). 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. 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 Teams polls are rendered as Adaptive Cards and require the gateway to stay online
to record votes in `~/.openclaw/msteams-polls.json`. to record votes in `~/.openclaw/msteams-polls.json`.

View File

@@ -732,6 +732,28 @@ openclaw message send --channel telegram --target 123456789 --message "hi"
openclaw message send --channel telegram --target @name --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> </Accordion>
</AccordionGroup> </AccordionGroup>
@@ -813,6 +835,7 @@ Primary reference:
- `channels.telegram.tokenFile`: read token from file path. - `channels.telegram.tokenFile`: read token from file path.
- `channels.telegram.dmPolicy`: `pairing | allowlist | open | disabled` (default: pairing). - `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.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.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.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+`). - `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+`).

View File

@@ -4,7 +4,11 @@ import type { OpenClawConfig } from "../config/config.js";
import { setActivePluginRegistry } from "../plugins/runtime.js"; import { setActivePluginRegistry } from "../plugins/runtime.js";
import { defaultRuntime } from "../runtime.js"; import { defaultRuntime } from "../runtime.js";
import { createTestRegistry } from "../test-utils/channel-plugins.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", () => { describe("channel tools", () => {
const errorSpy = vi.spyOn(defaultRuntime, "error").mockImplementation(() => undefined); const errorSpy = vi.spyOn(defaultRuntime, "error").mockImplementation(() => undefined);
@@ -49,4 +53,35 @@ describe("channel tools", () => {
expect(listAllChannelSupportedActions({ cfg })).toEqual([]); expect(listAllChannelSupportedActions({ cfg })).toEqual([]);
expect(errorSpy).toHaveBeenCalledTimes(1); 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); 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", () => { it("truncates when integer is true", () => {
const params = { messageId: "42.9" }; const params = { messageId: "42.9" };
expect(readNumberParam(params, "messageId", { integer: true })).toBe(42); expect(readNumberParam(params, "messageId", { integer: true })).toBe(42);

View File

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

View File

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

View File

@@ -61,6 +61,7 @@ const {
removeReactionDiscord, removeReactionDiscord,
searchMessagesDiscord, searchMessagesDiscord,
sendMessageDiscord, sendMessageDiscord,
sendPollDiscord,
sendVoiceMessageDiscord, sendVoiceMessageDiscord,
setChannelPermissionDiscord, setChannelPermissionDiscord,
timeoutMemberDiscord, timeoutMemberDiscord,
@@ -166,6 +167,31 @@ describe("handleDiscordMessagingAction", () => {
).rejects.toThrow(/Discord reactions are disabled/); ).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 () => { it("adds normalized timestamps to readMessages payloads", async () => {
readMessagesDiscord.mockResolvedValueOnce([ readMessagesDiscord.mockResolvedValueOnce([
{ id: "1", timestamp: "2026-01-15T10:00:00.000Z" }, { id: "1", timestamp: "2026-01-15T10:00:00.000Z" },

View File

@@ -1,5 +1,5 @@
import { afterEach, describe, expect, it, vi } from "vitest"; 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 type { MessageActionRunResult } from "../../infra/outbound/message-action-runner.js";
import { setActivePluginRegistry } from "../../plugins/runtime.js"; import { setActivePluginRegistry } from "../../plugins/runtime.js";
import { createTestRegistry } from "../../test-utils/channel-plugins.js"; import { createTestRegistry } from "../../test-utils/channel-plugins.js";
@@ -45,7 +45,8 @@ function createChannelPlugin(params: {
label: string; label: string;
docsPath: string; docsPath: string;
blurb: string; blurb: string;
actions: string[]; actions?: ChannelMessageActionName[];
listActions?: NonNullable<NonNullable<ChannelPlugin["actions"]>["listActions"]>;
supportsButtons?: boolean; supportsButtons?: boolean;
messaging?: ChannelPlugin["messaging"]; messaging?: ChannelPlugin["messaging"];
}): ChannelPlugin { }): ChannelPlugin {
@@ -65,7 +66,11 @@ function createChannelPlugin(params: {
}, },
...(params.messaging ? { messaging: params.messaging } : {}), ...(params.messaging ? { messaging: params.messaging } : {}),
actions: { actions: {
listActions: () => params.actions as never, listActions:
params.listActions ??
(() => {
return (params.actions ?? []) as never;
}),
...(params.supportsButtons ? { supportsButtons: () => true } : {}), ...(params.supportsButtons ? { supportsButtons: () => true } : {}),
}, },
}; };
@@ -139,7 +144,7 @@ describe("message tool schema scoping", () => {
label: "Telegram", label: "Telegram",
docsPath: "/channels/telegram", docsPath: "/channels/telegram",
blurb: "Telegram test plugin.", blurb: "Telegram test plugin.",
actions: ["send", "react"], actions: ["send", "react", "poll"],
supportsButtons: true, supportsButtons: true,
}); });
@@ -161,6 +166,7 @@ describe("message tool schema scoping", () => {
expectComponents: false, expectComponents: false,
expectButtons: true, expectButtons: true,
expectButtonStyle: true, expectButtonStyle: true,
expectTelegramPollExtras: true,
expectedActions: ["send", "react", "poll", "poll-vote"], expectedActions: ["send", "react", "poll", "poll-vote"],
}, },
{ {
@@ -168,11 +174,19 @@ describe("message tool schema scoping", () => {
expectComponents: true, expectComponents: true,
expectButtons: false, expectButtons: false,
expectButtonStyle: false, expectButtonStyle: false,
expectTelegramPollExtras: true,
expectedActions: ["send", "poll", "poll-vote", "react"], expectedActions: ["send", "poll", "poll-vote", "react"],
}, },
])( ])(
"scopes schema fields for $provider", "scopes schema fields for $provider",
({ provider, expectComponents, expectButtons, expectButtonStyle, expectedActions }) => { ({
provider,
expectComponents,
expectButtons,
expectButtonStyle,
expectTelegramPollExtras,
expectedActions,
}) => {
setActivePluginRegistry( setActivePluginRegistry(
createTestRegistry([ createTestRegistry([
{ pluginId: "telegram", source: "test", plugin: telegramPlugin }, { pluginId: "telegram", source: "test", plugin: telegramPlugin },
@@ -209,11 +223,75 @@ describe("message tool schema scoping", () => {
for (const action of expectedActions) { for (const action of expectedActions) {
expect(actionEnum).toContain(action); 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.pollId).toBeDefined();
expect(properties.pollOptionIndex).toBeDefined(); expect(properties.pollOptionIndex).toBeDefined();
expect(properties.pollOptionId).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", () => { 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 { GATEWAY_CLIENT_IDS, GATEWAY_CLIENT_MODES } from "../../gateway/protocol/client-info.js";
import { getToolResult, runMessageAction } from "../../infra/outbound/message-action-runner.js"; import { getToolResult, runMessageAction } from "../../infra/outbound/message-action-runner.js";
import { normalizeTargetForProvider } from "../../infra/outbound/target-normalization.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 { normalizeAccountId } from "../../routing/session-key.js";
import { stripReasoningTagsFromText } from "../../shared/text/reasoning-tags.js"; import { stripReasoningTagsFromText } from "../../shared/text/reasoning-tags.js";
import { normalizeMessageChannel } from "../../utils/message-channel.js"; import { normalizeMessageChannel } from "../../utils/message-channel.js";
@@ -271,12 +272,8 @@ function buildFetchSchema() {
}; };
} }
function buildPollSchema() { function buildPollSchema(options?: { includeTelegramExtras?: boolean }) {
return { const props: Record<string, unknown> = {
pollQuestion: Type.Optional(Type.String()),
pollOption: Type.Optional(Type.Array(Type.String())),
pollDurationHours: Type.Optional(Type.Number()),
pollMulti: Type.Optional(Type.Boolean()),
pollId: Type.Optional(Type.String()), pollId: Type.Optional(Type.String()),
pollOptionId: Type.Optional( pollOptionId: Type.Optional(
Type.String({ 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() { function buildChannelTargetSchema() {
@@ -425,13 +443,14 @@ function buildMessageToolSchemaProps(options: {
includeButtons: boolean; includeButtons: boolean;
includeCards: boolean; includeCards: boolean;
includeComponents: boolean; includeComponents: boolean;
includeTelegramPollExtras: boolean;
}) { }) {
return { return {
...buildRoutingSchema(), ...buildRoutingSchema(),
...buildSendSchema(options), ...buildSendSchema(options),
...buildReactionSchema(), ...buildReactionSchema(),
...buildFetchSchema(), ...buildFetchSchema(),
...buildPollSchema(), ...buildPollSchema({ includeTelegramExtras: options.includeTelegramPollExtras }),
...buildChannelTargetSchema(), ...buildChannelTargetSchema(),
...buildStickerSchema(), ...buildStickerSchema(),
...buildThreadSchema(), ...buildThreadSchema(),
@@ -445,7 +464,12 @@ function buildMessageToolSchemaProps(options: {
function buildMessageToolSchemaFromActions( function buildMessageToolSchemaFromActions(
actions: readonly string[], actions: readonly string[],
options: { includeButtons: boolean; includeCards: boolean; includeComponents: boolean }, options: {
includeButtons: boolean;
includeCards: boolean;
includeComponents: boolean;
includeTelegramPollExtras: boolean;
},
) { ) {
const props = buildMessageToolSchemaProps(options); const props = buildMessageToolSchemaProps(options);
return Type.Object({ return Type.Object({
@@ -458,6 +482,7 @@ const MessageToolSchema = buildMessageToolSchemaFromActions(AllMessageActions, {
includeButtons: true, includeButtons: true,
includeCards: true, includeCards: true,
includeComponents: true, includeComponents: true,
includeTelegramPollExtras: true,
}); });
type MessageToolOptions = { type MessageToolOptions = {
@@ -519,6 +544,16 @@ function resolveIncludeComponents(params: {
return listChannelSupportedActions({ cfg: params.cfg, channel: "discord" }).length > 0; 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: { function buildMessageToolSchema(params: {
cfg: OpenClawConfig; cfg: OpenClawConfig;
currentChannelProvider?: string; currentChannelProvider?: string;
@@ -533,10 +568,12 @@ function buildMessageToolSchema(params: {
? supportsChannelMessageCardsForChannel({ cfg: params.cfg, channel: currentChannel }) ? supportsChannelMessageCardsForChannel({ cfg: params.cfg, channel: currentChannel })
: supportsChannelMessageCards(params.cfg); : supportsChannelMessageCards(params.cfg);
const includeComponents = resolveIncludeComponents(params); const includeComponents = resolveIncludeComponents(params);
const includeTelegramPollExtras = resolveIncludeTelegramPollExtras(params);
return buildMessageToolSchemaFromActions(actions.length > 0 ? actions : ["send"], { return buildMessageToolSchemaFromActions(actions.length > 0 ? actions : ["send"], {
includeButtons, includeButtons,
includeCards, includeCards,
includeComponents, includeComponents,
includeTelegramPollExtras,
}); });
} }

View File

@@ -8,6 +8,11 @@ const sendMessageTelegram = vi.fn(async () => ({
messageId: "789", messageId: "789",
chatId: "123", chatId: "123",
})); }));
const sendPollTelegram = vi.fn(async () => ({
messageId: "790",
chatId: "123",
pollId: "poll-1",
}));
const sendStickerTelegram = vi.fn(async () => ({ const sendStickerTelegram = vi.fn(async () => ({
messageId: "456", messageId: "456",
chatId: "123", chatId: "123",
@@ -20,6 +25,7 @@ vi.mock("../../telegram/send.js", () => ({
reactMessageTelegram(...args), reactMessageTelegram(...args),
sendMessageTelegram: (...args: Parameters<typeof sendMessageTelegram>) => sendMessageTelegram: (...args: Parameters<typeof sendMessageTelegram>) =>
sendMessageTelegram(...args), sendMessageTelegram(...args),
sendPollTelegram: (...args: Parameters<typeof sendPollTelegram>) => sendPollTelegram(...args),
sendStickerTelegram: (...args: Parameters<typeof sendStickerTelegram>) => sendStickerTelegram: (...args: Parameters<typeof sendStickerTelegram>) =>
sendStickerTelegram(...args), sendStickerTelegram(...args),
deleteMessageTelegram: (...args: Parameters<typeof deleteMessageTelegram>) => deleteMessageTelegram: (...args: Parameters<typeof deleteMessageTelegram>) =>
@@ -81,6 +87,7 @@ describe("handleTelegramAction", () => {
envSnapshot = captureEnv(["TELEGRAM_BOT_TOKEN"]); envSnapshot = captureEnv(["TELEGRAM_BOT_TOKEN"]);
reactMessageTelegram.mockClear(); reactMessageTelegram.mockClear();
sendMessageTelegram.mockClear(); sendMessageTelegram.mockClear();
sendPollTelegram.mockClear();
sendStickerTelegram.mockClear(); sendStickerTelegram.mockClear();
deleteMessageTelegram.mockClear(); deleteMessageTelegram.mockClear();
process.env.TELEGRAM_BOT_TOKEN = "tok"; 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 () => { it("forwards trusted mediaLocalRoots into sendMessageTelegram", async () => {
await handleTelegramAction( await handleTelegramAction(
{ {
@@ -390,6 +461,25 @@ describe("handleTelegramAction", () => {
).rejects.toThrow(/Telegram sendMessage is disabled/); ).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 () => { it("deletes a message", async () => {
const cfg = { const cfg = {
channels: { telegram: { botToken: "tok" } }, channels: { telegram: { botToken: "tok" } },

View File

@@ -1,6 +1,11 @@
import type { AgentToolResult } from "@mariozechner/pi-agent-core"; import type { AgentToolResult } from "@mariozechner/pi-agent-core";
import type { OpenClawConfig } from "../../config/config.js"; 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 type { TelegramButtonStyle, TelegramInlineButtons } from "../../telegram/button-types.js";
import { import {
resolveTelegramInlineButtonsScope, resolveTelegramInlineButtonsScope,
@@ -13,6 +18,7 @@ import {
editMessageTelegram, editMessageTelegram,
reactMessageTelegram, reactMessageTelegram,
sendMessageTelegram, sendMessageTelegram,
sendPollTelegram,
sendStickerTelegram, sendStickerTelegram,
} from "../../telegram/send.js"; } from "../../telegram/send.js";
import { getCacheStats, searchStickers } from "../../telegram/sticker-cache.js"; import { getCacheStats, searchStickers } from "../../telegram/sticker-cache.js";
@@ -21,6 +27,7 @@ import {
jsonResult, jsonResult,
readNumberParam, readNumberParam,
readReactionParams, readReactionParams,
readStringArrayParam,
readStringOrNumberParam, readStringOrNumberParam,
readStringParam, readStringParam,
} from "./common.js"; } from "./common.js";
@@ -238,8 +245,8 @@ export async function handleTelegramAction(
replyToMessageId: replyToMessageId ?? undefined, replyToMessageId: replyToMessageId ?? undefined,
messageThreadId: messageThreadId ?? undefined, messageThreadId: messageThreadId ?? undefined,
quoteText: quoteText ?? undefined, quoteText: quoteText ?? undefined,
asVoice: typeof params.asVoice === "boolean" ? params.asVoice : undefined, asVoice: readBooleanParam(params, "asVoice"),
silent: typeof params.silent === "boolean" ? params.silent : undefined, silent: readBooleanParam(params, "silent"),
}); });
return jsonResult({ return jsonResult({
ok: true, 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 (action === "deleteMessage") {
if (!isActionEnabled("deleteMessage")) { if (!isActionEnabled("deleteMessage")) {
throw new Error("Telegram deleteMessage is disabled."); throw new Error("Telegram deleteMessage is disabled.");

View File

@@ -329,6 +329,44 @@ describe("handleDiscordMessageAction", () => {
answers: ["Yes", "No"], answers: ["Yes", "No"],
}, },
}, },
{
name: "parses string booleans for discord poll adapter params",
input: {
action: "poll" as const,
params: {
to: "channel:123",
pollQuestion: "Ready?",
pollOption: ["Yes", "No"],
pollMulti: "true",
},
},
expected: {
action: "poll",
to: "channel:123",
question: "Ready?",
answers: ["Yes", "No"],
allowMultiselect: true,
},
},
{
name: "rejects partially numeric poll duration for discord poll adapter params",
input: {
action: "poll" as const,
params: {
to: "channel:123",
pollQuestion: "Ready?",
pollOption: ["Yes", "No"],
pollDurationHours: "24h",
},
},
expected: {
action: "poll",
to: "channel:123",
question: "Ready?",
answers: ["Yes", "No"],
durationHours: undefined,
},
},
{ {
name: "forwards accountId for thread replies", name: "forwards accountId for thread replies",
input: { input: {
@@ -496,6 +534,71 @@ describe("handleDiscordMessageAction", () => {
}); });
describe("telegramMessageActions", () => { 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("omits poll when sendMessage and poll are split across accounts", () => {
const cfg = {
channels: {
telegram: {
accounts: {
senderOnly: {
botToken: "tok-send",
actions: {
sendMessage: true,
poll: false,
},
},
pollOnly: {
botToken: "tok-poll",
actions: {
sendMessage: false,
poll: true,
},
},
},
},
},
} as OpenClawConfig;
const actions = telegramMessageActions.listActions?.({ cfg }) ?? [];
expect(actions).not.toContain("poll");
});
it("lists sticker actions only when enabled by config", () => { it("lists sticker actions only when enabled by config", () => {
const cases = [ const cases = [
{ {
@@ -595,6 +698,85 @@ describe("telegramMessageActions", () => {
accountId: undefined, 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: "poll parses string booleans before telegram action handoff",
action: "poll" as const,
params: {
to: "123",
pollQuestion: "Ready?",
pollOption: ["Yes", "No"],
pollMulti: "true",
pollPublic: "true",
silent: "true",
},
expectedPayload: {
action: "poll",
to: "123",
question: "Ready?",
answers: ["Yes", "No"],
allowMultiselect: true,
durationHours: undefined,
durationSeconds: undefined,
replyToMessageId: undefined,
messageThreadId: undefined,
isAnonymous: false,
silent: true,
accountId: undefined,
},
},
{
name: "poll rejects partially numeric duration strings before telegram action handoff",
action: "poll" as const,
params: {
to: "123",
pollQuestion: "Ready?",
pollOption: ["Yes", "No"],
pollDurationSeconds: "60s",
},
expectedPayload: {
action: "poll",
to: "123",
question: "Ready?",
answers: ["Yes", "No"],
allowMultiselect: undefined,
durationHours: undefined,
durationSeconds: undefined,
replyToMessageId: undefined,
messageThreadId: undefined,
isAnonymous: undefined,
silent: undefined,
accountId: undefined,
},
},
{ {
name: "topic-create maps to createForumTopic", name: "topic-create maps to createForumTopic",
action: "topic-create" as const, action: "topic-create" as const,

View File

@@ -7,6 +7,7 @@ import {
import { readDiscordParentIdParam } from "../../../../agents/tools/discord-actions-shared.js"; import { readDiscordParentIdParam } from "../../../../agents/tools/discord-actions-shared.js";
import { handleDiscordAction } from "../../../../agents/tools/discord-actions.js"; import { handleDiscordAction } from "../../../../agents/tools/discord-actions.js";
import { resolveDiscordChannelId } from "../../../../discord/targets.js"; import { resolveDiscordChannelId } from "../../../../discord/targets.js";
import { readBooleanParam } from "../../../../plugin-sdk/boolean-param.js";
import type { ChannelMessageActionContext } from "../../types.js"; import type { ChannelMessageActionContext } from "../../types.js";
import { resolveReactionMessageId } from "../reaction-message-id.js"; import { resolveReactionMessageId } from "../reaction-message-id.js";
import { tryHandleDiscordMessageActionGuildAdmin } from "./handle-action.guild-admin.js"; import { tryHandleDiscordMessageActionGuildAdmin } from "./handle-action.guild-admin.js";
@@ -38,7 +39,7 @@ export async function handleDiscordMessageAction(
if (action === "send") { if (action === "send") {
const to = readStringParam(params, "to", { required: true }); const to = readStringParam(params, "to", { required: true });
const asVoice = params.asVoice === true; const asVoice = readBooleanParam(params, "asVoice") === true;
const rawComponents = params.components; const rawComponents = params.components;
const hasComponents = const hasComponents =
Boolean(rawComponents) && Boolean(rawComponents) &&
@@ -57,7 +58,7 @@ export async function handleDiscordMessageAction(
const replyTo = readStringParam(params, "replyTo"); const replyTo = readStringParam(params, "replyTo");
const rawEmbeds = params.embeds; const rawEmbeds = params.embeds;
const embeds = Array.isArray(rawEmbeds) ? rawEmbeds : undefined; const embeds = Array.isArray(rawEmbeds) ? rawEmbeds : undefined;
const silent = params.silent === true; const silent = readBooleanParam(params, "silent") === true;
const sessionKey = readStringParam(params, "__sessionKey"); const sessionKey = readStringParam(params, "__sessionKey");
const agentId = readStringParam(params, "__agentId"); const agentId = readStringParam(params, "__agentId");
return await handleDiscordAction( return await handleDiscordAction(
@@ -86,10 +87,11 @@ export async function handleDiscordMessageAction(
const question = readStringParam(params, "pollQuestion", { const question = readStringParam(params, "pollQuestion", {
required: true, required: true,
}); });
const answers = readStringArrayParam(params, "pollOption", { required: true }) ?? []; const answers = readStringArrayParam(params, "pollOption", { required: true });
const allowMultiselect = typeof params.pollMulti === "boolean" ? params.pollMulti : undefined; const allowMultiselect = readBooleanParam(params, "pollMulti");
const durationHours = readNumberParam(params, "pollDurationHours", { const durationHours = readNumberParam(params, "pollDurationHours", {
integer: true, integer: true,
strict: true,
}); });
return await handleDiscordAction( return await handleDiscordAction(
{ {
@@ -116,7 +118,7 @@ export async function handleDiscordMessageAction(
); );
} }
const emoji = readStringParam(params, "emoji", { allowEmpty: true }); const emoji = readStringParam(params, "emoji", { allowEmpty: true });
const remove = typeof params.remove === "boolean" ? params.remove : undefined; const remove = readBooleanParam(params, "remove");
return await handleDiscordAction( return await handleDiscordAction(
{ {
action: "react", action: "react",

View File

@@ -6,10 +6,13 @@ import {
} from "../../../agents/tools/common.js"; } from "../../../agents/tools/common.js";
import { handleTelegramAction } from "../../../agents/tools/telegram-actions.js"; import { handleTelegramAction } from "../../../agents/tools/telegram-actions.js";
import type { TelegramActionConfig } from "../../../config/types.telegram.js"; import type { TelegramActionConfig } from "../../../config/types.telegram.js";
import { readBooleanParam } from "../../../plugin-sdk/boolean-param.js";
import { extractToolSend } from "../../../plugin-sdk/tool-send.js"; import { extractToolSend } from "../../../plugin-sdk/tool-send.js";
import { resolveTelegramPollVisibility } from "../../../poll-params.js";
import { import {
createTelegramActionGate, createTelegramActionGate,
listEnabledTelegramAccounts, listEnabledTelegramAccounts,
resolveTelegramPollActionGateState,
} from "../../../telegram/accounts.js"; } from "../../../telegram/accounts.js";
import { isTelegramInlineButtonsEnabled } from "../../../telegram/inline-buttons.js"; import { isTelegramInlineButtonsEnabled } from "../../../telegram/inline-buttons.js";
import type { ChannelMessageActionAdapter, ChannelMessageActionName } from "../types.js"; import type { ChannelMessageActionAdapter, ChannelMessageActionName } from "../types.js";
@@ -27,8 +30,8 @@ function readTelegramSendParams(params: Record<string, unknown>) {
const replyTo = readStringParam(params, "replyTo"); const replyTo = readStringParam(params, "replyTo");
const threadId = readStringParam(params, "threadId"); const threadId = readStringParam(params, "threadId");
const buttons = params.buttons; const buttons = params.buttons;
const asVoice = typeof params.asVoice === "boolean" ? params.asVoice : undefined; const asVoice = readBooleanParam(params, "asVoice");
const silent = typeof params.silent === "boolean" ? params.silent : undefined; const silent = readBooleanParam(params, "silent");
const quoteText = readStringParam(params, "quoteText"); const quoteText = readStringParam(params, "quoteText");
return { return {
to, to,
@@ -78,6 +81,16 @@ export const telegramMessageActions: ChannelMessageActionAdapter = {
const isEnabled = (key: keyof TelegramActionConfig, defaultValue = true) => const isEnabled = (key: keyof TelegramActionConfig, defaultValue = true) =>
gate(key, defaultValue); gate(key, defaultValue);
const actions = new Set<ChannelMessageActionName>(["send"]); const actions = new Set<ChannelMessageActionName>(["send"]);
const pollEnabledForAnyAccount = accounts.some((account) => {
const accountGate = createTelegramActionGate({
cfg,
accountId: account.accountId,
});
return resolveTelegramPollActionGateState(accountGate).enabled;
});
if (pollEnabledForAnyAccount) {
actions.add("poll");
}
if (isEnabled("reactions")) { if (isEnabled("reactions")) {
actions.add("react"); actions.add("react");
} }
@@ -125,7 +138,7 @@ export const telegramMessageActions: ChannelMessageActionAdapter = {
if (action === "react") { if (action === "react") {
const messageId = resolveReactionMessageId({ args: params, toolContext }); const messageId = resolveReactionMessageId({ args: params, toolContext });
const emoji = readStringParam(params, "emoji", { allowEmpty: true }); const emoji = readStringParam(params, "emoji", { allowEmpty: true });
const remove = typeof params.remove === "boolean" ? params.remove : undefined; const remove = readBooleanParam(params, "remove");
return await handleTelegramAction( return await handleTelegramAction(
{ {
action: "react", action: "react",
@@ -140,6 +153,45 @@ 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,
strict: true,
});
const durationSeconds = readNumberParam(params, "pollDurationSeconds", {
integer: true,
strict: true,
});
const replyToMessageId = readNumberParam(params, "replyTo", { integer: true });
const messageThreadId = readNumberParam(params, "threadId", { integer: true });
const allowMultiselect = readBooleanParam(params, "pollMulti");
const pollAnonymous = readBooleanParam(params, "pollAnonymous");
const pollPublic = readBooleanParam(params, "pollPublic");
const isAnonymous = resolveTelegramPollVisibility({ pollAnonymous, pollPublic });
const silent = readBooleanParam(params, "silent");
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") { if (action === "delete") {
const chatId = readTelegramChatIdParam(params); const chatId = readTelegramChatIdParam(params);
const messageId = readTelegramMessageIdParam(params); const messageId = readTelegramMessageIdParam(params);

View File

@@ -336,6 +336,12 @@ export type ChannelToolSend = {
}; };
export type ChannelMessageActionAdapter = { 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[]; listActions?: (params: { cfg: OpenClawConfig }) => ChannelMessageActionName[];
supportsAction?: (params: { action: ChannelMessageActionName }) => boolean; supportsAction?: (params: { action: ChannelMessageActionName }) => boolean;
supportsButtons?: (params: { cfg: OpenClawConfig }) => boolean; supportsButtons?: (params: { cfg: OpenClawConfig }) => boolean;

View File

@@ -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"); const { messageCommand } = await import("./message.js");
describe("messageCommand", () => { describe("messageCommand", () => {
@@ -468,4 +486,34 @@ describe("messageCommand", () => {
expect.any(Object), 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),
);
});
}); });

View File

@@ -0,0 +1,36 @@
import { describe, expect, it } from "vitest";
import { validateConfigObject } from "./config.js";
describe("telegram poll action config", () => {
it("accepts channels.telegram.actions.poll", () => {
const res = validateConfigObject({
channels: {
telegram: {
actions: {
poll: false,
},
},
},
});
expect(res.ok).toBe(true);
});
it("accepts channels.telegram.accounts.<id>.actions.poll", () => {
const res = validateConfigObject({
channels: {
telegram: {
accounts: {
ops: {
actions: {
poll: false,
},
},
},
},
},
});
expect(res.ok).toBe(true);
});
});

View File

@@ -14,6 +14,8 @@ import type { GroupToolPolicyBySenderConfig, GroupToolPolicyConfig } from "./typ
export type TelegramActionConfig = { export type TelegramActionConfig = {
reactions?: boolean; reactions?: boolean;
sendMessage?: boolean; sendMessage?: boolean;
/** Enable poll creation. Requires sendMessage to also be enabled. */
poll?: boolean;
deleteMessage?: boolean; deleteMessage?: boolean;
editMessage?: boolean; editMessage?: boolean;
/** Enable sticker actions (send and search). */ /** Enable sticker actions (send and search). */

View File

@@ -225,6 +225,7 @@ export const TelegramAccountSchemaBase = z
.object({ .object({
reactions: z.boolean().optional(), reactions: z.boolean().optional(),
sendMessage: z.boolean().optional(), sendMessage: z.boolean().optional(),
poll: z.boolean().optional(),
deleteMessage: z.boolean().optional(), deleteMessage: z.boolean().optional(),
sticker: z.boolean().optional(), sticker: z.boolean().optional(),
}) })

View File

@@ -236,6 +236,72 @@ describe("runMessageAction context isolation", () => {
).rejects.toThrow(/message required/i); ).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("rejects send actions that include string-encoded poll params", async () => {
await expect(
runDrySend({
cfg: slackConfig,
actionParams: {
channel: "slack",
target: "#C12345678",
message: "hi",
pollDurationSeconds: "60",
pollPublic: "true",
},
toolContext: { currentChannelId: "C12345678" },
}),
).rejects.toThrow(/use action "poll" instead of "send"/i);
});
it("rejects send actions that include snake_case poll params", async () => {
await expect(
runDrySend({
cfg: slackConfig,
actionParams: {
channel: "slack",
target: "#C12345678",
message: "hi",
poll_question: "Ready?",
poll_option: ["Yes", "No"],
poll_public: "true",
},
toolContext: { currentChannelId: "C12345678" },
}),
).rejects.toThrow(/use action "poll" instead of "send"/i);
});
it("allows send when poll booleans are explicitly false", async () => {
const result = await runDrySend({
cfg: slackConfig,
actionParams: {
channel: "slack",
target: "#C12345678",
message: "hi",
pollMulti: false,
pollAnonymous: false,
pollPublic: false,
},
toolContext: { currentChannelId: "C12345678" },
});
expect(result.kind).toBe("send");
});
it("blocks send when target differs from current channel", async () => { it("blocks send when target differs from current channel", async () => {
const result = await runDrySend({ const result = await runDrySend({
cfg: slackConfig, cfg: slackConfig,
@@ -902,6 +968,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", () => { describe("runMessageAction components parsing", () => {
const handleAction = vi.fn(async ({ params }: { params: Record<string, unknown> }) => const handleAction = vi.fn(async ({ params }: { params: Record<string, unknown> }) =>
jsonResult({ jsonResult({

View File

@@ -14,6 +14,8 @@ import type {
} from "../../channels/plugins/types.js"; } from "../../channels/plugins/types.js";
import type { OpenClawConfig } from "../../config/config.js"; import type { OpenClawConfig } from "../../config/config.js";
import { getAgentScopedMediaLocalRoots } from "../../media/local-roots.js"; import { getAgentScopedMediaLocalRoots } from "../../media/local-roots.js";
import { hasPollCreationParams, resolveTelegramPollVisibility } from "../../poll-params.js";
import { resolvePollMaxSelections } from "../../polls.js";
import { buildChannelAccountBindings } from "../../routing/bindings.js"; import { buildChannelAccountBindings } from "../../routing/bindings.js";
import { normalizeAgentId } from "../../routing/session-key.js"; import { normalizeAgentId } from "../../routing/session-key.js";
import { type GatewayClientMode, type GatewayClientName } from "../../utils/message-channel.js"; import { type GatewayClientMode, type GatewayClientName } from "../../utils/message-channel.js";
@@ -307,7 +309,7 @@ async function handleBroadcastAction(
if (!broadcastEnabled) { if (!broadcastEnabled) {
throw new Error("Broadcast is disabled. Set tools.message.broadcast.enabled to true."); throw new Error("Broadcast is disabled. Set tools.message.broadcast.enabled to true.");
} }
const rawTargets = readStringArrayParam(params, "targets", { required: true }) ?? []; const rawTargets = readStringArrayParam(params, "targets", { required: true });
if (rawTargets.length === 0) { if (rawTargets.length === 0) {
throw new Error("Broadcast requires at least one target in --targets."); throw new Error("Broadcast requires at least one target in --targets.");
} }
@@ -571,7 +573,7 @@ async function handlePollAction(ctx: ResolvedActionContext): Promise<MessageActi
const question = readStringParam(params, "pollQuestion", { const question = readStringParam(params, "pollQuestion", {
required: true, required: true,
}); });
const options = readStringArrayParam(params, "pollOption", { required: true }) ?? []; const options = readStringArrayParam(params, "pollOption", { required: true });
if (options.length < 2) { if (options.length < 2) {
throw new Error("pollOption requires at least two values"); throw new Error("pollOption requires at least two values");
} }
@@ -579,17 +581,16 @@ async function handlePollAction(ctx: ResolvedActionContext): Promise<MessageActi
const allowMultiselect = readBooleanParam(params, "pollMulti") ?? false; const allowMultiselect = readBooleanParam(params, "pollMulti") ?? false;
const pollAnonymous = readBooleanParam(params, "pollAnonymous"); const pollAnonymous = readBooleanParam(params, "pollAnonymous");
const pollPublic = readBooleanParam(params, "pollPublic"); const pollPublic = readBooleanParam(params, "pollPublic");
if (pollAnonymous && pollPublic) { const isAnonymous = resolveTelegramPollVisibility({ pollAnonymous, pollPublic });
throw new Error("pollAnonymous and pollPublic are mutually exclusive");
}
const isAnonymous = pollAnonymous ? true : pollPublic ? false : undefined;
const durationHours = readNumberParam(params, "pollDurationHours", { const durationHours = readNumberParam(params, "pollDurationHours", {
integer: true, integer: true,
strict: true,
}); });
const durationSeconds = readNumberParam(params, "pollDurationSeconds", { const durationSeconds = readNumberParam(params, "pollDurationSeconds", {
integer: true, integer: true,
strict: true,
}); });
const maxSelections = allowMultiselect ? Math.max(2, options.length) : 1; const maxSelections = resolvePollMaxSelections(options.length, allowMultiselect);
if (durationSeconds !== undefined && channel !== "telegram") { if (durationSeconds !== undefined && channel !== "telegram") {
throw new Error("pollDurationSeconds is only supported for Telegram polls"); throw new Error("pollDurationSeconds is only supported for Telegram polls");
@@ -766,6 +767,10 @@ export async function runMessageAction(
cfg, cfg,
}); });
if (action === "send" && hasPollCreationParams(params)) {
throw new Error('Poll fields require action "poll"; use action "poll" instead of "send".');
}
const gateway = resolveGateway(input); const gateway = resolveGateway(input);
if (action === "send") { if (action === "send") {

60
src/poll-params.test.ts Normal file
View File

@@ -0,0 +1,60 @@
import { describe, expect, it } from "vitest";
import { hasPollCreationParams, resolveTelegramPollVisibility } from "./poll-params.js";
describe("poll params", () => {
it("does not treat explicit false booleans as poll creation params", () => {
expect(
hasPollCreationParams({
pollMulti: false,
pollAnonymous: false,
pollPublic: false,
}),
).toBe(false);
});
it.each([{ key: "pollMulti" }, { key: "pollAnonymous" }, { key: "pollPublic" }])(
"treats $key=true as poll creation intent",
({ key }) => {
expect(
hasPollCreationParams({
[key]: true,
}),
).toBe(true);
},
);
it("treats finite numeric poll params as poll creation intent", () => {
expect(hasPollCreationParams({ pollDurationHours: 0 })).toBe(true);
expect(hasPollCreationParams({ pollDurationSeconds: 60 })).toBe(true);
expect(hasPollCreationParams({ pollDurationSeconds: "60" })).toBe(true);
expect(hasPollCreationParams({ pollDurationSeconds: "1e3" })).toBe(true);
expect(hasPollCreationParams({ pollDurationHours: Number.NaN })).toBe(false);
expect(hasPollCreationParams({ pollDurationSeconds: Infinity })).toBe(false);
expect(hasPollCreationParams({ pollDurationSeconds: "60abc" })).toBe(false);
});
it("treats string-encoded boolean poll params as poll creation intent when true", () => {
expect(hasPollCreationParams({ pollPublic: "true" })).toBe(true);
expect(hasPollCreationParams({ pollAnonymous: "false" })).toBe(false);
});
it("treats string poll options as poll creation intent", () => {
expect(hasPollCreationParams({ pollOption: "Yes" })).toBe(true);
});
it("detects snake_case poll fields as poll creation intent", () => {
expect(hasPollCreationParams({ poll_question: "Lunch?" })).toBe(true);
expect(hasPollCreationParams({ poll_option: ["Pizza", "Sushi"] })).toBe(true);
expect(hasPollCreationParams({ poll_duration_seconds: "60" })).toBe(true);
expect(hasPollCreationParams({ poll_public: "true" })).toBe(true);
});
it("resolves telegram poll visibility flags", () => {
expect(resolveTelegramPollVisibility({ pollAnonymous: true })).toBe(true);
expect(resolveTelegramPollVisibility({ pollPublic: true })).toBe(false);
expect(resolveTelegramPollVisibility({})).toBeUndefined();
expect(() => resolveTelegramPollVisibility({ pollAnonymous: true, pollPublic: true })).toThrow(
/mutually exclusive/i,
);
});
});

89
src/poll-params.ts Normal file
View File

@@ -0,0 +1,89 @@
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);
function toSnakeCaseKey(key: string): string {
return key
.replace(/([A-Z]+)([A-Z][a-z])/g, "$1_$2")
.replace(/([a-z0-9])([A-Z])/g, "$1_$2")
.toLowerCase();
}
function readPollParamRaw(params: Record<string, unknown>, key: string): unknown {
if (Object.hasOwn(params, key)) {
return params[key];
}
const snakeKey = toSnakeCaseKey(key);
if (snakeKey !== key && Object.hasOwn(params, snakeKey)) {
return params[snakeKey];
}
return undefined;
}
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 = readPollParamRaw(params, key);
if (def.kind === "string" && typeof value === "string" && value.trim().length > 0) {
return true;
}
if (def.kind === "stringArray") {
if (
Array.isArray(value) &&
value.some((entry) => typeof entry === "string" && entry.trim())
) {
return true;
}
if (typeof value === "string" && value.trim().length > 0) {
return true;
}
}
if (def.kind === "number") {
if (typeof value === "number" && Number.isFinite(value)) {
return true;
}
if (typeof value === "string") {
const trimmed = value.trim();
if (trimmed.length > 0 && Number.isFinite(Number(trimmed))) {
return true;
}
}
}
if (def.kind === "boolean") {
if (value === true) {
return true;
}
if (typeof value === "string" && value.trim().toLowerCase() === "true") {
return true;
}
}
}
return false;
}

View File

@@ -26,6 +26,13 @@ type NormalizePollOptions = {
maxOptions?: number; maxOptions?: number;
}; };
export function resolvePollMaxSelections(
optionCount: number,
allowMultiselect: boolean | undefined,
): number {
return allowMultiselect ? Math.max(2, optionCount) : 1;
}
export function normalizePollInput( export function normalizePollInput(
input: PollInput, input: PollInput,
options: NormalizePollOptions = {}, options: NormalizePollOptions = {},

View File

@@ -4,6 +4,7 @@ import { withEnv } from "../test-utils/env.js";
import { import {
listTelegramAccountIds, listTelegramAccountIds,
resetMissingDefaultWarnFlag, resetMissingDefaultWarnFlag,
resolveTelegramPollActionGateState,
resolveDefaultTelegramAccountId, resolveDefaultTelegramAccountId,
resolveTelegramAccount, resolveTelegramAccount,
} from "./accounts.js"; } from "./accounts.js";
@@ -308,6 +309,26 @@ describe("resolveTelegramAccount allowFrom precedence", () => {
}); });
}); });
describe("resolveTelegramPollActionGateState", () => {
it("requires both sendMessage and poll actions", () => {
const state = resolveTelegramPollActionGateState((key) => key !== "poll");
expect(state).toEqual({
sendMessageEnabled: true,
pollEnabled: false,
enabled: false,
});
});
it("returns enabled only when both actions are enabled", () => {
const state = resolveTelegramPollActionGateState(() => true);
expect(state).toEqual({
sendMessageEnabled: true,
pollEnabled: true,
enabled: true,
});
});
});
describe("resolveTelegramAccount groups inheritance (#30673)", () => { describe("resolveTelegramAccount groups inheritance (#30673)", () => {
const createMultiAccountGroupsConfig = (): OpenClawConfig => ({ const createMultiAccountGroupsConfig = (): OpenClawConfig => ({
channels: { channels: {

View File

@@ -142,6 +142,24 @@ export function createTelegramActionGate(params: {
}); });
} }
export type TelegramPollActionGateState = {
sendMessageEnabled: boolean;
pollEnabled: boolean;
enabled: boolean;
};
export function resolveTelegramPollActionGateState(
isActionEnabled: (key: keyof TelegramActionConfig, defaultValue?: boolean) => boolean,
): TelegramPollActionGateState {
const sendMessageEnabled = isActionEnabled("sendMessage");
const pollEnabled = isActionEnabled("poll");
return {
sendMessageEnabled,
pollEnabled,
enabled: sendMessageEnabled && pollEnabled,
};
}
export function resolveTelegramAccount(params: { export function resolveTelegramAccount(params: {
cfg: OpenClawConfig; cfg: OpenClawConfig;
accountId?: string | null; accountId?: string | null;