@spectrum-ts/whatsapp-business 12.4.0 → 12.6.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/dist/index.js +158 -11
- package/package.json +1 -1
package/dist/index.js
CHANGED
|
@@ -1,16 +1,86 @@
|
|
|
1
1
|
import { TypedEventStream, button, buttons, createClient, list } from "@photon-ai/whatsapp-business";
|
|
2
|
-
import { UnsupportedError, cloud, definePlatform,
|
|
3
|
-
import { asAttachment, asContact, asCustom, asGroup, asPollOption, asReaction, asReply, asText, asUnsend, asVoice, createLogger, createTokenRenewal, errorAttrs, tracedFetch } from "@spectrum-ts/core/authoring";
|
|
2
|
+
import { UnsupportedError, cloud, definePlatform, stream } from "@spectrum-ts/core";
|
|
3
|
+
import { asAttachment, asContact, asCustom, asGroup, asPollOption, asReaction, asReply, asText, asUnsend, asVoice, createLogger, createStreamGroup, createTokenRenewal, errorAttrs, tracedFetch } from "@spectrum-ts/core/authoring";
|
|
4
4
|
import { extension } from "mime-types";
|
|
5
5
|
import z from "zod";
|
|
6
|
+
//#region src/lines.ts
|
|
7
|
+
const linesLog = createLogger("spectrum.whatsapp.lines");
|
|
8
|
+
const observers = /* @__PURE__ */ new WeakMap();
|
|
9
|
+
const lineIds = /* @__PURE__ */ new WeakMap();
|
|
10
|
+
let fallbackKeys = 0;
|
|
11
|
+
/** Pairs a client with the phone number id it was built for. */
|
|
12
|
+
const setLineId = (client, phoneNumberId) => {
|
|
13
|
+
lineIds.set(client, phoneNumberId);
|
|
14
|
+
};
|
|
15
|
+
/**
|
|
16
|
+
* Stable per-client key. Falls back to a generated id for directly-configured
|
|
17
|
+
* clients, which are not built from a token payload and carry no line id.
|
|
18
|
+
*/
|
|
19
|
+
const lineKey = (client) => {
|
|
20
|
+
const existing = lineIds.get(client);
|
|
21
|
+
if (existing) return existing;
|
|
22
|
+
fallbackKeys += 1;
|
|
23
|
+
const generated = `line-${fallbackKeys}`;
|
|
24
|
+
lineIds.set(client, generated);
|
|
25
|
+
return generated;
|
|
26
|
+
};
|
|
27
|
+
/**
|
|
28
|
+
* Registers `observer` and returns a disposer that removes just this one, so a
|
|
29
|
+
* closed message stream stops being called into. `clearLineObservers` remains
|
|
30
|
+
* the whole-array teardown used when the client itself is destroyed.
|
|
31
|
+
*/
|
|
32
|
+
const addLineObserver = (clients, observer) => {
|
|
33
|
+
const existing = observers.get(clients);
|
|
34
|
+
if (existing) existing.add(observer);
|
|
35
|
+
else observers.set(clients, new Set([observer]));
|
|
36
|
+
return () => {
|
|
37
|
+
const current = observers.get(clients);
|
|
38
|
+
if (!current?.delete(observer)) return;
|
|
39
|
+
if (current.size === 0) observers.delete(clients);
|
|
40
|
+
};
|
|
41
|
+
};
|
|
42
|
+
const clearLineObservers = (clients) => {
|
|
43
|
+
observers.delete(clients);
|
|
44
|
+
};
|
|
45
|
+
/**
|
|
46
|
+
* Synchronous by contract: the reconcile calls this immediately after pushing
|
|
47
|
+
* the client, with no await in between, so an observer can never see a
|
|
48
|
+
* half-applied array. A throwing observer is contained — it must not be able to
|
|
49
|
+
* reject the token refresh, which would stall renewal for every line.
|
|
50
|
+
*/
|
|
51
|
+
const notifyLineAttached = (clients, client) => {
|
|
52
|
+
for (const observer of observers.get(clients) ?? []) try {
|
|
53
|
+
observer.attach(client);
|
|
54
|
+
} catch (error) {
|
|
55
|
+
linesLog.warn("whatsapp line observer failed to attach", errorAttrs(error), error instanceof Error ? error : void 0);
|
|
56
|
+
}
|
|
57
|
+
};
|
|
58
|
+
/**
|
|
59
|
+
* Returns each observer's detach promise so the caller can settle them off the
|
|
60
|
+
* refresh path — a wedged stream close must not stall token renewal.
|
|
61
|
+
*/
|
|
62
|
+
const notifyLineDetached = (clients, client) => {
|
|
63
|
+
const pending = [];
|
|
64
|
+
for (const observer of observers.get(clients) ?? []) try {
|
|
65
|
+
pending.push(Promise.resolve(observer.detach(client)));
|
|
66
|
+
} catch (error) {
|
|
67
|
+
linesLog.warn("whatsapp line observer failed to detach", errorAttrs(error), error instanceof Error ? error : void 0);
|
|
68
|
+
}
|
|
69
|
+
return pending;
|
|
70
|
+
};
|
|
71
|
+
//#endregion
|
|
6
72
|
//#region src/auth.ts
|
|
7
73
|
const streamLog$1 = createLogger("spectrum.whatsapp.stream");
|
|
74
|
+
const authLog = createLogger("spectrum.whatsapp.auth");
|
|
8
75
|
const RESUBSCRIBE_BACKOFF_MS = 500;
|
|
76
|
+
const lineAttrs = (phoneNumberId) => ({ "spectrum.whatsapp.line": phoneNumberId });
|
|
9
77
|
const ignoreCleanupError = () => void 0;
|
|
10
78
|
const cloudAuthState = /* @__PURE__ */ new WeakMap();
|
|
11
79
|
async function createCloudClients(projectId, projectSecret) {
|
|
12
80
|
let tokenData = await cloud.issueWhatsappBusinessTokens(projectId, projectSecret);
|
|
13
81
|
const lines = /* @__PURE__ */ new Map();
|
|
82
|
+
const clients = [];
|
|
83
|
+
const records = /* @__PURE__ */ new Map();
|
|
14
84
|
const buildRawClient = (phoneNumberId) => {
|
|
15
85
|
const accessToken = tokenData.auth[phoneNumberId];
|
|
16
86
|
if (!accessToken) throw new Error(`WhatsApp Business line ${phoneNumberId} missing from token response`);
|
|
@@ -20,6 +90,70 @@ async function createCloudClients(projectId, projectSecret) {
|
|
|
20
90
|
phoneNumberId
|
|
21
91
|
});
|
|
22
92
|
};
|
|
93
|
+
const attachLine = (phoneNumberId) => {
|
|
94
|
+
const state = {
|
|
95
|
+
current: buildRawClient(phoneNumberId),
|
|
96
|
+
subscriptions: /* @__PURE__ */ new Set()
|
|
97
|
+
};
|
|
98
|
+
lines.set(phoneNumberId, state);
|
|
99
|
+
const proxy = buildClientProxy(state, renewal.refreshIfNeeded);
|
|
100
|
+
setLineId(proxy, phoneNumberId);
|
|
101
|
+
records.set(phoneNumberId, proxy);
|
|
102
|
+
clients.push(proxy);
|
|
103
|
+
notifyLineAttached(clients, proxy);
|
|
104
|
+
};
|
|
105
|
+
const retire = async (proxy) => {
|
|
106
|
+
await Promise.allSettled(notifyLineDetached(clients, proxy));
|
|
107
|
+
await proxy.close();
|
|
108
|
+
};
|
|
109
|
+
const removeMissing = (auth) => {
|
|
110
|
+
let removed = 0;
|
|
111
|
+
for (const [phoneNumberId, proxy] of records) {
|
|
112
|
+
if (auth[phoneNumberId]) continue;
|
|
113
|
+
records.delete(phoneNumberId);
|
|
114
|
+
lines.delete(phoneNumberId);
|
|
115
|
+
const index = clients.indexOf(proxy);
|
|
116
|
+
if (index >= 0) clients.splice(index, 1);
|
|
117
|
+
removed += 1;
|
|
118
|
+
retire(proxy).catch((error) => {
|
|
119
|
+
authLog.warn("failed to retire whatsapp line", {
|
|
120
|
+
...lineAttrs(phoneNumberId),
|
|
121
|
+
...errorAttrs(error)
|
|
122
|
+
}, error instanceof Error ? error : void 0);
|
|
123
|
+
});
|
|
124
|
+
}
|
|
125
|
+
return removed;
|
|
126
|
+
};
|
|
127
|
+
/**
|
|
128
|
+
* Brings the client set in line with the token payload, which is the only
|
|
129
|
+
* line inventory the cloud exposes: keys present but untracked are newly
|
|
130
|
+
* provisioned, tracked keys that vanished were deprovisioned.
|
|
131
|
+
*
|
|
132
|
+
* An empty payload means the project has no lines, not that the response is
|
|
133
|
+
* suspect — keeping entries the payload no longer covers would leave the
|
|
134
|
+
* client routing through lines whose tokens have stopped being refreshed. A
|
|
135
|
+
* genuinely malformed payload (no `auth` at all) throws instead, which the
|
|
136
|
+
* caller contains before any line is removed.
|
|
137
|
+
*/
|
|
138
|
+
const reconcile = (auth) => {
|
|
139
|
+
const next = Object.keys(auth);
|
|
140
|
+
const removed = removeMissing(auth);
|
|
141
|
+
let added = 0;
|
|
142
|
+
for (const phoneNumberId of next) {
|
|
143
|
+
const existing = records.get(phoneNumberId);
|
|
144
|
+
if (existing) {
|
|
145
|
+
notifyLineAttached(clients, existing);
|
|
146
|
+
continue;
|
|
147
|
+
}
|
|
148
|
+
attachLine(phoneNumberId);
|
|
149
|
+
added += 1;
|
|
150
|
+
}
|
|
151
|
+
if (added > 0 || removed > 0) authLog.info("whatsapp lines reconciled", {
|
|
152
|
+
"spectrum.whatsapp.lines.added": added,
|
|
153
|
+
"spectrum.whatsapp.lines.removed": removed,
|
|
154
|
+
"spectrum.whatsapp.lines.total": clients.length
|
|
155
|
+
});
|
|
156
|
+
};
|
|
23
157
|
const refreshTokens = async () => {
|
|
24
158
|
tokenData = await cloud.issueWhatsappBusinessTokens(projectId, projectSecret);
|
|
25
159
|
for (const [phoneNumberId, state] of lines) {
|
|
@@ -29,20 +163,18 @@ async function createCloudClients(projectId, projectSecret) {
|
|
|
29
163
|
for (const sub of state.subscriptions) sub.swap();
|
|
30
164
|
await old.close().catch(() => void 0);
|
|
31
165
|
}
|
|
166
|
+
try {
|
|
167
|
+
reconcile(tokenData.auth);
|
|
168
|
+
} catch (error) {
|
|
169
|
+
authLog.error("whatsapp line reconcile failed", errorAttrs(error), error instanceof Error ? error : void 0);
|
|
170
|
+
}
|
|
32
171
|
};
|
|
33
172
|
const renewal = createTokenRenewal({
|
|
34
173
|
expiresInSeconds: () => tokenData.expiresIn,
|
|
35
174
|
name: "whatsapp",
|
|
36
175
|
refresh: refreshTokens
|
|
37
176
|
});
|
|
38
|
-
|
|
39
|
-
const state = {
|
|
40
|
-
current: buildRawClient(phoneNumberId),
|
|
41
|
-
subscriptions: /* @__PURE__ */ new Set()
|
|
42
|
-
};
|
|
43
|
-
lines.set(phoneNumberId, state);
|
|
44
|
-
return buildClientProxy(state, renewal.refreshIfNeeded);
|
|
45
|
-
});
|
|
177
|
+
reconcile(tokenData.auth);
|
|
46
178
|
cloudAuthState.set(clients, { dispose: async () => {
|
|
47
179
|
renewal.dispose();
|
|
48
180
|
for (const state of lines.values()) for (const sub of state.subscriptions) sub.close();
|
|
@@ -52,6 +184,7 @@ async function createCloudClients(projectId, projectSecret) {
|
|
|
52
184
|
return clients;
|
|
53
185
|
}
|
|
54
186
|
async function disposeCloudAuth(clients) {
|
|
187
|
+
clearLineObservers(clients);
|
|
55
188
|
const auth = cloudAuthState.get(clients);
|
|
56
189
|
if (!auth) return;
|
|
57
190
|
await auth.dispose();
|
|
@@ -634,7 +767,21 @@ const clientStream = (client) => {
|
|
|
634
767
|
};
|
|
635
768
|
});
|
|
636
769
|
};
|
|
637
|
-
const messages = (clients) =>
|
|
770
|
+
const messages = (clients) => {
|
|
771
|
+
const group = createStreamGroup({ label: "whatsapp.messages" });
|
|
772
|
+
for (const client of clients) group.add(lineKey(client), () => clientStream(client));
|
|
773
|
+
const disposeObserver = addLineObserver(clients, {
|
|
774
|
+
attach: (client) => {
|
|
775
|
+
group.add(lineKey(client), () => clientStream(client));
|
|
776
|
+
},
|
|
777
|
+
detach: (client) => group.remove(lineKey(client)).then(() => void 0)
|
|
778
|
+
});
|
|
779
|
+
const closeGroup = group.close.bind(group);
|
|
780
|
+
return Object.assign(group, { close: async () => {
|
|
781
|
+
disposeObserver();
|
|
782
|
+
await closeGroup();
|
|
783
|
+
} });
|
|
784
|
+
};
|
|
638
785
|
const MAX_CAPTION_LENGTH = 1024;
|
|
639
786
|
const captionablePair = (group) => {
|
|
640
787
|
if (group.items.length !== 2) return;
|