@moda-labs/bobi-events-core 0.1.0

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 (43) hide show
  1. package/LICENSE +201 -0
  2. package/README.md +32 -0
  3. package/dist/adapters/chat-sdk-slack.d.ts +18 -0
  4. package/dist/adapters/chat-sdk-slack.d.ts.map +1 -0
  5. package/dist/adapters/chat-sdk-slack.js +201 -0
  6. package/dist/adapters/chat-sdk-slack.js.map +1 -0
  7. package/dist/adapters/github.d.ts +3 -0
  8. package/dist/adapters/github.d.ts.map +1 -0
  9. package/dist/adapters/github.js +114 -0
  10. package/dist/adapters/github.js.map +1 -0
  11. package/dist/adapters/linear.d.ts +3 -0
  12. package/dist/adapters/linear.d.ts.map +1 -0
  13. package/dist/adapters/linear.js +47 -0
  14. package/dist/adapters/linear.js.map +1 -0
  15. package/dist/adapters/whatsapp.d.ts +25 -0
  16. package/dist/adapters/whatsapp.d.ts.map +1 -0
  17. package/dist/adapters/whatsapp.js +96 -0
  18. package/dist/adapters/whatsapp.js.map +1 -0
  19. package/dist/channels.d.ts +80 -0
  20. package/dist/channels.d.ts.map +1 -0
  21. package/dist/channels.js +499 -0
  22. package/dist/channels.js.map +1 -0
  23. package/dist/circuit-breaker.d.ts +79 -0
  24. package/dist/circuit-breaker.d.ts.map +1 -0
  25. package/dist/circuit-breaker.js +288 -0
  26. package/dist/circuit-breaker.js.map +1 -0
  27. package/dist/conversation.d.ts +29 -0
  28. package/dist/conversation.d.ts.map +1 -0
  29. package/dist/conversation.js +86 -0
  30. package/dist/conversation.js.map +1 -0
  31. package/dist/core.d.ts +254 -0
  32. package/dist/core.d.ts.map +1 -0
  33. package/dist/core.js +1775 -0
  34. package/dist/core.js.map +1 -0
  35. package/package.json +40 -0
  36. package/src/adapters/chat-sdk-slack.ts +209 -0
  37. package/src/adapters/github.ts +111 -0
  38. package/src/adapters/linear.ts +53 -0
  39. package/src/adapters/whatsapp.ts +120 -0
  40. package/src/channels.ts +600 -0
  41. package/src/circuit-breaker.ts +337 -0
  42. package/src/conversation.ts +96 -0
  43. package/src/core.ts +2413 -0
