feat: unify poll support

Co-authored-by: DBH <5251425+dbhurley@users.noreply.github.com>
This commit is contained in:
Peter Steinberger
2026-01-06 04:43:35 +00:00
parent 1f4d9e83ff
commit 0b27964693
19 changed files with 360 additions and 118 deletions

View File

@@ -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.

View File

@@ -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).

View File

@@ -441,5 +441,5 @@ Thanks to all clawtributors:
<a href="https://github.com/mbelinky"><img src="https://avatars.githubusercontent.com/u/132747814?v=4&s=48" width="48" height="48" alt="mbelinky" title="mbelinky"/></a> <a href="https://github.com/julianengel"><img src="https://avatars.githubusercontent.com/u/10634231?v=4&s=48" width="48" height="48" alt="julianengel" title="julianengel"/></a> <a href="https://github.com/CashWilliams"><img src="https://avatars.githubusercontent.com/u/613573?v=4&s=48" width="48" height="48" alt="CashWilliams" title="CashWilliams"/></a> <a href="https://github.com/omniwired"><img src="https://avatars.githubusercontent.com/u/322761?v=4&s=48" width="48" height="48" alt="omniwired" title="omniwired"/></a> <a href="https://github.com/jverdi"><img src="https://avatars.githubusercontent.com/u/345050?v=4&s=48" width="48" height="48" alt="jverdi" title="jverdi"/></a> <a href="https://github.com/Syhids"><img src="https://avatars.githubusercontent.com/u/671202?v=4&s=48" width="48" height="48" alt="Syhids" title="Syhids"/></a> <a href="https://github.com/meaningfool"><img src="https://avatars.githubusercontent.com/u/2862331?v=4&s=48" width="48" height="48" alt="meaningfool" title="meaningfool"/></a> <a href="https://github.com/rafaelreis-r"><img src="https://avatars.githubusercontent.com/u/57492577?v=4&s=48" width="48" height="48" alt="rafaelreis-r" title="rafaelreis-r"/></a> <a href="https://github.com/wstock"><img src="https://avatars.githubusercontent.com/u/1394687?v=4&s=48" width="48" height="48" alt="wstock" title="wstock"/></a> <a href="https://github.com/vsabavat"><img src="https://avatars.githubusercontent.com/u/50385532?v=4&s=48" width="48" height="48" alt="vsabavat" title="vsabavat"/></a>
<a href="https://github.com/scald"><img src="https://avatars.githubusercontent.com/u/1215913?v=4&s=48" width="48" height="48" alt="scald" title="scald"/></a> <a href="https://github.com/sreekaransrinath"><img src="https://avatars.githubusercontent.com/u/50989977?v=4&s=48" width="48" height="48" alt="sreekaransrinath" title="sreekaransrinath"/></a> <a href="https://github.com/ratulsarna"><img src="https://avatars.githubusercontent.com/u/105903728?v=4&s=48" width="48" height="48" alt="ratulsarna" title="ratulsarna"/></a> <a href="https://github.com/osolmaz"><img src="https://avatars.githubusercontent.com/u/2453968?v=4&s=48" width="48" height="48" alt="osolmaz" title="osolmaz"/></a> <a href="https://github.com/conhecendocontato"><img src="https://avatars.githubusercontent.com/u/82890727?v=4&s=48" width="48" height="48" alt="conhecendocontato" title="conhecendocontato"/></a> <a href="https://github.com/hrdwdmrbl"><img src="https://avatars.githubusercontent.com/u/554881?v=4&s=48" width="48" height="48" alt="hrdwdmrbl" title="hrdwdmrbl"/></a> <a href="https://github.com/jayhickey"><img src="https://avatars.githubusercontent.com/u/1676460?v=4&s=48" width="48" height="48" alt="jayhickey" title="jayhickey"/></a> <a href="https://github.com/jamesgroat"><img src="https://avatars.githubusercontent.com/u/2634024?v=4&s=48" width="48" height="48" alt="jamesgroat" title="jamesgroat"/></a> <a href="https://github.com/gtsifrikas"><img src="https://avatars.githubusercontent.com/u/8904378?v=4&s=48" width="48" height="48" alt="gtsifrikas" title="gtsifrikas"/></a> <a href="https://github.com/djangonavarro220"><img src="https://avatars.githubusercontent.com/u/251162586?v=4&s=48" width="48" height="48" alt="djangonavarro220" title="djangonavarro220"/></a>
<a href="https://github.com/azade-c"><img src="https://avatars.githubusercontent.com/u/252790079?v=4&s=48" width="48" height="48" alt="azade-c" title="azade-c"/></a> <a href="https://github.com/andranik-sahakyan"><img src="https://avatars.githubusercontent.com/u/8908029?v=4&s=48" width="48" height="48" alt="andranik-sahakyan" title="andranik-sahakyan"/></a>
<a href="https://github.com/adamgall"><img src="https://avatars.githubusercontent.com/u/706929?v=4&s=48" width="48" height="48" alt="adamgall" title="adamgall"/></a> <a href="https://github.com/jalehman"><img src="https://avatars.githubusercontent.com/u/550978?v=4&s=48" width="48" height="48" alt="jalehman" title="jalehman"/></a> <a href="https://github.com/jarvis-medmatic"><img src="https://avatars.githubusercontent.com/u/252428873?v=4&s=48" width="48" height="48" alt="jarvis-medmatic" title="jarvis-medmatic"/></a> <a href="https://github.com/mneves75"><img src="https://avatars.githubusercontent.com/u/2423436?v=4&s=48" width="48" height="48" alt="mneves75" title="mneves75"/></a> <a href="https://github.com/regenrek"><img src="https://avatars.githubusercontent.com/u/5182020?v=4&s=48" width="48" height="48" alt="regenrek" title="regenrek"/></a> <a href="https://github.com/tobiasbischoff"><img src="https://avatars.githubusercontent.com/u/711564?v=4&s=48" width="48" height="48" alt="tobiasbischoff" title="tobiasbischoff"/></a> <a href="https://github.com/MSch"><img src="https://avatars.githubusercontent.com/u/7475?v=4&s=48" width="48" height="48" alt="MSch" title="MSch"/></a> <a href="https://github.com/obviyus"><img src="https://avatars.githubusercontent.com/u/22031114?v=4&s=48" width="48" height="48" alt="obviyus" title="obviyus"/></a>
<a href="https://github.com/adamgall"><img src="https://avatars.githubusercontent.com/u/706929?v=4&s=48" width="48" height="48" alt="adamgall" title="adamgall"/></a> <a href="https://github.com/jalehman"><img src="https://avatars.githubusercontent.com/u/550978?v=4&s=48" width="48" height="48" alt="jalehman" title="jalehman"/></a> <a href="https://github.com/jarvis-medmatic"><img src="https://avatars.githubusercontent.com/u/252428873?v=4&s=48" width="48" height="48" alt="jarvis-medmatic" title="jarvis-medmatic"/></a> <a href="https://github.com/mneves75"><img src="https://avatars.githubusercontent.com/u/2423436?v=4&s=48" width="48" height="48" alt="mneves75" title="mneves75"/></a> <a href="https://github.com/regenrek"><img src="https://avatars.githubusercontent.com/u/5182020?v=4&s=48" width="48" height="48" alt="regenrek" title="regenrek"/></a> <a href="https://github.com/tobiasbischoff"><img src="https://avatars.githubusercontent.com/u/711564?v=4&s=48" width="48" height="48" alt="tobiasbischoff" title="tobiasbischoff"/></a> <a href="https://github.com/MSch"><img src="https://avatars.githubusercontent.com/u/7475?v=4&s=48" width="48" height="48" alt="MSch" title="MSch"/></a> <a href="https://github.com/obviyus"><img src="https://avatars.githubusercontent.com/u/22031114?v=4&s=48" width="48" height="48" alt="obviyus" title="obviyus"/></a> <a href="https://github.com/dbhurley"><img src="https://avatars.githubusercontent.com/u/5251425?v=4&s=48" width="48" height="48" alt="dbhurley" title="dbhurley"/></a>
</p>

