@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,238 @@
1
+ import {
2
+ Array as Arr,
3
+ type Cause,
4
+ Deferred,
5
+ Duration,
6
+ Effect,
7
+ Match,
8
+ Option,
9
+ Queue,
10
+ Ref,
11
+ Schema,
12
+ type Scope,
13
+ } from "effect"
14
+ import * as Socket from "effect/unstable/socket/Socket"
15
+ import * as MessengerError from "@effect-uai/core/MessengerError"
16
+ import * as Events from "./events.js"
17
+ import { provider } from "./api.js"
18
+
19
+ // ---------------------------------------------------------------------------
20
+ // Disconnects
21
+ // ---------------------------------------------------------------------------
22
+
23
+ /** What the session does once a connection is gone. */
24
+ export type DisconnectAction = "refresh" | "reconnect" | "fatal"
25
+
26
+ /**
27
+ * Slack names the one reason that must not be retried; the rolling refresh is
28
+ * scheduled rather than a failure, so it reconnects without backing off, and
29
+ * an unknown reason is an ordinary reconnect.
30
+ */
31
+ export const classifyDisconnect = (reason: string | undefined): DisconnectAction =>
32
+ Match.value(reason).pipe(
33
+ Match.when("link_disabled", (): DisconnectAction => "fatal"),
34
+ Match.when("refresh_requested", (): DisconnectAction => "refresh"),
35
+ Match.when("warning", (): DisconnectAction => "refresh"),
36
+ Match.orElse((): DisconnectAction => "reconnect"),
37
+ )
38
+
39
+ // ---------------------------------------------------------------------------
40
+ // Redelivery
41
+ // ---------------------------------------------------------------------------
42
+
43
+ /**
44
+ * Transport state, not conversation state: Slack redelivers an envelope whose
45
+ * acknowledgement it missed, and this exists only to drop the second copy.
46
+ */
47
+ export type Recent = ReadonlyArray<string>
48
+
49
+ /** How many ids to hold. Redeliveries arrive at once, +1 minute and +5 minutes. */
50
+ export const RECENT = 256
51
+
52
+ /** `None` when `id` has been seen; otherwise the set with `id` in it, oldest dropped. */
53
+ export const remember = (seen: Recent, id: string, cap: number = RECENT): Option.Option<Recent> =>
54
+ seen.includes(id) ? Option.none() : Option.some(Arr.takeRight([...seen, id], cap))
55
+
56
+ // ---------------------------------------------------------------------------
57
+ // Session
58
+ // ---------------------------------------------------------------------------
59
+
60
+ export type Config = {
61
+ /**
62
+ * A fresh single-use Socket Mode URL, called for every connection. Failing
63
+ * before `hello` has ever arrived is what makes a bad app token a connect
64
+ * failure rather than a silent retry.
65
+ */
66
+ readonly open: Effect.Effect<string, MessengerError.MessengerError>
67
+ }
68
+
69
+ /** A decoded envelope beside the frame it came from, which `raw` carries. */
70
+ export type Incoming = {
71
+ readonly envelope: Events.Envelope
72
+ readonly raw: unknown
73
+ }
74
+
75
+ export type Session = {
76
+ /**
77
+ * Envelopes in arrival order, already acknowledged, ended when the scope
78
+ * closes and failed with `MessengerTransportClosed` on a disconnect Slack
79
+ * says not to retry.
80
+ */
81
+ readonly envelopes: Queue.Queue<Incoming, MessengerError.MessengerError | Cause.Done>
82
+ }
83
+
84
+ type Ended = { readonly action: DisconnectAction; readonly reason: string }
85
+
86
+ const parseFrame = Schema.decodeUnknownEffect(Schema.fromJsonString(Schema.Unknown))
87
+ const decodeFrame = Schema.decodeUnknownEffect(Events.Frame)
88
+
89
+ const socketReason = (error: Socket.SocketError): string =>
90
+ error.reason._tag === "SocketCloseError"
91
+ ? (error.reason.closeReason ?? error.reason.message)
92
+ : error.reason.message
93
+
94
+ const reasonOf = (e: MessengerError.MessengerError | Socket.SocketError): string =>
95
+ e._tag === "SocketError" ? socketReason(e) : MessengerError.describe(e)
96
+
97
+ // Capped exponential, from a beat to a minute. Reset once a connection is live.
98
+ const backoff = (attempt: number): Duration.Duration =>
99
+ Duration.millis(Math.min(60_000, 500 * 2 ** attempt))
100
+
101
+ /**
102
+ * One Socket Mode session: open, acknowledge, dedupe, reconnect.
103
+ *
104
+ * Returns once `hello` has arrived, so a rejected app token is a
105
+ * `MessengerConnectFailed` at layer build rather than a stream that dies a
106
+ * moment later. From then on reconnects are silent and only a `link_disabled`
107
+ * disconnect ends `envelopes`.
108
+ */
109
+ export const connect = (
110
+ cfg: Config,
111
+ ): Effect.Effect<Session, MessengerError.MessengerConnectFailed, Scope.Scope> =>
112
+ Effect.gen(function* () {
113
+ const envelopes = yield* Queue.unbounded<Incoming, MessengerError.MessengerError | Cause.Done>()
114
+ const ready = yield* Deferred.make<void, MessengerError.MessengerConnectFailed>()
115
+ const attempts = yield* Ref.make(0)
116
+ const seen = yield* Ref.make<Recent>([])
117
+
118
+ // -- one connection ----------------------------------------------------
119
+
120
+ const once = Effect.gen(function* () {
121
+ const url = yield* cfg.open
122
+ const socket = yield* Socket.makeWebSocket(url, {
123
+ // Effect treats every close as an error by default; the standard clean
124
+ // codes are not, and Slack's close codes say nothing a reconnect
125
+ // cannot fix, so a `disconnect` frame is the only fatal signal.
126
+ closeCodeIsError: (code: number) => code !== 1000 && code !== 1001 && code !== 1005,
127
+ // The ticket is in the URL, so every runtime's global `WebSocket` is enough.
128
+ }).pipe(Effect.provide(Socket.layerWebSocketConstructorGlobal))
129
+ const write = yield* socket.writer
130
+ // Set by a `disconnect` frame, the one end a close code cannot express.
131
+ const requested = yield* Ref.make(Option.none<Ended>())
132
+
133
+ // Acknowledged before the envelope is offered, well inside Slack's three
134
+ // second deadline, so the platform is never waiting on the recipe.
135
+ const acknowledge = (envelopeId: string) => write(JSON.stringify({ envelope_id: envelopeId }))
136
+
137
+ // A redelivery is acknowledged like any other envelope and then dropped,
138
+ // so Slack stops retrying it and the recipe never sees it twice.
139
+ const offer = (envelope: Events.Envelope, raw: unknown) =>
140
+ Effect.gen(function* () {
141
+ yield* acknowledge(envelope.envelope_id)
142
+ const id = Events.eventId(envelope)
143
+ if (Option.isSome(id)) {
144
+ const next = remember(yield* Ref.get(seen), id.value)
145
+ if (Option.isNone(next)) return
146
+ yield* Ref.set(seen, next.value)
147
+ }
148
+ yield* Queue.offer(envelopes, { envelope, raw })
149
+ })
150
+
151
+ const disconnect = (reason: string | undefined) =>
152
+ Effect.gen(function* () {
153
+ const action = classifyDisconnect(reason)
154
+ yield* Ref.set(requested, Option.some<Ended>({ action, reason: reason ?? "disconnect" }))
155
+ yield* write(new Socket.CloseEvent(1000, reason ?? "disconnect"))
156
+ })
157
+
158
+ const handle = (text: string) =>
159
+ Effect.gen(function* () {
160
+ const raw = yield* parseFrame(text).pipe(Effect.option)
161
+ if (Option.isNone(raw)) return
162
+ const frame = yield* decodeFrame(raw.value).pipe(Effect.option)
163
+ if (Option.isNone(frame)) return
164
+ yield* Match.value(frame.value).pipe(
165
+ Match.when({ type: "hello" }, () =>
166
+ Ref.set(attempts, 0).pipe(Effect.andThen(Deferred.succeed(ready, undefined))),
167
+ ),
168
+ Match.when({ type: "disconnect" }, ({ reason }) => disconnect(reason)),
169
+ Match.orElse((envelope) => offer(envelope, raw.value)),
170
+ )
171
+ }).pipe(Effect.ignore)
172
+
173
+ // A clean 1000 is only ever our own teardown or a disconnect we asked
174
+ // for; anything else is a drop, and every drop reconnects.
175
+ const ended = yield* socket.runString(handle).pipe(
176
+ Effect.as<Ended>({ action: "reconnect", reason: "closed" }),
177
+ Effect.catch((error: Socket.SocketError) =>
178
+ Effect.succeed<Ended>({ action: "reconnect", reason: reasonOf(error) }),
179
+ ),
180
+ )
181
+ return Option.getOrElse(yield* Ref.get(requested), () => ended)
182
+ }).pipe(
183
+ Effect.scoped,
184
+ // A URL we cannot get, or a socket that will not open, is fatal only
185
+ // before `hello`: a rejected app token fails the layer, while the same
186
+ // failure later is one more reconnect.
187
+ Effect.catch((e: MessengerError.MessengerError | Socket.SocketError) =>
188
+ Effect.map(Deferred.isDone(ready), (live): Ended => ({
189
+ action: live ? "reconnect" : "fatal",
190
+ reason: reasonOf(e),
191
+ })),
192
+ ),
193
+ )
194
+
195
+ // -- the reconnect loop ------------------------------------------------
196
+
197
+ const cycle = Effect.gen(function* () {
198
+ const ended = yield* once
199
+ if (ended.action === "fatal") {
200
+ return yield* new MessengerError.MessengerTransportClosed({
201
+ provider,
202
+ reason: ended.reason,
203
+ })
204
+ }
205
+ const attempt = yield* Ref.get(attempts)
206
+ yield* Ref.set(attempts, attempt + 1)
207
+ // A refresh is Slack cycling the connection on schedule, so the next one
208
+ // opens at once; anything else backs off.
209
+ const wait = ended.action === "refresh" ? Duration.zero : backoff(attempt)
210
+ yield* Effect.logDebug("socket mode reconnecting", {
211
+ reason: ended.reason,
212
+ action: ended.action,
213
+ attempt,
214
+ in: Duration.toSeconds(wait),
215
+ })
216
+ yield* Effect.sleep(wait)
217
+ })
218
+
219
+ // Failing the deferred is a no-op once `hello` has landed, so the same end
220
+ // is a connect failure before it and a transport close after.
221
+ yield* Effect.forever(cycle).pipe(
222
+ Effect.catch((closed: MessengerError.MessengerTransportClosed) =>
223
+ Deferred.fail(
224
+ ready,
225
+ new MessengerError.MessengerConnectFailed({
226
+ provider,
227
+ reason: closed.reason,
228
+ raw: closed.raw,
229
+ }),
230
+ ).pipe(Effect.andThen(Queue.fail(envelopes, closed))),
231
+ ),
232
+ Effect.ensuring(Queue.end(envelopes)),
233
+ Effect.forkScoped,
234
+ )
235
+
236
+ yield* Deferred.await(ready)
237
+ return { envelopes }
238
+ })