mirror of
https://github.com/openclaw/openclaw.git
synced 2026-03-12 07:20:45 +00:00
Harden Telegram poll gating and schema consistency (#36547)
Merged via squash.
Prepared head SHA: f77824419e
Co-authored-by: gumadeiras <5599352+gumadeiras@users.noreply.github.com>
Co-authored-by: gumadeiras <5599352+gumadeiras@users.noreply.github.com>
Reviewed-by: @gumadeiras
This commit is contained in:
committed by
GitHub
parent
f771ba8de9
commit
6dfd39c32f
@@ -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.
|
||||
|
||||
- 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 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.
|
||||
|
||||
@@ -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([]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -48,6 +48,16 @@ describe("readNumberParam", () => {
|
||||
expect(readNumberParam(params, "messageId")).toBe(42);
|
||||
});
|
||||
|
||||
it("keeps partial parse behavior by default", () => {
|
||||
const params = { messageId: "42abc" };
|
||||
expect(readNumberParam(params, "messageId")).toBe(42);
|
||||
});
|
||||
|
||||
it("rejects partial numeric strings when strict is enabled", () => {
|
||||
const params = { messageId: "42abc" };
|
||||
expect(readNumberParam(params, "messageId", { strict: true })).toBeUndefined();
|
||||
});
|
||||
|
||||
it("truncates when integer is true", () => {
|
||||
const params = { messageId: "42.9" };
|
||||
expect(readNumberParam(params, "messageId", { integer: true })).toBe(42);
|
||||
|
||||
@@ -129,9 +129,9 @@ export function readStringOrNumberParam(
|
||||
export function readNumberParam(
|
||||
params: Record<string, unknown>,
|
||||
key: string,
|
||||
options: { required?: boolean; label?: string; integer?: boolean } = {},
|
||||
options: { required?: boolean; label?: string; integer?: boolean; strict?: boolean } = {},
|
||||
): number | undefined {
|
||||
const { required = false, label = key, integer = false } = options;
|
||||
const { required = false, label = key, integer = false, strict = false } = options;
|
||||
const raw = readParamRaw(params, key);
|
||||
let value: number | undefined;
|
||||
if (typeof raw === "number" && Number.isFinite(raw)) {
|
||||
@@ -139,7 +139,7 @@ export function readNumberParam(
|
||||
} else if (typeof raw === "string") {
|
||||
const trimmed = raw.trim();
|
||||
if (trimmed) {
|
||||
const parsed = Number.parseFloat(trimmed);
|
||||
const parsed = strict ? Number(trimmed) : Number.parseFloat(trimmed);
|
||||
if (Number.isFinite(parsed)) {
|
||||
value = parsed;
|
||||
}
|
||||
|
||||
@@ -26,11 +26,14 @@ import {
|
||||
} from "../../discord/send.js";
|
||||
import type { DiscordSendComponents, DiscordSendEmbeds } from "../../discord/send.shared.js";
|
||||
import { resolveDiscordChannelId } from "../../discord/targets.js";
|
||||
import { readBooleanParam } from "../../plugin-sdk/boolean-param.js";
|
||||
import { resolvePollMaxSelections } from "../../polls.js";
|
||||
import { withNormalizedTimestamp } from "../date-time.js";
|
||||
import { assertMediaNotDataUrl } from "../sandbox-paths.js";
|
||||
import {
|
||||
type ActionGate,
|
||||
jsonResult,
|
||||
readNumberParam,
|
||||
readReactionParams,
|
||||
readStringArrayParam,
|
||||
readStringParam,
|
||||
@@ -126,9 +129,7 @@ export async function handleDiscordMessagingAction(
|
||||
const messageId = readStringParam(params, "messageId", {
|
||||
required: true,
|
||||
});
|
||||
const limitRaw = params.limit;
|
||||
const limit =
|
||||
typeof limitRaw === "number" && Number.isFinite(limitRaw) ? limitRaw : undefined;
|
||||
const limit = readNumberParam(params, "limit");
|
||||
const reactions = await fetchReactionsDiscord(channelId, messageId, {
|
||||
...cfgOptions,
|
||||
...(accountId ? { accountId } : {}),
|
||||
@@ -166,13 +167,9 @@ export async function handleDiscordMessagingAction(
|
||||
required: true,
|
||||
label: "answers",
|
||||
});
|
||||
const allowMultiselectRaw = params.allowMultiselect;
|
||||
const allowMultiselect =
|
||||
typeof allowMultiselectRaw === "boolean" ? allowMultiselectRaw : undefined;
|
||||
const durationRaw = params.durationHours;
|
||||
const durationHours =
|
||||
typeof durationRaw === "number" && Number.isFinite(durationRaw) ? durationRaw : undefined;
|
||||
const maxSelections = allowMultiselect ? Math.max(2, answers.length) : 1;
|
||||
const allowMultiselect = readBooleanParam(params, "allowMultiselect");
|
||||
const durationHours = readNumberParam(params, "durationHours");
|
||||
const maxSelections = resolvePollMaxSelections(answers.length, allowMultiselect);
|
||||
await sendPollDiscord(
|
||||
to,
|
||||
{ question, options: answers, maxSelections, durationHours },
|
||||
@@ -226,10 +223,7 @@ export async function handleDiscordMessagingAction(
|
||||
}
|
||||
const channelId = resolveChannelId();
|
||||
const query = {
|
||||
limit:
|
||||
typeof params.limit === "number" && Number.isFinite(params.limit)
|
||||
? params.limit
|
||||
: undefined,
|
||||
limit: readNumberParam(params, "limit"),
|
||||
before: readStringParam(params, "before"),
|
||||
after: readStringParam(params, "after"),
|
||||
around: readStringParam(params, "around"),
|
||||
@@ -372,11 +366,7 @@ export async function handleDiscordMessagingAction(
|
||||
const name = readStringParam(params, "name", { required: true });
|
||||
const messageId = readStringParam(params, "messageId");
|
||||
const content = readStringParam(params, "content");
|
||||
const autoArchiveMinutesRaw = params.autoArchiveMinutes;
|
||||
const autoArchiveMinutes =
|
||||
typeof autoArchiveMinutesRaw === "number" && Number.isFinite(autoArchiveMinutesRaw)
|
||||
? autoArchiveMinutesRaw
|
||||
: undefined;
|
||||
const autoArchiveMinutes = readNumberParam(params, "autoArchiveMinutes");
|
||||
const appliedTags = readStringArrayParam(params, "appliedTags");
|
||||
const payload = {
|
||||
name,
|
||||
@@ -398,13 +388,9 @@ export async function handleDiscordMessagingAction(
|
||||
required: true,
|
||||
});
|
||||
const channelId = readStringParam(params, "channelId");
|
||||
const includeArchived =
|
||||
typeof params.includeArchived === "boolean" ? params.includeArchived : undefined;
|
||||
const includeArchived = readBooleanParam(params, "includeArchived");
|
||||
const before = readStringParam(params, "before");
|
||||
const limit =
|
||||
typeof params.limit === "number" && Number.isFinite(params.limit)
|
||||
? params.limit
|
||||
: undefined;
|
||||
const limit = readNumberParam(params, "limit");
|
||||
const threads = accountId
|
||||
? await listThreadsDiscord(
|
||||
{
|
||||
@@ -498,10 +484,7 @@ export async function handleDiscordMessagingAction(
|
||||
const channelIds = readStringArrayParam(params, "channelIds");
|
||||
const authorId = readStringParam(params, "authorId");
|
||||
const authorIds = readStringArrayParam(params, "authorIds");
|
||||
const limit =
|
||||
typeof params.limit === "number" && Number.isFinite(params.limit)
|
||||
? params.limit
|
||||
: undefined;
|
||||
const limit = readNumberParam(params, "limit");
|
||||
const channelIdList = [...(channelIds ?? []), ...(channelId ? [channelId] : [])];
|
||||
const authorIdList = [...(authorIds ?? []), ...(authorId ? [authorId] : [])];
|
||||
const results = accountId
|
||||
|
||||
@@ -61,6 +61,7 @@ const {
|
||||
removeReactionDiscord,
|
||||
searchMessagesDiscord,
|
||||
sendMessageDiscord,
|
||||
sendPollDiscord,
|
||||
sendVoiceMessageDiscord,
|
||||
setChannelPermissionDiscord,
|
||||
timeoutMemberDiscord,
|
||||
@@ -166,6 +167,31 @@ describe("handleDiscordMessagingAction", () => {
|
||||
).rejects.toThrow(/Discord reactions are disabled/);
|
||||
});
|
||||
|
||||
it("parses string booleans for poll options", async () => {
|
||||
await handleDiscordMessagingAction(
|
||||
"poll",
|
||||
{
|
||||
to: "channel:123",
|
||||
question: "Lunch?",
|
||||
answers: ["Pizza", "Sushi"],
|
||||
allowMultiselect: "true",
|
||||
durationHours: "24",
|
||||
},
|
||||
enableAllActions,
|
||||
);
|
||||
|
||||
expect(sendPollDiscord).toHaveBeenCalledWith(
|
||||
"channel:123",
|
||||
{
|
||||
question: "Lunch?",
|
||||
options: ["Pizza", "Sushi"],
|
||||
maxSelections: 2,
|
||||
durationHours: 24,
|
||||
},
|
||||
expect.any(Object),
|
||||
);
|
||||
});
|
||||
|
||||
it("adds normalized timestamps to readMessages payloads", async () => {
|
||||
readMessagesDiscord.mockResolvedValueOnce([
|
||||
{ id: "1", timestamp: "2026-01-15T10:00:00.000Z" },
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import type { ChannelPlugin } from "../../channels/plugins/types.js";
|
||||
import type { ChannelMessageActionName, ChannelPlugin } from "../../channels/plugins/types.js";
|
||||
import type { MessageActionRunResult } from "../../infra/outbound/message-action-runner.js";
|
||||
import { setActivePluginRegistry } from "../../plugins/runtime.js";
|
||||
import { createTestRegistry } from "../../test-utils/channel-plugins.js";
|
||||
@@ -45,7 +45,8 @@ function createChannelPlugin(params: {
|
||||
label: string;
|
||||
docsPath: string;
|
||||
blurb: string;
|
||||
actions: string[];
|
||||
actions?: ChannelMessageActionName[];
|
||||
listActions?: NonNullable<NonNullable<ChannelPlugin["actions"]>["listActions"]>;
|
||||
supportsButtons?: boolean;
|
||||
messaging?: ChannelPlugin["messaging"];
|
||||
}): ChannelPlugin {
|
||||
@@ -65,7 +66,11 @@ function createChannelPlugin(params: {
|
||||
},
|
||||
...(params.messaging ? { messaging: params.messaging } : {}),
|
||||
actions: {
|
||||
listActions: () => params.actions as never,
|
||||
listActions:
|
||||
params.listActions ??
|
||||
(() => {
|
||||
return (params.actions ?? []) as never;
|
||||
}),
|
||||
...(params.supportsButtons ? { supportsButtons: () => true } : {}),
|
||||
},
|
||||
};
|
||||
@@ -139,7 +144,7 @@ describe("message tool schema scoping", () => {
|
||||
label: "Telegram",
|
||||
docsPath: "/channels/telegram",
|
||||
blurb: "Telegram test plugin.",
|
||||
actions: ["send", "react"],
|
||||
actions: ["send", "react", "poll"],
|
||||
supportsButtons: true,
|
||||
});
|
||||
|
||||
@@ -161,6 +166,7 @@ describe("message tool schema scoping", () => {
|
||||
expectComponents: false,
|
||||
expectButtons: true,
|
||||
expectButtonStyle: true,
|
||||
expectTelegramPollExtras: true,
|
||||
expectedActions: ["send", "react", "poll", "poll-vote"],
|
||||
},
|
||||
{
|
||||
@@ -168,11 +174,19 @@ describe("message tool schema scoping", () => {
|
||||
expectComponents: true,
|
||||
expectButtons: false,
|
||||
expectButtonStyle: false,
|
||||
expectTelegramPollExtras: true,
|
||||
expectedActions: ["send", "poll", "poll-vote", "react"],
|
||||
},
|
||||
])(
|
||||
"scopes schema fields for $provider",
|
||||
({ provider, expectComponents, expectButtons, expectButtonStyle, expectedActions }) => {
|
||||
({
|
||||
provider,
|
||||
expectComponents,
|
||||
expectButtons,
|
||||
expectButtonStyle,
|
||||
expectTelegramPollExtras,
|
||||
expectedActions,
|
||||
}) => {
|
||||
setActivePluginRegistry(
|
||||
createTestRegistry([
|
||||
{ pluginId: "telegram", source: "test", plugin: telegramPlugin },
|
||||
@@ -209,11 +223,75 @@ describe("message tool schema scoping", () => {
|
||||
for (const action of expectedActions) {
|
||||
expect(actionEnum).toContain(action);
|
||||
}
|
||||
if (expectTelegramPollExtras) {
|
||||
expect(properties.pollDurationSeconds).toBeDefined();
|
||||
expect(properties.pollAnonymous).toBeDefined();
|
||||
expect(properties.pollPublic).toBeDefined();
|
||||
} else {
|
||||
expect(properties.pollDurationSeconds).toBeUndefined();
|
||||
expect(properties.pollAnonymous).toBeUndefined();
|
||||
expect(properties.pollPublic).toBeUndefined();
|
||||
}
|
||||
expect(properties.pollId).toBeDefined();
|
||||
expect(properties.pollOptionIndex).toBeDefined();
|
||||
expect(properties.pollOptionId).toBeDefined();
|
||||
},
|
||||
);
|
||||
|
||||
it("includes poll in the action enum when the current channel supports poll actions", () => {
|
||||
setActivePluginRegistry(
|
||||
createTestRegistry([{ pluginId: "telegram", source: "test", plugin: telegramPlugin }]),
|
||||
);
|
||||
|
||||
const tool = createMessageTool({
|
||||
config: {} as never,
|
||||
currentChannelProvider: "telegram",
|
||||
});
|
||||
const actionEnum = getActionEnum(getToolProperties(tool));
|
||||
|
||||
expect(actionEnum).toContain("poll");
|
||||
});
|
||||
|
||||
it("hides telegram poll extras when telegram polls are disabled in scoped mode", () => {
|
||||
const telegramPluginWithConfig = createChannelPlugin({
|
||||
id: "telegram",
|
||||
label: "Telegram",
|
||||
docsPath: "/channels/telegram",
|
||||
blurb: "Telegram test plugin.",
|
||||
listActions: ({ cfg }) => {
|
||||
const telegramCfg = (cfg as { channels?: { telegram?: { actions?: { poll?: boolean } } } })
|
||||
.channels?.telegram;
|
||||
return telegramCfg?.actions?.poll === false ? ["send", "react"] : ["send", "react", "poll"];
|
||||
},
|
||||
supportsButtons: true,
|
||||
});
|
||||
|
||||
setActivePluginRegistry(
|
||||
createTestRegistry([
|
||||
{ pluginId: "telegram", source: "test", plugin: telegramPluginWithConfig },
|
||||
]),
|
||||
);
|
||||
|
||||
const tool = createMessageTool({
|
||||
config: {
|
||||
channels: {
|
||||
telegram: {
|
||||
actions: {
|
||||
poll: false,
|
||||
},
|
||||
},
|
||||
},
|
||||
} as never,
|
||||
currentChannelProvider: "telegram",
|
||||
});
|
||||
const properties = getToolProperties(tool);
|
||||
const actionEnum = getActionEnum(properties);
|
||||
|
||||
expect(actionEnum).not.toContain("poll");
|
||||
expect(properties.pollDurationSeconds).toBeUndefined();
|
||||
expect(properties.pollAnonymous).toBeUndefined();
|
||||
expect(properties.pollPublic).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("message tool description", () => {
|
||||
|
||||
@@ -17,6 +17,7 @@ import { loadConfig } from "../../config/config.js";
|
||||
import { GATEWAY_CLIENT_IDS, GATEWAY_CLIENT_MODES } from "../../gateway/protocol/client-info.js";
|
||||
import { getToolResult, runMessageAction } from "../../infra/outbound/message-action-runner.js";
|
||||
import { normalizeTargetForProvider } from "../../infra/outbound/target-normalization.js";
|
||||
import { POLL_CREATION_PARAM_DEFS, POLL_CREATION_PARAM_NAMES } from "../../poll-params.js";
|
||||
import { normalizeAccountId } from "../../routing/session-key.js";
|
||||
import { stripReasoningTagsFromText } from "../../shared/text/reasoning-tags.js";
|
||||
import { normalizeMessageChannel } from "../../utils/message-channel.js";
|
||||
@@ -271,12 +272,8 @@ function buildFetchSchema() {
|
||||
};
|
||||
}
|
||||
|
||||
function buildPollSchema() {
|
||||
return {
|
||||
pollQuestion: Type.Optional(Type.String()),
|
||||
pollOption: Type.Optional(Type.Array(Type.String())),
|
||||
pollDurationHours: Type.Optional(Type.Number()),
|
||||
pollMulti: Type.Optional(Type.Boolean()),
|
||||
function buildPollSchema(options?: { includeTelegramExtras?: boolean }) {
|
||||
const props: Record<string, unknown> = {
|
||||
pollId: Type.Optional(Type.String()),
|
||||
pollOptionId: Type.Optional(
|
||||
Type.String({
|
||||
@@ -306,6 +303,27 @@ function buildPollSchema() {
|
||||
),
|
||||
),
|
||||
};
|
||||
for (const name of POLL_CREATION_PARAM_NAMES) {
|
||||
const def = POLL_CREATION_PARAM_DEFS[name];
|
||||
if (def.telegramOnly && !options?.includeTelegramExtras) {
|
||||
continue;
|
||||
}
|
||||
switch (def.kind) {
|
||||
case "string":
|
||||
props[name] = Type.Optional(Type.String());
|
||||
break;
|
||||
case "stringArray":
|
||||
props[name] = Type.Optional(Type.Array(Type.String()));
|
||||
break;
|
||||
case "number":
|
||||
props[name] = Type.Optional(Type.Number());
|
||||
break;
|
||||
case "boolean":
|
||||
props[name] = Type.Optional(Type.Boolean());
|
||||
break;
|
||||
}
|
||||
}
|
||||
return props;
|
||||
}
|
||||
|
||||
function buildChannelTargetSchema() {
|
||||
@@ -425,13 +443,14 @@ function buildMessageToolSchemaProps(options: {
|
||||
includeButtons: boolean;
|
||||
includeCards: boolean;
|
||||
includeComponents: boolean;
|
||||
includeTelegramPollExtras: boolean;
|
||||
}) {
|
||||
return {
|
||||
...buildRoutingSchema(),
|
||||
...buildSendSchema(options),
|
||||
...buildReactionSchema(),
|
||||
...buildFetchSchema(),
|
||||
...buildPollSchema(),
|
||||
...buildPollSchema({ includeTelegramExtras: options.includeTelegramPollExtras }),
|
||||
...buildChannelTargetSchema(),
|
||||
...buildStickerSchema(),
|
||||
...buildThreadSchema(),
|
||||
@@ -445,7 +464,12 @@ function buildMessageToolSchemaProps(options: {
|
||||
|
||||
function buildMessageToolSchemaFromActions(
|
||||
actions: readonly string[],
|
||||
options: { includeButtons: boolean; includeCards: boolean; includeComponents: boolean },
|
||||
options: {
|
||||
includeButtons: boolean;
|
||||
includeCards: boolean;
|
||||
includeComponents: boolean;
|
||||
includeTelegramPollExtras: boolean;
|
||||
},
|
||||
) {
|
||||
const props = buildMessageToolSchemaProps(options);
|
||||
return Type.Object({
|
||||
@@ -458,6 +482,7 @@ const MessageToolSchema = buildMessageToolSchemaFromActions(AllMessageActions, {
|
||||
includeButtons: true,
|
||||
includeCards: true,
|
||||
includeComponents: true,
|
||||
includeTelegramPollExtras: true,
|
||||
});
|
||||
|
||||
type MessageToolOptions = {
|
||||
@@ -519,6 +544,16 @@ function resolveIncludeComponents(params: {
|
||||
return listChannelSupportedActions({ cfg: params.cfg, channel: "discord" }).length > 0;
|
||||
}
|
||||
|
||||
function resolveIncludeTelegramPollExtras(params: {
|
||||
cfg: OpenClawConfig;
|
||||
currentChannelProvider?: string;
|
||||
}): boolean {
|
||||
return listChannelSupportedActions({
|
||||
cfg: params.cfg,
|
||||
channel: "telegram",
|
||||
}).includes("poll");
|
||||
}
|
||||
|
||||
function buildMessageToolSchema(params: {
|
||||
cfg: OpenClawConfig;
|
||||
currentChannelProvider?: string;
|
||||
@@ -533,10 +568,12 @@ function buildMessageToolSchema(params: {
|
||||
? supportsChannelMessageCardsForChannel({ cfg: params.cfg, channel: currentChannel })
|
||||
: supportsChannelMessageCards(params.cfg);
|
||||
const includeComponents = resolveIncludeComponents(params);
|
||||
const includeTelegramPollExtras = resolveIncludeTelegramPollExtras(params);
|
||||
return buildMessageToolSchemaFromActions(actions.length > 0 ? actions : ["send"], {
|
||||
includeButtons,
|
||||
includeCards,
|
||||
includeComponents,
|
||||
includeTelegramPollExtras,
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -8,6 +8,11 @@ const sendMessageTelegram = vi.fn(async () => ({
|
||||
messageId: "789",
|
||||
chatId: "123",
|
||||
}));
|
||||
const sendPollTelegram = vi.fn(async () => ({
|
||||
messageId: "790",
|
||||
chatId: "123",
|
||||
pollId: "poll-1",
|
||||
}));
|
||||
const sendStickerTelegram = vi.fn(async () => ({
|
||||
messageId: "456",
|
||||
chatId: "123",
|
||||
@@ -20,6 +25,7 @@ vi.mock("../../telegram/send.js", () => ({
|
||||
reactMessageTelegram(...args),
|
||||
sendMessageTelegram: (...args: Parameters<typeof sendMessageTelegram>) =>
|
||||
sendMessageTelegram(...args),
|
||||
sendPollTelegram: (...args: Parameters<typeof sendPollTelegram>) => sendPollTelegram(...args),
|
||||
sendStickerTelegram: (...args: Parameters<typeof sendStickerTelegram>) =>
|
||||
sendStickerTelegram(...args),
|
||||
deleteMessageTelegram: (...args: Parameters<typeof deleteMessageTelegram>) =>
|
||||
@@ -81,6 +87,7 @@ describe("handleTelegramAction", () => {
|
||||
envSnapshot = captureEnv(["TELEGRAM_BOT_TOKEN"]);
|
||||
reactMessageTelegram.mockClear();
|
||||
sendMessageTelegram.mockClear();
|
||||
sendPollTelegram.mockClear();
|
||||
sendStickerTelegram.mockClear();
|
||||
deleteMessageTelegram.mockClear();
|
||||
process.env.TELEGRAM_BOT_TOKEN = "tok";
|
||||
@@ -291,6 +298,70 @@ describe("handleTelegramAction", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("sends a poll", async () => {
|
||||
const result = await handleTelegramAction(
|
||||
{
|
||||
action: "poll",
|
||||
to: "@testchannel",
|
||||
question: "Ready?",
|
||||
answers: ["Yes", "No"],
|
||||
allowMultiselect: true,
|
||||
durationSeconds: 60,
|
||||
isAnonymous: false,
|
||||
silent: true,
|
||||
},
|
||||
telegramConfig(),
|
||||
);
|
||||
expect(sendPollTelegram).toHaveBeenCalledWith(
|
||||
"@testchannel",
|
||||
{
|
||||
question: "Ready?",
|
||||
options: ["Yes", "No"],
|
||||
maxSelections: 2,
|
||||
durationSeconds: 60,
|
||||
durationHours: undefined,
|
||||
},
|
||||
expect.objectContaining({
|
||||
token: "tok",
|
||||
isAnonymous: false,
|
||||
silent: true,
|
||||
}),
|
||||
);
|
||||
expect(result.details).toMatchObject({
|
||||
ok: true,
|
||||
messageId: "790",
|
||||
chatId: "123",
|
||||
pollId: "poll-1",
|
||||
});
|
||||
});
|
||||
|
||||
it("parses string booleans for poll flags", async () => {
|
||||
await handleTelegramAction(
|
||||
{
|
||||
action: "poll",
|
||||
to: "@testchannel",
|
||||
question: "Ready?",
|
||||
answers: ["Yes", "No"],
|
||||
allowMultiselect: "true",
|
||||
isAnonymous: "false",
|
||||
silent: "true",
|
||||
},
|
||||
telegramConfig(),
|
||||
);
|
||||
expect(sendPollTelegram).toHaveBeenCalledWith(
|
||||
"@testchannel",
|
||||
expect.objectContaining({
|
||||
question: "Ready?",
|
||||
options: ["Yes", "No"],
|
||||
maxSelections: 2,
|
||||
}),
|
||||
expect.objectContaining({
|
||||
isAnonymous: false,
|
||||
silent: true,
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("forwards trusted mediaLocalRoots into sendMessageTelegram", async () => {
|
||||
await handleTelegramAction(
|
||||
{
|
||||
@@ -390,6 +461,25 @@ describe("handleTelegramAction", () => {
|
||||
).rejects.toThrow(/Telegram sendMessage is disabled/);
|
||||
});
|
||||
|
||||
it("respects poll gating", async () => {
|
||||
const cfg = {
|
||||
channels: {
|
||||
telegram: { botToken: "tok", actions: { poll: false } },
|
||||
},
|
||||
} as OpenClawConfig;
|
||||
await expect(
|
||||
handleTelegramAction(
|
||||
{
|
||||
action: "poll",
|
||||
to: "@testchannel",
|
||||
question: "Lunch?",
|
||||
answers: ["Pizza", "Sushi"],
|
||||
},
|
||||
cfg,
|
||||
),
|
||||
).rejects.toThrow(/Telegram polls are disabled/);
|
||||
});
|
||||
|
||||
it("deletes a message", async () => {
|
||||
const cfg = {
|
||||
channels: { telegram: { botToken: "tok" } },
|
||||
|
||||
@@ -1,6 +1,11 @@
|
||||
import type { AgentToolResult } from "@mariozechner/pi-agent-core";
|
||||
import type { OpenClawConfig } from "../../config/config.js";
|
||||
import { createTelegramActionGate } from "../../telegram/accounts.js";
|
||||
import { readBooleanParam } from "../../plugin-sdk/boolean-param.js";
|
||||
import { resolvePollMaxSelections } from "../../polls.js";
|
||||
import {
|
||||
createTelegramActionGate,
|
||||
resolveTelegramPollActionGateState,
|
||||
} from "../../telegram/accounts.js";
|
||||
import type { TelegramButtonStyle, TelegramInlineButtons } from "../../telegram/button-types.js";
|
||||
import {
|
||||
resolveTelegramInlineButtonsScope,
|
||||
@@ -13,6 +18,7 @@ import {
|
||||
editMessageTelegram,
|
||||
reactMessageTelegram,
|
||||
sendMessageTelegram,
|
||||
sendPollTelegram,
|
||||
sendStickerTelegram,
|
||||
} from "../../telegram/send.js";
|
||||
import { getCacheStats, searchStickers } from "../../telegram/sticker-cache.js";
|
||||
@@ -21,6 +27,7 @@ import {
|
||||
jsonResult,
|
||||
readNumberParam,
|
||||
readReactionParams,
|
||||
readStringArrayParam,
|
||||
readStringOrNumberParam,
|
||||
readStringParam,
|
||||
} from "./common.js";
|
||||
@@ -238,8 +245,8 @@ export async function handleTelegramAction(
|
||||
replyToMessageId: replyToMessageId ?? undefined,
|
||||
messageThreadId: messageThreadId ?? undefined,
|
||||
quoteText: quoteText ?? undefined,
|
||||
asVoice: typeof params.asVoice === "boolean" ? params.asVoice : undefined,
|
||||
silent: typeof params.silent === "boolean" ? params.silent : undefined,
|
||||
asVoice: readBooleanParam(params, "asVoice"),
|
||||
silent: readBooleanParam(params, "silent"),
|
||||
});
|
||||
return jsonResult({
|
||||
ok: true,
|
||||
@@ -248,6 +255,60 @@ export async function handleTelegramAction(
|
||||
});
|
||||
}
|
||||
|
||||
if (action === "poll") {
|
||||
const pollActionState = resolveTelegramPollActionGateState(isActionEnabled);
|
||||
if (!pollActionState.sendMessageEnabled) {
|
||||
throw new Error("Telegram sendMessage is disabled.");
|
||||
}
|
||||
if (!pollActionState.pollEnabled) {
|
||||
throw new Error("Telegram polls are disabled.");
|
||||
}
|
||||
const to = readStringParam(params, "to", { required: true });
|
||||
const question = readStringParam(params, "question", { required: true });
|
||||
const answers = readStringArrayParam(params, "answers", { required: true });
|
||||
const allowMultiselect = readBooleanParam(params, "allowMultiselect") ?? false;
|
||||
const durationSeconds = readNumberParam(params, "durationSeconds", { integer: true });
|
||||
const durationHours = readNumberParam(params, "durationHours", { integer: true });
|
||||
const replyToMessageId = readNumberParam(params, "replyToMessageId", {
|
||||
integer: true,
|
||||
});
|
||||
const messageThreadId = readNumberParam(params, "messageThreadId", {
|
||||
integer: true,
|
||||
});
|
||||
const isAnonymous = readBooleanParam(params, "isAnonymous");
|
||||
const silent = readBooleanParam(params, "silent");
|
||||
const token = resolveTelegramToken(cfg, { accountId }).token;
|
||||
if (!token) {
|
||||
throw new Error(
|
||||
"Telegram bot token missing. Set TELEGRAM_BOT_TOKEN or channels.telegram.botToken.",
|
||||
);
|
||||
}
|
||||
const result = await sendPollTelegram(
|
||||
to,
|
||||
{
|
||||
question,
|
||||
options: answers,
|
||||
maxSelections: resolvePollMaxSelections(answers.length, allowMultiselect),
|
||||
durationSeconds: durationSeconds ?? undefined,
|
||||
durationHours: durationHours ?? undefined,
|
||||
},
|
||||
{
|
||||
token,
|
||||
accountId: accountId ?? undefined,
|
||||
replyToMessageId: replyToMessageId ?? undefined,
|
||||
messageThreadId: messageThreadId ?? undefined,
|
||||
isAnonymous: isAnonymous ?? undefined,
|
||||
silent: silent ?? undefined,
|
||||
},
|
||||
);
|
||||
return jsonResult({
|
||||
ok: true,
|
||||
messageId: result.messageId,
|
||||
chatId: result.chatId,
|
||||
pollId: result.pollId,
|
||||
});
|
||||
}
|
||||
|
||||
if (action === "deleteMessage") {
|
||||
if (!isActionEnabled("deleteMessage")) {
|
||||
throw new Error("Telegram deleteMessage is disabled.");
|
||||
|
||||
@@ -329,6 +329,44 @@ describe("handleDiscordMessageAction", () => {
|
||||
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",
|
||||
input: {
|
||||
@@ -496,6 +534,71 @@ 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("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", () => {
|
||||
const cases = [
|
||||
{
|
||||
@@ -595,6 +698,85 @@ 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: "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",
|
||||
action: "topic-create" as const,
|
||||
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
import { readDiscordParentIdParam } from "../../../../agents/tools/discord-actions-shared.js";
|
||||
import { handleDiscordAction } from "../../../../agents/tools/discord-actions.js";
|
||||
import { resolveDiscordChannelId } from "../../../../discord/targets.js";
|
||||
import { readBooleanParam } from "../../../../plugin-sdk/boolean-param.js";
|
||||
import type { ChannelMessageActionContext } from "../../types.js";
|
||||
import { resolveReactionMessageId } from "../reaction-message-id.js";
|
||||
import { tryHandleDiscordMessageActionGuildAdmin } from "./handle-action.guild-admin.js";
|
||||
@@ -38,7 +39,7 @@ export async function handleDiscordMessageAction(
|
||||
|
||||
if (action === "send") {
|
||||
const to = readStringParam(params, "to", { required: true });
|
||||
const asVoice = params.asVoice === true;
|
||||
const asVoice = readBooleanParam(params, "asVoice") === true;
|
||||
const rawComponents = params.components;
|
||||
const hasComponents =
|
||||
Boolean(rawComponents) &&
|
||||
@@ -57,7 +58,7 @@ export async function handleDiscordMessageAction(
|
||||
const replyTo = readStringParam(params, "replyTo");
|
||||
const rawEmbeds = params.embeds;
|
||||
const embeds = Array.isArray(rawEmbeds) ? rawEmbeds : undefined;
|
||||
const silent = params.silent === true;
|
||||
const silent = readBooleanParam(params, "silent") === true;
|
||||
const sessionKey = readStringParam(params, "__sessionKey");
|
||||
const agentId = readStringParam(params, "__agentId");
|
||||
return await handleDiscordAction(
|
||||
@@ -86,10 +87,11 @@ export async function handleDiscordMessageAction(
|
||||
const question = readStringParam(params, "pollQuestion", {
|
||||
required: true,
|
||||
});
|
||||
const answers = readStringArrayParam(params, "pollOption", { required: true }) ?? [];
|
||||
const allowMultiselect = typeof params.pollMulti === "boolean" ? params.pollMulti : undefined;
|
||||
const answers = readStringArrayParam(params, "pollOption", { required: true });
|
||||
const allowMultiselect = readBooleanParam(params, "pollMulti");
|
||||
const durationHours = readNumberParam(params, "pollDurationHours", {
|
||||
integer: true,
|
||||
strict: true,
|
||||
});
|
||||
return await handleDiscordAction(
|
||||
{
|
||||
@@ -116,7 +118,7 @@ export async function handleDiscordMessageAction(
|
||||
);
|
||||
}
|
||||
const emoji = readStringParam(params, "emoji", { allowEmpty: true });
|
||||
const remove = typeof params.remove === "boolean" ? params.remove : undefined;
|
||||
const remove = readBooleanParam(params, "remove");
|
||||
return await handleDiscordAction(
|
||||
{
|
||||
action: "react",
|
||||
|
||||
@@ -6,10 +6,13 @@ import {
|
||||
} from "../../../agents/tools/common.js";
|
||||
import { handleTelegramAction } from "../../../agents/tools/telegram-actions.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 { resolveTelegramPollVisibility } from "../../../poll-params.js";
|
||||
import {
|
||||
createTelegramActionGate,
|
||||
listEnabledTelegramAccounts,
|
||||
resolveTelegramPollActionGateState,
|
||||
} from "../../../telegram/accounts.js";
|
||||
import { isTelegramInlineButtonsEnabled } from "../../../telegram/inline-buttons.js";
|
||||
import type { ChannelMessageActionAdapter, ChannelMessageActionName } from "../types.js";
|
||||
@@ -27,8 +30,8 @@ function readTelegramSendParams(params: Record<string, unknown>) {
|
||||
const replyTo = readStringParam(params, "replyTo");
|
||||
const threadId = readStringParam(params, "threadId");
|
||||
const buttons = params.buttons;
|
||||
const asVoice = typeof params.asVoice === "boolean" ? params.asVoice : undefined;
|
||||
const silent = typeof params.silent === "boolean" ? params.silent : undefined;
|
||||
const asVoice = readBooleanParam(params, "asVoice");
|
||||
const silent = readBooleanParam(params, "silent");
|
||||
const quoteText = readStringParam(params, "quoteText");
|
||||
return {
|
||||
to,
|
||||
@@ -78,6 +81,16 @@ export const telegramMessageActions: ChannelMessageActionAdapter = {
|
||||
const isEnabled = (key: keyof TelegramActionConfig, defaultValue = true) =>
|
||||
gate(key, defaultValue);
|
||||
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")) {
|
||||
actions.add("react");
|
||||
}
|
||||
@@ -125,7 +138,7 @@ export const telegramMessageActions: ChannelMessageActionAdapter = {
|
||||
if (action === "react") {
|
||||
const messageId = resolveReactionMessageId({ args: params, toolContext });
|
||||
const emoji = readStringParam(params, "emoji", { allowEmpty: true });
|
||||
const remove = typeof params.remove === "boolean" ? params.remove : undefined;
|
||||
const remove = readBooleanParam(params, "remove");
|
||||
return await handleTelegramAction(
|
||||
{
|
||||
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") {
|
||||
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),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
36
src/config/telegram-actions-poll.test.ts
Normal file
36
src/config/telegram-actions-poll.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
@@ -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). */
|
||||
|
||||
@@ -225,6 +225,7 @@ export const TelegramAccountSchemaBase = z
|
||||
.object({
|
||||
reactions: z.boolean().optional(),
|
||||
sendMessage: z.boolean().optional(),
|
||||
poll: z.boolean().optional(),
|
||||
deleteMessage: z.boolean().optional(),
|
||||
sticker: z.boolean().optional(),
|
||||
})
|
||||
|
||||
@@ -236,6 +236,72 @@ 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("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 () => {
|
||||
const result = await runDrySend({
|
||||
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", () => {
|
||||
const handleAction = vi.fn(async ({ params }: { params: Record<string, unknown> }) =>
|
||||
jsonResult({
|
||||
|
||||
@@ -14,6 +14,8 @@ import type {
|
||||
} from "../../channels/plugins/types.js";
|
||||
import type { OpenClawConfig } from "../../config/config.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 { normalizeAgentId } from "../../routing/session-key.js";
|
||||
import { type GatewayClientMode, type GatewayClientName } from "../../utils/message-channel.js";
|
||||
@@ -307,7 +309,7 @@ async function handleBroadcastAction(
|
||||
if (!broadcastEnabled) {
|
||||
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) {
|
||||
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", {
|
||||
required: true,
|
||||
});
|
||||
const options = readStringArrayParam(params, "pollOption", { required: true }) ?? [];
|
||||
const options = readStringArrayParam(params, "pollOption", { required: true });
|
||||
if (options.length < 2) {
|
||||
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 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,
|
||||
strict: true,
|
||||
});
|
||||
const durationSeconds = readNumberParam(params, "pollDurationSeconds", {
|
||||
integer: true,
|
||||
strict: 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 +767,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") {
|
||||
|
||||
60
src/poll-params.test.ts
Normal file
60
src/poll-params.test.ts
Normal 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
89
src/poll-params.ts
Normal 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;
|
||||
}
|
||||
@@ -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 = {},
|
||||
|
||||
@@ -4,6 +4,7 @@ import { withEnv } from "../test-utils/env.js";
|
||||
import {
|
||||
listTelegramAccountIds,
|
||||
resetMissingDefaultWarnFlag,
|
||||
resolveTelegramPollActionGateState,
|
||||
resolveDefaultTelegramAccountId,
|
||||
resolveTelegramAccount,
|
||||
} 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)", () => {
|
||||
const createMultiAccountGroupsConfig = (): OpenClawConfig => ({
|
||||
channels: {
|
||||
|
||||
@@ -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: {
|
||||
cfg: OpenClawConfig;
|
||||
accountId?: string | null;
|
||||
|
||||
Reference in New Issue
Block a user