diff --git a/AGENTS.md b/AGENTS.md
index 48ff1d6eb..a2b576c72 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -33,6 +33,7 @@
- When working on a PR: add a changelog entry with the PR ID and thank the contributor.
- When working on an issue: reference the issue in the changelog entry.
- When merging a PR: leave a PR comment that explains exactly what we did.
+- When merging a PR from a new contributor: add their avatar to the README “Thanks to all clawtributors” thumbnail list.
## Security & Configuration Tips
- Web provider stores creds at `~/.clawdbot/credentials/`; rerun `clawdbot login` if logged out.
diff --git a/CHANGELOG.md b/CHANGELOG.md
index bc54a8650..baa71b1a3 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -18,6 +18,7 @@
- Google: merge consecutive messages to satisfy strict role alternation for Google provider models. Thanks @Asleep123 for PR #266.
- WhatsApp/Telegram: add groupPolicy handling for group messages and normalize allowFrom matching (tg/telegram prefixes). Thanks @mneves75.
- Auto-reply: add configurable ack reactions for inbound messages (default 👀 or `identity.emoji`) with scope controls. Thanks @obviyus for PR #178.
+- Polls: unify WhatsApp + Discord poll sends via the gateway + CLI (`clawdbot poll`). (#123) — thanks @dbhurley
- Onboarding: resolve CLI entrypoint when running via `npx` so gateway daemon install works without a build step.
- Onboarding: when OpenAI Codex OAuth is used, default to `openai-codex/gpt-5.2` and warn if the selected model lacks auth.
- CLI: auto-migrate legacy config entries on command start (same behavior as gateway startup).
diff --git a/README.md b/README.md
index 0c32c8242..dfa4ad4fe 100644
--- a/README.md
+++ b/README.md
@@ -441,5 +441,5 @@ Thanks to all clawtributors:
-
+
diff --git a/docs/poll.md b/docs/poll.md
new file mode 100644
index 000000000..f00269512
--- /dev/null
+++ b/docs/poll.md
@@ -0,0 +1,52 @@
+---
+summary: "Poll sending via gateway + CLI"
+read_when:
+ - Adding or modifying poll support
+ - Debugging poll sends from the CLI or gateway
+---
+# Polls
+
+Updated: 2026-01-06
+
+## Supported providers
+- WhatsApp (web provider)
+- Discord
+
+## CLI
+
+```bash
+# WhatsApp
+clawdbot poll --to +15555550123 -q "Lunch today?" -o "Yes" -o "No" -o "Maybe"
+clawdbot poll --to 123456789@g.us -q "Meeting time?" -o "10am" -o "2pm" -o "4pm" -s 2
+
+# Discord
+clawdbot poll --to channel:123456789 -q "Snack?" -o "Pizza" -o "Sushi" --provider discord
+clawdbot poll --to channel:123456789 -q "Plan?" -o "A" -o "B" --provider discord --duration-hours 48
+```
+
+Options:
+- `--provider`: `whatsapp` (default) or `discord`
+- `--max-selections`: how many choices a voter can select (default: 1)
+- `--duration-hours`: Discord-only (defaults to 24 when omitted)
+
+## Gateway RPC
+
+Method: `poll`
+
+Params:
+- `to` (string, required)
+- `question` (string, required)
+- `options` (string[], required)
+- `maxSelections` (number, optional)
+- `durationHours` (number, optional)
+- `provider` (string, optional, default: `whatsapp`)
+- `idempotencyKey` (string, required)
+
+## Provider differences
+- 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.
+
+## Agent tool (Discord)
+The Discord tool action `poll` still uses `question`, `answers`, optional `allowMultiselect`, `durationHours`, and `content`. The gateway/CLI poll model maps `allowMultiselect` to `maxSelections > 1`.
+
+Note: Discord has no “pick exactly N” mode; `maxSelections` is treated as a boolean (`> 1` = multiselect).
diff --git a/src/agents/tools/discord-actions-messaging.ts b/src/agents/tools/discord-actions-messaging.ts
index 63fbe491a..855a72d8f 100644
--- a/src/agents/tools/discord-actions-messaging.ts
+++ b/src/agents/tools/discord-actions-messaging.ts
@@ -126,9 +126,10 @@ export async function handleDiscordMessagingAction(
typeof durationRaw === "number" && Number.isFinite(durationRaw)
? durationRaw
: undefined;
+ const maxSelections = allowMultiselect ? Math.max(2, answers.length) : 1;
await sendPollDiscord(
to,
- { question, answers, allowMultiselect, durationHours },
+ { question, options: answers, maxSelections, durationHours },
{ content },
);
return jsonResult({ ok: true });
diff --git a/src/cli/program.ts b/src/cli/program.ts
index 4f51abe9f..0b46a84ab 100644
--- a/src/cli/program.ts
+++ b/src/cli/program.ts
@@ -388,10 +388,10 @@ Examples:
program
.command("poll")
- .description("Create a WhatsApp poll in a chat or group")
+ .description("Create a poll via WhatsApp or Discord")
.requiredOption(
- "-t, --to ",
- "Recipient JID (e.g. +15555550123 or group JID)",
+ "-t, --to ",
+ "Recipient: WhatsApp JID/number or Discord channel/user",
)
.requiredOption("-q, --question ", "Poll question")
.requiredOption(
@@ -401,9 +401,16 @@ Examples:
[] as string[],
)
.option(
- "-s, --selectable-count ",
+ "-s, --max-selections ",
"How many options can be selected (default: 1)",
- "1",
+ )
+ .option(
+ "--duration-hours ",
+ "Poll duration in hours (Discord only, default: 24)",
+ )
+ .option(
+ "--provider ",
+ "Delivery provider: whatsapp|discord (default: whatsapp)",
)
.option("--dry-run", "Print payload and skip sending", false)
.option("--json", "Output result as JSON", false)
@@ -414,24 +421,14 @@ Examples:
Examples:
clawdbot poll --to +15555550123 -q "Lunch today?" -o "Yes" -o "No" -o "Maybe"
clawdbot poll --to 123456789@g.us -q "Meeting time?" -o "10am" -o "2pm" -o "4pm" -s 2
- clawdbot poll --to +15555550123 -q "Favorite color?" -o "Red" -o "Blue" --json`,
+ clawdbot poll --to channel:123456789 -q "Snack?" -o "Pizza" -o "Sushi" --provider discord
+ clawdbot poll --to channel:123456789 -q "Plan?" -o "A" -o "B" --provider discord --duration-hours 48`,
)
.action(async (opts) => {
setVerbose(Boolean(opts.verbose));
const deps = createDefaultDeps();
try {
- await pollCommand(
- {
- to: opts.to,
- question: opts.question,
- options: opts.option,
- selectableCount: Number.parseInt(opts.selectableCount, 10) || 1,
- json: opts.json,
- dryRun: opts.dryRun,
- },
- deps,
- defaultRuntime,
- );
+ await pollCommand(opts, deps, defaultRuntime);
} catch (err) {
defaultRuntime.error(String(err));
defaultRuntime.exit(1);
diff --git a/src/commands/poll.ts b/src/commands/poll.ts
index a7d528c7e..5fda34838 100644
--- a/src/commands/poll.ts
+++ b/src/commands/poll.ts
@@ -1,30 +1,53 @@
import type { CliDeps } from "../cli/deps.js";
import { callGateway, randomIdempotencyKey } from "../gateway/call.js";
import { success } from "../globals.js";
+import { normalizePollInput, type PollInput } from "../polls.js";
import type { RuntimeEnv } from "../runtime.js";
+function parseIntOption(value: unknown, label: string): number | undefined {
+ if (value === undefined || value === null) return undefined;
+ if (typeof value !== "string" || value.trim().length === 0) return undefined;
+ const parsed = Number.parseInt(value, 10);
+ if (!Number.isFinite(parsed)) {
+ throw new Error(`${label} must be a number`);
+ }
+ return parsed;
+}
+
export async function pollCommand(
opts: {
to: string;
question: string;
- options: string[];
- selectableCount?: number;
+ option: string[];
+ maxSelections?: string;
+ durationHours?: string;
+ provider?: string;
json?: boolean;
dryRun?: boolean;
},
_deps: CliDeps,
runtime: RuntimeEnv,
) {
- if (opts.options.length < 2) {
- throw new Error("Poll requires at least 2 options");
- }
- if (opts.options.length > 12) {
- throw new Error("Poll supports at most 12 options");
+ const provider = (opts.provider ?? "whatsapp").toLowerCase();
+ if (provider !== "whatsapp" && provider !== "discord") {
+ throw new Error(`Unsupported poll provider: ${provider}`);
}
+ const maxSelections = parseIntOption(opts.maxSelections, "max-selections");
+ const durationHours = parseIntOption(opts.durationHours, "duration-hours");
+
+ const pollInput: PollInput = {
+ question: opts.question,
+ options: opts.option,
+ maxSelections,
+ durationHours,
+ };
+ const maxOptions = provider === "discord" ? 10 : 12;
+ const normalized = normalizePollInput(pollInput, { maxOptions });
+
if (opts.dryRun) {
runtime.log(
- `[dry-run] would send poll to ${opts.to}:\n Question: ${opts.question}\n Options: ${opts.options.join(", ")}\n Selectable: ${opts.selectableCount ?? 1}`,
+ `[dry-run] would send poll via ${provider} -> ${opts.to}:\n Question: ${normalized.question}\n Options: ${normalized.options.join(", ")}\n Max selections: ${normalized.maxSelections}`,
);
return;
}
@@ -32,14 +55,17 @@ export async function pollCommand(
const result = await callGateway<{
messageId: string;
toJid?: string;
+ channelId?: string;
}>({
url: "ws://127.0.0.1:18789",
method: "poll",
params: {
to: opts.to,
- question: opts.question,
- options: opts.options,
- selectableCount: opts.selectableCount ?? 1,
+ question: normalized.question,
+ options: normalized.options,
+ maxSelections: normalized.maxSelections,
+ durationHours: normalized.durationHours,
+ provider,
idempotencyKey: randomIdempotencyKey(),
},
timeoutMs: 10_000,
@@ -49,21 +75,23 @@ export async function pollCommand(
runtime.log(
success(
- `✅ Poll sent via gateway. Message ID: ${result.messageId ?? "unknown"}`,
+ `✅ Poll sent via gateway (${provider}). Message ID: ${result.messageId ?? "unknown"}`,
),
);
if (opts.json) {
runtime.log(
JSON.stringify(
{
- provider: "whatsapp",
+ provider,
via: "gateway",
to: opts.to,
- toJid: result.toJid,
+ toJid: result.toJid ?? null,
+ channelId: result.channelId ?? null,
messageId: result.messageId,
- question: opts.question,
- options: opts.options,
- selectableCount: opts.selectableCount ?? 1,
+ question: normalized.question,
+ options: normalized.options,
+ maxSelections: normalized.maxSelections,
+ durationHours: normalized.durationHours ?? null,
},
null,
2,
diff --git a/src/discord/index.ts b/src/discord/index.ts
index 4bd4018e3..c9e1b3c83 100644
--- a/src/discord/index.ts
+++ b/src/discord/index.ts
@@ -1,2 +1,2 @@
export { monitorDiscordProvider } from "./monitor.js";
-export { sendMessageDiscord } from "./send.js";
+export { sendMessageDiscord, sendPollDiscord } from "./send.js";
diff --git a/src/discord/send.test.ts b/src/discord/send.test.ts
index 675cb464a..f0b291698 100644
--- a/src/discord/send.test.ts
+++ b/src/discord/send.test.ts
@@ -596,7 +596,7 @@ describe("sendPollDiscord", () => {
"channel:789",
{
question: "Lunch?",
- answers: ["Pizza", "Sushi"],
+ options: ["Pizza", "Sushi"],
},
{
rest,
diff --git a/src/discord/send.ts b/src/discord/send.ts
index 821cd1b80..7c58158fa 100644
--- a/src/discord/send.ts
+++ b/src/discord/send.ts
@@ -14,6 +14,11 @@ import type {
import { chunkText } from "../auto-reply/chunk.js";
import { loadConfig } from "../config/config.js";
+import {
+ normalizePollDurationHours,
+ normalizePollInput,
+ type PollInput,
+} from "../polls.js";
import { loadWebMedia, loadWebMediaRaw } from "../web/media.js";
import { normalizeDiscordToken } from "./token.js";
@@ -21,7 +26,6 @@ const DISCORD_TEXT_LIMIT = 2000;
const DISCORD_MAX_STICKERS = 3;
const DISCORD_MAX_EMOJI_BYTES = 256 * 1024;
const DISCORD_MAX_STICKER_BYTES = 512 * 1024;
-const DISCORD_POLL_MIN_ANSWERS = 2;
const DISCORD_POLL_MAX_ANSWERS = 10;
const DISCORD_POLL_MAX_DURATION_HOURS = 32 * 24;
const DISCORD_MISSING_PERMISSIONS = 50013;
@@ -66,13 +70,6 @@ export type DiscordSendResult = {
channelId: string;
};
-export type DiscordPollInput = {
- question: string;
- answers: string[];
- allowMultiselect?: boolean;
- durationHours?: number;
-};
-
export type DiscordReactOpts = {
token?: string;
rest?: REST;
@@ -238,34 +235,19 @@ function normalizeEmojiName(raw: string, label: string) {
return name;
}
-function normalizePollInput(input: DiscordPollInput): RESTAPIPoll {
- const question = input.question.trim();
- if (!question) {
- throw new Error("Poll question is required");
- }
- const answers = (input.answers ?? [])
- .map((answer) => answer.trim())
- .filter(Boolean);
- if (answers.length < DISCORD_POLL_MIN_ANSWERS) {
- throw new Error("Polls require at least 2 answers");
- }
- if (answers.length > DISCORD_POLL_MAX_ANSWERS) {
- throw new Error("Polls support up to 10 answers");
- }
- const durationRaw =
- typeof input.durationHours === "number" &&
- Number.isFinite(input.durationHours)
- ? Math.floor(input.durationHours)
- : 24;
- const duration = Math.min(
- Math.max(durationRaw, 1),
- DISCORD_POLL_MAX_DURATION_HOURS,
- );
+function normalizeDiscordPollInput(input: PollInput): RESTAPIPoll {
+ const poll = normalizePollInput(input, {
+ maxOptions: DISCORD_POLL_MAX_ANSWERS,
+ });
+ const duration = normalizePollDurationHours(poll.durationHours, {
+ defaultHours: 24,
+ maxHours: DISCORD_POLL_MAX_DURATION_HOURS,
+ });
return {
- question: { text: question },
- answers: answers.map((answer) => ({ poll_media: { text: answer } })),
+ question: { text: poll.question },
+ answers: poll.options.map((answer) => ({ poll_media: { text: answer } })),
duration,
- allow_multiselect: input.allowMultiselect ?? false,
+ allow_multiselect: poll.maxSelections > 1,
layout_type: PollLayoutType.Default,
};
}
@@ -519,7 +501,7 @@ export async function sendStickerDiscord(
export async function sendPollDiscord(
to: string,
- poll: DiscordPollInput,
+ poll: PollInput,
opts: DiscordSendOpts & { content?: string } = {},
): Promise {
const token = resolveToken(opts.token);
@@ -527,7 +509,7 @@ export async function sendPollDiscord(
const recipient = parseRecipient(to);
const { channelId } = await resolveChannelId(rest, recipient);
const content = opts.content?.trim();
- const payload = normalizePollInput(poll);
+ const payload = normalizeDiscordPollInput(poll);
const res = (await rest.post(Routes.channelMessages(channelId), {
body: {
content: content || undefined,
diff --git a/src/gateway/protocol/index.ts b/src/gateway/protocol/index.ts
index e2892a84c..edd0d4590 100644
--- a/src/gateway/protocol/index.ts
+++ b/src/gateway/protocol/index.ts
@@ -68,6 +68,8 @@ import {
NodePairVerifyParamsSchema,
type NodeRenameParams,
NodeRenameParamsSchema,
+ type PollParams,
+ PollParamsSchema,
PROTOCOL_VERSION,
type PresenceEntry,
PresenceEntrySchema,
@@ -349,6 +351,7 @@ export type {
ErrorShape,
StateVersion,
AgentEvent,
+ PollParams,
AgentWaitParams,
ChatEvent,
TickEvent,
diff --git a/src/gateway/protocol/schema.ts b/src/gateway/protocol/schema.ts
index def5d430c..c93645366 100644
--- a/src/gateway/protocol/schema.ts
+++ b/src/gateway/protocol/schema.ts
@@ -203,12 +203,13 @@ export const PollParamsSchema = Type.Object(
to: NonEmptyString,
question: NonEmptyString,
options: Type.Array(NonEmptyString, { minItems: 2, maxItems: 12 }),
- selectableCount: Type.Optional(Type.Integer({ minimum: 1, maximum: 12 })),
+ maxSelections: Type.Optional(Type.Integer({ minimum: 1, maximum: 12 })),
+ durationHours: Type.Optional(Type.Integer({ minimum: 1 })),
+ provider: Type.Optional(Type.String()),
idempotencyKey: NonEmptyString,
},
{ additionalProperties: false },
);
-
export const AgentParamsSchema = Type.Object(
{
message: NonEmptyString,
diff --git a/src/gateway/server-methods/send.ts b/src/gateway/server-methods/send.ts
index 39d103e33..65461385a 100644
--- a/src/gateway/server-methods/send.ts
+++ b/src/gateway/server-methods/send.ts
@@ -1,5 +1,5 @@
import { loadConfig } from "../../config/config.js";
-import { sendMessageDiscord } from "../../discord/index.js";
+import { sendMessageDiscord, sendPollDiscord } from "../../discord/index.js";
import { shouldLogVerbose } from "../../globals.js";
import { sendMessageIMessage } from "../../imessage/index.js";
import { sendMessageSignal } from "../../signal/index.js";
@@ -179,7 +179,6 @@ export const sendHandlers: GatewayRequestHandlers = {
});
}
},
-
poll: async ({ params, respond, context }) => {
const p = params as Record;
if (!validatePollParams(p)) {
@@ -197,7 +196,9 @@ export const sendHandlers: GatewayRequestHandlers = {
to: string;
question: string;
options: string[];
- selectableCount?: number;
+ maxSelections?: number;
+ durationHours?: number;
+ provider?: string;
idempotencyKey: string;
};
const idem = request.idempotencyKey;
@@ -209,28 +210,57 @@ export const sendHandlers: GatewayRequestHandlers = {
return;
}
const to = request.to.trim();
- const question = request.question.trim();
- const options = request.options.map((o) => o.trim());
- const selectableCount = request.selectableCount ?? 1;
-
- try {
- const result = await sendPollWhatsApp(
- to,
- { question, options, selectableCount },
- { verbose: shouldLogVerbose() },
+ const providerRaw = (request.provider ?? "whatsapp").toLowerCase();
+ const provider = providerRaw === "imsg" ? "imessage" : providerRaw;
+ if (provider !== "whatsapp" && provider !== "discord") {
+ respond(
+ false,
+ undefined,
+ errorShape(
+ ErrorCodes.INVALID_REQUEST,
+ `unsupported poll provider: ${provider}`,
+ ),
);
- const payload = {
- runId: idem,
- messageId: result.messageId,
- toJid: result.toJid ?? `${to}@s.whatsapp.net`,
- provider: "whatsapp",
- };
- context.dedupe.set(`poll:${idem}`, {
- ts: Date.now(),
- ok: true,
- payload,
- });
- respond(true, payload, undefined, { provider: "whatsapp" });
+ return;
+ }
+ const poll = {
+ question: request.question,
+ options: request.options,
+ maxSelections: request.maxSelections,
+ durationHours: request.durationHours,
+ };
+ try {
+ if (provider === "discord") {
+ const result = await sendPollDiscord(to, poll);
+ const payload = {
+ runId: idem,
+ messageId: result.messageId,
+ channelId: result.channelId,
+ provider,
+ };
+ context.dedupe.set(`poll:${idem}`, {
+ ts: Date.now(),
+ ok: true,
+ payload,
+ });
+ respond(true, payload, undefined, { provider });
+ } else {
+ const result = await sendPollWhatsApp(to, poll, {
+ verbose: shouldLogVerbose(),
+ });
+ const payload = {
+ runId: idem,
+ messageId: result.messageId,
+ toJid: result.toJid ?? `${to}@s.whatsapp.net`,
+ provider,
+ };
+ context.dedupe.set(`poll:${idem}`, {
+ ts: Date.now(),
+ ok: true,
+ payload,
+ });
+ respond(true, payload, undefined, { provider });
+ }
} catch (err) {
const error = errorShape(ErrorCodes.UNAVAILABLE, String(err));
context.dedupe.set(`poll:${idem}`, {
@@ -239,7 +269,7 @@ export const sendHandlers: GatewayRequestHandlers = {
error,
});
respond(false, undefined, error, {
- provider: "whatsapp",
+ provider,
error: formatForLog(err),
});
}
diff --git a/src/polls.test.ts b/src/polls.test.ts
new file mode 100644
index 000000000..e2f351b9a
--- /dev/null
+++ b/src/polls.test.ts
@@ -0,0 +1,41 @@
+import { describe, expect, it } from "vitest";
+
+import { normalizePollDurationHours, normalizePollInput } from "./polls.js";
+
+describe("polls", () => {
+ it("normalizes question/options and validates maxSelections", () => {
+ expect(
+ normalizePollInput({
+ question: " Lunch? ",
+ options: [" Pizza ", " ", "Sushi"],
+ maxSelections: 2,
+ }),
+ ).toEqual({
+ question: "Lunch?",
+ options: ["Pizza", "Sushi"],
+ maxSelections: 2,
+ durationHours: undefined,
+ });
+ });
+
+ it("enforces max option count when configured", () => {
+ expect(() =>
+ normalizePollInput(
+ { question: "Q", options: ["A", "B", "C"] },
+ { maxOptions: 2 },
+ ),
+ ).toThrow(/at most 2/);
+ });
+
+ it("clamps poll duration with defaults", () => {
+ expect(
+ normalizePollDurationHours(undefined, { defaultHours: 24, maxHours: 48 }),
+ ).toBe(24);
+ expect(
+ normalizePollDurationHours(999, { defaultHours: 24, maxHours: 48 }),
+ ).toBe(48);
+ expect(
+ normalizePollDurationHours(1, { defaultHours: 24, maxHours: 48 }),
+ ).toBe(1);
+ });
+});
diff --git a/src/polls.ts b/src/polls.ts
new file mode 100644
index 000000000..784412fd4
--- /dev/null
+++ b/src/polls.ts
@@ -0,0 +1,71 @@
+export type PollInput = {
+ question: string;
+ options: string[];
+ maxSelections?: number;
+ durationHours?: number;
+};
+
+export type NormalizedPollInput = {
+ question: string;
+ options: string[];
+ maxSelections: number;
+ durationHours?: number;
+};
+
+type NormalizePollOptions = {
+ maxOptions?: number;
+};
+
+export function normalizePollInput(
+ input: PollInput,
+ options: NormalizePollOptions = {},
+): NormalizedPollInput {
+ const question = input.question.trim();
+ if (!question) {
+ throw new Error("Poll question is required");
+ }
+ const pollOptions = (input.options ?? []).map((option) => option.trim());
+ const cleaned = pollOptions.filter(Boolean);
+ if (cleaned.length < 2) {
+ throw new Error("Poll requires at least 2 options");
+ }
+ if (options.maxOptions !== undefined && cleaned.length > options.maxOptions) {
+ throw new Error(`Poll supports at most ${options.maxOptions} options`);
+ }
+ const maxSelectionsRaw = input.maxSelections;
+ const maxSelections =
+ typeof maxSelectionsRaw === "number" && Number.isFinite(maxSelectionsRaw)
+ ? Math.floor(maxSelectionsRaw)
+ : 1;
+ if (maxSelections < 1) {
+ throw new Error("maxSelections must be at least 1");
+ }
+ if (maxSelections > cleaned.length) {
+ throw new Error("maxSelections cannot exceed option count");
+ }
+ const durationRaw = input.durationHours;
+ const durationHours =
+ typeof durationRaw === "number" && Number.isFinite(durationRaw)
+ ? Math.floor(durationRaw)
+ : undefined;
+ if (durationHours !== undefined && durationHours < 1) {
+ throw new Error("durationHours must be at least 1");
+ }
+ return {
+ question,
+ options: cleaned,
+ maxSelections,
+ durationHours,
+ };
+}
+
+export function normalizePollDurationHours(
+ value: number | undefined,
+ options: { defaultHours: number; maxHours: number },
+): number {
+ const base =
+ typeof value === "number" && Number.isFinite(value)
+ ? Math.floor(value)
+ : options.defaultHours;
+ return Math.min(Math.max(base, 1), options.maxHours);
+}
diff --git a/src/web/active-listener.ts b/src/web/active-listener.ts
index 5bad604d5..6c9fc41a6 100644
--- a/src/web/active-listener.ts
+++ b/src/web/active-listener.ts
@@ -1,13 +1,9 @@
+import type { PollInput } from "../polls.js";
+
export type ActiveWebSendOptions = {
gifPlayback?: boolean;
};
-export type PollOptions = {
- question: string;
- options: string[];
- selectableCount?: number;
-};
-
export type ActiveWebListener = {
sendMessage: (
to: string,
@@ -16,7 +12,7 @@ export type ActiveWebListener = {
mediaType?: string,
options?: ActiveWebSendOptions,
) => Promise<{ messageId: string }>;
- sendPoll: (to: string, poll: PollOptions) => Promise<{ messageId: string }>;
+ sendPoll: (to: string, poll: PollInput) => Promise<{ messageId: string }>;
sendComposingTo: (to: string) => Promise;
close?: () => Promise;
};
diff --git a/src/web/inbound.ts b/src/web/inbound.ts
index a9ec2d2fd..973a0c4b4 100644
--- a/src/web/inbound.ts
+++ b/src/web/inbound.ts
@@ -456,6 +456,20 @@ export async function monitorWebInbox(options: {
const result = await sock.sendMessage(jid, payload);
return { messageId: result?.key?.id ?? "unknown" };
},
+ sendPoll: async (
+ to: string,
+ poll: { question: string; options: string[]; maxSelections?: number },
+ ): Promise<{ messageId: string }> => {
+ const jid = toWhatsappJid(to);
+ const result = await sock.sendMessage(jid, {
+ poll: {
+ name: poll.question,
+ values: poll.options,
+ selectableCount: poll.maxSelections ?? 1,
+ },
+ });
+ return { messageId: result?.key?.id ?? "unknown" };
+ },
/**
* Send typing indicator ("composing") to a chat.
* Used after IPC send to show more messages are coming.
diff --git a/src/web/outbound.test.ts b/src/web/outbound.test.ts
index d36a51f66..e7c3a2ba1 100644
--- a/src/web/outbound.test.ts
+++ b/src/web/outbound.test.ts
@@ -8,15 +8,16 @@ vi.mock("./media.js", () => ({
loadWebMedia: (...args: unknown[]) => loadWebMediaMock(...args),
}));
-import { sendMessageWhatsApp } from "./outbound.js";
+import { sendMessageWhatsApp, sendPollWhatsApp } from "./outbound.js";
describe("web outbound", () => {
const sendComposingTo = vi.fn(async () => {});
const sendMessage = vi.fn(async () => ({ messageId: "msg123" }));
+ const sendPoll = vi.fn(async () => ({ messageId: "poll123" }));
beforeEach(() => {
vi.clearAllMocks();
- setActiveWebListener({ sendComposingTo, sendMessage });
+ setActiveWebListener({ sendComposingTo, sendMessage, sendPoll });
});
afterEach(() => {
@@ -137,4 +138,22 @@ describe("web outbound", () => {
"application/pdf",
);
});
+
+ it("sends polls via active listener", async () => {
+ const result = await sendPollWhatsApp(
+ "+1555",
+ { question: "Lunch?", options: ["Pizza", "Sushi"], maxSelections: 2 },
+ { verbose: false },
+ );
+ expect(result).toEqual({
+ messageId: "poll123",
+ toJid: "1555@s.whatsapp.net",
+ });
+ expect(sendPoll).toHaveBeenCalledWith("+1555", {
+ question: "Lunch?",
+ options: ["Pizza", "Sushi"],
+ maxSelections: 2,
+ durationHours: undefined,
+ });
+ });
});
diff --git a/src/web/outbound.ts b/src/web/outbound.ts
index 27bb559a9..5d4b3dfdc 100644
--- a/src/web/outbound.ts
+++ b/src/web/outbound.ts
@@ -1,10 +1,10 @@
import { randomUUID } from "node:crypto";
import { createSubsystemLogger, getChildLogger } from "../logging.js";
+import { normalizePollInput, type PollInput } from "../polls.js";
import { toWhatsappJid } from "../utils.js";
import {
type ActiveWebSendOptions,
- type PollOptions,
getActiveWebListener,
} from "./active-listener.js";
import { loadWebMedia } from "./media.js";
@@ -86,11 +86,10 @@ export async function sendMessageWhatsApp(
throw err;
}
}
-
export async function sendPollWhatsApp(
to: string,
- poll: PollOptions,
- options: { verbose: boolean },
+ poll: PollInput,
+ _options: { verbose: boolean },
): Promise<{ messageId: string; toJid: string }> {
const correlationId = randomUUID();
const startedAt = Date.now();
@@ -107,12 +106,18 @@ export async function sendPollWhatsApp(
});
try {
const jid = toWhatsappJid(to);
- outboundLog.info(`Sending poll -> ${jid}: "${poll.question}"`);
+ const normalized = normalizePollInput(poll, { maxOptions: 12 });
+ outboundLog.info(`Sending poll -> ${jid}: "${normalized.question}"`);
logger.info(
- { jid, question: poll.question, optionCount: poll.options.length },
+ {
+ jid,
+ question: normalized.question,
+ optionCount: normalized.options.length,
+ maxSelections: normalized.maxSelections,
+ },
"sending poll",
);
- const result = await active.sendPoll(to, poll);
+ const result = await active.sendPoll(to, normalized);
const messageId =
(result as { messageId?: string })?.messageId ?? "unknown";
const durationMs = Date.now() - startedAt;