@spectrum-ts/core 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 +84 -0
- package/dist/attachment-a_lrhg6w.d.ts +7558 -0
- package/dist/authoring.d.ts +94 -0
- package/dist/authoring.js +389 -0
- package/dist/elysia.d.ts +92 -0
- package/dist/elysia.js +32 -0
- package/dist/express.d.ts +60 -0
- package/dist/express.js +37 -0
- package/dist/hono.d.ts +59 -0
- package/dist/hono.js +27 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.js +4529 -0
- package/dist/stream-BLWs7NJ5.js +1489 -0
- package/package.json +78 -0
package/dist/index.js
ADDED
|
@@ -0,0 +1,4529 @@
|
|
|
1
|
+
import { A as resolveContents, B as fromVCard, C as poll, F as drainStreamText, G as attachment, H as buildPhotoAction, I as asCustom, J as fetchLinkMetadata, L as custom, M as text, O as group, P as StreamConsumedError, R as asContact, S as option, T as markdown, U as photoActionSchema, V as toVCard, W as asAttachment, a as contentAttrs, c as markdownToPlainText, f as voice, g as read, i as stream, j as asText, m as richlink, n as createAsyncQueue, o as errorAttrs, p as asRichlink, q as fetchImage, r as mergeStreams, s as senderAttrs, t as broadcast, u as classifyIdentifier, v as reaction, w as asMarkdown, z as contact } from "./stream-BLWs7NJ5.js";
|
|
2
|
+
import z from "zod";
|
|
3
|
+
import { createHmac, timingSafeEqual } from "node:crypto";
|
|
4
|
+
import { createLogger, setLogLevel, setupOtel, withSpan } from "@photon-ai/otel";
|
|
5
|
+
import { RawInboundEvent } from "@photon-ai/proto/photon/fusor/v1/inbound";
|
|
6
|
+
//#region src/content/app.ts
|
|
7
|
+
/**
|
|
8
|
+
* Visible layout of an app card. Mirrors Apple's `MSMessageTemplateLayout`
|
|
9
|
+
* (the iMessage mini-app surface) — the iMessage provider renders it natively;
|
|
10
|
+
* other platforms ignore it and fall back to the bare URL. At least one of
|
|
11
|
+
* `caption`, `subcaption`, `trailingCaption`, `trailingSubcaption`, or `image`
|
|
12
|
+
* must be set so the bubble is not empty — `summary` is the fallback text shown
|
|
13
|
+
* on surfaces that cannot render the card and is not a visible slot on its own.
|
|
14
|
+
* `image` and `imageTitle` must be set together; `imageSubtitle` requires
|
|
15
|
+
* `image`.
|
|
16
|
+
*/
|
|
17
|
+
const appLayoutSchema = z.object({
|
|
18
|
+
caption: z.string().nonempty().optional(),
|
|
19
|
+
subcaption: z.string().nonempty().optional(),
|
|
20
|
+
trailingCaption: z.string().nonempty().optional(),
|
|
21
|
+
trailingSubcaption: z.string().nonempty().optional(),
|
|
22
|
+
image: z.instanceof(Uint8Array).optional(),
|
|
23
|
+
imageTitle: z.string().nonempty().optional(),
|
|
24
|
+
imageSubtitle: z.string().nonempty().optional(),
|
|
25
|
+
summary: z.string().nonempty().optional()
|
|
26
|
+
}).refine((layout) => layout.caption !== void 0 || layout.subcaption !== void 0 || layout.trailingCaption !== void 0 || layout.trailingSubcaption !== void 0 || layout.image !== void 0, { message: "layout must set at least one of caption, subcaption, trailingCaption, trailingSubcaption, image" }).refine((layout) => layout.image === void 0 === (layout.imageTitle === void 0), {
|
|
27
|
+
message: "layout.image and layout.imageTitle must be set together",
|
|
28
|
+
path: ["imageTitle"]
|
|
29
|
+
}).refine((layout) => layout.imageSubtitle === void 0 || layout.image !== void 0, {
|
|
30
|
+
message: "layout.imageSubtitle requires layout.image",
|
|
31
|
+
path: ["imageSubtitle"]
|
|
32
|
+
});
|
|
33
|
+
const urlAccessor = z.function({
|
|
34
|
+
input: [],
|
|
35
|
+
output: z.promise(z.url())
|
|
36
|
+
});
|
|
37
|
+
const layoutAccessor = z.function({
|
|
38
|
+
input: [],
|
|
39
|
+
output: z.promise(appLayoutSchema)
|
|
40
|
+
});
|
|
41
|
+
const appSchema = z.object({
|
|
42
|
+
type: z.literal("app"),
|
|
43
|
+
url: urlAccessor,
|
|
44
|
+
layout: layoutAccessor
|
|
45
|
+
});
|
|
46
|
+
const memoize = (factory) => {
|
|
47
|
+
let cached;
|
|
48
|
+
return () => {
|
|
49
|
+
cached ??= factory();
|
|
50
|
+
return cached;
|
|
51
|
+
};
|
|
52
|
+
};
|
|
53
|
+
const resolveUrl = (url) => memoize(async () => typeof url === "function" ? await url() : await url);
|
|
54
|
+
const WWW_PREFIX = /^www\./;
|
|
55
|
+
const siteHost = (url) => {
|
|
56
|
+
try {
|
|
57
|
+
return new URL(url).host.replace(WWW_PREFIX, "");
|
|
58
|
+
} catch {
|
|
59
|
+
return url;
|
|
60
|
+
}
|
|
61
|
+
};
|
|
62
|
+
const JPEG_PROXY = "https://wsrv.nl/";
|
|
63
|
+
const MAX_IMAGE_WIDTH = 1200;
|
|
64
|
+
const toJpegUrl = (imageUrl) => `${JPEG_PROXY}?url=${encodeURIComponent(imageUrl)}&output=jpg&w=${MAX_IMAGE_WIDTH}`;
|
|
65
|
+
const isJpeg = (bytes) => bytes.length > 2 && bytes[0] === 255 && bytes[1] === 216;
|
|
66
|
+
const buildLayout = (metadata, url, image) => {
|
|
67
|
+
const title = metadata.title ?? metadata.siteName ?? siteHost(url);
|
|
68
|
+
if (image) return appLayoutSchema.parse({
|
|
69
|
+
caption: title,
|
|
70
|
+
subcaption: metadata.summary,
|
|
71
|
+
image,
|
|
72
|
+
imageTitle: metadata.siteName ?? title,
|
|
73
|
+
summary: title
|
|
74
|
+
});
|
|
75
|
+
return appLayoutSchema.parse({
|
|
76
|
+
caption: title,
|
|
77
|
+
subcaption: metadata.summary,
|
|
78
|
+
summary: title
|
|
79
|
+
});
|
|
80
|
+
};
|
|
81
|
+
/**
|
|
82
|
+
* Construct an `app` content value.
|
|
83
|
+
*
|
|
84
|
+
* `url` is stored as a lazy accessor; `layout` is derived from the URL's Open
|
|
85
|
+
* Graph / link metadata (title → caption, og:site_name → image overlay,
|
|
86
|
+
* description → subcaption, og:image → JPEG-transcoded image) using the same
|
|
87
|
+
* machinery as `richlink`. A single metadata fetch is shared and memoized
|
|
88
|
+
* across `url()` / `layout()`; fetch / parse failures resolve to a host-only
|
|
89
|
+
* caption (no throw, no retry).
|
|
90
|
+
*/
|
|
91
|
+
const asApp = (url) => {
|
|
92
|
+
const getUrl = resolveUrl(url);
|
|
93
|
+
const getMetadata = memoize(async () => fetchLinkMetadata(await getUrl()));
|
|
94
|
+
const getLayout = memoize(async () => {
|
|
95
|
+
const resolvedUrl = await getUrl();
|
|
96
|
+
const metadata = await getMetadata();
|
|
97
|
+
let image;
|
|
98
|
+
if (metadata.image) try {
|
|
99
|
+
const bytes = (await fetchImage(toJpegUrl(metadata.image.url))).data;
|
|
100
|
+
image = isJpeg(bytes) ? bytes : void 0;
|
|
101
|
+
} catch {
|
|
102
|
+
image = void 0;
|
|
103
|
+
}
|
|
104
|
+
return buildLayout(metadata, resolvedUrl, image);
|
|
105
|
+
});
|
|
106
|
+
return appSchema.parse({
|
|
107
|
+
type: "app",
|
|
108
|
+
url: getUrl,
|
|
109
|
+
layout: getLayout
|
|
110
|
+
});
|
|
111
|
+
};
|
|
112
|
+
function app(url) {
|
|
113
|
+
return { build: async () => asApp(url) };
|
|
114
|
+
}
|
|
115
|
+
//#endregion
|
|
116
|
+
//#region src/content/avatar.ts
|
|
117
|
+
/**
|
|
118
|
+
* Set or clear the chat avatar (group icon). Universal content — providers
|
|
119
|
+
* dispatch by `content.type === "avatar"` in their `send` action and decide
|
|
120
|
+
* their own support story (e.g. iMessage only supports it for remote group
|
|
121
|
+
* chats).
|
|
122
|
+
*
|
|
123
|
+
* `space.send(avatar(...))` is the canonical form; `space.avatar(...)` is
|
|
124
|
+
* universal sugar that delegates here. Per-platform constraints (e.g.
|
|
125
|
+
* group-only, remote-only) surface as `UnsupportedError` from the provider's
|
|
126
|
+
* `send` action so the canonical and sugar forms share one error path.
|
|
127
|
+
*/
|
|
128
|
+
const avatarSchema = z.object({
|
|
129
|
+
type: z.literal("avatar"),
|
|
130
|
+
action: photoActionSchema
|
|
131
|
+
});
|
|
132
|
+
function avatar(input, options) {
|
|
133
|
+
const action = buildPhotoAction(input, options, "avatar");
|
|
134
|
+
return { build: async () => avatarSchema.parse({
|
|
135
|
+
type: "avatar",
|
|
136
|
+
action
|
|
137
|
+
}) };
|
|
138
|
+
}
|
|
139
|
+
//#endregion
|
|
140
|
+
//#region src/content/edit.ts
|
|
141
|
+
const isMessage$2 = (v) => typeof v === "object" && v !== null && "id" in v && "content" in v;
|
|
142
|
+
const isContent$1 = (v) => typeof v === "object" && v !== null && "type" in v && typeof v.type === "string";
|
|
143
|
+
/**
|
|
144
|
+
* An `edit` rewrites the content of a previously-sent outbound message.
|
|
145
|
+
*
|
|
146
|
+
* `space.send(edit(newContent, message))` is the canonical outbound API;
|
|
147
|
+
* `message.edit(newContent)` and `space.edit(message, newContent)` are
|
|
148
|
+
* sugar that delegate here. Edits are fire-and-forget — providers handle
|
|
149
|
+
* them inside their `send` action and the resolved value is `undefined`
|
|
150
|
+
* (no new message id is produced; the existing message mutates in place).
|
|
151
|
+
*
|
|
152
|
+
* Edit cannot wrap `edit`, `reply`, `reaction`, `group`, `typing`, `rename`,
|
|
153
|
+
* `avatar`, `unsend`, or `read` content.
|
|
154
|
+
*/
|
|
155
|
+
const editSchema = z.object({
|
|
156
|
+
type: z.literal("edit"),
|
|
157
|
+
content: z.custom(isContent$1, { message: "edit content must be a Content value" }),
|
|
158
|
+
target: z.custom(isMessage$2, { message: "edit target must be a Message" })
|
|
159
|
+
});
|
|
160
|
+
const asEdit = (input) => editSchema.parse({
|
|
161
|
+
type: "edit",
|
|
162
|
+
...input
|
|
163
|
+
});
|
|
164
|
+
/**
|
|
165
|
+
* Construct an `edit` content value rewriting `target`'s content.
|
|
166
|
+
*
|
|
167
|
+
* Only outbound messages (those sent by the agent) can be edited; calling
|
|
168
|
+
* this with an inbound target throws at build time so the misuse surfaces
|
|
169
|
+
* before the send pipeline runs.
|
|
170
|
+
*/
|
|
171
|
+
function edit(content, target) {
|
|
172
|
+
return { build: async () => {
|
|
173
|
+
if (!target) throw new Error("edit() target is undefined — the targeted message was never sent (space.send resolves undefined when a platform skips unsupported content)");
|
|
174
|
+
if (target.direction !== "outbound") throw new Error(`edit() target must be an outbound message (got direction "${target.direction}", message id "${target.id}")`);
|
|
175
|
+
const [resolved] = await resolveContents([content]);
|
|
176
|
+
if (!resolved) throw new Error("edit() requires content");
|
|
177
|
+
if (resolved.type === "edit" || resolved.type === "reply" || resolved.type === "reaction" || resolved.type === "group" || resolved.type === "typing" || resolved.type === "rename" || resolved.type === "avatar" || resolved.type === "unsend" || resolved.type === "read") throw new Error(`edit() cannot wrap "${resolved.type}" content`);
|
|
178
|
+
return asEdit({
|
|
179
|
+
content: resolved,
|
|
180
|
+
target
|
|
181
|
+
});
|
|
182
|
+
} };
|
|
183
|
+
}
|
|
184
|
+
//#endregion
|
|
185
|
+
//#region src/content/rename.ts
|
|
186
|
+
/**
|
|
187
|
+
* Rename the current chat. Universal content — providers dispatch by
|
|
188
|
+
* `content.type === "rename"` in their `send` action and decide their own
|
|
189
|
+
* support story (e.g. iMessage only supports it for remote group chats).
|
|
190
|
+
*
|
|
191
|
+
* `space.send(rename("New Name"))` is the canonical form; `space.rename(...)`
|
|
192
|
+
* is universal sugar that delegates here.
|
|
193
|
+
*
|
|
194
|
+
* Throws at build time if `displayName` is empty. Per-platform constraints
|
|
195
|
+
* (e.g. group-only, remote-only) surface as `UnsupportedError` from the
|
|
196
|
+
* provider's `send` action so the canonical and sugar forms share one
|
|
197
|
+
* error path.
|
|
198
|
+
*/
|
|
199
|
+
const renameSchema = z.object({
|
|
200
|
+
type: z.literal("rename"),
|
|
201
|
+
displayName: z.string().min(1, "rename() displayName must be non-empty")
|
|
202
|
+
});
|
|
203
|
+
function rename(displayName) {
|
|
204
|
+
return { build: async () => renameSchema.parse({
|
|
205
|
+
type: "rename",
|
|
206
|
+
displayName
|
|
207
|
+
}) };
|
|
208
|
+
}
|
|
209
|
+
//#endregion
|
|
210
|
+
//#region src/content/reply.ts
|
|
211
|
+
const isMessage$1 = (v) => typeof v === "object" && v !== null && "id" in v && "content" in v;
|
|
212
|
+
const isContent = (v) => typeof v === "object" && v !== null && "type" in v && typeof v.type === "string";
|
|
213
|
+
/**
|
|
214
|
+
* A `reply` wraps inner content with the message it replies to.
|
|
215
|
+
*
|
|
216
|
+
* `space.send(reply(content, message))` is the canonical outbound API;
|
|
217
|
+
* `message.reply(content)` is sugar that delegates here. Providers see
|
|
218
|
+
* `reply` like any other content type and route to a threaded send.
|
|
219
|
+
*
|
|
220
|
+
* Reply cannot wrap `reply`, `edit`, `reaction`, `group`, `typing`,
|
|
221
|
+
* `rename`, `avatar`, `unsend`, or `read` content.
|
|
222
|
+
*/
|
|
223
|
+
const replySchema = z.object({
|
|
224
|
+
type: z.literal("reply"),
|
|
225
|
+
content: z.custom(isContent, { message: "reply content must be a Content value" }),
|
|
226
|
+
target: z.custom(isMessage$1, { message: "reply target must be a Message" })
|
|
227
|
+
});
|
|
228
|
+
const asReply = (input) => replySchema.parse({
|
|
229
|
+
type: "reply",
|
|
230
|
+
...input
|
|
231
|
+
});
|
|
232
|
+
function reply(content, target) {
|
|
233
|
+
return { build: async () => {
|
|
234
|
+
if (!target) throw new Error("reply() target is undefined — the targeted message was never sent (space.send resolves undefined when a platform skips unsupported content)");
|
|
235
|
+
const [resolved] = await resolveContents([content]);
|
|
236
|
+
if (!resolved) throw new Error("reply() requires content");
|
|
237
|
+
if (resolved.type === "reply" || resolved.type === "edit" || resolved.type === "reaction" || resolved.type === "group" || resolved.type === "typing" || resolved.type === "rename" || resolved.type === "avatar" || resolved.type === "unsend" || resolved.type === "read") throw new Error(`reply() cannot wrap "${resolved.type}" content`);
|
|
238
|
+
return asReply({
|
|
239
|
+
content: resolved,
|
|
240
|
+
target
|
|
241
|
+
});
|
|
242
|
+
} };
|
|
243
|
+
}
|
|
244
|
+
//#endregion
|
|
245
|
+
//#region src/content/typing.ts
|
|
246
|
+
/**
|
|
247
|
+
* A `typing` content value carries a typing-indicator signal — either
|
|
248
|
+
* `"start"` or `"stop"`. Like `edit`, it's fire-and-forget: providers
|
|
249
|
+
* dispatch on `content.type === "typing"` inside their `send()` action and
|
|
250
|
+
* `space.send(typing(...))` resolves to `undefined`.
|
|
251
|
+
*
|
|
252
|
+
* `space.startTyping()` / `space.stopTyping()` / `space.responding()` are
|
|
253
|
+
* sugar over `space.send(typing("start" | "stop"))`. Platforms that have no
|
|
254
|
+
* typing-indicator API (e.g. WhatsApp Business) silently no-op so the
|
|
255
|
+
* signal is best-effort everywhere.
|
|
256
|
+
*/
|
|
257
|
+
const typingSchema = z.object({
|
|
258
|
+
type: z.literal("typing"),
|
|
259
|
+
state: z.enum(["start", "stop"])
|
|
260
|
+
});
|
|
261
|
+
/**
|
|
262
|
+
* Construct a `typing` content value. Defaults to `"start"`.
|
|
263
|
+
*
|
|
264
|
+
* `space.send(typing())` is equivalent to `space.startTyping()`;
|
|
265
|
+
* `space.send(typing("stop"))` is equivalent to `space.stopTyping()`.
|
|
266
|
+
*/
|
|
267
|
+
function typing(state = "start") {
|
|
268
|
+
return { build: async () => typingSchema.parse({
|
|
269
|
+
type: "typing",
|
|
270
|
+
state
|
|
271
|
+
}) };
|
|
272
|
+
}
|
|
273
|
+
//#endregion
|
|
274
|
+
//#region src/content/unsend.ts
|
|
275
|
+
const isMessage = (v) => typeof v === "object" && v !== null && "id" in v && "content" in v;
|
|
276
|
+
/**
|
|
277
|
+
* An `unsend` retracts a previously-sent outbound message.
|
|
278
|
+
*
|
|
279
|
+
* `space.send(unsend(message))` is the canonical outbound API;
|
|
280
|
+
* `message.unsend()` and `space.unsend(message)` are sugar that delegate
|
|
281
|
+
* here. Unsends are fire-and-forget — providers handle them inside their
|
|
282
|
+
* `send` action and the resolved value is `undefined` (no new message id is
|
|
283
|
+
* produced; the existing message is retracted in place).
|
|
284
|
+
*
|
|
285
|
+
* Platform constraints surface from the provider at send time — e.g.
|
|
286
|
+
* iMessage enforces Apple's ~2-minute unsend window for regular messages
|
|
287
|
+
* (reaction removal is not time-limited), and a late or repeated unsend
|
|
288
|
+
* rejects with the provider's error. `space.getMessage(id)` results are
|
|
289
|
+
* wrapped as inbound, so a message cannot be unsent from a refetched id
|
|
290
|
+
* after a restart — keep the Message returned by `send` (same limitation
|
|
291
|
+
* as `edit`).
|
|
292
|
+
*/
|
|
293
|
+
const unsendSchema = z.object({
|
|
294
|
+
type: z.literal("unsend"),
|
|
295
|
+
target: z.custom(isMessage, { message: "unsend target must be a Message" })
|
|
296
|
+
});
|
|
297
|
+
const asUnsend = (input) => unsendSchema.parse({
|
|
298
|
+
type: "unsend",
|
|
299
|
+
...input
|
|
300
|
+
});
|
|
301
|
+
/**
|
|
302
|
+
* Construct an `unsend` content value retracting `target`.
|
|
303
|
+
*
|
|
304
|
+
* Only outbound messages (those sent by the agent) can be unsent; calling
|
|
305
|
+
* this with an inbound target throws at build time so the misuse surfaces
|
|
306
|
+
* before the send pipeline runs.
|
|
307
|
+
*
|
|
308
|
+
* Accepts `Message | undefined` so `space.send` results chain without
|
|
309
|
+
* narrowing (`send` resolves `undefined` when a platform skips unsupported
|
|
310
|
+
* content); an undefined target throws at build time.
|
|
311
|
+
*/
|
|
312
|
+
function unsend(target) {
|
|
313
|
+
return { build: async () => {
|
|
314
|
+
if (!target) throw new Error("unsend() target is undefined — the targeted message was never sent (space.send resolves undefined when a platform skips unsupported content)");
|
|
315
|
+
if (target.direction !== "outbound") throw new Error(`unsend() target must be an outbound message (got direction "${target.direction}", message id "${target.id}")`);
|
|
316
|
+
return asUnsend({ target });
|
|
317
|
+
} };
|
|
318
|
+
}
|
|
319
|
+
//#endregion
|
|
320
|
+
//#region src/emoji/generated.ts
|
|
321
|
+
const GeneratedEmoji = {
|
|
322
|
+
_1stPlaceMedal: "🥇",
|
|
323
|
+
_2ndPlaceMedal: "🥈",
|
|
324
|
+
_3rdPlaceMedal: "🥉",
|
|
325
|
+
abacus: "🧮",
|
|
326
|
+
abButton: "🆎",
|
|
327
|
+
aButton: "🅰️",
|
|
328
|
+
accordion: "🪗",
|
|
329
|
+
adhesiveBandage: "🩹",
|
|
330
|
+
admissionTickets: "🎟️",
|
|
331
|
+
aerialTramway: "🚡",
|
|
332
|
+
airplane: "✈️",
|
|
333
|
+
airplaneArrival: "🛬",
|
|
334
|
+
airplaneDeparture: "🛫",
|
|
335
|
+
alarmClock: "⏰",
|
|
336
|
+
alembic: "⚗️",
|
|
337
|
+
alien: "👽",
|
|
338
|
+
alienMonster: "👾",
|
|
339
|
+
ambulance: "🚑",
|
|
340
|
+
americanFootball: "🏈",
|
|
341
|
+
amphora: "🏺",
|
|
342
|
+
anatomicalHeart: "🫀",
|
|
343
|
+
anchor: "⚓",
|
|
344
|
+
angerSymbol: "💢",
|
|
345
|
+
angryFace: "😠",
|
|
346
|
+
angryFaceWithHorns: "👿",
|
|
347
|
+
anguishedFace: "😧",
|
|
348
|
+
ant: "🐜",
|
|
349
|
+
antennaBars: "📶",
|
|
350
|
+
anxiousFaceWithSweat: "😰",
|
|
351
|
+
aquarius: "♒",
|
|
352
|
+
aries: "♈",
|
|
353
|
+
articulatedLorry: "🚛",
|
|
354
|
+
artist: "🧑🎨",
|
|
355
|
+
artistPalette: "🎨",
|
|
356
|
+
astonishedFace: "😲",
|
|
357
|
+
astronaut: "🧑🚀",
|
|
358
|
+
atmSign: "🏧",
|
|
359
|
+
atomSymbol: "⚛️",
|
|
360
|
+
automobile: "🚗",
|
|
361
|
+
autoRickshaw: "🛺",
|
|
362
|
+
avocado: "🥑",
|
|
363
|
+
axe: "🪓",
|
|
364
|
+
baby: "👶",
|
|
365
|
+
babyAngel: "👼",
|
|
366
|
+
babyBottle: "🍼",
|
|
367
|
+
babyChick: "🐤",
|
|
368
|
+
babySymbol: "🚼",
|
|
369
|
+
backArrow: "🔙",
|
|
370
|
+
backhandIndexPointingDown: "👇",
|
|
371
|
+
backhandIndexPointingLeft: "👈",
|
|
372
|
+
backhandIndexPointingRight: "👉",
|
|
373
|
+
backhandIndexPointingUp: "👆",
|
|
374
|
+
backpack: "🎒",
|
|
375
|
+
bacon: "🥓",
|
|
376
|
+
badger: "🦡",
|
|
377
|
+
badminton: "🏸",
|
|
378
|
+
bagel: "🥯",
|
|
379
|
+
baggageClaim: "🛄",
|
|
380
|
+
baguetteBread: "🥖",
|
|
381
|
+
balanceScale: "⚖️",
|
|
382
|
+
balletDancer: "🧑🩰",
|
|
383
|
+
balletShoes: "🩰",
|
|
384
|
+
balloon: "🎈",
|
|
385
|
+
ballotBoxWithBallot: "🗳️",
|
|
386
|
+
banana: "🍌",
|
|
387
|
+
banjo: "🪕",
|
|
388
|
+
bank: "🏦",
|
|
389
|
+
barberPole: "💈",
|
|
390
|
+
barChart: "📊",
|
|
391
|
+
baseball: "⚾",
|
|
392
|
+
basket: "🧺",
|
|
393
|
+
basketball: "🏀",
|
|
394
|
+
bat: "🦇",
|
|
395
|
+
bathtub: "🛁",
|
|
396
|
+
battery: "🔋",
|
|
397
|
+
bButton: "🅱️",
|
|
398
|
+
beachWithUmbrella: "🏖️",
|
|
399
|
+
beamingFaceWithSmilingEyes: "😁",
|
|
400
|
+
beans: "🫘",
|
|
401
|
+
bear: "🐻",
|
|
402
|
+
beatingHeart: "💓",
|
|
403
|
+
beaver: "🦫",
|
|
404
|
+
bed: "🛏️",
|
|
405
|
+
beerMug: "🍺",
|
|
406
|
+
beetle: "🪲",
|
|
407
|
+
bell: "🔔",
|
|
408
|
+
bellhopBell: "🛎️",
|
|
409
|
+
bellPepper: "🫑",
|
|
410
|
+
bellWithSlash: "🔕",
|
|
411
|
+
bentoBox: "🍱",
|
|
412
|
+
beverageBox: "🧃",
|
|
413
|
+
bicycle: "🚲",
|
|
414
|
+
bikini: "👙",
|
|
415
|
+
billedCap: "🧢",
|
|
416
|
+
biohazard: "☣️",
|
|
417
|
+
bird: "🐦",
|
|
418
|
+
birthdayCake: "🎂",
|
|
419
|
+
bison: "🦬",
|
|
420
|
+
bitingLip: "🫦",
|
|
421
|
+
blackBird: "🐦⬛",
|
|
422
|
+
blackCat: "🐈⬛",
|
|
423
|
+
blackCircle: "⚫",
|
|
424
|
+
blackFlag: "🏴",
|
|
425
|
+
blackHeart: "🖤",
|
|
426
|
+
blackLargeSquare: "⬛",
|
|
427
|
+
blackMediumSmallSquare: "◾",
|
|
428
|
+
blackMediumSquare: "◼️",
|
|
429
|
+
blackNib: "✒️",
|
|
430
|
+
blackSmallSquare: "▪️",
|
|
431
|
+
blackSquareButton: "🔲",
|
|
432
|
+
blossom: "🌼",
|
|
433
|
+
blowfish: "🐡",
|
|
434
|
+
blueberries: "🫐",
|
|
435
|
+
blueBook: "📘",
|
|
436
|
+
blueCircle: "🔵",
|
|
437
|
+
blueHeart: "💙",
|
|
438
|
+
blueSquare: "🟦",
|
|
439
|
+
boar: "🐗",
|
|
440
|
+
bomb: "💣",
|
|
441
|
+
bone: "🦴",
|
|
442
|
+
bookmark: "🔖",
|
|
443
|
+
bookmarkTabs: "📑",
|
|
444
|
+
books: "📚",
|
|
445
|
+
boomerang: "🪃",
|
|
446
|
+
bottleWithPoppingCork: "🍾",
|
|
447
|
+
bouquet: "💐",
|
|
448
|
+
bowAndArrow: "🏹",
|
|
449
|
+
bowling: "🎳",
|
|
450
|
+
bowlWithSpoon: "🥣",
|
|
451
|
+
boxingGlove: "🥊",
|
|
452
|
+
boy: "👦",
|
|
453
|
+
brain: "🧠",
|
|
454
|
+
bread: "🍞",
|
|
455
|
+
breastFeeding: "🤱",
|
|
456
|
+
brick: "🧱",
|
|
457
|
+
bridgeAtNight: "🌉",
|
|
458
|
+
briefcase: "💼",
|
|
459
|
+
briefs: "🩲",
|
|
460
|
+
brightButton: "🔆",
|
|
461
|
+
broccoli: "🥦",
|
|
462
|
+
brokenChain: "⛓️💥",
|
|
463
|
+
brokenHeart: "💔",
|
|
464
|
+
broom: "🧹",
|
|
465
|
+
brownCircle: "🟤",
|
|
466
|
+
brownHeart: "🤎",
|
|
467
|
+
brownMushroom: "🍄🟫",
|
|
468
|
+
brownSquare: "🟫",
|
|
469
|
+
bubbles: "🫧",
|
|
470
|
+
bubbleTea: "🧋",
|
|
471
|
+
bucket: "🪣",
|
|
472
|
+
bug: "🐛",
|
|
473
|
+
buildingConstruction: "🏗️",
|
|
474
|
+
bulletTrain: "🚅",
|
|
475
|
+
bullseye: "🎯",
|
|
476
|
+
burrito: "🌯",
|
|
477
|
+
bus: "🚌",
|
|
478
|
+
busStop: "🚏",
|
|
479
|
+
bustInSilhouette: "👤",
|
|
480
|
+
bustsInSilhouette: "👥",
|
|
481
|
+
butter: "🧈",
|
|
482
|
+
butterfly: "🦋",
|
|
483
|
+
cactus: "🌵",
|
|
484
|
+
calendar: "📅",
|
|
485
|
+
callMeHand: "🤙",
|
|
486
|
+
camel: "🐪",
|
|
487
|
+
camera: "📷",
|
|
488
|
+
cameraWithFlash: "📸",
|
|
489
|
+
camping: "🏕️",
|
|
490
|
+
cancer: "♋",
|
|
491
|
+
candle: "🕯️",
|
|
492
|
+
candy: "🍬",
|
|
493
|
+
cannedFood: "🥫",
|
|
494
|
+
canoe: "🛶",
|
|
495
|
+
capricorn: "♑",
|
|
496
|
+
cardFileBox: "🗃️",
|
|
497
|
+
cardIndex: "📇",
|
|
498
|
+
cardIndexDividers: "🗂️",
|
|
499
|
+
carouselHorse: "🎠",
|
|
500
|
+
carpentrySaw: "🪚",
|
|
501
|
+
carpStreamer: "🎏",
|
|
502
|
+
carrot: "🥕",
|
|
503
|
+
castle: "🏰",
|
|
504
|
+
cat: "🐈",
|
|
505
|
+
catFace: "🐱",
|
|
506
|
+
catWithTearsOfJoy: "😹",
|
|
507
|
+
catWithWrySmile: "😼",
|
|
508
|
+
chains: "⛓️",
|
|
509
|
+
chair: "🪑",
|
|
510
|
+
chartDecreasing: "📉",
|
|
511
|
+
chartIncreasing: "📈",
|
|
512
|
+
chartIncreasingWithYen: "💹",
|
|
513
|
+
checkBoxWithCheck: "☑️",
|
|
514
|
+
checkMark: "✔️",
|
|
515
|
+
checkMarkButton: "✅",
|
|
516
|
+
cheeseWedge: "🧀",
|
|
517
|
+
chequeredFlag: "🏁",
|
|
518
|
+
cherries: "🍒",
|
|
519
|
+
cherryBlossom: "🌸",
|
|
520
|
+
chessPawn: "♟️",
|
|
521
|
+
chestnut: "🌰",
|
|
522
|
+
chicken: "🐔",
|
|
523
|
+
child: "🧒",
|
|
524
|
+
childrenCrossing: "🚸",
|
|
525
|
+
chipmunk: "🐿️",
|
|
526
|
+
chocolateBar: "🍫",
|
|
527
|
+
chopsticks: "🥢",
|
|
528
|
+
christmasTree: "🎄",
|
|
529
|
+
church: "⛪",
|
|
530
|
+
cigarette: "🚬",
|
|
531
|
+
cinema: "🎦",
|
|
532
|
+
circledM: "Ⓜ️",
|
|
533
|
+
circusTent: "🎪",
|
|
534
|
+
cityscape: "🏙️",
|
|
535
|
+
cityscapeAtDusk: "🌆",
|
|
536
|
+
clamp: "🗜️",
|
|
537
|
+
clapperBoard: "🎬",
|
|
538
|
+
clappingHands: "👏",
|
|
539
|
+
classicalBuilding: "🏛️",
|
|
540
|
+
clButton: "🆑",
|
|
541
|
+
clinkingBeerMugs: "🍻",
|
|
542
|
+
clinkingGlasses: "🥂",
|
|
543
|
+
clipboard: "📋",
|
|
544
|
+
clockwiseVerticalArrows: "🔃",
|
|
545
|
+
closedBook: "📕",
|
|
546
|
+
closedMailboxWithLoweredFlag: "📪",
|
|
547
|
+
closedMailboxWithRaisedFlag: "📫",
|
|
548
|
+
closedUmbrella: "🌂",
|
|
549
|
+
cloud: "☁️",
|
|
550
|
+
cloudWithLightning: "🌩️",
|
|
551
|
+
cloudWithLightningAndRain: "⛈️",
|
|
552
|
+
cloudWithRain: "🌧️",
|
|
553
|
+
cloudWithSnow: "🌨️",
|
|
554
|
+
clownFace: "🤡",
|
|
555
|
+
clubSuit: "♣️",
|
|
556
|
+
clutchBag: "👝",
|
|
557
|
+
coat: "🧥",
|
|
558
|
+
cockroach: "🪳",
|
|
559
|
+
cocktailGlass: "🍸",
|
|
560
|
+
coconut: "🥥",
|
|
561
|
+
coffin: "⚰️",
|
|
562
|
+
coin: "🪙",
|
|
563
|
+
coldFace: "🥶",
|
|
564
|
+
collision: "💥",
|
|
565
|
+
comet: "☄️",
|
|
566
|
+
compass: "🧭",
|
|
567
|
+
computerDisk: "💽",
|
|
568
|
+
computerMouse: "🖱️",
|
|
569
|
+
confettiBall: "🎊",
|
|
570
|
+
confoundedFace: "😖",
|
|
571
|
+
confusedFace: "😕",
|
|
572
|
+
construction: "🚧",
|
|
573
|
+
constructionWorker: "👷",
|
|
574
|
+
controlKnobs: "🎛️",
|
|
575
|
+
convenienceStore: "🏪",
|
|
576
|
+
cook: "🧑🍳",
|
|
577
|
+
cookedRice: "🍚",
|
|
578
|
+
cookie: "🍪",
|
|
579
|
+
cooking: "🍳",
|
|
580
|
+
coolButton: "🆒",
|
|
581
|
+
copyright: "©️",
|
|
582
|
+
coral: "🪸",
|
|
583
|
+
couchAndLamp: "🛋️",
|
|
584
|
+
counterclockwiseArrowsButton: "🔄",
|
|
585
|
+
coupleWithHeart: "💑",
|
|
586
|
+
coupleWithHeartManMan: "👨❤️👨",
|
|
587
|
+
coupleWithHeartWomanMan: "👩❤️👨",
|
|
588
|
+
coupleWithHeartWomanWoman: "👩❤️👩",
|
|
589
|
+
cow: "🐄",
|
|
590
|
+
cowboyHatFace: "🤠",
|
|
591
|
+
cowFace: "🐮",
|
|
592
|
+
crab: "🦀",
|
|
593
|
+
crayon: "🖍️",
|
|
594
|
+
creditCard: "💳",
|
|
595
|
+
crescentMoon: "🌙",
|
|
596
|
+
cricket: "🦗",
|
|
597
|
+
cricketGame: "🏏",
|
|
598
|
+
crocodile: "🐊",
|
|
599
|
+
croissant: "🥐",
|
|
600
|
+
crossedFingers: "🤞",
|
|
601
|
+
crossedFlags: "🎌",
|
|
602
|
+
crossedSwords: "⚔️",
|
|
603
|
+
crossMark: "❌",
|
|
604
|
+
crossMarkButton: "❎",
|
|
605
|
+
crown: "👑",
|
|
606
|
+
crutch: "🩼",
|
|
607
|
+
cryingCat: "😿",
|
|
608
|
+
cryingFace: "😢",
|
|
609
|
+
crystalBall: "🔮",
|
|
610
|
+
cucumber: "🥒",
|
|
611
|
+
cupcake: "🧁",
|
|
612
|
+
cupWithStraw: "🥤",
|
|
613
|
+
curlingStone: "🥌",
|
|
614
|
+
curlyLoop: "➰",
|
|
615
|
+
currencyExchange: "💱",
|
|
616
|
+
curryRice: "🍛",
|
|
617
|
+
custard: "🍮",
|
|
618
|
+
customs: "🛃",
|
|
619
|
+
cutOfMeat: "🥩",
|
|
620
|
+
cyclone: "🌀",
|
|
621
|
+
dagger: "🗡️",
|
|
622
|
+
dango: "🍡",
|
|
623
|
+
dashingAway: "💨",
|
|
624
|
+
deafMan: "🧏♂️",
|
|
625
|
+
deafPerson: "🧏",
|
|
626
|
+
deafWoman: "🧏♀️",
|
|
627
|
+
deciduousTree: "🌳",
|
|
628
|
+
deer: "🦌",
|
|
629
|
+
deliveryTruck: "🚚",
|
|
630
|
+
departmentStore: "🏬",
|
|
631
|
+
derelictHouse: "🏚️",
|
|
632
|
+
desert: "🏜️",
|
|
633
|
+
desertIsland: "🏝️",
|
|
634
|
+
desktopComputer: "🖥️",
|
|
635
|
+
detective: "🕵️",
|
|
636
|
+
diamondSuit: "♦️",
|
|
637
|
+
diamondWithADot: "💠",
|
|
638
|
+
dimButton: "🔅",
|
|
639
|
+
disappointedFace: "😞",
|
|
640
|
+
disguisedFace: "🥸",
|
|
641
|
+
distortedFace: "",
|
|
642
|
+
divide: "➗",
|
|
643
|
+
divingMask: "🤿",
|
|
644
|
+
diyaLamp: "🪔",
|
|
645
|
+
dizzy: "💫",
|
|
646
|
+
dna: "🧬",
|
|
647
|
+
dodo: "🦤",
|
|
648
|
+
dog: "🐕",
|
|
649
|
+
dogFace: "🐶",
|
|
650
|
+
dollarBanknote: "💵",
|
|
651
|
+
dolphin: "🐬",
|
|
652
|
+
donkey: "🫏",
|
|
653
|
+
door: "🚪",
|
|
654
|
+
dottedLineFace: "🫥",
|
|
655
|
+
dottedSixPointedStar: "🔯",
|
|
656
|
+
doubleCurlyLoop: "➿",
|
|
657
|
+
doubleExclamationMark: "‼️",
|
|
658
|
+
doughnut: "🍩",
|
|
659
|
+
dove: "🕊️",
|
|
660
|
+
downArrow: "⬇️",
|
|
661
|
+
downcastFaceWithSweat: "😓",
|
|
662
|
+
downLeftArrow: "↙️",
|
|
663
|
+
downRightArrow: "↘️",
|
|
664
|
+
downwardsButton: "🔽",
|
|
665
|
+
dragon: "🐉",
|
|
666
|
+
dragonFace: "🐲",
|
|
667
|
+
dress: "👗",
|
|
668
|
+
droolingFace: "🤤",
|
|
669
|
+
droplet: "💧",
|
|
670
|
+
dropOfBlood: "🩸",
|
|
671
|
+
drum: "🥁",
|
|
672
|
+
duck: "🦆",
|
|
673
|
+
dumpling: "🥟",
|
|
674
|
+
dvd: "📀",
|
|
675
|
+
eagle: "🦅",
|
|
676
|
+
ear: "👂",
|
|
677
|
+
earOfCorn: "🌽",
|
|
678
|
+
earWithHearingAid: "🦻",
|
|
679
|
+
egg: "🥚",
|
|
680
|
+
eggplant: "🍆",
|
|
681
|
+
eightOClock: "🕗",
|
|
682
|
+
eightPointedStar: "✴️",
|
|
683
|
+
eightSpokedAsterisk: "✳️",
|
|
684
|
+
eightThirty: "🕣",
|
|
685
|
+
ejectButton: "⏏️",
|
|
686
|
+
electricPlug: "🔌",
|
|
687
|
+
elephant: "🐘",
|
|
688
|
+
elevator: "🛗",
|
|
689
|
+
elevenOClock: "🕚",
|
|
690
|
+
elevenThirty: "🕦",
|
|
691
|
+
elf: "🧝",
|
|
692
|
+
eMail: "📧",
|
|
693
|
+
emptyNest: "🪹",
|
|
694
|
+
endArrow: "🔚",
|
|
695
|
+
enragedFace: "😡",
|
|
696
|
+
envelope: "✉️",
|
|
697
|
+
envelopeWithArrow: "📩",
|
|
698
|
+
euroBanknote: "💶",
|
|
699
|
+
evergreenTree: "🌲",
|
|
700
|
+
ewe: "🐑",
|
|
701
|
+
exclamationQuestionMark: "⁉️",
|
|
702
|
+
explodingHead: "🤯",
|
|
703
|
+
expressionlessFace: "😑",
|
|
704
|
+
eye: "👁️",
|
|
705
|
+
eyeInSpeechBubble: "👁️🗨️",
|
|
706
|
+
eyes: "👀",
|
|
707
|
+
faceBlowingAKiss: "😘",
|
|
708
|
+
faceExhaling: "😮💨",
|
|
709
|
+
faceHoldingBackTears: "🥹",
|
|
710
|
+
faceInClouds: "😶🌫️",
|
|
711
|
+
faceSavoringFood: "😋",
|
|
712
|
+
faceScreamingInFear: "😱",
|
|
713
|
+
faceVomiting: "🤮",
|
|
714
|
+
faceWithBagsUnderEyes: "",
|
|
715
|
+
faceWithCrossedOutEyes: "😵",
|
|
716
|
+
faceWithDiagonalMouth: "🫤",
|
|
717
|
+
faceWithHandOverMouth: "🤭",
|
|
718
|
+
faceWithHeadBandage: "🤕",
|
|
719
|
+
faceWithMedicalMask: "😷",
|
|
720
|
+
faceWithMonocle: "🧐",
|
|
721
|
+
faceWithOpenEyesAndHandOverMouth: "🫢",
|
|
722
|
+
faceWithOpenMouth: "😮",
|
|
723
|
+
faceWithoutMouth: "😶",
|
|
724
|
+
faceWithPeekingEye: "🫣",
|
|
725
|
+
faceWithRaisedEyebrow: "🤨",
|
|
726
|
+
faceWithRollingEyes: "🙄",
|
|
727
|
+
faceWithSpiralEyes: "😵💫",
|
|
728
|
+
faceWithSteamFromNose: "😤",
|
|
729
|
+
faceWithSymbolsOnMouth: "🤬",
|
|
730
|
+
faceWithTearsOfJoy: "😂",
|
|
731
|
+
faceWithThermometer: "🤒",
|
|
732
|
+
faceWithTongue: "😛",
|
|
733
|
+
factory: "🏭",
|
|
734
|
+
factoryWorker: "🧑🏭",
|
|
735
|
+
fairy: "🧚",
|
|
736
|
+
falafel: "🧆",
|
|
737
|
+
fallenLeaf: "🍂",
|
|
738
|
+
family: "👪",
|
|
739
|
+
familyAdultAdultChild: "🧑🧑🧒",
|
|
740
|
+
familyAdultAdultChildChild: "🧑🧑🧒🧒",
|
|
741
|
+
familyAdultChild: "🧑🧒",
|
|
742
|
+
familyAdultChildChild: "🧑🧒🧒",
|
|
743
|
+
familyManBoy: "👨👦",
|
|
744
|
+
familyManBoyBoy: "👨👦👦",
|
|
745
|
+
familyManGirl: "👨👧",
|
|
746
|
+
familyManGirlBoy: "👨👧👦",
|
|
747
|
+
familyManGirlGirl: "👨👧👧",
|
|
748
|
+
familyManManBoy: "👨👨👦",
|
|
749
|
+
familyManManBoyBoy: "👨👨👦👦",
|
|
750
|
+
familyManManGirl: "👨👨👧",
|
|
751
|
+
familyManManGirlBoy: "👨👨👧👦",
|
|
752
|
+
familyManManGirlGirl: "👨👨👧👧",
|
|
753
|
+
familyManWomanBoy: "👨👩👦",
|
|
754
|
+
familyManWomanBoyBoy: "👨👩👦👦",
|
|
755
|
+
familyManWomanGirl: "👨👩👧",
|
|
756
|
+
familyManWomanGirlBoy: "👨👩👧👦",
|
|
757
|
+
familyManWomanGirlGirl: "👨👩👧👧",
|
|
758
|
+
familyWomanBoy: "👩👦",
|
|
759
|
+
familyWomanBoyBoy: "👩👦👦",
|
|
760
|
+
familyWomanGirl: "👩👧",
|
|
761
|
+
familyWomanGirlBoy: "👩👧👦",
|
|
762
|
+
familyWomanGirlGirl: "👩👧👧",
|
|
763
|
+
familyWomanWomanBoy: "👩👩👦",
|
|
764
|
+
familyWomanWomanBoyBoy: "👩👩👦👦",
|
|
765
|
+
familyWomanWomanGirl: "👩👩👧",
|
|
766
|
+
familyWomanWomanGirlBoy: "👩👩👧👦",
|
|
767
|
+
familyWomanWomanGirlGirl: "👩👩👧👧",
|
|
768
|
+
farmer: "🧑🌾",
|
|
769
|
+
fastDownButton: "⏬",
|
|
770
|
+
fastForwardButton: "⏩",
|
|
771
|
+
fastReverseButton: "⏪",
|
|
772
|
+
fastUpButton: "⏫",
|
|
773
|
+
faxMachine: "📠",
|
|
774
|
+
fearfulFace: "😨",
|
|
775
|
+
feather: "🪶",
|
|
776
|
+
femaleSign: "♀️",
|
|
777
|
+
ferrisWheel: "🎡",
|
|
778
|
+
ferry: "⛴️",
|
|
779
|
+
fieldHockey: "🏑",
|
|
780
|
+
fightCloud: "",
|
|
781
|
+
fileCabinet: "🗄️",
|
|
782
|
+
fileFolder: "📁",
|
|
783
|
+
filmFrames: "🎞️",
|
|
784
|
+
filmProjector: "📽️",
|
|
785
|
+
fingerprint: "",
|
|
786
|
+
fire: "🔥",
|
|
787
|
+
firecracker: "🧨",
|
|
788
|
+
fireEngine: "🚒",
|
|
789
|
+
fireExtinguisher: "🧯",
|
|
790
|
+
firefighter: "🧑🚒",
|
|
791
|
+
fireworks: "🎆",
|
|
792
|
+
firstQuarterMoon: "🌓",
|
|
793
|
+
firstQuarterMoonFace: "🌛",
|
|
794
|
+
fish: "🐟",
|
|
795
|
+
fishCakeWithSwirl: "🍥",
|
|
796
|
+
fishingPole: "🎣",
|
|
797
|
+
fiveOClock: "🕔",
|
|
798
|
+
fiveThirty: "🕠",
|
|
799
|
+
flagAfghanistan: "🇦🇫",
|
|
800
|
+
flagAlandIslands: "🇦🇽",
|
|
801
|
+
flagAlbania: "🇦🇱",
|
|
802
|
+
flagAlgeria: "🇩🇿",
|
|
803
|
+
flagAmericanSamoa: "🇦🇸",
|
|
804
|
+
flagAndorra: "🇦🇩",
|
|
805
|
+
flagAngola: "🇦🇴",
|
|
806
|
+
flagAnguilla: "🇦🇮",
|
|
807
|
+
flagAntarctica: "🇦🇶",
|
|
808
|
+
flagAntiguaBarbuda: "🇦🇬",
|
|
809
|
+
flagArgentina: "🇦🇷",
|
|
810
|
+
flagArmenia: "🇦🇲",
|
|
811
|
+
flagAruba: "🇦🇼",
|
|
812
|
+
flagAscensionIsland: "🇦🇨",
|
|
813
|
+
flagAustralia: "🇦🇺",
|
|
814
|
+
flagAustria: "🇦🇹",
|
|
815
|
+
flagAzerbaijan: "🇦🇿",
|
|
816
|
+
flagBahamas: "🇧🇸",
|
|
817
|
+
flagBahrain: "🇧🇭",
|
|
818
|
+
flagBangladesh: "🇧🇩",
|
|
819
|
+
flagBarbados: "🇧🇧",
|
|
820
|
+
flagBelarus: "🇧🇾",
|
|
821
|
+
flagBelgium: "🇧🇪",
|
|
822
|
+
flagBelize: "🇧🇿",
|
|
823
|
+
flagBenin: "🇧🇯",
|
|
824
|
+
flagBermuda: "🇧🇲",
|
|
825
|
+
flagBhutan: "🇧🇹",
|
|
826
|
+
flagBolivia: "🇧🇴",
|
|
827
|
+
flagBosniaHerzegovina: "🇧🇦",
|
|
828
|
+
flagBotswana: "🇧🇼",
|
|
829
|
+
flagBouvetIsland: "🇧🇻",
|
|
830
|
+
flagBrazil: "🇧🇷",
|
|
831
|
+
flagBritishIndianOceanTerritory: "🇮🇴",
|
|
832
|
+
flagBritishVirginIslands: "🇻🇬",
|
|
833
|
+
flagBrunei: "🇧🇳",
|
|
834
|
+
flagBulgaria: "🇧🇬",
|
|
835
|
+
flagBurkinaFaso: "🇧🇫",
|
|
836
|
+
flagBurundi: "🇧🇮",
|
|
837
|
+
flagCambodia: "🇰🇭",
|
|
838
|
+
flagCameroon: "🇨🇲",
|
|
839
|
+
flagCanada: "🇨🇦",
|
|
840
|
+
flagCanaryIslands: "🇮🇨",
|
|
841
|
+
flagCapeVerde: "🇨🇻",
|
|
842
|
+
flagCaribbeanNetherlands: "🇧🇶",
|
|
843
|
+
flagCaymanIslands: "🇰🇾",
|
|
844
|
+
flagCentralAfricanRepublic: "🇨🇫",
|
|
845
|
+
flagCeutaMelilla: "🇪🇦",
|
|
846
|
+
flagChad: "🇹🇩",
|
|
847
|
+
flagChile: "🇨🇱",
|
|
848
|
+
flagChina: "🇨🇳",
|
|
849
|
+
flagChristmasIsland: "🇨🇽",
|
|
850
|
+
flagClippertonIsland: "🇨🇵",
|
|
851
|
+
flagCocosIslands: "🇨🇨",
|
|
852
|
+
flagColombia: "🇨🇴",
|
|
853
|
+
flagComoros: "🇰🇲",
|
|
854
|
+
flagCongoBrazzaville: "🇨🇬",
|
|
855
|
+
flagCongoKinshasa: "🇨🇩",
|
|
856
|
+
flagCookIslands: "🇨🇰",
|
|
857
|
+
flagCostaRica: "🇨🇷",
|
|
858
|
+
flagCoteDIvoire: "🇨🇮",
|
|
859
|
+
flagCroatia: "🇭🇷",
|
|
860
|
+
flagCuba: "🇨🇺",
|
|
861
|
+
flagCuracao: "🇨🇼",
|
|
862
|
+
flagCyprus: "🇨🇾",
|
|
863
|
+
flagCzechia: "🇨🇿",
|
|
864
|
+
flagDenmark: "🇩🇰",
|
|
865
|
+
flagDiegoGarcia: "🇩🇬",
|
|
866
|
+
flagDjibouti: "🇩🇯",
|
|
867
|
+
flagDominica: "🇩🇲",
|
|
868
|
+
flagDominicanRepublic: "🇩🇴",
|
|
869
|
+
flagEcuador: "🇪🇨",
|
|
870
|
+
flagEgypt: "🇪🇬",
|
|
871
|
+
flagElSalvador: "🇸🇻",
|
|
872
|
+
flagEngland: "🏴",
|
|
873
|
+
flagEquatorialGuinea: "🇬🇶",
|
|
874
|
+
flagEritrea: "🇪🇷",
|
|
875
|
+
flagEstonia: "🇪🇪",
|
|
876
|
+
flagEswatini: "🇸🇿",
|
|
877
|
+
flagEthiopia: "🇪🇹",
|
|
878
|
+
flagEuropeanUnion: "🇪🇺",
|
|
879
|
+
flagFalklandIslands: "🇫🇰",
|
|
880
|
+
flagFaroeIslands: "🇫🇴",
|
|
881
|
+
flagFiji: "🇫🇯",
|
|
882
|
+
flagFinland: "🇫🇮",
|
|
883
|
+
flagFrance: "🇫🇷",
|
|
884
|
+
flagFrenchGuiana: "🇬🇫",
|
|
885
|
+
flagFrenchPolynesia: "🇵🇫",
|
|
886
|
+
flagFrenchSouthernTerritories: "🇹🇫",
|
|
887
|
+
flagGabon: "🇬🇦",
|
|
888
|
+
flagGambia: "🇬🇲",
|
|
889
|
+
flagGeorgia: "🇬🇪",
|
|
890
|
+
flagGermany: "🇩🇪",
|
|
891
|
+
flagGhana: "🇬🇭",
|
|
892
|
+
flagGibraltar: "🇬🇮",
|
|
893
|
+
flagGreece: "🇬🇷",
|
|
894
|
+
flagGreenland: "🇬🇱",
|
|
895
|
+
flagGrenada: "🇬🇩",
|
|
896
|
+
flagGuadeloupe: "🇬🇵",
|
|
897
|
+
flagGuam: "🇬🇺",
|
|
898
|
+
flagGuatemala: "🇬🇹",
|
|
899
|
+
flagGuernsey: "🇬🇬",
|
|
900
|
+
flagGuinea: "🇬🇳",
|
|
901
|
+
flagGuineaBissau: "🇬🇼",
|
|
902
|
+
flagGuyana: "🇬🇾",
|
|
903
|
+
flagHaiti: "🇭🇹",
|
|
904
|
+
flagHeardMcdonaldIslands: "🇭🇲",
|
|
905
|
+
flagHonduras: "🇭🇳",
|
|
906
|
+
flagHongKongSarChina: "🇭🇰",
|
|
907
|
+
flagHungary: "🇭🇺",
|
|
908
|
+
flagIceland: "🇮🇸",
|
|
909
|
+
flagIndia: "🇮🇳",
|
|
910
|
+
flagIndonesia: "🇮🇩",
|
|
911
|
+
flagInHole: "⛳",
|
|
912
|
+
flagIran: "🇮🇷",
|
|
913
|
+
flagIraq: "🇮🇶",
|
|
914
|
+
flagIreland: "🇮🇪",
|
|
915
|
+
flagIsleOfMan: "🇮🇲",
|
|
916
|
+
flagIsrael: "🇮🇱",
|
|
917
|
+
flagItaly: "🇮🇹",
|
|
918
|
+
flagJamaica: "🇯🇲",
|
|
919
|
+
flagJapan: "🇯🇵",
|
|
920
|
+
flagJersey: "🇯🇪",
|
|
921
|
+
flagJordan: "🇯🇴",
|
|
922
|
+
flagKazakhstan: "🇰🇿",
|
|
923
|
+
flagKenya: "🇰🇪",
|
|
924
|
+
flagKiribati: "🇰🇮",
|
|
925
|
+
flagKosovo: "🇽🇰",
|
|
926
|
+
flagKuwait: "🇰🇼",
|
|
927
|
+
flagKyrgyzstan: "🇰🇬",
|
|
928
|
+
flagLaos: "🇱🇦",
|
|
929
|
+
flagLatvia: "🇱🇻",
|
|
930
|
+
flagLebanon: "🇱🇧",
|
|
931
|
+
flagLesotho: "🇱🇸",
|
|
932
|
+
flagLiberia: "🇱🇷",
|
|
933
|
+
flagLibya: "🇱🇾",
|
|
934
|
+
flagLiechtenstein: "🇱🇮",
|
|
935
|
+
flagLithuania: "🇱🇹",
|
|
936
|
+
flagLuxembourg: "🇱🇺",
|
|
937
|
+
flagMacaoSarChina: "🇲🇴",
|
|
938
|
+
flagMadagascar: "🇲🇬",
|
|
939
|
+
flagMalawi: "🇲🇼",
|
|
940
|
+
flagMalaysia: "🇲🇾",
|
|
941
|
+
flagMaldives: "🇲🇻",
|
|
942
|
+
flagMali: "🇲🇱",
|
|
943
|
+
flagMalta: "🇲🇹",
|
|
944
|
+
flagMarshallIslands: "🇲🇭",
|
|
945
|
+
flagMartinique: "🇲🇶",
|
|
946
|
+
flagMauritania: "🇲🇷",
|
|
947
|
+
flagMauritius: "🇲🇺",
|
|
948
|
+
flagMayotte: "🇾🇹",
|
|
949
|
+
flagMexico: "🇲🇽",
|
|
950
|
+
flagMicronesia: "🇫🇲",
|
|
951
|
+
flagMoldova: "🇲🇩",
|
|
952
|
+
flagMonaco: "🇲🇨",
|
|
953
|
+
flagMongolia: "🇲🇳",
|
|
954
|
+
flagMontenegro: "🇲🇪",
|
|
955
|
+
flagMontserrat: "🇲🇸",
|
|
956
|
+
flagMorocco: "🇲🇦",
|
|
957
|
+
flagMozambique: "🇲🇿",
|
|
958
|
+
flagMyanmar: "🇲🇲",
|
|
959
|
+
flagNamibia: "🇳🇦",
|
|
960
|
+
flagNauru: "🇳🇷",
|
|
961
|
+
flagNepal: "🇳🇵",
|
|
962
|
+
flagNetherlands: "🇳🇱",
|
|
963
|
+
flagNewCaledonia: "🇳🇨",
|
|
964
|
+
flagNewZealand: "🇳🇿",
|
|
965
|
+
flagNicaragua: "🇳🇮",
|
|
966
|
+
flagNiger: "🇳🇪",
|
|
967
|
+
flagNigeria: "🇳🇬",
|
|
968
|
+
flagNiue: "🇳🇺",
|
|
969
|
+
flagNorfolkIsland: "🇳🇫",
|
|
970
|
+
flagNorthernMarianaIslands: "🇲🇵",
|
|
971
|
+
flagNorthKorea: "🇰🇵",
|
|
972
|
+
flagNorthMacedonia: "🇲🇰",
|
|
973
|
+
flagNorway: "🇳🇴",
|
|
974
|
+
flagOman: "🇴🇲",
|
|
975
|
+
flagPakistan: "🇵🇰",
|
|
976
|
+
flagPalau: "🇵🇼",
|
|
977
|
+
flagPalestinianTerritories: "🇵🇸",
|
|
978
|
+
flagPanama: "🇵🇦",
|
|
979
|
+
flagPapuaNewGuinea: "🇵🇬",
|
|
980
|
+
flagParaguay: "🇵🇾",
|
|
981
|
+
flagPeru: "🇵🇪",
|
|
982
|
+
flagPhilippines: "🇵🇭",
|
|
983
|
+
flagPitcairnIslands: "🇵🇳",
|
|
984
|
+
flagPoland: "🇵🇱",
|
|
985
|
+
flagPortugal: "🇵🇹",
|
|
986
|
+
flagPuertoRico: "🇵🇷",
|
|
987
|
+
flagQatar: "🇶🇦",
|
|
988
|
+
flagReunion: "🇷🇪",
|
|
989
|
+
flagRomania: "🇷🇴",
|
|
990
|
+
flagRussia: "🇷🇺",
|
|
991
|
+
flagRwanda: "🇷🇼",
|
|
992
|
+
flagSamoa: "🇼🇸",
|
|
993
|
+
flagSanMarino: "🇸🇲",
|
|
994
|
+
flagSaoTomePrincipe: "🇸🇹",
|
|
995
|
+
flagSark: "🇨🇶",
|
|
996
|
+
flagSaudiArabia: "🇸🇦",
|
|
997
|
+
flagScotland: "🏴",
|
|
998
|
+
flagSenegal: "🇸🇳",
|
|
999
|
+
flagSerbia: "🇷🇸",
|
|
1000
|
+
flagSeychelles: "🇸🇨",
|
|
1001
|
+
flagSierraLeone: "🇸🇱",
|
|
1002
|
+
flagSingapore: "🇸🇬",
|
|
1003
|
+
flagSintMaarten: "🇸🇽",
|
|
1004
|
+
flagSlovakia: "🇸🇰",
|
|
1005
|
+
flagSlovenia: "🇸🇮",
|
|
1006
|
+
flagSolomonIslands: "🇸🇧",
|
|
1007
|
+
flagSomalia: "🇸🇴",
|
|
1008
|
+
flagSouthAfrica: "🇿🇦",
|
|
1009
|
+
flagSouthGeorgiaSouthSandwichIslands: "🇬🇸",
|
|
1010
|
+
flagSouthKorea: "🇰🇷",
|
|
1011
|
+
flagSouthSudan: "🇸🇸",
|
|
1012
|
+
flagSpain: "🇪🇸",
|
|
1013
|
+
flagSriLanka: "🇱🇰",
|
|
1014
|
+
flagStBarthelemy: "🇧🇱",
|
|
1015
|
+
flagStHelena: "🇸🇭",
|
|
1016
|
+
flagStKittsNevis: "🇰🇳",
|
|
1017
|
+
flagStLucia: "🇱🇨",
|
|
1018
|
+
flagStMartin: "🇲🇫",
|
|
1019
|
+
flagStPierreMiquelon: "🇵🇲",
|
|
1020
|
+
flagStVincentGrenadines: "🇻🇨",
|
|
1021
|
+
flagSudan: "🇸🇩",
|
|
1022
|
+
flagSuriname: "🇸🇷",
|
|
1023
|
+
flagSvalbardJanMayen: "🇸🇯",
|
|
1024
|
+
flagSweden: "🇸🇪",
|
|
1025
|
+
flagSwitzerland: "🇨🇭",
|
|
1026
|
+
flagSyria: "🇸🇾",
|
|
1027
|
+
flagTaiwan: "🇹🇼",
|
|
1028
|
+
flagTajikistan: "🇹🇯",
|
|
1029
|
+
flagTanzania: "🇹🇿",
|
|
1030
|
+
flagThailand: "🇹🇭",
|
|
1031
|
+
flagTimorLeste: "🇹🇱",
|
|
1032
|
+
flagTogo: "🇹🇬",
|
|
1033
|
+
flagTokelau: "🇹🇰",
|
|
1034
|
+
flagTonga: "🇹🇴",
|
|
1035
|
+
flagTrinidadTobago: "🇹🇹",
|
|
1036
|
+
flagTristanDaCunha: "🇹🇦",
|
|
1037
|
+
flagTunisia: "🇹🇳",
|
|
1038
|
+
flagTurkiye: "🇹🇷",
|
|
1039
|
+
flagTurkmenistan: "🇹🇲",
|
|
1040
|
+
flagTurksCaicosIslands: "🇹🇨",
|
|
1041
|
+
flagTuvalu: "🇹🇻",
|
|
1042
|
+
flagUganda: "🇺🇬",
|
|
1043
|
+
flagUkraine: "🇺🇦",
|
|
1044
|
+
flagUnitedArabEmirates: "🇦🇪",
|
|
1045
|
+
flagUnitedKingdom: "🇬🇧",
|
|
1046
|
+
flagUnitedNations: "🇺🇳",
|
|
1047
|
+
flagUnitedStates: "🇺🇸",
|
|
1048
|
+
flagUruguay: "🇺🇾",
|
|
1049
|
+
flagUSOutlyingIslands: "🇺🇲",
|
|
1050
|
+
flagUSVirginIslands: "🇻🇮",
|
|
1051
|
+
flagUzbekistan: "🇺🇿",
|
|
1052
|
+
flagVanuatu: "🇻🇺",
|
|
1053
|
+
flagVaticanCity: "🇻🇦",
|
|
1054
|
+
flagVenezuela: "🇻🇪",
|
|
1055
|
+
flagVietnam: "🇻🇳",
|
|
1056
|
+
flagWales: "🏴",
|
|
1057
|
+
flagWallisFutuna: "🇼🇫",
|
|
1058
|
+
flagWesternSahara: "🇪🇭",
|
|
1059
|
+
flagYemen: "🇾🇪",
|
|
1060
|
+
flagZambia: "🇿🇲",
|
|
1061
|
+
flagZimbabwe: "🇿🇼",
|
|
1062
|
+
flamingo: "🦩",
|
|
1063
|
+
flashlight: "🔦",
|
|
1064
|
+
flatbread: "🫓",
|
|
1065
|
+
flatShoe: "🥿",
|
|
1066
|
+
fleurDeLis: "⚜️",
|
|
1067
|
+
flexedBiceps: "💪",
|
|
1068
|
+
floppyDisk: "💾",
|
|
1069
|
+
flowerPlayingCards: "🎴",
|
|
1070
|
+
flushedFace: "😳",
|
|
1071
|
+
flute: "🪈",
|
|
1072
|
+
fly: "🪰",
|
|
1073
|
+
flyingDisc: "🥏",
|
|
1074
|
+
flyingSaucer: "🛸",
|
|
1075
|
+
fog: "🌫️",
|
|
1076
|
+
foggy: "🌁",
|
|
1077
|
+
foldedHands: "🙏",
|
|
1078
|
+
foldingHandFan: "🪭",
|
|
1079
|
+
fondue: "🫕",
|
|
1080
|
+
foot: "🦶",
|
|
1081
|
+
footprints: "👣",
|
|
1082
|
+
forkAndKnife: "🍴",
|
|
1083
|
+
forkAndKnifeWithPlate: "🍽️",
|
|
1084
|
+
fortuneCookie: "🥠",
|
|
1085
|
+
fountain: "⛲",
|
|
1086
|
+
fountainPen: "🖋️",
|
|
1087
|
+
fourLeafClover: "🍀",
|
|
1088
|
+
fourOClock: "🕓",
|
|
1089
|
+
fourThirty: "🕟",
|
|
1090
|
+
fox: "🦊",
|
|
1091
|
+
framedPicture: "🖼️",
|
|
1092
|
+
freeButton: "🆓",
|
|
1093
|
+
frenchFries: "🍟",
|
|
1094
|
+
friedShrimp: "🍤",
|
|
1095
|
+
frog: "🐸",
|
|
1096
|
+
frontFacingBabyChick: "🐥",
|
|
1097
|
+
frowningFace: "☹️",
|
|
1098
|
+
frowningFaceWithOpenMouth: "😦",
|
|
1099
|
+
fuelPump: "⛽",
|
|
1100
|
+
fullMoon: "🌕",
|
|
1101
|
+
fullMoonFace: "🌝",
|
|
1102
|
+
funeralUrn: "⚱️",
|
|
1103
|
+
gameDie: "🎲",
|
|
1104
|
+
garlic: "🧄",
|
|
1105
|
+
gear: "⚙️",
|
|
1106
|
+
gemini: "♊",
|
|
1107
|
+
gemStone: "💎",
|
|
1108
|
+
genie: "🧞",
|
|
1109
|
+
ghost: "👻",
|
|
1110
|
+
gingerRoot: "🫚",
|
|
1111
|
+
giraffe: "🦒",
|
|
1112
|
+
girl: "👧",
|
|
1113
|
+
glasses: "👓",
|
|
1114
|
+
glassOfMilk: "🥛",
|
|
1115
|
+
globeShowingAmericas: "🌎",
|
|
1116
|
+
globeShowingAsiaAustralia: "🌏",
|
|
1117
|
+
globeShowingEuropeAfrica: "🌍",
|
|
1118
|
+
globeWithMeridians: "🌐",
|
|
1119
|
+
gloves: "🧤",
|
|
1120
|
+
glowingStar: "🌟",
|
|
1121
|
+
goalNet: "🥅",
|
|
1122
|
+
goat: "🐐",
|
|
1123
|
+
goblin: "👺",
|
|
1124
|
+
goggles: "🥽",
|
|
1125
|
+
goose: "🪿",
|
|
1126
|
+
gorilla: "🦍",
|
|
1127
|
+
graduationCap: "🎓",
|
|
1128
|
+
grapes: "🍇",
|
|
1129
|
+
greenApple: "🍏",
|
|
1130
|
+
greenBook: "📗",
|
|
1131
|
+
greenCircle: "🟢",
|
|
1132
|
+
greenHeart: "💚",
|
|
1133
|
+
greenSalad: "🥗",
|
|
1134
|
+
greenSquare: "🟩",
|
|
1135
|
+
greyHeart: "🩶",
|
|
1136
|
+
grimacingFace: "😬",
|
|
1137
|
+
grinningCat: "😺",
|
|
1138
|
+
grinningCatWithSmilingEyes: "😸",
|
|
1139
|
+
grinningFace: "😀",
|
|
1140
|
+
grinningFaceWithBigEyes: "😃",
|
|
1141
|
+
grinningFaceWithSmilingEyes: "😄",
|
|
1142
|
+
grinningFaceWithSweat: "😅",
|
|
1143
|
+
grinningSquintingFace: "😆",
|
|
1144
|
+
growingHeart: "💗",
|
|
1145
|
+
guard: "💂",
|
|
1146
|
+
guideDog: "🦮",
|
|
1147
|
+
guitar: "🎸",
|
|
1148
|
+
hairPick: "🪮",
|
|
1149
|
+
hairyCreature: "",
|
|
1150
|
+
hamburger: "🍔",
|
|
1151
|
+
hammer: "🔨",
|
|
1152
|
+
hammerAndPick: "⚒️",
|
|
1153
|
+
hammerAndWrench: "🛠️",
|
|
1154
|
+
hamsa: "🪬",
|
|
1155
|
+
hamster: "🐹",
|
|
1156
|
+
handbag: "👜",
|
|
1157
|
+
handshake: "🤝",
|
|
1158
|
+
handWithFingersSplayed: "🖐️",
|
|
1159
|
+
handWithIndexFingerAndThumbCrossed: "🫰",
|
|
1160
|
+
harp: "",
|
|
1161
|
+
hatchingChick: "🐣",
|
|
1162
|
+
headphone: "🎧",
|
|
1163
|
+
headShakingHorizontally: "🙂↔️",
|
|
1164
|
+
headShakingVertically: "🙂↕️",
|
|
1165
|
+
headstone: "🪦",
|
|
1166
|
+
healthWorker: "🧑⚕️",
|
|
1167
|
+
hearNoEvilMonkey: "🙉",
|
|
1168
|
+
heartDecoration: "💟",
|
|
1169
|
+
heartExclamation: "❣️",
|
|
1170
|
+
heartHands: "🫶",
|
|
1171
|
+
heartOnFire: "❤️🔥",
|
|
1172
|
+
heartSuit: "♥️",
|
|
1173
|
+
heartWithArrow: "💘",
|
|
1174
|
+
heartWithRibbon: "💝",
|
|
1175
|
+
heavyDollarSign: "💲",
|
|
1176
|
+
heavyEqualsSign: "🟰",
|
|
1177
|
+
hedgehog: "🦔",
|
|
1178
|
+
helicopter: "🚁",
|
|
1179
|
+
herb: "🌿",
|
|
1180
|
+
hibiscus: "🌺",
|
|
1181
|
+
highHeeledShoe: "👠",
|
|
1182
|
+
highSpeedTrain: "🚄",
|
|
1183
|
+
highVoltage: "⚡",
|
|
1184
|
+
hikingBoot: "🥾",
|
|
1185
|
+
hinduTemple: "🛕",
|
|
1186
|
+
hippopotamus: "🦛",
|
|
1187
|
+
hole: "🕳️",
|
|
1188
|
+
hollowRedCircle: "⭕",
|
|
1189
|
+
honeybee: "🐝",
|
|
1190
|
+
honeyPot: "🍯",
|
|
1191
|
+
hook: "🪝",
|
|
1192
|
+
horizontalTrafficLight: "🚥",
|
|
1193
|
+
horse: "🐎",
|
|
1194
|
+
horseFace: "🐴",
|
|
1195
|
+
horseRacing: "🏇",
|
|
1196
|
+
hospital: "🏥",
|
|
1197
|
+
hotBeverage: "☕",
|
|
1198
|
+
hotDog: "🌭",
|
|
1199
|
+
hotel: "🏨",
|
|
1200
|
+
hotFace: "🥵",
|
|
1201
|
+
hotPepper: "🌶️",
|
|
1202
|
+
hotSprings: "♨️",
|
|
1203
|
+
hourglassDone: "⌛",
|
|
1204
|
+
hourglassNotDone: "⏳",
|
|
1205
|
+
house: "🏠",
|
|
1206
|
+
houses: "🏘️",
|
|
1207
|
+
houseWithGarden: "🏡",
|
|
1208
|
+
hundredPoints: "💯",
|
|
1209
|
+
hushedFace: "😯",
|
|
1210
|
+
hut: "🛖",
|
|
1211
|
+
hyacinth: "🪻",
|
|
1212
|
+
ice: "🧊",
|
|
1213
|
+
iceCream: "🍨",
|
|
1214
|
+
iceHockey: "🏒",
|
|
1215
|
+
iceSkate: "⛸️",
|
|
1216
|
+
idButton: "🆔",
|
|
1217
|
+
identificationCard: "🪪",
|
|
1218
|
+
inboxTray: "📥",
|
|
1219
|
+
incomingEnvelope: "📨",
|
|
1220
|
+
indexPointingAtTheViewer: "🫵",
|
|
1221
|
+
indexPointingUp: "☝️",
|
|
1222
|
+
infinity: "♾️",
|
|
1223
|
+
information: "ℹ️",
|
|
1224
|
+
inputLatinLetters: "🔤",
|
|
1225
|
+
inputLatinLowercase: "🔡",
|
|
1226
|
+
inputLatinUppercase: "🔠",
|
|
1227
|
+
inputNumbers: "🔢",
|
|
1228
|
+
inputSymbols: "🔣",
|
|
1229
|
+
jackOLantern: "🎃",
|
|
1230
|
+
japaneseAcceptableButton: "🉑",
|
|
1231
|
+
japaneseApplicationButton: "🈸",
|
|
1232
|
+
japaneseBargainButton: "🉐",
|
|
1233
|
+
japaneseCastle: "🏯",
|
|
1234
|
+
japaneseCongratulationsButton: "㊗️",
|
|
1235
|
+
japaneseDiscountButton: "🈹",
|
|
1236
|
+
japaneseDolls: "🎎",
|
|
1237
|
+
japaneseFreeOfChargeButton: "🈚",
|
|
1238
|
+
japaneseHereButton: "🈁",
|
|
1239
|
+
japaneseMonthlyAmountButton: "🈷️",
|
|
1240
|
+
japaneseNotFreeOfChargeButton: "🈶",
|
|
1241
|
+
japaneseNoVacancyButton: "🈵",
|
|
1242
|
+
japaneseOpenForBusinessButton: "🈺",
|
|
1243
|
+
japanesePassingGradeButton: "🈴",
|
|
1244
|
+
japanesePostOffice: "🏣",
|
|
1245
|
+
japaneseProhibitedButton: "🈲",
|
|
1246
|
+
japaneseReservedButton: "🈯",
|
|
1247
|
+
japaneseSecretButton: "㊙️",
|
|
1248
|
+
japaneseServiceChargeButton: "🈂️",
|
|
1249
|
+
japaneseSymbolForBeginner: "🔰",
|
|
1250
|
+
japaneseVacancyButton: "🈳",
|
|
1251
|
+
jar: "🫙",
|
|
1252
|
+
jeans: "👖",
|
|
1253
|
+
jellyfish: "🪼",
|
|
1254
|
+
joker: "🃏",
|
|
1255
|
+
joystick: "🕹️",
|
|
1256
|
+
judge: "🧑⚖️",
|
|
1257
|
+
kaaba: "🕋",
|
|
1258
|
+
kangaroo: "🦘",
|
|
1259
|
+
key: "🔑",
|
|
1260
|
+
keyboard: "⌨️",
|
|
1261
|
+
keycap0: "0️⃣",
|
|
1262
|
+
keycap1: "1️⃣",
|
|
1263
|
+
keycap10: "🔟",
|
|
1264
|
+
keycap2: "2️⃣",
|
|
1265
|
+
keycap3: "3️⃣",
|
|
1266
|
+
keycap4: "4️⃣",
|
|
1267
|
+
keycap5: "5️⃣",
|
|
1268
|
+
keycap6: "6️⃣",
|
|
1269
|
+
keycap7: "7️⃣",
|
|
1270
|
+
keycap8: "8️⃣",
|
|
1271
|
+
keycap9: "9️⃣",
|
|
1272
|
+
keycapAsterisk: "*️⃣",
|
|
1273
|
+
keycapNumberSign: "#️⃣",
|
|
1274
|
+
khanda: "🪯",
|
|
1275
|
+
kickScooter: "🛴",
|
|
1276
|
+
kimono: "👘",
|
|
1277
|
+
kiss: "💏",
|
|
1278
|
+
kissingCat: "😽",
|
|
1279
|
+
kissingFace: "😗",
|
|
1280
|
+
kissingFaceWithClosedEyes: "😚",
|
|
1281
|
+
kissingFaceWithSmilingEyes: "😙",
|
|
1282
|
+
kissManMan: "👨❤️💋👨",
|
|
1283
|
+
kissMark: "💋",
|
|
1284
|
+
kissWomanMan: "👩❤️💋👨",
|
|
1285
|
+
kissWomanWoman: "👩❤️💋👩",
|
|
1286
|
+
kitchenKnife: "🔪",
|
|
1287
|
+
kite: "🪁",
|
|
1288
|
+
kiwiFruit: "🥝",
|
|
1289
|
+
knot: "🪢",
|
|
1290
|
+
koala: "🐨",
|
|
1291
|
+
labCoat: "🥼",
|
|
1292
|
+
label: "🏷️",
|
|
1293
|
+
lacrosse: "🥍",
|
|
1294
|
+
ladder: "🪜",
|
|
1295
|
+
ladyBeetle: "🐞",
|
|
1296
|
+
landslide: "",
|
|
1297
|
+
laptop: "💻",
|
|
1298
|
+
largeBlueDiamond: "🔷",
|
|
1299
|
+
largeOrangeDiamond: "🔶",
|
|
1300
|
+
lastQuarterMoon: "🌗",
|
|
1301
|
+
lastQuarterMoonFace: "🌜",
|
|
1302
|
+
lastTrackButton: "⏮️",
|
|
1303
|
+
latinCross: "✝️",
|
|
1304
|
+
leafFlutteringInWind: "🍃",
|
|
1305
|
+
leaflessTree: "",
|
|
1306
|
+
leafyGreen: "🥬",
|
|
1307
|
+
ledger: "📒",
|
|
1308
|
+
leftArrow: "⬅️",
|
|
1309
|
+
leftArrowCurvingRight: "↪️",
|
|
1310
|
+
leftFacingFist: "🤛",
|
|
1311
|
+
leftLuggage: "🛅",
|
|
1312
|
+
leftRightArrow: "↔️",
|
|
1313
|
+
leftSpeechBubble: "🗨️",
|
|
1314
|
+
leftwardsHand: "🫲",
|
|
1315
|
+
leftwardsPushingHand: "🫷",
|
|
1316
|
+
leg: "🦵",
|
|
1317
|
+
lemon: "🍋",
|
|
1318
|
+
leo: "♌",
|
|
1319
|
+
leopard: "🐆",
|
|
1320
|
+
levelSlider: "🎚️",
|
|
1321
|
+
libra: "♎",
|
|
1322
|
+
lightBlueHeart: "🩵",
|
|
1323
|
+
lightBulb: "💡",
|
|
1324
|
+
lightRail: "🚈",
|
|
1325
|
+
lime: "🍋🟩",
|
|
1326
|
+
link: "🔗",
|
|
1327
|
+
linkedPaperclips: "🖇️",
|
|
1328
|
+
lion: "🦁",
|
|
1329
|
+
lipstick: "💄",
|
|
1330
|
+
litterInBinSign: "🚮",
|
|
1331
|
+
lizard: "🦎",
|
|
1332
|
+
llama: "🦙",
|
|
1333
|
+
lobster: "🦞",
|
|
1334
|
+
locked: "🔒",
|
|
1335
|
+
lockedWithKey: "🔐",
|
|
1336
|
+
lockedWithPen: "🔏",
|
|
1337
|
+
locomotive: "🚂",
|
|
1338
|
+
lollipop: "🍭",
|
|
1339
|
+
longDrum: "🪘",
|
|
1340
|
+
lotionBottle: "🧴",
|
|
1341
|
+
lotus: "🪷",
|
|
1342
|
+
loudlyCryingFace: "😭",
|
|
1343
|
+
loudspeaker: "📢",
|
|
1344
|
+
loveHotel: "🏩",
|
|
1345
|
+
loveLetter: "💌",
|
|
1346
|
+
loveYouGesture: "🤟",
|
|
1347
|
+
lowBattery: "🪫",
|
|
1348
|
+
luggage: "🧳",
|
|
1349
|
+
lungs: "🫁",
|
|
1350
|
+
lyingFace: "🤥",
|
|
1351
|
+
mage: "🧙",
|
|
1352
|
+
magicWand: "🪄",
|
|
1353
|
+
magnet: "🧲",
|
|
1354
|
+
magnifyingGlassTiltedLeft: "🔍",
|
|
1355
|
+
magnifyingGlassTiltedRight: "🔎",
|
|
1356
|
+
mahjongRedDragon: "🀄",
|
|
1357
|
+
maleSign: "♂️",
|
|
1358
|
+
mammoth: "🦣",
|
|
1359
|
+
man: "👨",
|
|
1360
|
+
manArtist: "👨🎨",
|
|
1361
|
+
manAstronaut: "👨🚀",
|
|
1362
|
+
manBald: "👨🦲",
|
|
1363
|
+
manBeard: "🧔♂️",
|
|
1364
|
+
manBiking: "🚴♂️",
|
|
1365
|
+
manBlondHair: "👱♂️",
|
|
1366
|
+
manBouncingBall: "⛹️♂️",
|
|
1367
|
+
manBowing: "🙇♂️",
|
|
1368
|
+
manCartwheeling: "🤸♂️",
|
|
1369
|
+
manClimbing: "🧗♂️",
|
|
1370
|
+
manConstructionWorker: "👷♂️",
|
|
1371
|
+
manCook: "👨🍳",
|
|
1372
|
+
manCurlyHair: "👨🦱",
|
|
1373
|
+
manDancing: "🕺",
|
|
1374
|
+
manDetective: "🕵️♂️",
|
|
1375
|
+
manElf: "🧝♂️",
|
|
1376
|
+
manFacepalming: "🤦♂️",
|
|
1377
|
+
manFactoryWorker: "👨🏭",
|
|
1378
|
+
manFairy: "🧚♂️",
|
|
1379
|
+
manFarmer: "👨🌾",
|
|
1380
|
+
manFeedingBaby: "👨🍼",
|
|
1381
|
+
manFirefighter: "👨🚒",
|
|
1382
|
+
manFrowning: "🙍♂️",
|
|
1383
|
+
manGenie: "🧞♂️",
|
|
1384
|
+
manGesturingNo: "🙅♂️",
|
|
1385
|
+
manGesturingOk: "🙆♂️",
|
|
1386
|
+
manGettingHaircut: "💇♂️",
|
|
1387
|
+
manGettingMassage: "💆♂️",
|
|
1388
|
+
mango: "🥭",
|
|
1389
|
+
manGolfing: "🏌️♂️",
|
|
1390
|
+
manGuard: "💂♂️",
|
|
1391
|
+
manHealthWorker: "👨⚕️",
|
|
1392
|
+
manInLotusPosition: "🧘♂️",
|
|
1393
|
+
manInManualWheelchair: "👨🦽",
|
|
1394
|
+
manInManualWheelchairFacingRight: "👨🦽➡️",
|
|
1395
|
+
manInMotorizedWheelchair: "👨🦼",
|
|
1396
|
+
manInMotorizedWheelchairFacingRight: "👨🦼➡️",
|
|
1397
|
+
manInSteamyRoom: "🧖♂️",
|
|
1398
|
+
manInTuxedo: "🤵♂️",
|
|
1399
|
+
manJudge: "👨⚖️",
|
|
1400
|
+
manJuggling: "🤹♂️",
|
|
1401
|
+
manKneeling: "🧎♂️",
|
|
1402
|
+
manKneelingFacingRight: "🧎♂️➡️",
|
|
1403
|
+
manLiftingWeights: "🏋️♂️",
|
|
1404
|
+
manMage: "🧙♂️",
|
|
1405
|
+
manMechanic: "👨🔧",
|
|
1406
|
+
manMountainBiking: "🚵♂️",
|
|
1407
|
+
manOfficeWorker: "👨💼",
|
|
1408
|
+
manPilot: "👨✈️",
|
|
1409
|
+
manPlayingHandball: "🤾♂️",
|
|
1410
|
+
manPlayingWaterPolo: "🤽♂️",
|
|
1411
|
+
manPoliceOfficer: "👮♂️",
|
|
1412
|
+
manPouting: "🙎♂️",
|
|
1413
|
+
manRaisingHand: "🙋♂️",
|
|
1414
|
+
manRedHair: "👨🦰",
|
|
1415
|
+
manRowingBoat: "🚣♂️",
|
|
1416
|
+
manRunning: "🏃♂️",
|
|
1417
|
+
manRunningFacingRight: "🏃♂️➡️",
|
|
1418
|
+
manScientist: "👨🔬",
|
|
1419
|
+
manShrugging: "🤷♂️",
|
|
1420
|
+
manSinger: "👨🎤",
|
|
1421
|
+
manSShoe: "👞",
|
|
1422
|
+
manStanding: "🧍♂️",
|
|
1423
|
+
manStudent: "👨🎓",
|
|
1424
|
+
manSuperhero: "🦸♂️",
|
|
1425
|
+
manSupervillain: "🦹♂️",
|
|
1426
|
+
manSurfing: "🏄♂️",
|
|
1427
|
+
manSwimming: "🏊♂️",
|
|
1428
|
+
manTeacher: "👨🏫",
|
|
1429
|
+
manTechnologist: "👨💻",
|
|
1430
|
+
mantelpieceClock: "🕰️",
|
|
1431
|
+
manTippingHand: "💁♂️",
|
|
1432
|
+
manualWheelchair: "🦽",
|
|
1433
|
+
manVampire: "🧛♂️",
|
|
1434
|
+
manWalking: "🚶♂️",
|
|
1435
|
+
manWalkingFacingRight: "🚶♂️➡️",
|
|
1436
|
+
manWearingTurban: "👳♂️",
|
|
1437
|
+
manWhiteHair: "👨🦳",
|
|
1438
|
+
manWithVeil: "👰♂️",
|
|
1439
|
+
manWithWhiteCane: "👨🦯",
|
|
1440
|
+
manWithWhiteCaneFacingRight: "👨🦯➡️",
|
|
1441
|
+
manZombie: "🧟♂️",
|
|
1442
|
+
mapleLeaf: "🍁",
|
|
1443
|
+
mapOfJapan: "🗾",
|
|
1444
|
+
maracas: "🪇",
|
|
1445
|
+
martialArtsUniform: "🥋",
|
|
1446
|
+
mate: "🧉",
|
|
1447
|
+
meatOnBone: "🍖",
|
|
1448
|
+
mechanic: "🧑🔧",
|
|
1449
|
+
mechanicalArm: "🦾",
|
|
1450
|
+
mechanicalLeg: "🦿",
|
|
1451
|
+
medicalSymbol: "⚕️",
|
|
1452
|
+
megaphone: "📣",
|
|
1453
|
+
melon: "🍈",
|
|
1454
|
+
meltingFace: "🫠",
|
|
1455
|
+
memo: "📝",
|
|
1456
|
+
mendingHeart: "❤️🩹",
|
|
1457
|
+
menHoldingHands: "👬",
|
|
1458
|
+
menorah: "🕎",
|
|
1459
|
+
menSRoom: "🚹",
|
|
1460
|
+
menWithBunnyEars: "👯♂️",
|
|
1461
|
+
menWrestling: "🤼♂️",
|
|
1462
|
+
mermaid: "🧜♀️",
|
|
1463
|
+
merman: "🧜♂️",
|
|
1464
|
+
merperson: "🧜",
|
|
1465
|
+
metro: "🚇",
|
|
1466
|
+
microbe: "🦠",
|
|
1467
|
+
microphone: "🎤",
|
|
1468
|
+
microscope: "🔬",
|
|
1469
|
+
middleFinger: "🖕",
|
|
1470
|
+
militaryHelmet: "🪖",
|
|
1471
|
+
militaryMedal: "🎖️",
|
|
1472
|
+
milkyWay: "🌌",
|
|
1473
|
+
minibus: "🚐",
|
|
1474
|
+
minus: "➖",
|
|
1475
|
+
mirror: "🪞",
|
|
1476
|
+
mirrorBall: "🪩",
|
|
1477
|
+
moai: "🗿",
|
|
1478
|
+
mobilePhone: "📱",
|
|
1479
|
+
mobilePhoneOff: "📴",
|
|
1480
|
+
mobilePhoneWithArrow: "📲",
|
|
1481
|
+
moneyBag: "💰",
|
|
1482
|
+
moneyMouthFace: "🤑",
|
|
1483
|
+
moneyWithWings: "💸",
|
|
1484
|
+
monkey: "🐒",
|
|
1485
|
+
monkeyFace: "🐵",
|
|
1486
|
+
monorail: "🚝",
|
|
1487
|
+
moonCake: "🥮",
|
|
1488
|
+
moonViewingCeremony: "🎑",
|
|
1489
|
+
moose: "🫎",
|
|
1490
|
+
mosque: "🕌",
|
|
1491
|
+
mosquito: "🦟",
|
|
1492
|
+
motorBoat: "🛥️",
|
|
1493
|
+
motorcycle: "🏍️",
|
|
1494
|
+
motorizedWheelchair: "🦼",
|
|
1495
|
+
motorScooter: "🛵",
|
|
1496
|
+
motorway: "🛣️",
|
|
1497
|
+
mountain: "⛰️",
|
|
1498
|
+
mountainCableway: "🚠",
|
|
1499
|
+
mountainRailway: "🚞",
|
|
1500
|
+
mountFuji: "🗻",
|
|
1501
|
+
mouse: "🐁",
|
|
1502
|
+
mouseFace: "🐭",
|
|
1503
|
+
mouseTrap: "🪤",
|
|
1504
|
+
mouth: "👄",
|
|
1505
|
+
movieCamera: "🎥",
|
|
1506
|
+
mrsClaus: "🤶",
|
|
1507
|
+
multiply: "✖️",
|
|
1508
|
+
mushroom: "🍄",
|
|
1509
|
+
musicalKeyboard: "🎹",
|
|
1510
|
+
musicalNote: "🎵",
|
|
1511
|
+
musicalNotes: "🎶",
|
|
1512
|
+
musicalScore: "🎼",
|
|
1513
|
+
mutedSpeaker: "🔇",
|
|
1514
|
+
mxClaus: "🧑🎄",
|
|
1515
|
+
nailPolish: "💅",
|
|
1516
|
+
nameBadge: "📛",
|
|
1517
|
+
nationalPark: "🏞️",
|
|
1518
|
+
nauseatedFace: "🤢",
|
|
1519
|
+
nazarAmulet: "🧿",
|
|
1520
|
+
necktie: "👔",
|
|
1521
|
+
nerdFace: "🤓",
|
|
1522
|
+
nestingDolls: "🪆",
|
|
1523
|
+
nestWithEggs: "🪺",
|
|
1524
|
+
neutralFace: "😐",
|
|
1525
|
+
newButton: "🆕",
|
|
1526
|
+
newMoon: "🌑",
|
|
1527
|
+
newMoonFace: "🌚",
|
|
1528
|
+
newspaper: "📰",
|
|
1529
|
+
nextTrackButton: "⏭️",
|
|
1530
|
+
ngButton: "🆖",
|
|
1531
|
+
nightWithStars: "🌃",
|
|
1532
|
+
nineOClock: "🕘",
|
|
1533
|
+
nineThirty: "🕤",
|
|
1534
|
+
ninja: "🥷",
|
|
1535
|
+
noBicycles: "🚳",
|
|
1536
|
+
noEntry: "⛔",
|
|
1537
|
+
noLittering: "🚯",
|
|
1538
|
+
noMobilePhones: "📵",
|
|
1539
|
+
nonPotableWater: "🚱",
|
|
1540
|
+
noOneUnderEighteen: "🔞",
|
|
1541
|
+
noPedestrians: "🚷",
|
|
1542
|
+
nose: "👃",
|
|
1543
|
+
noSmoking: "🚭",
|
|
1544
|
+
notebook: "📓",
|
|
1545
|
+
notebookWithDecorativeCover: "📔",
|
|
1546
|
+
nutAndBolt: "🔩",
|
|
1547
|
+
oButton: "🅾️",
|
|
1548
|
+
octopus: "🐙",
|
|
1549
|
+
oden: "🍢",
|
|
1550
|
+
officeBuilding: "🏢",
|
|
1551
|
+
officeWorker: "🧑💼",
|
|
1552
|
+
ogre: "👹",
|
|
1553
|
+
oilDrum: "🛢️",
|
|
1554
|
+
okButton: "🆗",
|
|
1555
|
+
okHand: "👌",
|
|
1556
|
+
olderPerson: "🧓",
|
|
1557
|
+
oldKey: "🗝️",
|
|
1558
|
+
oldMan: "👴",
|
|
1559
|
+
oldWoman: "👵",
|
|
1560
|
+
olive: "🫒",
|
|
1561
|
+
om: "🕉️",
|
|
1562
|
+
onArrow: "🔛",
|
|
1563
|
+
oncomingAutomobile: "🚘",
|
|
1564
|
+
oncomingBus: "🚍",
|
|
1565
|
+
oncomingFist: "👊",
|
|
1566
|
+
oncomingPoliceCar: "🚔",
|
|
1567
|
+
oncomingTaxi: "🚖",
|
|
1568
|
+
oneOClock: "🕐",
|
|
1569
|
+
onePieceSwimsuit: "🩱",
|
|
1570
|
+
oneThirty: "🕜",
|
|
1571
|
+
onion: "🧅",
|
|
1572
|
+
openBook: "📖",
|
|
1573
|
+
openFileFolder: "📂",
|
|
1574
|
+
openHands: "👐",
|
|
1575
|
+
openMailboxWithLoweredFlag: "📭",
|
|
1576
|
+
openMailboxWithRaisedFlag: "📬",
|
|
1577
|
+
ophiuchus: "⛎",
|
|
1578
|
+
opticalDisk: "💿",
|
|
1579
|
+
orangeBook: "📙",
|
|
1580
|
+
orangeCircle: "🟠",
|
|
1581
|
+
orangeHeart: "🧡",
|
|
1582
|
+
orangeSquare: "🟧",
|
|
1583
|
+
orangutan: "🦧",
|
|
1584
|
+
orca: "",
|
|
1585
|
+
orthodoxCross: "☦️",
|
|
1586
|
+
otter: "🦦",
|
|
1587
|
+
outboxTray: "📤",
|
|
1588
|
+
owl: "🦉",
|
|
1589
|
+
ox: "🐂",
|
|
1590
|
+
oyster: "🦪",
|
|
1591
|
+
package_: "📦",
|
|
1592
|
+
pageFacingUp: "📄",
|
|
1593
|
+
pager: "📟",
|
|
1594
|
+
pageWithCurl: "📃",
|
|
1595
|
+
paintbrush: "🖌️",
|
|
1596
|
+
palmDownHand: "🫳",
|
|
1597
|
+
palmsUpTogether: "🤲",
|
|
1598
|
+
palmTree: "🌴",
|
|
1599
|
+
palmUpHand: "🫴",
|
|
1600
|
+
pancakes: "🥞",
|
|
1601
|
+
panda: "🐼",
|
|
1602
|
+
paperclip: "📎",
|
|
1603
|
+
parachute: "🪂",
|
|
1604
|
+
parrot: "🦜",
|
|
1605
|
+
partAlternationMark: "〽️",
|
|
1606
|
+
partyingFace: "🥳",
|
|
1607
|
+
partyPopper: "🎉",
|
|
1608
|
+
passengerShip: "🛳️",
|
|
1609
|
+
passportControl: "🛂",
|
|
1610
|
+
pauseButton: "⏸️",
|
|
1611
|
+
pawPrints: "🐾",
|
|
1612
|
+
pButton: "🅿️",
|
|
1613
|
+
peaceSymbol: "☮️",
|
|
1614
|
+
peach: "🍑",
|
|
1615
|
+
peacock: "🦚",
|
|
1616
|
+
peanuts: "🥜",
|
|
1617
|
+
peaPod: "🫛",
|
|
1618
|
+
pear: "🍐",
|
|
1619
|
+
pen: "🖊️",
|
|
1620
|
+
pencil: "✏️",
|
|
1621
|
+
penguin: "🐧",
|
|
1622
|
+
pensiveFace: "😔",
|
|
1623
|
+
peopleHoldingHands: "🧑🤝🧑",
|
|
1624
|
+
peopleHugging: "🫂",
|
|
1625
|
+
peopleWithBunnyEars: "👯",
|
|
1626
|
+
peopleWrestling: "🤼",
|
|
1627
|
+
performingArts: "🎭",
|
|
1628
|
+
perseveringFace: "😣",
|
|
1629
|
+
person: "🧑",
|
|
1630
|
+
personBald: "🧑🦲",
|
|
1631
|
+
personBeard: "🧔",
|
|
1632
|
+
personBiking: "🚴",
|
|
1633
|
+
personBlondHair: "👱",
|
|
1634
|
+
personBouncingBall: "⛹️",
|
|
1635
|
+
personBowing: "🙇",
|
|
1636
|
+
personCartwheeling: "🤸",
|
|
1637
|
+
personClimbing: "🧗",
|
|
1638
|
+
personCurlyHair: "🧑🦱",
|
|
1639
|
+
personFacepalming: "🤦",
|
|
1640
|
+
personFeedingBaby: "🧑🍼",
|
|
1641
|
+
personFencing: "🤺",
|
|
1642
|
+
personFrowning: "🙍",
|
|
1643
|
+
personGesturingNo: "🙅",
|
|
1644
|
+
personGesturingOk: "🙆",
|
|
1645
|
+
personGettingHaircut: "💇",
|
|
1646
|
+
personGettingMassage: "💆",
|
|
1647
|
+
personGolfing: "🏌️",
|
|
1648
|
+
personInBed: "🛌",
|
|
1649
|
+
personInLotusPosition: "🧘",
|
|
1650
|
+
personInManualWheelchair: "🧑🦽",
|
|
1651
|
+
personInManualWheelchairFacingRight: "🧑🦽➡️",
|
|
1652
|
+
personInMotorizedWheelchair: "🧑🦼",
|
|
1653
|
+
personInMotorizedWheelchairFacingRight: "🧑🦼➡️",
|
|
1654
|
+
personInSteamyRoom: "🧖",
|
|
1655
|
+
personInSuitLevitating: "🕴️",
|
|
1656
|
+
personInTuxedo: "🤵",
|
|
1657
|
+
personJuggling: "🤹",
|
|
1658
|
+
personKneeling: "🧎",
|
|
1659
|
+
personKneelingFacingRight: "🧎➡️",
|
|
1660
|
+
personLiftingWeights: "🏋️",
|
|
1661
|
+
personMountainBiking: "🚵",
|
|
1662
|
+
personPlayingHandball: "🤾",
|
|
1663
|
+
personPlayingWaterPolo: "🤽",
|
|
1664
|
+
personPouting: "🙎",
|
|
1665
|
+
personRaisingHand: "🙋",
|
|
1666
|
+
personRedHair: "🧑🦰",
|
|
1667
|
+
personRowingBoat: "🚣",
|
|
1668
|
+
personRunning: "🏃",
|
|
1669
|
+
personRunningFacingRight: "🏃➡️",
|
|
1670
|
+
personShrugging: "🤷",
|
|
1671
|
+
personStanding: "🧍",
|
|
1672
|
+
personSurfing: "🏄",
|
|
1673
|
+
personSwimming: "🏊",
|
|
1674
|
+
personTakingBath: "🛀",
|
|
1675
|
+
personTippingHand: "💁",
|
|
1676
|
+
personWalking: "🚶",
|
|
1677
|
+
personWalkingFacingRight: "🚶➡️",
|
|
1678
|
+
personWearingTurban: "👳",
|
|
1679
|
+
personWhiteHair: "🧑🦳",
|
|
1680
|
+
personWithCrown: "🫅",
|
|
1681
|
+
personWithSkullcap: "👲",
|
|
1682
|
+
personWithVeil: "👰",
|
|
1683
|
+
personWithWhiteCane: "🧑🦯",
|
|
1684
|
+
personWithWhiteCaneFacingRight: "🧑🦯➡️",
|
|
1685
|
+
petriDish: "🧫",
|
|
1686
|
+
phoenix: "🐦🔥",
|
|
1687
|
+
pick: "⛏️",
|
|
1688
|
+
pickupTruck: "🛻",
|
|
1689
|
+
pie: "🥧",
|
|
1690
|
+
pig: "🐖",
|
|
1691
|
+
pigFace: "🐷",
|
|
1692
|
+
pigNose: "🐽",
|
|
1693
|
+
pileOfPoo: "💩",
|
|
1694
|
+
pill: "💊",
|
|
1695
|
+
pilot: "🧑✈️",
|
|
1696
|
+
pinata: "🪅",
|
|
1697
|
+
pinchedFingers: "🤌",
|
|
1698
|
+
pinchingHand: "🤏",
|
|
1699
|
+
pineapple: "🍍",
|
|
1700
|
+
pineDecoration: "🎍",
|
|
1701
|
+
pingPong: "🏓",
|
|
1702
|
+
pinkHeart: "🩷",
|
|
1703
|
+
pirateFlag: "🏴☠️",
|
|
1704
|
+
pisces: "♓",
|
|
1705
|
+
pizza: "🍕",
|
|
1706
|
+
placard: "🪧",
|
|
1707
|
+
placeOfWorship: "🛐",
|
|
1708
|
+
playButton: "▶️",
|
|
1709
|
+
playgroundSlide: "🛝",
|
|
1710
|
+
playOrPauseButton: "⏯️",
|
|
1711
|
+
pleadingFace: "🥺",
|
|
1712
|
+
plunger: "🪠",
|
|
1713
|
+
plus: "➕",
|
|
1714
|
+
polarBear: "🐻❄️",
|
|
1715
|
+
policeCar: "🚓",
|
|
1716
|
+
policeCarLight: "🚨",
|
|
1717
|
+
policeOfficer: "👮",
|
|
1718
|
+
poodle: "🐩",
|
|
1719
|
+
pool8Ball: "🎱",
|
|
1720
|
+
popcorn: "🍿",
|
|
1721
|
+
postalHorn: "📯",
|
|
1722
|
+
postbox: "📮",
|
|
1723
|
+
postOffice: "🏤",
|
|
1724
|
+
potableWater: "🚰",
|
|
1725
|
+
potato: "🥔",
|
|
1726
|
+
potOfFood: "🍲",
|
|
1727
|
+
pottedPlant: "🪴",
|
|
1728
|
+
poultryLeg: "🍗",
|
|
1729
|
+
poundBanknote: "💷",
|
|
1730
|
+
pouringLiquid: "🫗",
|
|
1731
|
+
poutingCat: "😾",
|
|
1732
|
+
prayerBeads: "📿",
|
|
1733
|
+
pregnantMan: "🫃",
|
|
1734
|
+
pregnantPerson: "🫄",
|
|
1735
|
+
pregnantWoman: "🤰",
|
|
1736
|
+
pretzel: "🥨",
|
|
1737
|
+
prince: "🤴",
|
|
1738
|
+
princess: "👸",
|
|
1739
|
+
printer: "🖨️",
|
|
1740
|
+
prohibited: "🚫",
|
|
1741
|
+
purpleCircle: "🟣",
|
|
1742
|
+
purpleHeart: "💜",
|
|
1743
|
+
purpleSquare: "🟪",
|
|
1744
|
+
purse: "👛",
|
|
1745
|
+
pushpin: "📌",
|
|
1746
|
+
puzzlePiece: "🧩",
|
|
1747
|
+
rabbit: "🐇",
|
|
1748
|
+
rabbitFace: "🐰",
|
|
1749
|
+
raccoon: "🦝",
|
|
1750
|
+
racingCar: "🏎️",
|
|
1751
|
+
radio: "📻",
|
|
1752
|
+
radioactive: "☢️",
|
|
1753
|
+
radioButton: "🔘",
|
|
1754
|
+
railwayCar: "🚃",
|
|
1755
|
+
railwayTrack: "🛤️",
|
|
1756
|
+
rainbow: "🌈",
|
|
1757
|
+
rainbowFlag: "🏳️🌈",
|
|
1758
|
+
raisedBackOfHand: "🤚",
|
|
1759
|
+
raisedFist: "✊",
|
|
1760
|
+
raisedHand: "✋",
|
|
1761
|
+
raisingHands: "🙌",
|
|
1762
|
+
ram: "🐏",
|
|
1763
|
+
rat: "🐀",
|
|
1764
|
+
razor: "🪒",
|
|
1765
|
+
receipt: "🧾",
|
|
1766
|
+
recordButton: "⏺️",
|
|
1767
|
+
recyclingSymbol: "♻️",
|
|
1768
|
+
redApple: "🍎",
|
|
1769
|
+
redCircle: "🔴",
|
|
1770
|
+
redEnvelope: "🧧",
|
|
1771
|
+
redExclamationMark: "❗",
|
|
1772
|
+
redHeart: "❤️",
|
|
1773
|
+
redPaperLantern: "🏮",
|
|
1774
|
+
redQuestionMark: "❓",
|
|
1775
|
+
redSquare: "🟥",
|
|
1776
|
+
redTrianglePointedDown: "🔻",
|
|
1777
|
+
redTrianglePointedUp: "🔺",
|
|
1778
|
+
registered: "®️",
|
|
1779
|
+
relievedFace: "😌",
|
|
1780
|
+
reminderRibbon: "🎗️",
|
|
1781
|
+
repeatButton: "🔁",
|
|
1782
|
+
repeatSingleButton: "🔂",
|
|
1783
|
+
rescueWorkerSHelmet: "⛑️",
|
|
1784
|
+
restroom: "🚻",
|
|
1785
|
+
reverseButton: "◀️",
|
|
1786
|
+
revolvingHearts: "💞",
|
|
1787
|
+
rhinoceros: "🦏",
|
|
1788
|
+
ribbon: "🎀",
|
|
1789
|
+
riceBall: "🍙",
|
|
1790
|
+
riceCracker: "🍘",
|
|
1791
|
+
rightAngerBubble: "🗯️",
|
|
1792
|
+
rightArrow: "➡️",
|
|
1793
|
+
rightArrowCurvingDown: "⤵️",
|
|
1794
|
+
rightArrowCurvingLeft: "↩️",
|
|
1795
|
+
rightArrowCurvingUp: "⤴️",
|
|
1796
|
+
rightFacingFist: "🤜",
|
|
1797
|
+
rightwardsHand: "🫱",
|
|
1798
|
+
rightwardsPushingHand: "🫸",
|
|
1799
|
+
ring: "💍",
|
|
1800
|
+
ringBuoy: "🛟",
|
|
1801
|
+
ringedPlanet: "🪐",
|
|
1802
|
+
roastedSweetPotato: "🍠",
|
|
1803
|
+
robot: "🤖",
|
|
1804
|
+
rock: "🪨",
|
|
1805
|
+
rocket: "🚀",
|
|
1806
|
+
rolledUpNewspaper: "🗞️",
|
|
1807
|
+
rollerCoaster: "🎢",
|
|
1808
|
+
rollerSkate: "🛼",
|
|
1809
|
+
rollingOnTheFloorLaughing: "🤣",
|
|
1810
|
+
rollOfPaper: "🧻",
|
|
1811
|
+
rooster: "🐓",
|
|
1812
|
+
rootVegetable: "",
|
|
1813
|
+
rose: "🌹",
|
|
1814
|
+
rosette: "🏵️",
|
|
1815
|
+
roundPushpin: "📍",
|
|
1816
|
+
rugbyFootball: "🏉",
|
|
1817
|
+
runningShirt: "🎽",
|
|
1818
|
+
runningShoe: "👟",
|
|
1819
|
+
sadButRelievedFace: "😥",
|
|
1820
|
+
safetyPin: "🧷",
|
|
1821
|
+
safetyVest: "🦺",
|
|
1822
|
+
sagittarius: "♐",
|
|
1823
|
+
sailboat: "⛵",
|
|
1824
|
+
sake: "🍶",
|
|
1825
|
+
salt: "🧂",
|
|
1826
|
+
salutingFace: "🫡",
|
|
1827
|
+
sandwich: "🥪",
|
|
1828
|
+
santaClaus: "🎅",
|
|
1829
|
+
sari: "🥻",
|
|
1830
|
+
satellite: "🛰️",
|
|
1831
|
+
satelliteAntenna: "📡",
|
|
1832
|
+
sauropod: "🦕",
|
|
1833
|
+
saxophone: "🎷",
|
|
1834
|
+
scarf: "🧣",
|
|
1835
|
+
school: "🏫",
|
|
1836
|
+
scientist: "🧑🔬",
|
|
1837
|
+
scissors: "✂️",
|
|
1838
|
+
scorpio: "♏",
|
|
1839
|
+
scorpion: "🦂",
|
|
1840
|
+
screwdriver: "🪛",
|
|
1841
|
+
scroll: "📜",
|
|
1842
|
+
seal: "🦭",
|
|
1843
|
+
seat: "💺",
|
|
1844
|
+
seedling: "🌱",
|
|
1845
|
+
seeNoEvilMonkey: "🙈",
|
|
1846
|
+
selfie: "🤳",
|
|
1847
|
+
serviceDog: "🐕🦺",
|
|
1848
|
+
sevenOClock: "🕖",
|
|
1849
|
+
sevenThirty: "🕢",
|
|
1850
|
+
sewingNeedle: "🪡",
|
|
1851
|
+
shakingFace: "🫨",
|
|
1852
|
+
shallowPanOfFood: "🥘",
|
|
1853
|
+
shamrock: "☘️",
|
|
1854
|
+
shark: "🦈",
|
|
1855
|
+
shavedIce: "🍧",
|
|
1856
|
+
sheafOfRice: "🌾",
|
|
1857
|
+
shield: "🛡️",
|
|
1858
|
+
shintoShrine: "⛩️",
|
|
1859
|
+
ship: "🚢",
|
|
1860
|
+
shootingStar: "🌠",
|
|
1861
|
+
shoppingBags: "🛍️",
|
|
1862
|
+
shoppingCart: "🛒",
|
|
1863
|
+
shortcake: "🍰",
|
|
1864
|
+
shorts: "🩳",
|
|
1865
|
+
shovel: "",
|
|
1866
|
+
shower: "🚿",
|
|
1867
|
+
shrimp: "🦐",
|
|
1868
|
+
shuffleTracksButton: "🔀",
|
|
1869
|
+
shushingFace: "🤫",
|
|
1870
|
+
signOfTheHorns: "🤘",
|
|
1871
|
+
singer: "🧑🎤",
|
|
1872
|
+
sixOClock: "🕕",
|
|
1873
|
+
sixThirty: "🕡",
|
|
1874
|
+
skateboard: "🛹",
|
|
1875
|
+
skier: "⛷️",
|
|
1876
|
+
skis: "🎿",
|
|
1877
|
+
skull: "💀",
|
|
1878
|
+
skullAndCrossbones: "☠️",
|
|
1879
|
+
skunk: "🦨",
|
|
1880
|
+
sled: "🛷",
|
|
1881
|
+
sleepingFace: "😴",
|
|
1882
|
+
sleepyFace: "😪",
|
|
1883
|
+
slightlyFrowningFace: "🙁",
|
|
1884
|
+
slightlySmilingFace: "🙂",
|
|
1885
|
+
sloth: "🦥",
|
|
1886
|
+
slotMachine: "🎰",
|
|
1887
|
+
smallAirplane: "🛩️",
|
|
1888
|
+
smallBlueDiamond: "🔹",
|
|
1889
|
+
smallOrangeDiamond: "🔸",
|
|
1890
|
+
smilingCatWithHeartEyes: "😻",
|
|
1891
|
+
smilingFace: "☺️",
|
|
1892
|
+
smilingFaceWithHalo: "😇",
|
|
1893
|
+
smilingFaceWithHeartEyes: "😍",
|
|
1894
|
+
smilingFaceWithHearts: "🥰",
|
|
1895
|
+
smilingFaceWithHorns: "😈",
|
|
1896
|
+
smilingFaceWithOpenHands: "🤗",
|
|
1897
|
+
smilingFaceWithSmilingEyes: "😊",
|
|
1898
|
+
smilingFaceWithSunglasses: "😎",
|
|
1899
|
+
smilingFaceWithTear: "🥲",
|
|
1900
|
+
smirkingFace: "😏",
|
|
1901
|
+
snail: "🐌",
|
|
1902
|
+
snake: "🐍",
|
|
1903
|
+
sneezingFace: "🤧",
|
|
1904
|
+
snowboarder: "🏂",
|
|
1905
|
+
snowCappedMountain: "🏔️",
|
|
1906
|
+
snowflake: "❄️",
|
|
1907
|
+
snowman: "☃️",
|
|
1908
|
+
snowmanWithoutSnow: "⛄",
|
|
1909
|
+
soap: "🧼",
|
|
1910
|
+
soccerBall: "⚽",
|
|
1911
|
+
socks: "🧦",
|
|
1912
|
+
softball: "🥎",
|
|
1913
|
+
softIceCream: "🍦",
|
|
1914
|
+
soonArrow: "🔜",
|
|
1915
|
+
sosButton: "🆘",
|
|
1916
|
+
spadeSuit: "♠️",
|
|
1917
|
+
spaghetti: "🍝",
|
|
1918
|
+
sparkle: "❇️",
|
|
1919
|
+
sparkler: "🎇",
|
|
1920
|
+
sparkles: "✨",
|
|
1921
|
+
sparklingHeart: "💖",
|
|
1922
|
+
speakerHighVolume: "🔊",
|
|
1923
|
+
speakerLowVolume: "🔈",
|
|
1924
|
+
speakerMediumVolume: "🔉",
|
|
1925
|
+
speakingHead: "🗣️",
|
|
1926
|
+
speakNoEvilMonkey: "🙊",
|
|
1927
|
+
speechBalloon: "💬",
|
|
1928
|
+
speedboat: "🚤",
|
|
1929
|
+
spider: "🕷️",
|
|
1930
|
+
spiderWeb: "🕸️",
|
|
1931
|
+
spiralCalendar: "🗓️",
|
|
1932
|
+
spiralNotepad: "🗒️",
|
|
1933
|
+
spiralShell: "🐚",
|
|
1934
|
+
splatter: "",
|
|
1935
|
+
sponge: "🧽",
|
|
1936
|
+
spoon: "🥄",
|
|
1937
|
+
sportsMedal: "🏅",
|
|
1938
|
+
sportUtilityVehicle: "🚙",
|
|
1939
|
+
spoutingWhale: "🐳",
|
|
1940
|
+
squid: "🦑",
|
|
1941
|
+
squintingFaceWithTongue: "😝",
|
|
1942
|
+
stadium: "🏟️",
|
|
1943
|
+
star: "⭐",
|
|
1944
|
+
starAndCrescent: "☪️",
|
|
1945
|
+
starOfDavid: "✡️",
|
|
1946
|
+
starStruck: "🤩",
|
|
1947
|
+
station: "🚉",
|
|
1948
|
+
statueOfLiberty: "🗽",
|
|
1949
|
+
steamingBowl: "🍜",
|
|
1950
|
+
stethoscope: "🩺",
|
|
1951
|
+
stopButton: "⏹️",
|
|
1952
|
+
stopSign: "🛑",
|
|
1953
|
+
stopwatch: "⏱️",
|
|
1954
|
+
straightRuler: "📏",
|
|
1955
|
+
strawberry: "🍓",
|
|
1956
|
+
student: "🧑🎓",
|
|
1957
|
+
studioMicrophone: "🎙️",
|
|
1958
|
+
stuffedFlatbread: "🥙",
|
|
1959
|
+
sun: "☀️",
|
|
1960
|
+
sunBehindCloud: "⛅",
|
|
1961
|
+
sunBehindLargeCloud: "🌥️",
|
|
1962
|
+
sunBehindRainCloud: "🌦️",
|
|
1963
|
+
sunBehindSmallCloud: "🌤️",
|
|
1964
|
+
sunflower: "🌻",
|
|
1965
|
+
sunglasses: "🕶️",
|
|
1966
|
+
sunrise: "🌅",
|
|
1967
|
+
sunriseOverMountains: "🌄",
|
|
1968
|
+
sunset: "🌇",
|
|
1969
|
+
sunWithFace: "🌞",
|
|
1970
|
+
superhero: "🦸",
|
|
1971
|
+
supervillain: "🦹",
|
|
1972
|
+
sushi: "🍣",
|
|
1973
|
+
suspensionRailway: "🚟",
|
|
1974
|
+
swan: "🦢",
|
|
1975
|
+
sweatDroplets: "💦",
|
|
1976
|
+
synagogue: "🕍",
|
|
1977
|
+
syringe: "💉",
|
|
1978
|
+
taco: "🌮",
|
|
1979
|
+
takeoutBox: "🥡",
|
|
1980
|
+
tamale: "🫔",
|
|
1981
|
+
tanabataTree: "🎋",
|
|
1982
|
+
tangerine: "🍊",
|
|
1983
|
+
taurus: "♉",
|
|
1984
|
+
taxi: "🚕",
|
|
1985
|
+
teacher: "🧑🏫",
|
|
1986
|
+
teacupWithoutHandle: "🍵",
|
|
1987
|
+
teapot: "🫖",
|
|
1988
|
+
tearOffCalendar: "📆",
|
|
1989
|
+
technologist: "🧑💻",
|
|
1990
|
+
teddyBear: "🧸",
|
|
1991
|
+
telephone: "☎️",
|
|
1992
|
+
telephoneReceiver: "📞",
|
|
1993
|
+
telescope: "🔭",
|
|
1994
|
+
television: "📺",
|
|
1995
|
+
tennis: "🎾",
|
|
1996
|
+
tenOClock: "🕙",
|
|
1997
|
+
tent: "⛺",
|
|
1998
|
+
tenThirty: "🕥",
|
|
1999
|
+
testTube: "🧪",
|
|
2000
|
+
thermometer: "🌡️",
|
|
2001
|
+
thinkingFace: "🤔",
|
|
2002
|
+
thongSandal: "🩴",
|
|
2003
|
+
thoughtBalloon: "💭",
|
|
2004
|
+
thread: "🧵",
|
|
2005
|
+
threeOClock: "🕒",
|
|
2006
|
+
threeThirty: "🕞",
|
|
2007
|
+
thumbsDown: "👎",
|
|
2008
|
+
thumbsUp: "👍",
|
|
2009
|
+
ticket: "🎫",
|
|
2010
|
+
tiger: "🐅",
|
|
2011
|
+
tigerFace: "🐯",
|
|
2012
|
+
timerClock: "⏲️",
|
|
2013
|
+
tiredFace: "😫",
|
|
2014
|
+
toilet: "🚽",
|
|
2015
|
+
tokyoTower: "🗼",
|
|
2016
|
+
tomato: "🍅",
|
|
2017
|
+
tongue: "👅",
|
|
2018
|
+
toolbox: "🧰",
|
|
2019
|
+
tooth: "🦷",
|
|
2020
|
+
toothbrush: "🪥",
|
|
2021
|
+
topArrow: "🔝",
|
|
2022
|
+
topHat: "🎩",
|
|
2023
|
+
tornado: "🌪️",
|
|
2024
|
+
trackball: "🖲️",
|
|
2025
|
+
tractor: "🚜",
|
|
2026
|
+
tradeMark: "™️",
|
|
2027
|
+
train: "🚆",
|
|
2028
|
+
tram: "🚊",
|
|
2029
|
+
tramCar: "🚋",
|
|
2030
|
+
transgenderFlag: "🏳️⚧️",
|
|
2031
|
+
transgenderSymbol: "⚧️",
|
|
2032
|
+
treasureChest: "",
|
|
2033
|
+
tRex: "🦖",
|
|
2034
|
+
triangularFlag: "🚩",
|
|
2035
|
+
triangularRuler: "📐",
|
|
2036
|
+
tridentEmblem: "🔱",
|
|
2037
|
+
troll: "🧌",
|
|
2038
|
+
trolleybus: "🚎",
|
|
2039
|
+
trombone: "",
|
|
2040
|
+
trophy: "🏆",
|
|
2041
|
+
tropicalDrink: "🍹",
|
|
2042
|
+
tropicalFish: "🐠",
|
|
2043
|
+
trumpet: "🎺",
|
|
2044
|
+
tShirt: "👕",
|
|
2045
|
+
tulip: "🌷",
|
|
2046
|
+
tumblerGlass: "🥃",
|
|
2047
|
+
turkey: "🦃",
|
|
2048
|
+
turtle: "🐢",
|
|
2049
|
+
twelveOClock: "🕛",
|
|
2050
|
+
twelveThirty: "🕧",
|
|
2051
|
+
twoHearts: "💕",
|
|
2052
|
+
twoHumpCamel: "🐫",
|
|
2053
|
+
twoOClock: "🕑",
|
|
2054
|
+
twoThirty: "🕝",
|
|
2055
|
+
umbrella: "☂️",
|
|
2056
|
+
umbrellaOnGround: "⛱️",
|
|
2057
|
+
umbrellaWithRainDrops: "☔",
|
|
2058
|
+
unamusedFace: "😒",
|
|
2059
|
+
unicorn: "🦄",
|
|
2060
|
+
unlocked: "🔓",
|
|
2061
|
+
upArrow: "⬆️",
|
|
2062
|
+
upButton: "🆙",
|
|
2063
|
+
upDownArrow: "↕️",
|
|
2064
|
+
upLeftArrow: "↖️",
|
|
2065
|
+
upRightArrow: "↗️",
|
|
2066
|
+
upsideDownFace: "🙃",
|
|
2067
|
+
upwardsButton: "🔼",
|
|
2068
|
+
vampire: "🧛",
|
|
2069
|
+
verticalTrafficLight: "🚦",
|
|
2070
|
+
vibrationMode: "📳",
|
|
2071
|
+
victoryHand: "✌️",
|
|
2072
|
+
videoCamera: "📹",
|
|
2073
|
+
videocassette: "📼",
|
|
2074
|
+
videoGame: "🎮",
|
|
2075
|
+
violin: "🎻",
|
|
2076
|
+
virgo: "♍",
|
|
2077
|
+
volcano: "🌋",
|
|
2078
|
+
volleyball: "🏐",
|
|
2079
|
+
vsButton: "🆚",
|
|
2080
|
+
vulcanSalute: "🖖",
|
|
2081
|
+
waffle: "🧇",
|
|
2082
|
+
waningCrescentMoon: "🌘",
|
|
2083
|
+
waningGibbousMoon: "🌖",
|
|
2084
|
+
warning: "⚠️",
|
|
2085
|
+
wastebasket: "🗑️",
|
|
2086
|
+
watch: "⌚",
|
|
2087
|
+
waterBuffalo: "🐃",
|
|
2088
|
+
waterCloset: "🚾",
|
|
2089
|
+
watermelon: "🍉",
|
|
2090
|
+
waterPistol: "🔫",
|
|
2091
|
+
waterWave: "🌊",
|
|
2092
|
+
wavingHand: "👋",
|
|
2093
|
+
wavyDash: "〰️",
|
|
2094
|
+
waxingCrescentMoon: "🌒",
|
|
2095
|
+
waxingGibbousMoon: "🌔",
|
|
2096
|
+
wearyCat: "🙀",
|
|
2097
|
+
wearyFace: "😩",
|
|
2098
|
+
wedding: "💒",
|
|
2099
|
+
whale: "🐋",
|
|
2100
|
+
wheel: "🛞",
|
|
2101
|
+
wheelchairSymbol: "♿",
|
|
2102
|
+
wheelOfDharma: "☸️",
|
|
2103
|
+
whiteCane: "🦯",
|
|
2104
|
+
whiteCircle: "⚪",
|
|
2105
|
+
whiteExclamationMark: "❕",
|
|
2106
|
+
whiteFlag: "🏳️",
|
|
2107
|
+
whiteFlower: "💮",
|
|
2108
|
+
whiteHeart: "🤍",
|
|
2109
|
+
whiteLargeSquare: "⬜",
|
|
2110
|
+
whiteMediumSmallSquare: "◽",
|
|
2111
|
+
whiteMediumSquare: "◻️",
|
|
2112
|
+
whiteQuestionMark: "❔",
|
|
2113
|
+
whiteSmallSquare: "▫️",
|
|
2114
|
+
whiteSquareButton: "🔳",
|
|
2115
|
+
wiltedFlower: "🥀",
|
|
2116
|
+
windChime: "🎐",
|
|
2117
|
+
windFace: "🌬️",
|
|
2118
|
+
window: "🪟",
|
|
2119
|
+
wineGlass: "🍷",
|
|
2120
|
+
wing: "🪽",
|
|
2121
|
+
winkingFace: "😉",
|
|
2122
|
+
winkingFaceWithTongue: "😜",
|
|
2123
|
+
wireless: "🛜",
|
|
2124
|
+
wolf: "🐺",
|
|
2125
|
+
woman: "👩",
|
|
2126
|
+
womanAndManHoldingHands: "👫",
|
|
2127
|
+
womanArtist: "👩🎨",
|
|
2128
|
+
womanAstronaut: "👩🚀",
|
|
2129
|
+
womanBald: "👩🦲",
|
|
2130
|
+
womanBeard: "🧔♀️",
|
|
2131
|
+
womanBiking: "🚴♀️",
|
|
2132
|
+
womanBlondHair: "👱♀️",
|
|
2133
|
+
womanBouncingBall: "⛹️♀️",
|
|
2134
|
+
womanBowing: "🙇♀️",
|
|
2135
|
+
womanCartwheeling: "🤸♀️",
|
|
2136
|
+
womanClimbing: "🧗♀️",
|
|
2137
|
+
womanConstructionWorker: "👷♀️",
|
|
2138
|
+
womanCook: "👩🍳",
|
|
2139
|
+
womanCurlyHair: "👩🦱",
|
|
2140
|
+
womanDancing: "💃",
|
|
2141
|
+
womanDetective: "🕵️♀️",
|
|
2142
|
+
womanElf: "🧝♀️",
|
|
2143
|
+
womanFacepalming: "🤦♀️",
|
|
2144
|
+
womanFactoryWorker: "👩🏭",
|
|
2145
|
+
womanFairy: "🧚♀️",
|
|
2146
|
+
womanFarmer: "👩🌾",
|
|
2147
|
+
womanFeedingBaby: "👩🍼",
|
|
2148
|
+
womanFirefighter: "👩🚒",
|
|
2149
|
+
womanFrowning: "🙍♀️",
|
|
2150
|
+
womanGenie: "🧞♀️",
|
|
2151
|
+
womanGesturingNo: "🙅♀️",
|
|
2152
|
+
womanGesturingOk: "🙆♀️",
|
|
2153
|
+
womanGettingHaircut: "💇♀️",
|
|
2154
|
+
womanGettingMassage: "💆♀️",
|
|
2155
|
+
womanGolfing: "🏌️♀️",
|
|
2156
|
+
womanGuard: "💂♀️",
|
|
2157
|
+
womanHealthWorker: "👩⚕️",
|
|
2158
|
+
womanInLotusPosition: "🧘♀️",
|
|
2159
|
+
womanInManualWheelchair: "👩🦽",
|
|
2160
|
+
womanInManualWheelchairFacingRight: "👩🦽➡️",
|
|
2161
|
+
womanInMotorizedWheelchair: "👩🦼",
|
|
2162
|
+
womanInMotorizedWheelchairFacingRight: "👩🦼➡️",
|
|
2163
|
+
womanInSteamyRoom: "🧖♀️",
|
|
2164
|
+
womanInTuxedo: "🤵♀️",
|
|
2165
|
+
womanJudge: "👩⚖️",
|
|
2166
|
+
womanJuggling: "🤹♀️",
|
|
2167
|
+
womanKneeling: "🧎♀️",
|
|
2168
|
+
womanKneelingFacingRight: "🧎♀️➡️",
|
|
2169
|
+
womanLiftingWeights: "🏋️♀️",
|
|
2170
|
+
womanMage: "🧙♀️",
|
|
2171
|
+
womanMechanic: "👩🔧",
|
|
2172
|
+
womanMountainBiking: "🚵♀️",
|
|
2173
|
+
womanOfficeWorker: "👩💼",
|
|
2174
|
+
womanPilot: "👩✈️",
|
|
2175
|
+
womanPlayingHandball: "🤾♀️",
|
|
2176
|
+
womanPlayingWaterPolo: "🤽♀️",
|
|
2177
|
+
womanPoliceOfficer: "👮♀️",
|
|
2178
|
+
womanPouting: "🙎♀️",
|
|
2179
|
+
womanRaisingHand: "🙋♀️",
|
|
2180
|
+
womanRedHair: "👩🦰",
|
|
2181
|
+
womanRowingBoat: "🚣♀️",
|
|
2182
|
+
womanRunning: "🏃♀️",
|
|
2183
|
+
womanRunningFacingRight: "🏃♀️➡️",
|
|
2184
|
+
womanSBoot: "👢",
|
|
2185
|
+
womanScientist: "👩🔬",
|
|
2186
|
+
womanSClothes: "👚",
|
|
2187
|
+
womanSHat: "👒",
|
|
2188
|
+
womanShrugging: "🤷♀️",
|
|
2189
|
+
womanSinger: "👩🎤",
|
|
2190
|
+
womanSSandal: "👡",
|
|
2191
|
+
womanStanding: "🧍♀️",
|
|
2192
|
+
womanStudent: "👩🎓",
|
|
2193
|
+
womanSuperhero: "🦸♀️",
|
|
2194
|
+
womanSupervillain: "🦹♀️",
|
|
2195
|
+
womanSurfing: "🏄♀️",
|
|
2196
|
+
womanSwimming: "🏊♀️",
|
|
2197
|
+
womanTeacher: "👩🏫",
|
|
2198
|
+
womanTechnologist: "👩💻",
|
|
2199
|
+
womanTippingHand: "💁♀️",
|
|
2200
|
+
womanVampire: "🧛♀️",
|
|
2201
|
+
womanWalking: "🚶♀️",
|
|
2202
|
+
womanWalkingFacingRight: "🚶♀️➡️",
|
|
2203
|
+
womanWearingTurban: "👳♀️",
|
|
2204
|
+
womanWhiteHair: "👩🦳",
|
|
2205
|
+
womanWithHeadscarf: "🧕",
|
|
2206
|
+
womanWithVeil: "👰♀️",
|
|
2207
|
+
womanWithWhiteCane: "👩🦯",
|
|
2208
|
+
womanWithWhiteCaneFacingRight: "👩🦯➡️",
|
|
2209
|
+
womanZombie: "🧟♀️",
|
|
2210
|
+
womenHoldingHands: "👭",
|
|
2211
|
+
womenSRoom: "🚺",
|
|
2212
|
+
womenWithBunnyEars: "👯♀️",
|
|
2213
|
+
womenWrestling: "🤼♀️",
|
|
2214
|
+
wood: "🪵",
|
|
2215
|
+
woozyFace: "🥴",
|
|
2216
|
+
worldMap: "🗺️",
|
|
2217
|
+
worm: "🪱",
|
|
2218
|
+
worriedFace: "😟",
|
|
2219
|
+
wrappedGift: "🎁",
|
|
2220
|
+
wrench: "🔧",
|
|
2221
|
+
writingHand: "✍️",
|
|
2222
|
+
xRay: "🩻",
|
|
2223
|
+
yarn: "🧶",
|
|
2224
|
+
yawningFace: "🥱",
|
|
2225
|
+
yellowCircle: "🟡",
|
|
2226
|
+
yellowHeart: "💛",
|
|
2227
|
+
yellowSquare: "🟨",
|
|
2228
|
+
yenBanknote: "💴",
|
|
2229
|
+
yinYang: "☯️",
|
|
2230
|
+
yoYo: "🪀",
|
|
2231
|
+
zanyFace: "🤪",
|
|
2232
|
+
zebra: "🦓",
|
|
2233
|
+
zipperMouthFace: "🤐",
|
|
2234
|
+
zombie: "🧟",
|
|
2235
|
+
zzz: "💤"
|
|
2236
|
+
};
|
|
2237
|
+
//#endregion
|
|
2238
|
+
//#region src/emoji/index.ts
|
|
2239
|
+
const aliases = {
|
|
2240
|
+
love: GeneratedEmoji.redHeart,
|
|
2241
|
+
like: GeneratedEmoji.thumbsUp,
|
|
2242
|
+
dislike: GeneratedEmoji.thumbsDown,
|
|
2243
|
+
laugh: GeneratedEmoji.faceWithTearsOfJoy,
|
|
2244
|
+
emphasize: GeneratedEmoji.doubleExclamationMark,
|
|
2245
|
+
question: GeneratedEmoji.redQuestionMark
|
|
2246
|
+
};
|
|
2247
|
+
const Emoji = {
|
|
2248
|
+
...GeneratedEmoji,
|
|
2249
|
+
...aliases
|
|
2250
|
+
};
|
|
2251
|
+
//#endregion
|
|
2252
|
+
//#region src/fusor/types.ts
|
|
2253
|
+
const FUSOR_BRAND = Symbol.for("spectrum.fusor.client");
|
|
2254
|
+
//#endregion
|
|
2255
|
+
//#region src/fusor/event.ts
|
|
2256
|
+
const FUSOR_EVENT_BRAND = Symbol.for("spectrum.fusor.event");
|
|
2257
|
+
function fusorEvent(name, data) {
|
|
2258
|
+
return {
|
|
2259
|
+
[FUSOR_EVENT_BRAND]: true,
|
|
2260
|
+
name,
|
|
2261
|
+
data
|
|
2262
|
+
};
|
|
2263
|
+
}
|
|
2264
|
+
function isFusorEvent(value) {
|
|
2265
|
+
return typeof value === "object" && value !== null && value[FUSOR_EVENT_BRAND] === true;
|
|
2266
|
+
}
|
|
2267
|
+
//#endregion
|
|
2268
|
+
//#region src/fusor/index.ts
|
|
2269
|
+
function fusor(platform, verify) {
|
|
2270
|
+
return {
|
|
2271
|
+
[FUSOR_BRAND]: true,
|
|
2272
|
+
platform,
|
|
2273
|
+
verify
|
|
2274
|
+
};
|
|
2275
|
+
}
|
|
2276
|
+
function isFusorClient(value) {
|
|
2277
|
+
return typeof value === "object" && value !== null && value[FUSOR_BRAND] === true;
|
|
2278
|
+
}
|
|
2279
|
+
//#endregion
|
|
2280
|
+
//#region src/utils/errors.ts
|
|
2281
|
+
const composeMessage = (opts) => {
|
|
2282
|
+
return `${opts.platform ?? "platform"} does not support ${opts.kind === "content" ? `content type "${opts.contentType ?? "unknown"}"` : `action "${opts.action ?? "unknown"}"`}${opts.detail ? `: ${opts.detail}` : ""}`;
|
|
2283
|
+
};
|
|
2284
|
+
var UnsupportedError = class UnsupportedError extends Error {
|
|
2285
|
+
kind;
|
|
2286
|
+
platform;
|
|
2287
|
+
contentType;
|
|
2288
|
+
action;
|
|
2289
|
+
detail;
|
|
2290
|
+
constructor(opts) {
|
|
2291
|
+
super(composeMessage(opts));
|
|
2292
|
+
this.name = "UnsupportedError";
|
|
2293
|
+
this.kind = opts.kind;
|
|
2294
|
+
this.platform = opts.platform;
|
|
2295
|
+
this.contentType = opts.contentType;
|
|
2296
|
+
this.action = opts.action;
|
|
2297
|
+
this.detail = opts.detail;
|
|
2298
|
+
}
|
|
2299
|
+
static content(contentType, platform, detail) {
|
|
2300
|
+
return new UnsupportedError({
|
|
2301
|
+
kind: "content",
|
|
2302
|
+
contentType,
|
|
2303
|
+
platform,
|
|
2304
|
+
detail
|
|
2305
|
+
});
|
|
2306
|
+
}
|
|
2307
|
+
static action(action, platform, detail) {
|
|
2308
|
+
return new UnsupportedError({
|
|
2309
|
+
kind: "action",
|
|
2310
|
+
action,
|
|
2311
|
+
platform,
|
|
2312
|
+
detail
|
|
2313
|
+
});
|
|
2314
|
+
}
|
|
2315
|
+
withPlatform(platform) {
|
|
2316
|
+
if (this.platform) return this;
|
|
2317
|
+
return new UnsupportedError({
|
|
2318
|
+
kind: this.kind,
|
|
2319
|
+
platform,
|
|
2320
|
+
contentType: this.contentType,
|
|
2321
|
+
action: this.action,
|
|
2322
|
+
detail: this.detail
|
|
2323
|
+
});
|
|
2324
|
+
}
|
|
2325
|
+
};
|
|
2326
|
+
//#endregion
|
|
2327
|
+
//#region src/platform/build.ts
|
|
2328
|
+
const platformLog$1 = createLogger("spectrum.platform");
|
|
2329
|
+
const FIRE_AND_FORGET_TYPES = new Set([
|
|
2330
|
+
"typing",
|
|
2331
|
+
"edit",
|
|
2332
|
+
"rename",
|
|
2333
|
+
"avatar",
|
|
2334
|
+
"unsend",
|
|
2335
|
+
"read"
|
|
2336
|
+
]);
|
|
2337
|
+
const isFireAndForget = (item) => FIRE_AND_FORGET_TYPES.has(item.type) || item.__fireAndForget === true;
|
|
2338
|
+
const RESERVED_SPACE_KEYS = new Set([
|
|
2339
|
+
"__platform",
|
|
2340
|
+
"id",
|
|
2341
|
+
"send",
|
|
2342
|
+
"edit",
|
|
2343
|
+
"unsend",
|
|
2344
|
+
"read",
|
|
2345
|
+
"getMessage",
|
|
2346
|
+
"rename",
|
|
2347
|
+
"avatar",
|
|
2348
|
+
"startTyping",
|
|
2349
|
+
"stopTyping",
|
|
2350
|
+
"responding"
|
|
2351
|
+
]);
|
|
2352
|
+
const PLATFORM_WISE_ACTION_KEYS = new Set(["getMessage"]);
|
|
2353
|
+
const RESERVED_MESSAGE_KEYS = new Set([
|
|
2354
|
+
"content",
|
|
2355
|
+
"direction",
|
|
2356
|
+
"edit",
|
|
2357
|
+
"id",
|
|
2358
|
+
"platform",
|
|
2359
|
+
"react",
|
|
2360
|
+
"read",
|
|
2361
|
+
"reply",
|
|
2362
|
+
"sender",
|
|
2363
|
+
"space",
|
|
2364
|
+
"timestamp",
|
|
2365
|
+
"unsend"
|
|
2366
|
+
]);
|
|
2367
|
+
const scopeLabel = (scope) => {
|
|
2368
|
+
if (scope === "space") return "Space";
|
|
2369
|
+
if (scope === "message") return "Message";
|
|
2370
|
+
return "PlatformInstance";
|
|
2371
|
+
};
|
|
2372
|
+
const warnReservedAction = (scope, name, platform) => {
|
|
2373
|
+
platformLog$1.warn(`${platform} declared ${scope} action "${name}" which collides with a reserved ${scopeLabel(scope)} key; skipping.`, {
|
|
2374
|
+
"spectrum.provider": platform,
|
|
2375
|
+
"spectrum.reserved.scope": scope,
|
|
2376
|
+
"spectrum.reserved.action": name
|
|
2377
|
+
});
|
|
2378
|
+
};
|
|
2379
|
+
const warnUnsupported = (err, fallbackPlatform) => {
|
|
2380
|
+
const platform = err.platform ?? fallbackPlatform;
|
|
2381
|
+
const subject = err.kind === "content" ? `content type "${err.contentType ?? "unknown"}"` : `action "${err.action ?? "unknown"}"`;
|
|
2382
|
+
const detail = err.detail ? `: ${err.detail}` : "";
|
|
2383
|
+
platformLog$1.warn(`${platform} does not support ${subject}${detail}; skipping.`, {
|
|
2384
|
+
"spectrum.provider": platform,
|
|
2385
|
+
"spectrum.unsupported.kind": err.kind,
|
|
2386
|
+
"spectrum.unsupported.content_type": err.contentType,
|
|
2387
|
+
"spectrum.unsupported.action": err.action
|
|
2388
|
+
});
|
|
2389
|
+
};
|
|
2390
|
+
const contentPlatform = (content) => {
|
|
2391
|
+
const platform = content.__platform;
|
|
2392
|
+
return typeof platform === "string" ? platform : void 0;
|
|
2393
|
+
};
|
|
2394
|
+
const findUnsupportedPlatformContent = (content, platform) => {
|
|
2395
|
+
const scopedPlatform = contentPlatform(content);
|
|
2396
|
+
if (scopedPlatform && scopedPlatform !== platform) return scopedPlatform;
|
|
2397
|
+
if (content.type === "reply" || content.type === "edit") return findUnsupportedPlatformContent(content.content, platform);
|
|
2398
|
+
if (content.type !== "group") return;
|
|
2399
|
+
for (const item of content.items) {
|
|
2400
|
+
const nested = item.content;
|
|
2401
|
+
if (typeof nested !== "object" || nested === null || !("type" in nested)) continue;
|
|
2402
|
+
const unsupported = findUnsupportedPlatformContent(nested, platform);
|
|
2403
|
+
if (unsupported) return unsupported;
|
|
2404
|
+
}
|
|
2405
|
+
};
|
|
2406
|
+
const unsupportedPlatformContentError = (content, platform) => {
|
|
2407
|
+
const requiredPlatform = findUnsupportedPlatformContent(content, platform);
|
|
2408
|
+
if (!requiredPlatform) return;
|
|
2409
|
+
return UnsupportedError.content(content.type, platform, `requires ${requiredPlatform}`);
|
|
2410
|
+
};
|
|
2411
|
+
const findStreamText = (item) => {
|
|
2412
|
+
if (item.type === "streamText") return item;
|
|
2413
|
+
if ((item.type === "reply" || item.type === "edit") && item.content.type === "streamText") return item.content;
|
|
2414
|
+
};
|
|
2415
|
+
const replaceStreamText = (item, source, full) => {
|
|
2416
|
+
const inner = source.format === "markdown" ? asMarkdown(full) : asText(full);
|
|
2417
|
+
if (item.type === "reply" || item.type === "edit") return {
|
|
2418
|
+
...item,
|
|
2419
|
+
content: inner
|
|
2420
|
+
};
|
|
2421
|
+
return inner;
|
|
2422
|
+
};
|
|
2423
|
+
const downgradeMarkdown = (md) => {
|
|
2424
|
+
const plain = markdownToPlainText(md.markdown);
|
|
2425
|
+
return plain ? asText(plain) : void 0;
|
|
2426
|
+
};
|
|
2427
|
+
const replaceMarkdown = (item) => {
|
|
2428
|
+
if (item.type === "markdown") return downgradeMarkdown(item) ?? item;
|
|
2429
|
+
if ((item.type === "reply" || item.type === "edit") && item.content.type === "markdown") {
|
|
2430
|
+
const downgraded = downgradeMarkdown(item.content);
|
|
2431
|
+
return downgraded ? {
|
|
2432
|
+
...item,
|
|
2433
|
+
content: downgraded
|
|
2434
|
+
} : item;
|
|
2435
|
+
}
|
|
2436
|
+
if (item.type === "group") {
|
|
2437
|
+
let changed = false;
|
|
2438
|
+
const items = item.items.map((member) => {
|
|
2439
|
+
if (member.content.type !== "markdown") return member;
|
|
2440
|
+
const downgraded = downgradeMarkdown(member.content);
|
|
2441
|
+
if (!downgraded) return member;
|
|
2442
|
+
changed = true;
|
|
2443
|
+
return {
|
|
2444
|
+
...member,
|
|
2445
|
+
content: downgraded
|
|
2446
|
+
};
|
|
2447
|
+
});
|
|
2448
|
+
return changed ? {
|
|
2449
|
+
...item,
|
|
2450
|
+
items
|
|
2451
|
+
} : item;
|
|
2452
|
+
}
|
|
2453
|
+
return item;
|
|
2454
|
+
};
|
|
2455
|
+
async function resendDrainedStream(send, item, source, platform, unsupported) {
|
|
2456
|
+
platformLog$1.info(`${platform} does not support streaming text; waiting for the stream to finish to send the full text as one message.`, {
|
|
2457
|
+
"spectrum.provider": platform,
|
|
2458
|
+
"spectrum.stream_text.fallback": true
|
|
2459
|
+
});
|
|
2460
|
+
let full;
|
|
2461
|
+
try {
|
|
2462
|
+
full = await drainStreamText(source);
|
|
2463
|
+
} catch (drainErr) {
|
|
2464
|
+
if (drainErr instanceof StreamConsumedError) throw unsupported;
|
|
2465
|
+
throw drainErr;
|
|
2466
|
+
}
|
|
2467
|
+
if (!full) throw unsupported;
|
|
2468
|
+
return await sendWithFallbacks(send, replaceStreamText(item, source, full), platform);
|
|
2469
|
+
}
|
|
2470
|
+
/**
|
|
2471
|
+
* Dispatch `content` to the provider, downgrading on platforms that reject it
|
|
2472
|
+
* with `UnsupportedError`:
|
|
2473
|
+
*
|
|
2474
|
+
* - `streamText` (top-level or inside `reply`/`edit`): wait for the stream to
|
|
2475
|
+
* finish and re-send the accumulated text — as `markdown` content for a
|
|
2476
|
+
* markdown-formatted stream, plain `text` otherwise — so `streamText`
|
|
2477
|
+
* works everywhere, just without live updates.
|
|
2478
|
+
* - `markdown` (top-level, inside `reply`/`edit`, or a `group` item): re-send
|
|
2479
|
+
* with each markdown occurrence rendered to readable plain text — so
|
|
2480
|
+
* `markdown` works everywhere, just without styling.
|
|
2481
|
+
*
|
|
2482
|
+
* The two chain rather than compete: a drained markdown stream re-enters this
|
|
2483
|
+
* function as `markdown` content and can downgrade once more to plain text.
|
|
2484
|
+
* Rethrows the original error when no fallback applies; an `UnsupportedError`
|
|
2485
|
+
* from the final fallback send itself propagates too. Both land in the
|
|
2486
|
+
* caller's warn-and-skip handling.
|
|
2487
|
+
*/
|
|
2488
|
+
async function sendWithFallbacks(send, item, platform) {
|
|
2489
|
+
try {
|
|
2490
|
+
return await send(item);
|
|
2491
|
+
} catch (err) {
|
|
2492
|
+
if (!(err instanceof UnsupportedError)) throw err;
|
|
2493
|
+
const source = findStreamText(item);
|
|
2494
|
+
if (source) return await resendDrainedStream(send, item, source, platform, err);
|
|
2495
|
+
const downgraded = replaceMarkdown(item);
|
|
2496
|
+
if (downgraded === item) throw err;
|
|
2497
|
+
platformLog$1.info(`${platform} does not support markdown; sending the content as plain text instead.`, {
|
|
2498
|
+
"spectrum.provider": platform,
|
|
2499
|
+
"spectrum.markdown.fallback": true
|
|
2500
|
+
});
|
|
2501
|
+
return await send(downgraded);
|
|
2502
|
+
}
|
|
2503
|
+
}
|
|
2504
|
+
const providerMessageCoreKeys = new Set([
|
|
2505
|
+
"content",
|
|
2506
|
+
"direction",
|
|
2507
|
+
"id",
|
|
2508
|
+
"sender",
|
|
2509
|
+
"space",
|
|
2510
|
+
"timestamp"
|
|
2511
|
+
]);
|
|
2512
|
+
const extractExtras = (raw, definition) => {
|
|
2513
|
+
const entries = Object.entries(raw).filter(([key]) => !providerMessageCoreKeys.has(key));
|
|
2514
|
+
const extra = Object.fromEntries(entries);
|
|
2515
|
+
return definition.message?.schema ? definition.message.schema.parse(extra) : extra;
|
|
2516
|
+
};
|
|
2517
|
+
const rawDirection = (raw) => raw.direction === "inbound" || raw.direction === "outbound" ? raw.direction : void 0;
|
|
2518
|
+
/**
|
|
2519
|
+
* Wrap a raw provider message record (and any nested raw targets/items inside
|
|
2520
|
+
* its content) into a fully-built `Message`. The same path serves inbound
|
|
2521
|
+
* (`messages`, `actions.getMessage`) and outbound (`send`) flows — the only
|
|
2522
|
+
* difference is `direction`, which decides whether the resulting Message
|
|
2523
|
+
* exposes inbound (`react`/`reply`) or outbound (`edit`) affordances. A raw
|
|
2524
|
+
* record can carry its own `direction` when the provider knows better than the
|
|
2525
|
+
* wrapping context, which matters for inbound reactions targeting outbound
|
|
2526
|
+
* messages.
|
|
2527
|
+
* Recursion through `wrapNestedContent` handles reaction targets and group
|
|
2528
|
+
* items, which providers return as nested raw records.
|
|
2529
|
+
*/
|
|
2530
|
+
function wrapProviderMessage(raw, ctx, direction) {
|
|
2531
|
+
const effectiveDirection = rawDirection(raw) ?? direction;
|
|
2532
|
+
const wrappedContent = wrapNestedContent(raw.content, ctx, effectiveDirection);
|
|
2533
|
+
const base = {
|
|
2534
|
+
id: raw.id,
|
|
2535
|
+
content: wrappedContent,
|
|
2536
|
+
timestamp: raw.timestamp ?? /* @__PURE__ */ new Date(),
|
|
2537
|
+
extras: extractExtras(raw, ctx.definition),
|
|
2538
|
+
spaceRef: ctx.spaceRef,
|
|
2539
|
+
space: ctx.space,
|
|
2540
|
+
definition: ctx.definition,
|
|
2541
|
+
client: ctx.client,
|
|
2542
|
+
config: ctx.config,
|
|
2543
|
+
store: ctx.store
|
|
2544
|
+
};
|
|
2545
|
+
if (effectiveDirection === "inbound") return buildMessage({
|
|
2546
|
+
...base,
|
|
2547
|
+
sender: raw.sender,
|
|
2548
|
+
direction: "inbound"
|
|
2549
|
+
});
|
|
2550
|
+
return buildMessage({
|
|
2551
|
+
...base,
|
|
2552
|
+
sender: raw.sender,
|
|
2553
|
+
direction: "outbound"
|
|
2554
|
+
});
|
|
2555
|
+
}
|
|
2556
|
+
const wrapNestedContent = (content, ctx, direction) => {
|
|
2557
|
+
if (content.type === "reaction") {
|
|
2558
|
+
const target = content.target;
|
|
2559
|
+
if (isRawProviderRecord(target)) return {
|
|
2560
|
+
...content,
|
|
2561
|
+
target: wrapProviderMessage(target, ctx, "inbound")
|
|
2562
|
+
};
|
|
2563
|
+
return content;
|
|
2564
|
+
}
|
|
2565
|
+
if (content.type === "edit") {
|
|
2566
|
+
const target = content.target;
|
|
2567
|
+
if (isRawProviderRecord(target)) return {
|
|
2568
|
+
...content,
|
|
2569
|
+
target: wrapProviderMessage(target, ctx, "outbound")
|
|
2570
|
+
};
|
|
2571
|
+
return content;
|
|
2572
|
+
}
|
|
2573
|
+
if (content.type === "group") {
|
|
2574
|
+
const items = content.items.map((item) => {
|
|
2575
|
+
const raw = item;
|
|
2576
|
+
if (!isRawProviderRecord(raw)) return item;
|
|
2577
|
+
return direction === "inbound" ? wrapProviderMessage(raw, ctx, "inbound") : wrapProviderMessage(raw, ctx, "outbound");
|
|
2578
|
+
});
|
|
2579
|
+
return {
|
|
2580
|
+
...content,
|
|
2581
|
+
items
|
|
2582
|
+
};
|
|
2583
|
+
}
|
|
2584
|
+
return content;
|
|
2585
|
+
};
|
|
2586
|
+
const isRawProviderRecord = (v) => {
|
|
2587
|
+
if (typeof v !== "object" || v === null) return false;
|
|
2588
|
+
const record = v;
|
|
2589
|
+
return "id" in record && "content" in record && typeof record.react !== "function" && typeof record.reply !== "function";
|
|
2590
|
+
};
|
|
2591
|
+
function buildSpace(params) {
|
|
2592
|
+
const { spaceRef, extras, actionCtx, definition, client, config, store } = params;
|
|
2593
|
+
let space;
|
|
2594
|
+
async function dispatchSend(item) {
|
|
2595
|
+
return withSpan("spectrum.message.send", {
|
|
2596
|
+
"spectrum.provider": definition.name,
|
|
2597
|
+
"spectrum.space.id": spaceRef.id,
|
|
2598
|
+
"spectrum.message.fire_and_forget": isFireAndForget(item),
|
|
2599
|
+
...contentAttrs(item)
|
|
2600
|
+
}, async () => {
|
|
2601
|
+
const platformError = unsupportedPlatformContentError(item, definition.name);
|
|
2602
|
+
if (platformError) {
|
|
2603
|
+
warnUnsupported(platformError, definition.name);
|
|
2604
|
+
return;
|
|
2605
|
+
}
|
|
2606
|
+
const providerSend = async (content) => await definition.send({
|
|
2607
|
+
...actionCtx,
|
|
2608
|
+
content
|
|
2609
|
+
});
|
|
2610
|
+
let raw;
|
|
2611
|
+
try {
|
|
2612
|
+
raw = await sendWithFallbacks(providerSend, item, definition.name);
|
|
2613
|
+
} catch (err) {
|
|
2614
|
+
if (err instanceof UnsupportedError) {
|
|
2615
|
+
warnUnsupported(err, definition.name);
|
|
2616
|
+
return;
|
|
2617
|
+
}
|
|
2618
|
+
throw err;
|
|
2619
|
+
}
|
|
2620
|
+
if (!raw?.id) {
|
|
2621
|
+
if (isFireAndForget(item)) return;
|
|
2622
|
+
throw new Error(`Platform "${definition.name}" send did not return a message id`);
|
|
2623
|
+
}
|
|
2624
|
+
return wrapProviderMessage(raw, {
|
|
2625
|
+
client,
|
|
2626
|
+
config,
|
|
2627
|
+
definition,
|
|
2628
|
+
space,
|
|
2629
|
+
spaceRef,
|
|
2630
|
+
store
|
|
2631
|
+
}, "outbound");
|
|
2632
|
+
});
|
|
2633
|
+
}
|
|
2634
|
+
async function sendImpl(...content) {
|
|
2635
|
+
const resolved = await resolveContents(content);
|
|
2636
|
+
const results = [];
|
|
2637
|
+
for (const item of resolved) {
|
|
2638
|
+
const sent = await dispatchSend(item);
|
|
2639
|
+
if (sent) results.push(sent);
|
|
2640
|
+
}
|
|
2641
|
+
if (content.length === 1) return results[0];
|
|
2642
|
+
return results;
|
|
2643
|
+
}
|
|
2644
|
+
async function getMessageImpl(id) {
|
|
2645
|
+
const getMessage = definition.actions?.getMessage;
|
|
2646
|
+
if (!getMessage) throw UnsupportedError.action("getMessage", definition.name);
|
|
2647
|
+
return withSpan("spectrum.message.get", {
|
|
2648
|
+
"spectrum.provider": definition.name,
|
|
2649
|
+
"spectrum.space.id": spaceRef.id,
|
|
2650
|
+
"spectrum.message.id": id
|
|
2651
|
+
}, async () => {
|
|
2652
|
+
const raw = await getMessage({
|
|
2653
|
+
client,
|
|
2654
|
+
config,
|
|
2655
|
+
store
|
|
2656
|
+
}, spaceRef, id);
|
|
2657
|
+
if (!raw) return;
|
|
2658
|
+
return wrapProviderMessage(raw, {
|
|
2659
|
+
client,
|
|
2660
|
+
config,
|
|
2661
|
+
definition,
|
|
2662
|
+
space,
|
|
2663
|
+
spaceRef,
|
|
2664
|
+
store
|
|
2665
|
+
}, "inbound");
|
|
2666
|
+
});
|
|
2667
|
+
}
|
|
2668
|
+
const platformActions = {};
|
|
2669
|
+
const declaredActions = definition.space.actions;
|
|
2670
|
+
if (declaredActions) for (const [name, factory] of Object.entries(declaredActions)) {
|
|
2671
|
+
if (RESERVED_SPACE_KEYS.has(name)) {
|
|
2672
|
+
warnReservedAction("space", name, definition.name);
|
|
2673
|
+
continue;
|
|
2674
|
+
}
|
|
2675
|
+
platformActions[name] = async (...args) => {
|
|
2676
|
+
await factory(space, ...args);
|
|
2677
|
+
};
|
|
2678
|
+
}
|
|
2679
|
+
space = {
|
|
2680
|
+
...extras,
|
|
2681
|
+
...spaceRef,
|
|
2682
|
+
...platformActions,
|
|
2683
|
+
send: sendImpl,
|
|
2684
|
+
edit: async (message, newContent) => {
|
|
2685
|
+
await space.send(edit(newContent, message));
|
|
2686
|
+
},
|
|
2687
|
+
unsend: async (message) => {
|
|
2688
|
+
await space.send(unsend(message));
|
|
2689
|
+
},
|
|
2690
|
+
read: async (message) => {
|
|
2691
|
+
await space.send(read(message));
|
|
2692
|
+
},
|
|
2693
|
+
getMessage: getMessageImpl,
|
|
2694
|
+
rename: async (displayName) => {
|
|
2695
|
+
await space.send(rename(displayName));
|
|
2696
|
+
},
|
|
2697
|
+
avatar: (async (input, options) => {
|
|
2698
|
+
if (typeof input === "string" || input instanceof URL) {
|
|
2699
|
+
await space.send(avatar(input, options));
|
|
2700
|
+
return;
|
|
2701
|
+
}
|
|
2702
|
+
if (!options?.mimeType) throw new Error("space.avatar(Buffer) requires options.mimeType — pass { mimeType: '...' }");
|
|
2703
|
+
await space.send(avatar(input, { mimeType: options.mimeType }));
|
|
2704
|
+
}),
|
|
2705
|
+
startTyping: async () => {
|
|
2706
|
+
await space.send(typing("start"));
|
|
2707
|
+
},
|
|
2708
|
+
stopTyping: async () => {
|
|
2709
|
+
await space.send(typing("stop"));
|
|
2710
|
+
},
|
|
2711
|
+
responding: async (fn) => {
|
|
2712
|
+
await space.send(typing("start"));
|
|
2713
|
+
try {
|
|
2714
|
+
return await fn();
|
|
2715
|
+
} finally {
|
|
2716
|
+
await space.send(typing("stop")).catch(() => {});
|
|
2717
|
+
}
|
|
2718
|
+
}
|
|
2719
|
+
};
|
|
2720
|
+
return space;
|
|
2721
|
+
}
|
|
2722
|
+
function buildMessage(params) {
|
|
2723
|
+
const { definition, space } = params;
|
|
2724
|
+
let self;
|
|
2725
|
+
const requireBuiltMessage = (action) => {
|
|
2726
|
+
if (!self) throw new Error(`${action}() called before message construction completed (internal bug)`);
|
|
2727
|
+
return self;
|
|
2728
|
+
};
|
|
2729
|
+
const react = async (emoji) => {
|
|
2730
|
+
const target = requireBuiltMessage("react");
|
|
2731
|
+
return await space.send(reaction(emoji, target));
|
|
2732
|
+
};
|
|
2733
|
+
async function reply$1(...content) {
|
|
2734
|
+
const target = requireBuiltMessage("reply");
|
|
2735
|
+
const wrapped = content.map((c) => reply(c, target));
|
|
2736
|
+
return space.send(...wrapped);
|
|
2737
|
+
}
|
|
2738
|
+
const edit$1 = async (newContent) => {
|
|
2739
|
+
const target = requireBuiltMessage("edit");
|
|
2740
|
+
if (target.direction !== "outbound") throw new Error(`cannot edit message ${target.id}: only outbound messages can be edited (direction: "${target.direction}")`);
|
|
2741
|
+
await space.send(edit(newContent, target));
|
|
2742
|
+
};
|
|
2743
|
+
const unsend$1 = async () => {
|
|
2744
|
+
const target = requireBuiltMessage("unsend");
|
|
2745
|
+
if (target.direction !== "outbound") throw new Error(`cannot unsend message ${target.id}: only outbound messages can be unsent (direction: "${target.direction}")`);
|
|
2746
|
+
await space.send(unsend(target));
|
|
2747
|
+
};
|
|
2748
|
+
const read$1 = async () => {
|
|
2749
|
+
const target = requireBuiltMessage("read");
|
|
2750
|
+
if (target.direction !== "inbound") throw new Error(`cannot mark message ${target.id} as read: only inbound messages can be marked read (direction: "${target.direction}")`);
|
|
2751
|
+
await space.send(read(target));
|
|
2752
|
+
};
|
|
2753
|
+
const buildSenderWithPlatform = () => {
|
|
2754
|
+
if (params.sender === void 0) return;
|
|
2755
|
+
if (params.direction === "outbound") return {
|
|
2756
|
+
...params.sender,
|
|
2757
|
+
__platform: definition.name,
|
|
2758
|
+
kind: "agent"
|
|
2759
|
+
};
|
|
2760
|
+
return {
|
|
2761
|
+
...params.sender,
|
|
2762
|
+
__platform: definition.name
|
|
2763
|
+
};
|
|
2764
|
+
};
|
|
2765
|
+
const senderWithPlatform = buildSenderWithPlatform();
|
|
2766
|
+
const messagePlatformActions = {};
|
|
2767
|
+
const declaredMessageActions = definition.message?.actions;
|
|
2768
|
+
if (declaredMessageActions) for (const [name, factory] of Object.entries(declaredMessageActions)) {
|
|
2769
|
+
if (RESERVED_MESSAGE_KEYS.has(name)) {
|
|
2770
|
+
warnReservedAction("message", name, definition.name);
|
|
2771
|
+
continue;
|
|
2772
|
+
}
|
|
2773
|
+
messagePlatformActions[name] = async (...args) => {
|
|
2774
|
+
await factory(requireBuiltMessage(name), ...args);
|
|
2775
|
+
};
|
|
2776
|
+
}
|
|
2777
|
+
const message = {
|
|
2778
|
+
...params.extras,
|
|
2779
|
+
...messagePlatformActions,
|
|
2780
|
+
id: params.id,
|
|
2781
|
+
content: params.content,
|
|
2782
|
+
direction: params.direction,
|
|
2783
|
+
platform: definition.name,
|
|
2784
|
+
react,
|
|
2785
|
+
read: read$1,
|
|
2786
|
+
reply: reply$1,
|
|
2787
|
+
edit: edit$1,
|
|
2788
|
+
unsend: unsend$1,
|
|
2789
|
+
sender: senderWithPlatform,
|
|
2790
|
+
space,
|
|
2791
|
+
timestamp: params.timestamp
|
|
2792
|
+
};
|
|
2793
|
+
self = message;
|
|
2794
|
+
return message;
|
|
2795
|
+
}
|
|
2796
|
+
//#endregion
|
|
2797
|
+
//#region src/platform/define.ts
|
|
2798
|
+
const platformLog = createLogger("spectrum.platform");
|
|
2799
|
+
function buildInstanceActions(platformName, declared, reservedKeys, buildCtx) {
|
|
2800
|
+
const out = {};
|
|
2801
|
+
for (const key of PLATFORM_WISE_ACTION_KEYS) {
|
|
2802
|
+
const override = declared?.[key];
|
|
2803
|
+
if (override && typeof override === "function") out[key] = (...args) => override(buildCtx(), ...args);
|
|
2804
|
+
else out[key] = () => {
|
|
2805
|
+
throw UnsupportedError.action(key, platformName);
|
|
2806
|
+
};
|
|
2807
|
+
}
|
|
2808
|
+
if (!declared) return out;
|
|
2809
|
+
for (const [name, factory] of Object.entries(declared)) {
|
|
2810
|
+
if (PLATFORM_WISE_ACTION_KEYS.has(name)) continue;
|
|
2811
|
+
if (reservedKeys.has(name)) {
|
|
2812
|
+
warnReservedAction("instance", name, platformName);
|
|
2813
|
+
continue;
|
|
2814
|
+
}
|
|
2815
|
+
if (typeof factory !== "function") continue;
|
|
2816
|
+
out[name] = (...args) => factory(buildCtx(), ...args);
|
|
2817
|
+
}
|
|
2818
|
+
return out;
|
|
2819
|
+
}
|
|
2820
|
+
function createPlatformInstance(def, runtime) {
|
|
2821
|
+
const resolveUserID = async (userID) => {
|
|
2822
|
+
return {
|
|
2823
|
+
...await def.user.resolve({
|
|
2824
|
+
input: { userID },
|
|
2825
|
+
client: runtime.client,
|
|
2826
|
+
config: runtime.config,
|
|
2827
|
+
store: runtime.store
|
|
2828
|
+
}),
|
|
2829
|
+
__platform: def.name
|
|
2830
|
+
};
|
|
2831
|
+
};
|
|
2832
|
+
const providerCtx = () => ({
|
|
2833
|
+
client: runtime.client,
|
|
2834
|
+
config: runtime.config,
|
|
2835
|
+
store: runtime.store
|
|
2836
|
+
});
|
|
2837
|
+
const parseSpaceParams = (params) => params !== void 0 && def.space.params ? def.space.params.parse(params) : params;
|
|
2838
|
+
const finalizeSpace = (resolved) => {
|
|
2839
|
+
const parsedSpace = def.space.schema ? def.space.schema.parse(resolved) : resolved;
|
|
2840
|
+
const spaceRef = {
|
|
2841
|
+
...parsedSpace,
|
|
2842
|
+
id: parsedSpace.id,
|
|
2843
|
+
__platform: def.name
|
|
2844
|
+
};
|
|
2845
|
+
return buildSpace({
|
|
2846
|
+
spaceRef,
|
|
2847
|
+
extras: parsedSpace,
|
|
2848
|
+
actionCtx: {
|
|
2849
|
+
space: spaceRef,
|
|
2850
|
+
...providerCtx()
|
|
2851
|
+
},
|
|
2852
|
+
definition: def,
|
|
2853
|
+
client: runtime.client,
|
|
2854
|
+
config: runtime.config,
|
|
2855
|
+
store: runtime.store
|
|
2856
|
+
});
|
|
2857
|
+
};
|
|
2858
|
+
const base = {
|
|
2859
|
+
async user(userID) {
|
|
2860
|
+
return await resolveUserID(userID);
|
|
2861
|
+
},
|
|
2862
|
+
space: {
|
|
2863
|
+
create: async (users, params) => {
|
|
2864
|
+
const userList = Array.isArray(users) ? users : [users];
|
|
2865
|
+
const first = userList.length === 1 ? userList[0] : void 0;
|
|
2866
|
+
const single = typeof first === "string" ? classifyIdentifier(first) : void 0;
|
|
2867
|
+
const kind = userList.length > 1 ? "group" : single?.kind ?? "unknown";
|
|
2868
|
+
return await withSpan("spectrum.space.create", {
|
|
2869
|
+
"spectrum.provider": def.name,
|
|
2870
|
+
"spectrum.space.user_count": userList.length,
|
|
2871
|
+
"spectrum.space.identifier_kind": kind,
|
|
2872
|
+
"spectrum.space.identifier": kind === "unknown" ? void 0 : single?.identifier
|
|
2873
|
+
}, async () => {
|
|
2874
|
+
const resolvedUsers = await Promise.all(userList.map((u) => typeof u === "string" ? resolveUserID(u) : u));
|
|
2875
|
+
return finalizeSpace(await def.space.create({
|
|
2876
|
+
input: {
|
|
2877
|
+
users: resolvedUsers,
|
|
2878
|
+
params: parseSpaceParams(params)
|
|
2879
|
+
},
|
|
2880
|
+
...providerCtx()
|
|
2881
|
+
}));
|
|
2882
|
+
});
|
|
2883
|
+
},
|
|
2884
|
+
get: async (id, params) => await withSpan("spectrum.space.get", {
|
|
2885
|
+
"spectrum.provider": def.name,
|
|
2886
|
+
"spectrum.space.id": id
|
|
2887
|
+
}, async () => {
|
|
2888
|
+
const parsedParams = parseSpaceParams(params);
|
|
2889
|
+
if (def.space.get) return finalizeSpace(await def.space.get({
|
|
2890
|
+
input: {
|
|
2891
|
+
id,
|
|
2892
|
+
params: parsedParams
|
|
2893
|
+
},
|
|
2894
|
+
...providerCtx()
|
|
2895
|
+
}));
|
|
2896
|
+
const candidate = { id };
|
|
2897
|
+
if (def.space.schema) {
|
|
2898
|
+
const parsed = def.space.schema.safeParse(candidate);
|
|
2899
|
+
if (!parsed.success) throw new Error(`Platform "${def.name}" cannot construct a space from an id alone — its space schema requires more fields. Implement \`space.get\` in the "${def.name}" provider definition.`, { cause: parsed.error });
|
|
2900
|
+
}
|
|
2901
|
+
return finalizeSpace(candidate);
|
|
2902
|
+
})
|
|
2903
|
+
}
|
|
2904
|
+
};
|
|
2905
|
+
const eventProperties = {};
|
|
2906
|
+
const customEvents = def.events ?? {};
|
|
2907
|
+
for (const eventName of Object.keys(customEvents)) {
|
|
2908
|
+
const declared = customEvents[eventName];
|
|
2909
|
+
if (typeof declared === "function") {
|
|
2910
|
+
eventProperties[eventName] = declared({
|
|
2911
|
+
client: runtime.client,
|
|
2912
|
+
config: runtime.config,
|
|
2913
|
+
projectConfig: runtime.projectConfig,
|
|
2914
|
+
store: runtime.store
|
|
2915
|
+
});
|
|
2916
|
+
continue;
|
|
2917
|
+
}
|
|
2918
|
+
const fusorEvents = runtime.subscribeEvent?.(eventName);
|
|
2919
|
+
if (fusorEvents) eventProperties[eventName] = fusorEvents;
|
|
2920
|
+
}
|
|
2921
|
+
let messagesIterable;
|
|
2922
|
+
Object.defineProperty(base, "messages", {
|
|
2923
|
+
enumerable: true,
|
|
2924
|
+
get() {
|
|
2925
|
+
messagesIterable ??= runtime.subscribeMessages();
|
|
2926
|
+
return messagesIterable;
|
|
2927
|
+
}
|
|
2928
|
+
});
|
|
2929
|
+
const instanceActions = buildInstanceActions(def.name, def.actions, new Set([
|
|
2930
|
+
"user",
|
|
2931
|
+
"space",
|
|
2932
|
+
"messages",
|
|
2933
|
+
...Object.keys(customEvents)
|
|
2934
|
+
]), () => ({
|
|
2935
|
+
client: runtime.client,
|
|
2936
|
+
config: runtime.config,
|
|
2937
|
+
store: runtime.store
|
|
2938
|
+
}));
|
|
2939
|
+
return Object.assign(base, instanceActions, eventProperties);
|
|
2940
|
+
}
|
|
2941
|
+
function definePlatform(name, rawDef) {
|
|
2942
|
+
const def = rawDef;
|
|
2943
|
+
const fullDef = {
|
|
2944
|
+
...def,
|
|
2945
|
+
name
|
|
2946
|
+
};
|
|
2947
|
+
const platformCache = /* @__PURE__ */ new WeakMap();
|
|
2948
|
+
const narrowSpectrum = (spectrum) => {
|
|
2949
|
+
const cached = platformCache.get(spectrum);
|
|
2950
|
+
if (cached) return cached;
|
|
2951
|
+
const runtime = spectrum.__internal.platforms.get(name);
|
|
2952
|
+
if (!runtime) throw new Error(`Platform "${name}" is not registered`);
|
|
2953
|
+
const instance = createPlatformInstance(fullDef, runtime);
|
|
2954
|
+
platformCache.set(spectrum, instance);
|
|
2955
|
+
return instance;
|
|
2956
|
+
};
|
|
2957
|
+
const narrowSpace = (input) => {
|
|
2958
|
+
if (input.__platform !== name) platformLog.warn("space platform mismatch; narrowing skipped", {
|
|
2959
|
+
"spectrum.platform.expected": name,
|
|
2960
|
+
"spectrum.platform.actual": input.__platform
|
|
2961
|
+
});
|
|
2962
|
+
return input;
|
|
2963
|
+
};
|
|
2964
|
+
const narrowMessage = (input) => {
|
|
2965
|
+
if (input.platform !== name) platformLog.warn("message platform mismatch; narrowing skipped", {
|
|
2966
|
+
"spectrum.platform.expected": name,
|
|
2967
|
+
"spectrum.platform.actual": input.platform
|
|
2968
|
+
});
|
|
2969
|
+
return input;
|
|
2970
|
+
};
|
|
2971
|
+
const narrower = ((input) => {
|
|
2972
|
+
if ("__providers" in input && "__internal" in input) return narrowSpectrum(input);
|
|
2973
|
+
if ("__platform" in input && "send" in input) return narrowSpace(input);
|
|
2974
|
+
if ("platform" in input && "sender" in input && "space" in input) return narrowMessage(input);
|
|
2975
|
+
throw new Error("Invalid input to platform narrowing function");
|
|
2976
|
+
});
|
|
2977
|
+
narrower.config = (config) => {
|
|
2978
|
+
return {
|
|
2979
|
+
__tag: "PlatformProviderConfig",
|
|
2980
|
+
__def: void 0,
|
|
2981
|
+
__name: name,
|
|
2982
|
+
config: config ?? {},
|
|
2983
|
+
__definition: fullDef
|
|
2984
|
+
};
|
|
2985
|
+
};
|
|
2986
|
+
narrower.is = ((input) => {
|
|
2987
|
+
if (typeof input !== "object" || input === null) return false;
|
|
2988
|
+
if ("__platform" in input) return input.__platform === name;
|
|
2989
|
+
if ("platform" in input) return input.platform === name;
|
|
2990
|
+
return false;
|
|
2991
|
+
});
|
|
2992
|
+
if (def.static) Object.assign(narrower, def.static);
|
|
2993
|
+
return narrower;
|
|
2994
|
+
}
|
|
2995
|
+
//#endregion
|
|
2996
|
+
//#region src/build-env.ts
|
|
2997
|
+
const SPECTRUM_SDK_VERSION = "5.0.0";
|
|
2998
|
+
//#endregion
|
|
2999
|
+
//#region src/utils/provider-packages.ts
|
|
3000
|
+
const OFFICIAL_PROVIDER_PACKAGES = {
|
|
3001
|
+
imessage: "@spectrum-ts/imessage",
|
|
3002
|
+
slack: "@spectrum-ts/slack",
|
|
3003
|
+
telegram: "@spectrum-ts/telegram",
|
|
3004
|
+
terminal: "@spectrum-ts/terminal",
|
|
3005
|
+
"whatsapp-business": "@spectrum-ts/whatsapp-business"
|
|
3006
|
+
};
|
|
3007
|
+
const SEPARATORS = /[\s_]+/g;
|
|
3008
|
+
const normalizePlatformKey = (platform) => platform.trim().toLowerCase().replace(SEPARATORS, "-");
|
|
3009
|
+
const officialProviderPackage = (platform) => OFFICIAL_PROVIDER_PACKAGES[normalizePlatformKey(platform)];
|
|
3010
|
+
const installCommand = (pkg) => process.versions.bun ? `bun add ${pkg}` : `npm install ${pkg}`;
|
|
3011
|
+
/**
|
|
3012
|
+
* One-line install hint for a platform provided by an official package, or
|
|
3013
|
+
* undefined for unknown/custom platforms. Appended to "no handler" style
|
|
3014
|
+
* warnings — advisory only, callers must not change behavior based on it.
|
|
3015
|
+
*/
|
|
3016
|
+
const officialProviderInstallHint = (platform) => {
|
|
3017
|
+
const pkg = officialProviderPackage(platform);
|
|
3018
|
+
if (!pkg) return;
|
|
3019
|
+
return `the "${platform}" platform is provided by the optional package ${pkg} — install it (\`${installCommand(pkg)}\`) and pass it to Spectrum({ providers: [...] })`;
|
|
3020
|
+
};
|
|
3021
|
+
//#endregion
|
|
3022
|
+
//#region src/utils/cloud.ts
|
|
3023
|
+
const SPECTRUM_CLOUD_URL = process.env.SPECTRUM_CLOUD_URL ?? "https://spectrum.photon.codes";
|
|
3024
|
+
var SpectrumCloudError = class extends Error {
|
|
3025
|
+
status;
|
|
3026
|
+
code;
|
|
3027
|
+
constructor(status, code, message) {
|
|
3028
|
+
super(message);
|
|
3029
|
+
this.name = "SpectrumCloudError";
|
|
3030
|
+
this.status = status;
|
|
3031
|
+
this.code = code;
|
|
3032
|
+
}
|
|
3033
|
+
};
|
|
3034
|
+
const request = async (path, init) => {
|
|
3035
|
+
const response = await fetch(`${SPECTRUM_CLOUD_URL}${path}`, init);
|
|
3036
|
+
if (!response.ok) {
|
|
3037
|
+
const body = await response.text().catch(() => "");
|
|
3038
|
+
try {
|
|
3039
|
+
const parsed = JSON.parse(body);
|
|
3040
|
+
throw new SpectrumCloudError(response.status, parsed.code, parsed.message);
|
|
3041
|
+
} catch (error) {
|
|
3042
|
+
if (error instanceof SpectrumCloudError) throw error;
|
|
3043
|
+
throw new SpectrumCloudError(response.status, "UNKNOWN", body || response.statusText);
|
|
3044
|
+
}
|
|
3045
|
+
}
|
|
3046
|
+
const json = await response.json();
|
|
3047
|
+
if (!json.succeed) throw new SpectrumCloudError(response.status, "UNKNOWN", "Server returned succeed=false");
|
|
3048
|
+
return json.data;
|
|
3049
|
+
};
|
|
3050
|
+
const basicAuth = (projectId, projectSecret) => `Basic ${btoa(`${projectId}:${projectSecret}`)}`;
|
|
3051
|
+
const cloud = {
|
|
3052
|
+
getProject: (projectId, projectSecret) => request(`/projects/${projectId}/`, { headers: { Authorization: basicAuth(projectId, projectSecret) } }),
|
|
3053
|
+
getSubscription: (projectId) => request(`/projects/${projectId}/billing/subscription`),
|
|
3054
|
+
issueImessageTokens: (projectId, projectSecret) => request(`/projects/${projectId}/imessage/tokens`, {
|
|
3055
|
+
method: "POST",
|
|
3056
|
+
headers: { Authorization: basicAuth(projectId, projectSecret) }
|
|
3057
|
+
}),
|
|
3058
|
+
getImessageInfo: (projectId) => request(`/projects/${projectId}/imessage/`),
|
|
3059
|
+
issueWhatsappBusinessTokens: (projectId, projectSecret) => request(`/projects/${projectId}/whatsapp-business/tokens`, {
|
|
3060
|
+
method: "POST",
|
|
3061
|
+
headers: { Authorization: basicAuth(projectId, projectSecret) }
|
|
3062
|
+
}),
|
|
3063
|
+
issueSlackTokens: (projectId, projectSecret) => request(`/projects/${projectId}/slack/tokens`, {
|
|
3064
|
+
method: "POST",
|
|
3065
|
+
headers: { Authorization: basicAuth(projectId, projectSecret) }
|
|
3066
|
+
}),
|
|
3067
|
+
issueFusorToken: (projectId, projectSecret) => request(`/projects/${projectId}/fusor/token`, {
|
|
3068
|
+
method: "POST",
|
|
3069
|
+
headers: { Authorization: basicAuth(projectId, projectSecret) }
|
|
3070
|
+
}),
|
|
3071
|
+
getPlatforms: (projectId) => request(`/projects/${projectId}/platforms/`),
|
|
3072
|
+
togglePlatform: (projectId, projectSecret, platform, enabled) => request(`/projects/${projectId}/platforms/`, {
|
|
3073
|
+
method: "PATCH",
|
|
3074
|
+
headers: {
|
|
3075
|
+
Authorization: basicAuth(projectId, projectSecret),
|
|
3076
|
+
"Content-Type": "application/json"
|
|
3077
|
+
},
|
|
3078
|
+
body: JSON.stringify({
|
|
3079
|
+
platform,
|
|
3080
|
+
enabled
|
|
3081
|
+
})
|
|
3082
|
+
})
|
|
3083
|
+
};
|
|
3084
|
+
//#endregion
|
|
3085
|
+
//#region src/fusor/auth.ts
|
|
3086
|
+
const log$2 = createLogger("spectrum.fusor.auth");
|
|
3087
|
+
const RENEWAL_RATIO = .8;
|
|
3088
|
+
const EXPIRY_BUFFER_MS = 3e4;
|
|
3089
|
+
const RETRY_DELAY_MS = 3e4;
|
|
3090
|
+
/**
|
|
3091
|
+
* Single-token provider for the fusor stream. Mirrors the renewal cadence
|
|
3092
|
+
* of the slack provider package's auth but without per-team bookkeeping —
|
|
3093
|
+
* fusor issues one bearer JWT per project.
|
|
3094
|
+
*/
|
|
3095
|
+
function createFusorTokenProvider(projectId, projectSecret) {
|
|
3096
|
+
return (async () => {
|
|
3097
|
+
let tokenData = await cloud.issueFusorToken(projectId, projectSecret);
|
|
3098
|
+
let tokenExpiresAt = Date.now() + tokenData.expiresIn * 1e3;
|
|
3099
|
+
let disposed = false;
|
|
3100
|
+
let renewalTimer;
|
|
3101
|
+
let refreshFailures = 0;
|
|
3102
|
+
const clearRenewalTimer = () => {
|
|
3103
|
+
if (renewalTimer !== void 0) {
|
|
3104
|
+
clearTimeout(renewalTimer);
|
|
3105
|
+
renewalTimer = void 0;
|
|
3106
|
+
}
|
|
3107
|
+
};
|
|
3108
|
+
const refresh = async () => {
|
|
3109
|
+
tokenData = await cloud.issueFusorToken(projectId, projectSecret);
|
|
3110
|
+
tokenExpiresAt = Date.now() + tokenData.expiresIn * 1e3;
|
|
3111
|
+
};
|
|
3112
|
+
const onRefreshSuccess = () => {
|
|
3113
|
+
if (refreshFailures > 0) {
|
|
3114
|
+
log$2.info("fusor token refresh recovered", { "spectrum.fusor.auth.attempt": refreshFailures });
|
|
3115
|
+
refreshFailures = 0;
|
|
3116
|
+
}
|
|
3117
|
+
};
|
|
3118
|
+
const onRefreshFailure = (error) => {
|
|
3119
|
+
refreshFailures += 1;
|
|
3120
|
+
log$2.warn("fusor token refresh failed; retrying", {
|
|
3121
|
+
"spectrum.fusor.auth.attempt": refreshFailures,
|
|
3122
|
+
"spectrum.fusor.auth.retry_in_ms": RETRY_DELAY_MS,
|
|
3123
|
+
...errorAttrs(error)
|
|
3124
|
+
}, error);
|
|
3125
|
+
};
|
|
3126
|
+
const scheduleRetry = () => {
|
|
3127
|
+
if (disposed) return;
|
|
3128
|
+
clearRenewalTimer();
|
|
3129
|
+
renewalTimer = setTimeout(async () => {
|
|
3130
|
+
if (disposed) return;
|
|
3131
|
+
try {
|
|
3132
|
+
await refresh();
|
|
3133
|
+
onRefreshSuccess();
|
|
3134
|
+
scheduleRenewal();
|
|
3135
|
+
} catch (retryErr) {
|
|
3136
|
+
onRefreshFailure(retryErr);
|
|
3137
|
+
scheduleRetry();
|
|
3138
|
+
}
|
|
3139
|
+
}, RETRY_DELAY_MS);
|
|
3140
|
+
renewalTimer?.unref?.();
|
|
3141
|
+
};
|
|
3142
|
+
const scheduleRenewal = () => {
|
|
3143
|
+
if (disposed) return;
|
|
3144
|
+
clearRenewalTimer();
|
|
3145
|
+
const ttlMs = tokenData.expiresIn * 1e3;
|
|
3146
|
+
const renewInMs = Math.max(ttlMs * RENEWAL_RATIO, 5e3);
|
|
3147
|
+
renewalTimer = setTimeout(async () => {
|
|
3148
|
+
try {
|
|
3149
|
+
await refresh();
|
|
3150
|
+
onRefreshSuccess();
|
|
3151
|
+
scheduleRenewal();
|
|
3152
|
+
} catch (err) {
|
|
3153
|
+
onRefreshFailure(err);
|
|
3154
|
+
scheduleRetry();
|
|
3155
|
+
}
|
|
3156
|
+
}, renewInMs);
|
|
3157
|
+
renewalTimer?.unref?.();
|
|
3158
|
+
};
|
|
3159
|
+
scheduleRenewal();
|
|
3160
|
+
return {
|
|
3161
|
+
async getToken() {
|
|
3162
|
+
if (Date.now() >= tokenExpiresAt - EXPIRY_BUFFER_MS) {
|
|
3163
|
+
await refresh();
|
|
3164
|
+
onRefreshSuccess();
|
|
3165
|
+
scheduleRenewal();
|
|
3166
|
+
}
|
|
3167
|
+
return tokenData.token;
|
|
3168
|
+
},
|
|
3169
|
+
invalidate() {
|
|
3170
|
+
tokenExpiresAt = 0;
|
|
3171
|
+
},
|
|
3172
|
+
async dispose() {
|
|
3173
|
+
disposed = true;
|
|
3174
|
+
clearRenewalTimer();
|
|
3175
|
+
}
|
|
3176
|
+
};
|
|
3177
|
+
})();
|
|
3178
|
+
}
|
|
3179
|
+
//#endregion
|
|
3180
|
+
//#region src/fusor/parse.ts
|
|
3181
|
+
const CR = 13;
|
|
3182
|
+
const LF = 10;
|
|
3183
|
+
function findHeaderEnd(bytes) {
|
|
3184
|
+
for (let i = 0; i + 3 < bytes.length; i++) if (bytes[i] === CR && bytes[i + 1] === LF && bytes[i + 2] === CR && bytes[i + 3] === LF) return i;
|
|
3185
|
+
return -1;
|
|
3186
|
+
}
|
|
3187
|
+
/**
|
|
3188
|
+
* Parses an HTTP/1.1 wire-format request out of `raw_request` from
|
|
3189
|
+
* `RawInboundEvent`. Headers are lowercased. Multiple header values with the
|
|
3190
|
+
* same name are joined with ", " (RFC 7230 §3.2.2).
|
|
3191
|
+
*/
|
|
3192
|
+
function parseHttpRequest(bytes) {
|
|
3193
|
+
const headerEnd = findHeaderEnd(bytes);
|
|
3194
|
+
if (headerEnd < 0) throw new Error("fusor: raw_request missing CRLFCRLF header terminator");
|
|
3195
|
+
const headerText = new TextDecoder("utf-8").decode(bytes.subarray(0, headerEnd));
|
|
3196
|
+
const rawBody = bytes.subarray(headerEnd + 4);
|
|
3197
|
+
const lines = headerText.split("\r\n");
|
|
3198
|
+
const requestLine = lines[0];
|
|
3199
|
+
if (!requestLine) throw new Error("fusor: raw_request missing request line");
|
|
3200
|
+
const firstSpace = requestLine.indexOf(" ");
|
|
3201
|
+
const lastSpace = requestLine.lastIndexOf(" ");
|
|
3202
|
+
if (firstSpace < 0 || lastSpace <= firstSpace) throw new Error(`fusor: malformed request line: ${requestLine}`);
|
|
3203
|
+
const method = requestLine.slice(0, firstSpace);
|
|
3204
|
+
const path = requestLine.slice(firstSpace + 1, lastSpace);
|
|
3205
|
+
const headers = {};
|
|
3206
|
+
for (let i = 1; i < lines.length; i++) {
|
|
3207
|
+
const line = lines[i];
|
|
3208
|
+
if (!line) continue;
|
|
3209
|
+
const colon = line.indexOf(":");
|
|
3210
|
+
if (colon < 0) continue;
|
|
3211
|
+
const key = line.slice(0, colon).trim().toLowerCase();
|
|
3212
|
+
const value = line.slice(colon + 1).trim();
|
|
3213
|
+
if (!key) continue;
|
|
3214
|
+
const existing = headers[key];
|
|
3215
|
+
headers[key] = existing ? `${existing}, ${value}` : value;
|
|
3216
|
+
}
|
|
3217
|
+
return {
|
|
3218
|
+
method,
|
|
3219
|
+
path,
|
|
3220
|
+
headers,
|
|
3221
|
+
rawBody
|
|
3222
|
+
};
|
|
3223
|
+
}
|
|
3224
|
+
//#endregion
|
|
3225
|
+
//#region src/fusor/websocket.ts
|
|
3226
|
+
const log$1 = createLogger("spectrum.fusor.ws");
|
|
3227
|
+
const FUSOR_WS_SUBPROTOCOL = "fusor.v1.json";
|
|
3228
|
+
const STALENESS_GRACE_MS = 5e3;
|
|
3229
|
+
var FusorWsError = class extends Error {
|
|
3230
|
+
closeCode;
|
|
3231
|
+
errorCode;
|
|
3232
|
+
constructor(message, closeCode, errorCode) {
|
|
3233
|
+
super(message);
|
|
3234
|
+
this.name = "FusorWsError";
|
|
3235
|
+
this.closeCode = closeCode;
|
|
3236
|
+
this.errorCode = errorCode;
|
|
3237
|
+
}
|
|
3238
|
+
};
|
|
3239
|
+
function isWsAuthError(error) {
|
|
3240
|
+
return error instanceof FusorWsError && (error.closeCode === 4401 || error.errorCode === "unauthenticated");
|
|
3241
|
+
}
|
|
3242
|
+
function decodeBase64(value) {
|
|
3243
|
+
if (typeof Buffer !== "undefined") return new Uint8Array(Buffer.from(value, "base64"));
|
|
3244
|
+
return Uint8Array.from(atob(value), (c) => c.charCodeAt(0));
|
|
3245
|
+
}
|
|
3246
|
+
function encodeBase64(bytes) {
|
|
3247
|
+
if (typeof Buffer !== "undefined") return Buffer.from(bytes).toString("base64");
|
|
3248
|
+
const chunkSize = 32768;
|
|
3249
|
+
const parts = [];
|
|
3250
|
+
for (let i = 0; i < bytes.length; i += chunkSize) parts.push(String.fromCharCode(...bytes.subarray(i, i + chunkSize)));
|
|
3251
|
+
return btoa(parts.join(""));
|
|
3252
|
+
}
|
|
3253
|
+
function toRawInboundEvent(frame) {
|
|
3254
|
+
const e = frame.event;
|
|
3255
|
+
return {
|
|
3256
|
+
eventId: e.eventId,
|
|
3257
|
+
projectId: e.projectId,
|
|
3258
|
+
platform: e.platform,
|
|
3259
|
+
receivedAt: e.receivedAt ? new Date(e.receivedAt) : void 0,
|
|
3260
|
+
sourceId: e.sourceId ?? "",
|
|
3261
|
+
prevSubjectSeq: e.prevSubjectSeq ?? 0,
|
|
3262
|
+
rawRequest: decodeBase64(e.rawRequest)
|
|
3263
|
+
};
|
|
3264
|
+
}
|
|
3265
|
+
function runFusorWsSession(options) {
|
|
3266
|
+
const WebSocketCtor = globalThis.WebSocket;
|
|
3267
|
+
if (typeof WebSocketCtor !== "function") throw new FusorWsError("global WebSocket is not available in this runtime — the fusor websocket transport needs Bun, Node >= 22, or a browser/worker environment");
|
|
3268
|
+
const wsOpen = WebSocketCtor.OPEN;
|
|
3269
|
+
const ws = new WebSocketCtor(options.url, [FUSOR_WS_SUBPROTOCOL]);
|
|
3270
|
+
let settled = false;
|
|
3271
|
+
let closedByUs = false;
|
|
3272
|
+
let pendingError = null;
|
|
3273
|
+
let stalenessBudgetMs = 65e3;
|
|
3274
|
+
let watchdog;
|
|
3275
|
+
let tail = Promise.resolve();
|
|
3276
|
+
let resolveDone;
|
|
3277
|
+
let rejectDone;
|
|
3278
|
+
const done = new Promise((resolve, reject) => {
|
|
3279
|
+
resolveDone = resolve;
|
|
3280
|
+
rejectDone = reject;
|
|
3281
|
+
});
|
|
3282
|
+
const settle = (error) => {
|
|
3283
|
+
if (settled) return;
|
|
3284
|
+
settled = true;
|
|
3285
|
+
if (watchdog) {
|
|
3286
|
+
clearTimeout(watchdog);
|
|
3287
|
+
watchdog = void 0;
|
|
3288
|
+
}
|
|
3289
|
+
if (error) rejectDone(error);
|
|
3290
|
+
else resolveDone();
|
|
3291
|
+
};
|
|
3292
|
+
const armWatchdog = () => {
|
|
3293
|
+
if (settled) return;
|
|
3294
|
+
if (watchdog) clearTimeout(watchdog);
|
|
3295
|
+
watchdog = setTimeout(() => {
|
|
3296
|
+
log$1.warn("fusor ws: no frame within staleness budget; closing", { "spectrum.fusor.ws.staleness_budget_ms": stalenessBudgetMs });
|
|
3297
|
+
settle(new FusorWsError("websocket heartbeat timeout"));
|
|
3298
|
+
try {
|
|
3299
|
+
ws.close();
|
|
3300
|
+
} catch {}
|
|
3301
|
+
}, stalenessBudgetMs);
|
|
3302
|
+
watchdog.unref?.();
|
|
3303
|
+
};
|
|
3304
|
+
const sendReplyFor = (eventId) => (reply) => {
|
|
3305
|
+
if (ws.readyState !== wsOpen) return;
|
|
3306
|
+
ws.send(JSON.stringify({
|
|
3307
|
+
type: "reply",
|
|
3308
|
+
eventId,
|
|
3309
|
+
status: reply.status,
|
|
3310
|
+
headers: reply.headers,
|
|
3311
|
+
...reply.body.length > 0 && { body: encodeBase64(reply.body) },
|
|
3312
|
+
...reply.errorReason && { errorReason: reply.errorReason }
|
|
3313
|
+
}));
|
|
3314
|
+
};
|
|
3315
|
+
const handleReadyFrame = (frame) => {
|
|
3316
|
+
const interval = frame.heartbeatIntervalMs;
|
|
3317
|
+
if (typeof interval === "number" && interval > 0) stalenessBudgetMs = 2 * interval + STALENESS_GRACE_MS;
|
|
3318
|
+
log$1.info("fusor ws stream ready", {
|
|
3319
|
+
"spectrum.fusor.ws.project_id": typeof frame.projectId === "string" ? frame.projectId : "",
|
|
3320
|
+
"spectrum.fusor.ws.heartbeat_interval_ms": typeof interval === "number" ? interval : 0
|
|
3321
|
+
});
|
|
3322
|
+
};
|
|
3323
|
+
const handleEventFrame = (frame) => {
|
|
3324
|
+
const eventFrame = frame;
|
|
3325
|
+
let event;
|
|
3326
|
+
try {
|
|
3327
|
+
event = toRawInboundEvent(eventFrame);
|
|
3328
|
+
} catch (error) {
|
|
3329
|
+
log$1.warn("fusor ws: undecodable event frame; skipping", errorAttrs(error), error);
|
|
3330
|
+
return;
|
|
3331
|
+
}
|
|
3332
|
+
const sendReply = eventFrame.replyExpected ? sendReplyFor(event.eventId) : void 0;
|
|
3333
|
+
tail = tail.then(() => options.onEvent(event, sendReply)).catch((error) => {
|
|
3334
|
+
log$1.warn("fusor ws: event handler failed", {
|
|
3335
|
+
"spectrum.fusor.ws.event_id": event.eventId,
|
|
3336
|
+
...errorAttrs(error)
|
|
3337
|
+
}, error);
|
|
3338
|
+
});
|
|
3339
|
+
};
|
|
3340
|
+
const handleErrorFrame = (frame) => {
|
|
3341
|
+
const code = typeof frame.code === "string" ? frame.code : "unknown";
|
|
3342
|
+
const message = typeof frame.message === "string" ? frame.message : "server error";
|
|
3343
|
+
const reason = typeof frame.reason === "string" ? frame.reason : void 0;
|
|
3344
|
+
if (frame.fatal === true) pendingError = {
|
|
3345
|
+
code,
|
|
3346
|
+
message,
|
|
3347
|
+
reason
|
|
3348
|
+
};
|
|
3349
|
+
else log$1.warn("fusor ws: server notice", {
|
|
3350
|
+
"spectrum.fusor.ws.notice_code": code,
|
|
3351
|
+
"spectrum.fusor.ws.notice_message": message,
|
|
3352
|
+
"spectrum.fusor.ws.notice_reason": reason
|
|
3353
|
+
});
|
|
3354
|
+
};
|
|
3355
|
+
const handleFrame = (raw) => {
|
|
3356
|
+
if (typeof raw !== "string") return;
|
|
3357
|
+
let frame;
|
|
3358
|
+
try {
|
|
3359
|
+
frame = JSON.parse(raw);
|
|
3360
|
+
} catch {
|
|
3361
|
+
log$1.warn("fusor ws: unparseable server frame; ignoring");
|
|
3362
|
+
return;
|
|
3363
|
+
}
|
|
3364
|
+
switch (frame.type) {
|
|
3365
|
+
case "ready":
|
|
3366
|
+
handleReadyFrame(frame);
|
|
3367
|
+
return;
|
|
3368
|
+
case "event":
|
|
3369
|
+
handleEventFrame(frame);
|
|
3370
|
+
return;
|
|
3371
|
+
case "error":
|
|
3372
|
+
handleErrorFrame(frame);
|
|
3373
|
+
return;
|
|
3374
|
+
default: return;
|
|
3375
|
+
}
|
|
3376
|
+
};
|
|
3377
|
+
ws.onopen = () => {
|
|
3378
|
+
armWatchdog();
|
|
3379
|
+
ws.send(JSON.stringify({
|
|
3380
|
+
type: "init",
|
|
3381
|
+
startSeq: 0,
|
|
3382
|
+
token: options.token
|
|
3383
|
+
}));
|
|
3384
|
+
};
|
|
3385
|
+
ws.onmessage = (messageEvent) => {
|
|
3386
|
+
armWatchdog();
|
|
3387
|
+
handleFrame(messageEvent.data);
|
|
3388
|
+
};
|
|
3389
|
+
ws.onerror = () => {
|
|
3390
|
+
log$1.debug("fusor ws: socket error event");
|
|
3391
|
+
};
|
|
3392
|
+
ws.onclose = (closeEvent) => {
|
|
3393
|
+
if (closedByUs) {
|
|
3394
|
+
settle();
|
|
3395
|
+
return;
|
|
3396
|
+
}
|
|
3397
|
+
const detail = pendingError ? `${pendingError.code}${pendingError.reason ? `:${pendingError.reason}` : ""} — ${pendingError.message}` : closeEvent.reason || "connection closed";
|
|
3398
|
+
settle(new FusorWsError(`fusor websocket closed (${closeEvent.code}): ${detail}`, closeEvent.code, pendingError?.code ?? (closeEvent.reason || void 0)));
|
|
3399
|
+
};
|
|
3400
|
+
armWatchdog();
|
|
3401
|
+
return {
|
|
3402
|
+
done,
|
|
3403
|
+
close() {
|
|
3404
|
+
closedByUs = true;
|
|
3405
|
+
try {
|
|
3406
|
+
ws.close(1e3);
|
|
3407
|
+
} catch {}
|
|
3408
|
+
setTimeout(() => settle(), 2e3).unref?.();
|
|
3409
|
+
}
|
|
3410
|
+
};
|
|
3411
|
+
}
|
|
3412
|
+
//#endregion
|
|
3413
|
+
//#region src/fusor/core.ts
|
|
3414
|
+
const DEFAULT_FUSOR_WS_URL = "wss://fusor-ws.spectrum.photon.codes/v1/subscribe";
|
|
3415
|
+
const RECONNECT_BASE_MS = 1e3;
|
|
3416
|
+
const RECONNECT_MAX_MS = 3e4;
|
|
3417
|
+
const log = createLogger("spectrum.fusor");
|
|
3418
|
+
const errorText = (error) => error instanceof Error ? error.message : String(error);
|
|
3419
|
+
function toReplyBytes(body) {
|
|
3420
|
+
if (body === void 0) return new Uint8Array(0);
|
|
3421
|
+
if (typeof body === "string") return new TextEncoder().encode(body);
|
|
3422
|
+
return body;
|
|
3423
|
+
}
|
|
3424
|
+
function combineReplies(outcomes) {
|
|
3425
|
+
const successes = outcomes.filter((o) => o.ok);
|
|
3426
|
+
if (successes.length === 0) return {
|
|
3427
|
+
eventId: "",
|
|
3428
|
+
errorReason: outcomes[0]?.errorReason ?? "no handler succeeded",
|
|
3429
|
+
status: 0,
|
|
3430
|
+
headers: {},
|
|
3431
|
+
body: new Uint8Array(0)
|
|
3432
|
+
};
|
|
3433
|
+
let status = 0;
|
|
3434
|
+
const headers = {};
|
|
3435
|
+
let body = new Uint8Array(0);
|
|
3436
|
+
for (const outcome of successes) {
|
|
3437
|
+
const reply = outcome.reply;
|
|
3438
|
+
if (!reply) continue;
|
|
3439
|
+
if (reply.status !== void 0 && reply.status > status) status = reply.status;
|
|
3440
|
+
if (reply.headers) for (const [k, v] of Object.entries(reply.headers)) headers[k.toLowerCase()] = v;
|
|
3441
|
+
const candidate = toReplyBytes(reply.body);
|
|
3442
|
+
if (candidate.length > 0) body = candidate;
|
|
3443
|
+
}
|
|
3444
|
+
return {
|
|
3445
|
+
eventId: "",
|
|
3446
|
+
errorReason: "",
|
|
3447
|
+
status,
|
|
3448
|
+
headers,
|
|
3449
|
+
body
|
|
3450
|
+
};
|
|
3451
|
+
}
|
|
3452
|
+
function routeHandlerResult(result, handler, deliver) {
|
|
3453
|
+
if (result === void 0) return;
|
|
3454
|
+
const items = Array.isArray(result) ? result : [result];
|
|
3455
|
+
for (const item of items) {
|
|
3456
|
+
if (!isFusorEvent(item)) {
|
|
3457
|
+
deliver(item);
|
|
3458
|
+
continue;
|
|
3459
|
+
}
|
|
3460
|
+
if (item.name === "messages") deliver(item.data);
|
|
3461
|
+
else handler.pushEvent(item.name, item.data);
|
|
3462
|
+
}
|
|
3463
|
+
}
|
|
3464
|
+
function runHandlerOnce(handler, parsedRequest, deliver = handler.pushMessage) {
|
|
3465
|
+
return (async () => {
|
|
3466
|
+
try {
|
|
3467
|
+
const payload = await handler.verify(parsedRequest);
|
|
3468
|
+
let reply;
|
|
3469
|
+
let respondCalled = false;
|
|
3470
|
+
let returned = false;
|
|
3471
|
+
const respond = (next) => {
|
|
3472
|
+
if (returned) {
|
|
3473
|
+
log.warn("fusor.respond called after handler returned; ignoring");
|
|
3474
|
+
return;
|
|
3475
|
+
}
|
|
3476
|
+
if (respondCalled) log.debug("fusor.respond called more than once; last call wins");
|
|
3477
|
+
respondCalled = true;
|
|
3478
|
+
reply = next;
|
|
3479
|
+
};
|
|
3480
|
+
const result = await handler.messages({
|
|
3481
|
+
payload,
|
|
3482
|
+
respond
|
|
3483
|
+
});
|
|
3484
|
+
returned = true;
|
|
3485
|
+
routeHandlerResult(result, handler, deliver);
|
|
3486
|
+
return {
|
|
3487
|
+
ok: true,
|
|
3488
|
+
reply
|
|
3489
|
+
};
|
|
3490
|
+
} catch (error) {
|
|
3491
|
+
return {
|
|
3492
|
+
ok: false,
|
|
3493
|
+
errorReason: errorText(error)
|
|
3494
|
+
};
|
|
3495
|
+
}
|
|
3496
|
+
})();
|
|
3497
|
+
}
|
|
3498
|
+
var FusorCore = class {
|
|
3499
|
+
options;
|
|
3500
|
+
websocketEndpoint;
|
|
3501
|
+
handlers = /* @__PURE__ */ new Map();
|
|
3502
|
+
tokenProvider;
|
|
3503
|
+
wsSession;
|
|
3504
|
+
connectionLoop;
|
|
3505
|
+
started = false;
|
|
3506
|
+
stopped = false;
|
|
3507
|
+
stopResolve;
|
|
3508
|
+
stoppedPromise;
|
|
3509
|
+
reconnectTimer;
|
|
3510
|
+
reconnectResolve;
|
|
3511
|
+
constructor(options) {
|
|
3512
|
+
this.options = options;
|
|
3513
|
+
this.websocketEndpoint = options.websocketEndpoint ?? process.env.SPECTRUM_FUSOR_WS_URL ?? DEFAULT_FUSOR_WS_URL;
|
|
3514
|
+
this.stoppedPromise = new Promise((resolve) => {
|
|
3515
|
+
this.stopResolve = resolve;
|
|
3516
|
+
});
|
|
3517
|
+
}
|
|
3518
|
+
register(platform, handler) {
|
|
3519
|
+
const list = this.handlers.get(platform) ?? [];
|
|
3520
|
+
list.push(handler);
|
|
3521
|
+
this.handlers.set(platform, list);
|
|
3522
|
+
}
|
|
3523
|
+
async start() {
|
|
3524
|
+
if (!(this.options.projectId && this.options.projectSecret)) throw new Error("fusor: streaming via spectrum.messages requires projectId and projectSecret");
|
|
3525
|
+
if (this.started) return;
|
|
3526
|
+
this.started = true;
|
|
3527
|
+
this.tokenProvider = await createFusorTokenProvider(this.options.projectId, this.options.projectSecret);
|
|
3528
|
+
this.connectionLoop = this.runConnectionLoop().catch((error) => {
|
|
3529
|
+
log.error("fusor connection loop crashed", errorAttrs(error), error);
|
|
3530
|
+
});
|
|
3531
|
+
}
|
|
3532
|
+
async runConnectionLoop() {
|
|
3533
|
+
let attempt = 0;
|
|
3534
|
+
while (!this.stopped) {
|
|
3535
|
+
const wsRan = await this.tryWebsocketOnce();
|
|
3536
|
+
if (this.stopped) return;
|
|
3537
|
+
if (wsRan) {
|
|
3538
|
+
attempt = 0;
|
|
3539
|
+
continue;
|
|
3540
|
+
}
|
|
3541
|
+
attempt += 1;
|
|
3542
|
+
await this.backoffSleep(this.backoffMs(attempt));
|
|
3543
|
+
}
|
|
3544
|
+
}
|
|
3545
|
+
async tryWebsocketOnce() {
|
|
3546
|
+
try {
|
|
3547
|
+
await this.runWebsocketOnce();
|
|
3548
|
+
return true;
|
|
3549
|
+
} catch (error) {
|
|
3550
|
+
if (isWsAuthError(error)) this.tokenProvider?.invalidate();
|
|
3551
|
+
if (!this.stopped) log.warn("fusor websocket stream errored; reconnecting", errorAttrs(error), error);
|
|
3552
|
+
return false;
|
|
3553
|
+
}
|
|
3554
|
+
}
|
|
3555
|
+
backoffMs(attempt) {
|
|
3556
|
+
return Math.min(RECONNECT_BASE_MS * 2 ** (attempt - 1), RECONNECT_MAX_MS);
|
|
3557
|
+
}
|
|
3558
|
+
async backoffSleep(backoff) {
|
|
3559
|
+
await new Promise((resolve) => {
|
|
3560
|
+
this.reconnectResolve = resolve;
|
|
3561
|
+
const timer = setTimeout(resolve, backoff);
|
|
3562
|
+
timer.unref?.();
|
|
3563
|
+
this.reconnectTimer = timer;
|
|
3564
|
+
});
|
|
3565
|
+
this.reconnectTimer = void 0;
|
|
3566
|
+
this.reconnectResolve = void 0;
|
|
3567
|
+
}
|
|
3568
|
+
async runWebsocketOnce() {
|
|
3569
|
+
if (!this.tokenProvider) throw new Error("fusor: token not initialized");
|
|
3570
|
+
const token = await this.tokenProvider.getToken();
|
|
3571
|
+
const session = runFusorWsSession({
|
|
3572
|
+
url: this.websocketEndpoint,
|
|
3573
|
+
token,
|
|
3574
|
+
onEvent: async (event, sendReply) => {
|
|
3575
|
+
if (this.stopped) return;
|
|
3576
|
+
const reply = await this.processEvent(event);
|
|
3577
|
+
sendReply?.(reply);
|
|
3578
|
+
}
|
|
3579
|
+
});
|
|
3580
|
+
this.wsSession = session;
|
|
3581
|
+
try {
|
|
3582
|
+
await session.done;
|
|
3583
|
+
} finally {
|
|
3584
|
+
this.wsSession = void 0;
|
|
3585
|
+
}
|
|
3586
|
+
}
|
|
3587
|
+
async processEvent(event, deliver) {
|
|
3588
|
+
const handlers = this.handlers.get(event.platform) ?? [];
|
|
3589
|
+
if (handlers.length === 0) {
|
|
3590
|
+
const hint = officialProviderInstallHint(event.platform);
|
|
3591
|
+
log.warn(hint ? `fusor: no handler for platform — ${hint}` : "fusor: no handler for platform", {
|
|
3592
|
+
"spectrum.fusor.platform": event.platform,
|
|
3593
|
+
"spectrum.fusor.event_id": event.eventId
|
|
3594
|
+
});
|
|
3595
|
+
return {
|
|
3596
|
+
eventId: event.eventId,
|
|
3597
|
+
errorReason: `no handler for platform ${event.platform}`,
|
|
3598
|
+
status: 0,
|
|
3599
|
+
headers: {},
|
|
3600
|
+
body: new Uint8Array(0)
|
|
3601
|
+
};
|
|
3602
|
+
}
|
|
3603
|
+
let parsedRequest;
|
|
3604
|
+
try {
|
|
3605
|
+
parsedRequest = parseHttpRequest(event.rawRequest);
|
|
3606
|
+
} catch (error) {
|
|
3607
|
+
const errorReason = errorText(error);
|
|
3608
|
+
log.warn("fusor: failed to parse raw_request", {
|
|
3609
|
+
"spectrum.fusor.platform": event.platform,
|
|
3610
|
+
"spectrum.fusor.event_id": event.eventId,
|
|
3611
|
+
...errorAttrs(error)
|
|
3612
|
+
});
|
|
3613
|
+
return {
|
|
3614
|
+
eventId: event.eventId,
|
|
3615
|
+
errorReason,
|
|
3616
|
+
status: 0,
|
|
3617
|
+
headers: {},
|
|
3618
|
+
body: new Uint8Array(0)
|
|
3619
|
+
};
|
|
3620
|
+
}
|
|
3621
|
+
const combined = combineReplies(await Promise.all(handlers.map((handler) => runHandlerOnce(handler, parsedRequest, deliver))));
|
|
3622
|
+
combined.eventId = event.eventId;
|
|
3623
|
+
return combined;
|
|
3624
|
+
}
|
|
3625
|
+
async close() {
|
|
3626
|
+
if (this.stopped) return;
|
|
3627
|
+
this.stopped = true;
|
|
3628
|
+
this.wsSession?.close();
|
|
3629
|
+
if (this.reconnectTimer) {
|
|
3630
|
+
clearTimeout(this.reconnectTimer);
|
|
3631
|
+
this.reconnectTimer = void 0;
|
|
3632
|
+
}
|
|
3633
|
+
this.reconnectResolve?.();
|
|
3634
|
+
this.reconnectResolve = void 0;
|
|
3635
|
+
if (this.tokenProvider) await this.tokenProvider.dispose();
|
|
3636
|
+
if (this.connectionLoop) await this.connectionLoop;
|
|
3637
|
+
this.stopResolve?.();
|
|
3638
|
+
}
|
|
3639
|
+
async waitStopped() {
|
|
3640
|
+
return this.stoppedPromise;
|
|
3641
|
+
}
|
|
3642
|
+
};
|
|
3643
|
+
//#endregion
|
|
3644
|
+
//#region src/utils/store.ts
|
|
3645
|
+
const isRecordObject = (value) => {
|
|
3646
|
+
if (typeof value !== "object" || value === null || Array.isArray(value)) return false;
|
|
3647
|
+
const prototype = Object.getPrototypeOf(value);
|
|
3648
|
+
return prototype === Object.prototype || prototype === null;
|
|
3649
|
+
};
|
|
3650
|
+
function createStore() {
|
|
3651
|
+
const data = /* @__PURE__ */ new Map();
|
|
3652
|
+
return {
|
|
3653
|
+
set(key, value) {
|
|
3654
|
+
data.set(key, value);
|
|
3655
|
+
},
|
|
3656
|
+
get(key) {
|
|
3657
|
+
return data.get(key);
|
|
3658
|
+
},
|
|
3659
|
+
has(key) {
|
|
3660
|
+
return data.has(key);
|
|
3661
|
+
},
|
|
3662
|
+
delete(key) {
|
|
3663
|
+
return data.delete(key);
|
|
3664
|
+
},
|
|
3665
|
+
clear() {
|
|
3666
|
+
data.clear();
|
|
3667
|
+
},
|
|
3668
|
+
keys() {
|
|
3669
|
+
return Array.from(data.keys());
|
|
3670
|
+
},
|
|
3671
|
+
string(key) {
|
|
3672
|
+
const v = data.get(key);
|
|
3673
|
+
return typeof v === "string" ? v : void 0;
|
|
3674
|
+
},
|
|
3675
|
+
number(key) {
|
|
3676
|
+
const v = data.get(key);
|
|
3677
|
+
return typeof v === "number" ? v : void 0;
|
|
3678
|
+
},
|
|
3679
|
+
bool(key) {
|
|
3680
|
+
const v = data.get(key);
|
|
3681
|
+
return typeof v === "boolean" ? v : void 0;
|
|
3682
|
+
},
|
|
3683
|
+
object(key) {
|
|
3684
|
+
const v = data.get(key);
|
|
3685
|
+
if (!isRecordObject(v)) return;
|
|
3686
|
+
return v;
|
|
3687
|
+
},
|
|
3688
|
+
array(key) {
|
|
3689
|
+
const v = data.get(key);
|
|
3690
|
+
return Array.isArray(v) ? v : void 0;
|
|
3691
|
+
}
|
|
3692
|
+
};
|
|
3693
|
+
}
|
|
3694
|
+
//#endregion
|
|
3695
|
+
//#region src/webhook/deserialize.ts
|
|
3696
|
+
/** The single event type that carries a message today. */
|
|
3697
|
+
const MESSAGES_EVENT = "messages";
|
|
3698
|
+
const DEFAULT_ATTACHMENT_NAME = "attachment";
|
|
3699
|
+
const DEFAULT_MIME_TYPE = "application/octet-stream";
|
|
3700
|
+
const isRecord = (value) => typeof value === "object" && value !== null;
|
|
3701
|
+
const asString = (value) => typeof value === "string" ? value : "";
|
|
3702
|
+
const asOptionalDate = (value) => typeof value === "string" ? new Date(value) : void 0;
|
|
3703
|
+
/**
|
|
3704
|
+
* Map a native Spectrum webhook envelope to a `ProviderMessageRecord` plus the
|
|
3705
|
+
* platform that owns it, ready for `resolveRecordToMessages`. Returns `null`
|
|
3706
|
+
* when the delivery carries nothing to route (an unknown `event` type, or a
|
|
3707
|
+
* message with no resolvable platform) — the caller acknowledges it (200)
|
|
3708
|
+
* rather than failing, since neither is fixed by a retry.
|
|
3709
|
+
*
|
|
3710
|
+
* Reaction targets and group items are emitted as **raw nested records**; the
|
|
3711
|
+
* `wrapProviderMessage`/`wrapNestedContent` pipeline turns them into fully-built
|
|
3712
|
+
* Messages, exactly as a provider's own `messages` handler would.
|
|
3713
|
+
*/
|
|
3714
|
+
function deserializeSpectrumMessage(envelope, ctx) {
|
|
3715
|
+
if (envelope.event !== MESSAGES_EVENT) return null;
|
|
3716
|
+
const message = envelope.message;
|
|
3717
|
+
const platform = resolvePlatform(message);
|
|
3718
|
+
if (!platform) return null;
|
|
3719
|
+
const spaceRef = { ...message.space };
|
|
3720
|
+
return {
|
|
3721
|
+
platform,
|
|
3722
|
+
record: {
|
|
3723
|
+
id: message.id,
|
|
3724
|
+
direction: "inbound",
|
|
3725
|
+
content: deserializeContent(message.content, platform, spaceRef, ctx),
|
|
3726
|
+
space: spaceRef,
|
|
3727
|
+
sender: message.sender ? { ...message.sender } : void 0,
|
|
3728
|
+
timestamp: asOptionalDate(message.timestamp)
|
|
3729
|
+
}
|
|
3730
|
+
};
|
|
3731
|
+
}
|
|
3732
|
+
const resolvePlatform = (message) => message.platform ?? message.space.platform;
|
|
3733
|
+
const deserializeContent = (content, platform, spaceRef, ctx) => {
|
|
3734
|
+
try {
|
|
3735
|
+
return mapContent(content, platform, spaceRef, ctx);
|
|
3736
|
+
} catch {
|
|
3737
|
+
return asCustom(content);
|
|
3738
|
+
}
|
|
3739
|
+
};
|
|
3740
|
+
const mapContent = (content, platform, spaceRef, ctx) => {
|
|
3741
|
+
const raw = content;
|
|
3742
|
+
switch (content.type) {
|
|
3743
|
+
case "text": return {
|
|
3744
|
+
type: "text",
|
|
3745
|
+
text: asString(raw.text)
|
|
3746
|
+
};
|
|
3747
|
+
case "richlink": return asRichlink({ url: asString(raw.url) });
|
|
3748
|
+
case "contact": return deserializeContact(raw);
|
|
3749
|
+
case "reaction": return deserializeReaction(raw, spaceRef);
|
|
3750
|
+
case "group": return deserializeGroup(raw, platform, spaceRef, ctx);
|
|
3751
|
+
case "attachment": return deserializeAttachment(raw, platform, spaceRef, ctx);
|
|
3752
|
+
default: return asCustom(content);
|
|
3753
|
+
}
|
|
3754
|
+
};
|
|
3755
|
+
const deserializeAttachment = (raw, platform, spaceRef, ctx) => {
|
|
3756
|
+
const id = asString(raw.id);
|
|
3757
|
+
const bytes = ctx.resolveAttachment?.(platform, spaceRef, id);
|
|
3758
|
+
const unavailable = () => Promise.reject(UnsupportedError.action("getAttachment", platform, `attachment "${id}" arrived without bytes over the Spectrum webhook and "${platform}" exposes no getAttachment`));
|
|
3759
|
+
return asAttachment({
|
|
3760
|
+
id,
|
|
3761
|
+
name: asString(raw.name) || DEFAULT_ATTACHMENT_NAME,
|
|
3762
|
+
mimeType: asString(raw.mimeType) || DEFAULT_MIME_TYPE,
|
|
3763
|
+
size: typeof raw.size === "number" ? raw.size : void 0,
|
|
3764
|
+
read: bytes ? bytes.read : unavailable,
|
|
3765
|
+
stream: bytes?.stream
|
|
3766
|
+
});
|
|
3767
|
+
};
|
|
3768
|
+
const deserializeReaction = (raw, spaceRef) => ({
|
|
3769
|
+
type: "reaction",
|
|
3770
|
+
emoji: asString(raw.emoji),
|
|
3771
|
+
target: buildTargetRecord(raw.target, spaceRef)
|
|
3772
|
+
});
|
|
3773
|
+
const buildTargetRecord = (target, spaceRef) => {
|
|
3774
|
+
const ref = isRecord(target) ? target : {};
|
|
3775
|
+
return {
|
|
3776
|
+
id: asString(ref.id),
|
|
3777
|
+
content: {
|
|
3778
|
+
type: "text",
|
|
3779
|
+
text: asString(ref.contentPreview)
|
|
3780
|
+
},
|
|
3781
|
+
space: { ...spaceRef },
|
|
3782
|
+
sender: ref.sender ? { ...ref.sender } : void 0,
|
|
3783
|
+
timestamp: asOptionalDate(ref.timestamp)
|
|
3784
|
+
};
|
|
3785
|
+
};
|
|
3786
|
+
const deserializeGroup = (raw, platform, spaceRef, ctx) => {
|
|
3787
|
+
return {
|
|
3788
|
+
type: "group",
|
|
3789
|
+
items: (Array.isArray(raw.items) ? raw.items : []).map((item) => buildItemRecord(item, platform, spaceRef, ctx))
|
|
3790
|
+
};
|
|
3791
|
+
};
|
|
3792
|
+
const buildItemRecord = (item, platform, spaceRef, ctx) => {
|
|
3793
|
+
const record = isRecord(item) ? item : {};
|
|
3794
|
+
const itemSpace = isRecord(record.space) ? {
|
|
3795
|
+
...record.space,
|
|
3796
|
+
id: asString(record.space.id) || spaceRef.id
|
|
3797
|
+
} : spaceRef;
|
|
3798
|
+
const content = isRecord(record.content) ? deserializeContent(record.content, platform, itemSpace, ctx) : asCustom(record.content);
|
|
3799
|
+
return {
|
|
3800
|
+
id: asString(record.id),
|
|
3801
|
+
content,
|
|
3802
|
+
space: itemSpace,
|
|
3803
|
+
sender: isRecord(record.sender) ? {
|
|
3804
|
+
...record.sender,
|
|
3805
|
+
id: asString(record.sender.id)
|
|
3806
|
+
} : void 0,
|
|
3807
|
+
timestamp: asOptionalDate(record.timestamp)
|
|
3808
|
+
};
|
|
3809
|
+
};
|
|
3810
|
+
const deserializeContact = (raw) => {
|
|
3811
|
+
const input = {};
|
|
3812
|
+
const name = normalizeContactName(raw.name);
|
|
3813
|
+
if (name) input.name = name;
|
|
3814
|
+
const phones = normalizeContactPhones(raw.phones);
|
|
3815
|
+
if (phones) input.phones = phones;
|
|
3816
|
+
if (typeof raw.note === "string") input.note = raw.note;
|
|
3817
|
+
if (raw.raw !== void 0) input.raw = raw.raw;
|
|
3818
|
+
return asContact(input);
|
|
3819
|
+
};
|
|
3820
|
+
const CONTACT_NAME_KEYS = [
|
|
3821
|
+
"formatted",
|
|
3822
|
+
"first",
|
|
3823
|
+
"last",
|
|
3824
|
+
"middle",
|
|
3825
|
+
"prefix",
|
|
3826
|
+
"suffix"
|
|
3827
|
+
];
|
|
3828
|
+
const normalizeContactName = (value) => {
|
|
3829
|
+
if (typeof value === "string") return { formatted: value };
|
|
3830
|
+
if (!isRecord(value)) return;
|
|
3831
|
+
const name = {};
|
|
3832
|
+
for (const key of CONTACT_NAME_KEYS) {
|
|
3833
|
+
const part = value[key];
|
|
3834
|
+
if (typeof part === "string") name[key] = part;
|
|
3835
|
+
}
|
|
3836
|
+
return Object.keys(name).length > 0 ? name : void 0;
|
|
3837
|
+
};
|
|
3838
|
+
const normalizeContactPhones = (value) => {
|
|
3839
|
+
if (!Array.isArray(value)) return;
|
|
3840
|
+
const phones = [];
|
|
3841
|
+
for (const entry of value) if (typeof entry === "string") phones.push({ value: entry });
|
|
3842
|
+
else if (isRecord(entry) && typeof entry.value === "string") phones.push({ value: entry.value });
|
|
3843
|
+
return phones.length > 0 ? phones : void 0;
|
|
3844
|
+
};
|
|
3845
|
+
//#endregion
|
|
3846
|
+
//#region src/webhook/types.ts
|
|
3847
|
+
/**
|
|
3848
|
+
* Wire schemas for the **native Spectrum webhook**
|
|
3849
|
+
* (https://photon.codes/docs/webhooks).
|
|
3850
|
+
*
|
|
3851
|
+
* Unlike the fusor webhook — which relays a raw provider request inside a
|
|
3852
|
+
* protobuf envelope — the native webhook delivers Spectrum's own message model
|
|
3853
|
+
* already normalized to slim JSON (methods and byte payloads stripped), signed
|
|
3854
|
+
* with an HMAC. These schemas validate the fields the deserializer depends on
|
|
3855
|
+
* while **preserving** unknown/extra fields (`z.looseObject`), so additive
|
|
3856
|
+
* changes — new platform-specific space fields, future content arms — never
|
|
3857
|
+
* break an older SDK. Content is discriminated by hand in `deserialize.ts`
|
|
3858
|
+
* rather than here, so an unknown content `type` survives parsing instead of
|
|
3859
|
+
* throwing.
|
|
3860
|
+
*/
|
|
3861
|
+
const slimSenderSchema = z.looseObject({
|
|
3862
|
+
id: z.string(),
|
|
3863
|
+
platform: z.string().optional()
|
|
3864
|
+
});
|
|
3865
|
+
const slimSpaceSchema = z.looseObject({
|
|
3866
|
+
id: z.string(),
|
|
3867
|
+
platform: z.string().optional()
|
|
3868
|
+
});
|
|
3869
|
+
const slimContentSchema = z.looseObject({ type: z.string() });
|
|
3870
|
+
z.looseObject({
|
|
3871
|
+
id: z.string(),
|
|
3872
|
+
platform: z.string().optional(),
|
|
3873
|
+
timestamp: z.string().optional(),
|
|
3874
|
+
sender: slimSenderSchema.optional(),
|
|
3875
|
+
contentPreview: z.string().optional()
|
|
3876
|
+
});
|
|
3877
|
+
const slimMessageSchema = z.looseObject({
|
|
3878
|
+
id: z.string(),
|
|
3879
|
+
platform: z.string().optional(),
|
|
3880
|
+
direction: z.string().optional(),
|
|
3881
|
+
timestamp: z.string().optional(),
|
|
3882
|
+
sender: slimSenderSchema.optional(),
|
|
3883
|
+
space: slimSpaceSchema,
|
|
3884
|
+
content: slimContentSchema
|
|
3885
|
+
});
|
|
3886
|
+
const slimEnvelopeSchema = z.looseObject({
|
|
3887
|
+
event: z.string(),
|
|
3888
|
+
space: slimSpaceSchema.optional(),
|
|
3889
|
+
message: slimMessageSchema
|
|
3890
|
+
});
|
|
3891
|
+
//#endregion
|
|
3892
|
+
//#region src/webhook/verify.ts
|
|
3893
|
+
const SIGNATURE_HEADER = "x-spectrum-signature";
|
|
3894
|
+
const TIMESTAMP_HEADER = "x-spectrum-timestamp";
|
|
3895
|
+
const SIGNATURE_PREFIX = "v0=";
|
|
3896
|
+
const SIGNATURE_SCHEME = "v0";
|
|
3897
|
+
/**
|
|
3898
|
+
* Replay-protection window, in seconds. Spectrum signs each delivery with a
|
|
3899
|
+
* timestamp; a delivery whose timestamp is further than this from now (past or
|
|
3900
|
+
* future) is rejected, so a captured request cannot be replayed indefinitely.
|
|
3901
|
+
* Matches the documented 5-minute tolerance.
|
|
3902
|
+
*/
|
|
3903
|
+
const REPLAY_TOLERANCE_SECONDS = 300;
|
|
3904
|
+
const MILLIS_PER_SECOND = 1e3;
|
|
3905
|
+
/**
|
|
3906
|
+
* Verify a native Spectrum webhook signature.
|
|
3907
|
+
*
|
|
3908
|
+
* The header is `X-Spectrum-Signature: v0=<lowercase-hex>` where the hex digest
|
|
3909
|
+
* is `HMAC-SHA256(secret, "v0:" + timestamp + ":" + rawBody)` and `timestamp`
|
|
3910
|
+
* is the `X-Spectrum-Timestamp` header (unix seconds). The base string is built
|
|
3911
|
+
* over the **exact body bytes**: never JSON-parse-then-restringify before
|
|
3912
|
+
* verifying, or the bytes (key order, whitespace) change and the MAC won't
|
|
3913
|
+
* match. The digest comparison is constant-time.
|
|
3914
|
+
*/
|
|
3915
|
+
function verifySpectrumSignature(input) {
|
|
3916
|
+
const { rawBody, headers, secret, now = Date.now() } = input;
|
|
3917
|
+
const provided = headers[SIGNATURE_HEADER];
|
|
3918
|
+
const timestamp = headers[TIMESTAMP_HEADER];
|
|
3919
|
+
if (!(provided && timestamp)) return {
|
|
3920
|
+
ok: false,
|
|
3921
|
+
reason: "missing-headers"
|
|
3922
|
+
};
|
|
3923
|
+
const timestampSeconds = Number(timestamp);
|
|
3924
|
+
if (!Number.isFinite(timestampSeconds)) return {
|
|
3925
|
+
ok: false,
|
|
3926
|
+
reason: "missing-headers"
|
|
3927
|
+
};
|
|
3928
|
+
const nowSeconds = Math.floor(now / MILLIS_PER_SECOND);
|
|
3929
|
+
if (Math.abs(nowSeconds - timestampSeconds) > REPLAY_TOLERANCE_SECONDS) return {
|
|
3930
|
+
ok: false,
|
|
3931
|
+
reason: "expired"
|
|
3932
|
+
};
|
|
3933
|
+
const base = Buffer.concat([Buffer.from(`${SIGNATURE_SCHEME}:${timestamp}:`, "utf8"), Buffer.from(rawBody)]);
|
|
3934
|
+
const expected = createHmac("sha256", secret).update(base).digest();
|
|
3935
|
+
const providedHex = provided.startsWith(SIGNATURE_PREFIX) ? provided.slice(3) : provided;
|
|
3936
|
+
const providedBytes = Buffer.from(providedHex, "hex");
|
|
3937
|
+
if (providedBytes.length !== expected.length || !timingSafeEqual(providedBytes, expected)) return {
|
|
3938
|
+
ok: false,
|
|
3939
|
+
reason: "signature-mismatch"
|
|
3940
|
+
};
|
|
3941
|
+
return { ok: true };
|
|
3942
|
+
}
|
|
3943
|
+
//#endregion
|
|
3944
|
+
//#region src/spectrum.ts
|
|
3945
|
+
const PHOTON_OTEL_ENDPOINT = "https://otlp.photon.codes";
|
|
3946
|
+
const STREAM_CLOSE_TIMEOUT_MS = 5e3;
|
|
3947
|
+
const lifecycleLog = createLogger("spectrum.lifecycle");
|
|
3948
|
+
const ignoreCleanupError = () => void 0;
|
|
3949
|
+
const spectrumOptionsSchema = z.object({
|
|
3950
|
+
flattenGroups: z.boolean().optional(),
|
|
3951
|
+
logLevel: z.enum([
|
|
3952
|
+
"debug",
|
|
3953
|
+
"info",
|
|
3954
|
+
"warn",
|
|
3955
|
+
"error",
|
|
3956
|
+
"silent"
|
|
3957
|
+
]).optional()
|
|
3958
|
+
}).optional();
|
|
3959
|
+
const spectrumConfigSchema = z.union([z.object({
|
|
3960
|
+
projectId: z.string().min(1),
|
|
3961
|
+
projectSecret: z.string().min(1),
|
|
3962
|
+
providers: z.array(z.custom()),
|
|
3963
|
+
options: spectrumOptionsSchema,
|
|
3964
|
+
telemetry: z.boolean().optional(),
|
|
3965
|
+
webhookSecret: z.string().min(1).optional()
|
|
3966
|
+
}), z.object({
|
|
3967
|
+
projectId: z.undefined().optional(),
|
|
3968
|
+
projectSecret: z.undefined().optional(),
|
|
3969
|
+
providers: z.array(z.custom()),
|
|
3970
|
+
options: spectrumOptionsSchema,
|
|
3971
|
+
telemetry: z.boolean().optional(),
|
|
3972
|
+
webhookSecret: z.string().min(1).optional()
|
|
3973
|
+
})]);
|
|
3974
|
+
function bootstrapTelemetry(opts) {
|
|
3975
|
+
const headers = {};
|
|
3976
|
+
if (opts.projectId && opts.projectSecret) {
|
|
3977
|
+
const credential = `${opts.projectId}:${opts.projectSecret}`;
|
|
3978
|
+
headers.Authorization = `Basic ${btoa(credential)}`;
|
|
3979
|
+
}
|
|
3980
|
+
const resourceAttributes = { "deployment.environment": process.env.DEPLOYMENT_ENV ?? "production" };
|
|
3981
|
+
if (opts.projectId) resourceAttributes["spectrum.project_id"] = opts.projectId;
|
|
3982
|
+
return setupOtel({
|
|
3983
|
+
serviceName: "spectrum-ts",
|
|
3984
|
+
serviceVersion: SPECTRUM_SDK_VERSION,
|
|
3985
|
+
endpoint: PHOTON_OTEL_ENDPOINT,
|
|
3986
|
+
headers,
|
|
3987
|
+
resourceAttributes
|
|
3988
|
+
});
|
|
3989
|
+
}
|
|
3990
|
+
function applyLogLevel(level) {
|
|
3991
|
+
if (level) setLogLevel(level);
|
|
3992
|
+
}
|
|
3993
|
+
async function Spectrum(options) {
|
|
3994
|
+
spectrumConfigSchema.parse(options);
|
|
3995
|
+
const { projectId, projectSecret, providers, options: runtimeOptions, telemetry, webhookSecret } = options;
|
|
3996
|
+
const flattenGroups = runtimeOptions?.flattenGroups ?? false;
|
|
3997
|
+
applyLogLevel(runtimeOptions?.logLevel);
|
|
3998
|
+
const resolvedWebhookSecret = webhookSecret ?? process.env.SPECTRUM_WEBHOOK_SECRET;
|
|
3999
|
+
const otelHandle = telemetry ? bootstrapTelemetry({
|
|
4000
|
+
projectId,
|
|
4001
|
+
projectSecret
|
|
4002
|
+
}) : void 0;
|
|
4003
|
+
const projectConfig = projectId !== void 0 && projectSecret !== void 0 ? await cloud.getProject(projectId, projectSecret) : void 0;
|
|
4004
|
+
const platformStates = /* @__PURE__ */ new Map();
|
|
4005
|
+
const fusorMessageSources = /* @__PURE__ */ new Map();
|
|
4006
|
+
const messageBroadcasters = /* @__PURE__ */ new Map();
|
|
4007
|
+
const fusorEventSources = /* @__PURE__ */ new Map();
|
|
4008
|
+
const eventBroadcasters = /* @__PURE__ */ new Map();
|
|
4009
|
+
const customEventStreams = /* @__PURE__ */ new Map();
|
|
4010
|
+
let stopped = false;
|
|
4011
|
+
const adaptIterable = (iterable, project) => stream((emit, end) => {
|
|
4012
|
+
const iterator = iterable[Symbol.asyncIterator]();
|
|
4013
|
+
const pump = (async () => {
|
|
4014
|
+
try {
|
|
4015
|
+
let result = await iterator.next();
|
|
4016
|
+
while (!result.done) {
|
|
4017
|
+
if (project) await project(result.value, emit);
|
|
4018
|
+
else await emit(result.value);
|
|
4019
|
+
result = await iterator.next();
|
|
4020
|
+
}
|
|
4021
|
+
end();
|
|
4022
|
+
} catch (error) {
|
|
4023
|
+
end(error);
|
|
4024
|
+
}
|
|
4025
|
+
})();
|
|
4026
|
+
return async () => {
|
|
4027
|
+
await iterator.return?.();
|
|
4028
|
+
await pump.catch(ignoreCleanupError);
|
|
4029
|
+
};
|
|
4030
|
+
});
|
|
4031
|
+
const resolveRecordToMessages = async (record, rt) => {
|
|
4032
|
+
const { client, config, definition, store } = rt;
|
|
4033
|
+
const { space, normalizedMessage } = await withSpan("spectrum.message.receive", {
|
|
4034
|
+
"spectrum.provider": definition.name,
|
|
4035
|
+
"spectrum.message.id": record.id,
|
|
4036
|
+
"spectrum.space.id": record.space?.id,
|
|
4037
|
+
...contentAttrs(record.content),
|
|
4038
|
+
...senderAttrs(record.sender)
|
|
4039
|
+
}, () => {
|
|
4040
|
+
const spaceRef = {
|
|
4041
|
+
...record.space,
|
|
4042
|
+
__platform: definition.name
|
|
4043
|
+
};
|
|
4044
|
+
const space = buildSpace({
|
|
4045
|
+
spaceRef,
|
|
4046
|
+
extras: {},
|
|
4047
|
+
actionCtx: {
|
|
4048
|
+
space: spaceRef,
|
|
4049
|
+
client,
|
|
4050
|
+
config,
|
|
4051
|
+
store
|
|
4052
|
+
},
|
|
4053
|
+
definition,
|
|
4054
|
+
client,
|
|
4055
|
+
config,
|
|
4056
|
+
store
|
|
4057
|
+
});
|
|
4058
|
+
return {
|
|
4059
|
+
space,
|
|
4060
|
+
normalizedMessage: wrapProviderMessage(record, {
|
|
4061
|
+
client,
|
|
4062
|
+
config,
|
|
4063
|
+
definition,
|
|
4064
|
+
space,
|
|
4065
|
+
spaceRef,
|
|
4066
|
+
store
|
|
4067
|
+
}, "inbound")
|
|
4068
|
+
};
|
|
4069
|
+
});
|
|
4070
|
+
if (flattenGroups && normalizedMessage.content.type === "group") return normalizedMessage.content.items.map((item) => [space, item]);
|
|
4071
|
+
return [[space, normalizedMessage]];
|
|
4072
|
+
};
|
|
4073
|
+
const createProviderMessagesStream = (state) => {
|
|
4074
|
+
const { client, config, definition, store } = state;
|
|
4075
|
+
const fusorSource = fusorMessageSources.get(definition.name);
|
|
4076
|
+
return adaptIterable(fusorSource ? fusorSource.iterable : definition.messages({
|
|
4077
|
+
client,
|
|
4078
|
+
config,
|
|
4079
|
+
projectConfig,
|
|
4080
|
+
store
|
|
4081
|
+
}), async (record, emit) => {
|
|
4082
|
+
const tuples = await resolveRecordToMessages(record, {
|
|
4083
|
+
client,
|
|
4084
|
+
config,
|
|
4085
|
+
definition,
|
|
4086
|
+
store
|
|
4087
|
+
});
|
|
4088
|
+
for (const tuple of tuples) await emit(tuple);
|
|
4089
|
+
});
|
|
4090
|
+
};
|
|
4091
|
+
const getOrCreateMessageBroadcast = (state) => {
|
|
4092
|
+
if (stopped) throw new Error(`Spectrum instance has been stopped; cannot subscribe to "${state.definition.name}" messages`);
|
|
4093
|
+
const name = state.definition.name;
|
|
4094
|
+
let broadcaster = messageBroadcasters.get(name);
|
|
4095
|
+
if (!broadcaster) {
|
|
4096
|
+
broadcaster = broadcast(createProviderMessagesStream(state));
|
|
4097
|
+
messageBroadcasters.set(name, broadcaster);
|
|
4098
|
+
}
|
|
4099
|
+
return broadcaster;
|
|
4100
|
+
};
|
|
4101
|
+
const getOrCreateEventBroadcast = (platform, channel) => {
|
|
4102
|
+
const queue = fusorEventSources.get(platform)?.get(channel);
|
|
4103
|
+
if (!queue) return;
|
|
4104
|
+
if (stopped) throw new Error(`Spectrum instance has been stopped; cannot subscribe to "${platform}" event "${channel}"`);
|
|
4105
|
+
const key = `${platform}${channel}`;
|
|
4106
|
+
let broadcaster = eventBroadcasters.get(key);
|
|
4107
|
+
if (!broadcaster) {
|
|
4108
|
+
broadcaster = broadcast(adaptIterable(queue.iterable));
|
|
4109
|
+
eventBroadcasters.set(key, broadcaster);
|
|
4110
|
+
}
|
|
4111
|
+
return broadcaster;
|
|
4112
|
+
};
|
|
4113
|
+
await withSpan("spectrum.init", {
|
|
4114
|
+
"spectrum.provider_count": providers.length,
|
|
4115
|
+
"spectrum.flatten_groups": flattenGroups
|
|
4116
|
+
}, async () => {
|
|
4117
|
+
for (const provider of providers) {
|
|
4118
|
+
const providerConfig = provider;
|
|
4119
|
+
const def = providerConfig.__definition;
|
|
4120
|
+
const userConfig = def.config.parse(providerConfig.config);
|
|
4121
|
+
const store = createStore();
|
|
4122
|
+
const state = {
|
|
4123
|
+
client: await withSpan("spectrum.provider.create_client", { "spectrum.provider": def.name }, () => def.lifecycle.createClient({
|
|
4124
|
+
config: userConfig,
|
|
4125
|
+
projectId,
|
|
4126
|
+
projectSecret,
|
|
4127
|
+
projectConfig,
|
|
4128
|
+
store
|
|
4129
|
+
})),
|
|
4130
|
+
config: userConfig,
|
|
4131
|
+
definition: def,
|
|
4132
|
+
store
|
|
4133
|
+
};
|
|
4134
|
+
platformStates.set(def.name, {
|
|
4135
|
+
...state,
|
|
4136
|
+
projectConfig,
|
|
4137
|
+
subscribeMessages: () => getOrCreateMessageBroadcast(state).subscribe(),
|
|
4138
|
+
subscribeEvent: (channel) => getOrCreateEventBroadcast(def.name, channel)?.subscribe()
|
|
4139
|
+
});
|
|
4140
|
+
}
|
|
4141
|
+
});
|
|
4142
|
+
let fusorCore;
|
|
4143
|
+
let fusorStartPromise;
|
|
4144
|
+
const fusorPlatforms = [];
|
|
4145
|
+
for (const [name, state] of platformStates) if (isFusorClient(state.client)) fusorPlatforms.push({
|
|
4146
|
+
name,
|
|
4147
|
+
client: state.client
|
|
4148
|
+
});
|
|
4149
|
+
if (fusorPlatforms.length > 0) {
|
|
4150
|
+
fusorCore = new FusorCore({
|
|
4151
|
+
projectId,
|
|
4152
|
+
projectSecret
|
|
4153
|
+
});
|
|
4154
|
+
for (const { name, client } of fusorPlatforms) {
|
|
4155
|
+
const queue = createAsyncQueue();
|
|
4156
|
+
fusorMessageSources.set(name, queue);
|
|
4157
|
+
const runtime = platformStates.get(name);
|
|
4158
|
+
if (!runtime) continue;
|
|
4159
|
+
const userMessages = runtime.definition.messages;
|
|
4160
|
+
const declaredEvents = runtime.definition.events ?? {};
|
|
4161
|
+
const eventQueues = /* @__PURE__ */ new Map();
|
|
4162
|
+
for (const channel of Object.keys(declaredEvents)) eventQueues.set(channel, createAsyncQueue());
|
|
4163
|
+
fusorEventSources.set(name, eventQueues);
|
|
4164
|
+
const handler = {
|
|
4165
|
+
verify: client.verify,
|
|
4166
|
+
messages: async (ctx) => userMessages({
|
|
4167
|
+
...ctx,
|
|
4168
|
+
config: runtime.config,
|
|
4169
|
+
store: runtime.store,
|
|
4170
|
+
projectConfig: runtime.projectConfig
|
|
4171
|
+
}),
|
|
4172
|
+
pushMessage: (record) => queue.push(record),
|
|
4173
|
+
pushEvent: (channel, data) => {
|
|
4174
|
+
const eventQueue = eventQueues.get(channel);
|
|
4175
|
+
if (!eventQueue) {
|
|
4176
|
+
lifecycleLog.warn(`spectrum: fusorEvent("${channel}", …) names a channel not declared in "${name}".events; dropping`, {
|
|
4177
|
+
"spectrum.lifecycle.platform": name,
|
|
4178
|
+
"spectrum.lifecycle.channel": channel
|
|
4179
|
+
});
|
|
4180
|
+
return;
|
|
4181
|
+
}
|
|
4182
|
+
eventQueue.push(data);
|
|
4183
|
+
}
|
|
4184
|
+
};
|
|
4185
|
+
fusorCore.register(client.platform, handler);
|
|
4186
|
+
}
|
|
4187
|
+
}
|
|
4188
|
+
const ensureFusorStarted = () => {
|
|
4189
|
+
if (!fusorCore) return Promise.resolve();
|
|
4190
|
+
if (!fusorStartPromise) fusorStartPromise = fusorCore.start();
|
|
4191
|
+
return fusorStartPromise;
|
|
4192
|
+
};
|
|
4193
|
+
const providerNames = providers.map((p) => p.__definition.name).join(",");
|
|
4194
|
+
lifecycleLog.info("Spectrum started", {
|
|
4195
|
+
"spectrum.lifecycle.provider_count": providers.length,
|
|
4196
|
+
"spectrum.lifecycle.providers": providerNames,
|
|
4197
|
+
"spectrum.lifecycle.telemetry": telemetry === true
|
|
4198
|
+
});
|
|
4199
|
+
if (projectConfig && projectId !== void 0) {
|
|
4200
|
+
const registered = new Set(Array.from(platformStates.keys(), normalizePlatformKey));
|
|
4201
|
+
cloud.getPlatforms(projectId).then((platforms) => {
|
|
4202
|
+
for (const [platform, status] of Object.entries(platforms)) {
|
|
4203
|
+
if (!status.enabled || registered.has(normalizePlatformKey(platform))) continue;
|
|
4204
|
+
const hint = officialProviderInstallHint(platform);
|
|
4205
|
+
lifecycleLog.warn(hint ? `spectrum: project has "${platform}" enabled but no matching provider is registered — ${hint}` : `spectrum: project has "${platform}" enabled but no matching provider is registered`, { "spectrum.lifecycle.platform": platform });
|
|
4206
|
+
}
|
|
4207
|
+
}).catch(() => {});
|
|
4208
|
+
}
|
|
4209
|
+
const createMessagesStream = () => stream((emit, end) => {
|
|
4210
|
+
ensureFusorStarted().catch((error) => end(error));
|
|
4211
|
+
const merged = mergeStreams(Array.from(platformStates.values(), (runtime) => runtime.subscribeMessages()));
|
|
4212
|
+
const pump = (async () => {
|
|
4213
|
+
try {
|
|
4214
|
+
for await (const value of merged) await emit(value);
|
|
4215
|
+
end();
|
|
4216
|
+
} catch (error) {
|
|
4217
|
+
end(error);
|
|
4218
|
+
}
|
|
4219
|
+
})();
|
|
4220
|
+
return async () => {
|
|
4221
|
+
await merged.close();
|
|
4222
|
+
await pump.catch(ignoreCleanupError);
|
|
4223
|
+
};
|
|
4224
|
+
});
|
|
4225
|
+
const createCustomEventStream = (eventName) => stream((emit, end) => {
|
|
4226
|
+
const providerStreams = [];
|
|
4227
|
+
for (const state of platformStates.values()) {
|
|
4228
|
+
const { client, config, definition, store } = state;
|
|
4229
|
+
let source = state.subscribeEvent?.(eventName);
|
|
4230
|
+
if (!source) {
|
|
4231
|
+
const producer = definition.events?.[eventName];
|
|
4232
|
+
if (typeof producer !== "function") continue;
|
|
4233
|
+
source = producer({
|
|
4234
|
+
client,
|
|
4235
|
+
config,
|
|
4236
|
+
projectConfig,
|
|
4237
|
+
store
|
|
4238
|
+
});
|
|
4239
|
+
}
|
|
4240
|
+
const providerEvents = source;
|
|
4241
|
+
providerStreams.push(adaptIterable(providerEvents, async (value, emit) => {
|
|
4242
|
+
await emit(await withSpan("spectrum.event", {
|
|
4243
|
+
"spectrum.provider": definition.name,
|
|
4244
|
+
"spectrum.event.name": eventName
|
|
4245
|
+
}, () => typeof value === "object" && value !== null ? {
|
|
4246
|
+
...value,
|
|
4247
|
+
platform: definition.name
|
|
4248
|
+
} : {
|
|
4249
|
+
platform: definition.name,
|
|
4250
|
+
payload: value
|
|
4251
|
+
}));
|
|
4252
|
+
}));
|
|
4253
|
+
}
|
|
4254
|
+
const merged = mergeStreams(providerStreams);
|
|
4255
|
+
const pump = (async () => {
|
|
4256
|
+
try {
|
|
4257
|
+
for await (const value of merged) await emit(value);
|
|
4258
|
+
end();
|
|
4259
|
+
} catch (error) {
|
|
4260
|
+
end(error);
|
|
4261
|
+
}
|
|
4262
|
+
})();
|
|
4263
|
+
return async () => {
|
|
4264
|
+
await merged.close();
|
|
4265
|
+
await pump.catch(ignoreCleanupError);
|
|
4266
|
+
};
|
|
4267
|
+
});
|
|
4268
|
+
const messagesStream = createMessagesStream();
|
|
4269
|
+
const closeFusorSources = () => {
|
|
4270
|
+
for (const queue of fusorMessageSources.values()) queue.close();
|
|
4271
|
+
fusorMessageSources.clear();
|
|
4272
|
+
for (const queues of fusorEventSources.values()) for (const queue of queues.values()) queue.close();
|
|
4273
|
+
fusorEventSources.clear();
|
|
4274
|
+
};
|
|
4275
|
+
const stopOnce = async () => {
|
|
4276
|
+
if (stopped) return;
|
|
4277
|
+
stopped = true;
|
|
4278
|
+
const streamShutdowns = [
|
|
4279
|
+
messagesStream.close(),
|
|
4280
|
+
...Array.from(customEventStreams.values(), (eventStream) => eventStream.close()),
|
|
4281
|
+
...Array.from(messageBroadcasters.values(), (broadcaster) => broadcaster.close()),
|
|
4282
|
+
...Array.from(eventBroadcasters.values(), (broadcaster) => broadcaster.close())
|
|
4283
|
+
];
|
|
4284
|
+
process.off("SIGINT", handleSignal);
|
|
4285
|
+
process.off("SIGTERM", handleSignal);
|
|
4286
|
+
const streamCloseStart = performance.now();
|
|
4287
|
+
const streamSettled = Promise.allSettled(streamShutdowns);
|
|
4288
|
+
let streamTimedOut = false;
|
|
4289
|
+
await Promise.race([streamSettled, new Promise((resolve) => {
|
|
4290
|
+
setTimeout(() => {
|
|
4291
|
+
streamTimedOut = true;
|
|
4292
|
+
resolve();
|
|
4293
|
+
}, STREAM_CLOSE_TIMEOUT_MS).unref();
|
|
4294
|
+
})]);
|
|
4295
|
+
if (streamTimedOut) lifecycleLog.warn("stream close timed out; proceeding to teardown", { "spectrum.lifecycle.stream_close_timeout_ms": STREAM_CLOSE_TIMEOUT_MS });
|
|
4296
|
+
let fusorCloseMs = 0;
|
|
4297
|
+
if (fusorCore) {
|
|
4298
|
+
const fusorCloseStart = performance.now();
|
|
4299
|
+
if (fusorStartPromise) await fusorStartPromise.catch(ignoreCleanupError);
|
|
4300
|
+
await fusorCore.close().catch((error) => {
|
|
4301
|
+
lifecycleLog.warn("fusor core close failed", errorAttrs(error), error);
|
|
4302
|
+
});
|
|
4303
|
+
fusorCloseMs = Math.round(performance.now() - fusorCloseStart);
|
|
4304
|
+
closeFusorSources();
|
|
4305
|
+
}
|
|
4306
|
+
const clientShutdowns = [];
|
|
4307
|
+
for (const state of platformStates.values()) {
|
|
4308
|
+
const destroy = state.definition.lifecycle.destroyClient;
|
|
4309
|
+
if (!destroy) continue;
|
|
4310
|
+
clientShutdowns.push(withSpan("spectrum.provider.destroy_client", { "spectrum.provider": state.definition.name }, () => destroy({
|
|
4311
|
+
client: state.client,
|
|
4312
|
+
store: state.store
|
|
4313
|
+
})));
|
|
4314
|
+
}
|
|
4315
|
+
const clientCloseStart = performance.now();
|
|
4316
|
+
await Promise.allSettled(clientShutdowns);
|
|
4317
|
+
const clientCloseMs = Math.round(performance.now() - clientCloseStart);
|
|
4318
|
+
await streamSettled.catch(() => void 0);
|
|
4319
|
+
const streamCloseMs = Math.round(performance.now() - streamCloseStart);
|
|
4320
|
+
customEventStreams.clear();
|
|
4321
|
+
messageBroadcasters.clear();
|
|
4322
|
+
eventBroadcasters.clear();
|
|
4323
|
+
platformStates.clear();
|
|
4324
|
+
lifecycleLog.info("Spectrum stopped", {
|
|
4325
|
+
"spectrum.lifecycle.providers": providerNames,
|
|
4326
|
+
"spectrum.lifecycle.stream_close_ms": streamCloseMs,
|
|
4327
|
+
"spectrum.lifecycle.fusor_close_ms": fusorCloseMs,
|
|
4328
|
+
"spectrum.lifecycle.client_close_ms": clientCloseMs
|
|
4329
|
+
});
|
|
4330
|
+
if (otelHandle) await otelHandle.shutdown();
|
|
4331
|
+
};
|
|
4332
|
+
const handleSignal = () => {
|
|
4333
|
+
setTimeout(() => process.exit(1), 3e3).unref();
|
|
4334
|
+
stopOnce().then(() => process.exit(0)).catch(() => process.exit(1));
|
|
4335
|
+
};
|
|
4336
|
+
process.on("SIGINT", handleSignal);
|
|
4337
|
+
process.on("SIGTERM", handleSignal);
|
|
4338
|
+
const messages = messagesStream;
|
|
4339
|
+
const customEventProxy = new Proxy({}, { get(_target, prop) {
|
|
4340
|
+
let eventStream = customEventStreams.get(prop);
|
|
4341
|
+
if (!eventStream) {
|
|
4342
|
+
eventStream = createCustomEventStream(prop);
|
|
4343
|
+
customEventStreams.set(prop, eventStream);
|
|
4344
|
+
}
|
|
4345
|
+
return eventStream;
|
|
4346
|
+
} });
|
|
4347
|
+
const encodeText = (s) => new TextEncoder().encode(s);
|
|
4348
|
+
const buildWebhookResult = (asWeb, result) => {
|
|
4349
|
+
if (asWeb) return new Response(result.body, {
|
|
4350
|
+
status: result.status,
|
|
4351
|
+
headers: result.headers
|
|
4352
|
+
});
|
|
4353
|
+
return result;
|
|
4354
|
+
};
|
|
4355
|
+
const readWebhookInput = async (request) => {
|
|
4356
|
+
if (typeof Request !== "undefined" && request instanceof Request) {
|
|
4357
|
+
const headers = {};
|
|
4358
|
+
for (const [key, value] of request.headers) headers[key.toLowerCase()] = value;
|
|
4359
|
+
return {
|
|
4360
|
+
asWeb: true,
|
|
4361
|
+
bodyBytes: new Uint8Array(await request.arrayBuffer()),
|
|
4362
|
+
headers
|
|
4363
|
+
};
|
|
4364
|
+
}
|
|
4365
|
+
const raw = request;
|
|
4366
|
+
const bodyBytes = raw.body instanceof ArrayBuffer ? new Uint8Array(raw.body) : raw.body;
|
|
4367
|
+
const headers = {};
|
|
4368
|
+
for (const [key, value] of Object.entries(raw.headers ?? {})) headers[key.toLowerCase()] = String(value);
|
|
4369
|
+
return {
|
|
4370
|
+
asWeb: false,
|
|
4371
|
+
bodyBytes,
|
|
4372
|
+
headers
|
|
4373
|
+
};
|
|
4374
|
+
};
|
|
4375
|
+
const deliverWebhookMessages = async (collected, runtime, handler, context) => {
|
|
4376
|
+
for (const record of collected) {
|
|
4377
|
+
const tuples = await resolveRecordToMessages(record, runtime);
|
|
4378
|
+
for (const [space, message] of tuples) try {
|
|
4379
|
+
await handler(space, message);
|
|
4380
|
+
} catch (error) {
|
|
4381
|
+
lifecycleLog.error("spectrum.webhook: handler threw (async)", {
|
|
4382
|
+
"spectrum.webhook.event_id": context.eventId,
|
|
4383
|
+
"spectrum.webhook.platform": context.platform,
|
|
4384
|
+
"spectrum.webhook.message_id": message.id,
|
|
4385
|
+
...errorAttrs(error)
|
|
4386
|
+
}, error);
|
|
4387
|
+
}
|
|
4388
|
+
}
|
|
4389
|
+
};
|
|
4390
|
+
const decodeWebhookEvent = (bodyBytes) => {
|
|
4391
|
+
try {
|
|
4392
|
+
return RawInboundEvent.decode(bodyBytes);
|
|
4393
|
+
} catch (error) {
|
|
4394
|
+
lifecycleLog.warn("spectrum.webhook: undecodable RawInboundEvent body", errorAttrs(error), error);
|
|
4395
|
+
return null;
|
|
4396
|
+
}
|
|
4397
|
+
};
|
|
4398
|
+
const processWebhookEvent = async (core, event, handler) => {
|
|
4399
|
+
const collected = [];
|
|
4400
|
+
const reply = await core.processEvent(event, (record) => {
|
|
4401
|
+
collected.push(record);
|
|
4402
|
+
});
|
|
4403
|
+
if (reply.errorReason) return {
|
|
4404
|
+
status: 400,
|
|
4405
|
+
headers: reply.headers ?? {},
|
|
4406
|
+
body: encodeText(reply.errorReason)
|
|
4407
|
+
};
|
|
4408
|
+
const result = {
|
|
4409
|
+
status: reply.status === 0 ? 200 : reply.status,
|
|
4410
|
+
headers: reply.headers ?? {},
|
|
4411
|
+
body: reply.body ?? new Uint8Array(0)
|
|
4412
|
+
};
|
|
4413
|
+
const runtime = platformStates.get(event.platform);
|
|
4414
|
+
if (runtime && collected.length > 0) deliverWebhookMessages(collected, runtime, handler, event).catch((error) => {
|
|
4415
|
+
lifecycleLog.error("spectrum.webhook: delivery failed (async)", {
|
|
4416
|
+
"spectrum.webhook.event_id": event.eventId,
|
|
4417
|
+
"spectrum.webhook.platform": event.platform,
|
|
4418
|
+
...errorAttrs(error)
|
|
4419
|
+
}, error);
|
|
4420
|
+
});
|
|
4421
|
+
return result;
|
|
4422
|
+
};
|
|
4423
|
+
const looksLikeNativePayload = (bodyBytes) => {
|
|
4424
|
+
for (const byte of bodyBytes) {
|
|
4425
|
+
if (byte === 32 || byte === 9 || byte === 10 || byte === 13) continue;
|
|
4426
|
+
return byte === 123;
|
|
4427
|
+
}
|
|
4428
|
+
return false;
|
|
4429
|
+
};
|
|
4430
|
+
const webhookText = (status, text) => ({
|
|
4431
|
+
status,
|
|
4432
|
+
headers: {},
|
|
4433
|
+
body: encodeText(text)
|
|
4434
|
+
});
|
|
4435
|
+
const resolveWebhookAttachment = (platform, spaceRef, attachmentId) => {
|
|
4436
|
+
const runtime = platformStates.get(platform);
|
|
4437
|
+
const action = (runtime?.definition)?.actions?.getAttachment;
|
|
4438
|
+
if (!runtime || typeof action !== "function") return;
|
|
4439
|
+
const getAttachment = action;
|
|
4440
|
+
const phone = typeof spaceRef.phone === "string" ? spaceRef.phone : void 0;
|
|
4441
|
+
let cached;
|
|
4442
|
+
const fetchOnce = () => {
|
|
4443
|
+
cached ??= getAttachment({
|
|
4444
|
+
client: runtime.client,
|
|
4445
|
+
config: runtime.config,
|
|
4446
|
+
store: runtime.store
|
|
4447
|
+
}, attachmentId, phone);
|
|
4448
|
+
return cached;
|
|
4449
|
+
};
|
|
4450
|
+
return {
|
|
4451
|
+
read: async () => {
|
|
4452
|
+
const found = await fetchOnce();
|
|
4453
|
+
if (!found) throw new Error(`Spectrum webhook attachment "${attachmentId}" not found on "${platform}"`);
|
|
4454
|
+
return found.read();
|
|
4455
|
+
},
|
|
4456
|
+
stream: async () => {
|
|
4457
|
+
const found = await fetchOnce();
|
|
4458
|
+
if (!found?.stream) throw new Error(`Spectrum webhook attachment "${attachmentId}" has no stream on "${platform}"`);
|
|
4459
|
+
return found.stream();
|
|
4460
|
+
}
|
|
4461
|
+
};
|
|
4462
|
+
};
|
|
4463
|
+
const handleSpectrumWebhook = async (bodyBytes, headers, handler) => {
|
|
4464
|
+
if (!resolvedWebhookSecret) {
|
|
4465
|
+
lifecycleLog.error("spectrum.webhook: received a signed Spectrum webhook but no webhookSecret is configured (set Spectrum({ webhookSecret }) or SPECTRUM_WEBHOOK_SECRET)", { "spectrum.webhook.reason": "missing-secret" });
|
|
4466
|
+
return webhookText(500, "webhook secret not configured");
|
|
4467
|
+
}
|
|
4468
|
+
const verification = verifySpectrumSignature({
|
|
4469
|
+
rawBody: bodyBytes,
|
|
4470
|
+
headers,
|
|
4471
|
+
secret: resolvedWebhookSecret
|
|
4472
|
+
});
|
|
4473
|
+
if (!verification.ok) return webhookText(verification.reason === "missing-headers" ? 400 : 401, verification.reason);
|
|
4474
|
+
let envelope;
|
|
4475
|
+
try {
|
|
4476
|
+
const parsed = JSON.parse(new TextDecoder().decode(bodyBytes));
|
|
4477
|
+
envelope = slimEnvelopeSchema.parse(parsed);
|
|
4478
|
+
} catch (error) {
|
|
4479
|
+
lifecycleLog.warn("spectrum.webhook: malformed Spectrum webhook payload", errorAttrs(error), error);
|
|
4480
|
+
return webhookText(400, "malformed payload");
|
|
4481
|
+
}
|
|
4482
|
+
const deserialized = deserializeSpectrumMessage(envelope, { resolveAttachment: resolveWebhookAttachment });
|
|
4483
|
+
if (!deserialized) return webhookText(200, "ok");
|
|
4484
|
+
const { platform, record } = deserialized;
|
|
4485
|
+
const runtime = platformStates.get(platform);
|
|
4486
|
+
if (!runtime) {
|
|
4487
|
+
lifecycleLog.warn(`spectrum.webhook: no provider configured for platform "${platform}"; acknowledging without delivery`, { "spectrum.webhook.platform": platform });
|
|
4488
|
+
return webhookText(200, "ok");
|
|
4489
|
+
}
|
|
4490
|
+
deliverWebhookMessages([record], runtime, handler, { platform }).catch((error) => {
|
|
4491
|
+
lifecycleLog.error("spectrum.webhook: Spectrum delivery failed (async)", {
|
|
4492
|
+
"spectrum.webhook.platform": platform,
|
|
4493
|
+
"spectrum.webhook.message_id": record.id,
|
|
4494
|
+
...errorAttrs(error)
|
|
4495
|
+
}, error);
|
|
4496
|
+
});
|
|
4497
|
+
return webhookText(200, "ok");
|
|
4498
|
+
};
|
|
4499
|
+
const handleWebhook = async (request, handler) => {
|
|
4500
|
+
const { asWeb, bodyBytes, headers } = await readWebhookInput(request);
|
|
4501
|
+
if (looksLikeNativePayload(bodyBytes)) return buildWebhookResult(asWeb, await handleSpectrumWebhook(bodyBytes, headers, handler));
|
|
4502
|
+
if (!fusorCore) throw new Error("spectrum.webhook() received a non-Spectrum (fusor) request but no fusor provider is configured");
|
|
4503
|
+
const event = decodeWebhookEvent(bodyBytes);
|
|
4504
|
+
if (!event) return buildWebhookResult(asWeb, {
|
|
4505
|
+
status: 400,
|
|
4506
|
+
headers: {},
|
|
4507
|
+
body: new Uint8Array(0)
|
|
4508
|
+
});
|
|
4509
|
+
return buildWebhookResult(asWeb, await processWebhookEvent(fusorCore, event, handler));
|
|
4510
|
+
};
|
|
4511
|
+
return new Proxy({
|
|
4512
|
+
__providers: providers,
|
|
4513
|
+
__internal: { platforms: platformStates },
|
|
4514
|
+
config: projectConfig,
|
|
4515
|
+
messages,
|
|
4516
|
+
stop: stopOnce,
|
|
4517
|
+
webhook: handleWebhook,
|
|
4518
|
+
send: (async (space, ...content) => content.length === 1 ? await space.send(content[0]) : await space.send(...content)),
|
|
4519
|
+
edit: async (message, newContent) => {
|
|
4520
|
+
await message.edit(newContent);
|
|
4521
|
+
},
|
|
4522
|
+
responding: async (space, fn) => space.responding(fn)
|
|
4523
|
+
}, { get(target, prop, receiver) {
|
|
4524
|
+
if (prop in target) return Reflect.get(target, prop, receiver);
|
|
4525
|
+
if (typeof prop === "string") return customEventProxy[prop];
|
|
4526
|
+
} });
|
|
4527
|
+
}
|
|
4528
|
+
//#endregion
|
|
4529
|
+
export { Emoji, Spectrum, SpectrumCloudError, UnsupportedError, app, appLayoutSchema, attachment, avatar, broadcast, cloud, contact, custom, definePlatform, edit, fromVCard, fusor, fusorEvent, group, isFusorClient, isFusorEvent, markdown, mergeStreams, option, poll, reaction, read, rename, reply, resolveContents, richlink, stream, text, toVCard, typing, unsend, voice };
|