From fd423fe90c97bb2de92f18078909dd997fa557e2 Mon Sep 17 00:00:00 2001 From: Clawd Date: Thu, 29 Jan 2026 12:28:57 -0600 Subject: [PATCH] feat(signal): add poll support Wire up signal-cli's sendPollCreate to the message tool, enabling poll creation in Signal chats and groups. - Add sendPollSignal() function in src/signal/send.ts - Add sendPoll method to signal outbound adapter - Support 2-12 options, single or multi-select Closes #TBD --- src/channels/plugins/outbound/signal.ts | 10 +++- src/signal/send.ts | 66 +++++++++++++++++++++++++ 2 files changed, 75 insertions(+), 1 deletion(-) diff --git a/src/channels/plugins/outbound/signal.ts b/src/channels/plugins/outbound/signal.ts index c2f0710cf..1a085e093 100644 --- a/src/channels/plugins/outbound/signal.ts +++ b/src/channels/plugins/outbound/signal.ts @@ -1,5 +1,5 @@ import { chunkText } from "../../../auto-reply/chunk.js"; -import { sendMessageSignal } from "../../../signal/send.js"; +import { sendMessageSignal, sendPollSignal } from "../../../signal/send.js"; import { resolveChannelMediaMaxBytes } from "../media-limits.js"; import type { ChannelOutboundAdapter } from "../types.js"; @@ -8,6 +8,7 @@ export const signalOutbound: ChannelOutboundAdapter = { chunker: chunkText, chunkerMode: "text", textChunkLimit: 4000, + pollMaxOptions: 12, sendText: async ({ cfg, to, text, accountId, deps }) => { const send = deps?.sendSignal ?? sendMessageSignal; const maxBytes = resolveChannelMediaMaxBytes({ @@ -37,4 +38,11 @@ export const signalOutbound: ChannelOutboundAdapter = { }); return { channel: "signal", ...result }; }, + sendPoll: async ({ to, poll, accountId }) => { + const result = await sendPollSignal(to, poll.question, poll.options, { + multiSelect: (poll.maxSelections ?? 1) > 1, + accountId: accountId ?? undefined, + }); + return { messageId: result.messageId }; + }, }; diff --git a/src/signal/send.ts b/src/signal/send.ts index 32ca09094..ee3ac23f0 100644 --- a/src/signal/send.ts +++ b/src/signal/send.ts @@ -255,3 +255,69 @@ export async function sendReadReceiptSignal( }); return true; } + +export type SignalPollOpts = SignalRpcOpts & { + /** Allow multiple selections (default: true) */ + multiSelect?: boolean; +}; + +export type SignalPollResult = { + messageId: string; + timestamp?: number; +}; + +/** + * Create and send a poll to a Signal recipient or group. + */ +export async function sendPollSignal( + to: string, + question: string, + options: string[], + opts: SignalPollOpts = {}, +): Promise { + if (!question?.trim()) { + throw new Error("Poll question is required"); + } + if (!options || options.length < 2) { + throw new Error("Poll requires at least 2 options"); + } + if (options.length > 12) { + throw new Error("Poll cannot have more than 12 options"); + } + + const { baseUrl, account } = resolveSignalRpcContext(opts); + const target = parseTarget(to); + const targetParams = buildTargetParams(target, { + recipient: true, + group: true, + username: true, + }); + if (!targetParams) { + throw new Error("Signal poll recipient is required"); + } + + const params: Record = { + question: question.trim(), + option: options.map((o) => o.trim()).filter(Boolean), + ...targetParams, + }; + + if (account) params.account = account; + + // signal-cli uses --no-multi to disable multi-select, so we invert the logic + // Default is multi-select allowed (true), pass multiSelect: false to restrict to single choice + if (opts.multiSelect === false) { + params.multiSelect = false; + } + + const result = await signalRpcRequest<{ timestamp?: number }>("sendPollCreate", params, { + baseUrl, + timeoutMs: opts.timeoutMs ?? 15_000, // polls may take longer + }); + + const timestamp = result?.timestamp; + return { + messageId: timestamp ? String(timestamp) : "unknown", + timestamp, + }; +}