feat: unify poll support
Co-authored-by: DBH <5251425+dbhurley@users.noreply.github.com>
This commit is contained in:
@@ -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 });
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -1,2 +1,2 @@
|
||||
export { monitorDiscordProvider } from "./monitor.js";
|
||||
export { sendMessageDiscord } from "./send.js";
|
||||
export { sendMessageDiscord, sendPollDiscord } from "./send.js";
|
||||
|
||||
@@ -596,7 +596,7 @@ describe("sendPollDiscord", () => {
|
||||
"channel:789",
|
||||
{
|
||||
question: "Lunch?",
|
||||
answers: ["Pizza", "Sushi"],
|
||||
options: ["Pizza", "Sushi"],
|
||||
},
|
||||
{
|
||||
rest,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
41
src/polls.test.ts
Normal 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
71
src/polls.ts
Normal 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);
|
||||
}
|
||||
@@ -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>;
|
||||
};
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user