talon-agent 3.24.1 → 3.25.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (36) hide show
  1. package/README.md +123 -25
  2. package/package.json +4 -1
  3. package/prompts/whatsapp.md +58 -0
  4. package/src/backend/shared/delivery-contract.ts +1 -0
  5. package/src/core/doctor.ts +5 -0
  6. package/src/core/frontend-runtime/builtins.ts +8 -0
  7. package/src/core/mcp-hub/talon-server.ts +1 -0
  8. package/src/core/prompt/embedded-prompts.ts +2 -0
  9. package/src/core/tools/chat.ts +4 -4
  10. package/src/core/tools/history.ts +5 -5
  11. package/src/core/tools/media.ts +1 -1
  12. package/src/core/tools/members.ts +4 -4
  13. package/src/core/tools/messaging.ts +12 -11
  14. package/src/core/tools/moderation.ts +2 -2
  15. package/src/core/tools/scheduling.ts +2 -2
  16. package/src/core/tools/types.ts +1 -1
  17. package/src/frontend/factories.ts +1 -0
  18. package/src/frontend/whatsapp/actions/chat-info.ts +228 -0
  19. package/src/frontend/whatsapp/actions/index.ts +78 -0
  20. package/src/frontend/whatsapp/actions/media.ts +248 -0
  21. package/src/frontend/whatsapp/actions/messaging.ts +281 -0
  22. package/src/frontend/whatsapp/actions/moderation.ts +231 -0
  23. package/src/frontend/whatsapp/actions/shared.ts +196 -0
  24. package/src/frontend/whatsapp/actions/types.ts +34 -0
  25. package/src/frontend/whatsapp/factory.ts +12 -0
  26. package/src/frontend/whatsapp/formatting.ts +172 -0
  27. package/src/frontend/whatsapp/identity.ts +150 -0
  28. package/src/frontend/whatsapp/index.ts +553 -0
  29. package/src/frontend/whatsapp/media-store.ts +97 -0
  30. package/src/frontend/whatsapp/message-store.ts +140 -0
  31. package/src/frontend/whatsapp/pins.ts +52 -0
  32. package/src/frontend/whatsapp/registry.ts +92 -0
  33. package/src/util/chat-id.ts +5 -0
  34. package/src/util/config.ts +43 -0
  35. package/src/util/log.ts +1 -0
  36. package/src/util/paths.ts +2 -0
