@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.
- package/LICENSE +21 -0
- package/README.md +236 -0
- package/dist/adapter.d.ts +57 -0
- package/dist/adapter.d.ts.map +1 -0
- package/dist/adapter.js +242 -0
- package/dist/adapter.test.d.ts +2 -0
- package/dist/adapter.test.d.ts.map +1 -0
- package/dist/adapter.test.js +137 -0
- package/dist/built-in-context.d.ts +7 -0
- package/dist/built-in-context.d.ts.map +1 -0
- package/dist/built-in-context.js +19 -0
- package/dist/built-in-context.test.d.ts +2 -0
- package/dist/built-in-context.test.d.ts.map +1 -0
- package/dist/built-in-context.test.js +17 -0
- package/dist/built-in-tools.d.ts +8 -0
- package/dist/built-in-tools.d.ts.map +1 -0
- package/dist/built-in-tools.js +6 -0
- package/dist/client.d.ts +40 -0
- package/dist/client.d.ts.map +1 -0
- package/dist/client.js +108 -0
- package/dist/client.test.d.ts +2 -0
- package/dist/client.test.d.ts.map +1 -0
- package/dist/client.test.js +108 -0
- package/dist/conversation-store.d.ts +23 -0
- package/dist/conversation-store.d.ts.map +1 -0
- package/dist/conversation-store.js +41 -0
- package/dist/conversation-store.test.d.ts +2 -0
- package/dist/conversation-store.test.d.ts.map +1 -0
- package/dist/conversation-store.test.js +40 -0
- package/dist/download-files.d.ts +44 -0
- package/dist/download-files.d.ts.map +1 -0
- package/dist/download-files.js +66 -0
- package/dist/download-files.test.d.ts +2 -0
- package/dist/download-files.test.d.ts.map +1 -0
- package/dist/download-files.test.js +34 -0
- package/dist/event-renderer.d.ts +18 -0
- package/dist/event-renderer.d.ts.map +1 -0
- package/dist/event-renderer.js +110 -0
- package/dist/event-renderer.test.d.ts +2 -0
- package/dist/event-renderer.test.d.ts.map +1 -0
- package/dist/event-renderer.test.js +68 -0
- package/dist/history-store.d.ts +36 -0
- package/dist/history-store.d.ts.map +1 -0
- package/dist/history-store.js +17 -0
- package/dist/history-store.test.d.ts +2 -0
- package/dist/history-store.test.d.ts.map +1 -0
- package/dist/history-store.test.js +24 -0
- package/dist/index.d.ts +18 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +12 -0
- package/dist/interaction.d.ts +19 -0
- package/dist/interaction.d.ts.map +1 -0
- package/dist/interaction.js +56 -0
- package/dist/interaction.test.d.ts +2 -0
- package/dist/interaction.test.d.ts.map +1 -0
- package/dist/interaction.test.js +67 -0
- package/dist/markdown-to-wa.d.ts +15 -0
- package/dist/markdown-to-wa.d.ts.map +1 -0
- package/dist/markdown-to-wa.js +56 -0
- package/dist/markdown-to-wa.test.d.ts +2 -0
- package/dist/markdown-to-wa.test.d.ts.map +1 -0
- package/dist/markdown-to-wa.test.js +29 -0
- package/dist/render/budget.d.ts +34 -0
- package/dist/render/budget.d.ts.map +1 -0
- package/dist/render/budget.js +40 -0
- package/dist/render/budget.test.d.ts +2 -0
- package/dist/render/budget.test.d.ts.map +1 -0
- package/dist/render/budget.test.js +24 -0
- package/dist/render/message.d.ts +74 -0
- package/dist/render/message.d.ts.map +1 -0
- package/dist/render/message.js +247 -0
- package/dist/render/message.test.d.ts +2 -0
- package/dist/render/message.test.d.ts.map +1 -0
- package/dist/render/message.test.js +184 -0
- package/dist/types.d.ts +107 -0
- package/dist/types.d.ts.map +1 -0
- package/dist/types.js +1 -0
- package/dist/webhook-listener.d.ts +16 -0
- package/dist/webhook-listener.d.ts.map +1 -0
- package/dist/webhook-listener.js +128 -0
- package/dist/webhook-listener.test.d.ts +2 -0
- package/dist/webhook-listener.test.d.ts.map +1 -0
- package/dist/webhook-listener.test.js +204 -0
- package/dist/webhook-server.d.ts +23 -0
- package/dist/webhook-server.d.ts.map +1 -0
- package/dist/webhook-server.js +99 -0
- package/dist/webhook-server.test.d.ts +2 -0
- package/dist/webhook-server.test.d.ts.map +1 -0
- package/dist/webhook-server.test.js +150 -0
- 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 @@
|
|
|
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 @@
|
|
|
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 @@
|
|
|
1
|
+
{"version":3,"file":"message.test.d.ts","sourceRoot":"","sources":["../../src/render/message.test.ts"],"names":[],"mappings":""}
|