@copilotkit/channels-whatsapp 0.0.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 (90) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +236 -0
  3. package/dist/adapter.d.ts +57 -0
  4. package/dist/adapter.d.ts.map +1 -0
  5. package/dist/adapter.js +242 -0
  6. package/dist/adapter.test.d.ts +2 -0
  7. package/dist/adapter.test.d.ts.map +1 -0
  8. package/dist/adapter.test.js +137 -0
  9. package/dist/built-in-context.d.ts +7 -0
  10. package/dist/built-in-context.d.ts.map +1 -0
  11. package/dist/built-in-context.js +19 -0
  12. package/dist/built-in-context.test.d.ts +2 -0
  13. package/dist/built-in-context.test.d.ts.map +1 -0
  14. package/dist/built-in-context.test.js +17 -0
  15. package/dist/built-in-tools.d.ts +8 -0
  16. package/dist/built-in-tools.d.ts.map +1 -0
  17. package/dist/built-in-tools.js +6 -0
  18. package/dist/client.d.ts +40 -0
  19. package/dist/client.d.ts.map +1 -0
  20. package/dist/client.js +108 -0
  21. package/dist/client.test.d.ts +2 -0
  22. package/dist/client.test.d.ts.map +1 -0
  23. package/dist/client.test.js +108 -0
  24. package/dist/conversation-store.d.ts +23 -0
  25. package/dist/conversation-store.d.ts.map +1 -0
  26. package/dist/conversation-store.js +41 -0
  27. package/dist/conversation-store.test.d.ts +2 -0
  28. package/dist/conversation-store.test.d.ts.map +1 -0
  29. package/dist/conversation-store.test.js +40 -0
  30. package/dist/download-files.d.ts +44 -0
  31. package/dist/download-files.d.ts.map +1 -0
  32. package/dist/download-files.js +66 -0
  33. package/dist/download-files.test.d.ts +2 -0
  34. package/dist/download-files.test.d.ts.map +1 -0
  35. package/dist/download-files.test.js +34 -0
  36. package/dist/event-renderer.d.ts +18 -0
  37. package/dist/event-renderer.d.ts.map +1 -0
  38. package/dist/event-renderer.js +110 -0
  39. package/dist/event-renderer.test.d.ts +2 -0
  40. package/dist/event-renderer.test.d.ts.map +1 -0
  41. package/dist/event-renderer.test.js +68 -0
  42. package/dist/history-store.d.ts +36 -0
  43. package/dist/history-store.d.ts.map +1 -0
  44. package/dist/history-store.js +17 -0
  45. package/dist/history-store.test.d.ts +2 -0
  46. package/dist/history-store.test.d.ts.map +1 -0
  47. package/dist/history-store.test.js +24 -0
  48. package/dist/index.d.ts +18 -0
  49. package/dist/index.d.ts.map +1 -0
  50. package/dist/index.js +12 -0
  51. package/dist/interaction.d.ts +19 -0
  52. package/dist/interaction.d.ts.map +1 -0
  53. package/dist/interaction.js +56 -0
  54. package/dist/interaction.test.d.ts +2 -0
  55. package/dist/interaction.test.d.ts.map +1 -0
  56. package/dist/interaction.test.js +67 -0
  57. package/dist/markdown-to-wa.d.ts +15 -0
  58. package/dist/markdown-to-wa.d.ts.map +1 -0
  59. package/dist/markdown-to-wa.js +56 -0
  60. package/dist/markdown-to-wa.test.d.ts +2 -0
  61. package/dist/markdown-to-wa.test.d.ts.map +1 -0
  62. package/dist/markdown-to-wa.test.js +29 -0
  63. package/dist/render/budget.d.ts +34 -0
  64. package/dist/render/budget.d.ts.map +1 -0
  65. package/dist/render/budget.js +40 -0
  66. package/dist/render/budget.test.d.ts +2 -0
  67. package/dist/render/budget.test.d.ts.map +1 -0
  68. package/dist/render/budget.test.js +24 -0
  69. package/dist/render/message.d.ts +74 -0
  70. package/dist/render/message.d.ts.map +1 -0
  71. package/dist/render/message.js +247 -0
  72. package/dist/render/message.test.d.ts +2 -0
  73. package/dist/render/message.test.d.ts.map +1 -0
  74. package/dist/render/message.test.js +184 -0
  75. package/dist/types.d.ts +107 -0
  76. package/dist/types.d.ts.map +1 -0
  77. package/dist/types.js +1 -0
  78. package/dist/webhook-listener.d.ts +16 -0
  79. package/dist/webhook-listener.d.ts.map +1 -0
  80. package/dist/webhook-listener.js +128 -0
  81. package/dist/webhook-listener.test.d.ts +2 -0
  82. package/dist/webhook-listener.test.d.ts.map +1 -0
  83. package/dist/webhook-listener.test.js +204 -0
  84. package/dist/webhook-server.d.ts +23 -0
  85. package/dist/webhook-server.d.ts.map +1 -0
  86. package/dist/webhook-server.js +99 -0
  87. package/dist/webhook-server.test.d.ts +2 -0
  88. package/dist/webhook-server.test.d.ts.map +1 -0
  89. package/dist/webhook-server.test.js +150 -0
  90. package/package.json +55 -0
