@effect-uai/slack 0.14.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.
Files changed (42) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +51 -0
  3. package/dist/Slack.d.mts +43 -0
  4. package/dist/Slack.d.mts.map +1 -0
  5. package/dist/Slack.mjs +215 -0
  6. package/dist/Slack.mjs.map +1 -0
  7. package/dist/api-BBgliKo7.d.mts +51 -0
  8. package/dist/api-BBgliKo7.d.mts.map +1 -0
  9. package/dist/events-Cj5RRjK1.d.mts +317 -0
  10. package/dist/events-Cj5RRjK1.d.mts.map +1 -0
  11. package/dist/index.d.mts +2 -0
  12. package/dist/index.mjs +2 -0
  13. package/dist/internal/api.d.mts +2 -0
  14. package/dist/internal/api.mjs +79 -0
  15. package/dist/internal/api.mjs.map +1 -0
  16. package/dist/internal/events.d.mts +2 -0
  17. package/dist/internal/events.mjs +184 -0
  18. package/dist/internal/events.mjs.map +1 -0
  19. package/dist/internal/events.test.d.mts +1 -0
  20. package/dist/internal/events.test.mjs +174 -0
  21. package/dist/internal/events.test.mjs.map +1 -0
  22. package/dist/internal/socket.d.mts +2 -0
  23. package/dist/internal/socket.mjs +109 -0
  24. package/dist/internal/socket.mjs.map +1 -0
  25. package/dist/internal/socket.test.d.mts +1 -0
  26. package/dist/internal/socket.test.mjs +25 -0
  27. package/dist/internal/socket.test.mjs.map +1 -0
  28. package/dist/magic-string.es-CUAnjKYC.mjs +1014 -0
  29. package/dist/magic-string.es-CUAnjKYC.mjs.map +1 -0
  30. package/dist/rolldown-runtime-D7D4PA-g.mjs +13 -0
  31. package/dist/socket-DJgdt_ka.d.mts +54 -0
  32. package/dist/socket-DJgdt_ka.d.mts.map +1 -0
  33. package/dist/test.DNmyFkvJ-N3QqMRwZ.mjs +13658 -0
  34. package/dist/test.DNmyFkvJ-N3QqMRwZ.mjs.map +1 -0
  35. package/package.json +59 -0
  36. package/src/Slack.ts +419 -0
  37. package/src/index.ts +1 -0
  38. package/src/internal/api.ts +171 -0
  39. package/src/internal/events.test.ts +121 -0
  40. package/src/internal/events.ts +330 -0
  41. package/src/internal/socket.test.ts +25 -0
  42. package/src/internal/socket.ts +238 -0
