@spectrum-ts/slack 5.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +22 -0
- package/dist/index.d.ts +44 -0
- package/dist/index.js +466 -0
- package/package.json +42 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License Copyright (c) 2025 Photon AI
|
|
2
|
+
|
|
3
|
+
Permission is hereby granted,
|
|
4
|
+
free of charge, to any person obtaining a copy of this software and associated
|
|
5
|
+
documentation files (the "Software"), to deal in the Software without
|
|
6
|
+
restriction, including without limitation the rights to use, copy, modify, merge,
|
|
7
|
+
publish, distribute, sublicense, and/or sell copies of the Software, and to
|
|
8
|
+
permit persons to whom the Software is furnished to do so, subject to the
|
|
9
|
+
following conditions:
|
|
10
|
+
|
|
11
|
+
The above copyright notice and this permission notice
|
|
12
|
+
(including the next paragraph) shall be included in all copies or substantial
|
|
13
|
+
portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF
|
|
16
|
+
ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
|
|
17
|
+
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO
|
|
18
|
+
EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR
|
|
19
|
+
OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
|
|
20
|
+
FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
|
|
21
|
+
THE SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
# @spectrum-ts/slack
|
|
2
|
+
|
|
3
|
+
Slack provider for [spectrum-ts](https://github.com/photon-hq/spectrum-ts).
|
|
4
|
+
|
|
5
|
+
## Install
|
|
6
|
+
|
|
7
|
+
```sh
|
|
8
|
+
bun add spectrum-ts @spectrum-ts/slack
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
## Use
|
|
12
|
+
|
|
13
|
+
```ts
|
|
14
|
+
import { Spectrum } from "spectrum-ts";
|
|
15
|
+
import { slack } from "@spectrum-ts/slack";
|
|
16
|
+
|
|
17
|
+
const spectrum = Spectrum({
|
|
18
|
+
providers: [slack.config({ tokens: { T012ABCDE: "xoxb-..." } })],
|
|
19
|
+
});
|
|
20
|
+
```
|
|
21
|
+
|
|
22
|
+
See the [spectrum-ts documentation](https://photon.codes/spectrum) for the full guide.
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import { SchemaMessage } from "@spectrum-ts/core";
|
|
2
|
+
import z from "zod";
|
|
3
|
+
|
|
4
|
+
//#region src/types.d.ts
|
|
5
|
+
declare const userSchema: z.ZodObject<{}, z.core.$strip>;
|
|
6
|
+
declare const spaceSchema: z.ZodObject<{
|
|
7
|
+
id: z.ZodString;
|
|
8
|
+
teamId: z.ZodString;
|
|
9
|
+
}, z.core.$strip>;
|
|
10
|
+
type SlackMessage = SchemaMessage<typeof userSchema, typeof spaceSchema> & {
|
|
11
|
+
isFromMe: boolean;
|
|
12
|
+
subtype?: string;
|
|
13
|
+
threadTs?: string;
|
|
14
|
+
ts?: string;
|
|
15
|
+
};
|
|
16
|
+
//#endregion
|
|
17
|
+
//#region src/index.d.ts
|
|
18
|
+
declare const slack: import("@spectrum-ts/core").Platform<import("@spectrum-ts/core").PlatformDef<"Slack", import("zod").ZodUnion<readonly [import("zod").ZodObject<{
|
|
19
|
+
endpoint: import("zod").ZodOptional<import("zod").ZodString>;
|
|
20
|
+
teams: import("zod").ZodOptional<import("zod").ZodRecord<import("zod").ZodString, import("zod").ZodObject<{
|
|
21
|
+
appId: import("zod").ZodString;
|
|
22
|
+
botUserId: import("zod").ZodString;
|
|
23
|
+
grantedScopes: import("zod").ZodArray<import("zod").ZodString>;
|
|
24
|
+
teamName: import("zod").ZodString;
|
|
25
|
+
}, import("zod/v4/core").$strip>>>;
|
|
26
|
+
tokens: import("zod").ZodRecord<import("zod").ZodString, import("zod").ZodString>;
|
|
27
|
+
}, import("zod/v4/core").$strip>, import("zod").ZodObject<{}, import("zod/v4/core").$strict>]>, import("zod").ZodObject<{}, import("zod/v4/core").$strip>, import("zod").ZodObject<{
|
|
28
|
+
id: import("zod").ZodString;
|
|
29
|
+
teamId: import("zod").ZodString;
|
|
30
|
+
}, import("zod/v4/core").$strip>, import("zod").ZodObject<{
|
|
31
|
+
teamId: import("zod").ZodString;
|
|
32
|
+
}, import("zod/v4/core").$strip>, import("@photon-ai/slack").SlackClient, {
|
|
33
|
+
id: string;
|
|
34
|
+
}, {
|
|
35
|
+
id: string;
|
|
36
|
+
teamId: string;
|
|
37
|
+
}, import("zod").ZodObject<{
|
|
38
|
+
isFromMe: import("zod").ZodBoolean;
|
|
39
|
+
subtype: import("zod").ZodOptional<import("zod").ZodString>;
|
|
40
|
+
threadTs: import("zod").ZodOptional<import("zod").ZodString>;
|
|
41
|
+
ts: import("zod").ZodOptional<import("zod").ZodString>;
|
|
42
|
+
}, import("zod/v4/core").$strip>, SlackMessage, undefined, Record<never, never>, Record<never, never>, Record<never, never>>> & Readonly<Record<never, never>>;
|
|
43
|
+
//#endregion
|
|
44
|
+
export { slack };
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,466 @@
|
|
|
1
|
+
import { createClient, staticTokens } from "@photon-ai/slack";
|
|
2
|
+
import { UnsupportedError, cloud, definePlatform, mergeStreams, stream } from "@spectrum-ts/core";
|
|
3
|
+
import { asAttachment, asCustom, asReaction, asText, createLogger, errorAttrs } from "@spectrum-ts/core/authoring";
|
|
4
|
+
import z from "zod";
|
|
5
|
+
//#region src/auth.ts
|
|
6
|
+
const log = createLogger("spectrum.slack.auth");
|
|
7
|
+
const RENEWAL_RATIO = .8;
|
|
8
|
+
const EXPIRY_BUFFER_MS = 3e4;
|
|
9
|
+
const RETRY_DELAY_MS = 3e4;
|
|
10
|
+
const cloudAuthState = /* @__PURE__ */ new WeakMap();
|
|
11
|
+
const toTeamMetadata = (meta) => ({
|
|
12
|
+
appId: meta.appId,
|
|
13
|
+
botUserId: meta.botUserId,
|
|
14
|
+
grantedScopes: meta.grantedScopes,
|
|
15
|
+
teamName: meta.teamName
|
|
16
|
+
});
|
|
17
|
+
/**
|
|
18
|
+
* Build a {@link SlackClient} backed by a {@link TokenProvider} that lazily
|
|
19
|
+
* refreshes its tokens against `POST /projects/:id/slack/tokens` on TTL or on
|
|
20
|
+
* an UNAUTHENTICATED bounce (slack-ts middleware calls `invalidate(teamId)`).
|
|
21
|
+
*
|
|
22
|
+
* Token + team metadata are kept in a single snapshot, refreshed atomically.
|
|
23
|
+
* `listTeams()` returns the latest snapshot so `client.teams()` always
|
|
24
|
+
* reflects active installations — used by the slack provider's message stream
|
|
25
|
+
* to discover which workspaces to subscribe to.
|
|
26
|
+
*/
|
|
27
|
+
async function createCloudClients(projectId, projectSecret, endpoint) {
|
|
28
|
+
let tokenData = await cloud.issueSlackTokens(projectId, projectSecret);
|
|
29
|
+
let tokenExpiresAt = Date.now() + tokenData.expiresIn * 1e3;
|
|
30
|
+
let disposed = false;
|
|
31
|
+
let renewalTimer;
|
|
32
|
+
let refreshFailures = 0;
|
|
33
|
+
const clearRenewalTimer = () => {
|
|
34
|
+
if (renewalTimer !== void 0) {
|
|
35
|
+
clearTimeout(renewalTimer);
|
|
36
|
+
renewalTimer = void 0;
|
|
37
|
+
}
|
|
38
|
+
};
|
|
39
|
+
const refreshTokens = async () => {
|
|
40
|
+
tokenData = await cloud.issueSlackTokens(projectId, projectSecret);
|
|
41
|
+
tokenExpiresAt = Date.now() + tokenData.expiresIn * 1e3;
|
|
42
|
+
};
|
|
43
|
+
const onRefreshSuccess = () => {
|
|
44
|
+
if (refreshFailures > 0) {
|
|
45
|
+
log.info("slack token refresh recovered", { "spectrum.slack.auth.attempt": refreshFailures });
|
|
46
|
+
refreshFailures = 0;
|
|
47
|
+
}
|
|
48
|
+
};
|
|
49
|
+
const onRefreshFailure = (error) => {
|
|
50
|
+
refreshFailures += 1;
|
|
51
|
+
log.warn("slack token refresh failed; retrying", {
|
|
52
|
+
"spectrum.slack.auth.attempt": refreshFailures,
|
|
53
|
+
"spectrum.slack.auth.retry_in_ms": RETRY_DELAY_MS,
|
|
54
|
+
...errorAttrs(error)
|
|
55
|
+
}, error);
|
|
56
|
+
};
|
|
57
|
+
const scheduleRetry = () => {
|
|
58
|
+
if (disposed) return;
|
|
59
|
+
clearRenewalTimer();
|
|
60
|
+
renewalTimer = setTimeout(async () => {
|
|
61
|
+
if (disposed) return;
|
|
62
|
+
try {
|
|
63
|
+
await refreshTokens();
|
|
64
|
+
onRefreshSuccess();
|
|
65
|
+
scheduleRenewal();
|
|
66
|
+
} catch (retryErr) {
|
|
67
|
+
onRefreshFailure(retryErr);
|
|
68
|
+
scheduleRetry();
|
|
69
|
+
}
|
|
70
|
+
}, RETRY_DELAY_MS);
|
|
71
|
+
renewalTimer?.unref?.();
|
|
72
|
+
};
|
|
73
|
+
const scheduleRenewal = () => {
|
|
74
|
+
if (disposed) return;
|
|
75
|
+
clearRenewalTimer();
|
|
76
|
+
const ttlMs = tokenData.expiresIn * 1e3;
|
|
77
|
+
const renewInMs = Math.max(ttlMs * RENEWAL_RATIO, 5e3);
|
|
78
|
+
renewalTimer = setTimeout(async () => {
|
|
79
|
+
try {
|
|
80
|
+
await refreshTokens();
|
|
81
|
+
onRefreshSuccess();
|
|
82
|
+
scheduleRenewal();
|
|
83
|
+
} catch (err) {
|
|
84
|
+
onRefreshFailure(err);
|
|
85
|
+
scheduleRetry();
|
|
86
|
+
}
|
|
87
|
+
}, renewInMs);
|
|
88
|
+
renewalTimer?.unref?.();
|
|
89
|
+
};
|
|
90
|
+
const refreshIfNeeded = async () => {
|
|
91
|
+
if (Date.now() < tokenExpiresAt - EXPIRY_BUFFER_MS) return;
|
|
92
|
+
await refreshTokens();
|
|
93
|
+
onRefreshSuccess();
|
|
94
|
+
scheduleRenewal();
|
|
95
|
+
};
|
|
96
|
+
scheduleRenewal();
|
|
97
|
+
const client = createClient({
|
|
98
|
+
spectrumSlackEndpoint: endpoint,
|
|
99
|
+
tokenProvider: {
|
|
100
|
+
async getAccessToken(teamId) {
|
|
101
|
+
await refreshIfNeeded();
|
|
102
|
+
const token = tokenData.auth[teamId];
|
|
103
|
+
if (!token) throw new Error(`Slack team ${teamId} has no active installation in this project`);
|
|
104
|
+
return token;
|
|
105
|
+
},
|
|
106
|
+
invalidate(_teamId) {
|
|
107
|
+
tokenExpiresAt = 0;
|
|
108
|
+
},
|
|
109
|
+
async listTeams() {
|
|
110
|
+
await refreshIfNeeded();
|
|
111
|
+
const entries = Object.entries(tokenData.teams).map(([teamId, meta]) => [teamId, toTeamMetadata(meta)]);
|
|
112
|
+
return new Map(entries);
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
});
|
|
116
|
+
cloudAuthState.set(client, { dispose: async () => {
|
|
117
|
+
disposed = true;
|
|
118
|
+
clearRenewalTimer();
|
|
119
|
+
} });
|
|
120
|
+
return client;
|
|
121
|
+
}
|
|
122
|
+
async function disposeCloudAuth(client) {
|
|
123
|
+
const auth = cloudAuthState.get(client);
|
|
124
|
+
if (!auth) return;
|
|
125
|
+
await auth.dispose();
|
|
126
|
+
cloudAuthState.delete(client);
|
|
127
|
+
}
|
|
128
|
+
//#endregion
|
|
129
|
+
//#region src/messages.ts
|
|
130
|
+
const toRecord = (result, space, content) => ({
|
|
131
|
+
id: result.ts,
|
|
132
|
+
content,
|
|
133
|
+
space: {
|
|
134
|
+
id: result.channel,
|
|
135
|
+
teamId: space.teamId
|
|
136
|
+
},
|
|
137
|
+
timestamp: tsToDate(result.ts),
|
|
138
|
+
ts: result.ts,
|
|
139
|
+
isFromMe: true
|
|
140
|
+
});
|
|
141
|
+
const toUploadRecord = (result, space, content) => {
|
|
142
|
+
const shareTs = result.shares.find((s) => s.channel === space.id)?.ts;
|
|
143
|
+
return {
|
|
144
|
+
id: shareTs ?? result.file.id,
|
|
145
|
+
content,
|
|
146
|
+
space: {
|
|
147
|
+
id: space.id,
|
|
148
|
+
teamId: space.teamId
|
|
149
|
+
},
|
|
150
|
+
timestamp: shareTs ? tsToDate(shareTs) : /* @__PURE__ */ new Date(),
|
|
151
|
+
ts: shareTs,
|
|
152
|
+
isFromMe: true
|
|
153
|
+
};
|
|
154
|
+
};
|
|
155
|
+
const tsToDate = (ts) => {
|
|
156
|
+
if (!ts) return /* @__PURE__ */ new Date();
|
|
157
|
+
const seconds = Number.parseFloat(ts);
|
|
158
|
+
if (!Number.isFinite(seconds)) return /* @__PURE__ */ new Date();
|
|
159
|
+
return /* @__PURE__ */ new Date(seconds * 1e3);
|
|
160
|
+
};
|
|
161
|
+
const lazySlackFile = (client, teamId, file) => asAttachment({
|
|
162
|
+
id: file.id,
|
|
163
|
+
name: file.name,
|
|
164
|
+
mimeType: file.mimeType,
|
|
165
|
+
size: file.size,
|
|
166
|
+
read: async () => {
|
|
167
|
+
const { bytes } = await client.team(teamId).files.getContentBuffer(file.id);
|
|
168
|
+
return Buffer.from(bytes);
|
|
169
|
+
},
|
|
170
|
+
stream: async () => {
|
|
171
|
+
const { content } = await client.team(teamId).files.getContent(file.id);
|
|
172
|
+
return new ReadableStream({ async start(controller) {
|
|
173
|
+
try {
|
|
174
|
+
for await (const chunk of content) controller.enqueue(chunk);
|
|
175
|
+
controller.close();
|
|
176
|
+
} catch (err) {
|
|
177
|
+
controller.error(err);
|
|
178
|
+
}
|
|
179
|
+
} });
|
|
180
|
+
}
|
|
181
|
+
});
|
|
182
|
+
const toMessages = (client, event) => {
|
|
183
|
+
if (event.type === "message") return messageToMessages(client, event.teamId, event.message);
|
|
184
|
+
if (event.type === "reaction") return [reactionToMessage(event.teamId, event.reaction)];
|
|
185
|
+
if (event.type === "mention") return [{
|
|
186
|
+
id: event.mention.ts,
|
|
187
|
+
content: asText(event.mention.text),
|
|
188
|
+
sender: { id: event.mention.user },
|
|
189
|
+
space: {
|
|
190
|
+
id: event.mention.channel,
|
|
191
|
+
teamId: event.teamId
|
|
192
|
+
},
|
|
193
|
+
timestamp: tsToDate(event.mention.ts),
|
|
194
|
+
ts: event.mention.ts,
|
|
195
|
+
isFromMe: event.mention.isFromMe
|
|
196
|
+
}];
|
|
197
|
+
return [];
|
|
198
|
+
};
|
|
199
|
+
const messageToMessages = (client, teamId, msg) => {
|
|
200
|
+
const base = {
|
|
201
|
+
sender: { id: msg.user },
|
|
202
|
+
space: {
|
|
203
|
+
id: msg.channel,
|
|
204
|
+
teamId
|
|
205
|
+
},
|
|
206
|
+
timestamp: tsToDate(msg.ts),
|
|
207
|
+
ts: msg.ts,
|
|
208
|
+
threadTs: msg.threadTs,
|
|
209
|
+
subtype: msg.subtype,
|
|
210
|
+
isFromMe: msg.isFromMe
|
|
211
|
+
};
|
|
212
|
+
const results = [];
|
|
213
|
+
if (msg.text) results.push({
|
|
214
|
+
...base,
|
|
215
|
+
id: msg.files.length > 0 ? `${msg.ts}:text` : msg.ts,
|
|
216
|
+
content: asText(msg.text)
|
|
217
|
+
});
|
|
218
|
+
for (const [index, file] of msg.files.entries()) {
|
|
219
|
+
const singleFile = msg.files.length === 1 && !msg.text;
|
|
220
|
+
results.push({
|
|
221
|
+
...base,
|
|
222
|
+
id: singleFile ? msg.ts : `${msg.ts}:file:${index}`,
|
|
223
|
+
content: lazySlackFile(client, teamId, file)
|
|
224
|
+
});
|
|
225
|
+
}
|
|
226
|
+
if (results.length === 0) results.push({
|
|
227
|
+
...base,
|
|
228
|
+
id: msg.ts,
|
|
229
|
+
content: asCustom({ slack_type: "empty" })
|
|
230
|
+
});
|
|
231
|
+
return results;
|
|
232
|
+
};
|
|
233
|
+
const reactionToMessage = (teamId, reaction) => {
|
|
234
|
+
const stubTarget = {
|
|
235
|
+
id: reaction.itemTs,
|
|
236
|
+
content: asCustom({
|
|
237
|
+
slack_type: "reaction-target",
|
|
238
|
+
stub: true
|
|
239
|
+
}),
|
|
240
|
+
sender: { id: "" },
|
|
241
|
+
space: {
|
|
242
|
+
id: reaction.itemChannel,
|
|
243
|
+
teamId
|
|
244
|
+
}
|
|
245
|
+
};
|
|
246
|
+
return {
|
|
247
|
+
id: `${reaction.itemTs}:reaction:${reaction.user}:${reaction.name}`,
|
|
248
|
+
content: asReaction({
|
|
249
|
+
emoji: reaction.name,
|
|
250
|
+
target: stubTarget
|
|
251
|
+
}),
|
|
252
|
+
sender: { id: reaction.user },
|
|
253
|
+
space: {
|
|
254
|
+
id: reaction.itemChannel,
|
|
255
|
+
teamId
|
|
256
|
+
},
|
|
257
|
+
timestamp: /* @__PURE__ */ new Date(),
|
|
258
|
+
ts: reaction.itemTs,
|
|
259
|
+
subtype: reaction.removed ? "reaction_removed" : "reaction_added",
|
|
260
|
+
isFromMe: reaction.isFromMe
|
|
261
|
+
};
|
|
262
|
+
};
|
|
263
|
+
const teamStream = (client, teamId) => {
|
|
264
|
+
const eventStream = client.team(teamId).events.subscribe();
|
|
265
|
+
return stream((emit, end) => {
|
|
266
|
+
const pump = (async () => {
|
|
267
|
+
try {
|
|
268
|
+
for await (const event of eventStream) for (const m of toMessages(client, event)) await emit(m);
|
|
269
|
+
end();
|
|
270
|
+
} catch (e) {
|
|
271
|
+
end(e);
|
|
272
|
+
}
|
|
273
|
+
})();
|
|
274
|
+
return async () => {
|
|
275
|
+
await eventStream.close();
|
|
276
|
+
await pump;
|
|
277
|
+
};
|
|
278
|
+
});
|
|
279
|
+
};
|
|
280
|
+
const messages = (client, resolveTeamIds) => stream(async (emit, end) => {
|
|
281
|
+
let teamIds;
|
|
282
|
+
try {
|
|
283
|
+
teamIds = await resolveTeamIds();
|
|
284
|
+
} catch (err) {
|
|
285
|
+
end(err);
|
|
286
|
+
return;
|
|
287
|
+
}
|
|
288
|
+
const merged = mergeStreams(teamIds.map((id) => teamStream(client, id)));
|
|
289
|
+
const pump = (async () => {
|
|
290
|
+
try {
|
|
291
|
+
for await (const value of merged) await emit(value);
|
|
292
|
+
end();
|
|
293
|
+
} catch (e) {
|
|
294
|
+
end(e);
|
|
295
|
+
}
|
|
296
|
+
})();
|
|
297
|
+
return async () => {
|
|
298
|
+
await merged.close();
|
|
299
|
+
await pump;
|
|
300
|
+
};
|
|
301
|
+
});
|
|
302
|
+
const mimeToMediaName = (mimeType, fallback) => {
|
|
303
|
+
const slash = mimeType.indexOf("/");
|
|
304
|
+
if (slash < 0) return fallback;
|
|
305
|
+
return `${fallback}.${mimeType.slice(slash + 1)}`;
|
|
306
|
+
};
|
|
307
|
+
const send = async (client, space, content) => {
|
|
308
|
+
if (content.type === "reply") return await replyToMessage(client, space, content.target.ts ?? content.target.id, content.content);
|
|
309
|
+
if (content.type === "reaction") return await reactToMessage(client, space, content.target.ts ?? content.target.id, content);
|
|
310
|
+
if (content.type === "typing") return;
|
|
311
|
+
if (content.type === "read") return;
|
|
312
|
+
return await sendContent(client, space, content);
|
|
313
|
+
};
|
|
314
|
+
const sendContent = async (client, space, content, threadTs) => {
|
|
315
|
+
const team = client.team(space.teamId);
|
|
316
|
+
switch (content.type) {
|
|
317
|
+
case "text": return toRecord(await team.messages.send({
|
|
318
|
+
channel: space.id,
|
|
319
|
+
text: content.text,
|
|
320
|
+
threadTs
|
|
321
|
+
}), space, content);
|
|
322
|
+
case "attachment": return toUploadRecord(await team.files.upload({
|
|
323
|
+
channel: space.id,
|
|
324
|
+
content: await content.read(),
|
|
325
|
+
filename: content.name,
|
|
326
|
+
mimeType: content.mimeType,
|
|
327
|
+
threadTs
|
|
328
|
+
}), space, content);
|
|
329
|
+
case "voice": return toUploadRecord(await team.files.upload({
|
|
330
|
+
channel: space.id,
|
|
331
|
+
content: await content.read(),
|
|
332
|
+
filename: content.name ?? mimeToMediaName(content.mimeType, "voice"),
|
|
333
|
+
mimeType: content.mimeType,
|
|
334
|
+
threadTs
|
|
335
|
+
}), space, content);
|
|
336
|
+
case "app": return toRecord(await team.messages.send({
|
|
337
|
+
channel: space.id,
|
|
338
|
+
text: await content.url(),
|
|
339
|
+
threadTs
|
|
340
|
+
}), space, content);
|
|
341
|
+
default: throw UnsupportedError.content(content.type);
|
|
342
|
+
}
|
|
343
|
+
};
|
|
344
|
+
const reactToMessage = async (client, space, targetTs, content) => {
|
|
345
|
+
await client.team(space.teamId).messages.send({
|
|
346
|
+
channel: space.id,
|
|
347
|
+
reaction: {
|
|
348
|
+
emoji: content.emoji,
|
|
349
|
+
itemChannel: space.id,
|
|
350
|
+
itemTs: targetTs
|
|
351
|
+
}
|
|
352
|
+
});
|
|
353
|
+
return {
|
|
354
|
+
id: `${targetTs}:reaction:self:${content.emoji}`,
|
|
355
|
+
content,
|
|
356
|
+
space: {
|
|
357
|
+
id: space.id,
|
|
358
|
+
teamId: space.teamId
|
|
359
|
+
},
|
|
360
|
+
timestamp: /* @__PURE__ */ new Date(),
|
|
361
|
+
ts: targetTs,
|
|
362
|
+
isFromMe: true
|
|
363
|
+
};
|
|
364
|
+
};
|
|
365
|
+
const replyToMessage = async (client, space, targetTs, content) => await sendContent(client, space, content, targetTs);
|
|
366
|
+
//#endregion
|
|
367
|
+
//#region src/types.ts
|
|
368
|
+
const teamMetadataSchema = z.object({
|
|
369
|
+
appId: z.string(),
|
|
370
|
+
botUserId: z.string(),
|
|
371
|
+
grantedScopes: z.array(z.string()),
|
|
372
|
+
teamName: z.string()
|
|
373
|
+
});
|
|
374
|
+
const directConfig = z.object({
|
|
375
|
+
endpoint: z.string().optional(),
|
|
376
|
+
teams: z.record(z.string(), teamMetadataSchema).optional(),
|
|
377
|
+
tokens: z.record(z.string(), z.string().min(1)).refine((t) => Object.keys(t).length > 0, { message: "at least one token entry is required" })
|
|
378
|
+
});
|
|
379
|
+
const cloudConfig = z.object({}).strict();
|
|
380
|
+
const configSchema = z.union([directConfig, cloudConfig]);
|
|
381
|
+
const isCloudConfig = (config) => !("tokens" in config);
|
|
382
|
+
const userSchema = z.object({});
|
|
383
|
+
const spaceSchema = z.object({
|
|
384
|
+
id: z.string(),
|
|
385
|
+
teamId: z.string()
|
|
386
|
+
});
|
|
387
|
+
const spaceParamsSchema = z.object({ teamId: z.string() });
|
|
388
|
+
/**
|
|
389
|
+
* Slack-specific per-message metadata surfaced on `SlackMessage`.
|
|
390
|
+
* - `isFromMe`: server-stamped by spectrum-slack — `true` when `sender.id` is
|
|
391
|
+
* this installation's bot user id. Use this to filter self-echo without
|
|
392
|
+
* plumbing `bot_user_id` from `client.teams()` into the consumer.
|
|
393
|
+
* - `ts`: the canonical Slack message timestamp id (mirrors `id` for messages
|
|
394
|
+
* sourced from the events stream; useful when constructing replies that
|
|
395
|
+
* target the same thread).
|
|
396
|
+
* - `threadTs`: the parent message ts when the row is itself a threaded reply.
|
|
397
|
+
* - `subtype`: Slack's subtype, e.g. `bot_message`, `message_changed`, etc.
|
|
398
|
+
*/
|
|
399
|
+
const messageSchema = z.object({
|
|
400
|
+
isFromMe: z.boolean(),
|
|
401
|
+
subtype: z.string().optional(),
|
|
402
|
+
threadTs: z.string().optional(),
|
|
403
|
+
ts: z.string().optional()
|
|
404
|
+
});
|
|
405
|
+
//#endregion
|
|
406
|
+
//#region src/index.ts
|
|
407
|
+
const slack = definePlatform("Slack", {
|
|
408
|
+
config: configSchema,
|
|
409
|
+
lifecycle: {
|
|
410
|
+
createClient: async ({ config, projectId, projectSecret }) => {
|
|
411
|
+
if (!isCloudConfig(config)) return createClient({
|
|
412
|
+
spectrumSlackEndpoint: config.endpoint,
|
|
413
|
+
tokenProvider: staticTokens({
|
|
414
|
+
tokens: config.tokens,
|
|
415
|
+
teams: config.teams
|
|
416
|
+
})
|
|
417
|
+
});
|
|
418
|
+
if (!(projectId && projectSecret)) throw new Error("Slack cloud mode requires projectId and projectSecret. Either pass credentials to Spectrum(), or provide direct credentials: slack.config({ tokens: { T012ABCDE: 'jwt...' } })");
|
|
419
|
+
return await createCloudClients(projectId, projectSecret, process.env.SPECTRUM_SLACK_ENDPOINT);
|
|
420
|
+
},
|
|
421
|
+
destroyClient: async ({ client }) => {
|
|
422
|
+
await disposeCloudAuth(client);
|
|
423
|
+
await client.close();
|
|
424
|
+
}
|
|
425
|
+
},
|
|
426
|
+
user: {
|
|
427
|
+
schema: userSchema,
|
|
428
|
+
resolve: async ({ input }) => ({ id: input.userID })
|
|
429
|
+
},
|
|
430
|
+
space: {
|
|
431
|
+
schema: spaceSchema,
|
|
432
|
+
params: spaceParamsSchema,
|
|
433
|
+
create: async ({ input }) => {
|
|
434
|
+
const teamId = input.params?.teamId;
|
|
435
|
+
if (!teamId) throw new Error("Slack space creation requires a teamId param. Pass it via space.create(user, { teamId }).");
|
|
436
|
+
if (input.users.length > 1) throw UnsupportedError.action("space.create", "Slack", "group DMs require an explicit channel id (Slack's conversations.open is not exposed); use space.get(channelId, { teamId })");
|
|
437
|
+
const user = input.users[0];
|
|
438
|
+
if (!user) throw new Error("Slack space creation requires a user");
|
|
439
|
+
return {
|
|
440
|
+
id: user.id,
|
|
441
|
+
teamId
|
|
442
|
+
};
|
|
443
|
+
},
|
|
444
|
+
get: async ({ input }) => {
|
|
445
|
+
const teamId = input.params?.teamId;
|
|
446
|
+
if (!teamId) throw new Error("Slack spaces require a teamId param. Pass it via space.get(channelId, { teamId }).");
|
|
447
|
+
return {
|
|
448
|
+
id: input.id,
|
|
449
|
+
teamId
|
|
450
|
+
};
|
|
451
|
+
}
|
|
452
|
+
},
|
|
453
|
+
message: { schema: messageSchema },
|
|
454
|
+
messages: ({ client, config }) => messages(client, async () => {
|
|
455
|
+
const teams = await client.teams();
|
|
456
|
+
if (teams.size > 0) return Array.from(teams.keys());
|
|
457
|
+
if (isCloudConfig(config)) return [];
|
|
458
|
+
return Object.keys(config.tokens);
|
|
459
|
+
}),
|
|
460
|
+
send: async ({ space, content, client }) => await send(client, {
|
|
461
|
+
id: space.id,
|
|
462
|
+
teamId: space.teamId
|
|
463
|
+
}, content)
|
|
464
|
+
});
|
|
465
|
+
//#endregion
|
|
466
|
+
export { slack };
|
package/package.json
ADDED
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@spectrum-ts/slack",
|
|
3
|
+
"version": "5.0.0",
|
|
4
|
+
"description": "Slack provider for spectrum-ts.",
|
|
5
|
+
"repository": {
|
|
6
|
+
"type": "git",
|
|
7
|
+
"url": "git+https://github.com/photon-hq/spectrum-ts.git",
|
|
8
|
+
"directory": "packages/slack"
|
|
9
|
+
},
|
|
10
|
+
"homepage": "https://photon.codes/spectrum",
|
|
11
|
+
"bugs": {
|
|
12
|
+
"url": "https://github.com/photon-hq/spectrum-ts/issues"
|
|
13
|
+
},
|
|
14
|
+
"type": "module",
|
|
15
|
+
"sideEffects": false,
|
|
16
|
+
"files": [
|
|
17
|
+
"dist"
|
|
18
|
+
],
|
|
19
|
+
"exports": {
|
|
20
|
+
".": {
|
|
21
|
+
"types": "./dist/index.d.ts",
|
|
22
|
+
"default": "./dist/index.js"
|
|
23
|
+
}
|
|
24
|
+
},
|
|
25
|
+
"publishConfig": {
|
|
26
|
+
"access": "public"
|
|
27
|
+
},
|
|
28
|
+
"spectrum": {
|
|
29
|
+
"key": "slack",
|
|
30
|
+
"import": "slack",
|
|
31
|
+
"label": "Slack"
|
|
32
|
+
},
|
|
33
|
+
"dependencies": {
|
|
34
|
+
"@photon-ai/slack": "^0.2.0",
|
|
35
|
+
"zod": "^4.2.1"
|
|
36
|
+
},
|
|
37
|
+
"peerDependencies": {
|
|
38
|
+
"@spectrum-ts/core": "^5.0.0",
|
|
39
|
+
"typescript": "^5 || ^6.0.0"
|
|
40
|
+
},
|
|
41
|
+
"license": "MIT"
|
|
42
|
+
}
|