167 lines
5.4 KiB
TypeScript
167 lines
5.4 KiB
TypeScript
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, requireActiveWebListener } from "./active-listener.js";
|
|
import { loadWebMedia } from "./media.js";
|
|
|
|
const outboundLog = createSubsystemLogger("gateway/channels/whatsapp").child("outbound");
|
|
|
|
export async function sendMessageWhatsApp(
|
|
to: string,
|
|
body: string,
|
|
options: {
|
|
verbose: boolean;
|
|
mediaUrl?: string;
|
|
gifPlayback?: boolean;
|
|
accountId?: string;
|
|
},
|
|
): Promise<{ messageId: string; toJid: string }> {
|
|
let text = body;
|
|
const correlationId = randomUUID();
|
|
const startedAt = Date.now();
|
|
const { listener: active, accountId: resolvedAccountId } = requireActiveWebListener(
|
|
options.accountId,
|
|
);
|
|
const logger = getChildLogger({
|
|
module: "web-outbound",
|
|
correlationId,
|
|
to,
|
|
});
|
|
try {
|
|
const jid = toWhatsappJid(to);
|
|
let mediaBuffer: Buffer | undefined;
|
|
let mediaType: string | undefined;
|
|
if (options.mediaUrl) {
|
|
const media = await loadWebMedia(options.mediaUrl);
|
|
const caption = text || undefined;
|
|
mediaBuffer = media.buffer;
|
|
mediaType = media.contentType;
|
|
if (media.kind === "audio") {
|
|
// WhatsApp expects explicit opus codec for PTT voice notes.
|
|
mediaType =
|
|
media.contentType === "audio/ogg"
|
|
? "audio/ogg; codecs=opus"
|
|
: (media.contentType ?? "application/octet-stream");
|
|
} else if (media.kind === "video") {
|
|
text = caption ?? "";
|
|
} else if (media.kind === "image") {
|
|
text = caption ?? "";
|
|
} else {
|
|
text = caption ?? "";
|
|
}
|
|
}
|
|
outboundLog.info(`Sending message -> ${jid}${options.mediaUrl ? " (media)" : ""}`);
|
|
logger.info({ jid, hasMedia: Boolean(options.mediaUrl) }, "sending message");
|
|
await active.sendComposingTo(to);
|
|
const hasExplicitAccountId = Boolean(options.accountId?.trim());
|
|
const accountId = hasExplicitAccountId ? resolvedAccountId : undefined;
|
|
const sendOptions: ActiveWebSendOptions | undefined =
|
|
options.gifPlayback || accountId
|
|
? {
|
|
...(options.gifPlayback ? { gifPlayback: true } : {}),
|
|
accountId,
|
|
}
|
|
: undefined;
|
|
const result = sendOptions
|
|
? await active.sendMessage(to, text, mediaBuffer, mediaType, sendOptions)
|
|
: await active.sendMessage(to, text, mediaBuffer, mediaType);
|
|
const messageId = (result as { messageId?: string })?.messageId ?? "unknown";
|
|
const durationMs = Date.now() - startedAt;
|
|
outboundLog.info(
|
|
`Sent message ${messageId} -> ${jid}${options.mediaUrl ? " (media)" : ""} (${durationMs}ms)`,
|
|
);
|
|
logger.info({ jid, messageId }, "sent message");
|
|
return { messageId, toJid: jid };
|
|
} catch (err) {
|
|
logger.error(
|
|
{ err: String(err), to, hasMedia: Boolean(options.mediaUrl) },
|
|
"failed to send via web session",
|
|
);
|
|
throw err;
|
|
}
|
|
}
|
|
|
|
export async function sendReactionWhatsApp(
|
|
chatJid: string,
|
|
messageId: string,
|
|
emoji: string,
|
|
options: {
|
|
verbose: boolean;
|
|
fromMe?: boolean;
|
|
participant?: string;
|
|
accountId?: string;
|
|
},
|
|
): Promise<void> {
|
|
const correlationId = randomUUID();
|
|
const { listener: active } = requireActiveWebListener(options.accountId);
|
|
const logger = getChildLogger({
|
|
module: "web-outbound",
|
|
correlationId,
|
|
chatJid,
|
|
messageId,
|
|
});
|
|
try {
|
|
const jid = toWhatsappJid(chatJid);
|
|
outboundLog.info(`Sending reaction "${emoji}" -> message ${messageId}`);
|
|
logger.info({ chatJid: jid, messageId, emoji }, "sending reaction");
|
|
await active.sendReaction(
|
|
chatJid,
|
|
messageId,
|
|
emoji,
|
|
options.fromMe ?? false,
|
|
options.participant,
|
|
);
|
|
outboundLog.info(`Sent reaction "${emoji}" -> message ${messageId}`);
|
|
logger.info({ chatJid: jid, messageId, emoji }, "sent reaction");
|
|
} catch (err) {
|
|
logger.error(
|
|
{ err: String(err), chatJid, messageId, emoji },
|
|
"failed to send reaction via web session",
|
|
);
|
|
throw err;
|
|
}
|
|
}
|
|
|
|
export async function sendPollWhatsApp(
|
|
to: string,
|
|
poll: PollInput,
|
|
options: { verbose: boolean; accountId?: string },
|
|
): Promise<{ messageId: string; toJid: string }> {
|
|
const correlationId = randomUUID();
|
|
const startedAt = Date.now();
|
|
const { listener: active } = requireActiveWebListener(options.accountId);
|
|
const logger = getChildLogger({
|
|
module: "web-outbound",
|
|
correlationId,
|
|
to,
|
|
});
|
|
try {
|
|
const jid = toWhatsappJid(to);
|
|
const normalized = normalizePollInput(poll, { maxOptions: 12 });
|
|
outboundLog.info(`Sending poll -> ${jid}: "${normalized.question}"`);
|
|
logger.info(
|
|
{
|
|
jid,
|
|
question: normalized.question,
|
|
optionCount: normalized.options.length,
|
|
maxSelections: normalized.maxSelections,
|
|
},
|
|
"sending poll",
|
|
);
|
|
const result = await active.sendPoll(to, normalized);
|
|
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;
|
|
}
|
|
}
|