@@ -0,0 +1,121 @@
1
+ import { Schema } from "effect"
2
+ import { describe, expect, it } from "vitest"
3
+ import { type BotIdentity, Envelope, type Options, type ReplyIn, toEvents } from "./events.js"
4
+
5
+ const bot: BotIdentity = { userId: "UBOT", botId: "BBOT", teamId: "T1" }
6
+
7
+ const options = (replyIn: ReplyIn = "thread"): Options => ({ bot, replyIn })
8
+
9
+ /** One frame through the real decoder, as the socket hands it over. */
10
+ const frame = (raw: Record<string, unknown>, replyIn?: ReplyIn) =>
11
+ toEvents(options(replyIn))(Schema.decodeUnknownSync(Envelope)(raw), raw)
12
+
13
+ const event = (payload: Record<string, unknown>, replyIn?: ReplyIn) =>
14
+ frame(
15
+ { type: "events_api", envelope_id: "e1", payload: { event_id: "Ev1", event: payload } },
16
+ replyIn,
17
+ )[0]
18
+
19
+ const message = (fields: Record<string, unknown>, replyIn?: ReplyIn) =>
20
+ event(
21
+ { type: "message", channel: "C1", channel_type: "channel", user: "U1", ts: "1.1", ...fields },
22
+ replyIn,
23
+ )
24
+
25
+ const mention = (fields: Record<string, unknown>, replyIn?: ReplyIn) =>
26
+ event({ type: "app_mention", channel: "C1", user: "U1", ts: "1.1", ...fields }, replyIn)
27
+
28
+ describe("addressed", () => {
29
+ it("is a DM or a mention, and a thread follow-up without one is neither", () => {
30
+ expect(message({ channel: "D1", channel_type: "im", text: "hi" })).toMatchObject({
31
+ addressed: true,
32
+ })
33
+ expect(mention({ text: "<@UBOT> ping" })).toMatchObject({ addressed: true })
34
+ expect(message({ text: "still there?", thread_ts: "1.0" })).toMatchObject({ addressed: false })
35
+ })
36
+
37
+ it("strips the bot's own mention, labelled or not, and leaves other users'", () => {
38
+ expect(mention({ text: "<@UBOT|betty> ask <@U9> too" })).toMatchObject({
39
+ text: "ask <@U9> too",
40
+ })
41
+ })
42
+ })
43
+
44
+ describe("one event per message", () => {
45
+ // Slack sends a channel mention twice, as `app_mention` and `message`, and
46
+ // sends a mention inside a DM only as `message.im`.
47
+ it("keeps the app_mention in a channel and the message in a DM", () => {
48
+ expect(message({ text: "<@UBOT> ping" })).toBeUndefined()
49
+ expect(mention({ text: "<@UBOT> ping" })).toMatchObject({ _tag: "Message", id: "1.1" })
50
+ expect(message({ channel: "D1", channel_type: "im", text: "<@UBOT> hi" })).toMatchObject({
51
+ text: "hi",
52
+ })
53
+ expect(mention({ channel: "D1" })).toBeUndefined()
54
+ })
55
+
56
+ it("ignores edits and every bot, so two bots cannot answer each other", () => {
57
+ expect(message({ subtype: "message_changed", text: "edited" })).toBeUndefined()
58
+ expect(message({ bot_id: "B2", text: "hi" })).toBeUndefined()
59
+ expect(message({ user: "UBOT", text: "my own words" })).toBeUndefined()
60
+ })
61
+ })
62
+
63
+ describe("conversation", () => {
64
+ it("mints a thread from a top-level mention only under replyIn thread", () => {
65
+ expect(mention({ ts: "1.1" }, "thread")).toMatchObject({
66
+ conversation: { channel: "C1", thread: "1.1" },
67
+ })
68
+ expect(mention({ ts: "1.1" }, "channel")).toMatchObject({ conversation: { channel: "C1" } })
69
+ })
70
+
71
+ it("keeps a follow-up in the thread it was written in, and a DM out of one", () => {
72
+ expect(mention({ ts: "2.2", thread_ts: "1.1" })).toMatchObject({
73
+ conversation: { channel: "C1", thread: "1.1" },
74
+ })
75
+ expect(message({ channel: "D1", channel_type: "im", text: "hi" })).toMatchObject({
76
+ conversation: { channel: "D1" },
77
+ })
78
+ })
79
+ })
80
+
81
+ describe("reactions", () => {
82
+ const added = (user = "U1") =>
83
+ event({ type: "reaction_added", user, reaction: "eyes", item: { channel: "C1", ts: "1.1" } })
84
+
85
+ it("names the reacted message as its own thread, and drops the bot's own", () => {
86
+ // `reaction_added` carries no `thread_ts`, so this matches a reaction on the
87
+ // message that opened the thread and nothing deeper.
88
+ expect(added()).toMatchObject({ emoji: "eyes", conversation: { thread: "1.1" } })
89
+ expect(added("UBOT")).toBeUndefined()
90
+ })
91
+ })
92
+
93
+ describe("commands and actions", () => {
94
+ it("drops the slash and keeps the rest as args", () => {
95
+ expect(
96
+ frame({
97
+ type: "slash_commands",
98
+ envelope_id: "e1",
99
+ payload: { channel_id: "C1", user_id: "U1", command: "/start", text: " now " },
100
+ })[0],
101
+ ).toMatchObject({ _tag: "Command", name: "start", args: "now" })
102
+ })
103
+
104
+ const interactive = (payload: Record<string, unknown>) =>
105
+ frame({ type: "interactive", envelope_id: "e1", payload })
106
+
107
+ it("maps every pressed element, and ignores payloads needing their own reply", () => {
108
+ expect(
109
+ interactive({
110
+ type: "block_actions",
111
+ user: { id: "U1" },
112
+ container: { channel_id: "C1", thread_ts: "1.1" },
113
+ actions: [{ action_id: "approve", value: "12" }, { action_id: "deny" }],
114
+ }),
115
+ ).toMatchObject([
116
+ { actionId: "approve", value: "12", conversation: { thread: "1.1" } },
117
+ { actionId: "deny" },
118
+ ])
119
+ expect(interactive({ type: "view_submission", user: { id: "U1" } })).toEqual([])
120
+ })
121
+ })
@@ -0,0 +1,330 @@
1
+ import { Array as Arr, Match, Option, Schema } from "effect"
2
+ import {
3
+ ChannelId,
4
+ type ConversationRef,
5
+ InboundEvent,
6
+ MessageId,
7
+ ThreadId,
8
+ UserId,
9
+ } from "@effect-uai/core/Messenger"
10
+
11
+ // ---------------------------------------------------------------------------
12
+ // Wire shapes: the fields this adapter reads. The rest survives on `raw`.
13
+ // ---------------------------------------------------------------------------
14
+
15
+ export const AppMention = Schema.Struct({
16
+ type: Schema.Literal("app_mention"),
17
+ channel: Schema.String,
18
+ user: Schema.optional(Schema.String),
19
+ bot_id: Schema.optional(Schema.String),
20
+ text: Schema.optional(Schema.String),
21
+ ts: Schema.String,
22
+ thread_ts: Schema.optional(Schema.String),
23
+ })
24
+ export type AppMention = typeof AppMention.Type
25
+
26
+ export const Message = Schema.Struct({
27
+ type: Schema.Literal("message"),
28
+ channel: Schema.String,
29
+ /** `channel`, `group`, `im` or `mpim`. Authoritative for what kind of chat this is. */
30
+ channel_type: Schema.optional(Schema.String),
31
+ user: Schema.optional(Schema.String),
32
+ bot_id: Schema.optional(Schema.String),
33
+ subtype: Schema.optional(Schema.String),
34
+ text: Schema.optional(Schema.String),
35
+ ts: Schema.String,
36
+ thread_ts: Schema.optional(Schema.String),
37
+ })
38
+ export type Message = typeof Message.Type
39
+
40
+ export const ReactionAdded = Schema.Struct({
41
+ type: Schema.Literal("reaction_added"),
42
+ user: Schema.String,
43
+ /** A shortcode without colons, which is also what `reactions.add` takes. */
44
+ reaction: Schema.String,
45
+ item: Schema.Struct({ channel: Schema.String, ts: Schema.String }),
46
+ })
47
+ export type ReactionAdded = typeof ReactionAdded.Type
48
+
49
+ /** The events this adapter subscribes to. Anything else fails to decode and is dropped. */
50
+ export const Event = Schema.Union([AppMention, Message, ReactionAdded])
51
+ export type Event = typeof Event.Type
52
+
53
+ export const EventsApi = Schema.Struct({
54
+ type: Schema.Literal("events_api"),
55
+ envelope_id: Schema.String,
56
+ payload: Schema.Struct({
57
+ /** Stable across a redelivery, which is what the socket dedupes on. */
58
+ event_id: Schema.optional(Schema.String),
59
+ event: Event,
60
+ }),
61
+ })
62
+ export type EventsApi = typeof EventsApi.Type
63
+
64
+ export const BlockAction = Schema.Struct({
65
+ action_id: Schema.optional(Schema.String),
66
+ value: Schema.optional(Schema.String),
67
+ })
68
+ export type BlockAction = typeof BlockAction.Type
69
+
70
+ export const Interactive = Schema.Struct({
71
+ type: Schema.Literal("interactive"),
72
+ envelope_id: Schema.String,
73
+ payload: Schema.Struct({
74
+ /** `block_actions`, `view_submission`, `shortcut`; only the first maps. */
75
+ type: Schema.String,
76
+ user: Schema.Struct({ id: Schema.String }),
77
+ container: Schema.optional(
78
+ Schema.Struct({
79
+ channel_id: Schema.optional(Schema.String),
80
+ thread_ts: Schema.optional(Schema.String),
81
+ }),
82
+ ),
83
+ channel: Schema.optional(Schema.Struct({ id: Schema.String })),
84
+ actions: Schema.optional(Schema.Array(BlockAction)),
85
+ }),
86
+ })
87
+ export type Interactive = typeof Interactive.Type
88
+
89
+ export const SlashCommand = Schema.Struct({
90
+ type: Schema.Literal("slash_commands"),
91
+ envelope_id: Schema.String,
92
+ payload: Schema.Struct({
93
+ channel_id: Schema.String,
94
+ user_id: Schema.String,
95
+ command: Schema.String,
96
+ text: Schema.optional(Schema.String),
97
+ }),
98
+ })
99
+ export type SlashCommand = typeof SlashCommand.Type
100
+
101
+ /** A frame carrying work, and so an `envelope_id` that has to be acknowledged. */
102
+ export const Envelope = Schema.Union([EventsApi, Interactive, SlashCommand])
103
+ export type Envelope = typeof Envelope.Type
104
+
105
+ export const Hello = Schema.Struct({ type: Schema.Literal("hello") })
106
+
107
+ export const Disconnect = Schema.Struct({
108
+ type: Schema.Literal("disconnect"),
109
+ reason: Schema.optional(Schema.String),
110
+ })
111
+
112
+ /** Everything Socket Mode sends down the wire. */
113
+ export const Frame = Schema.Union([Hello, Disconnect, EventsApi, Interactive, SlashCommand])
114
+ export type Frame = typeof Frame.Type
115
+
116
+ /** From `auth.test` at layer build: the identity behind `addressed`. */
117
+ export type BotIdentity = {
118
+ readonly userId: string
119
+ readonly botId: string
120
+ readonly teamId: string
121
+ }
122
+
123
+ /** Where a top-level mention is answered: in a thread under it, or in the channel. */
124
+ export type ReplyIn = "thread" | "channel"
125
+
126
+ export type Options = {
127
+ readonly bot: BotIdentity
128
+ readonly replyIn: ReplyIn
129
+ }
130
+
131
+ /** The id Slack repeats when it redelivers. Only an `events_api` frame carries one. */
132
+ export const eventId = (envelope: Envelope): Option.Option<string> =>
133
+ envelope.type === "events_api" ? Option.fromNullishOr(envelope.payload.event_id) : Option.none()
134
+
135
+ // ---------------------------------------------------------------------------
136
+ // Mapping
137
+ // ---------------------------------------------------------------------------
138
+
139
+ // Slack ids name their own kind, and `channel_type` says so outright where the
140
+ // payload has one. Group DMs are `mpim` and are treated as a channel.
141
+ const isDirect = (channel: string, channelType?: string): boolean =>
142
+ channelType === "im" || (channelType === undefined && channel.startsWith("D"))
143
+
144
+ /**
145
+ * Under `replyIn: "thread"` a channel message with no thread of its own gets
146
+ * one minted from its own `ts`, so the bot answers under the mention and a
147
+ * follow-up inside that thread is the same conversation. A DM never does.
148
+ */
149
+ const conversation = (
150
+ options: Options,
151
+ channel: string,
152
+ ts: string,
153
+ threadTs: string | undefined,
154
+ direct: boolean,
155
+ ): ConversationRef => {
156
+ const thread = threadTs ?? (options.replyIn === "thread" && !direct ? ts : undefined)
157
+ return {
158
+ channel: ChannelId(channel),
159
+ ...(thread !== undefined && { thread: ThreadId(thread) }),
160
+ }
161
+ }
162
+
163
+ // Slack writes a mention as `<@U123>`, or `<@U123|name>` in older payloads.
164
+ const mention = (userId: string): RegExp => new RegExp(`<@${userId}(\\|[^>]*)?>`, "g")
165
+
166
+ const mentionsBot = (bot: BotIdentity, text: string): boolean => mention(bot.userId).test(text)
167
+
168
+ /** The bot's own mention removed; other users' mentions stay as Slack wrote them. */
169
+ const withoutMention = (bot: BotIdentity, text: string): string =>
170
+ text.replace(mention(bot.userId), "").trim()
171
+
172
+ // Anything the bot itself or another app posted, so two bots cannot answer
173
+ // each other.
174
+ const fromBot = (
175
+ bot: BotIdentity,
176
+ event: { readonly bot_id?: string | undefined; readonly user?: string | undefined },
177
+ ): boolean => event.bot_id !== undefined || event.user === bot.userId
178
+
179
+ // Subtypes that are not a person saying something new.
180
+ const droppedSubtypes = [
181
+ "bot_message",
182
+ "message_changed",
183
+ "message_deleted",
184
+ "thread_broadcast",
185
+ "file_share",
186
+ ]
187
+
188
+ const fromMessage = (
189
+ options: Options,
190
+ event: Message,
191
+ raw: unknown,
192
+ ): Option.Option<InboundEvent> => {
193
+ const direct = isDirect(event.channel, event.channel_type)
194
+ const text = event.text ?? ""
195
+ const keep =
196
+ !droppedSubtypes.includes(event.subtype ?? "") &&
197
+ !fromBot(options.bot, event) &&
198
+ // In a channel the very same message also arrives as `app_mention`, and
199
+ // that is the copy this adapter keeps.
200
+ (direct || !mentionsBot(options.bot, text))
201
+ return keep
202
+ ? Option.map(Option.fromNullishOr(event.user), (user) =>
203
+ InboundEvent.Message({
204
+ conversation: conversation(options, event.channel, event.ts, event.thread_ts, direct),
205
+ id: MessageId(event.ts),
206
+ author: UserId(user),
207
+ text: withoutMention(options.bot, text),
208
+ // A thread follow-up without a mention is not addressed: the payload
209
+ // does not say who opened the thread and this adapter keeps no memory.
210
+ addressed: direct,
211
+ raw,
212
+ }),
213
+ )
214
+ : Option.none()
215
+ }
216
+
217
+ const fromMention = (
218
+ options: Options,
219
+ event: AppMention,
220
+ raw: unknown,
221
+ ): Option.Option<InboundEvent> =>
222
+ // A mention inside a DM already arrived as `message.im`.
223
+ Option.liftPredicate(event, (e) => !isDirect(e.channel) && !fromBot(options.bot, e)).pipe(
224
+ Option.flatMap((e) =>
225
+ Option.map(Option.fromNullishOr(e.user), (user) =>
226
+ InboundEvent.Message({
227
+ conversation: conversation(options, e.channel, e.ts, e.thread_ts, false),
228
+ id: MessageId(e.ts),
229
+ author: UserId(user),
230
+ text: withoutMention(options.bot, e.text ?? ""),
231
+ addressed: true,
232
+ raw,
233
+ }),
234
+ ),
235
+ ),
236
+ )
237
+
238
+ const fromReaction = (
239
+ options: Options,
240
+ event: ReactionAdded,
241
+ raw: unknown,
242
+ ): Option.Option<InboundEvent> =>
243
+ Option.liftPredicate(event, (e) => e.user !== options.bot.userId).pipe(
244
+ Option.map((e) =>
245
+ InboundEvent.Reaction({
246
+ // `reaction_added` names the message but not the thread it sits in, so
247
+ // under `replyIn: "thread"` this matches a reaction on the message that
248
+ // opened the thread and nothing deeper.
249
+ conversation: conversation(
250
+ options,
251
+ e.item.channel,
252
+ e.item.ts,
253
+ undefined,
254
+ isDirect(e.item.channel),
255
+ ),
256
+ message: MessageId(e.item.ts),
257
+ emoji: e.reaction,
258
+ author: UserId(e.user),
259
+ raw,
260
+ }),
261
+ ),
262
+ )
263
+
264
+ const fromEvent = (options: Options, event: Event, raw: unknown): Option.Option<InboundEvent> =>
265
+ Match.value(event).pipe(
266
+ Match.when({ type: "app_mention" }, (e) => fromMention(options, e, raw)),
267
+ Match.when({ type: "message" }, (e) => fromMessage(options, e, raw)),
268
+ Match.when({ type: "reaction_added" }, (e) => fromReaction(options, e, raw)),
269
+ Match.exhaustive,
270
+ )
271
+
272
+ const BLOCK_ACTIONS = "block_actions"
273
+
274
+ const actionConversation = (payload: Interactive["payload"]): Option.Option<ConversationRef> =>
275
+ Option.map(
276
+ Option.fromNullishOr(payload.container?.channel_id ?? payload.channel?.id),
277
+ (channel) => ({
278
+ channel: ChannelId(channel),
279
+ ...(payload.container?.thread_ts !== undefined && {
280
+ thread: ThreadId(payload.container.thread_ts),
281
+ }),
282
+ }),
283
+ )
284
+
285
+ /** One `Action` per pressed element; a view submission or shortcut is ignored. */
286
+ const fromInteractive = (
287
+ payload: Interactive["payload"],
288
+ raw: unknown,
289
+ ): ReadonlyArray<InboundEvent> =>
290
+ payload.type !== BLOCK_ACTIONS
291
+ ? []
292
+ : Option.match(actionConversation(payload), {
293
+ onNone: () => [],
294
+ onSome: (at) =>
295
+ (payload.actions ?? []).map((action) =>
296
+ InboundEvent.Action({
297
+ conversation: at,
298
+ actionId: action.action_id ?? "",
299
+ ...(action.value !== undefined && { value: action.value }),
300
+ author: UserId(payload.user.id),
301
+ raw,
302
+ }),
303
+ ),
304
+ })
305
+
306
+ const fromCommand = (payload: SlashCommand["payload"], raw: unknown): InboundEvent =>
307
+ InboundEvent.Command({
308
+ // A slash command names no thread, so it starts at channel level.
309
+ conversation: { channel: ChannelId(payload.channel_id) },
310
+ name: payload.command.startsWith("/") ? payload.command.slice(1) : payload.command,
311
+ args: (payload.text ?? "").trim(),
312
+ author: UserId(payload.user_id),
313
+ raw,
314
+ })
315
+
316
+ /**
317
+ * Everything one envelope says to the agent. `raw` is the frame as it came off
318
+ * the wire, since decoding keeps only the fields above.
319
+ */
320
+ export const toEvents =
321
+ (options: Options) =>
322
+ (envelope: Envelope, raw: unknown): ReadonlyArray<InboundEvent> =>
323
+ Match.value(envelope).pipe(
324
+ Match.when({ type: "events_api" }, ({ payload }) =>
325
+ Arr.fromOption(fromEvent(options, payload.event, raw)),
326
+ ),
327
+ Match.when({ type: "interactive" }, ({ payload }) => fromInteractive(payload, raw)),
328
+ Match.when({ type: "slash_commands" }, ({ payload }) => [fromCommand(payload, raw)]),
329
+ Match.exhaustive,
330
+ )
@@ -0,0 +1,25 @@
1
+ import { Option } from "effect"
2
+ import { describe, expect, it } from "vitest"
3
+ import { classifyDisconnect, remember } from "./socket.js"
4
+
5
+ describe("classifyDisconnect", () => {
6
+ it("ends only on the reason Slack says not to retry", () => {
7
+ expect(classifyDisconnect("link_disabled")).toBe("fatal")
8
+ // A rolling refresh is scheduled, so it reopens without backing off.
9
+ expect(["refresh_requested", "warning"].map(classifyDisconnect)).toEqual(["refresh", "refresh"])
10
+ expect(classifyDisconnect(undefined)).toBe("reconnect")
11
+ })
12
+ })
13
+
14
+ describe("remember", () => {
15
+ it("drops a redelivery of an id still in the set", () => {
16
+ const seen = Option.getOrThrow(remember([], "Ev1"))
17
+ expect(remember(seen, "Ev1")).toEqual(Option.none())
18
+ expect(Option.getOrThrow(remember(seen, "Ev2"))).toEqual(["Ev1", "Ev2"])
19
+ })
20
+
21
+ it("forgets the oldest once the set is full, so it cannot grow without bound", () => {
22
+ const full = ["a", "b"]
23
+ expect(Option.getOrThrow(remember(full, "c", 2))).toEqual(["b", "c"])
24
+ })
25
+ })