spectrum-ts 0.2.2 → 0.4.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/src/spectrum.ts DELETED
@@ -1,390 +0,0 @@
1
- import z from "zod";
2
- import type {
3
- AnyPlatformDef,
4
- CustomEventStreams,
5
- PlatformProviderConfig,
6
- SpectrumLike,
7
- } from "./platform/types";
8
- import type { Content, ContentBuilder } from "./types/content";
9
- import type { Message } from "./types/message";
10
- import type { Space } from "./types/space";
11
- import { type ManagedStream, mergeStreams, stream } from "./utils/stream";
12
-
13
- type ProviderMessageRecord = {
14
- id: string;
15
- content: Content[];
16
- sender: { id: string } & Record<string, unknown>;
17
- space: { id: string } & Record<string, unknown>;
18
- timestamp?: Date;
19
- } & Record<string, unknown>;
20
-
21
- const providerMessageCoreKeys = new Set([
22
- "content",
23
- "id",
24
- "sender",
25
- "space",
26
- "timestamp",
27
- ]);
28
-
29
- // ---------------------------------------------------------------------------
30
- // SpectrumInstance — the typed return of Spectrum()
31
- // ---------------------------------------------------------------------------
32
-
33
- export type SpectrumInstance<
34
- Providers extends PlatformProviderConfig[] = PlatformProviderConfig[],
35
- > = SpectrumLike<Providers> &
36
- CustomEventStreams<Providers> & {
37
- readonly messages: AsyncIterable<[Space, Message]>;
38
- stop(): Promise<void>;
39
- send(
40
- space: Space,
41
- ...content: [ContentBuilder, ...ContentBuilder[]]
42
- ): Promise<void>;
43
- responding<T>(space: Space, fn: () => T | Promise<T>): Promise<T>;
44
- };
45
-
46
- // ---------------------------------------------------------------------------
47
- // Config validation
48
- // ---------------------------------------------------------------------------
49
-
50
- const spectrumConfigSchema = z.union([
51
- z.object({
52
- projectId: z.string().min(1),
53
- projectSecret: z.string().min(1),
54
- providers: z.array(z.custom<PlatformProviderConfig>()),
55
- }),
56
- z.object({
57
- projectId: z.undefined().optional(),
58
- projectSecret: z.undefined().optional(),
59
- providers: z.array(z.custom<PlatformProviderConfig>()),
60
- }),
61
- ]);
62
-
63
- // ---------------------------------------------------------------------------
64
- // Spectrum() factory
65
- // ---------------------------------------------------------------------------
66
-
67
- export async function Spectrum<
68
- const Providers extends PlatformProviderConfig[],
69
- >(
70
- options:
71
- | {
72
- projectId: string;
73
- projectSecret: string;
74
- providers: [...Providers];
75
- }
76
- | {
77
- projectId?: never;
78
- projectSecret?: never;
79
- providers: [...Providers];
80
- }
81
- ): Promise<SpectrumInstance<Providers>> {
82
- spectrumConfigSchema.parse(options);
83
-
84
- const { projectId, projectSecret, providers } = options;
85
-
86
- const platformStates = new Map<
87
- string,
88
- { client: unknown; config: unknown; definition: AnyPlatformDef }
89
- >();
90
-
91
- // Custom event streams keyed by event name
92
- const customEventStreams = new Map<string, ManagedStream<unknown>>();
93
-
94
- let stopped = false;
95
-
96
- // Initialize all provider clients eagerly
97
- for (const provider of providers) {
98
- const providerConfig = provider as PlatformProviderConfig;
99
- const def = providerConfig.__definition;
100
- const userConfig = def.config.parse(providerConfig.config);
101
-
102
- const client = await def.lifecycle.createClient({
103
- config: userConfig,
104
- projectId,
105
- projectSecret,
106
- });
107
-
108
- platformStates.set(def.name, {
109
- client,
110
- config: userConfig,
111
- definition: def,
112
- });
113
- }
114
-
115
- const adaptIterable = <T>(iterable: AsyncIterable<T>): ManagedStream<T> => {
116
- return stream<T>((emit, end) => {
117
- const iterator = iterable[Symbol.asyncIterator]();
118
-
119
- (async () => {
120
- try {
121
- let result = await iterator.next();
122
- while (!result.done) {
123
- emit(result.value);
124
- result = await iterator.next();
125
- }
126
- end();
127
- } catch (error) {
128
- end(error);
129
- }
130
- })();
131
-
132
- return async () => {
133
- await iterator.return?.();
134
- };
135
- });
136
- };
137
-
138
- const createProviderMessagesStream = (state: {
139
- client: unknown;
140
- config: unknown;
141
- definition: AnyPlatformDef;
142
- }): ManagedStream<[Space, Message]> => {
143
- const { client, config, definition } = state;
144
- const raw = definition.events.messages({
145
- client,
146
- config,
147
- }) as AsyncIterable<ProviderMessageRecord>;
148
-
149
- const bindSend = async function* (): AsyncIterable<[Space, Message]> {
150
- for await (const msg of raw) {
151
- const extraEntries = Object.entries(msg).filter(
152
- ([key]) => !providerMessageCoreKeys.has(key)
153
- );
154
- const extra = Object.fromEntries(extraEntries);
155
- const parsedExtra = definition.message?.schema
156
- ? definition.message.schema.parse(extra)
157
- : {};
158
- const spaceRef = {
159
- ...msg.space,
160
- __platform: definition.name,
161
- };
162
- const typingCtx = { space: spaceRef, client, config };
163
- const space = {
164
- ...spaceRef,
165
- send: async (...content: [ContentBuilder, ...ContentBuilder[]]) => {
166
- const resolved = await Promise.all(content.map((c) => c.build()));
167
- await definition.actions.send({
168
- ...typingCtx,
169
- content: resolved,
170
- });
171
- },
172
- startTyping: async () => {
173
- await definition.actions.startTyping?.(typingCtx);
174
- },
175
- stopTyping: async () => {
176
- await definition.actions.stopTyping?.(typingCtx);
177
- },
178
- responding: async <T>(fn: () => T | Promise<T>): Promise<T> => {
179
- await definition.actions.startTyping?.(typingCtx);
180
- try {
181
- return await fn();
182
- } finally {
183
- await definition.actions.stopTyping?.(typingCtx).catch(() => {});
184
- }
185
- },
186
- };
187
- const normalizedMessage = {
188
- ...parsedExtra,
189
- id: msg.id,
190
- content: msg.content,
191
- platform: definition.name,
192
- react: async (reaction: string): Promise<void> => {
193
- if (!definition.actions.reactToMessage) {
194
- return;
195
- }
196
- await definition.actions.reactToMessage({
197
- space: spaceRef,
198
- messageId: msg.id,
199
- reaction,
200
- client,
201
- config,
202
- });
203
- },
204
- reply: async (
205
- ...content: [ContentBuilder, ...ContentBuilder[]]
206
- ): Promise<void> => {
207
- if (!definition.actions.replyToMessage) {
208
- return;
209
- }
210
- const resolved = await Promise.all(content.map((c) => c.build()));
211
- await definition.actions.replyToMessage({
212
- space: spaceRef,
213
- messageId: msg.id,
214
- content: resolved,
215
- client,
216
- config,
217
- });
218
- },
219
- sender: {
220
- ...msg.sender,
221
- __platform: definition.name,
222
- },
223
- space,
224
- timestamp: msg.timestamp ?? new Date(),
225
- };
226
-
227
- yield [space, normalizedMessage];
228
- }
229
- };
230
-
231
- return adaptIterable(bindSend());
232
- };
233
-
234
- const createMessagesStream = (): ManagedStream<[Space, Message]> => {
235
- return stream<[Space, Message]>(async (emit, end) => {
236
- const merged = mergeStreams(
237
- Array.from(platformStates.values(), createProviderMessagesStream)
238
- );
239
-
240
- (async () => {
241
- try {
242
- for await (const value of merged) {
243
- emit(value);
244
- }
245
- end();
246
- } catch (error) {
247
- end(error);
248
- }
249
- })();
250
-
251
- return async () => {
252
- await merged.close();
253
- };
254
- });
255
- };
256
-
257
- const createCustomEventStream = (
258
- eventName: string
259
- ): ManagedStream<unknown> => {
260
- return stream<unknown>(async (emit, end) => {
261
- const providerStreams = Array.from(platformStates.values(), (state) => {
262
- const { client, config, definition } = state;
263
- const producer = definition.events[eventName] as
264
- | ((ctx: {
265
- client: unknown;
266
- config: unknown;
267
- }) => AsyncIterable<unknown>)
268
- | undefined;
269
- if (!producer) {
270
- return undefined;
271
- }
272
-
273
- const providerEvents = producer({ client, config });
274
- const annotatePlatform = async function* (): AsyncIterable<unknown> {
275
- for await (const value of providerEvents) {
276
- yield { ...(value as object), platform: definition.name };
277
- }
278
- };
279
-
280
- return adaptIterable(annotatePlatform());
281
- }).filter(
282
- (value): value is ManagedStream<unknown> => value !== undefined
283
- );
284
-
285
- const merged = mergeStreams(providerStreams);
286
-
287
- (async () => {
288
- try {
289
- for await (const value of merged) {
290
- emit(value);
291
- }
292
- end();
293
- } catch (error) {
294
- end(error);
295
- }
296
- })();
297
-
298
- return async () => {
299
- await merged.close();
300
- };
301
- });
302
- };
303
-
304
- const messagesStream = createMessagesStream();
305
-
306
- const stopOnce = async () => {
307
- if (stopped) {
308
- return;
309
- }
310
- stopped = true;
311
-
312
- const streamShutdowns = [
313
- messagesStream.close(),
314
- ...Array.from(customEventStreams.values(), (eventStream) =>
315
- eventStream.close()
316
- ),
317
- ];
318
-
319
- process.off("SIGINT", handleSignal);
320
- process.off("SIGTERM", handleSignal);
321
-
322
- await Promise.allSettled(streamShutdowns);
323
- const clientShutdowns = Array.from(platformStates.values(), (state) =>
324
- state.definition.lifecycle.destroyClient({
325
- client: state.client,
326
- })
327
- );
328
- await Promise.allSettled(clientShutdowns);
329
- customEventStreams.clear();
330
- platformStates.clear();
331
- };
332
-
333
- const handleSignal = () => {
334
- setTimeout(() => process.exit(1), 3000).unref();
335
- stopOnce()
336
- .then(() => process.exit(0))
337
- .catch(() => process.exit(1));
338
- };
339
- process.on("SIGINT", handleSignal);
340
- process.on("SIGTERM", handleSignal);
341
-
342
- const messages = messagesStream as AsyncIterable<[Space, Message]>;
343
-
344
- // Proxy for flat custom event access (app.typing, app.readReceipt, etc.)
345
- const customEventProxy = new Proxy(
346
- {} as Record<string, AsyncIterable<unknown>>,
347
- {
348
- get(_target, prop: string) {
349
- let eventStream = customEventStreams.get(prop);
350
- if (!eventStream) {
351
- eventStream = createCustomEventStream(prop);
352
- customEventStreams.set(prop, eventStream);
353
- }
354
- return eventStream;
355
- },
356
- }
357
- );
358
-
359
- const base = {
360
- __providers: providers,
361
- __internal: { platforms: platformStates },
362
- messages,
363
- stop: stopOnce,
364
- send: async (
365
- space: Space,
366
- ...content: [ContentBuilder, ...ContentBuilder[]]
367
- ) => {
368
- await space.send(...content);
369
- },
370
- responding: async <T>(
371
- space: Space,
372
- fn: () => T | Promise<T>
373
- ): Promise<T> => {
374
- return space.responding(fn);
375
- },
376
- };
377
-
378
- // Merge base instance with custom event proxy
379
- return new Proxy(base, {
380
- get(target, prop, receiver) {
381
- if (prop in target) {
382
- return Reflect.get(target, prop, receiver);
383
- }
384
- if (typeof prop === "string") {
385
- return customEventProxy[prop];
386
- }
387
- return undefined;
388
- },
389
- }) as SpectrumInstance<Providers>;
390
- }
@@ -1,85 +0,0 @@
1
- import { readFile } from "node:fs/promises";
2
- import { basename } from "node:path";
3
- import { lookup as lookupMimeType } from "mime-types";
4
- import type { NonEmptyString } from "type-fest";
5
- import z from "zod";
6
-
7
- const DEFAULT_ATTACHMENT_NAME = "attachment";
8
-
9
- const contentSchema = z.discriminatedUnion("type", [
10
- z.object({
11
- type: z.literal("plain_text"),
12
- text: z.string().nonempty(),
13
- }),
14
- z.object({
15
- type: z.literal("custom"),
16
- raw: z.json(),
17
- }),
18
- z.object({
19
- type: z.literal("attachment"),
20
- data: z.instanceof(Buffer),
21
- mimeType: z.string().nonempty(),
22
- name: z.string().nonempty(),
23
- }),
24
- ]);
25
-
26
- export type Content = z.infer<typeof contentSchema>;
27
-
28
- export interface ContentBuilder {
29
- build(): Promise<Content>;
30
- }
31
-
32
- export function text(
33
- text: string
34
- ): ContentBuilder {
35
- return {
36
- build: (): Promise<Content> =>
37
- Promise.resolve({ type: "plain_text", text }),
38
- };
39
- }
40
-
41
- export function custom(
42
- raw: z.infer<ReturnType<typeof z.json>>
43
- ): ContentBuilder {
44
- return {
45
- build: (): Promise<Content> => Promise.resolve({ type: "custom", raw }),
46
- };
47
- }
48
-
49
- const resolveAttachmentName = (input: string | Buffer, name?: string): string =>
50
- name ||
51
- (typeof input === "string" ? basename(input) : DEFAULT_ATTACHMENT_NAME);
52
-
53
- const resolveAttachmentMimeType = (name: string, mimeType?: string): string => {
54
- if (mimeType) {
55
- return mimeType;
56
- }
57
-
58
- const resolvedMimeType = lookupMimeType(name);
59
- if (!resolvedMimeType) {
60
- throw new Error(
61
- `Unable to resolve MIME type for attachment "${name}". Pass options.mimeType explicitly.`
62
- );
63
- }
64
-
65
- return resolvedMimeType;
66
- };
67
-
68
- export function attachment(
69
- input: string | Buffer,
70
- options?: { mimeType?: string; name?: string }
71
- ): ContentBuilder {
72
- return {
73
- build: async (): Promise<Content> => {
74
- const data = typeof input === "string" ? await readFile(input) : input;
75
- const name = resolveAttachmentName(input, options?.name);
76
-
77
- return {
78
- data,
79
- mimeType: resolveAttachmentMimeType(name, options?.mimeType),
80
- name,
81
- type: "attachment",
82
- };
83
- },
84
- };
85
- }
@@ -1,18 +0,0 @@
1
- import type { Content, ContentBuilder } from "./content";
2
- import type { Space } from "./space";
3
- import type { User } from "./user";
4
-
5
- export interface Message<
6
- TPlatform extends string = string,
7
- TSender extends User = User,
8
- TSpace extends Space = Space,
9
- > {
10
- content: Content[];
11
- readonly id: string;
12
- platform: TPlatform;
13
- react(reaction: string): Promise<void>;
14
- reply(...content: [ContentBuilder, ...ContentBuilder[]]): Promise<void>;
15
- sender: TSender;
16
- space: TSpace;
17
- timestamp: Date;
18
- }
@@ -1,10 +0,0 @@
1
- import type { ContentBuilder } from "./content";
2
-
3
- export interface Space<_Def = unknown> {
4
- readonly __platform: string;
5
- readonly id: string;
6
- responding<T>(fn: () => T | Promise<T>): Promise<T>;
7
- send(...content: [ContentBuilder, ...ContentBuilder[]]): Promise<void>;
8
- startTyping(): Promise<void>;
9
- stopTyping(): Promise<void>;
10
- }
package/src/types/user.ts DELETED
@@ -1,4 +0,0 @@
1
- export interface User {
2
- readonly __platform: string;
3
- readonly id: string;
4
- }
@@ -1,147 +0,0 @@
1
- export const SPECTRUM_CLOUD_URL = `https://${process.env.SPECTRUM_CLOUD_URL ?? "spectrum.photon.codes"}`;
2
-
3
- // ---------------------------------------------------------------------------
4
- // API response types (aligned with OpenAPI spec)
5
- // ---------------------------------------------------------------------------
6
-
7
- export type SubscriptionStatus = "active" | "canceled" | "past_due";
8
-
9
- export interface SubscriptionData {
10
- status: SubscriptionStatus | null;
11
- tier: string;
12
- }
13
-
14
- export interface SharedTokenData {
15
- expiresIn: number;
16
- token: string;
17
- type: "shared";
18
- }
19
-
20
- export interface DedicatedTokenData {
21
- auth: Record<string, string>;
22
- expiresIn: number;
23
- type: "dedicated";
24
- }
25
-
26
- export type TokenData = SharedTokenData | DedicatedTokenData;
27
-
28
- export type CloudPlatform = "imessage" | "whatsapp_business";
29
-
30
- export interface PlatformStatus {
31
- enabled: boolean;
32
- }
33
-
34
- export type PlatformsData = Record<CloudPlatform, PlatformStatus>;
35
-
36
- export interface ImessageInfoData {
37
- type: "shared" | "dedicated";
38
- }
39
-
40
- // ---------------------------------------------------------------------------
41
- // Error
42
- // ---------------------------------------------------------------------------
43
-
44
- export class SpectrumCloudError extends Error {
45
- readonly status: number;
46
- readonly code: string;
47
-
48
- constructor(status: number, code: string, message: string) {
49
- super(message);
50
- this.name = "SpectrumCloudError";
51
- this.status = status;
52
- this.code = code;
53
- }
54
- }
55
-
56
- // ---------------------------------------------------------------------------
57
- // Internal helpers
58
- // ---------------------------------------------------------------------------
59
-
60
- interface SuccessResponse<T> {
61
- data: T;
62
- succeed: true;
63
- }
64
-
65
- interface ErrorBody {
66
- code: string;
67
- message: string;
68
- succeed: false;
69
- }
70
-
71
- const request = async <T>(path: string, init?: RequestInit): Promise<T> => {
72
- const response = await fetch(`${SPECTRUM_CLOUD_URL}${path}`, init);
73
-
74
- if (!response.ok) {
75
- const body = await response.text().catch(() => "");
76
- try {
77
- const parsed = JSON.parse(body) as ErrorBody;
78
- throw new SpectrumCloudError(
79
- response.status,
80
- parsed.code,
81
- parsed.message
82
- );
83
- } catch (error) {
84
- if (error instanceof SpectrumCloudError) {
85
- throw error;
86
- }
87
- throw new SpectrumCloudError(
88
- response.status,
89
- "UNKNOWN",
90
- body || response.statusText
91
- );
92
- }
93
- }
94
-
95
- const json = (await response.json()) as SuccessResponse<T>;
96
- if (!json.succeed) {
97
- throw new SpectrumCloudError(
98
- response.status,
99
- "UNKNOWN",
100
- "Server returned succeed=false"
101
- );
102
- }
103
-
104
- return json.data;
105
- };
106
-
107
- const basicAuth = (projectId: string, projectSecret: string): string =>
108
- `Basic ${btoa(`${projectId}:${projectSecret}`)}`;
109
-
110
- // ---------------------------------------------------------------------------
111
- // Cloud API client
112
- // ---------------------------------------------------------------------------
113
-
114
- export const cloud = {
115
- getSubscription: (projectId: string): Promise<SubscriptionData> =>
116
- request(`/projects/${projectId}/billing/subscription`),
117
-
118
- issueImessageTokens: (
119
- projectId: string,
120
- projectSecret: string
121
- ): Promise<TokenData> =>
122
- request(`/projects/${projectId}/imessage/tokens`, {
123
- method: "POST",
124
- headers: { Authorization: basicAuth(projectId, projectSecret) },
125
- }),
126
-
127
- getImessageInfo: (projectId: string): Promise<ImessageInfoData> =>
128
- request(`/projects/${projectId}/imessage/`),
129
-
130
- getPlatforms: (projectId: string): Promise<PlatformsData> =>
131
- request(`/projects/${projectId}/platforms/`),
132
-
133
- togglePlatform: (
134
- projectId: string,
135
- projectSecret: string,
136
- platform: CloudPlatform,
137
- enabled: boolean
138
- ): Promise<PlatformsData> =>
139
- request(`/projects/${projectId}/platforms/`, {
140
- method: "PATCH",
141
- headers: {
142
- Authorization: basicAuth(projectId, projectSecret),
143
- "Content-Type": "application/json",
144
- },
145
- body: JSON.stringify({ platform, enabled }),
146
- }),
147
- };