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 <jid> -q <question> -o <opt1> -o <opt2> [-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
This commit is contained in:
@@ -5,6 +5,7 @@ import { configureCommand } from "../commands/configure.js";
|
|||||||
import { doctorCommand } from "../commands/doctor.js";
|
import { doctorCommand } from "../commands/doctor.js";
|
||||||
import { healthCommand } from "../commands/health.js";
|
import { healthCommand } from "../commands/health.js";
|
||||||
import { onboardCommand } from "../commands/onboard.js";
|
import { onboardCommand } from "../commands/onboard.js";
|
||||||
|
import { pollCommand } from "../commands/poll.js";
|
||||||
import { sendCommand } from "../commands/send.js";
|
import { sendCommand } from "../commands/send.js";
|
||||||
import { sessionsCommand } from "../commands/sessions.js";
|
import { sessionsCommand } from "../commands/sessions.js";
|
||||||
import { setupCommand } from "../commands/setup.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 <jid>",
|
||||||
|
"Recipient JID (e.g. +15555550123 or group JID)",
|
||||||
|
)
|
||||||
|
.requiredOption("-q, --question <text>", "Poll question")
|
||||||
|
.requiredOption(
|
||||||
|
"-o, --option <choice>",
|
||||||
|
"Poll option (use multiple times, 2-12 required)",
|
||||||
|
(value: string, previous: string[]) => previous.concat([value]),
|
||||||
|
[] as string[],
|
||||||
|
)
|
||||||
|
.option(
|
||||||
|
"-s, --selectable-count <n>",
|
||||||
|
"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
|
program
|
||||||
.command("agent")
|
.command("agent")
|
||||||
.description("Run an agent turn via the Gateway (use --local for embedded)")
|
.description("Run an agent turn via the Gateway (use --local for embedded)")
|
||||||
|
|||||||
73
src/commands/poll.ts
Normal file
73
src/commands/poll.ts
Normal file
@@ -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,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -79,6 +79,8 @@ import {
|
|||||||
type ResponseFrame,
|
type ResponseFrame,
|
||||||
ResponseFrameSchema,
|
ResponseFrameSchema,
|
||||||
SendParamsSchema,
|
SendParamsSchema,
|
||||||
|
type PollParams,
|
||||||
|
PollParamsSchema,
|
||||||
type SessionsCompactParams,
|
type SessionsCompactParams,
|
||||||
SessionsCompactParamsSchema,
|
SessionsCompactParamsSchema,
|
||||||
type SessionsDeleteParams,
|
type SessionsDeleteParams,
|
||||||
@@ -147,6 +149,7 @@ export const validateResponseFrame =
|
|||||||
ajv.compile<ResponseFrame>(ResponseFrameSchema);
|
ajv.compile<ResponseFrame>(ResponseFrameSchema);
|
||||||
export const validateEventFrame = ajv.compile<EventFrame>(EventFrameSchema);
|
export const validateEventFrame = ajv.compile<EventFrame>(EventFrameSchema);
|
||||||
export const validateSendParams = ajv.compile(SendParamsSchema);
|
export const validateSendParams = ajv.compile(SendParamsSchema);
|
||||||
|
export const validatePollParams = ajv.compile<PollParams>(PollParamsSchema);
|
||||||
export const validateAgentParams = ajv.compile(AgentParamsSchema);
|
export const validateAgentParams = ajv.compile(AgentParamsSchema);
|
||||||
export const validateAgentWaitParams = ajv.compile<AgentWaitParams>(
|
export const validateAgentWaitParams = ajv.compile<AgentWaitParams>(
|
||||||
AgentWaitParamsSchema,
|
AgentWaitParamsSchema,
|
||||||
@@ -282,6 +285,7 @@ export {
|
|||||||
AgentEventSchema,
|
AgentEventSchema,
|
||||||
ChatEventSchema,
|
ChatEventSchema,
|
||||||
SendParamsSchema,
|
SendParamsSchema,
|
||||||
|
PollParamsSchema,
|
||||||
AgentParamsSchema,
|
AgentParamsSchema,
|
||||||
WakeParamsSchema,
|
WakeParamsSchema,
|
||||||
NodePairRequestParamsSchema,
|
NodePairRequestParamsSchema,
|
||||||
@@ -390,4 +394,5 @@ export type {
|
|||||||
CronRunParams,
|
CronRunParams,
|
||||||
CronRunsParams,
|
CronRunsParams,
|
||||||
CronRunLogEntry,
|
CronRunLogEntry,
|
||||||
|
PollParams,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -198,6 +198,17 @@ export const SendParamsSchema = Type.Object(
|
|||||||
{ additionalProperties: false },
|
{ 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(
|
export const AgentParamsSchema = Type.Object(
|
||||||
{
|
{
|
||||||
message: NonEmptyString,
|
message: NonEmptyString,
|
||||||
@@ -831,6 +842,7 @@ export const ProtocolSchemas: Record<string, TSchema> = {
|
|||||||
ErrorShape: ErrorShapeSchema,
|
ErrorShape: ErrorShapeSchema,
|
||||||
AgentEvent: AgentEventSchema,
|
AgentEvent: AgentEventSchema,
|
||||||
SendParams: SendParamsSchema,
|
SendParams: SendParamsSchema,
|
||||||
|
PollParams: PollParamsSchema,
|
||||||
AgentParams: AgentParamsSchema,
|
AgentParams: AgentParamsSchema,
|
||||||
AgentWaitParams: AgentWaitParamsSchema,
|
AgentWaitParams: AgentWaitParamsSchema,
|
||||||
WakeParams: WakeParamsSchema,
|
WakeParams: WakeParamsSchema,
|
||||||
@@ -900,6 +912,7 @@ export type PresenceEntry = Static<typeof PresenceEntrySchema>;
|
|||||||
export type ErrorShape = Static<typeof ErrorShapeSchema>;
|
export type ErrorShape = Static<typeof ErrorShapeSchema>;
|
||||||
export type StateVersion = Static<typeof StateVersionSchema>;
|
export type StateVersion = Static<typeof StateVersionSchema>;
|
||||||
export type AgentEvent = Static<typeof AgentEventSchema>;
|
export type AgentEvent = Static<typeof AgentEventSchema>;
|
||||||
|
export type PollParams = Static<typeof PollParamsSchema>;
|
||||||
export type AgentWaitParams = Static<typeof AgentWaitParamsSchema>;
|
export type AgentWaitParams = Static<typeof AgentWaitParamsSchema>;
|
||||||
export type WakeParams = Static<typeof WakeParamsSchema>;
|
export type WakeParams = Static<typeof WakeParamsSchema>;
|
||||||
export type NodePairRequestParams = Static<typeof NodePairRequestParamsSchema>;
|
export type NodePairRequestParams = Static<typeof NodePairRequestParamsSchema>;
|
||||||
|
|||||||
@@ -6,11 +6,12 @@ import { sendMessageSignal } from "../../signal/index.js";
|
|||||||
import { sendMessageSlack } from "../../slack/send.js";
|
import { sendMessageSlack } from "../../slack/send.js";
|
||||||
import { sendMessageTelegram } from "../../telegram/send.js";
|
import { sendMessageTelegram } from "../../telegram/send.js";
|
||||||
import { resolveTelegramToken } from "../../telegram/token.js";
|
import { resolveTelegramToken } from "../../telegram/token.js";
|
||||||
import { sendMessageWhatsApp } from "../../web/outbound.js";
|
import { sendMessageWhatsApp, sendPollWhatsApp } from "../../web/outbound.js";
|
||||||
import {
|
import {
|
||||||
ErrorCodes,
|
ErrorCodes,
|
||||||
errorShape,
|
errorShape,
|
||||||
formatValidationErrors,
|
formatValidationErrors,
|
||||||
|
validatePollParams,
|
||||||
validateSendParams,
|
validateSendParams,
|
||||||
} from "../protocol/index.js";
|
} from "../protocol/index.js";
|
||||||
import { formatForLog } from "../ws-log.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<string, unknown>;
|
||||||
|
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),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -2,6 +2,12 @@ export type ActiveWebSendOptions = {
|
|||||||
gifPlayback?: boolean;
|
gifPlayback?: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type PollOptions = {
|
||||||
|
question: string;
|
||||||
|
options: string[];
|
||||||
|
selectableCount?: number;
|
||||||
|
};
|
||||||
|
|
||||||
export type ActiveWebListener = {
|
export type ActiveWebListener = {
|
||||||
sendMessage: (
|
sendMessage: (
|
||||||
to: string,
|
to: string,
|
||||||
@@ -10,6 +16,7 @@ export type ActiveWebListener = {
|
|||||||
mediaType?: string,
|
mediaType?: string,
|
||||||
options?: ActiveWebSendOptions,
|
options?: ActiveWebSendOptions,
|
||||||
) => Promise<{ messageId: string }>;
|
) => Promise<{ messageId: string }>;
|
||||||
|
sendPoll: (to: string, poll: PollOptions) => Promise<{ messageId: string }>;
|
||||||
sendComposingTo: (to: string) => Promise<void>;
|
sendComposingTo: (to: string) => Promise<void>;
|
||||||
close?: () => Promise<void>;
|
close?: () => Promise<void>;
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -464,6 +464,24 @@ export async function monitorWebInbox(options: {
|
|||||||
const jid = toWhatsappJid(to);
|
const jid = toWhatsappJid(to);
|
||||||
await sock.sendPresenceUpdate("composing", jid);
|
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;
|
} as const;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { createSubsystemLogger, getChildLogger } from "../logging.js";
|
|||||||
import { toWhatsappJid } from "../utils.js";
|
import { toWhatsappJid } from "../utils.js";
|
||||||
import {
|
import {
|
||||||
type ActiveWebSendOptions,
|
type ActiveWebSendOptions,
|
||||||
|
type PollOptions,
|
||||||
getActiveWebListener,
|
getActiveWebListener,
|
||||||
} from "./active-listener.js";
|
} from "./active-listener.js";
|
||||||
import { loadWebMedia } from "./media.js";
|
import { loadWebMedia } from "./media.js";
|
||||||
@@ -85,3 +86,44 @@ export async function sendMessageWhatsApp(
|
|||||||
throw err;
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user