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