@@ -0,0 +1,56 @@
1
+ /**
2
+ * Translate GFM Markdown into WhatsApp's formatting subset:
3
+ * **bold** / __bold__ → *bold*
4
+ * *italic* / _italic_ → _italic_
5
+ * ~~strike~~ → ~strike~
6
+ * [text](url) → text (url)
7
+ * # / ## / ### Heading → *Heading*
8
+ * Fenced (```) and inline (`) code are preserved verbatim — no transforms
9
+ * run inside them.
10
+ *
11
+ * Implementation is a single forward scan that copies code spans/fences
12
+ * untouched and applies the substitutions only to the prose between them.
13
+ */
14
+ export function markdownToWhatsApp(input) {
15
+ const segments = splitOnCode(input);
16
+ return segments
17
+ .map((seg) => (seg.code ? seg.text : transformProse(seg.text)))
18
+ .join("");
19
+ }
20
+ /** Split into prose vs code segments (fenced ```...``` and inline `...`). */
21
+ function splitOnCode(input) {
22
+ const out = [];
23
+ const re = /(```[\s\S]*?```|`[^`\n]*`)/g;
24
+ let last = 0;
25
+ let m;
26
+ while ((m = re.exec(input)) !== null) {
27
+ if (m.index > last)
28
+ out.push({ text: input.slice(last, m.index), code: false });
29
+ out.push({ text: m[0], code: true });
30
+ last = m.index + m[0].length;
31
+ }
32
+ if (last < input.length)
33
+ out.push({ text: input.slice(last), code: false });
34
+ return out;
35
+ }
36
+ function transformProse(text) {
37
+ let s = text;
38
+ // Links: [text](url) → text (url)
39
+ s = s.replace(/\[([^\]]+)\]\(([^)\s]+)(?:\s+"[^"]*")?\)/g, "$1 ($2)");
40
+ // Single *italic* → _italic_ — run BEFORE bold so **x** double-stars guard
41
+ // against this regex (the leading [^*] or ^ followed immediately by ** won't
42
+ // match a lone * that is part of **bold**).
43
+ s = s.replace(/(^|[^*])\*([^*\n]+)\*(?!\*)/g, "$1_$2_");
44
+ // Single _italic_ (underscore form) — if not already handled as bold __x__.
45
+ // Run before __bold__ so double-underscore guards similarly.
46
+ s = s.replace(/(^|[^_])_([^_\n]+)_(?!_)/g, "$1_$2_");
47
+ // ATX headers at line start → bold line. Must run after italic so the
48
+ // produced *X* isn't re-processed by the italic regex above.
49
+ s = s.replace(/^[ \t]*#{1,6}[ \t]+(.+?)[ \t]*$/gm, "*$1*");
50
+ // Bold: **x** or __x__ → *x*
51
+ s = s.replace(/\*\*([^*]+)\*\*/g, "*$1*");
52
+ s = s.replace(/__([^_]+)__/g, "*$1*");
53
+ // Strike: ~~x~~ → ~x~
54
+ s = s.replace(/~~([^~]+)~~/g, "~$1~");
55
+ return s;
56
+ }
@@ -0,0 +1,2 @@
1
+ export {};
2
+ //# sourceMappingURL=markdown-to-wa.test.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"markdown-to-wa.test.d.ts","sourceRoot":"","sources":["../src/markdown-to-wa.test.ts"],"names":[],"mappings":""}
@@ -0,0 +1,29 @@
1
+ import { describe, it, expect } from "vitest";
2
+ import { markdownToWhatsApp } from "./markdown-to-wa.js";
3
+ describe("markdownToWhatsApp", () => {
4
+ it("converts **bold** to *bold*", () => {
5
+ expect(markdownToWhatsApp("**hi**")).toBe("*hi*");
6
+ expect(markdownToWhatsApp("__hi__")).toBe("*hi*");
7
+ });
8
+ it("keeps single-underscore/asterisk italic as _italic_", () => {
9
+ expect(markdownToWhatsApp("*hi*")).toBe("_hi_");
10
+ expect(markdownToWhatsApp("_hi_")).toBe("_hi_");
11
+ });
12
+ it("converts ~~strike~~ to ~strike~", () => {
13
+ expect(markdownToWhatsApp("~~gone~~")).toBe("~gone~");
14
+ });
15
+ it("renders a link as 'text (url)'", () => {
16
+ expect(markdownToWhatsApp("[docs](https://x.io)")).toBe("docs (https://x.io)");
17
+ });
18
+ it("strips ATX headers to plain bold lines", () => {
19
+ expect(markdownToWhatsApp("# Title")).toBe("*Title*");
20
+ expect(markdownToWhatsApp("### Sub")).toBe("*Sub*");
21
+ });
22
+ it("leaves fenced code blocks intact", () => {
23
+ const src = "```\ncode *not bold*\n```";
24
+ expect(markdownToWhatsApp(src)).toBe("```\ncode *not bold*\n```");
25
+ });
26
+ it("preserves inline code without transforming inside it", () => {
27
+ expect(markdownToWhatsApp("`a**b**`")).toBe("`a**b**`");
28
+ });
29
+ });
@@ -0,0 +1,34 @@
1
+ /**
2
+ * WhatsApp Cloud API hard limits we clamp against when lowering IR to
3
+ * messages. Sources: Cloud API "Messages" + "Interactive messages" docs.
4
+ */
5
+ export declare const WA_LIMITS: {
6
+ /** Max chars in a text message body. */
7
+ readonly bodyText: 4096;
8
+ /** Max reply buttons in an interactive `button` message. */
9
+ readonly replyButtons: 3;
10
+ /** Max chars in a reply-button title. */
11
+ readonly buttonTitle: 20;
12
+ /** Max chars in an interactive header/body/footer text. */
13
+ readonly interactiveBody: 1024;
14
+ readonly interactiveHeader: 60;
15
+ readonly interactiveFooter: 60;
16
+ /** Max total rows across all sections in an interactive `list` message. */
17
+ readonly listRows: 10;
18
+ /** Max chars in a list-row title. */
19
+ readonly rowTitle: 24;
20
+ /** Max chars in a list-row description. */
21
+ readonly rowDescription: 72;
22
+ /** Max chars in the list's open button label. */
23
+ readonly listButton: 20;
24
+ /** Max chars in an interactive control id (button/row id). */
25
+ readonly controlId: 256;
26
+ };
27
+ /** Truncate to max chars, appending an ellipsis marker if longer. Never returns >max. */
28
+ export declare function truncateText(text: string, max: number): string;
29
+ /** Clamp an array to max items; return kept items plus how many overflowed. */
30
+ export declare function clampArray<T>(items: readonly T[], max: number): {
31
+ items: T[];
32
+ overflow: number;
33
+ };
34
+ //# sourceMappingURL=budget.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"budget.d.ts","sourceRoot":"","sources":["../../src/render/budget.ts"],"names":[],"mappings":"AAAA;;;GAGG;AACH,eAAO,MAAM,SAAS;IACpB,wCAAwC;;IAExC,4DAA4D;;IAE5D,yCAAyC;;IAEzC,2DAA2D;;;;IAI3D,2EAA2E;;IAE3E,qCAAqC;;IAErC,2CAA2C;;IAE3C,iDAAiD;;IAEjD,8DAA8D;;CAEtD,CAAC;AAEX,yFAAyF;AACzF,wBAAgB,YAAY,CAAC,IAAI,EAAE,MAAM,EAAE,GAAG,EAAE,MAAM,GAAG,MAAM,CAI9D;AAED,+EAA+E;AAC/E,wBAAgB,UAAU,CAAC,CAAC,EAC1B,KAAK,EAAE,SAAS,CAAC,EAAE,EACnB,GAAG,EAAE,MAAM,GACV;IAAE,KAAK,EAAE,CAAC,EAAE,CAAC;IAAC,QAAQ,EAAE,MAAM,CAAA;CAAE,CAGlC"}
@@ -0,0 +1,40 @@
1
+ /**
2
+ * WhatsApp Cloud API hard limits we clamp against when lowering IR to
3
+ * messages. Sources: Cloud API "Messages" + "Interactive messages" docs.
4
+ */
5
+ export const WA_LIMITS = {
6
+ /** Max chars in a text message body. */
7
+ bodyText: 4096,
8
+ /** Max reply buttons in an interactive `button` message. */
9
+ replyButtons: 3,
10
+ /** Max chars in a reply-button title. */
11
+ buttonTitle: 20,
12
+ /** Max chars in an interactive header/body/footer text. */
13
+ interactiveBody: 1024,
14
+ interactiveHeader: 60,
15
+ interactiveFooter: 60,
16
+ /** Max total rows across all sections in an interactive `list` message. */
17
+ listRows: 10,
18
+ /** Max chars in a list-row title. */
19
+ rowTitle: 24,
20
+ /** Max chars in a list-row description. */
21
+ rowDescription: 72,
22
+ /** Max chars in the list's open button label. */
23
+ listButton: 20,
24
+ /** Max chars in an interactive control id (button/row id). */
25
+ controlId: 256,
26
+ };
27
+ /** Truncate to max chars, appending an ellipsis marker if longer. Never returns >max. */
28
+ export function truncateText(text, max) {
29
+ if (text.length <= max)
30
+ return text;
31
+ if (max <= 1)
32
+ return text.slice(0, max);
33
+ return text.slice(0, max - 1) + "…";
34
+ }
35
+ /** Clamp an array to max items; return kept items plus how many overflowed. */
36
+ export function clampArray(items, max) {
37
+ if (items.length <= max)
38
+ return { items: [...items], overflow: 0 };
39
+ return { items: items.slice(0, max), overflow: items.length - max };
40
+ }
@@ -0,0 +1,2 @@
1
+ export {};
2
+ //# sourceMappingURL=budget.test.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"budget.test.d.ts","sourceRoot":"","sources":["../../src/render/budget.test.ts"],"names":[],"mappings":""}
@@ -0,0 +1,24 @@
1
+ import { describe, it, expect } from "vitest";
2
+ import { WA_LIMITS, truncateText, clampArray } from "./budget.js";
3
+ describe("budget", () => {
4
+ it("exposes WhatsApp Cloud API limits", () => {
5
+ expect(WA_LIMITS.bodyText).toBe(4096);
6
+ expect(WA_LIMITS.replyButtons).toBe(3);
7
+ expect(WA_LIMITS.buttonTitle).toBe(20);
8
+ expect(WA_LIMITS.listRows).toBe(10);
9
+ expect(WA_LIMITS.rowTitle).toBe(24);
10
+ expect(WA_LIMITS.rowDescription).toBe(72);
11
+ });
12
+ it("truncateText never exceeds max and marks truncation", () => {
13
+ expect(truncateText("hello", 10)).toBe("hello");
14
+ expect(truncateText("hello world", 5)).toBe("hell…");
15
+ expect(truncateText("hello world", 5).length).toBe(5);
16
+ });
17
+ it("clampArray keeps max items and reports overflow", () => {
18
+ expect(clampArray([1, 2, 3], 5)).toEqual({ items: [1, 2, 3], overflow: 0 });
19
+ expect(clampArray([1, 2, 3, 4, 5], 3)).toEqual({
20
+ items: [1, 2, 3],
21
+ overflow: 2,
22
+ });
23
+ });
24
+ });
@@ -0,0 +1,74 @@
1
+ import type { BotNode } from "@copilotkit/channels-ui";
2
+ /** A Cloud API send payload (the body sans messaging_product/to, which the client adds). */
3
+ export type WhatsAppOutbound = {
4
+ type: "text";
5
+ text: {
6
+ body: string;
7
+ preview_url: boolean;
8
+ };
9
+ } | {
10
+ type: "image";
11
+ image: {
12
+ link?: string;
13
+ id?: string;
14
+ caption?: string;
15
+ };
16
+ } | {
17
+ type: "document";
18
+ document: {
19
+ link?: string;
20
+ id?: string;
21
+ filename?: string;
22
+ caption?: string;
23
+ };
24
+ } | {
25
+ type: "interactive";
26
+ interactive: InteractiveButton | InteractiveList;
27
+ };
28
+ interface InteractiveButton {
29
+ type: "button";
30
+ body: {
31
+ text: string;
32
+ };
33
+ action: {
34
+ buttons: Array<{
35
+ type: "reply";
36
+ reply: {
37
+ id: string;
38
+ title: string;
39
+ };
40
+ }>;
41
+ };
42
+ }
43
+ interface InteractiveList {
44
+ type: "list";
45
+ body: {
46
+ text: string;
47
+ };
48
+ action: {
49
+ button: string;
50
+ sections: Array<{
51
+ title?: string;
52
+ rows: Array<{
53
+ id: string;
54
+ title: string;
55
+ description?: string;
56
+ }>;
57
+ }>;
58
+ };
59
+ }
60
+ /**
61
+ * Lower `BotNode[]` IR to Cloud API payload(s).
62
+ *
63
+ * Strategy: collect (a) the prose text from text/section/header/markdown/
64
+ * fields/table/context/divider nodes, and (b) actionable controls from
65
+ * `button`/`select` nodes. Then decide a single shape:
66
+ * - 0 actions → a text message (image nodes emitted separately).
67
+ * - 1..3 button actions → interactive `button`.
68
+ * - 4..10 actions → interactive `list`.
69
+ * - >10 actions → numbered text menu (degraded).
70
+ * Image nodes always emit their own image payload.
71
+ */
72
+ export declare function renderWhatsAppMessage(ir: BotNode[]): WhatsAppOutbound[];
73
+ export {};
74
+ //# sourceMappingURL=message.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"message.d.ts","sourceRoot":"","sources":["../../src/render/message.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,OAAO,EAAE,MAAM,yBAAyB,CAAC;AAIvD,4FAA4F;AAC5F,MAAM,MAAM,gBAAgB,GACxB;IAAE,IAAI,EAAE,MAAM,CAAC;IAAC,IAAI,EAAE;QAAE,IAAI,EAAE,MAAM,CAAC;QAAC,WAAW,EAAE,OAAO,CAAA;KAAE,CAAA;CAAE,GAC9D;IAAE,IAAI,EAAE,OAAO,CAAC;IAAC,KAAK,EAAE;QAAE,IAAI,CAAC,EAAE,MAAM,CAAC;QAAC,EAAE,CAAC,EAAE,MAAM,CAAC;QAAC,OAAO,CAAC,EAAE,MAAM,CAAA;KAAE,CAAA;CAAE,GAC1E;IACE,IAAI,EAAE,UAAU,CAAC;IACjB,QAAQ,EAAE;QACR,IAAI,CAAC,EAAE,MAAM,CAAC;QACd,EAAE,CAAC,EAAE,MAAM,CAAC;QACZ,QAAQ,CAAC,EAAE,MAAM,CAAC;QAClB,OAAO,CAAC,EAAE,MAAM,CAAC;KAClB,CAAC;CACH,GACD;IAAE,IAAI,EAAE,aAAa,CAAC;IAAC,WAAW,EAAE,iBAAiB,GAAG,eAAe,CAAA;CAAE,CAAC;AAE9E,UAAU,iBAAiB;IACzB,IAAI,EAAE,QAAQ,CAAC;IACf,IAAI,EAAE;QAAE,IAAI,EAAE,MAAM,CAAA;KAAE,CAAC;IACvB,MAAM,EAAE;QACN,OAAO,EAAE,KAAK,CAAC;YAAE,IAAI,EAAE,OAAO,CAAC;YAAC,KAAK,EAAE;gBAAE,EAAE,EAAE,MAAM,CAAC;gBAAC,KAAK,EAAE,MAAM,CAAA;aAAE,CAAA;SAAE,CAAC,CAAC;KACzE,CAAC;CACH;AACD,UAAU,eAAe;IACvB,IAAI,EAAE,MAAM,CAAC;IACb,IAAI,EAAE;QAAE,IAAI,EAAE,MAAM,CAAA;KAAE,CAAC;IACvB,MAAM,EAAE;QACN,MAAM,EAAE,MAAM,CAAC;QACf,QAAQ,EAAE,KAAK,CAAC;YACd,KAAK,CAAC,EAAE,MAAM,CAAC;YACf,IAAI,EAAE,KAAK,CAAC;gBAAE,EAAE,EAAE,MAAM,CAAC;gBAAC,KAAK,EAAE,MAAM,CAAC;gBAAC,WAAW,CAAC,EAAE,MAAM,CAAA;aAAE,CAAC,CAAC;SAClE,CAAC,CAAC;KACJ,CAAC;CACH;AAgBD;;;;;;;;;;;GAWG;AACH,wBAAgB,qBAAqB,CAAC,EAAE,EAAE,OAAO,EAAE,GAAG,gBAAgB,EAAE,CA0HvE"}
@@ -0,0 +1,247 @@
1
+ import { markdownToWhatsApp } from "../markdown-to-wa.js";
2
+ import { WA_LIMITS, truncateText, clampArray } from "./budget.js";
3
+ /**
4
+ * Synthetic base id for a value-only button (one with a `value` but no
5
+ * `onClick`/`onSelect` — e.g. an `awaitChoice` HITL confirm–cancel pair). There's
6
+ * no handler to dispatch; the value is encoded into the reply id so it round-trips
7
+ * and the engine's awaiting waiter resolves with it.
8
+ */
9
+ const VALUE_ONLY_ID = "wa:choice";
10
+ /**
11
+ * Lower `BotNode[]` IR to Cloud API payload(s).
12
+ *
13
+ * Strategy: collect (a) the prose text from text/section/header/markdown/
14
+ * fields/table/context/divider nodes, and (b) actionable controls from
15
+ * `button`/`select` nodes. Then decide a single shape:
16
+ * - 0 actions → a text message (image nodes emitted separately).
17
+ * - 1..3 button actions → interactive `button`.
18
+ * - 4..10 actions → interactive `list`.
19
+ * - >10 actions → numbered text menu (degraded).
20
+ * Image nodes always emit their own image payload.
21
+ */
22
+ export function renderWhatsAppMessage(ir) {
23
+ const out = [];
24
+ const prose = [];
25
+ const actions = [];
26
+ let selectButtonLabel = "Choose";
27
+ let hasSelect = false;
28
+ // Walk the whole IR tree. `renderToIR` lowers text into `{ type: "text",
29
+ // props: { value } }` leaf nodes and nests controls inside containers
30
+ // (`message` > `actions` > `button`), so we must recurse — collecting prose
31
+ // from text leaves and actionable controls wherever they appear, at any depth.
32
+ const visitChildren = (children) => {
33
+ if (children == null || typeof children === "boolean")
34
+ return;
35
+ if (typeof children === "string") {
36
+ if (children)
37
+ prose.push(children);
38
+ return;
39
+ }
40
+ if (typeof children === "number") {
41
+ prose.push(String(children));
42
+ return;
43
+ }
44
+ if (Array.isArray(children)) {
45
+ for (const c of children)
46
+ visitChildren(c);
47
+ return;
48
+ }
49
+ if (typeof children === "object" && "type" in children) {
50
+ visitNode(children);
51
+ }
52
+ };
53
+ const visitNode = (n) => {
54
+ const t = typeof n.type === "string" ? n.type : "";
55
+ switch (t) {
56
+ case "text": {
57
+ const v = n.props.value;
58
+ if (typeof v === "string") {
59
+ if (v)
60
+ prose.push(v);
61
+ }
62
+ else if (typeof v === "number") {
63
+ prose.push(String(v));
64
+ }
65
+ return; // text leaves carry no children
66
+ }
67
+ case "image": {
68
+ const url = n.props.url;
69
+ if (url) {
70
+ const alt = n.props.alt;
71
+ out.push({
72
+ type: "image",
73
+ image: { link: url, ...(alt ? { caption: alt } : {}) },
74
+ });
75
+ }
76
+ return;
77
+ }
78
+ case "button": {
79
+ const title = textOf(n.props.children);
80
+ const handlerId = idFromHandler(n.props.onClick);
81
+ if (handlerId) {
82
+ // Handler-bound button: dispatch via the minted id (value round-trips too).
83
+ actions.push({ id: buildControlId(handlerId, n.props.value), title });
84
+ }
85
+ else if (n.props.value !== undefined) {
86
+ // Value-only button (e.g. awaitChoice / HITL confirm–cancel): there's no
87
+ // handler to dispatch, but the value MUST round-trip so the engine's
88
+ // waiter resolves. Encode it behind a synthetic base id; decodeInteraction
89
+ // splits it back into `{ value }` and the awaiting waiter takes it.
90
+ actions.push({
91
+ id: buildControlId(VALUE_ONLY_ID, n.props.value),
92
+ title,
93
+ });
94
+ }
95
+ // A button with neither a handler nor a value can't round-trip → skip.
96
+ return;
97
+ }
98
+ case "select": {
99
+ const baseId = idFromHandler(n.props.onSelect);
100
+ selectButtonLabel =
101
+ n.props.placeholder ?? "Choose";
102
+ hasSelect = true;
103
+ const opts = n.props.options ?? [];
104
+ if (baseId) {
105
+ for (const o of opts) {
106
+ actions.push({
107
+ id: buildControlId(baseId, o.value),
108
+ title: o.label,
109
+ });
110
+ }
111
+ }
112
+ return;
113
+ }
114
+ case "divider":
115
+ prose.push("───");
116
+ return;
117
+ default:
118
+ // Containers (message/section/header/markdown/fields/table/context/actions/…)
119
+ // and unknown nodes: recurse into children.
120
+ visitChildren(n.props.children);
121
+ }
122
+ };
123
+ for (const n of ir)
124
+ visitNode(n);
125
+ const bodyText = prose.filter(Boolean).join("\n");
126
+ if (actions.length === 0) {
127
+ if (bodyText)
128
+ out.unshift(textPayload(bodyText));
129
+ return out;
130
+ }
131
+ if (actions.length <= WA_LIMITS.replyButtons && !hasSelect) {
132
+ out.unshift(buttonPayload(bodyText || "​", actions));
133
+ return out;
134
+ }
135
+ if (actions.length <= WA_LIMITS.listRows) {
136
+ out.unshift(listPayload(bodyText || "​", selectButtonLabel, actions));
137
+ return out;
138
+ }
139
+ // Degrade: numbered text menu.
140
+ const numbered = actions.map((a, i) => `${i + 1}. ${a.title}`).join("\n");
141
+ out.unshift(textPayload(`${bodyText}\n\n${numbered}`.trim()));
142
+ return out;
143
+ }
144
+ function textPayload(body) {
145
+ return {
146
+ type: "text",
147
+ text: {
148
+ body: truncateText(markdownToWhatsApp(body), WA_LIMITS.bodyText),
149
+ preview_url: false,
150
+ },
151
+ };
152
+ }
153
+ function buttonPayload(body, actions) {
154
+ const { items } = clampArray(actions, WA_LIMITS.replyButtons);
155
+ return {
156
+ type: "interactive",
157
+ interactive: {
158
+ type: "button",
159
+ body: {
160
+ text: truncateText(markdownToWhatsApp(body), WA_LIMITS.interactiveBody),
161
+ },
162
+ action: {
163
+ buttons: items.map((a) => ({
164
+ type: "reply",
165
+ reply: {
166
+ id: a.id,
167
+ title: truncateText(a.title, WA_LIMITS.buttonTitle),
168
+ },
169
+ })),
170
+ },
171
+ },
172
+ };
173
+ }
174
+ function listPayload(body, buttonLabel, actions) {
175
+ const { items } = clampArray(actions, WA_LIMITS.listRows);
176
+ return {
177
+ type: "interactive",
178
+ interactive: {
179
+ type: "list",
180
+ body: {
181
+ text: truncateText(markdownToWhatsApp(body), WA_LIMITS.interactiveBody),
182
+ },
183
+ action: {
184
+ button: truncateText(buttonLabel, WA_LIMITS.listButton),
185
+ sections: [
186
+ {
187
+ rows: items.map((a) => ({
188
+ id: a.id,
189
+ title: truncateText(a.title, WA_LIMITS.rowTitle),
190
+ })),
191
+ },
192
+ ],
193
+ },
194
+ },
195
+ };
196
+ }
197
+ /** Extract the `{ id }` the registry stamped onto an event prop, if present. */
198
+ function idFromHandler(handler) {
199
+ if (handler && typeof handler === "object" && "id" in handler) {
200
+ const id = handler.id;
201
+ if (typeof id === "string")
202
+ return id;
203
+ }
204
+ return undefined;
205
+ }
206
+ /**
207
+ * Build a reply-control id. A bare minted id is short and safe to clamp. When a
208
+ * value must round-trip (WhatsApp replies carry only an id, no value field) we
209
+ * encode `${id}::${JSON.stringify(value)}`; if that exceeds WhatsApp's 256-char
210
+ * id limit it CANNOT round-trip, so we fail loudly rather than truncate (which
211
+ * would make decodeInteraction silently parse garbage).
212
+ */
213
+ function buildControlId(actionId, value) {
214
+ if (value === undefined)
215
+ return truncateText(actionId, WA_LIMITS.controlId);
216
+ const encoded = `${actionId}::${JSON.stringify(value)}`;
217
+ if (encoded.length > WA_LIMITS.controlId) {
218
+ throw new Error(`WhatsApp control value too large to round-trip: encoded id is ${encoded.length} chars ` +
219
+ `(max ${WA_LIMITS.controlId}). Use a smaller value or a short key the handler maps.`);
220
+ }
221
+ return encoded;
222
+ }
223
+ /**
224
+ * Flatten a `BotChildren` tree to a single text string. Handles both plain
225
+ * string children and `renderToIR`-lowered `{ type: "text", props: { value } }`
226
+ * leaf nodes.
227
+ */
228
+ function textOf(children) {
229
+ if (children == null || children === false || children === true)
230
+ return "";
231
+ if (typeof children === "string")
232
+ return children;
233
+ if (typeof children === "number")
234
+ return String(children);
235
+ if (Array.isArray(children))
236
+ return children.map(textOf).join("");
237
+ const node = children;
238
+ if (node && typeof node === "object" && "props" in node) {
239
+ const props = node.props;
240
+ if (node.type === "text" &&
241
+ (typeof props.value === "string" || typeof props.value === "number")) {
242
+ return String(props.value);
243
+ }
244
+ return textOf(props.children);
245
+ }
246
+ return "";
247
+ }
@@ -0,0 +1,2 @@
1
+ export {};
2
+ //# sourceMappingURL=message.test.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"message.test.d.ts","sourceRoot":"","sources":["../../src/render/message.test.ts"],"names":[],"mappings":""}