@@ -0,0 +1,120 @@
1
+ /**
2
+ * WhatsApp (Meta Cloud API) inbound normalizer (#656, epic #190 Phase 3).
3
+ *
4
+ * Hand-rolled: the Cloud API surface used here (webhook parse, text send,
5
+ * media upload) is small enough not to earn the @chat-adapter/whatsapp
6
+ * dependency; re-evaluate it when template messaging is built.
7
+ * Meta POSTs webhook payloads shaped
8
+ * `{object, entry: [{changes: [{field, value}]}]}`; the `messages` field
9
+ * carries user messages, `statuses` carries delivery receipts (skipped).
10
+ *
11
+ * One inbound user message becomes one NormalizedEvent on the global topic
12
+ * `whatsapp:<phone_number_id>` with the reply address
13
+ * `whatsapp:<pnid>:dm:<wa_id>` (the grammar in ../conversation.ts).
14
+ */
15
+ import type { NormalizedEvent } from "../core.js";
16
+ import { buildConversation } from "../conversation.js";
17
+
18
+ export interface WhatsAppNormalization {
19
+ events: NormalizedEvent[];
20
+ }
21
+
22
+ /** Human-readable fallback text for non-text message types. */
23
+ function messageText(msg: Record<string, unknown>): string {
24
+ const type = (msg.type as string) || "unknown";
25
+ if (type === "text") {
26
+ return ((msg.text as Record<string, unknown>)?.body as string) || "";
27
+ }
28
+ // Media messages carry an optional caption; surface it with a type marker
29
+ // so the agent knows a non-text payload arrived.
30
+ const media = msg[type] as Record<string, unknown> | undefined;
31
+ const caption = (media?.caption as string) || "";
32
+ return caption ? `[${type}] ${caption}` : `[${type} message]`;
33
+ }
34
+
35
+ /**
36
+ * Normalize one Meta webhook payload into NormalizedEvents. Never throws on
37
+ * malformed input - unknown shapes yield zero events (the route must still
38
+ * 200 so Meta does not retry forever).
39
+ */
40
+ export function normalizeWhatsAppWebhook(
41
+ payload: Record<string, unknown>,
42
+ ): WhatsAppNormalization {
43
+ const events: NormalizedEvent[] = [];
44
+
45
+ const entries = Array.isArray(payload.entry) ? payload.entry : [];
46
+ for (const entry of entries as Array<Record<string, unknown>>) {
47
+ const changes = Array.isArray(entry?.changes) ? entry.changes : [];
48
+ for (const change of changes as Array<Record<string, unknown>>) {
49
+ if (change?.field !== "messages") continue;
50
+ const value = (change.value as Record<string, unknown>) || {};
51
+ const metadata = (value.metadata as Record<string, unknown>) || {};
52
+ const pnid = (metadata.phone_number_id as string) || "";
53
+ if (!pnid) continue;
54
+
55
+ // Delivery receipts (value.statuses) intentionally produce nothing.
56
+ const messages = Array.isArray(value.messages) ? value.messages : [];
57
+ const contacts = Array.isArray(value.contacts) ? value.contacts : [];
58
+ for (const msg of messages as Array<Record<string, unknown>>) {
59
+ // Runtime type check, not a cast: a numeric `from` would throw
60
+ // on .includes and turn the webhook into a retried 5xx.
61
+ const waId = typeof msg.from === "string" ? msg.from : "";
62
+ if (!waId || waId.includes(":")) continue;
63
+ const text = messageText(msg).slice(0, 4000);
64
+ const contact = (contacts as Array<Record<string, unknown>>).find(
65
+ (c) => (c?.wa_id as string) === waId,
66
+ );
67
+ const profileName =
68
+ ((contact?.profile as Record<string, unknown>)?.name as string) || "";
69
+
70
+ let conversation: string;
71
+ try {
72
+ conversation = buildConversation({
73
+ source: "whatsapp", scope: pnid, chatType: "dm", chatId: waId,
74
+ });
75
+ } catch {
76
+ continue; // an id carrying ":" cannot be addressed - drop, never 500
77
+ }
78
+
79
+ const fields: Record<string, string | number | boolean> = {
80
+ user_id: waId,
81
+ phone_number_id: pnid,
82
+ message_type: (msg.type as string) || "unknown",
83
+ };
84
+ if (msg.id) fields.message_id = String(msg.id);
85
+ if (profileName) fields.profile_name = profileName;
86
+ // Meta stamps each message with unix seconds. Carry it so the
87
+ // 24h-window bookkeeping records the message's own time, not
88
+ // our arrival time - redeliveries can arrive days late.
89
+ const tsSec = Number(msg.timestamp);
90
+ if (Number.isFinite(tsSec) && tsSec > 0) {
91
+ fields.message_timestamp = new Date(tsSec * 1000).toISOString();
92
+ }
93
+
94
+ events.push({
95
+ v: 2,
96
+ // Meta retries deliveries with the same message id; using it as
97
+ // the event id gives downstream consumers a stable dedup key.
98
+ id: (msg.id as string) || crypto.randomUUID(),
99
+ source: "whatsapp",
100
+ type: "whatsapp.message",
101
+ topics: [`whatsapp:${pnid}`],
102
+ delivery: "chat",
103
+ text,
104
+ conversation,
105
+ fields,
106
+ timestamp: new Date().toISOString(),
107
+ payload: {
108
+ user_id: waId,
109
+ phone_number_id: pnid,
110
+ text,
111
+ message_type: (msg.type as string) || "unknown",
112
+ ...(msg.id ? { message_id: String(msg.id) } : {}),
113
+ ...(profileName ? { profile_name: profileName } : {}),
114
+ },
115
+ } as NormalizedEvent);
116
+ }
117
+ }
118
+ }
119
+ return { events };
120
+ }