@effect-uai/discord 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 +48 -0
  3. package/dist/Discord.d.mts +55 -0
  4. package/dist/Discord.d.mts.map +1 -0
  5. package/dist/Discord.mjs +196 -0
  6. package/dist/Discord.mjs.map +1 -0
  7. package/dist/events-Du1u-kVV.d.mts +172 -0
  8. package/dist/events-Du1u-kVV.d.mts.map +1 -0
  9. package/dist/gateway-C_Ae1HYB.d.mts +57 -0
  10. package/dist/gateway-C_Ae1HYB.d.mts.map +1 -0
  11. package/dist/index.d.mts +2 -0
  12. package/dist/index.mjs +2 -0
  13. package/dist/internal/events.d.mts +2 -0
  14. package/dist/internal/events.mjs +109 -0
  15. package/dist/internal/events.mjs.map +1 -0
  16. package/dist/internal/events.test.d.mts +1 -0
  17. package/dist/internal/events.test.mjs +130 -0
  18. package/dist/internal/events.test.mjs.map +1 -0
  19. package/dist/internal/gateway.d.mts +2 -0
  20. package/dist/internal/gateway.mjs +216 -0
  21. package/dist/internal/gateway.mjs.map +1 -0
  22. package/dist/internal/gateway.test.d.mts +1 -0
  23. package/dist/internal/gateway.test.mjs +40 -0
  24. package/dist/internal/gateway.test.mjs.map +1 -0
  25. package/dist/internal/rest.d.mts +2 -0
  26. package/dist/internal/rest.mjs +89 -0
  27. package/dist/internal/rest.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/rest-CZSW-VJK.d.mts +44 -0
  31. package/dist/rest-CZSW-VJK.d.mts.map +1 -0
  32. package/dist/rolldown-runtime-D7D4PA-g.mjs +13 -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/Discord.ts +393 -0
  37. package/src/index.ts +1 -0
  38. package/src/internal/events.test.ts +89 -0
  39. package/src/internal/events.ts +197 -0
  40. package/src/internal/gateway.test.ts +27 -0
  41. package/src/internal/gateway.ts +368 -0
  42. package/src/internal/rest.ts +164 -0
