@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.
- package/LICENSE +201 -0
- package/README.md +32 -0
- package/dist/adapters/chat-sdk-slack.d.ts +18 -0
- package/dist/adapters/chat-sdk-slack.d.ts.map +1 -0
- package/dist/adapters/chat-sdk-slack.js +201 -0
- package/dist/adapters/chat-sdk-slack.js.map +1 -0
- package/dist/adapters/github.d.ts +3 -0
- package/dist/adapters/github.d.ts.map +1 -0
- package/dist/adapters/github.js +114 -0
- package/dist/adapters/github.js.map +1 -0
- package/dist/adapters/linear.d.ts +3 -0
- package/dist/adapters/linear.d.ts.map +1 -0
- package/dist/adapters/linear.js +47 -0
- package/dist/adapters/linear.js.map +1 -0
- package/dist/adapters/whatsapp.d.ts +25 -0
- package/dist/adapters/whatsapp.d.ts.map +1 -0
- package/dist/adapters/whatsapp.js +96 -0
- package/dist/adapters/whatsapp.js.map +1 -0
- package/dist/channels.d.ts +80 -0
- package/dist/channels.d.ts.map +1 -0
- package/dist/channels.js +499 -0
- package/dist/channels.js.map +1 -0
- package/dist/circuit-breaker.d.ts +79 -0
- package/dist/circuit-breaker.d.ts.map +1 -0
- package/dist/circuit-breaker.js +288 -0
- package/dist/circuit-breaker.js.map +1 -0
- package/dist/conversation.d.ts +29 -0
- package/dist/conversation.d.ts.map +1 -0
- package/dist/conversation.js +86 -0
- package/dist/conversation.js.map +1 -0
- package/dist/core.d.ts +254 -0
- package/dist/core.d.ts.map +1 -0
- package/dist/core.js +1775 -0
- package/dist/core.js.map +1 -0
- package/package.json +40 -0
- package/src/adapters/chat-sdk-slack.ts +209 -0
- package/src/adapters/github.ts +111 -0
- package/src/adapters/linear.ts +53 -0
- package/src/adapters/whatsapp.ts +120 -0
- package/src/channels.ts +600 -0
- package/src/circuit-breaker.ts +337 -0
- package/src/conversation.ts +96 -0
- 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
|
+
}
|