@@ -0,0 +1,231 @@
1
+ /**
2
+ * Group administration — the `moderate` tool's op switch.
3
+ *
4
+ * WhatsApp's admin model is narrower than Telegram's: there is no ban
5
+ * list (removal is the only eviction), no per-member mute (a group is
6
+ * either open or admins-only), and no forum topics. Ops that have a
7
+ * faithful WhatsApp equivalent are mapped to it; the rest report what
8
+ * WhatsApp actually offers instead, so the model can pick another route
9
+ * rather than retrying something the platform cannot do.
10
+ */
11
+
12
+ import { toUserJid, tryAction } from "./shared.js";
13
+ import type { WhatsAppActionHandlers } from "./types.js";
14
+
15
+ /** Ops that act on one member. */
16
+ const PARTICIPANT_OPS: Record<string, "remove" | "add" | "promote" | "demote"> =
17
+ {
18
+ ban: "remove",
19
+ kick: "remove",
20
+ remove: "remove",
21
+ unban: "add",
22
+ add: "add",
23
+ promote: "promote",
24
+ demote: "demote",
25
+ };
26
+
27
+ export const moderationHandlers: WhatsAppActionHandlers = {
28
+ moderate: (body, _chatId, ctx) =>
29
+ tryAction("moderate", async () => {
30
+ const op = String(body.op ?? "");
31
+ const chat = ctx.chat!;
32
+ const { sock } = ctx;
33
+
34
+ const participantOp = PARTICIPANT_OPS[op];
35
+ if (participantOp) {
36
+ if (!chat.isGroup) {
37
+ return { ok: false, error: `moderate(${op}) only applies to groups` };
38
+ }
39
+ const jid = toUserJid(body.user_id);
40
+ if (!jid) {
41
+ return { ok: false, error: `moderate(${op}) requires user_id` };
42
+ }
43
+ const [result] = await sock.groupParticipantsUpdate(
44
+ chat.jid,
45
+ [jid],
46
+ participantOp,
47
+ );
48
+ // Baileys reports per-participant status codes rather than throwing:
49
+ // 403 is "privacy settings blocked the add", 408 "not on WhatsApp".
50
+ const status = String(result?.status ?? "200");
51
+ if (status !== "200") {
52
+ return {
53
+ ok: false,
54
+ error: `WhatsApp refused ${op} for ${jid} (status ${status})`,
55
+ };
56
+ }
57
+ return {
58
+ ok: true,
59
+ text:
60
+ op === "ban"
61
+ ? `Removed ${jid} — WhatsApp has no ban list, so they can rejoin via an invite.`
62
+ : `${op} applied to ${jid}`,
63
+ };
64
+ }
65
+
66
+ switch (op) {
67
+ case "mute":
68
+ case "set_permissions": {
69
+ if (!chat.isGroup) {
70
+ return {
71
+ ok: false,
72
+ error: `moderate(${op}) only applies to groups`,
73
+ };
74
+ }
75
+ if (body.user_id) {
76
+ return {
77
+ ok: false,
78
+ error:
79
+ "WhatsApp has no per-member mute — mute the whole group " +
80
+ "(admins-only mode) or remove the member.",
81
+ };
82
+ }
83
+ // Announcement mode is WhatsApp's whole-group mute.
84
+ const permissions = (body.permissions ?? {}) as {
85
+ send_messages?: boolean;
86
+ };
87
+ const open =
88
+ op === "mute" ? false : permissions.send_messages !== false;
89
+ await sock.groupSettingUpdate(
90
+ chat.jid,
91
+ open ? "not_announcement" : "announcement",
92
+ );
93
+ return {
94
+ ok: true,
95
+ text: open
96
+ ? "All members can send messages."
97
+ : "Only admins can send messages.",
98
+ };
99
+ }
100
+
101
+ case "unmute": {
102
+ if (!chat.isGroup) {
103
+ return {
104
+ ok: false,
105
+ error: "moderate(unmute) only applies to groups",
106
+ };
107
+ }
108
+ await sock.groupSettingUpdate(chat.jid, "not_announcement");
109
+ return { ok: true, text: "All members can send messages." };
110
+ }
111
+
112
+ case "create_invite_link": {
113
+ if (!chat.isGroup) {
114
+ return { ok: false, error: "Only groups have invite links" };
115
+ }
116
+ const code = await sock.groupInviteCode(chat.jid);
117
+ return { ok: true, text: `https://chat.whatsapp.com/${code}` };
118
+ }
119
+
120
+ case "revoke_invite_link": {
121
+ if (!chat.isGroup) {
122
+ return { ok: false, error: "Only groups have invite links" };
123
+ }
124
+ const code = await sock.groupRevokeInvite(chat.jid);
125
+ return {
126
+ ok: true,
127
+ text: `Old link revoked. New link: https://chat.whatsapp.com/${code}`,
128
+ };
129
+ }
130
+
131
+ case "list_join_requests": {
132
+ if (!chat.isGroup) {
133
+ return { ok: false, error: "Only groups have join requests" };
134
+ }
135
+ const requests = await sock.groupRequestParticipantsList(chat.jid);
136
+ return {
137
+ ok: true,
138
+ text: requests.length
139
+ ? requests
140
+ .map(
141
+ (r) =>
142
+ `${String(r.jid).split("@")[0]} (${r.request_method ?? "?"})`,
143
+ )
144
+ .join("\n")
145
+ : "No pending join requests.",
146
+ };
147
+ }
148
+
149
+ case "approve_join_request":
150
+ case "decline_join_request": {
151
+ if (!chat.isGroup) {
152
+ return { ok: false, error: "Only groups have join requests" };
153
+ }
154
+ const jid = toUserJid(body.user_id);
155
+ if (!jid) {
156
+ return { ok: false, error: `moderate(${op}) requires user_id` };
157
+ }
158
+ await sock.groupRequestParticipantsUpdate(
159
+ chat.jid,
160
+ [jid],
161
+ op === "approve_join_request" ? "approve" : "reject",
162
+ );
163
+ return { ok: true };
164
+ }
165
+
166
+ case "set_chat_photo": {
167
+ const source = body.file_path ?? body.url;
168
+ if (!source) {
169
+ return {
170
+ ok: false,
171
+ error: "moderate(set_chat_photo) requires file_path or url",
172
+ };
173
+ }
174
+ await sock.updateProfilePicture(chat.jid, { url: String(source) });
175
+ return { ok: true };
176
+ }
177
+
178
+ case "delete_chat_photo":
179
+ await sock.removeProfilePicture(chat.jid);
180
+ return { ok: true };
181
+
182
+ case "leave_chat": {
183
+ if (!chat.isGroup) {
184
+ return {
185
+ ok: false,
186
+ error:
187
+ "Cannot leave a direct message — block the contact instead",
188
+ };
189
+ }
190
+ await sock.groupLeave(chat.jid);
191
+ return { ok: true };
192
+ }
193
+
194
+ case "unpin_all": {
195
+ // WhatsApp unpins individually; clear the ones Talon placed.
196
+ const { listPins } = await import("../pins.js");
197
+ const pins = listPins(chat.chatId);
198
+ return {
199
+ ok: true,
200
+ text:
201
+ pins.length === 0
202
+ ? "No Talon-placed pins to clear."
203
+ : `WhatsApp unpins one message at a time — call unpin_message for: ${pins
204
+ .map((p) => p.msgId)
205
+ .join(", ")}`,
206
+ };
207
+ }
208
+
209
+ case "set_admin_title":
210
+ return {
211
+ ok: false,
212
+ error: "WhatsApp has no custom admin titles",
213
+ };
214
+
215
+ case "create_topic":
216
+ case "edit_topic":
217
+ case "close_topic":
218
+ case "reopen_topic":
219
+ case "delete_topic":
220
+ return {
221
+ ok: false,
222
+ error:
223
+ "WhatsApp groups have no topics — use separate groups, or a " +
224
+ "community with sub-groups (not manageable from this account).",
225
+ };
226
+
227
+ default:
228
+ return { ok: false, error: `Unknown moderate op: ${op}` };
229
+ }
230
+ }),
231
+ };
@@ -0,0 +1,196 @@
1
+ /**
2
+ * Shared helpers for the WhatsApp action handlers: uniform error
3
+ * capture, media-source resolution, quoted-reply resolution, and the
4
+ * JID coercion the member-facing actions need.
5
+ */
6
+
7
+ import { existsSync, statSync } from "node:fs";
8
+ import { basename, extname } from "node:path";
9
+ import type { AnyMessageContent, WAMessage, WASocket } from "baileys";
10
+ import { expandFsPath } from "../../../util/fs-path.js";
11
+ import { log, logError } from "../../../util/log.js";
12
+ import type { ActionResult } from "../../../core/types.js";
13
+ import { toWhatsAppChunks } from "../formatting.js";
14
+ import { lookupMessage, rememberMessage } from "../message-store.js";
15
+ import type { WhatsAppChatInfo } from "../registry.js";
16
+
17
+ /** WhatsApp's own ceiling for a media upload. */
18
+ const MAX_MEDIA_BYTES = 64 * 1024 * 1024;
19
+
20
+ /**
21
+ * Run an action, converting a throw into a structured failure. WhatsApp
22
+ * errors arrive as Boom objects whose message is the useful part; the
23
+ * model gets that text so it can adapt rather than retry blindly.
24
+ */
25
+ export async function tryAction(
26
+ label: string,
27
+ fn: () => Promise<ActionResult>,
28
+ ): Promise<ActionResult> {
29
+ try {
30
+ return await fn();
31
+ } catch (err) {
32
+ const msg = err instanceof Error ? err.message : String(err);
33
+ logError("whatsapp", `${label} failed: ${msg}`);
34
+ return { ok: false, error: `${label}: ${msg}` };
35
+ }
36
+ }
37
+
38
+ /** Baileys accepts a Buffer, a stream, or `{ url }` for local paths and HTTP. */
39
+ export type MediaUpload = { url: string };
40
+
41
+ /**
42
+ * Resolve one media input to something Baileys can upload. Two sources:
43
+ * a public URL (WhatsApp's uploader fetches it) or a workspace file path
44
+ * (streamed from disk). `file_id` is a Telegram concept with no WhatsApp
45
+ * equivalent — say so instead of failing obscurely.
46
+ */
47
+ export function resolveMediaUpload(
48
+ src: { file_path?: unknown; url?: unknown; file_id?: unknown },
49
+ label: string,
50
+ ): { media: MediaUpload; fileName: string } | { error: string } {
51
+ if (src.url) {
52
+ const url = String(src.url);
53
+ return {
54
+ media: { url },
55
+ fileName: basename(new URL(url).pathname) || "file",
56
+ };
57
+ }
58
+ if (src.file_id) {
59
+ return {
60
+ error:
61
+ `${label}: WhatsApp has no file_id — re-send by url (public) or ` +
62
+ `file_path (workspace file)`,
63
+ };
64
+ }
65
+ if (!src.file_path) {
66
+ return {
67
+ error: `${label}: provide file_path (workspace file) or url (public)`,
68
+ };
69
+ }
70
+ const filePath = expandFsPath(String(src.file_path));
71
+ if (!existsSync(filePath)) {
72
+ return {
73
+ error: `File not found: ${filePath} — check the workspace path, or send by url instead`,
74
+ };
75
+ }
76
+ if (statSync(filePath).size > MAX_MEDIA_BYTES) {
77
+ return { error: `${label}: file exceeds WhatsApp's 64MB limit` };
78
+ }
79
+ return { media: { url: filePath }, fileName: basename(filePath) };
80
+ }
81
+
82
+ /** Extension → mimetype for the document/audio paths that require one. */
83
+ const MIME_BY_EXT: Record<string, string> = {
84
+ ".pdf": "application/pdf",
85
+ ".txt": "text/plain",
86
+ ".md": "text/markdown",
87
+ ".json": "application/json",
88
+ ".csv": "text/csv",
89
+ ".zip": "application/zip",
90
+ ".png": "image/png",
91
+ ".jpg": "image/jpeg",
92
+ ".jpeg": "image/jpeg",
93
+ ".gif": "image/gif",
94
+ ".webp": "image/webp",
95
+ ".mp4": "video/mp4",
96
+ ".mov": "video/quicktime",
97
+ ".mp3": "audio/mpeg",
98
+ ".m4a": "audio/mp4",
99
+ ".ogg": "audio/ogg",
100
+ ".opus": "audio/ogg; codecs=opus",
101
+ ".wav": "audio/wav",
102
+ };
103
+
104
+ export function guessMimetype(fileName: string, fallback: string): string {
105
+ return MIME_BY_EXT[extname(fileName).toLowerCase()] ?? fallback;
106
+ }
107
+
108
+ /**
109
+ * Resolve a `reply_to` message id into the quoted message Baileys wants.
110
+ * An unknown id quotes nothing rather than failing the send — the reply
111
+ * link is a nicety, the message itself is the point.
112
+ */
113
+ export function resolveQuoted(
114
+ body: Record<string, unknown>,
115
+ chatId: string,
116
+ ): WAMessage | undefined {
117
+ const raw = body.reply_to ?? body.reply_to_message_id;
118
+ if (raw === undefined || raw === null) return undefined;
119
+ const msgId = typeof raw === "number" ? raw : Number(raw);
120
+ if (!Number.isFinite(msgId)) return undefined;
121
+ const stored = lookupMessage(msgId);
122
+ if (!stored || stored.chatId !== chatId) return undefined;
123
+ return (
124
+ stored.message ?? {
125
+ key: stored.key,
126
+ message: { conversation: stored.text },
127
+ }
128
+ );
129
+ }
130
+
131
+ /**
132
+ * Send one content payload, remember the resulting message so later
133
+ * tool calls can address it, and report its Talon numeric id.
134
+ */
135
+ export async function sendContent(
136
+ ctx: { sock: WASocket; gateway: { incrementMessages: (id: number) => void } },
137
+ chat: WhatsAppChatInfo,
138
+ content: AnyMessageContent,
139
+ options: { quoted?: WAMessage } = {},
140
+ ): Promise<ActionResult> {
141
+ const sent = await ctx.sock.sendMessage(chat.jid, content, options);
142
+ ctx.gateway.incrementMessages(chat.numericChatId);
143
+ if (!sent?.key) return { ok: true };
144
+ const msgId = rememberMessage({
145
+ key: sent.key,
146
+ chatId: chat.chatId,
147
+ message: sent,
148
+ text: "text" in content ? String(content.text ?? "") : "",
149
+ senderName: "bot",
150
+ });
151
+ return { ok: true, message_id: msgId };
152
+ }
153
+
154
+ /**
155
+ * Send text, split across bubbles when it exceeds WhatsApp's limit. The
156
+ * reported message id is the FIRST chunk's: it is the one a reply or
157
+ * reaction should attach to, and the one Talon's callers treat as "the"
158
+ * message.
159
+ */
160
+ export async function sendText(
161
+ ctx: { sock: WASocket; gateway: { incrementMessages: (id: number) => void } },
162
+ chat: WhatsAppChatInfo,
163
+ text: string,
164
+ quoted?: WAMessage,
165
+ ): Promise<ActionResult> {
166
+ const chunks = toWhatsAppChunks(text);
167
+ let first: ActionResult | undefined;
168
+ for (const [index, chunk] of chunks.entries()) {
169
+ const result = await sendContent(
170
+ ctx,
171
+ chat,
172
+ { text: chunk },
173
+ // Only the first chunk quotes — a quoted block on every bubble of a
174
+ // long answer is noise.
175
+ index === 0 && quoted ? { quoted } : {},
176
+ );
177
+ first ??= result;
178
+ }
179
+ log(
180
+ "whatsapp",
181
+ `Sent ${chunks.length} chunk(s) to ${chat.chatId} (${text.length} chars)`,
182
+ );
183
+ return first ?? { ok: true };
184
+ }
185
+
186
+ /**
187
+ * Coerce a user reference the model supplied — bare number, JID, or the
188
+ * numeric id from a member listing — into a WhatsApp user JID.
189
+ */
190
+ export function toUserJid(value: unknown): string | null {
191
+ const raw = String(value ?? "").trim();
192
+ if (!raw) return null;
193
+ if (raw.includes("@")) return raw;
194
+ const digits = raw.replace(/[^0-9]/g, "");
195
+ return digits ? `${digits}@s.whatsapp.net` : null;
196
+ }
@@ -0,0 +1,34 @@
1
+ /**
2
+ * WhatsApp action-handler types.
3
+ *
4
+ * Each domain module exports a `WhatsAppActionHandlers` map keyed by
5
+ * action name. Handlers receive the request body, the numeric chat id,
6
+ * and a shared context carrying the live socket, the gateway, the chat
7
+ * resolved from the id, and the per-handler scheduled-send timers.
8
+ *
9
+ * `chat` is non-null for every action the dispatcher routes here except
10
+ * the store-only scheduling ones, so handlers can use `ctx.chat!`.
11
+ */
12
+
13
+ import type { WASocket } from "baileys";
14
+ import type { Gateway } from "../../../core/engine/gateway.js";
15
+ import type { ActionResult } from "../../../core/types.js";
16
+ import type { WhatsAppChatInfo } from "../registry.js";
17
+
18
+ export interface WhatsAppActionContext {
19
+ /** The connected socket. Reconnects replace it, so never capture it. */
20
+ sock: WASocket;
21
+ gateway: Gateway;
22
+ /** Chat resolved from the numeric id; null only for store-only actions. */
23
+ chat: WhatsAppChatInfo | null;
24
+ /** Active scheduled-message timers, keyed by schedule id. */
25
+ scheduledMessages: Map<string, ReturnType<typeof setTimeout>>;
26
+ }
27
+
28
+ export type WhatsAppActionHandler = (
29
+ body: Record<string, unknown>,
30
+ chatId: number,
31
+ ctx: WhatsAppActionContext,
32
+ ) => Promise<ActionResult | null> | ActionResult | null;
33
+
34
+ export type WhatsAppActionHandlers = Record<string, WhatsAppActionHandler>;
@@ -0,0 +1,12 @@
1
+ /**
2
+ * WhatsApp frontend factory — attaches the create half of the registry
3
+ * entry (descriptor registered in core/frontend-runtime). The
4
+ * implementation (Baileys socket) loads only when created.
5
+ */
6
+
7
+ import { attachFrontendCreate } from "../../core/frontend-runtime/index.js";
8
+
9
+ attachFrontendCreate("whatsapp", async (config, gateway) => {
10
+ const { createWhatsAppFrontend } = await import("./index.js");
11
+ return createWhatsAppFrontend(config, gateway);
12
+ });
@@ -0,0 +1,172 @@
1
+ /**
2
+ * Markdown → WhatsApp text.
3
+ *
4
+ * The model writes standard Markdown; WhatsApp renders its own dialect
5
+ * (*bold*, _italic_, ~strike~, `code`, ```blocks```, "> " quotes, and
6
+ * "- "/"1. " lists). Translating between them by running regexes over the
7
+ * raw string is how you end up bolding the asterisks inside a code block
8
+ * or eating the underscores in a URL, so this walks marked's token tree
9
+ * instead: code spans and fenced blocks are separate token types by the
10
+ * time we see them, and emphasis is structural rather than textual.
11
+ *
12
+ * WhatsApp has no escape syntax, so literal delimiters in the source
13
+ * survive as themselves — there is nothing to escape into.
14
+ */
15
+
16
+ import { marked, type Token, type Tokens } from "marked";
17
+ import { splitMessage } from "../telegram/formatting.js";
18
+
19
+ /** WhatsApp accepts far more, but long bubbles read badly on a phone. */
20
+ export const WHATSAPP_MAX_TEXT = 4096;
21
+
22
+ /** Rendered width of a table cell, for the monospace fallback. */
23
+ const MAX_TABLE_CELL = 24;
24
+
25
+ function renderInline(tokens: Token[] | undefined): string {
26
+ if (!tokens) return "";
27
+ return tokens.map(renderInlineToken).join("");
28
+ }
29
+
30
+ function renderInlineToken(token: Token): string {
31
+ switch (token.type) {
32
+ case "strong":
33
+ return `*${renderInline((token as Tokens.Strong).tokens)}*`;
34
+ case "em":
35
+ return `_${renderInline((token as Tokens.Em).tokens)}_`;
36
+ case "del":
37
+ return `~${renderInline((token as Tokens.Del).tokens)}~`;
38
+ case "codespan":
39
+ // WhatsApp renders single-backtick spans as inline monospace.
40
+ return `\`${(token as Tokens.Codespan).text}\``;
41
+ case "link": {
42
+ const link = token as Tokens.Link;
43
+ const label = renderInline(link.tokens);
44
+ // WhatsApp auto-links bare URLs but renders no anchor text, so a
45
+ // labelled link has to show both halves or the destination is lost.
46
+ return !label || label === link.href
47
+ ? link.href
48
+ : `${label} (${link.href})`;
49
+ }
50
+ case "image": {
51
+ const image = token as Tokens.Image;
52
+ return image.text ? `${image.text} (${image.href})` : image.href;
53
+ }
54
+ case "br":
55
+ return "\n";
56
+ case "escape":
57
+ return (token as Tokens.Escape).text;
58
+ case "html":
59
+ // Inline HTML has no WhatsApp equivalent — emit the source text.
60
+ return (token as Tokens.HTML).raw;
61
+ default:
62
+ return (token as { text?: string }).text ?? "";
63
+ }
64
+ }
65
+
66
+ function renderListItems(list: Tokens.List, depth: number): string {
67
+ const indent = " ".repeat(depth);
68
+ return list.items
69
+ .map((item, index) => {
70
+ const marker = list.ordered ? `${Number(list.start || 1) + index}.` : "-";
71
+ // A task item's checkbox is structural in Markdown and cosmetic in
72
+ // WhatsApp; render it as a box so the state still reads.
73
+ const check = item.task ? (item.checked ? "[x] " : "[ ] ") : "";
74
+ const body = renderBlocks(item.tokens ?? [], depth + 1).trimEnd();
75
+ const [first = "", ...rest] = body.split("\n");
76
+ const head = `${indent}${marker} ${check}${first}`;
77
+ // Continuation lines of a wrapped item line up under its text.
78
+ const tail = rest.map((line) => `${indent} ${line}`);
79
+ return [head, ...tail].join("\n");
80
+ })
81
+ .join("\n");
82
+ }
83
+
84
+ /**
85
+ * Tables have no WhatsApp equivalent. A monospace block keeps the columns
86
+ * aligned, which is the part that carries the meaning.
87
+ */
88
+ function renderTable(table: Tokens.Table): string {
89
+ const clip = (s: string): string =>
90
+ s.length > MAX_TABLE_CELL ? `${s.slice(0, MAX_TABLE_CELL - 1)}…` : s;
91
+ const header = table.header.map((cell) => clip(renderInline(cell.tokens)));
92
+ const rows = table.rows.map((row) =>
93
+ row.map((cell) => clip(renderInline(cell.tokens))),
94
+ );
95
+ const widths = header.map((cell, i) =>
96
+ Math.max(cell.length, ...rows.map((row) => (row[i] ?? "").length)),
97
+ );
98
+ const line = (cells: string[]): string =>
99
+ cells
100
+ .map((cell, i) => cell.padEnd(widths[i] ?? 0))
101
+ .join(" ")
102
+ .trimEnd();
103
+ const separator = widths.map((width) => "─".repeat(width)).join(" ");
104
+ return ["```", line(header), separator, ...rows.map(line), "```"].join("\n");
105
+ }
106
+
107
+ function renderBlockToken(token: Token, depth: number): string {
108
+ switch (token.type) {
109
+ case "heading":
110
+ // WhatsApp has no headings; bold is the only emphasis that reads
111
+ // as a title in a chat bubble.
112
+ return `*${renderInline((token as Tokens.Heading).tokens)}*`;
113
+ case "paragraph":
114
+ return renderInline((token as Tokens.Paragraph).tokens);
115
+ case "text": {
116
+ const text = token as Tokens.Text;
117
+ return text.tokens ? renderInline(text.tokens) : text.text;
118
+ }
119
+ case "code": {
120
+ const code = token as Tokens.Code;
121
+ return `\`\`\`\n${code.text}\n\`\`\``;
122
+ }
123
+ case "blockquote": {
124
+ const quote = renderBlocks((token as Tokens.Blockquote).tokens, depth);
125
+ return quote
126
+ .split("\n")
127
+ .map((line) => `> ${line}`.trimEnd())
128
+ .join("\n");
129
+ }
130
+ case "list":
131
+ return renderListItems(token as Tokens.List, depth);
132
+ case "table":
133
+ return renderTable(token as Tokens.Table);
134
+ case "hr":
135
+ return "──────────";
136
+ case "html":
137
+ return (token as Tokens.HTML).raw.trim();
138
+ case "space":
139
+ return "";
140
+ default:
141
+ return (token as { text?: string }).text ?? "";
142
+ }
143
+ }
144
+
145
+ function renderBlocks(tokens: Token[], depth = 0): string {
146
+ const parts: string[] = [];
147
+ for (const token of tokens) {
148
+ const rendered = renderBlockToken(token, depth);
149
+ if (rendered !== "") parts.push(rendered);
150
+ }
151
+ // Inside a list item, blocks stack tightly; at top level they get the
152
+ // blank line that separates paragraphs in a bubble.
153
+ return parts.join(depth > 0 ? "\n" : "\n\n");
154
+ }
155
+
156
+ /**
157
+ * Translate Markdown into WhatsApp's formatting dialect. Falls back to
158
+ * the original text if the Markdown can't be parsed — an unformatted
159
+ * message beats a dropped one.
160
+ */
161
+ export function toWhatsAppText(text: string): string {
162
+ try {
163
+ return renderBlocks(marked.lexer(text)).trim();
164
+ } catch {
165
+ return text;
166
+ }
167
+ }
168
+
169
+ /** Translate to the WhatsApp dialect, then split into sendable chunks. */
170
+ export function toWhatsAppChunks(text: string): string[] {
171
+ return splitMessage(toWhatsAppText(text), WHATSAPP_MAX_TEXT);
172
+ }