@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
package/package.json
ADDED
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@effect-uai/slack",
|
|
3
|
+
"version": "0.14.0",
|
|
4
|
+
"description": "Slack provider for @effect-uai/core (Messenger capability over Socket Mode and the Web API).",
|
|
5
|
+
"keywords": [
|
|
6
|
+
"agents",
|
|
7
|
+
"ai",
|
|
8
|
+
"bot",
|
|
9
|
+
"effect",
|
|
10
|
+
"messenger",
|
|
11
|
+
"slack"
|
|
12
|
+
],
|
|
13
|
+
"homepage": "https://github.com/betalyra/effect-uai/tree/main/packages/providers/slack",
|
|
14
|
+
"bugs": {
|
|
15
|
+
"url": "https://github.com/betalyra/effect-uai/issues"
|
|
16
|
+
},
|
|
17
|
+
"license": "MIT",
|
|
18
|
+
"author": "Betalyra",
|
|
19
|
+
"repository": {
|
|
20
|
+
"type": "git",
|
|
21
|
+
"url": "https://github.com/betalyra/effect-uai",
|
|
22
|
+
"directory": "packages/providers/slack"
|
|
23
|
+
},
|
|
24
|
+
"files": [
|
|
25
|
+
"dist",
|
|
26
|
+
"src",
|
|
27
|
+
"README.md",
|
|
28
|
+
"LICENSE"
|
|
29
|
+
],
|
|
30
|
+
"type": "module",
|
|
31
|
+
"main": "./dist/index.mjs",
|
|
32
|
+
"types": "./dist/index.d.mts",
|
|
33
|
+
"exports": {
|
|
34
|
+
".": {
|
|
35
|
+
"types": "./dist/index.d.mts",
|
|
36
|
+
"import": "./dist/index.mjs"
|
|
37
|
+
},
|
|
38
|
+
"./Slack": {
|
|
39
|
+
"types": "./dist/Slack.d.mts",
|
|
40
|
+
"import": "./dist/Slack.mjs"
|
|
41
|
+
}
|
|
42
|
+
},
|
|
43
|
+
"publishConfig": {
|
|
44
|
+
"access": "public"
|
|
45
|
+
},
|
|
46
|
+
"devDependencies": {
|
|
47
|
+
"effect": "4.0.0-rc.111",
|
|
48
|
+
"typescript": "^7.0.2",
|
|
49
|
+
"@effect-uai/core": "0.14.0"
|
|
50
|
+
},
|
|
51
|
+
"peerDependencies": {
|
|
52
|
+
"@effect-uai/core": ">=0.15.0 <1",
|
|
53
|
+
"effect": ">=4.0.0-rc.111 <5.0.0"
|
|
54
|
+
},
|
|
55
|
+
"scripts": {
|
|
56
|
+
"build": "tsdown",
|
|
57
|
+
"typecheck": "tsc --noEmit"
|
|
58
|
+
}
|
|
59
|
+
}
|
package/src/Slack.ts
ADDED
|
@@ -0,0 +1,419 @@
|
|
|
1
|
+
import {
|
|
2
|
+
Array as Arr,
|
|
3
|
+
type Cause,
|
|
4
|
+
Context,
|
|
5
|
+
Effect,
|
|
6
|
+
Encoding,
|
|
7
|
+
Layer,
|
|
8
|
+
Match,
|
|
9
|
+
Option,
|
|
10
|
+
Queue,
|
|
11
|
+
Schema,
|
|
12
|
+
type Scope,
|
|
13
|
+
Stream,
|
|
14
|
+
} from "effect"
|
|
15
|
+
import { HttpClient } from "effect/unstable/http"
|
|
16
|
+
import type { MediaSource } from "@effect-uai/core/Media"
|
|
17
|
+
import {
|
|
18
|
+
CurrentConversation,
|
|
19
|
+
type ConversationRef,
|
|
20
|
+
type InboundEvent,
|
|
21
|
+
MessageId,
|
|
22
|
+
Messenger,
|
|
23
|
+
type MessengerLimits,
|
|
24
|
+
type MessengerService,
|
|
25
|
+
type Outbound,
|
|
26
|
+
type OutboundBody,
|
|
27
|
+
} from "@effect-uai/core/Messenger"
|
|
28
|
+
import {
|
|
29
|
+
type StreamViaEditsOptions,
|
|
30
|
+
splitForLimit,
|
|
31
|
+
streamViaEdits,
|
|
32
|
+
} from "@effect-uai/core/MessengerAdapter"
|
|
33
|
+
import * as MessengerError from "@effect-uai/core/MessengerError"
|
|
34
|
+
import * as Api from "./internal/api.js"
|
|
35
|
+
import * as Events from "./internal/events.js"
|
|
36
|
+
import * as SocketMode from "./internal/socket.js"
|
|
37
|
+
|
|
38
|
+
export type { BotIdentity, ReplyIn } from "./internal/events.js"
|
|
39
|
+
export { classifyDisconnect, type DisconnectAction } from "./internal/socket.js"
|
|
40
|
+
|
|
41
|
+
// ---------------------------------------------------------------------------
|
|
42
|
+
// Public types
|
|
43
|
+
// ---------------------------------------------------------------------------
|
|
44
|
+
|
|
45
|
+
export type Config = Api.Config & {
|
|
46
|
+
/**
|
|
47
|
+
* Where a top-level mention is answered. Default `"thread"`, which keeps a
|
|
48
|
+
* conversation out of the channel and lets follow-ups inside it map back.
|
|
49
|
+
*/
|
|
50
|
+
readonly replyIn?: Events.ReplyIn
|
|
51
|
+
/** Tuning for `stream`, which is post-then-edit on Slack. */
|
|
52
|
+
readonly stream?: StreamViaEditsOptions
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export type SlackService = MessengerService & {
|
|
56
|
+
/** From `auth.test` at layer build: the identity behind `addressed`. */
|
|
57
|
+
readonly bot: Events.BotIdentity
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Provider-typed tag. Yield this for the bot identity; yield the generic
|
|
62
|
+
* `Messenger` tag for provider-portable code. Both are registered by {@link layer}.
|
|
63
|
+
*/
|
|
64
|
+
export class Slack extends Context.Service<Slack, SlackService>()(
|
|
65
|
+
"@betalyra/effect-uai/providers/slack/Slack",
|
|
66
|
+
) {}
|
|
67
|
+
|
|
68
|
+
/** `markdown_text` allows far more, but a chat message past this is unreadable. */
|
|
69
|
+
export const limits: MessengerLimits = { maxText: 4000, maxCaption: 4000 }
|
|
70
|
+
|
|
71
|
+
// `chat.update` is Tier 3, so an edit a second is comfortably inside it.
|
|
72
|
+
const defaultStream: StreamViaEditsOptions = { every: "1200 millis" }
|
|
73
|
+
|
|
74
|
+
// ---------------------------------------------------------------------------
|
|
75
|
+
// Wire results
|
|
76
|
+
// ---------------------------------------------------------------------------
|
|
77
|
+
|
|
78
|
+
const AuthTest = Schema.Struct({
|
|
79
|
+
user_id: Schema.String,
|
|
80
|
+
bot_id: Schema.String,
|
|
81
|
+
team_id: Schema.String,
|
|
82
|
+
})
|
|
83
|
+
const Opened = Schema.Struct({ url: Schema.String })
|
|
84
|
+
const Posted = Schema.Struct({ ts: Schema.String })
|
|
85
|
+
const UploadUrl = Schema.Struct({ upload_url: Schema.String, file_id: Schema.String })
|
|
86
|
+
|
|
87
|
+
// `shares` nests visibility, then channel, then the messages the file appears in.
|
|
88
|
+
const Shares = Schema.Record(
|
|
89
|
+
Schema.String,
|
|
90
|
+
Schema.Record(Schema.String, Schema.Array(Schema.Struct({ ts: Schema.optional(Schema.String) }))),
|
|
91
|
+
)
|
|
92
|
+
const Completed = Schema.Struct({
|
|
93
|
+
files: Schema.Array(Schema.Struct({ id: Schema.String, shares: Schema.optional(Shares) })),
|
|
94
|
+
})
|
|
95
|
+
type Completed = typeof Completed.Type
|
|
96
|
+
|
|
97
|
+
const RawCall = Schema.Struct({
|
|
98
|
+
method: Schema.String,
|
|
99
|
+
params: Schema.optional(Schema.Record(Schema.String, Schema.Unknown)),
|
|
100
|
+
})
|
|
101
|
+
|
|
102
|
+
/** The message a file was shared as, when Slack reports one. */
|
|
103
|
+
const shareTs = (uploaded: Completed): Option.Option<string> =>
|
|
104
|
+
Arr.head(uploaded.files).pipe(
|
|
105
|
+
Option.flatMap((file) =>
|
|
106
|
+
Arr.findFirst(
|
|
107
|
+
Object.values(file.shares ?? {}).flatMap((byChannel) => Object.values(byChannel).flat()),
|
|
108
|
+
(share) => share.ts !== undefined,
|
|
109
|
+
),
|
|
110
|
+
),
|
|
111
|
+
Option.flatMap((share) => Option.fromNullishOr(share.ts)),
|
|
112
|
+
)
|
|
113
|
+
|
|
114
|
+
// ---------------------------------------------------------------------------
|
|
115
|
+
// Errors
|
|
116
|
+
// ---------------------------------------------------------------------------
|
|
117
|
+
|
|
118
|
+
const isApiFailure = (e: Api.ApiError): e is Api.ApiFailure => e._tag === "SlackApiFailure"
|
|
119
|
+
|
|
120
|
+
const fails = (needle: string) => (e: Api.ApiError) => isApiFailure(e) && e.error === needle
|
|
121
|
+
|
|
122
|
+
const reasonOf = (e: Api.ApiError): string => (isApiFailure(e) ? e.error : "rate limited")
|
|
123
|
+
|
|
124
|
+
/** An API rejection becomes the verb's `MessengerRequestFailed`; typed errors pass through. */
|
|
125
|
+
const requestFailed =
|
|
126
|
+
(operation: MessengerError.MessengerOperation) =>
|
|
127
|
+
(e: Api.ApiError | MessengerError.MessengerError): MessengerError.MessengerError =>
|
|
128
|
+
Match.value(e).pipe(
|
|
129
|
+
Match.tag(
|
|
130
|
+
"SlackApiFailure",
|
|
131
|
+
(failure) =>
|
|
132
|
+
new MessengerError.MessengerRequestFailed({
|
|
133
|
+
provider: Api.provider,
|
|
134
|
+
operation,
|
|
135
|
+
reason: failure.error,
|
|
136
|
+
raw: failure.raw,
|
|
137
|
+
}),
|
|
138
|
+
),
|
|
139
|
+
Match.orElse((typed) => typed),
|
|
140
|
+
)
|
|
141
|
+
|
|
142
|
+
const unsupported = (capability: string, reason: string) =>
|
|
143
|
+
new MessengerError.MessengerUnsupported({ provider: Api.provider, capability, reason })
|
|
144
|
+
|
|
145
|
+
// ---------------------------------------------------------------------------
|
|
146
|
+
// Outbound params
|
|
147
|
+
// ---------------------------------------------------------------------------
|
|
148
|
+
|
|
149
|
+
type MediaFields = Extract<OutboundBody, { _tag: "Media" }>
|
|
150
|
+
|
|
151
|
+
/**
|
|
152
|
+
* Slack's "reply" is posting in the thread, so a `replyTo` outside one opens a
|
|
153
|
+
* thread on that message and inside one is already named by the conversation.
|
|
154
|
+
*/
|
|
155
|
+
const target = (at: ConversationRef, msg: Outbound): Api.Params => {
|
|
156
|
+
const thread = at.thread ?? msg.replyTo
|
|
157
|
+
return { channel: at.channel, ...(thread !== undefined && { thread_ts: thread }) }
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
const defaultFilename = (media: MediaSource): string =>
|
|
161
|
+
`file.${media.mimeType?.split("/")[1] ?? "bin"}`
|
|
162
|
+
|
|
163
|
+
const invalidMedia = (raw: unknown) =>
|
|
164
|
+
new MessengerError.MessengerRequestFailed({
|
|
165
|
+
provider: Api.provider,
|
|
166
|
+
operation: "post",
|
|
167
|
+
reason: "invalid base64 media",
|
|
168
|
+
raw,
|
|
169
|
+
})
|
|
170
|
+
|
|
171
|
+
// `:eyes:` and `eyes` name the same reaction; Slack wants it bare. Only the
|
|
172
|
+
// outer colons go, so a skin tone modifier survives.
|
|
173
|
+
const shortcode = (emoji: string): string => emoji.replace(/^:|:$/g, "")
|
|
174
|
+
|
|
175
|
+
// ---------------------------------------------------------------------------
|
|
176
|
+
// Service
|
|
177
|
+
// ---------------------------------------------------------------------------
|
|
178
|
+
|
|
179
|
+
export const make = (
|
|
180
|
+
cfg: Config,
|
|
181
|
+
): Effect.Effect<
|
|
182
|
+
SlackService,
|
|
183
|
+
MessengerError.MessengerConnectFailed,
|
|
184
|
+
HttpClient.HttpClient | Scope.Scope
|
|
185
|
+
> =>
|
|
186
|
+
Effect.gen(function* () {
|
|
187
|
+
// Captured once so the verbs need no `HttpClient` of their own.
|
|
188
|
+
const client = yield* HttpClient.HttpClient
|
|
189
|
+
const withClient = Effect.provideService(HttpClient.HttpClient, client)
|
|
190
|
+
const call = (method: string, params?: Api.Params) => withClient(Api.call(cfg)(method, params))
|
|
191
|
+
const query = (method: string, params: Api.Params) => withClient(Api.query(cfg)(method, params))
|
|
192
|
+
|
|
193
|
+
const connectFailed = (e: Api.ApiError) =>
|
|
194
|
+
new MessengerError.MessengerConnectFailed({
|
|
195
|
+
provider: Api.provider,
|
|
196
|
+
reason: reasonOf(e),
|
|
197
|
+
raw: isApiFailure(e) ? e.raw : e,
|
|
198
|
+
})
|
|
199
|
+
|
|
200
|
+
// A bad bot token fails here rather than as a socket close a moment later.
|
|
201
|
+
const identity = yield* call("auth.test").pipe(
|
|
202
|
+
Api.decoded("auth.test", AuthTest),
|
|
203
|
+
Effect.mapError(connectFailed),
|
|
204
|
+
)
|
|
205
|
+
const bot: Events.BotIdentity = {
|
|
206
|
+
userId: identity.user_id,
|
|
207
|
+
botId: identity.bot_id,
|
|
208
|
+
teamId: identity.team_id,
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
// -- inbound -----------------------------------------------------------
|
|
212
|
+
|
|
213
|
+
// Each connection needs its own single-use URL, so this runs again on
|
|
214
|
+
// every reconnect. The app token is used nowhere else.
|
|
215
|
+
const open = withClient(Api.callAsApp(cfg)("apps.connections.open")).pipe(
|
|
216
|
+
Api.decoded("apps.connections.open", Opened),
|
|
217
|
+
Effect.map(({ url }) => url),
|
|
218
|
+
Effect.mapError(
|
|
219
|
+
(e): MessengerError.MessengerError =>
|
|
220
|
+
new MessengerError.MessengerConnectFailed({
|
|
221
|
+
provider: Api.provider,
|
|
222
|
+
reason: reasonOf(e),
|
|
223
|
+
raw: isApiFailure(e) ? e.raw : e,
|
|
224
|
+
}),
|
|
225
|
+
),
|
|
226
|
+
)
|
|
227
|
+
|
|
228
|
+
const session = yield* SocketMode.connect({ open })
|
|
229
|
+
const toEvents = Events.toEvents({ bot, replyIn: cfg.replyIn ?? "thread" })
|
|
230
|
+
const inbox = yield* Queue.unbounded<InboundEvent, MessengerError.MessengerError | Cause.Done>()
|
|
231
|
+
|
|
232
|
+
yield* Stream.fromQueue(session.envelopes).pipe(
|
|
233
|
+
Stream.runForEach(({ envelope, raw }) => Queue.offerAll(inbox, toEvents(envelope, raw))),
|
|
234
|
+
Effect.catch((closed) => Queue.fail(inbox, closed)),
|
|
235
|
+
Effect.ensuring(Queue.end(inbox)),
|
|
236
|
+
Effect.forkScoped,
|
|
237
|
+
)
|
|
238
|
+
|
|
239
|
+
// -- outbound ----------------------------------------------------------
|
|
240
|
+
|
|
241
|
+
const sendRaw = (payload: unknown) =>
|
|
242
|
+
Effect.gen(function* () {
|
|
243
|
+
const { method, params } = yield* Schema.decodeUnknownEffect(RawCall)(payload).pipe(
|
|
244
|
+
Effect.mapError(
|
|
245
|
+
() =>
|
|
246
|
+
new MessengerError.MessengerRequestFailed({
|
|
247
|
+
provider: Api.provider,
|
|
248
|
+
operation: "post",
|
|
249
|
+
reason: "raw payload must be { method, params? }",
|
|
250
|
+
raw: payload,
|
|
251
|
+
}),
|
|
252
|
+
),
|
|
253
|
+
)
|
|
254
|
+
return yield* call(method, params)
|
|
255
|
+
})
|
|
256
|
+
|
|
257
|
+
// A URL is not a Slack upload, so it goes as the link and Slack unfurls it.
|
|
258
|
+
const sendUrl = (at: ConversationRef, msg: Outbound, body: MediaFields, url: string) =>
|
|
259
|
+
call("chat.postMessage", {
|
|
260
|
+
...target(at, msg),
|
|
261
|
+
markdown_text: Arr.getSomes([Option.fromNullishOr(body.caption), Option.some(url)]).join(
|
|
262
|
+
"\n",
|
|
263
|
+
),
|
|
264
|
+
}).pipe(
|
|
265
|
+
Api.decoded("chat.postMessage", Posted),
|
|
266
|
+
Effect.map(({ ts }) => ts),
|
|
267
|
+
)
|
|
268
|
+
|
|
269
|
+
// Three steps: reserve a URL, put the bytes there, then share the file into
|
|
270
|
+
// the conversation with the caption as its comment.
|
|
271
|
+
const sendBytes = (
|
|
272
|
+
at: ConversationRef,
|
|
273
|
+
msg: Outbound,
|
|
274
|
+
body: MediaFields,
|
|
275
|
+
bytes: Uint8Array,
|
|
276
|
+
mimeType: string,
|
|
277
|
+
) =>
|
|
278
|
+
Effect.gen(function* () {
|
|
279
|
+
const filename = body.filename ?? defaultFilename(body.media)
|
|
280
|
+
const reserved = yield* query("files.getUploadURLExternal", {
|
|
281
|
+
filename,
|
|
282
|
+
length: bytes.length,
|
|
283
|
+
}).pipe(Api.decoded("files.getUploadURLExternal", UploadUrl))
|
|
284
|
+
yield* withClient(Api.upload(reserved.upload_url, { bytes, filename, mimeType }))
|
|
285
|
+
const uploaded = yield* call("files.completeUploadExternal", {
|
|
286
|
+
files: [{ id: reserved.file_id, title: filename }],
|
|
287
|
+
channel_id: at.channel,
|
|
288
|
+
...target(at, msg),
|
|
289
|
+
...(body.caption !== undefined && { initial_comment: body.caption }),
|
|
290
|
+
}).pipe(Api.decoded("files.completeUploadExternal", Completed))
|
|
291
|
+
// A share reports the message it became; without one the file id is
|
|
292
|
+
// the only handle there is, and a file message cannot be edited anyway.
|
|
293
|
+
return Option.getOrElse(shareTs(uploaded), () => reserved.file_id)
|
|
294
|
+
})
|
|
295
|
+
|
|
296
|
+
const sendMedia = (at: ConversationRef, msg: Outbound, body: MediaFields) =>
|
|
297
|
+
Match.value(body.media).pipe(
|
|
298
|
+
Match.tag("url", ({ url }) => sendUrl(at, msg, body, url)),
|
|
299
|
+
Match.tag("bytes", ({ bytes, mimeType }) => sendBytes(at, msg, body, bytes, mimeType)),
|
|
300
|
+
Match.tag("base64", ({ base64, mimeType }) =>
|
|
301
|
+
Effect.fromResult(Encoding.decodeBase64(base64)).pipe(
|
|
302
|
+
Effect.mapError(invalidMedia),
|
|
303
|
+
Effect.flatMap((bytes) => sendBytes(at, msg, body, bytes, mimeType)),
|
|
304
|
+
),
|
|
305
|
+
),
|
|
306
|
+
Match.exhaustive,
|
|
307
|
+
)
|
|
308
|
+
|
|
309
|
+
// Every chunk lands in the same thread, so only the id of the last matters.
|
|
310
|
+
const sendChunks = (at: ConversationRef, msg: Outbound, body: string) =>
|
|
311
|
+
Effect.gen(function* () {
|
|
312
|
+
const [first, ...rest] = splitForLimit(body, limits.maxText)
|
|
313
|
+
const send = (chunk: string) =>
|
|
314
|
+
call("chat.postMessage", { ...target(at, msg), markdown_text: chunk }).pipe(
|
|
315
|
+
Api.decoded("chat.postMessage", Posted),
|
|
316
|
+
Effect.map(({ ts }) => ts),
|
|
317
|
+
)
|
|
318
|
+
const head = yield* send(first)
|
|
319
|
+
const tail = yield* Effect.forEach(rest, send)
|
|
320
|
+
return Option.getOrElse(Arr.last(tail), () => head)
|
|
321
|
+
})
|
|
322
|
+
|
|
323
|
+
const post: MessengerService["post"] = (msg) =>
|
|
324
|
+
Effect.gen(function* () {
|
|
325
|
+
const at = yield* CurrentConversation
|
|
326
|
+
const ts = yield* Match.value(msg.body).pipe(
|
|
327
|
+
Match.tag("Text", ({ text }) => sendChunks(at, msg, text)),
|
|
328
|
+
Match.tag("Media", (body) => sendMedia(at, msg, body)),
|
|
329
|
+
Match.tag("Raw", ({ payload }) =>
|
|
330
|
+
sendRaw(payload).pipe(
|
|
331
|
+
Api.decoded("raw", Posted),
|
|
332
|
+
Effect.map(({ ts }) => ts),
|
|
333
|
+
),
|
|
334
|
+
),
|
|
335
|
+
Match.exhaustive,
|
|
336
|
+
)
|
|
337
|
+
return MessageId(ts)
|
|
338
|
+
}).pipe(Effect.mapError(requestFailed("post")))
|
|
339
|
+
|
|
340
|
+
const edit: MessengerService["edit"] = (ref, next) =>
|
|
341
|
+
Match.value(next.body).pipe(
|
|
342
|
+
Match.tag("Text", ({ text }) =>
|
|
343
|
+
call("chat.update", {
|
|
344
|
+
channel: ref.conversation.channel,
|
|
345
|
+
ts: ref.id,
|
|
346
|
+
markdown_text: text,
|
|
347
|
+
}).pipe(Effect.asVoid, Effect.mapError(requestFailed("edit"))),
|
|
348
|
+
),
|
|
349
|
+
Match.tag("Raw", ({ payload }) =>
|
|
350
|
+
sendRaw(payload).pipe(Effect.asVoid, Effect.mapError(requestFailed("edit"))),
|
|
351
|
+
),
|
|
352
|
+
Match.tag("Media", () =>
|
|
353
|
+
Effect.fail(unsupported("media edits", "send a new message instead")),
|
|
354
|
+
),
|
|
355
|
+
Match.exhaustive,
|
|
356
|
+
)
|
|
357
|
+
|
|
358
|
+
// The shortcode is the same spelling a `Reaction` event delivers.
|
|
359
|
+
const react: MessengerService["react"] = (ref, emoji) =>
|
|
360
|
+
call("reactions.add", {
|
|
361
|
+
channel: ref.conversation.channel,
|
|
362
|
+
timestamp: ref.id,
|
|
363
|
+
name: shortcode(emoji),
|
|
364
|
+
}).pipe(
|
|
365
|
+
Effect.asVoid,
|
|
366
|
+
Effect.catchIf(fails("invalid_name"), () =>
|
|
367
|
+
Effect.fail(unsupported("reaction", `${emoji} is not an emoji this workspace has`)),
|
|
368
|
+
),
|
|
369
|
+
Effect.mapError(requestFailed("react")),
|
|
370
|
+
)
|
|
371
|
+
|
|
372
|
+
/**
|
|
373
|
+
* Best effort: Slack shows a session status on its agent surfaces only, so
|
|
374
|
+
* a rejection is logged and swallowed rather than taking a turn down.
|
|
375
|
+
*/
|
|
376
|
+
const typing: MessengerService["typing"] = Effect.gen(function* () {
|
|
377
|
+
const at = yield* CurrentConversation
|
|
378
|
+
const status = (state: string) =>
|
|
379
|
+
call("agents.sessions.setStatus", {
|
|
380
|
+
channel_id: at.channel,
|
|
381
|
+
...(at.thread !== undefined && { thread_ts: at.thread }),
|
|
382
|
+
status: state,
|
|
383
|
+
}).pipe(
|
|
384
|
+
Effect.asVoid,
|
|
385
|
+
Effect.catch((e) => Effect.logDebug("slack status not set", { reason: reasonOf(e) })),
|
|
386
|
+
)
|
|
387
|
+
yield* Effect.acquireRelease(status("processing"), () => status("active"))
|
|
388
|
+
})
|
|
389
|
+
|
|
390
|
+
return {
|
|
391
|
+
bot,
|
|
392
|
+
events: Stream.fromQueue(inbox),
|
|
393
|
+
post,
|
|
394
|
+
edit,
|
|
395
|
+
react,
|
|
396
|
+
typing,
|
|
397
|
+
stream: streamViaEdits({ post, edit, limits }, { ...defaultStream, ...cfg.stream }),
|
|
398
|
+
limits,
|
|
399
|
+
}
|
|
400
|
+
})
|
|
401
|
+
|
|
402
|
+
// ---------------------------------------------------------------------------
|
|
403
|
+
// Layer
|
|
404
|
+
// ---------------------------------------------------------------------------
|
|
405
|
+
|
|
406
|
+
/**
|
|
407
|
+
* One Socket Mode connection, registered under both the `Slack` and
|
|
408
|
+
* `Messenger` tags. Building the layer authenticates and waits for `hello`, so
|
|
409
|
+
* a rejected token fails here; the connection lives until the scope closes and
|
|
410
|
+
* `events` ends with it.
|
|
411
|
+
*/
|
|
412
|
+
export const layer = (
|
|
413
|
+
cfg: Config,
|
|
414
|
+
): Layer.Layer<Slack | Messenger, MessengerError.MessengerConnectFailed, HttpClient.HttpClient> =>
|
|
415
|
+
Layer.effectContext(
|
|
416
|
+
Effect.map(make(cfg), (service) =>
|
|
417
|
+
Context.make(Slack, service).pipe(Context.add(Messenger, service)),
|
|
418
|
+
),
|
|
419
|
+
)
|
package/src/index.ts
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export * as Slack from "./Slack.js"
|
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
import { Data, Duration, Effect, Option, Redacted, Schema } from "effect"
|
|
2
|
+
import {
|
|
3
|
+
Headers,
|
|
4
|
+
HttpClient,
|
|
5
|
+
HttpClientRequest,
|
|
6
|
+
type HttpClientResponse,
|
|
7
|
+
} from "effect/unstable/http"
|
|
8
|
+
import * as MessengerError from "@effect-uai/core/MessengerError"
|
|
9
|
+
|
|
10
|
+
export const provider = "slack"
|
|
11
|
+
|
|
12
|
+
export type Config = {
|
|
13
|
+
/** `xoxb-`, for every Web API call. */
|
|
14
|
+
readonly botToken: Redacted.Redacted
|
|
15
|
+
/** `xapp-`, for `apps.connections.open` and nothing else. */
|
|
16
|
+
readonly appToken: Redacted.Redacted
|
|
17
|
+
/** Defaults to `https://slack.com/api`. */
|
|
18
|
+
readonly baseUrl?: string
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* A Web API call that came back `ok: false` or never came back at all.
|
|
23
|
+
* `error` is Slack's own error string (`channel_not_found`, `invalid_auth`);
|
|
24
|
+
* each verb maps this onto its own `MessengerError`.
|
|
25
|
+
*/
|
|
26
|
+
export class ApiFailure extends Data.TaggedError("SlackApiFailure")<{
|
|
27
|
+
readonly method: string
|
|
28
|
+
readonly status?: number
|
|
29
|
+
readonly error: string
|
|
30
|
+
readonly raw: unknown
|
|
31
|
+
}> {}
|
|
32
|
+
|
|
33
|
+
export type ApiError = ApiFailure | MessengerError.MessengerRateLimited
|
|
34
|
+
|
|
35
|
+
// Slack answers every method with `ok`, then the result fields beside it.
|
|
36
|
+
const Envelope = Schema.Struct({
|
|
37
|
+
ok: Schema.Boolean,
|
|
38
|
+
error: Schema.optional(Schema.String),
|
|
39
|
+
})
|
|
40
|
+
|
|
41
|
+
export type Params = Readonly<Record<string, unknown>>
|
|
42
|
+
|
|
43
|
+
/** The bytes behind a `files.getUploadURLExternal` URL. */
|
|
44
|
+
export type Upload = {
|
|
45
|
+
readonly bytes: Uint8Array
|
|
46
|
+
readonly filename: string
|
|
47
|
+
readonly mimeType: string
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
const endpoint = (cfg: Config, method: string): string =>
|
|
51
|
+
`${cfg.baseUrl ?? "https://slack.com/api"}/${method}`
|
|
52
|
+
|
|
53
|
+
const authorized = (
|
|
54
|
+
request: HttpClientRequest.HttpClientRequest,
|
|
55
|
+
token: Redacted.Redacted,
|
|
56
|
+
): HttpClientRequest.HttpClientRequest =>
|
|
57
|
+
HttpClientRequest.setHeader(request, "Authorization", `Bearer ${Redacted.value(token)}`)
|
|
58
|
+
|
|
59
|
+
const transportFailure = (method: string) => (raw: unknown) =>
|
|
60
|
+
new ApiFailure({ method, error: "transport failure", raw })
|
|
61
|
+
|
|
62
|
+
// A 429 carries the wait in a header rather than the body, and Slack's own
|
|
63
|
+
// floor for a missing one is a minute.
|
|
64
|
+
const retryAfter = (headers: Headers.Headers): Duration.Duration =>
|
|
65
|
+
Headers.get(headers, "retry-after").pipe(
|
|
66
|
+
Option.map(Number),
|
|
67
|
+
Option.filter(Number.isFinite),
|
|
68
|
+
Option.getOrElse(() => 60),
|
|
69
|
+
Duration.seconds,
|
|
70
|
+
)
|
|
71
|
+
|
|
72
|
+
const send = (
|
|
73
|
+
method: string,
|
|
74
|
+
request: HttpClientRequest.HttpClientRequest,
|
|
75
|
+
): Effect.Effect<unknown, ApiError, HttpClient.HttpClient> =>
|
|
76
|
+
Effect.gen(function* () {
|
|
77
|
+
const client = yield* HttpClient.HttpClient
|
|
78
|
+
const response = yield* client.execute(request).pipe(Effect.mapError(transportFailure(method)))
|
|
79
|
+
if (response.status === 429) {
|
|
80
|
+
return yield* new MessengerError.MessengerRateLimited({
|
|
81
|
+
provider,
|
|
82
|
+
retryAfter: retryAfter(response.headers),
|
|
83
|
+
})
|
|
84
|
+
}
|
|
85
|
+
const body = yield* response.json.pipe(Effect.mapError(transportFailure(method)))
|
|
86
|
+
const envelope = yield* Schema.decodeUnknownEffect(Envelope)(body).pipe(
|
|
87
|
+
Effect.mapError(() => new ApiFailure({ method, error: "malformed envelope", raw: body })),
|
|
88
|
+
)
|
|
89
|
+
// The result fields sit beside `ok`, so the whole body is the result.
|
|
90
|
+
return envelope.ok
|
|
91
|
+
? body
|
|
92
|
+
: yield* new ApiFailure({
|
|
93
|
+
method,
|
|
94
|
+
status: response.status,
|
|
95
|
+
error: envelope.error ?? "unknown error",
|
|
96
|
+
raw: body,
|
|
97
|
+
})
|
|
98
|
+
})
|
|
99
|
+
|
|
100
|
+
const post = (cfg: Config, method: string, params: Params | undefined) => {
|
|
101
|
+
const request = HttpClientRequest.post(endpoint(cfg, method))
|
|
102
|
+
// A method with no arguments takes no body at all; Slack rejects an empty one.
|
|
103
|
+
return params === undefined ? request : HttpClientRequest.bodyJsonUnsafe(request, params)
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/** One JSON method call with the bot token. The body comes back undecoded. */
|
|
107
|
+
export const call =
|
|
108
|
+
(cfg: Config) =>
|
|
109
|
+
(method: string, params?: Params): Effect.Effect<unknown, ApiError, HttpClient.HttpClient> =>
|
|
110
|
+
send(method, authorized(post(cfg, method, params), cfg.botToken))
|
|
111
|
+
|
|
112
|
+
/** `apps.connections.open`, the only call the app token is for. */
|
|
113
|
+
export const callAsApp =
|
|
114
|
+
(cfg: Config) =>
|
|
115
|
+
(method: string): Effect.Effect<unknown, ApiError, HttpClient.HttpClient> =>
|
|
116
|
+
send(method, authorized(post(cfg, method, undefined), cfg.appToken))
|
|
117
|
+
|
|
118
|
+
/** One method called with query parameters, for the few that take no JSON body. */
|
|
119
|
+
export const query =
|
|
120
|
+
(cfg: Config) =>
|
|
121
|
+
(method: string, params: Params): Effect.Effect<unknown, ApiError, HttpClient.HttpClient> =>
|
|
122
|
+
send(
|
|
123
|
+
method,
|
|
124
|
+
authorized(
|
|
125
|
+
HttpClientRequest.get(endpoint(cfg, method)).pipe(
|
|
126
|
+
HttpClientRequest.setUrlParams(
|
|
127
|
+
Object.entries(params).map(([name, value]) => [name, String(value)] as const),
|
|
128
|
+
),
|
|
129
|
+
),
|
|
130
|
+
cfg.botToken,
|
|
131
|
+
),
|
|
132
|
+
)
|
|
133
|
+
|
|
134
|
+
const uploadRejected = (response: HttpClientResponse.HttpClientResponse) =>
|
|
135
|
+
new ApiFailure({
|
|
136
|
+
method: "files.upload",
|
|
137
|
+
status: response.status,
|
|
138
|
+
error: `upload rejected with HTTP ${response.status}`,
|
|
139
|
+
raw: response.status,
|
|
140
|
+
})
|
|
141
|
+
|
|
142
|
+
/**
|
|
143
|
+
* Raw bytes to the URL `files.getUploadURLExternal` handed out. Not a Web API
|
|
144
|
+
* call: no token, no `ok` envelope, and the response body is plain text.
|
|
145
|
+
*/
|
|
146
|
+
export const upload = (
|
|
147
|
+
url: string,
|
|
148
|
+
file: Upload,
|
|
149
|
+
): Effect.Effect<void, ApiFailure, HttpClient.HttpClient> =>
|
|
150
|
+
Effect.gen(function* () {
|
|
151
|
+
const client = yield* HttpClient.HttpClient
|
|
152
|
+
const response = yield* client
|
|
153
|
+
.execute(
|
|
154
|
+
HttpClientRequest.post(url).pipe(
|
|
155
|
+
HttpClientRequest.bodyUint8Array(file.bytes as Uint8Array<ArrayBuffer>, file.mimeType),
|
|
156
|
+
),
|
|
157
|
+
)
|
|
158
|
+
.pipe(Effect.mapError(transportFailure("files.upload")))
|
|
159
|
+
if (response.status >= 400) return yield* uploadRejected(response)
|
|
160
|
+
})
|
|
161
|
+
|
|
162
|
+
/** Decode a response body; a shape mismatch is a malformed response, not a defect. */
|
|
163
|
+
export const decoded =
|
|
164
|
+
<A>(method: string, schema: Schema.Decoder<A>) =>
|
|
165
|
+
<E, R>(result: Effect.Effect<unknown, E, R>): Effect.Effect<A, E | ApiFailure, R> =>
|
|
166
|
+
Effect.gen(function* () {
|
|
167
|
+
const raw = yield* result
|
|
168
|
+
return yield* Schema.decodeUnknownEffect(schema)(raw).pipe(
|
|
169
|
+
Effect.mapError(() => new ApiFailure({ method, error: "malformed response", raw })),
|
|
170
|
+
)
|
|
171
|
+
})
|