@@ -0,0 +1,197 @@
1
+ import { Array as Arr, Match, Option, Schema } from "effect"
2
+ import {
3
+ ChannelId,
4
+ type ConversationRef,
5
+ InboundEvent,
6
+ MessageId,
7
+ UserId,
8
+ } from "@effect-uai/core/Messenger"
9
+
10
+ // ---------------------------------------------------------------------------
11
+ // Wire shapes: the fields this adapter reads. The rest survives on `raw`.
12
+ // ---------------------------------------------------------------------------
13
+
14
+ export const User = Schema.Struct({
15
+ id: Schema.String,
16
+ username: Schema.optional(Schema.String),
17
+ bot: Schema.optional(Schema.Boolean),
18
+ })
19
+ export type User = typeof User.Type
20
+
21
+ /** Unicode reactions carry a `name` and a null `id`; custom ones carry both. */
22
+ export const Emoji = Schema.Struct({
23
+ id: Schema.optional(Schema.NullOr(Schema.String)),
24
+ name: Schema.optional(Schema.NullOr(Schema.String)),
25
+ })
26
+ export type Emoji = typeof Emoji.Type
27
+
28
+ export const ReplyTarget = Schema.Struct({
29
+ id: Schema.String,
30
+ author: Schema.optional(User),
31
+ })
32
+ export type ReplyTarget = typeof ReplyTarget.Type
33
+
34
+ export const MessageCreate = Schema.Struct({
35
+ id: Schema.String,
36
+ channel_id: Schema.String,
37
+ // Absent in a DM, which is how a DM is recognised.
38
+ guild_id: Schema.optional(Schema.String),
39
+ author: User,
40
+ content: Schema.optional(Schema.String),
41
+ mentions: Schema.optional(Schema.Array(User)),
42
+ referenced_message: Schema.optional(Schema.NullOr(ReplyTarget)),
43
+ })
44
+ export type MessageCreate = typeof MessageCreate.Type
45
+
46
+ export const ReactionAdd = Schema.Struct({
47
+ user_id: Schema.String,
48
+ channel_id: Schema.String,
49
+ message_id: Schema.String,
50
+ emoji: Emoji,
51
+ })
52
+ export type ReactionAdd = typeof ReactionAdd.Type
53
+
54
+ export const InteractionData = Schema.Struct({
55
+ custom_id: Schema.optional(Schema.String),
56
+ values: Schema.optional(Schema.Array(Schema.String)),
57
+ })
58
+ export type InteractionData = typeof InteractionData.Type
59
+
60
+ export const InteractionCreate = Schema.Struct({
61
+ id: Schema.String,
62
+ token: Schema.String,
63
+ /** 1 ping, 2 slash command, 3 message component, 4 autocomplete, 5 modal. */
64
+ type: Schema.Number,
65
+ channel_id: Schema.optional(Schema.String),
66
+ user: Schema.optional(User),
67
+ member: Schema.optional(Schema.Struct({ user: Schema.optional(User) })),
68
+ data: Schema.optional(InteractionData),
69
+ })
70
+ export type InteractionCreate = typeof InteractionCreate.Type
71
+
72
+ /** The component interaction type; every other one is ignored in v1. */
73
+ export const MESSAGE_COMPONENT = 3
74
+
75
+ /**
76
+ * The dispatches this adapter reads, discriminated by the gateway's `t`.
77
+ * Anything else fails to decode and is dropped by the session.
78
+ */
79
+ export const Dispatch = Schema.Union([
80
+ Schema.Struct({ t: Schema.Literal("MESSAGE_CREATE"), d: MessageCreate }),
81
+ Schema.Struct({ t: Schema.Literal("MESSAGE_REACTION_ADD"), d: ReactionAdd }),
82
+ Schema.Struct({ t: Schema.Literal("INTERACTION_CREATE"), d: InteractionCreate }),
83
+ ])
84
+ export type Dispatch = typeof Dispatch.Type
85
+
86
+ export type BotIdentity = {
87
+ readonly id: string
88
+ readonly username: string
89
+ }
90
+
91
+ // ---------------------------------------------------------------------------
92
+ // Mapping
93
+ // ---------------------------------------------------------------------------
94
+
95
+ // A Discord thread is a channel of its own, so `thread` is never set here and
96
+ // `conversationKey` still separates a thread from its parent.
97
+ const conversation = (channelId: string): ConversationRef => ({ channel: ChannelId(channelId) })
98
+
99
+ const mentionsBot = (bot: BotIdentity, message: MessageCreate): boolean =>
100
+ (message.mentions ?? []).some((user) => user.id === bot.id)
101
+
102
+ // `@everyone` and role mentions are not in `mentions`, so they never address.
103
+ const isAddressed = (bot: BotIdentity, message: MessageCreate): boolean =>
104
+ message.guild_id === undefined ||
105
+ mentionsBot(bot, message) ||
106
+ message.referenced_message?.author?.id === bot.id
107
+
108
+ /** Both mention spellings of the bot removed; other users' mentions stay. */
109
+ const withoutMention = (bot: BotIdentity, text: string): string =>
110
+ text.replaceAll(`<@${bot.id}>`, "").replaceAll(`<@!${bot.id}>`, "").trim()
111
+
112
+ const fromMessage = (
113
+ bot: BotIdentity,
114
+ message: MessageCreate,
115
+ raw: unknown,
116
+ ): Option.Option<InboundEvent> =>
117
+ // Bot authors are dropped outright, so two bots cannot answer each other.
118
+ Option.liftPredicate(message, (m) => m.author.bot !== true).pipe(
119
+ Option.map((m) =>
120
+ InboundEvent.Message({
121
+ conversation: conversation(m.channel_id),
122
+ id: MessageId(m.id),
123
+ author: UserId(m.author.id),
124
+ text: withoutMention(bot, m.content ?? ""),
125
+ addressed: isAddressed(bot, m),
126
+ ...(m.referenced_message != null && { replyTo: MessageId(m.referenced_message.id) }),
127
+ raw,
128
+ }),
129
+ ),
130
+ )
131
+
132
+ /** Unicode reactions are their own name; custom ones are `name:id`, as REST wants. */
133
+ const emojiName = (emoji: Emoji): Option.Option<string> =>
134
+ Option.map(Option.fromNullishOr(emoji.name), (name) =>
135
+ emoji.id == null ? name : `${name}:${emoji.id}`,
136
+ )
137
+
138
+ const fromReaction = (
139
+ bot: BotIdentity,
140
+ reaction: ReactionAdd,
141
+ raw: unknown,
142
+ ): Option.Option<InboundEvent> =>
143
+ Option.liftPredicate(reaction, (r) => r.user_id !== bot.id).pipe(
144
+ Option.flatMap((r) =>
145
+ Option.map(emojiName(r.emoji), (emoji) =>
146
+ InboundEvent.Reaction({
147
+ conversation: conversation(r.channel_id),
148
+ message: MessageId(r.message_id),
149
+ emoji,
150
+ author: UserId(r.user_id),
151
+ raw,
152
+ }),
153
+ ),
154
+ ),
155
+ )
156
+
157
+ const interactionAuthor = (interaction: InteractionCreate): Option.Option<string> =>
158
+ Option.fromNullishOr(interaction.user?.id ?? interaction.member?.user?.id)
159
+
160
+ /** A select delivers several values; the shared shape carries them comma-joined. */
161
+ const fromInteraction = (
162
+ interaction: InteractionCreate,
163
+ raw: unknown,
164
+ ): Option.Option<InboundEvent> =>
165
+ Option.liftPredicate(interaction, (i) => i.type === MESSAGE_COMPONENT).pipe(
166
+ Option.flatMap((i) =>
167
+ Option.all([Option.fromNullishOr(i.channel_id), interactionAuthor(i)]).pipe(
168
+ Option.map(([channelId, author]) =>
169
+ InboundEvent.Action({
170
+ conversation: conversation(channelId),
171
+ actionId: i.data?.custom_id ?? "",
172
+ ...(i.data?.values !== undefined && { value: i.data.values.join(",") }),
173
+ author: UserId(author),
174
+ raw,
175
+ }),
176
+ ),
177
+ ),
178
+ ),
179
+ )
180
+
181
+ /**
182
+ * Everything one dispatch says to the agent. `raw` is the payload as it came
183
+ * off the wire, since decoding keeps only the fields above. Discord has no
184
+ * text-command convention, so nothing here becomes a `Command`: slash
185
+ * commands are a follow-up and need an interaction response of their own.
186
+ */
187
+ export const toEvents =
188
+ (bot: BotIdentity) =>
189
+ (dispatch: Dispatch, raw: unknown): ReadonlyArray<InboundEvent> =>
190
+ Arr.fromOption(
191
+ Match.value(dispatch).pipe(
192
+ Match.when({ t: "MESSAGE_CREATE" }, ({ d }) => fromMessage(bot, d, raw)),
193
+ Match.when({ t: "MESSAGE_REACTION_ADD" }, ({ d }) => fromReaction(bot, d, raw)),
194
+ Match.when({ t: "INTERACTION_CREATE" }, ({ d }) => fromInteraction(d, raw)),
195
+ Match.exhaustive,
196
+ ),
197
+ )
@@ -0,0 +1,27 @@
1
+ import { describe, expect, it } from "vitest"
2
+ import { classifyClose, closeReason } from "./gateway.js"
3
+
4
+ describe("classifyClose", () => {
5
+ it("never retries a close reconnecting cannot fix", () => {
6
+ expect([4004, 4010, 4011, 4012, 4013, 4014].map(classifyClose)).toEqual(Array(6).fill("fatal"))
7
+ })
8
+
9
+ it("identifies fresh only where the session itself is gone", () => {
10
+ expect([4007, 4009].map(classifyClose)).toEqual(["reidentify", "reidentify"])
11
+ })
12
+
13
+ it("resumes on everything else, transport drops included", () => {
14
+ // 1006 is a dropped connection, 4000 the code the zombie check sends.
15
+ expect([4000, 4003, 4005, 4008, 1006, 1011].map(classifyClose)).toEqual(Array(6).fill("resume"))
16
+ })
17
+ })
18
+
19
+ describe("closeReason", () => {
20
+ it("names a fatal code, since Discord's close frames carry no reason", () => {
21
+ expect(closeReason(4014, "")).toContain("privileged intent")
22
+ })
23
+
24
+ it("falls back to whatever the socket reported", () => {
25
+ expect(closeReason(1006, "connection reset")).toBe("connection reset")
26
+ })
27
+ })
@@ -0,0 +1,368 @@
1
+ import {
2
+ type Cause,
3
+ Deferred,
4
+ Duration,
5
+ Effect,
6
+ Match,
7
+ Option,
8
+ Queue,
9
+ Redacted,
10
+ Ref,
11
+ Schedule,
12
+ Schema,
13
+ type Scope,
14
+ } from "effect"
15
+ import * as Socket from "effect/unstable/socket/Socket"
16
+ import * as MessengerError from "@effect-uai/core/MessengerError"
17
+ import * as Events from "./events.js"
18
+ import { provider } from "./rest.js"
19
+
20
+ // ---------------------------------------------------------------------------
21
+ // Wire shapes
22
+ // ---------------------------------------------------------------------------
23
+
24
+ const Payload = Schema.Struct({
25
+ op: Schema.Number,
26
+ d: Schema.optional(Schema.Unknown),
27
+ s: Schema.optional(Schema.NullOr(Schema.Number)),
28
+ t: Schema.optional(Schema.NullOr(Schema.String)),
29
+ })
30
+ type Payload = typeof Payload.Type
31
+
32
+ const decodeFrame = Schema.decodeUnknownEffect(Schema.fromJsonString(Payload))
33
+
34
+ const Hello = Schema.Struct({ heartbeat_interval: Schema.Number })
35
+
36
+ export const Ready = Schema.Struct({
37
+ user: Schema.Struct({ id: Schema.String, username: Schema.String }),
38
+ session_id: Schema.String,
39
+ resume_gateway_url: Schema.String,
40
+ })
41
+ export type Ready = typeof Ready.Type
42
+
43
+ /** `GET /gateway/bot`, the only REST call the session needs. */
44
+ export const GatewayInfo = Schema.Struct({ url: Schema.String })
45
+
46
+ // Send: 1 heartbeat, 2 identify, 6 resume. Receive: 0 dispatch, 7 reconnect,
47
+ // 9 invalid session, 10 hello, 11 heartbeat ack.
48
+ const OP = {
49
+ dispatch: 0,
50
+ heartbeat: 1,
51
+ identify: 2,
52
+ resume: 6,
53
+ reconnect: 7,
54
+ invalidSession: 9,
55
+ hello: 10,
56
+ heartbeatAck: 11,
57
+ } as const
58
+
59
+ // ---------------------------------------------------------------------------
60
+ // Close codes
61
+ // ---------------------------------------------------------------------------
62
+
63
+ /** What the session does once a connection is gone. */
64
+ export type CloseAction = "resume" | "reidentify" | "fatal"
65
+
66
+ // Fatal: nothing about reconnecting would change the answer.
67
+ const fatalReasons: Record<number, string> = {
68
+ 4004: "authentication failed: the bot token was rejected",
69
+ 4010: "invalid shard",
70
+ 4011: "sharding required",
71
+ 4012: "invalid API version",
72
+ 4013: "invalid intents",
73
+ 4014: "disallowed intents: enable the privileged intent in the developer portal",
74
+ }
75
+
76
+ // The session is gone but the token is fine: identify fresh.
77
+ const staleSessionCodes = [4007, 4009]
78
+
79
+ /**
80
+ * Everything else, transport drops included, resumes: Discord only names the
81
+ * codes that must not be retried, so an unknown code is a reconnect.
82
+ */
83
+ export const classifyClose = (code: number): CloseAction =>
84
+ Match.value(code).pipe(
85
+ Match.when(
86
+ (c) => c in fatalReasons,
87
+ (): CloseAction => "fatal",
88
+ ),
89
+ Match.when(
90
+ (c) => staleSessionCodes.includes(c),
91
+ (): CloseAction => "reidentify",
92
+ ),
93
+ Match.orElse((): CloseAction => "resume"),
94
+ )
95
+
96
+ /** Discord's own words for a fatal code, since its close frames carry no reason. */
97
+ export const closeReason = (code: number, reason?: string): string =>
98
+ fatalReasons[code] ??
99
+ (reason === undefined || reason === "" ? `gateway closed with ${code}` : reason)
100
+
101
+ // A close we send ourselves. Anything but 1000/1001 keeps the session alive on
102
+ // Discord's side, which is what makes the next connection resumable.
103
+ const RESUMABLE_CLOSE = 4000
104
+
105
+ // ---------------------------------------------------------------------------
106
+ // Session
107
+ // ---------------------------------------------------------------------------
108
+
109
+ export type Config = {
110
+ readonly token: Redacted.Redacted
111
+ readonly intents: number
112
+ /** From `GET /gateway/bot`. Resumes go to `resume_gateway_url` instead. */
113
+ readonly url: string
114
+ }
115
+
116
+ /** A decoded dispatch beside the payload it came from, which `raw` carries. */
117
+ export type Incoming = {
118
+ readonly dispatch: Events.Dispatch
119
+ readonly raw: unknown
120
+ }
121
+
122
+ export type Session = {
123
+ readonly bot: Events.BotIdentity
124
+ /**
125
+ * Dispatches in arrival order, ended when the scope closes and failed with
126
+ * `MessengerTransportClosed` on a fatal gateway close.
127
+ */
128
+ readonly dispatches: Queue.Queue<Incoming, MessengerError.MessengerError | Cause.Done>
129
+ }
130
+
131
+ type Resume = { readonly id: string; readonly url: string }
132
+
133
+ type Ended = { readonly action: CloseAction; readonly code: number; readonly reason: string }
134
+
135
+ const socketCode = (error: Socket.SocketError): number =>
136
+ error.reason._tag === "SocketCloseError" ? error.reason.code : 1006
137
+
138
+ const socketReason = (error: Socket.SocketError): string =>
139
+ error.reason._tag === "SocketCloseError"
140
+ ? (error.reason.closeReason ?? error.reason.message)
141
+ : error.reason.message
142
+
143
+ // Discord asks for a random 1-5 s pause before identifying on a fresh session.
144
+ const freshSessionDelay = Effect.sync(() => Duration.millis(1000 + Math.random() * 4000))
145
+
146
+ // Capped exponential, from a beat to a minute. Reset once a connection is live.
147
+ const backoff = (attempt: number): Duration.Duration =>
148
+ Duration.millis(Math.min(60_000, 500 * 2 ** attempt))
149
+
150
+ const identity = { os: "effect-uai", browser: "effect-uai", device: "effect-uai" }
151
+
152
+ /**
153
+ * One gateway session: connect, identify, heartbeat, resume, reconnect.
154
+ *
155
+ * Returns once `READY` has arrived, so a bad token or a disallowed intent is
156
+ * a `MessengerConnectFailed` at layer build rather than a stream that dies a
157
+ * moment later. From then on reconnects are silent and only a fatal close
158
+ * ends `dispatches`.
159
+ */
160
+ export const connect = (
161
+ cfg: Config,
162
+ ): Effect.Effect<Session, MessengerError.MessengerConnectFailed, Scope.Scope> =>
163
+ Effect.gen(function* () {
164
+ const dispatches = yield* Queue.unbounded<
165
+ Incoming,
166
+ MessengerError.MessengerError | Cause.Done
167
+ >()
168
+ const ready = yield* Deferred.make<Events.BotIdentity, MessengerError.MessengerConnectFailed>()
169
+ const resume = yield* Ref.make(Option.none<Resume>())
170
+ const attempts = yield* Ref.make(0)
171
+
172
+ // -- one connection ----------------------------------------------------
173
+
174
+ const once = Effect.gen(function* () {
175
+ const from = yield* Ref.get(resume)
176
+ // A resumable session has its own host; a fresh one starts at the URL
177
+ // `GET /gateway/bot` gave us.
178
+ const host = Option.match(from, { onNone: () => cfg.url, onSome: (r: Resume) => r.url })
179
+ const socket = yield* Socket.makeWebSocket(`${host}/?v=10&encoding=json`, {
180
+ // Effect treats every close as an error by default; the standard clean
181
+ // codes are not, and Discord's 4xxx ones are what drives `classifyClose`.
182
+ closeCodeIsError: (code: number) => code !== 1000 && code !== 1001 && code !== 1005,
183
+ // The gateway takes its token in the identify payload, not a header,
184
+ // so every runtime's global `WebSocket` is enough.
185
+ }).pipe(Effect.provide(Socket.layerWebSocketConstructorGlobal))
186
+ const write = yield* socket.writer
187
+ const send = (payload: unknown) => write(JSON.stringify(payload))
188
+ const seq = yield* Ref.make(Option.none<number>())
189
+ const acked = yield* Ref.make(true)
190
+ // Set by op 9 with `d: false`, the one case the close code cannot express.
191
+ const requested = yield* Ref.make(Option.none<CloseAction>())
192
+
193
+ const heartbeat = Effect.gen(function* () {
194
+ const s = yield* Ref.get(seq)
195
+ return yield* send({ op: OP.heartbeat, d: Option.getOrNull(s) })
196
+ })
197
+
198
+ // A beat with no ACK since the last one means the connection is a zombie:
199
+ // close it non-cleanly so the next connection resumes.
200
+ const beat = Effect.gen(function* () {
201
+ const alive = yield* Ref.getAndSet(acked, false)
202
+ if (!alive) {
203
+ yield* write(new Socket.CloseEvent(RESUMABLE_CLOSE, "no heartbeat ack"))
204
+ return false
205
+ }
206
+ yield* heartbeat
207
+ return true
208
+ })
209
+
210
+ // Discord asks for the first beat to land at a random point in the
211
+ // interval, so a fleet of bots does not beat in lockstep.
212
+ const beating = (interval: number) =>
213
+ Effect.sleep(Duration.millis(interval * Math.random())).pipe(
214
+ Effect.andThen(
215
+ beat.pipe(
216
+ Effect.repeat({
217
+ schedule: Schedule.spaced(Duration.millis(interval)),
218
+ while: (alive: boolean) => alive,
219
+ }),
220
+ ),
221
+ ),
222
+ Effect.ignore,
223
+ Effect.forkScoped,
224
+ )
225
+
226
+ const hello = (d: unknown) =>
227
+ Effect.gen(function* () {
228
+ const { heartbeat_interval } = yield* Schema.decodeUnknownEffect(Hello)(d)
229
+ yield* beating(heartbeat_interval)
230
+ const from = yield* Ref.get(resume)
231
+ const s = yield* Ref.get(seq)
232
+ return yield* Option.match(from, {
233
+ onNone: () =>
234
+ send({
235
+ op: OP.identify,
236
+ d: {
237
+ token: Redacted.value(cfg.token),
238
+ intents: cfg.intents,
239
+ properties: identity,
240
+ },
241
+ }),
242
+ onSome: (r) =>
243
+ send({
244
+ op: OP.resume,
245
+ d: {
246
+ token: Redacted.value(cfg.token),
247
+ session_id: r.id,
248
+ seq: Option.getOrNull(s),
249
+ },
250
+ }),
251
+ })
252
+ })
253
+
254
+ const onReady = (d: unknown) =>
255
+ Effect.gen(function* () {
256
+ const info = yield* Schema.decodeUnknownEffect(Ready)(d)
257
+ yield* Ref.set(resume, Option.some({ id: info.session_id, url: info.resume_gateway_url }))
258
+ yield* Ref.set(attempts, 0)
259
+ yield* Deferred.succeed(ready, info.user)
260
+ })
261
+
262
+ const onDispatch = (payload: Payload) =>
263
+ Effect.gen(function* () {
264
+ if (payload.s != null) yield* Ref.set(seq, Option.some(payload.s))
265
+ if (payload.t === "READY") return yield* onReady(payload.d)
266
+ if (payload.t === "RESUMED") return yield* Ref.set(attempts, 0)
267
+ const dispatch = yield* Schema.decodeUnknownEffect(Events.Dispatch)({
268
+ t: payload.t,
269
+ d: payload.d,
270
+ }).pipe(Effect.option)
271
+ if (Option.isSome(dispatch)) {
272
+ yield* Queue.offer(dispatches, { dispatch: dispatch.value, raw: payload.d })
273
+ }
274
+ })
275
+
276
+ // `d: true` keeps the session, `d: false` drops it; both mean reconnect.
277
+ const invalidSession = (d: unknown) =>
278
+ Effect.gen(function* () {
279
+ if (d !== true) {
280
+ yield* Ref.set(resume, Option.none())
281
+ yield* Ref.set(requested, Option.some<CloseAction>("reidentify"))
282
+ }
283
+ yield* write(new Socket.CloseEvent(RESUMABLE_CLOSE, "invalid session"))
284
+ })
285
+
286
+ const handle = (raw: string) =>
287
+ Effect.gen(function* () {
288
+ const payload = yield* decodeFrame(raw).pipe(Effect.option)
289
+ if (Option.isNone(payload)) return
290
+ const message = payload.value
291
+ yield* Match.value(message.op).pipe(
292
+ Match.when(OP.dispatch, () => onDispatch(message)),
293
+ Match.when(OP.hello, () => hello(message.d)),
294
+ Match.when(OP.heartbeatAck, () => Ref.set(acked, true)),
295
+ Match.when(OP.heartbeat, () => heartbeat),
296
+ Match.when(OP.reconnect, () =>
297
+ write(new Socket.CloseEvent(RESUMABLE_CLOSE, "reconnect requested")),
298
+ ),
299
+ Match.when(OP.invalidSession, () => invalidSession(message.d)),
300
+ Match.orElse(() => Effect.void),
301
+ )
302
+ }).pipe(Effect.ignore)
303
+
304
+ // A clean 1000 is only ever our own teardown; anything else carries the
305
+ // close code the classifier reads.
306
+ const ended = yield* socket.runString(handle).pipe(
307
+ Effect.as<Ended>({ action: "resume", code: 1000, reason: "closed" }),
308
+ Effect.catch((error: Socket.SocketError) => {
309
+ const code = socketCode(error)
310
+ return Effect.succeed<Ended>({
311
+ action: classifyClose(code),
312
+ code,
313
+ reason: closeReason(code, socketReason(error)),
314
+ })
315
+ }),
316
+ )
317
+ const override = yield* Ref.get(requested)
318
+ return Option.match(override, {
319
+ onNone: (): Ended => ended,
320
+ onSome: (action): Ended => ({ ...ended, action }),
321
+ })
322
+ }).pipe(Effect.scoped)
323
+
324
+ // -- the reconnect loop ------------------------------------------------
325
+
326
+ const cycle = Effect.gen(function* () {
327
+ const ended = yield* once
328
+ if (ended.action === "fatal") {
329
+ return yield* new MessengerError.MessengerTransportClosed({
330
+ provider,
331
+ reason: ended.reason,
332
+ raw: { code: ended.code },
333
+ })
334
+ }
335
+ const attempt = yield* Ref.get(attempts)
336
+ yield* Ref.set(attempts, attempt + 1)
337
+ const wait = ended.action === "reidentify" ? yield* freshSessionDelay : backoff(attempt)
338
+ // Reconnects never reach `events`, so this is the only sign of one. A
339
+ // dropped connection is noticed only when a heartbeat goes unacked,
340
+ // which is up to two intervals after the network actually went away.
341
+ yield* Effect.logDebug("gateway reconnecting", {
342
+ code: ended.code,
343
+ action: ended.action,
344
+ attempt,
345
+ in: Duration.toSeconds(wait),
346
+ })
347
+ yield* Effect.sleep(wait)
348
+ })
349
+
350
+ // Failing the deferred is a no-op once `READY` has landed, so the same
351
+ // fatal close is a connect failure before it and a transport close after.
352
+ yield* Effect.forever(cycle).pipe(
353
+ Effect.catch((closed: MessengerError.MessengerTransportClosed) =>
354
+ Deferred.fail(
355
+ ready,
356
+ new MessengerError.MessengerConnectFailed({
357
+ provider,
358
+ reason: closed.reason,
359
+ raw: closed.raw,
360
+ }),
361
+ ).pipe(Effect.andThen(Queue.fail(dispatches, closed))),
362
+ ),
363
+ Effect.ensuring(Queue.end(dispatches)),
364
+ Effect.forkScoped,
365
+ )
366
+
367
+ return { bot: yield* Deferred.await(ready), dispatches }
368
+ })