52
docs/poll.md Normal file
View File

@@ -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).

View File

@@ -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 });

View File

@@ -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 <jid>",
"Recipient JID (e.g. +15555550123 or group JID)",
"-t, --to <id>",
"Recipient: WhatsApp JID/number or Discord channel/user",
)
.requiredOption("-q, --question <text>", "Poll question")
.requiredOption(
@@ -401,9 +401,16 @@ Examples:
[] as string[],
)
.option(
"-s, --selectable-count <n>",
"-s, --max-selections <n>",
"How many options can be selected (default: 1)",
"1",
)
.option(
"--duration-hours <n>",
"Poll duration in hours (Discord only, default: 24)",
)
.option(
"--provider <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);

View File

@@ -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,

View File

@@ -1,2 +1,2 @@
export { monitorDiscordProvider } from "./monitor.js";
export { sendMessageDiscord } from "./send.js";
export { sendMessageDiscord, sendPollDiscord } from "./send.js";

View File

@@ -596,7 +596,7 @@ describe("sendPollDiscord", () => {
"channel:789",
{
question: "Lunch?",
answers: ["Pizza", "Sushi"],
options: ["Pizza", "Sushi"],
},
{
rest,

View File

@@ -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<DiscordSendResult> {
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,

View File

@@ -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,

View File

@@ -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,

View File

@@ -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<string, unknown>;
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),
});
}

41
src/polls.test.ts Normal file
View File

@@ -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);
});
});

71
src/polls.ts Normal file
View File

@@ -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);
}

View File

@@ -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<void>;
close?: () => Promise<void>;
};

View File

@@ -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.

View File

@@ -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,
});
});
});

View File

@@ -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;