@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.
- package/LICENSE +21 -0
- package/README.md +48 -0
- package/dist/Discord.d.mts +55 -0
- package/dist/Discord.d.mts.map +1 -0
- package/dist/Discord.mjs +196 -0
- package/dist/Discord.mjs.map +1 -0
- package/dist/events-Du1u-kVV.d.mts +172 -0
- package/dist/events-Du1u-kVV.d.mts.map +1 -0
- package/dist/gateway-C_Ae1HYB.d.mts +57 -0
- package/dist/gateway-C_Ae1HYB.d.mts.map +1 -0
- package/dist/index.d.mts +2 -0
- package/dist/index.mjs +2 -0
- package/dist/internal/events.d.mts +2 -0
- package/dist/internal/events.mjs +109 -0
- package/dist/internal/events.mjs.map +1 -0
- package/dist/internal/events.test.d.mts +1 -0
- package/dist/internal/events.test.mjs +130 -0
- package/dist/internal/events.test.mjs.map +1 -0
- package/dist/internal/gateway.d.mts +2 -0
- package/dist/internal/gateway.mjs +216 -0
- package/dist/internal/gateway.mjs.map +1 -0
- package/dist/internal/gateway.test.d.mts +1 -0
- package/dist/internal/gateway.test.mjs +40 -0
- package/dist/internal/gateway.test.mjs.map +1 -0
- package/dist/internal/rest.d.mts +2 -0
- package/dist/internal/rest.mjs +89 -0
- package/dist/internal/rest.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/rest-CZSW-VJK.d.mts +44 -0
- package/dist/rest-CZSW-VJK.d.mts.map +1 -0
- package/dist/rolldown-runtime-D7D4PA-g.mjs +13 -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/Discord.ts +393 -0
- package/src/index.ts +1 -0
- package/src/internal/events.test.ts +89 -0
- package/src/internal/events.ts +197 -0
- package/src/internal/gateway.test.ts +27 -0
- package/src/internal/gateway.ts +368 -0
- 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
|
+
})
|