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: mbelinky julianengel CashWilliams omniwired jverdi Syhids meaningfool rafaelreis-r wstock vsabavat scald sreekaransrinath ratulsarna osolmaz conhecendocontato hrdwdmrbl jayhickey jamesgroat gtsifrikas djangonavarro220 azade-c andranik-sahakyan - adamgall jalehman jarvis-medmatic mneves75 regenrek tobiasbischoff MSch obviyus + adamgall jalehman jarvis-medmatic mneves75 regenrek tobiasbischoff MSch obviyus dbhurley

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;