@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.
- package/LICENSE +21 -0
- package/README.md +51 -0
- package/dist/Slack.d.mts +43 -0
- package/dist/Slack.d.mts.map +1 -0
- package/dist/Slack.mjs +215 -0
- package/dist/Slack.mjs.map +1 -0
- package/dist/api-BBgliKo7.d.mts +51 -0
- package/dist/api-BBgliKo7.d.mts.map +1 -0
- package/dist/events-Cj5RRjK1.d.mts +317 -0
- package/dist/events-Cj5RRjK1.d.mts.map +1 -0
- package/dist/index.d.mts +2 -0
- package/dist/index.mjs +2 -0
- package/dist/internal/api.d.mts +2 -0
- package/dist/internal/api.mjs +79 -0
- package/dist/internal/api.mjs.map +1 -0
- package/dist/internal/events.d.mts +2 -0
- package/dist/internal/events.mjs +184 -0
- package/dist/internal/events.mjs.map +1 -0
- package/dist/internal/events.test.d.mts +1 -0
- package/dist/internal/events.test.mjs +174 -0
- package/dist/internal/events.test.mjs.map +1 -0
- package/dist/internal/socket.d.mts +2 -0
- package/dist/internal/socket.mjs +109 -0
- package/dist/internal/socket.mjs.map +1 -0
- package/dist/internal/socket.test.d.mts +1 -0
- package/dist/internal/socket.test.mjs +25 -0
- package/dist/internal/socket.test.mjs.map +1 -0
- package/dist/magic-string.es-CUAnjKYC.mjs +1014 -0
- package/dist/magic-string.es-CUAnjKYC.mjs.map +1 -0
- package/dist/rolldown-runtime-D7D4PA-g.mjs +13 -0
- package/dist/socket-DJgdt_ka.d.mts +54 -0
- package/dist/socket-DJgdt_ka.d.mts.map +1 -0
- package/dist/test.DNmyFkvJ-N3QqMRwZ.mjs +13658 -0
- package/dist/test.DNmyFkvJ-N3QqMRwZ.mjs.map +1 -0
- package/package.json +59 -0
- package/src/Slack.ts +419 -0
- package/src/index.ts +1 -0
- package/src/internal/api.ts +171 -0
- package/src/internal/events.test.ts +121 -0
- package/src/internal/events.ts +330 -0
- package/src/internal/socket.test.ts +25 -0
- 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
|
+
})
|