From 2737e17c678991b67c411504ab5f25a6c5d4360c Mon Sep 17 00:00:00 2001 From: DBH Date: Mon, 5 Jan 2026 23:44:15 -0500 Subject: [PATCH] feat: Add WhatsApp poll support (#248) Implements issue #123 - WhatsApp Poll Support ## Gateway Protocol - Add `poll` RPC method with params: to, question, options (2-12), selectableCount ## ActiveWebListener - Add `sendPoll(to, poll)` method to interface - Implementation uses Baileys poll message type ## CLI Command - `clawdbot poll --to -q -o -o [-s count]` - Supports --dry-run, --json, --verbose flags - Validates 2-12 options ## Changes - src/gateway/protocol/schema.ts: Add PollParamsSchema - src/gateway/protocol/index.ts: Export validator and types - src/web/active-listener.ts: Add sendPoll to interface - src/web/inbound.ts: Implement sendPoll using Baileys - src/web/outbound.ts: Add sendPollWhatsApp function - src/gateway/server-methods/send.ts: Add poll handler - src/commands/poll.ts: New CLI command - src/cli/program.ts: Register poll command Closes #123 --- src/cli/program.ts | 53 ++++++++++++++++++++++ src/commands/poll.ts | 73 ++++++++++++++++++++++++++++++ src/gateway/protocol/index.ts | 5 ++ src/gateway/protocol/schema.ts | 13 ++++++ src/gateway/server-methods/send.ts | 68 +++++++++++++++++++++++++++- src/web/active-listener.ts | 7 +++ src/web/inbound.ts | 18 ++++++++ src/web/outbound.ts | 42 +++++++++++++++++ 8 files changed, 278 insertions(+), 1 deletion(-) create mode 100644 src/commands/poll.ts diff --git a/src/cli/program.ts b/src/cli/program.ts index d67b43a70..4f51abe9f 100644 --- a/src/cli/program.ts +++ b/src/cli/program.ts @@ -5,6 +5,7 @@ import { configureCommand } from "../commands/configure.js"; import { doctorCommand } from "../commands/doctor.js"; import { healthCommand } from "../commands/health.js"; import { onboardCommand } from "../commands/onboard.js"; +import { pollCommand } from "../commands/poll.js"; import { sendCommand } from "../commands/send.js"; import { sessionsCommand } from "../commands/sessions.js"; import { setupCommand } from "../commands/setup.js"; @@ -385,6 +386,58 @@ Examples: } }); + program + .command("poll") + .description("Create a WhatsApp poll in a chat or group") + .requiredOption( + "-t, --to ", + "Recipient JID (e.g. +15555550123 or group JID)", + ) + .requiredOption("-q, --question ", "Poll question") + .requiredOption( + "-o, --option ", + "Poll option (use multiple times, 2-12 required)", + (value: string, previous: string[]) => previous.concat([value]), + [] as string[], + ) + .option( + "-s, --selectable-count ", + "How many options can be selected (default: 1)", + "1", + ) + .option("--dry-run", "Print payload and skip sending", false) + .option("--json", "Output result as JSON", false) + .option("--verbose", "Verbose logging", false) + .addHelpText( + "after", + ` +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`, + ) + .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, + ); + } catch (err) { + defaultRuntime.error(String(err)); + defaultRuntime.exit(1); + } + }); + program .command("agent") .description("Run an agent turn via the Gateway (use --local for embedded)") diff --git a/src/commands/poll.ts b/src/commands/poll.ts new file mode 100644 index 000000000..a7d528c7e --- /dev/null +++ b/src/commands/poll.ts @@ -0,0 +1,73 @@ +import type { CliDeps } from "../cli/deps.js"; +import { callGateway, randomIdempotencyKey } from "../gateway/call.js"; +import { success } from "../globals.js"; +import type { RuntimeEnv } from "../runtime.js"; + +export async function pollCommand( + opts: { + to: string; + question: string; + options: string[]; + selectableCount?: number; + 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"); + } + + 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}`, + ); + return; + } + + const result = await callGateway<{ + messageId: string; + toJid?: string; + }>({ + url: "ws://127.0.0.1:18789", + method: "poll", + params: { + to: opts.to, + question: opts.question, + options: opts.options, + selectableCount: opts.selectableCount ?? 1, + idempotencyKey: randomIdempotencyKey(), + }, + timeoutMs: 10_000, + clientName: "cli", + mode: "cli", + }); + + runtime.log( + success( + `✅ Poll sent via gateway. Message ID: ${result.messageId ?? "unknown"}`, + ), + ); + if (opts.json) { + runtime.log( + JSON.stringify( + { + provider: "whatsapp", + via: "gateway", + to: opts.to, + toJid: result.toJid, + messageId: result.messageId, + question: opts.question, + options: opts.options, + selectableCount: opts.selectableCount ?? 1, + }, + null, + 2, + ), + ); + } +} diff --git a/src/gateway/protocol/index.ts b/src/gateway/protocol/index.ts index 3e39b0628..e2892a84c 100644 --- a/src/gateway/protocol/index.ts +++ b/src/gateway/protocol/index.ts @@ -79,6 +79,8 @@ import { type ResponseFrame, ResponseFrameSchema, SendParamsSchema, + type PollParams, + PollParamsSchema, type SessionsCompactParams, SessionsCompactParamsSchema, type SessionsDeleteParams, @@ -147,6 +149,7 @@ export const validateResponseFrame = ajv.compile(ResponseFrameSchema); export const validateEventFrame = ajv.compile(EventFrameSchema); export const validateSendParams = ajv.compile(SendParamsSchema); +export const validatePollParams = ajv.compile(PollParamsSchema); export const validateAgentParams = ajv.compile(AgentParamsSchema); export const validateAgentWaitParams = ajv.compile( AgentWaitParamsSchema, @@ -282,6 +285,7 @@ export { AgentEventSchema, ChatEventSchema, SendParamsSchema, + PollParamsSchema, AgentParamsSchema, WakeParamsSchema, NodePairRequestParamsSchema, @@ -390,4 +394,5 @@ export type { CronRunParams, CronRunsParams, CronRunLogEntry, + PollParams, }; diff --git a/src/gateway/protocol/schema.ts b/src/gateway/protocol/schema.ts index c4a2b1448..def5d430c 100644 --- a/src/gateway/protocol/schema.ts +++ b/src/gateway/protocol/schema.ts @@ -198,6 +198,17 @@ export const SendParamsSchema = Type.Object( { additionalProperties: false }, ); +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 })), + idempotencyKey: NonEmptyString, + }, + { additionalProperties: false }, +); + export const AgentParamsSchema = Type.Object( { message: NonEmptyString, @@ -831,6 +842,7 @@ export const ProtocolSchemas: Record = { ErrorShape: ErrorShapeSchema, AgentEvent: AgentEventSchema, SendParams: SendParamsSchema, + PollParams: PollParamsSchema, AgentParams: AgentParamsSchema, AgentWaitParams: AgentWaitParamsSchema, WakeParams: WakeParamsSchema, @@ -900,6 +912,7 @@ export type PresenceEntry = Static; export type ErrorShape = Static; export type StateVersion = Static; export type AgentEvent = Static; +export type PollParams = Static; export type AgentWaitParams = Static; export type WakeParams = Static; export type NodePairRequestParams = Static; diff --git a/src/gateway/server-methods/send.ts b/src/gateway/server-methods/send.ts index 07ebf4cdb..39d103e33 100644 --- a/src/gateway/server-methods/send.ts +++ b/src/gateway/server-methods/send.ts @@ -6,11 +6,12 @@ import { sendMessageSignal } from "../../signal/index.js"; import { sendMessageSlack } from "../../slack/send.js"; import { sendMessageTelegram } from "../../telegram/send.js"; import { resolveTelegramToken } from "../../telegram/token.js"; -import { sendMessageWhatsApp } from "../../web/outbound.js"; +import { sendMessageWhatsApp, sendPollWhatsApp } from "../../web/outbound.js"; import { ErrorCodes, errorShape, formatValidationErrors, + validatePollParams, validateSendParams, } from "../protocol/index.js"; import { formatForLog } from "../ws-log.js"; @@ -178,4 +179,69 @@ export const sendHandlers: GatewayRequestHandlers = { }); } }, + + poll: async ({ params, respond, context }) => { + const p = params as Record; + if (!validatePollParams(p)) { + respond( + false, + undefined, + errorShape( + ErrorCodes.INVALID_REQUEST, + `invalid poll params: ${formatValidationErrors(validatePollParams.errors)}`, + ), + ); + return; + } + const request = p as { + to: string; + question: string; + options: string[]; + selectableCount?: number; + idempotencyKey: string; + }; + const idem = request.idempotencyKey; + const cached = context.dedupe.get(`poll:${idem}`); + if (cached) { + respond(cached.ok, cached.payload, cached.error, { + cached: true, + }); + 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 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" }); + } catch (err) { + const error = errorShape(ErrorCodes.UNAVAILABLE, String(err)); + context.dedupe.set(`poll:${idem}`, { + ts: Date.now(), + ok: false, + error, + }); + respond(false, undefined, error, { + provider: "whatsapp", + error: formatForLog(err), + }); + } + }, }; diff --git a/src/web/active-listener.ts b/src/web/active-listener.ts index bdcac6b85..5bad604d5 100644 --- a/src/web/active-listener.ts +++ b/src/web/active-listener.ts @@ -2,6 +2,12 @@ export type ActiveWebSendOptions = { gifPlayback?: boolean; }; +export type PollOptions = { + question: string; + options: string[]; + selectableCount?: number; +}; + export type ActiveWebListener = { sendMessage: ( to: string, @@ -10,6 +16,7 @@ export type ActiveWebListener = { mediaType?: string, options?: ActiveWebSendOptions, ) => Promise<{ messageId: string }>; + sendPoll: (to: string, poll: PollOptions) => Promise<{ messageId: string }>; sendComposingTo: (to: string) => Promise; close?: () => Promise; }; diff --git a/src/web/inbound.ts b/src/web/inbound.ts index 0261291c1..a9ec2d2fd 100644 --- a/src/web/inbound.ts +++ b/src/web/inbound.ts @@ -464,6 +464,24 @@ export async function monitorWebInbox(options: { const jid = toWhatsappJid(to); await sock.sendPresenceUpdate("composing", jid); }, + /** + * Send a poll message through this connection's socket. + * Used by IPC to create WhatsApp polls in groups or chats. + */ + sendPoll: async ( + to: string, + poll: { question: string; options: string[]; selectableCount?: number }, + ): Promise<{ messageId: string }> => { + const jid = toWhatsappJid(to); + const result = await sock.sendMessage(jid, { + poll: { + name: poll.question, + values: poll.options, + selectableCount: poll.selectableCount ?? 1, + }, + }); + return { messageId: result?.key?.id ?? "unknown" }; + }, } as const; } diff --git a/src/web/outbound.ts b/src/web/outbound.ts index a8c3c076b..27bb559a9 100644 --- a/src/web/outbound.ts +++ b/src/web/outbound.ts @@ -4,6 +4,7 @@ import { createSubsystemLogger, getChildLogger } from "../logging.js"; import { toWhatsappJid } from "../utils.js"; import { type ActiveWebSendOptions, + type PollOptions, getActiveWebListener, } from "./active-listener.js"; import { loadWebMedia } from "./media.js"; @@ -85,3 +86,44 @@ export async function sendMessageWhatsApp( throw err; } } + +export async function sendPollWhatsApp( + to: string, + poll: PollOptions, + options: { verbose: boolean }, +): Promise<{ messageId: string; toJid: string }> { + const correlationId = randomUUID(); + const startedAt = Date.now(); + const active = getActiveWebListener(); + if (!active) { + throw new Error( + "No active gateway listener. Start the gateway before sending WhatsApp polls.", + ); + } + const logger = getChildLogger({ + module: "web-outbound", + correlationId, + to, + }); + try { + const jid = toWhatsappJid(to); + outboundLog.info(`Sending poll -> ${jid}: "${poll.question}"`); + logger.info( + { jid, question: poll.question, optionCount: poll.options.length }, + "sending poll", + ); + const result = await active.sendPoll(to, poll); + const messageId = + (result as { messageId?: string })?.messageId ?? "unknown"; + const durationMs = Date.now() - startedAt; + outboundLog.info(`Sent poll ${messageId} -> ${jid} (${durationMs}ms)`); + logger.info({ jid, messageId }, "sent poll"); + return { messageId, toJid: jid }; + } catch (err) { + logger.error( + { err: String(err), to, question: poll.question }, + "failed to send poll via web session", + ); + throw err; + } +}