spectrum-ts 1.9.2 → 1.12.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.
@@ -2,7 +2,7 @@ import {
2
2
  bufferToStream,
3
3
  readSchema,
4
4
  streamSchema
5
- } from "./chunk-XZSBR26X.js";
5
+ } from "./chunk-YDHES53X.js";
6
6
 
7
7
  // src/content/voice.ts
8
8
  import { createReadStream } from "fs";
@@ -1,16 +1,18 @@
1
1
  import {
2
2
  asVoice
3
- } from "./chunk-TN54TDTQ.js";
3
+ } from "./chunk-4TXLNBGE.js";
4
+ import {
5
+ asContact,
6
+ fromVCard,
7
+ toVCard
8
+ } from "./chunk-L3VXHUVY.js";
4
9
  import {
5
10
  UnsupportedError,
6
11
  asAttachment,
7
- asContact,
8
12
  asCustom,
9
13
  definePlatform,
10
- fromVCard,
11
- reactionSchema,
12
- toVCard
13
- } from "./chunk-XZSBR26X.js";
14
+ reactionSchema
15
+ } from "./chunk-YDHES53X.js";
14
16
 
15
17
  // src/providers/terminal/index.ts
16
18
  import { spawn } from "child_process";
@@ -0,0 +1,524 @@
1
+ import {
2
+ cloud,
3
+ mergeStreams,
4
+ stream
5
+ } from "./chunk-YKWKZ2PZ.js";
6
+ import {
7
+ UnsupportedError,
8
+ asAttachment,
9
+ asCustom,
10
+ asReaction,
11
+ asText,
12
+ definePlatform
13
+ } from "./chunk-YDHES53X.js";
14
+
15
+ // src/providers/slack/index.ts
16
+ import { createClient as createClient2, staticTokens } from "@photon-ai/slack";
17
+
18
+ // src/providers/slack/auth.ts
19
+ import {
20
+ createClient
21
+ } from "@photon-ai/slack";
22
+ var RENEWAL_RATIO = 0.8;
23
+ var EXPIRY_BUFFER_MS = 3e4;
24
+ var RETRY_DELAY_MS = 3e4;
25
+ var cloudAuthState = /* @__PURE__ */ new WeakMap();
26
+ var toTeamMetadata = (meta) => ({
27
+ appId: meta.appId,
28
+ botUserId: meta.botUserId,
29
+ grantedScopes: meta.grantedScopes,
30
+ teamName: meta.teamName
31
+ });
32
+ async function createCloudClients(projectId, projectSecret, endpoint) {
33
+ let tokenData = await cloud.issueSlackTokens(projectId, projectSecret);
34
+ let tokenExpiresAt = Date.now() + tokenData.expiresIn * 1e3;
35
+ let disposed = false;
36
+ let renewalTimer;
37
+ const clearRenewalTimer = () => {
38
+ if (renewalTimer !== void 0) {
39
+ clearTimeout(renewalTimer);
40
+ renewalTimer = void 0;
41
+ }
42
+ };
43
+ const refreshTokens = async () => {
44
+ tokenData = await cloud.issueSlackTokens(projectId, projectSecret);
45
+ tokenExpiresAt = Date.now() + tokenData.expiresIn * 1e3;
46
+ };
47
+ const scheduleRetry = () => {
48
+ if (disposed) {
49
+ return;
50
+ }
51
+ clearRenewalTimer();
52
+ renewalTimer = setTimeout(async () => {
53
+ if (disposed) {
54
+ return;
55
+ }
56
+ try {
57
+ await refreshTokens();
58
+ scheduleRenewal();
59
+ } catch (retryErr) {
60
+ console.warn(
61
+ `[spectrum-ts] Slack token refresh failed; retrying in ${RETRY_DELAY_MS}ms.`,
62
+ retryErr
63
+ );
64
+ scheduleRetry();
65
+ }
66
+ }, RETRY_DELAY_MS);
67
+ renewalTimer?.unref?.();
68
+ };
69
+ const scheduleRenewal = () => {
70
+ if (disposed) {
71
+ return;
72
+ }
73
+ clearRenewalTimer();
74
+ const ttlMs = tokenData.expiresIn * 1e3;
75
+ const renewInMs = Math.max(ttlMs * RENEWAL_RATIO, 5e3);
76
+ renewalTimer = setTimeout(async () => {
77
+ try {
78
+ await refreshTokens();
79
+ scheduleRenewal();
80
+ } catch (err) {
81
+ console.warn(
82
+ `[spectrum-ts] Slack token refresh failed; retrying in ${RETRY_DELAY_MS}ms.`,
83
+ err
84
+ );
85
+ scheduleRetry();
86
+ }
87
+ }, renewInMs);
88
+ renewalTimer?.unref?.();
89
+ };
90
+ const refreshIfNeeded = async () => {
91
+ if (Date.now() < tokenExpiresAt - EXPIRY_BUFFER_MS) {
92
+ return;
93
+ }
94
+ await refreshTokens();
95
+ scheduleRenewal();
96
+ };
97
+ scheduleRenewal();
98
+ const tokenProvider = {
99
+ async getAccessToken(teamId) {
100
+ await refreshIfNeeded();
101
+ const token = tokenData.auth[teamId];
102
+ if (!token) {
103
+ throw new Error(
104
+ `Slack team ${teamId} has no active installation in this project`
105
+ );
106
+ }
107
+ return token;
108
+ },
109
+ invalidate(_teamId) {
110
+ tokenExpiresAt = 0;
111
+ },
112
+ async listTeams() {
113
+ await refreshIfNeeded();
114
+ const entries = Object.entries(
115
+ tokenData.teams
116
+ ).map(([teamId, meta]) => [teamId, toTeamMetadata(meta)]);
117
+ return new Map(entries);
118
+ }
119
+ };
120
+ const client = createClient({
121
+ spectrumSlackEndpoint: endpoint,
122
+ tokenProvider
123
+ });
124
+ cloudAuthState.set(client, {
125
+ dispose: async () => {
126
+ disposed = true;
127
+ clearRenewalTimer();
128
+ }
129
+ });
130
+ return client;
131
+ }
132
+ async function disposeCloudAuth(client) {
133
+ const auth = cloudAuthState.get(client);
134
+ if (!auth) {
135
+ return;
136
+ }
137
+ await auth.dispose();
138
+ cloudAuthState.delete(client);
139
+ }
140
+
141
+ // src/providers/slack/messages.ts
142
+ var toRecord = (result, space, content) => ({
143
+ id: result.ts,
144
+ content,
145
+ space: { id: result.channel, teamId: space.teamId },
146
+ timestamp: tsToDate(result.ts),
147
+ ts: result.ts,
148
+ isFromMe: true
149
+ });
150
+ var toUploadRecord = (result, space, content) => {
151
+ const shareTs = result.shares.find((s) => s.channel === space.id)?.ts;
152
+ return {
153
+ id: shareTs ?? result.file.id,
154
+ content,
155
+ space: { id: space.id, teamId: space.teamId },
156
+ timestamp: shareTs ? tsToDate(shareTs) : /* @__PURE__ */ new Date(),
157
+ ts: shareTs,
158
+ isFromMe: true
159
+ };
160
+ };
161
+ var tsToDate = (ts) => {
162
+ if (!ts) {
163
+ return /* @__PURE__ */ new Date();
164
+ }
165
+ const seconds = Number.parseFloat(ts);
166
+ if (!Number.isFinite(seconds)) {
167
+ return /* @__PURE__ */ new Date();
168
+ }
169
+ return new Date(seconds * 1e3);
170
+ };
171
+ var lazySlackFile = (client, teamId, file) => asAttachment({
172
+ name: file.name,
173
+ mimeType: file.mimeType,
174
+ size: file.size,
175
+ read: async () => {
176
+ const { bytes } = await client.team(teamId).files.getContentBuffer(file.id);
177
+ return Buffer.from(bytes);
178
+ },
179
+ stream: async () => {
180
+ const { content } = await client.team(teamId).files.getContent(file.id);
181
+ return new ReadableStream({
182
+ async start(controller) {
183
+ try {
184
+ for await (const chunk of content) {
185
+ controller.enqueue(chunk);
186
+ }
187
+ controller.close();
188
+ } catch (err) {
189
+ controller.error(err);
190
+ }
191
+ }
192
+ });
193
+ }
194
+ });
195
+ var toMessages = (client, event) => {
196
+ if (event.type === "message") {
197
+ return messageToMessages(client, event.teamId, event.message);
198
+ }
199
+ if (event.type === "reaction") {
200
+ return [reactionToMessage(event.teamId, event.reaction)];
201
+ }
202
+ if (event.type === "mention") {
203
+ return [
204
+ {
205
+ id: event.mention.ts,
206
+ content: asText(event.mention.text),
207
+ sender: { id: event.mention.user },
208
+ space: { id: event.mention.channel, teamId: event.teamId },
209
+ timestamp: tsToDate(event.mention.ts),
210
+ ts: event.mention.ts,
211
+ isFromMe: event.mention.isFromMe
212
+ }
213
+ ];
214
+ }
215
+ return [];
216
+ };
217
+ var messageToMessages = (client, teamId, msg) => {
218
+ const base = {
219
+ sender: { id: msg.user },
220
+ space: { id: msg.channel, teamId },
221
+ timestamp: tsToDate(msg.ts),
222
+ ts: msg.ts,
223
+ threadTs: msg.threadTs,
224
+ subtype: msg.subtype,
225
+ isFromMe: msg.isFromMe
226
+ };
227
+ const results = [];
228
+ if (msg.text) {
229
+ results.push({
230
+ ...base,
231
+ id: msg.files.length > 0 ? `${msg.ts}:text` : msg.ts,
232
+ content: asText(msg.text)
233
+ });
234
+ }
235
+ for (const [index, file] of msg.files.entries()) {
236
+ const singleFile = msg.files.length === 1 && !msg.text;
237
+ results.push({
238
+ ...base,
239
+ id: singleFile ? msg.ts : `${msg.ts}:file:${index}`,
240
+ content: lazySlackFile(client, teamId, file)
241
+ });
242
+ }
243
+ if (results.length === 0) {
244
+ results.push({
245
+ ...base,
246
+ id: msg.ts,
247
+ content: asCustom({ slack_type: "empty" })
248
+ });
249
+ }
250
+ return results;
251
+ };
252
+ var reactionToMessage = (teamId, reaction) => {
253
+ const stubTarget = {
254
+ id: reaction.itemTs,
255
+ content: asCustom({ slack_type: "reaction-target", stub: true }),
256
+ sender: { id: "" },
257
+ space: { id: reaction.itemChannel, teamId }
258
+ };
259
+ return {
260
+ id: `${reaction.itemTs}:reaction:${reaction.user}:${reaction.name}`,
261
+ content: asReaction({
262
+ emoji: reaction.name,
263
+ // Cast through unknown: stub is a partial Message; core's
264
+ // wrapProviderMessage inflates the missing react/reply/edit methods.
265
+ target: stubTarget
266
+ }),
267
+ sender: { id: reaction.user },
268
+ space: { id: reaction.itemChannel, teamId },
269
+ timestamp: /* @__PURE__ */ new Date(),
270
+ ts: reaction.itemTs,
271
+ subtype: reaction.removed ? "reaction_removed" : "reaction_added",
272
+ isFromMe: reaction.isFromMe
273
+ };
274
+ };
275
+ var teamStream = (client, teamId) => {
276
+ const eventStream = client.team(teamId).events.subscribe();
277
+ return stream((emit, end) => {
278
+ const pump = (async () => {
279
+ try {
280
+ for await (const event of eventStream) {
281
+ for (const m of toMessages(client, event)) {
282
+ await emit(m);
283
+ }
284
+ }
285
+ end();
286
+ } catch (e) {
287
+ end(e);
288
+ }
289
+ })();
290
+ return async () => {
291
+ await eventStream.close();
292
+ await pump;
293
+ };
294
+ });
295
+ };
296
+ var messages = (client, resolveTeamIds) => stream(async (emit, end) => {
297
+ let teamIds;
298
+ try {
299
+ teamIds = await resolveTeamIds();
300
+ } catch (err) {
301
+ end(err);
302
+ return;
303
+ }
304
+ const merged = mergeStreams(teamIds.map((id) => teamStream(client, id)));
305
+ const pump = (async () => {
306
+ try {
307
+ for await (const value of merged) {
308
+ await emit(value);
309
+ }
310
+ end();
311
+ } catch (e) {
312
+ end(e);
313
+ }
314
+ })();
315
+ return async () => {
316
+ await merged.close();
317
+ await pump;
318
+ };
319
+ });
320
+ var mimeToMediaName = (mimeType, fallback) => {
321
+ const slash = mimeType.indexOf("/");
322
+ if (slash < 0) {
323
+ return fallback;
324
+ }
325
+ return `${fallback}.${mimeType.slice(slash + 1)}`;
326
+ };
327
+ var send = async (client, space, content) => {
328
+ if (content.type === "reply") {
329
+ return await replyToMessage(
330
+ client,
331
+ space,
332
+ content.target.ts ?? content.target.id,
333
+ content.content
334
+ );
335
+ }
336
+ if (content.type === "reaction") {
337
+ await reactToMessage(
338
+ client,
339
+ space,
340
+ content.target.ts ?? content.target.id,
341
+ content.emoji
342
+ );
343
+ return;
344
+ }
345
+ if (content.type === "typing") {
346
+ return;
347
+ }
348
+ return await sendContent(client, space, content);
349
+ };
350
+ var sendContent = async (client, space, content, threadTs) => {
351
+ const team = client.team(space.teamId);
352
+ switch (content.type) {
353
+ case "text": {
354
+ const result = await team.messages.send({
355
+ channel: space.id,
356
+ text: content.text,
357
+ threadTs
358
+ });
359
+ return toRecord(result, space, content);
360
+ }
361
+ case "attachment": {
362
+ const result = await team.files.upload({
363
+ channel: space.id,
364
+ content: await content.read(),
365
+ filename: content.name,
366
+ mimeType: content.mimeType,
367
+ threadTs
368
+ });
369
+ return toUploadRecord(result, space, content);
370
+ }
371
+ case "voice": {
372
+ const result = await team.files.upload({
373
+ channel: space.id,
374
+ content: await content.read(),
375
+ filename: content.name ?? mimeToMediaName(content.mimeType, "voice"),
376
+ mimeType: content.mimeType,
377
+ threadTs
378
+ });
379
+ return toUploadRecord(result, space, content);
380
+ }
381
+ default:
382
+ throw UnsupportedError.content(content.type);
383
+ }
384
+ };
385
+ var reactToMessage = async (client, space, targetTs, emoji) => {
386
+ await client.team(space.teamId).messages.send({
387
+ channel: space.id,
388
+ reaction: {
389
+ emoji,
390
+ itemChannel: space.id,
391
+ itemTs: targetTs
392
+ }
393
+ });
394
+ };
395
+ var replyToMessage = async (client, space, targetTs, content) => await sendContent(client, space, content, targetTs);
396
+
397
+ // src/providers/slack/types.ts
398
+ import z from "zod";
399
+ var teamMetadataSchema = z.object({
400
+ appId: z.string(),
401
+ botUserId: z.string(),
402
+ grantedScopes: z.array(z.string()),
403
+ teamName: z.string()
404
+ });
405
+ var directConfig = z.object({
406
+ endpoint: z.string().optional(),
407
+ teams: z.record(z.string(), teamMetadataSchema).optional(),
408
+ tokens: z.record(z.string(), z.string().min(1)).refine((t) => Object.keys(t).length > 0, {
409
+ message: "at least one token entry is required"
410
+ })
411
+ });
412
+ var cloudConfig = z.object({}).strict();
413
+ var configSchema = z.union([directConfig, cloudConfig]);
414
+ var isCloudConfig = (config) => !("tokens" in config);
415
+ var userSchema = z.object({});
416
+ var spaceSchema = z.object({
417
+ id: z.string(),
418
+ teamId: z.string()
419
+ });
420
+ var spaceParamsSchema = z.object({
421
+ channel: z.string().optional(),
422
+ teamId: z.string()
423
+ });
424
+ var messageSchema = z.object({
425
+ isFromMe: z.boolean(),
426
+ subtype: z.string().optional(),
427
+ threadTs: z.string().optional(),
428
+ ts: z.string().optional()
429
+ });
430
+
431
+ // src/providers/slack/index.ts
432
+ var slack = definePlatform("Slack", {
433
+ config: configSchema,
434
+ lifecycle: {
435
+ createClient: async ({
436
+ config,
437
+ projectId,
438
+ projectSecret
439
+ }) => {
440
+ if (!isCloudConfig(config)) {
441
+ return createClient2({
442
+ spectrumSlackEndpoint: config.endpoint,
443
+ tokenProvider: staticTokens({
444
+ tokens: config.tokens,
445
+ teams: config.teams
446
+ })
447
+ });
448
+ }
449
+ if (!(projectId && projectSecret)) {
450
+ throw new Error(
451
+ "Slack cloud mode requires projectId and projectSecret. Either pass credentials to Spectrum(), or provide direct credentials: slack.config({ tokens: { T012ABCDE: 'jwt...' } })"
452
+ );
453
+ }
454
+ return await createCloudClients(
455
+ projectId,
456
+ projectSecret,
457
+ process.env.SPECTRUM_SLACK_ENDPOINT
458
+ );
459
+ },
460
+ destroyClient: async ({ client }) => {
461
+ await disposeCloudAuth(client);
462
+ await client.close();
463
+ }
464
+ },
465
+ user: {
466
+ schema: userSchema,
467
+ resolve: async ({ input }) => ({ id: input.userID })
468
+ },
469
+ space: {
470
+ schema: spaceSchema,
471
+ params: spaceParamsSchema,
472
+ resolve: async ({ input }) => {
473
+ const teamId = input.params?.teamId;
474
+ if (!teamId) {
475
+ throw new Error(
476
+ "Slack space creation requires a teamId param. Pass it via slack.space({ channel, teamId }) or slack.space([user], { teamId })."
477
+ );
478
+ }
479
+ const channel = input.params?.channel;
480
+ if (channel) {
481
+ return { id: channel, teamId };
482
+ }
483
+ if (input.users.length === 0) {
484
+ throw new Error(
485
+ "Slack space creation requires either a channel param or at least one user"
486
+ );
487
+ }
488
+ if (input.users.length > 1) {
489
+ throw UnsupportedError.action(
490
+ "createSpace",
491
+ "Slack",
492
+ "group DMs require an explicit channel id (Slack's conversations.open is not exposed); pass `channel` in params"
493
+ );
494
+ }
495
+ const user = input.users[0];
496
+ if (!user) {
497
+ throw new Error("Slack space creation requires a user");
498
+ }
499
+ return { id: user.id, teamId };
500
+ }
501
+ },
502
+ message: {
503
+ schema: messageSchema
504
+ },
505
+ // Discover the team list at subscribe time from the live `TokenProvider`.
506
+ // Direct mode: `staticTokens.listTeams` returns whatever `teams` metadata
507
+ // the caller passed (we fall back to the `tokens` keys when absent).
508
+ // Cloud mode: our renewing provider returns the live `auth` snapshot.
509
+ messages: ({ client, config }) => messages(client, async () => {
510
+ const teams = await client.teams();
511
+ if (teams.size > 0) {
512
+ return Array.from(teams.keys());
513
+ }
514
+ if (isCloudConfig(config)) {
515
+ return [];
516
+ }
517
+ return Object.keys(config.tokens);
518
+ }),
519
+ send: async ({ space, content, client }) => await send(client, { id: space.id, teamId: space.teamId }, content)
520
+ });
521
+
522
+ export {
523
+ slack
524
+ };