@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.
Files changed (2) hide show
  1. package/dist/index.js +158 -11
  2. 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, mergeStreams, stream } from "@spectrum-ts/core";
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
- const clients = Object.keys(tokenData.auth).map((phoneNumberId) => {
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) => mergeStreams(clients.map(clientStream));
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;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@spectrum-ts/whatsapp-business",
3
- "version": "12.4.0",
3
+ "version": "12.6.0",
4
4
  "description": "WhatsApp Business provider for spectrum-ts.",
5
5
  "repository": {
6
6
  "type": "git",