diff --git a/apps/bot/src/whatsapp/sender.ts b/apps/bot/src/whatsapp/sender.ts index 413527a..0356c4b 100644 --- a/apps/bot/src/whatsapp/sender.ts +++ b/apps/bot/src/whatsapp/sender.ts @@ -1,11 +1,9 @@ -import type { WASocket } from "@whiskeysockets/baileys"; +import { readFile, stat } from "node:fs/promises"; +import type { WASocket, AnyMessageContent } from "@whiskeysockets/baileys"; import pino from "pino"; const logger = pino({ name: "sender" }); -// Internal Baileys method used to fetch pre-key bundles and establish individual -// libsignal sessions for a list of JIDs. Not part of the public type, but it's -// the only way to avoid "No sessions" on the first group send after pairing. type SocketWithAssertSessions = WASocket & { assertSessions?: (jids: string[], force: boolean) => Promise; }; @@ -14,19 +12,10 @@ const CHUNK_SIZE = 5; async function chunked(items: T[], size: number): Promise { const out: T[][] = []; - for (let i = 0; i < items.length; i += size) { - out.push(items.slice(i, i + size)); - } + for (let i = 0; i < items.length; i += size) out.push(items.slice(i, i + size)); return out; } -/** - * Establish per-participant libsignal sessions in small chunks. WhatsApp's - * pre-key endpoint returns 406 "not-acceptable" if any single JID in the - * batch is in a broken state (deleted account, deactivated, etc.) — so we - * chunk the work and tolerate per-chunk failures rather than letting one - * bad participant poison the whole send. - */ async function ensureSessionsForGroup( socket: WASocket, groupJid: string, @@ -39,44 +28,73 @@ async function ensureSessionsForGroup( } let ok = 0; let failed = 0; - const chunks = await chunked(participantJids, CHUNK_SIZE); - for (const chunk of chunks) { + for (const chunk of await chunked(participantJids, CHUNK_SIZE)) { try { await internal.assertSessions(chunk, true); ok += chunk.length; } catch (err) { failed += chunk.length; - logger.warn( - { groupJid, chunkSize: chunk.length, err: (err as Error).message }, - "assertSessions chunk failed; continuing", - ); + logger.warn({ groupJid, err: (err as Error).message }, "assertSessions chunk failed"); } } - logger.info( - { groupJid, ok, failed, total: participantJids.length }, - "ensureSessionsForGroup: done", - ); return { ok, failed, total: participantJids.length }; } +async function sendWithRetry( + socket: WASocket, + groupJid: string, + content: AnyMessageContent, +): Promise<{ messageId: string | undefined }> { + await ensureSessionsForGroup(socket, groupJid); + try { + const result = await socket.sendMessage(groupJid, content); + return { messageId: result?.key?.id ?? undefined }; + } catch (err) { + const message = (err as Error)?.message ?? ""; + if (message.includes("No sessions")) { + await new Promise((r) => setTimeout(r, 2000)); + await ensureSessionsForGroup(socket, groupJid); + const result = await socket.sendMessage(groupJid, content); + return { messageId: result?.key?.id ?? undefined }; + } + throw err; + } +} + export async function sendTextToGroup( socket: WASocket, groupJid: string, text: string, ): Promise<{ messageId: string | undefined }> { - await ensureSessionsForGroup(socket, groupJid); - - try { - const result = await socket.sendMessage(groupJid, { text }); - return { messageId: result?.key?.id ?? undefined }; - } catch (err) { - const message = (err as Error)?.message ?? ""; - if (message.includes("No sessions")) { - await new Promise((resolve) => setTimeout(resolve, 2000)); - await ensureSessionsForGroup(socket, groupJid); - const result = await socket.sendMessage(groupJid, { text }); - return { messageId: result?.key?.id ?? undefined }; - } - throw err; - } + return sendWithRetry(socket, groupJid, { text }); +} + +export type MediaKind = "image" | "video" | "document"; + +export async function sendMediaToGroup( + socket: WASocket, + groupJid: string, + kind: MediaKind, + filePath: string, + options: { caption?: string; mimeType?: string; filename?: string } = {}, +): Promise<{ messageId: string | undefined }> { + // Validate the file exists and read into a buffer. For very large files + // (>50MB) Baileys also accepts a stream, but for our reminder use case + // files are typically <30MB which fits comfortably in memory. + await stat(filePath); + const buffer = await readFile(filePath); + + const content: AnyMessageContent = + kind === "image" + ? { image: buffer, caption: options.caption, mimetype: options.mimeType } + : kind === "video" + ? { video: buffer, caption: options.caption, mimetype: options.mimeType } + : { + document: buffer, + caption: options.caption, + fileName: options.filename ?? "file", + mimetype: options.mimeType ?? "application/octet-stream", + }; + + return sendWithRetry(socket, groupJid, content); }