@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
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
+ })