@spectrum-ts/whatsapp-business 5.0.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 +21 -0
- package/README.md +22 -0
- package/dist/index.d.ts +26 -0
- package/dist/index.js +666 -0
- package/package.json +43 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License Copyright (c) 2025 Photon AI
|
|
2
|
+
|
|
3
|
+
Permission is hereby granted,
|
|
4
|
+
free of charge, to any person obtaining a copy of this software and associated
|
|
5
|
+
documentation files (the "Software"), to deal in the Software without
|
|
6
|
+
restriction, including without limitation the rights to use, copy, modify, merge,
|
|
7
|
+
publish, distribute, sublicense, and/or sell copies of the Software, and to
|
|
8
|
+
permit persons to whom the Software is furnished to do so, subject to the
|
|
9
|
+
following conditions:
|
|
10
|
+
|
|
11
|
+
The above copyright notice and this permission notice
|
|
12
|
+
(including the next paragraph) shall be included in all copies or substantial
|
|
13
|
+
portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF
|
|
16
|
+
ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
|
|
17
|
+
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO
|
|
18
|
+
EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR
|
|
19
|
+
OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
|
|
20
|
+
FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
|
|
21
|
+
THE SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
# @spectrum-ts/whatsapp-business
|
|
2
|
+
|
|
3
|
+
WhatsApp Business provider for [spectrum-ts](https://github.com/photon-hq/spectrum-ts).
|
|
4
|
+
|
|
5
|
+
## Install
|
|
6
|
+
|
|
7
|
+
```sh
|
|
8
|
+
bun add spectrum-ts @spectrum-ts/whatsapp-business
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
## Use
|
|
12
|
+
|
|
13
|
+
```ts
|
|
14
|
+
import { Spectrum } from "spectrum-ts";
|
|
15
|
+
import { whatsappBusiness } from "@spectrum-ts/whatsapp-business";
|
|
16
|
+
|
|
17
|
+
const spectrum = Spectrum({
|
|
18
|
+
providers: [whatsappBusiness.config({ /* ... */ })],
|
|
19
|
+
});
|
|
20
|
+
```
|
|
21
|
+
|
|
22
|
+
See the [spectrum-ts documentation](https://photon.codes/spectrum) for the full guide.
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import { WhatsAppClient } from "@photon-ai/whatsapp-business";
|
|
2
|
+
import { SchemaMessage } from "@spectrum-ts/core";
|
|
3
|
+
import z from "zod";
|
|
4
|
+
|
|
5
|
+
//#region src/types.d.ts
|
|
6
|
+
type WhatsAppClients = WhatsAppClient[];
|
|
7
|
+
declare const userSchema: z.ZodObject<{}, z.core.$strip>;
|
|
8
|
+
declare const spaceSchema: z.ZodObject<{
|
|
9
|
+
id: z.ZodString;
|
|
10
|
+
}, z.core.$strip>;
|
|
11
|
+
type WhatsAppMessage = SchemaMessage<typeof userSchema, typeof spaceSchema>;
|
|
12
|
+
//#endregion
|
|
13
|
+
//#region src/index.d.ts
|
|
14
|
+
declare const whatsappBusiness: import("@spectrum-ts/core").Platform<import("@spectrum-ts/core").PlatformDef<"WhatsApp Business", import("zod").ZodUnion<readonly [import("zod").ZodObject<{
|
|
15
|
+
accessToken: import("zod").ZodString;
|
|
16
|
+
appSecret: import("zod").ZodOptional<import("zod").ZodString>;
|
|
17
|
+
phoneNumberId: import("zod").ZodString;
|
|
18
|
+
}, import("zod/v4/core").$strip>, import("zod").ZodObject<{}, import("zod/v4/core").$strict>]>, import("zod").ZodType<object, unknown, import("zod/v4/core").$ZodTypeInternals<object, unknown>> | undefined, import("zod").ZodObject<{
|
|
19
|
+
id: import("zod").ZodString;
|
|
20
|
+
}, import("zod/v4/core").$strip>, import("zod").ZodType<object, unknown, import("zod/v4/core").$ZodTypeInternals<object, unknown>> | undefined, WhatsAppClients, {
|
|
21
|
+
id: string;
|
|
22
|
+
}, {
|
|
23
|
+
id: string;
|
|
24
|
+
}, undefined, WhatsAppMessage, undefined, Record<never, never>, Record<never, never>, Record<never, never>>> & Readonly<Record<never, never>>;
|
|
25
|
+
//#endregion
|
|
26
|
+
export { whatsappBusiness };
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,666 @@
|
|
|
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, asPollOption, asReaction, asText, createLogger, errorAttrs } from "@spectrum-ts/core/authoring";
|
|
4
|
+
import { extension } from "mime-types";
|
|
5
|
+
import z from "zod";
|
|
6
|
+
//#region src/auth.ts
|
|
7
|
+
const log = createLogger("spectrum.whatsapp.auth");
|
|
8
|
+
const streamLog = createLogger("spectrum.whatsapp.stream");
|
|
9
|
+
const RENEWAL_RATIO = .8;
|
|
10
|
+
const EXPIRY_BUFFER_MS = 3e4;
|
|
11
|
+
const RETRY_DELAY_MS = 3e4;
|
|
12
|
+
const RESUBSCRIBE_BACKOFF_MS = 500;
|
|
13
|
+
const cloudAuthState = /* @__PURE__ */ new WeakMap();
|
|
14
|
+
async function createCloudClients(projectId, projectSecret) {
|
|
15
|
+
let tokenData = await cloud.issueWhatsappBusinessTokens(projectId, projectSecret);
|
|
16
|
+
let tokenExpiresAt = Date.now() + tokenData.expiresIn * 1e3;
|
|
17
|
+
let disposed = false;
|
|
18
|
+
let renewalTimer;
|
|
19
|
+
let refreshFailures = 0;
|
|
20
|
+
const lines = /* @__PURE__ */ new Map();
|
|
21
|
+
const buildRawClient = (phoneNumberId) => {
|
|
22
|
+
const accessToken = tokenData.auth[phoneNumberId];
|
|
23
|
+
if (!accessToken) throw new Error(`WhatsApp Business line ${phoneNumberId} missing from token response`);
|
|
24
|
+
return createClient({
|
|
25
|
+
accessToken,
|
|
26
|
+
appSecret: "",
|
|
27
|
+
phoneNumberId
|
|
28
|
+
});
|
|
29
|
+
};
|
|
30
|
+
const refreshTokens = async () => {
|
|
31
|
+
tokenData = await cloud.issueWhatsappBusinessTokens(projectId, projectSecret);
|
|
32
|
+
tokenExpiresAt = Date.now() + tokenData.expiresIn * 1e3;
|
|
33
|
+
for (const [phoneNumberId, state] of lines) {
|
|
34
|
+
if (!tokenData.auth[phoneNumberId]) continue;
|
|
35
|
+
const old = state.current;
|
|
36
|
+
state.current = buildRawClient(phoneNumberId);
|
|
37
|
+
for (const sub of state.subscriptions) sub.swap();
|
|
38
|
+
await old.close().catch(() => void 0);
|
|
39
|
+
}
|
|
40
|
+
};
|
|
41
|
+
const onRefreshSuccess = () => {
|
|
42
|
+
if (refreshFailures > 0) {
|
|
43
|
+
log.info("whatsapp token refresh recovered", { "spectrum.whatsapp.auth.attempt": refreshFailures });
|
|
44
|
+
refreshFailures = 0;
|
|
45
|
+
}
|
|
46
|
+
};
|
|
47
|
+
const onRefreshFailure = (error) => {
|
|
48
|
+
refreshFailures += 1;
|
|
49
|
+
log.warn("whatsapp token refresh failed; retrying", {
|
|
50
|
+
"spectrum.whatsapp.auth.attempt": refreshFailures,
|
|
51
|
+
"spectrum.whatsapp.auth.retry_in_ms": RETRY_DELAY_MS,
|
|
52
|
+
...errorAttrs(error)
|
|
53
|
+
}, error);
|
|
54
|
+
};
|
|
55
|
+
const clearRenewalTimer = () => {
|
|
56
|
+
if (renewalTimer !== void 0) {
|
|
57
|
+
clearTimeout(renewalTimer);
|
|
58
|
+
renewalTimer = void 0;
|
|
59
|
+
}
|
|
60
|
+
};
|
|
61
|
+
const scheduleRenewal = () => {
|
|
62
|
+
if (disposed) return;
|
|
63
|
+
clearRenewalTimer();
|
|
64
|
+
const ttlMs = tokenData.expiresIn * 1e3;
|
|
65
|
+
const renewInMs = Math.max(ttlMs * RENEWAL_RATIO, 5e3);
|
|
66
|
+
renewalTimer = setTimeout(async () => {
|
|
67
|
+
try {
|
|
68
|
+
await refreshTokens();
|
|
69
|
+
onRefreshSuccess();
|
|
70
|
+
scheduleRenewal();
|
|
71
|
+
} catch (err) {
|
|
72
|
+
onRefreshFailure(err);
|
|
73
|
+
clearRenewalTimer();
|
|
74
|
+
renewalTimer = setTimeout(() => scheduleRenewal(), RETRY_DELAY_MS);
|
|
75
|
+
renewalTimer?.unref?.();
|
|
76
|
+
}
|
|
77
|
+
}, renewInMs);
|
|
78
|
+
renewalTimer?.unref?.();
|
|
79
|
+
};
|
|
80
|
+
const refreshIfNeeded = async () => {
|
|
81
|
+
if (Date.now() < tokenExpiresAt - EXPIRY_BUFFER_MS) return;
|
|
82
|
+
await refreshTokens();
|
|
83
|
+
onRefreshSuccess();
|
|
84
|
+
scheduleRenewal();
|
|
85
|
+
};
|
|
86
|
+
scheduleRenewal();
|
|
87
|
+
const clients = Object.keys(tokenData.auth).map((phoneNumberId) => {
|
|
88
|
+
const state = {
|
|
89
|
+
current: buildRawClient(phoneNumberId),
|
|
90
|
+
subscriptions: /* @__PURE__ */ new Set()
|
|
91
|
+
};
|
|
92
|
+
lines.set(phoneNumberId, state);
|
|
93
|
+
return buildClientProxy(state, refreshIfNeeded);
|
|
94
|
+
});
|
|
95
|
+
cloudAuthState.set(clients, { dispose: async () => {
|
|
96
|
+
disposed = true;
|
|
97
|
+
clearRenewalTimer();
|
|
98
|
+
for (const state of lines.values()) for (const sub of state.subscriptions) sub.close();
|
|
99
|
+
await Promise.allSettled(Array.from(lines.values()).map((s) => s.current.close()));
|
|
100
|
+
lines.clear();
|
|
101
|
+
} });
|
|
102
|
+
return clients;
|
|
103
|
+
}
|
|
104
|
+
async function disposeCloudAuth(clients) {
|
|
105
|
+
const auth = cloudAuthState.get(clients);
|
|
106
|
+
if (!auth) return;
|
|
107
|
+
await auth.dispose();
|
|
108
|
+
cloudAuthState.delete(clients);
|
|
109
|
+
}
|
|
110
|
+
const buildClientProxy = (state, refresh) => {
|
|
111
|
+
const forwarder = (pick) => new Proxy({}, { get: (_, prop) => async (...args) => {
|
|
112
|
+
await refresh();
|
|
113
|
+
const fn = pick(state.current)[prop];
|
|
114
|
+
return Reflect.apply(fn, pick(state.current), args);
|
|
115
|
+
} });
|
|
116
|
+
return {
|
|
117
|
+
events: {
|
|
118
|
+
fetchMissed: async (opts) => {
|
|
119
|
+
await refresh();
|
|
120
|
+
return state.current.events.fetchMissed(opts);
|
|
121
|
+
},
|
|
122
|
+
subscribe: (options) => resubscribableStream(state, options)
|
|
123
|
+
},
|
|
124
|
+
media: forwarder((c) => c.media),
|
|
125
|
+
messages: forwarder((c) => c.messages),
|
|
126
|
+
close: async () => {
|
|
127
|
+
for (const sub of state.subscriptions) sub.close();
|
|
128
|
+
await state.current.close();
|
|
129
|
+
},
|
|
130
|
+
[Symbol.asyncDispose]: async () => {
|
|
131
|
+
for (const sub of state.subscriptions) sub.close();
|
|
132
|
+
await state.current.close();
|
|
133
|
+
}
|
|
134
|
+
};
|
|
135
|
+
};
|
|
136
|
+
const pumpOnce = async (ctx) => {
|
|
137
|
+
const sub = ctx.getCurrent().events.subscribe(ctx.options);
|
|
138
|
+
ctx.setActive(sub);
|
|
139
|
+
try {
|
|
140
|
+
for await (const event of sub) await ctx.emit(event);
|
|
141
|
+
return true;
|
|
142
|
+
} catch (error) {
|
|
143
|
+
streamLog.warn("whatsapp event stream interrupted; resubscribing", {
|
|
144
|
+
"spectrum.whatsapp.resubscribe_in_ms": RESUBSCRIBE_BACKOFF_MS,
|
|
145
|
+
...errorAttrs(error)
|
|
146
|
+
}, error);
|
|
147
|
+
return false;
|
|
148
|
+
} finally {
|
|
149
|
+
ctx.setActive(void 0);
|
|
150
|
+
}
|
|
151
|
+
};
|
|
152
|
+
const resubscribableStream = (state, options) => {
|
|
153
|
+
let closed = false;
|
|
154
|
+
let active;
|
|
155
|
+
const source = stream((emit, end) => {
|
|
156
|
+
const ctx = {
|
|
157
|
+
emit,
|
|
158
|
+
getCurrent: () => state.current,
|
|
159
|
+
options,
|
|
160
|
+
setActive: (s) => {
|
|
161
|
+
active = s;
|
|
162
|
+
}
|
|
163
|
+
};
|
|
164
|
+
const pump = (async () => {
|
|
165
|
+
while (!closed) {
|
|
166
|
+
await pumpOnce(ctx);
|
|
167
|
+
if (!closed) await new Promise((r) => setTimeout(r, RESUBSCRIBE_BACKOFF_MS));
|
|
168
|
+
}
|
|
169
|
+
end();
|
|
170
|
+
})();
|
|
171
|
+
return async () => {
|
|
172
|
+
closed = true;
|
|
173
|
+
active?.close().catch(() => void 0);
|
|
174
|
+
active = void 0;
|
|
175
|
+
state.subscriptions.delete(subscription);
|
|
176
|
+
await pump;
|
|
177
|
+
};
|
|
178
|
+
});
|
|
179
|
+
const subscription = {
|
|
180
|
+
close: () => {
|
|
181
|
+
closed = true;
|
|
182
|
+
active?.close().catch(() => void 0);
|
|
183
|
+
},
|
|
184
|
+
swap: () => {
|
|
185
|
+
active?.close().catch(() => void 0);
|
|
186
|
+
}
|
|
187
|
+
};
|
|
188
|
+
state.subscriptions.add(subscription);
|
|
189
|
+
return new TypedEventStream(source, async () => {
|
|
190
|
+
closed = true;
|
|
191
|
+
active?.close().catch(() => void 0);
|
|
192
|
+
state.subscriptions.delete(subscription);
|
|
193
|
+
await source.close();
|
|
194
|
+
});
|
|
195
|
+
};
|
|
196
|
+
//#endregion
|
|
197
|
+
//#region src/poll.ts
|
|
198
|
+
const MAX_BUTTON_OPTIONS = 3;
|
|
199
|
+
const LIST_BUTTON_TEXT = "View options";
|
|
200
|
+
const LIST_SECTION_TITLE = "Options";
|
|
201
|
+
const pollOptionId = (index) => `opt_${index}`;
|
|
202
|
+
const pollToInteractive = (content) => {
|
|
203
|
+
if (content.options.length <= MAX_BUTTON_OPTIONS) return buttons(content.title, ...content.options.map((o, i) => button(pollOptionId(i), o.title)));
|
|
204
|
+
return list(content.title, LIST_BUTTON_TEXT).section(LIST_SECTION_TITLE, content.options.map((o, i) => ({
|
|
205
|
+
id: pollOptionId(i),
|
|
206
|
+
title: o.title
|
|
207
|
+
})));
|
|
208
|
+
};
|
|
209
|
+
//#endregion
|
|
210
|
+
//#region src/messages.ts
|
|
211
|
+
const primary = (clients) => {
|
|
212
|
+
const client = clients[0];
|
|
213
|
+
if (!client) throw new Error("No WhatsApp Business client available");
|
|
214
|
+
return client;
|
|
215
|
+
};
|
|
216
|
+
const toRecord = (result, spaceId, content) => ({
|
|
217
|
+
id: result.messageId,
|
|
218
|
+
content,
|
|
219
|
+
space: { id: spaceId },
|
|
220
|
+
timestamp: /* @__PURE__ */ new Date()
|
|
221
|
+
});
|
|
222
|
+
const MAX_POLL_CACHE_SIZE = 1e3;
|
|
223
|
+
const OPTION_ID_PREFIX = "opt_";
|
|
224
|
+
const pollCaches = /* @__PURE__ */ new WeakMap();
|
|
225
|
+
const getPollCache = (client) => {
|
|
226
|
+
let cache = pollCaches.get(client);
|
|
227
|
+
if (!cache) {
|
|
228
|
+
cache = /* @__PURE__ */ new Map();
|
|
229
|
+
pollCaches.set(client, cache);
|
|
230
|
+
}
|
|
231
|
+
return cache;
|
|
232
|
+
};
|
|
233
|
+
const cachePoll = (client, messageId, poll) => {
|
|
234
|
+
const cache = getPollCache(client);
|
|
235
|
+
if (cache.has(messageId)) cache.delete(messageId);
|
|
236
|
+
cache.set(messageId, poll);
|
|
237
|
+
if (cache.size > MAX_POLL_CACHE_SIZE) {
|
|
238
|
+
const first = cache.keys().next().value;
|
|
239
|
+
if (first !== void 0) cache.delete(first);
|
|
240
|
+
}
|
|
241
|
+
};
|
|
242
|
+
const optionIndexFromId = (id) => {
|
|
243
|
+
if (!id.startsWith(OPTION_ID_PREFIX)) return;
|
|
244
|
+
const index = Number(id.slice(4));
|
|
245
|
+
if (!Number.isInteger(index) || index < 0 || pollOptionId(index) !== id) return;
|
|
246
|
+
return index;
|
|
247
|
+
};
|
|
248
|
+
const mapWaPhoneType = (type) => {
|
|
249
|
+
if (!type) return;
|
|
250
|
+
const upper = type.toUpperCase();
|
|
251
|
+
if (upper === "CELL" || upper === "MOBILE" || upper === "IPHONE") return "mobile";
|
|
252
|
+
if (upper === "HOME") return "home";
|
|
253
|
+
if (upper === "WORK" || upper === "BUSINESS") return "work";
|
|
254
|
+
return "other";
|
|
255
|
+
};
|
|
256
|
+
const mapWaSimpleType = (type) => {
|
|
257
|
+
if (!type) return;
|
|
258
|
+
const upper = type.toUpperCase();
|
|
259
|
+
if (upper === "HOME") return "home";
|
|
260
|
+
if (upper === "WORK" || upper === "BUSINESS") return "work";
|
|
261
|
+
return "other";
|
|
262
|
+
};
|
|
263
|
+
const waNameToSpectrum = (name) => {
|
|
264
|
+
const result = { formatted: name.formattedName };
|
|
265
|
+
if (name.firstName) result.first = name.firstName;
|
|
266
|
+
if (name.lastName) result.last = name.lastName;
|
|
267
|
+
if (name.middleName) result.middle = name.middleName;
|
|
268
|
+
if (name.prefix) result.prefix = name.prefix;
|
|
269
|
+
if (name.suffix) result.suffix = name.suffix;
|
|
270
|
+
return result;
|
|
271
|
+
};
|
|
272
|
+
const waPhoneToSpectrum = (phone) => {
|
|
273
|
+
const entry = { value: phone.phone };
|
|
274
|
+
const type = mapWaPhoneType(phone.type);
|
|
275
|
+
if (type) entry.type = type;
|
|
276
|
+
return entry;
|
|
277
|
+
};
|
|
278
|
+
const waEmailToSpectrum = (email) => {
|
|
279
|
+
const entry = { value: email.email };
|
|
280
|
+
const type = mapWaSimpleType(email.type);
|
|
281
|
+
if (type) entry.type = type;
|
|
282
|
+
return entry;
|
|
283
|
+
};
|
|
284
|
+
const waAddressToSpectrum = (address) => {
|
|
285
|
+
const entry = {};
|
|
286
|
+
if (address.street) entry.street = address.street;
|
|
287
|
+
if (address.city) entry.city = address.city;
|
|
288
|
+
if (address.state) entry.region = address.state;
|
|
289
|
+
if (address.zip) entry.postalCode = address.zip;
|
|
290
|
+
if (address.country) entry.country = address.country;
|
|
291
|
+
const type = mapWaSimpleType(address.type);
|
|
292
|
+
if (type) entry.type = type;
|
|
293
|
+
return entry;
|
|
294
|
+
};
|
|
295
|
+
const waOrgToSpectrum = (org) => {
|
|
296
|
+
const entry = {};
|
|
297
|
+
if (org.company) entry.name = org.company;
|
|
298
|
+
if (org.title) entry.title = org.title;
|
|
299
|
+
if (org.department) entry.department = org.department;
|
|
300
|
+
return entry;
|
|
301
|
+
};
|
|
302
|
+
const waContactToSpectrum = (card) => {
|
|
303
|
+
const input = { raw: card };
|
|
304
|
+
input.name = waNameToSpectrum(card.name);
|
|
305
|
+
if (card.phones.length > 0) input.phones = card.phones.map(waPhoneToSpectrum);
|
|
306
|
+
if (card.emails.length > 0) input.emails = card.emails.map(waEmailToSpectrum);
|
|
307
|
+
if (card.addresses.length > 0) input.addresses = card.addresses.map(waAddressToSpectrum);
|
|
308
|
+
if (card.org) input.org = waOrgToSpectrum(card.org);
|
|
309
|
+
if (card.urls.length > 0) input.urls = card.urls.map((u) => u.url);
|
|
310
|
+
if (card.birthday) input.birthday = card.birthday;
|
|
311
|
+
return asContact(input);
|
|
312
|
+
};
|
|
313
|
+
const toMessages = (client, msg) => {
|
|
314
|
+
const base = {
|
|
315
|
+
sender: { id: msg.from },
|
|
316
|
+
space: { id: msg.from },
|
|
317
|
+
timestamp: msg.timestamp
|
|
318
|
+
};
|
|
319
|
+
if (msg.content.type === "contacts") {
|
|
320
|
+
const multi = msg.content.contacts.length > 1;
|
|
321
|
+
return msg.content.contacts.map((card, index) => ({
|
|
322
|
+
...base,
|
|
323
|
+
id: multi ? `${msg.id}:${index}` : msg.id,
|
|
324
|
+
content: waContactToSpectrum(card)
|
|
325
|
+
}));
|
|
326
|
+
}
|
|
327
|
+
return [{
|
|
328
|
+
...base,
|
|
329
|
+
id: msg.id,
|
|
330
|
+
content: mapContent(client, msg)
|
|
331
|
+
}];
|
|
332
|
+
};
|
|
333
|
+
const mapContent = (client, msg) => {
|
|
334
|
+
const { content } = msg;
|
|
335
|
+
switch (content.type) {
|
|
336
|
+
case "text": return asText(content.body);
|
|
337
|
+
case "image":
|
|
338
|
+
case "video":
|
|
339
|
+
case "audio":
|
|
340
|
+
case "document": return lazyMedia(client, content.media);
|
|
341
|
+
case "sticker": return asCustom({
|
|
342
|
+
whatsapp_type: "sticker",
|
|
343
|
+
...content.sticker
|
|
344
|
+
});
|
|
345
|
+
case "location": return asCustom({
|
|
346
|
+
whatsapp_type: "location",
|
|
347
|
+
...content.location
|
|
348
|
+
});
|
|
349
|
+
case "reaction": {
|
|
350
|
+
const stubTarget = {
|
|
351
|
+
id: content.reaction.messageId,
|
|
352
|
+
content: asCustom({
|
|
353
|
+
whatsapp_type: "reaction-target",
|
|
354
|
+
stub: true
|
|
355
|
+
})
|
|
356
|
+
};
|
|
357
|
+
return asReaction({
|
|
358
|
+
emoji: content.reaction.emoji,
|
|
359
|
+
target: stubTarget
|
|
360
|
+
});
|
|
361
|
+
}
|
|
362
|
+
case "interactive": {
|
|
363
|
+
const inter = content.interactive;
|
|
364
|
+
if (inter.type === "button_reply" || inter.type === "list_reply") {
|
|
365
|
+
const poll = msg.context?.id === void 0 ? void 0 : getPollCache(client).get(msg.context.id);
|
|
366
|
+
const optionIndex = optionIndexFromId(inter.reply.id);
|
|
367
|
+
const option = optionIndex === void 0 ? void 0 : poll?.options[optionIndex];
|
|
368
|
+
if (poll && option) return asPollOption({
|
|
369
|
+
poll,
|
|
370
|
+
option,
|
|
371
|
+
selected: true
|
|
372
|
+
});
|
|
373
|
+
}
|
|
374
|
+
return asCustom({
|
|
375
|
+
whatsapp_type: "interactive",
|
|
376
|
+
...inter
|
|
377
|
+
});
|
|
378
|
+
}
|
|
379
|
+
case "button": return asCustom({
|
|
380
|
+
whatsapp_type: "button",
|
|
381
|
+
...content.button
|
|
382
|
+
});
|
|
383
|
+
case "order": return asCustom({
|
|
384
|
+
whatsapp_type: "order",
|
|
385
|
+
...content.order
|
|
386
|
+
});
|
|
387
|
+
case "system": return asCustom({
|
|
388
|
+
whatsapp_type: "system",
|
|
389
|
+
...content.system
|
|
390
|
+
});
|
|
391
|
+
default: return asCustom({ whatsapp_type: "unknown" });
|
|
392
|
+
}
|
|
393
|
+
};
|
|
394
|
+
const fetchMedia = async (client, mediaId) => {
|
|
395
|
+
const { url } = await client.media.getUrl(mediaId);
|
|
396
|
+
const response = await fetch(url);
|
|
397
|
+
if (!response.ok) throw new Error(`Media download failed: ${response.status}`);
|
|
398
|
+
return response;
|
|
399
|
+
};
|
|
400
|
+
const lazyMedia = (client, media) => asAttachment({
|
|
401
|
+
id: media.id,
|
|
402
|
+
name: media.filename ?? `media-${media.id}`,
|
|
403
|
+
mimeType: media.mimeType,
|
|
404
|
+
read: async () => Buffer.from(await (await fetchMedia(client, media.id)).arrayBuffer()),
|
|
405
|
+
stream: async () => {
|
|
406
|
+
const response = await fetchMedia(client, media.id);
|
|
407
|
+
if (!response.body) throw new Error("Media response missing body");
|
|
408
|
+
return response.body;
|
|
409
|
+
}
|
|
410
|
+
});
|
|
411
|
+
const mimeToMediaType = (mimeType) => {
|
|
412
|
+
if (mimeType.startsWith("image/")) return "image";
|
|
413
|
+
if (mimeType.startsWith("video/")) return "video";
|
|
414
|
+
if (mimeType.startsWith("audio/")) return "audio";
|
|
415
|
+
return "document";
|
|
416
|
+
};
|
|
417
|
+
const voiceFilename = (content) => {
|
|
418
|
+
if (content.name) return content.name;
|
|
419
|
+
const ext = extension(content.mimeType);
|
|
420
|
+
return ext ? `voice.${ext}` : "voice";
|
|
421
|
+
};
|
|
422
|
+
const spectrumPhoneTypeToWa = (type) => {
|
|
423
|
+
if (type === "mobile") return "CELL";
|
|
424
|
+
if (type === "home" || type === "work" || type === "other") return type.toUpperCase();
|
|
425
|
+
};
|
|
426
|
+
const spectrumSimpleTypeToWa = (type) => type ? type.toUpperCase() : void 0;
|
|
427
|
+
const spectrumNameToWa = (name) => ({
|
|
428
|
+
formattedName: name?.formatted ?? ([
|
|
429
|
+
name?.first,
|
|
430
|
+
name?.middle,
|
|
431
|
+
name?.last
|
|
432
|
+
].filter((p) => Boolean(p)).join(" ") || "Unknown"),
|
|
433
|
+
firstName: name?.first,
|
|
434
|
+
lastName: name?.last,
|
|
435
|
+
middleName: name?.middle,
|
|
436
|
+
prefix: name?.prefix,
|
|
437
|
+
suffix: name?.suffix
|
|
438
|
+
});
|
|
439
|
+
const isWhatsAppContactCard = (value) => {
|
|
440
|
+
if (!value || typeof value !== "object") return false;
|
|
441
|
+
const raw = value;
|
|
442
|
+
const name = raw.name;
|
|
443
|
+
if (!name || typeof name !== "object" || typeof name.formattedName !== "string") return false;
|
|
444
|
+
return Array.isArray(raw.phones) && Array.isArray(raw.emails) && Array.isArray(raw.addresses) && Array.isArray(raw.urls);
|
|
445
|
+
};
|
|
446
|
+
const contactToWa = (contact) => {
|
|
447
|
+
if (isWhatsAppContactCard(contact.raw)) return contact.raw;
|
|
448
|
+
return {
|
|
449
|
+
name: spectrumNameToWa(contact.name),
|
|
450
|
+
phones: (contact.phones ?? []).map((p) => ({
|
|
451
|
+
phone: p.value,
|
|
452
|
+
type: spectrumPhoneTypeToWa(p.type)
|
|
453
|
+
})),
|
|
454
|
+
emails: (contact.emails ?? []).map((e) => ({
|
|
455
|
+
email: e.value,
|
|
456
|
+
type: spectrumSimpleTypeToWa(e.type)
|
|
457
|
+
})),
|
|
458
|
+
addresses: (contact.addresses ?? []).map((a) => ({
|
|
459
|
+
street: a.street,
|
|
460
|
+
city: a.city,
|
|
461
|
+
state: a.region,
|
|
462
|
+
zip: a.postalCode,
|
|
463
|
+
country: a.country,
|
|
464
|
+
type: spectrumSimpleTypeToWa(a.type)
|
|
465
|
+
})),
|
|
466
|
+
urls: (contact.urls ?? []).map((url) => ({ url })),
|
|
467
|
+
org: contact.org?.name || contact.org?.department || contact.org?.title ? {
|
|
468
|
+
company: contact.org.name,
|
|
469
|
+
department: contact.org.department,
|
|
470
|
+
title: contact.org.title
|
|
471
|
+
} : void 0,
|
|
472
|
+
birthday: contact.birthday
|
|
473
|
+
};
|
|
474
|
+
};
|
|
475
|
+
const clientStream = (client) => {
|
|
476
|
+
const eventStream = client.events.subscribe().filter((e) => e.type === "message");
|
|
477
|
+
return stream((emit, end) => {
|
|
478
|
+
const pump = (async () => {
|
|
479
|
+
try {
|
|
480
|
+
for await (const event of eventStream) for (const m of toMessages(client, event.message)) await emit(m);
|
|
481
|
+
end();
|
|
482
|
+
} catch (e) {
|
|
483
|
+
end(e);
|
|
484
|
+
}
|
|
485
|
+
})();
|
|
486
|
+
return async () => {
|
|
487
|
+
await eventStream.close();
|
|
488
|
+
await pump;
|
|
489
|
+
};
|
|
490
|
+
});
|
|
491
|
+
};
|
|
492
|
+
const messages = (clients) => mergeStreams(clients.map(clientStream));
|
|
493
|
+
const send = async (clients, spaceId, content) => {
|
|
494
|
+
if (content.type === "reply") return await replyToMessage(clients, spaceId, content.target.id, content.content);
|
|
495
|
+
if (content.type === "reaction") return await reactToMessage(clients, spaceId, content);
|
|
496
|
+
if (content.type === "typing") return;
|
|
497
|
+
if (content.type === "read") {
|
|
498
|
+
await primary(clients).messages.markRead(content.target.id);
|
|
499
|
+
return;
|
|
500
|
+
}
|
|
501
|
+
const client = primary(clients);
|
|
502
|
+
switch (content.type) {
|
|
503
|
+
case "text": return toRecord(await client.messages.send({
|
|
504
|
+
to: spaceId,
|
|
505
|
+
text: content.text
|
|
506
|
+
}), spaceId, content);
|
|
507
|
+
case "attachment": {
|
|
508
|
+
const { mediaId } = await client.media.upload({
|
|
509
|
+
file: await content.read(),
|
|
510
|
+
mimeType: content.mimeType,
|
|
511
|
+
filename: content.name
|
|
512
|
+
});
|
|
513
|
+
const mediaType = mimeToMediaType(content.mimeType);
|
|
514
|
+
const mediaPayload = mediaType === "document" ? {
|
|
515
|
+
id: mediaId,
|
|
516
|
+
filename: content.name
|
|
517
|
+
} : { id: mediaId };
|
|
518
|
+
return toRecord(await client.messages.send({
|
|
519
|
+
to: spaceId,
|
|
520
|
+
[mediaType]: mediaPayload
|
|
521
|
+
}), spaceId, content);
|
|
522
|
+
}
|
|
523
|
+
case "contact": return toRecord(await client.messages.send({
|
|
524
|
+
to: spaceId,
|
|
525
|
+
contacts: [contactToWa(content)]
|
|
526
|
+
}), spaceId, content);
|
|
527
|
+
case "voice": {
|
|
528
|
+
const { mediaId } = await client.media.upload({
|
|
529
|
+
file: await content.read(),
|
|
530
|
+
mimeType: content.mimeType,
|
|
531
|
+
filename: voiceFilename(content)
|
|
532
|
+
});
|
|
533
|
+
return toRecord(await client.messages.send({
|
|
534
|
+
to: spaceId,
|
|
535
|
+
audio: { id: mediaId }
|
|
536
|
+
}), spaceId, content);
|
|
537
|
+
}
|
|
538
|
+
case "poll": {
|
|
539
|
+
const result = await client.messages.send({
|
|
540
|
+
to: spaceId,
|
|
541
|
+
interactive: pollToInteractive(content)
|
|
542
|
+
});
|
|
543
|
+
cachePoll(client, result.messageId, content);
|
|
544
|
+
return toRecord(result, spaceId, content);
|
|
545
|
+
}
|
|
546
|
+
case "app": return toRecord(await client.messages.send({
|
|
547
|
+
to: spaceId,
|
|
548
|
+
text: await content.url()
|
|
549
|
+
}), spaceId, content);
|
|
550
|
+
default: throw UnsupportedError.content(content.type);
|
|
551
|
+
}
|
|
552
|
+
};
|
|
553
|
+
const reactToMessage = async (clients, spaceId, content) => {
|
|
554
|
+
return toRecord(await primary(clients).messages.send({
|
|
555
|
+
to: spaceId,
|
|
556
|
+
reaction: {
|
|
557
|
+
messageId: content.target.id,
|
|
558
|
+
emoji: content.emoji
|
|
559
|
+
}
|
|
560
|
+
}), spaceId, content);
|
|
561
|
+
};
|
|
562
|
+
const replyToMessage = async (clients, spaceId, messageId, content) => {
|
|
563
|
+
const client = primary(clients);
|
|
564
|
+
switch (content.type) {
|
|
565
|
+
case "text": return toRecord(await client.messages.send({
|
|
566
|
+
to: spaceId,
|
|
567
|
+
replyTo: messageId,
|
|
568
|
+
text: content.text
|
|
569
|
+
}), spaceId, content);
|
|
570
|
+
case "attachment": {
|
|
571
|
+
const { mediaId } = await client.media.upload({
|
|
572
|
+
file: await content.read(),
|
|
573
|
+
mimeType: content.mimeType,
|
|
574
|
+
filename: content.name
|
|
575
|
+
});
|
|
576
|
+
const mediaType = mimeToMediaType(content.mimeType);
|
|
577
|
+
const mediaPayload = mediaType === "document" ? {
|
|
578
|
+
id: mediaId,
|
|
579
|
+
filename: content.name
|
|
580
|
+
} : { id: mediaId };
|
|
581
|
+
return toRecord(await client.messages.send({
|
|
582
|
+
to: spaceId,
|
|
583
|
+
replyTo: messageId,
|
|
584
|
+
[mediaType]: mediaPayload
|
|
585
|
+
}), spaceId, content);
|
|
586
|
+
}
|
|
587
|
+
case "contact": return toRecord(await client.messages.send({
|
|
588
|
+
to: spaceId,
|
|
589
|
+
replyTo: messageId,
|
|
590
|
+
contacts: [contactToWa(content)]
|
|
591
|
+
}), spaceId, content);
|
|
592
|
+
case "voice": {
|
|
593
|
+
const { mediaId } = await client.media.upload({
|
|
594
|
+
file: await content.read(),
|
|
595
|
+
mimeType: content.mimeType,
|
|
596
|
+
filename: voiceFilename(content)
|
|
597
|
+
});
|
|
598
|
+
return toRecord(await client.messages.send({
|
|
599
|
+
to: spaceId,
|
|
600
|
+
replyTo: messageId,
|
|
601
|
+
audio: { id: mediaId }
|
|
602
|
+
}), spaceId, content);
|
|
603
|
+
}
|
|
604
|
+
case "poll": {
|
|
605
|
+
const result = await client.messages.send({
|
|
606
|
+
to: spaceId,
|
|
607
|
+
replyTo: messageId,
|
|
608
|
+
interactive: pollToInteractive(content)
|
|
609
|
+
});
|
|
610
|
+
cachePoll(client, result.messageId, content);
|
|
611
|
+
return toRecord(result, spaceId, content);
|
|
612
|
+
}
|
|
613
|
+
case "app": return toRecord(await client.messages.send({
|
|
614
|
+
to: spaceId,
|
|
615
|
+
replyTo: messageId,
|
|
616
|
+
text: await content.url()
|
|
617
|
+
}), spaceId, content);
|
|
618
|
+
default: throw UnsupportedError.content(content.type);
|
|
619
|
+
}
|
|
620
|
+
};
|
|
621
|
+
//#endregion
|
|
622
|
+
//#region src/types.ts
|
|
623
|
+
const directConfig = z.object({
|
|
624
|
+
accessToken: z.string().min(1),
|
|
625
|
+
appSecret: z.string().optional(),
|
|
626
|
+
phoneNumberId: z.string().min(1)
|
|
627
|
+
});
|
|
628
|
+
const cloudConfig = z.object({}).strict();
|
|
629
|
+
const configSchema = z.union([directConfig, cloudConfig]);
|
|
630
|
+
const isCloudConfig = (config) => !("accessToken" in config);
|
|
631
|
+
z.object({});
|
|
632
|
+
//#endregion
|
|
633
|
+
//#region src/index.ts
|
|
634
|
+
const whatsappBusiness = definePlatform("WhatsApp Business", {
|
|
635
|
+
config: configSchema,
|
|
636
|
+
lifecycle: {
|
|
637
|
+
createClient: async ({ config, projectId, projectSecret }) => {
|
|
638
|
+
if (!isCloudConfig(config)) return [createClient({
|
|
639
|
+
accessToken: config.accessToken,
|
|
640
|
+
appSecret: config.appSecret ?? "",
|
|
641
|
+
phoneNumberId: config.phoneNumberId
|
|
642
|
+
})];
|
|
643
|
+
if (!(projectId && projectSecret)) throw new Error("WhatsApp Business cloud mode requires projectId and projectSecret. Either pass credentials to Spectrum(), or provide direct credentials: whatsappBusiness.config({ accessToken, phoneNumberId })");
|
|
644
|
+
return await createCloudClients(projectId, projectSecret);
|
|
645
|
+
},
|
|
646
|
+
destroyClient: async ({ client }) => {
|
|
647
|
+
await disposeCloudAuth(client);
|
|
648
|
+
await Promise.allSettled(client.map((c) => c.close()));
|
|
649
|
+
}
|
|
650
|
+
},
|
|
651
|
+
user: { resolve: async ({ input }) => ({ id: input.userID }) },
|
|
652
|
+
space: {
|
|
653
|
+
schema: z.object({ id: z.string() }),
|
|
654
|
+
create: async ({ input }) => {
|
|
655
|
+
if (input.users.length === 0) throw new Error("WhatsApp space creation requires at least one user");
|
|
656
|
+
if (input.users.length > 1) throw UnsupportedError.action("space.create", "WhatsApp Business", "only 1:1 conversations are supported");
|
|
657
|
+
const user = input.users[0];
|
|
658
|
+
if (!user) throw new Error("WhatsApp space creation requires a user");
|
|
659
|
+
return { id: user.id };
|
|
660
|
+
}
|
|
661
|
+
},
|
|
662
|
+
messages: ({ client }) => messages(client),
|
|
663
|
+
send: async ({ space, content, client }) => await send(client, space.id, content)
|
|
664
|
+
});
|
|
665
|
+
//#endregion
|
|
666
|
+
export { whatsappBusiness };
|
package/package.json
ADDED
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@spectrum-ts/whatsapp-business",
|
|
3
|
+
"version": "5.0.0",
|
|
4
|
+
"description": "WhatsApp Business provider for spectrum-ts.",
|
|
5
|
+
"repository": {
|
|
6
|
+
"type": "git",
|
|
7
|
+
"url": "git+https://github.com/photon-hq/spectrum-ts.git",
|
|
8
|
+
"directory": "packages/whatsapp-business"
|
|
9
|
+
},
|
|
10
|
+
"homepage": "https://photon.codes/spectrum",
|
|
11
|
+
"bugs": {
|
|
12
|
+
"url": "https://github.com/photon-hq/spectrum-ts/issues"
|
|
13
|
+
},
|
|
14
|
+
"type": "module",
|
|
15
|
+
"sideEffects": false,
|
|
16
|
+
"files": [
|
|
17
|
+
"dist"
|
|
18
|
+
],
|
|
19
|
+
"exports": {
|
|
20
|
+
".": {
|
|
21
|
+
"types": "./dist/index.d.ts",
|
|
22
|
+
"default": "./dist/index.js"
|
|
23
|
+
}
|
|
24
|
+
},
|
|
25
|
+
"publishConfig": {
|
|
26
|
+
"access": "public"
|
|
27
|
+
},
|
|
28
|
+
"spectrum": {
|
|
29
|
+
"key": "whatsapp-business",
|
|
30
|
+
"import": "whatsappBusiness",
|
|
31
|
+
"label": "WhatsApp Business"
|
|
32
|
+
},
|
|
33
|
+
"dependencies": {
|
|
34
|
+
"@photon-ai/whatsapp-business": "^0.1.1",
|
|
35
|
+
"mime-types": "^3.0.1",
|
|
36
|
+
"zod": "^4.2.1"
|
|
37
|
+
},
|
|
38
|
+
"peerDependencies": {
|
|
39
|
+
"@spectrum-ts/core": "^5.0.0",
|
|
40
|
+
"typescript": "^5 || ^6.0.0"
|
|
41
|
+
},
|
|
42
|
+
"license": "MIT"
|
|
43
|
+
}
|