@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,109 @@
|
|
|
1
|
+
import { Array, Match, Option, Schema } from "effect";
|
|
2
|
+
import { ChannelId, InboundEvent, MessageId, UserId } from "@effect-uai/core/Messenger";
|
|
3
|
+
//#region src/internal/events.ts
|
|
4
|
+
const User = Schema.Struct({
|
|
5
|
+
id: Schema.String,
|
|
6
|
+
username: Schema.optional(Schema.String),
|
|
7
|
+
bot: Schema.optional(Schema.Boolean)
|
|
8
|
+
});
|
|
9
|
+
/** Unicode reactions carry a `name` and a null `id`; custom ones carry both. */
|
|
10
|
+
const Emoji = Schema.Struct({
|
|
11
|
+
id: Schema.optional(Schema.NullOr(Schema.String)),
|
|
12
|
+
name: Schema.optional(Schema.NullOr(Schema.String))
|
|
13
|
+
});
|
|
14
|
+
const ReplyTarget = Schema.Struct({
|
|
15
|
+
id: Schema.String,
|
|
16
|
+
author: Schema.optional(User)
|
|
17
|
+
});
|
|
18
|
+
const MessageCreate = Schema.Struct({
|
|
19
|
+
id: Schema.String,
|
|
20
|
+
channel_id: Schema.String,
|
|
21
|
+
guild_id: Schema.optional(Schema.String),
|
|
22
|
+
author: User,
|
|
23
|
+
content: Schema.optional(Schema.String),
|
|
24
|
+
mentions: Schema.optional(Schema.Array(User)),
|
|
25
|
+
referenced_message: Schema.optional(Schema.NullOr(ReplyTarget))
|
|
26
|
+
});
|
|
27
|
+
const ReactionAdd = Schema.Struct({
|
|
28
|
+
user_id: Schema.String,
|
|
29
|
+
channel_id: Schema.String,
|
|
30
|
+
message_id: Schema.String,
|
|
31
|
+
emoji: Emoji
|
|
32
|
+
});
|
|
33
|
+
const InteractionData = Schema.Struct({
|
|
34
|
+
custom_id: Schema.optional(Schema.String),
|
|
35
|
+
values: Schema.optional(Schema.Array(Schema.String))
|
|
36
|
+
});
|
|
37
|
+
const InteractionCreate = Schema.Struct({
|
|
38
|
+
id: Schema.String,
|
|
39
|
+
token: Schema.String,
|
|
40
|
+
/** 1 ping, 2 slash command, 3 message component, 4 autocomplete, 5 modal. */
|
|
41
|
+
type: Schema.Number,
|
|
42
|
+
channel_id: Schema.optional(Schema.String),
|
|
43
|
+
user: Schema.optional(User),
|
|
44
|
+
member: Schema.optional(Schema.Struct({ user: Schema.optional(User) })),
|
|
45
|
+
data: Schema.optional(InteractionData)
|
|
46
|
+
});
|
|
47
|
+
/** The component interaction type; every other one is ignored in v1. */
|
|
48
|
+
const MESSAGE_COMPONENT = 3;
|
|
49
|
+
/**
|
|
50
|
+
* The dispatches this adapter reads, discriminated by the gateway's `t`.
|
|
51
|
+
* Anything else fails to decode and is dropped by the session.
|
|
52
|
+
*/
|
|
53
|
+
const Dispatch = Schema.Union([
|
|
54
|
+
Schema.Struct({
|
|
55
|
+
t: Schema.Literal("MESSAGE_CREATE"),
|
|
56
|
+
d: MessageCreate
|
|
57
|
+
}),
|
|
58
|
+
Schema.Struct({
|
|
59
|
+
t: Schema.Literal("MESSAGE_REACTION_ADD"),
|
|
60
|
+
d: ReactionAdd
|
|
61
|
+
}),
|
|
62
|
+
Schema.Struct({
|
|
63
|
+
t: Schema.Literal("INTERACTION_CREATE"),
|
|
64
|
+
d: InteractionCreate
|
|
65
|
+
})
|
|
66
|
+
]);
|
|
67
|
+
const conversation = (channelId) => ({ channel: ChannelId(channelId) });
|
|
68
|
+
const mentionsBot = (bot, message) => (message.mentions ?? []).some((user) => user.id === bot.id);
|
|
69
|
+
const isAddressed = (bot, message) => message.guild_id === void 0 || mentionsBot(bot, message) || message.referenced_message?.author?.id === bot.id;
|
|
70
|
+
/** Both mention spellings of the bot removed; other users' mentions stay. */
|
|
71
|
+
const withoutMention = (bot, text) => text.replaceAll(`<@${bot.id}>`, "").replaceAll(`<@!${bot.id}>`, "").trim();
|
|
72
|
+
const fromMessage = (bot, message, raw) => Option.liftPredicate(message, (m) => m.author.bot !== true).pipe(Option.map((m) => InboundEvent.Message({
|
|
73
|
+
conversation: conversation(m.channel_id),
|
|
74
|
+
id: MessageId(m.id),
|
|
75
|
+
author: UserId(m.author.id),
|
|
76
|
+
text: withoutMention(bot, m.content ?? ""),
|
|
77
|
+
addressed: isAddressed(bot, m),
|
|
78
|
+
...m.referenced_message != null && { replyTo: MessageId(m.referenced_message.id) },
|
|
79
|
+
raw
|
|
80
|
+
})));
|
|
81
|
+
/** Unicode reactions are their own name; custom ones are `name:id`, as REST wants. */
|
|
82
|
+
const emojiName = (emoji) => Option.map(Option.fromNullishOr(emoji.name), (name) => emoji.id == null ? name : `${name}:${emoji.id}`);
|
|
83
|
+
const fromReaction = (bot, reaction, raw) => Option.liftPredicate(reaction, (r) => r.user_id !== bot.id).pipe(Option.flatMap((r) => Option.map(emojiName(r.emoji), (emoji) => InboundEvent.Reaction({
|
|
84
|
+
conversation: conversation(r.channel_id),
|
|
85
|
+
message: MessageId(r.message_id),
|
|
86
|
+
emoji,
|
|
87
|
+
author: UserId(r.user_id),
|
|
88
|
+
raw
|
|
89
|
+
}))));
|
|
90
|
+
const interactionAuthor = (interaction) => Option.fromNullishOr(interaction.user?.id ?? interaction.member?.user?.id);
|
|
91
|
+
/** A select delivers several values; the shared shape carries them comma-joined. */
|
|
92
|
+
const fromInteraction = (interaction, raw) => Option.liftPredicate(interaction, (i) => i.type === 3).pipe(Option.flatMap((i) => Option.all([Option.fromNullishOr(i.channel_id), interactionAuthor(i)]).pipe(Option.map(([channelId, author]) => InboundEvent.Action({
|
|
93
|
+
conversation: conversation(channelId),
|
|
94
|
+
actionId: i.data?.custom_id ?? "",
|
|
95
|
+
...i.data?.values !== void 0 && { value: i.data.values.join(",") },
|
|
96
|
+
author: UserId(author),
|
|
97
|
+
raw
|
|
98
|
+
})))));
|
|
99
|
+
/**
|
|
100
|
+
* Everything one dispatch says to the agent. `raw` is the payload as it came
|
|
101
|
+
* off the wire, since decoding keeps only the fields above. Discord has no
|
|
102
|
+
* text-command convention, so nothing here becomes a `Command`: slash
|
|
103
|
+
* commands are a follow-up and need an interaction response of their own.
|
|
104
|
+
*/
|
|
105
|
+
const toEvents = (bot) => (dispatch, raw) => Array.fromOption(Match.value(dispatch).pipe(Match.when({ t: "MESSAGE_CREATE" }, ({ d }) => fromMessage(bot, d, raw)), Match.when({ t: "MESSAGE_REACTION_ADD" }, ({ d }) => fromReaction(bot, d, raw)), Match.when({ t: "INTERACTION_CREATE" }, ({ d }) => fromInteraction(d, raw)), Match.exhaustive));
|
|
106
|
+
//#endregion
|
|
107
|
+
export { Dispatch, Emoji, InteractionCreate, InteractionData, MESSAGE_COMPONENT, MessageCreate, ReactionAdd, ReplyTarget, User, toEvents };
|
|
108
|
+
|
|
109
|
+
//# sourceMappingURL=events.mjs.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"events.mjs","names":["Arr"],"sources":["../../src/internal/events.ts"],"sourcesContent":["import { Array as Arr, Match, Option, Schema } from \"effect\"\nimport {\n ChannelId,\n type ConversationRef,\n InboundEvent,\n MessageId,\n UserId,\n} from \"@effect-uai/core/Messenger\"\n\n// ---------------------------------------------------------------------------\n// Wire shapes: the fields this adapter reads. The rest survives on `raw`.\n// ---------------------------------------------------------------------------\n\nexport const User = Schema.Struct({\n id: Schema.String,\n username: Schema.optional(Schema.String),\n bot: Schema.optional(Schema.Boolean),\n})\nexport type User = typeof User.Type\n\n/** Unicode reactions carry a `name` and a null `id`; custom ones carry both. */\nexport const Emoji = Schema.Struct({\n id: Schema.optional(Schema.NullOr(Schema.String)),\n name: Schema.optional(Schema.NullOr(Schema.String)),\n})\nexport type Emoji = typeof Emoji.Type\n\nexport const ReplyTarget = Schema.Struct({\n id: Schema.String,\n author: Schema.optional(User),\n})\nexport type ReplyTarget = typeof ReplyTarget.Type\n\nexport const MessageCreate = Schema.Struct({\n id: Schema.String,\n channel_id: Schema.String,\n // Absent in a DM, which is how a DM is recognised.\n guild_id: Schema.optional(Schema.String),\n author: User,\n content: Schema.optional(Schema.String),\n mentions: Schema.optional(Schema.Array(User)),\n referenced_message: Schema.optional(Schema.NullOr(ReplyTarget)),\n})\nexport type MessageCreate = typeof MessageCreate.Type\n\nexport const ReactionAdd = Schema.Struct({\n user_id: Schema.String,\n channel_id: Schema.String,\n message_id: Schema.String,\n emoji: Emoji,\n})\nexport type ReactionAdd = typeof ReactionAdd.Type\n\nexport const InteractionData = Schema.Struct({\n custom_id: Schema.optional(Schema.String),\n values: Schema.optional(Schema.Array(Schema.String)),\n})\nexport type InteractionData = typeof InteractionData.Type\n\nexport const InteractionCreate = Schema.Struct({\n id: Schema.String,\n token: Schema.String,\n /** 1 ping, 2 slash command, 3 message component, 4 autocomplete, 5 modal. */\n type: Schema.Number,\n channel_id: Schema.optional(Schema.String),\n user: Schema.optional(User),\n member: Schema.optional(Schema.Struct({ user: Schema.optional(User) })),\n data: Schema.optional(InteractionData),\n})\nexport type InteractionCreate = typeof InteractionCreate.Type\n\n/** The component interaction type; every other one is ignored in v1. */\nexport const MESSAGE_COMPONENT = 3\n\n/**\n * The dispatches this adapter reads, discriminated by the gateway's `t`.\n * Anything else fails to decode and is dropped by the session.\n */\nexport const Dispatch = Schema.Union([\n Schema.Struct({ t: Schema.Literal(\"MESSAGE_CREATE\"), d: MessageCreate }),\n Schema.Struct({ t: Schema.Literal(\"MESSAGE_REACTION_ADD\"), d: ReactionAdd }),\n Schema.Struct({ t: Schema.Literal(\"INTERACTION_CREATE\"), d: InteractionCreate }),\n])\nexport type Dispatch = typeof Dispatch.Type\n\nexport type BotIdentity = {\n readonly id: string\n readonly username: string\n}\n\n// ---------------------------------------------------------------------------\n// Mapping\n// ---------------------------------------------------------------------------\n\n// A Discord thread is a channel of its own, so `thread` is never set here and\n// `conversationKey` still separates a thread from its parent.\nconst conversation = (channelId: string): ConversationRef => ({ channel: ChannelId(channelId) })\n\nconst mentionsBot = (bot: BotIdentity, message: MessageCreate): boolean =>\n (message.mentions ?? []).some((user) => user.id === bot.id)\n\n// `@everyone` and role mentions are not in `mentions`, so they never address.\nconst isAddressed = (bot: BotIdentity, message: MessageCreate): boolean =>\n message.guild_id === undefined ||\n mentionsBot(bot, message) ||\n message.referenced_message?.author?.id === bot.id\n\n/** Both mention spellings of the bot removed; other users' mentions stay. */\nconst withoutMention = (bot: BotIdentity, text: string): string =>\n text.replaceAll(`<@${bot.id}>`, \"\").replaceAll(`<@!${bot.id}>`, \"\").trim()\n\nconst fromMessage = (\n bot: BotIdentity,\n message: MessageCreate,\n raw: unknown,\n): Option.Option<InboundEvent> =>\n // Bot authors are dropped outright, so two bots cannot answer each other.\n Option.liftPredicate(message, (m) => m.author.bot !== true).pipe(\n Option.map((m) =>\n InboundEvent.Message({\n conversation: conversation(m.channel_id),\n id: MessageId(m.id),\n author: UserId(m.author.id),\n text: withoutMention(bot, m.content ?? \"\"),\n addressed: isAddressed(bot, m),\n ...(m.referenced_message != null && { replyTo: MessageId(m.referenced_message.id) }),\n raw,\n }),\n ),\n )\n\n/** Unicode reactions are their own name; custom ones are `name:id`, as REST wants. */\nconst emojiName = (emoji: Emoji): Option.Option<string> =>\n Option.map(Option.fromNullishOr(emoji.name), (name) =>\n emoji.id == null ? name : `${name}:${emoji.id}`,\n )\n\nconst fromReaction = (\n bot: BotIdentity,\n reaction: ReactionAdd,\n raw: unknown,\n): Option.Option<InboundEvent> =>\n Option.liftPredicate(reaction, (r) => r.user_id !== bot.id).pipe(\n Option.flatMap((r) =>\n Option.map(emojiName(r.emoji), (emoji) =>\n InboundEvent.Reaction({\n conversation: conversation(r.channel_id),\n message: MessageId(r.message_id),\n emoji,\n author: UserId(r.user_id),\n raw,\n }),\n ),\n ),\n )\n\nconst interactionAuthor = (interaction: InteractionCreate): Option.Option<string> =>\n Option.fromNullishOr(interaction.user?.id ?? interaction.member?.user?.id)\n\n/** A select delivers several values; the shared shape carries them comma-joined. */\nconst fromInteraction = (\n interaction: InteractionCreate,\n raw: unknown,\n): Option.Option<InboundEvent> =>\n Option.liftPredicate(interaction, (i) => i.type === MESSAGE_COMPONENT).pipe(\n Option.flatMap((i) =>\n Option.all([Option.fromNullishOr(i.channel_id), interactionAuthor(i)]).pipe(\n Option.map(([channelId, author]) =>\n InboundEvent.Action({\n conversation: conversation(channelId),\n actionId: i.data?.custom_id ?? \"\",\n ...(i.data?.values !== undefined && { value: i.data.values.join(\",\") }),\n author: UserId(author),\n raw,\n }),\n ),\n ),\n ),\n )\n\n/**\n * Everything one dispatch says to the agent. `raw` is the payload as it came\n * off the wire, since decoding keeps only the fields above. Discord has no\n * text-command convention, so nothing here becomes a `Command`: slash\n * commands are a follow-up and need an interaction response of their own.\n */\nexport const toEvents =\n (bot: BotIdentity) =>\n (dispatch: Dispatch, raw: unknown): ReadonlyArray<InboundEvent> =>\n Arr.fromOption(\n Match.value(dispatch).pipe(\n Match.when({ t: \"MESSAGE_CREATE\" }, ({ d }) => fromMessage(bot, d, raw)),\n Match.when({ t: \"MESSAGE_REACTION_ADD\" }, ({ d }) => fromReaction(bot, d, raw)),\n Match.when({ t: \"INTERACTION_CREATE\" }, ({ d }) => fromInteraction(d, raw)),\n Match.exhaustive,\n ),\n )\n"],"mappings":"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"}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {}
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
import { Dispatch, toEvents } from "./events.mjs";
|
|
2
|
+
import { n as describe, r as it, t as globalExpect } from "../test.DNmyFkvJ-N3QqMRwZ.mjs";
|
|
3
|
+
import { Schema } from "effect";
|
|
4
|
+
//#region src/internal/events.test.ts
|
|
5
|
+
const events = toEvents({
|
|
6
|
+
id: "42",
|
|
7
|
+
username: "HelperBot"
|
|
8
|
+
});
|
|
9
|
+
const alice = {
|
|
10
|
+
id: "7",
|
|
11
|
+
username: "alice"
|
|
12
|
+
};
|
|
13
|
+
/** One dispatch through the real decoder, as the gateway hands it over. */
|
|
14
|
+
const dispatch = (t, d) => events(Schema.decodeUnknownSync(Dispatch)({
|
|
15
|
+
t,
|
|
16
|
+
d
|
|
17
|
+
}), d)[0];
|
|
18
|
+
const message = (fields) => dispatch("MESSAGE_CREATE", {
|
|
19
|
+
id: "10",
|
|
20
|
+
channel_id: "555",
|
|
21
|
+
guild_id: "100",
|
|
22
|
+
author: alice,
|
|
23
|
+
...fields
|
|
24
|
+
});
|
|
25
|
+
describe("addressed", () => {
|
|
26
|
+
it("is a DM, a mention, or a reply to the bot, and nothing else", () => {
|
|
27
|
+
globalExpect(message({
|
|
28
|
+
guild_id: void 0,
|
|
29
|
+
content: "hi"
|
|
30
|
+
})).toMatchObject({ addressed: true });
|
|
31
|
+
globalExpect(message({
|
|
32
|
+
content: "<@42> ping",
|
|
33
|
+
mentions: [{ id: "42" }]
|
|
34
|
+
})).toMatchObject({ addressed: true });
|
|
35
|
+
globalExpect(message({
|
|
36
|
+
content: "and?",
|
|
37
|
+
referenced_message: {
|
|
38
|
+
id: "9",
|
|
39
|
+
author: { id: "42" }
|
|
40
|
+
}
|
|
41
|
+
})).toMatchObject({
|
|
42
|
+
addressed: true,
|
|
43
|
+
replyTo: "9"
|
|
44
|
+
});
|
|
45
|
+
globalExpect(message({
|
|
46
|
+
content: "and?",
|
|
47
|
+
referenced_message: {
|
|
48
|
+
id: "9",
|
|
49
|
+
author: alice
|
|
50
|
+
}
|
|
51
|
+
})).toMatchObject({
|
|
52
|
+
addressed: false,
|
|
53
|
+
replyTo: "9"
|
|
54
|
+
});
|
|
55
|
+
globalExpect(message({
|
|
56
|
+
content: "@everyone lunch?",
|
|
57
|
+
mention_everyone: true
|
|
58
|
+
})).toMatchObject({ addressed: false });
|
|
59
|
+
});
|
|
60
|
+
it("strips both spellings of the bot's own mention and leaves other users'", () => {
|
|
61
|
+
globalExpect(message({
|
|
62
|
+
content: "<@!42> ask <@7> too",
|
|
63
|
+
mentions: [{ id: "42" }, alice]
|
|
64
|
+
})).toMatchObject({ text: "ask <@7> too" });
|
|
65
|
+
});
|
|
66
|
+
it("drops messages another bot wrote, so two bots cannot answer each other", () => {
|
|
67
|
+
globalExpect(message({
|
|
68
|
+
content: "hello",
|
|
69
|
+
author: {
|
|
70
|
+
id: "99",
|
|
71
|
+
bot: true
|
|
72
|
+
}
|
|
73
|
+
})).toBeUndefined();
|
|
74
|
+
});
|
|
75
|
+
});
|
|
76
|
+
describe("reactions", () => {
|
|
77
|
+
const add = (emoji, user_id = "7") => dispatch("MESSAGE_REACTION_ADD", {
|
|
78
|
+
user_id,
|
|
79
|
+
channel_id: "555",
|
|
80
|
+
message_id: "10",
|
|
81
|
+
emoji
|
|
82
|
+
});
|
|
83
|
+
it("names unicode by itself and custom emoji as `name:id`", () => {
|
|
84
|
+
globalExpect(add({
|
|
85
|
+
id: null,
|
|
86
|
+
name: "👀"
|
|
87
|
+
})).toMatchObject({
|
|
88
|
+
_tag: "Reaction",
|
|
89
|
+
emoji: "👀"
|
|
90
|
+
});
|
|
91
|
+
globalExpect(add({
|
|
92
|
+
id: "8",
|
|
93
|
+
name: "blobcat"
|
|
94
|
+
})).toMatchObject({ emoji: "blobcat:8" });
|
|
95
|
+
});
|
|
96
|
+
it("drops the bot's own reactions", () => {
|
|
97
|
+
globalExpect(add({
|
|
98
|
+
id: null,
|
|
99
|
+
name: "👀"
|
|
100
|
+
}, "42")).toBeUndefined();
|
|
101
|
+
});
|
|
102
|
+
});
|
|
103
|
+
describe("interactions", () => {
|
|
104
|
+
const interaction = (type, data) => dispatch("INTERACTION_CREATE", {
|
|
105
|
+
id: "1",
|
|
106
|
+
token: "tok",
|
|
107
|
+
type,
|
|
108
|
+
channel_id: "555",
|
|
109
|
+
member: { user: alice },
|
|
110
|
+
data
|
|
111
|
+
});
|
|
112
|
+
it("maps a component press to an Action, joining a select's values", () => {
|
|
113
|
+
globalExpect(interaction(3, {
|
|
114
|
+
custom_id: "approve:12",
|
|
115
|
+
values: ["a", "b"]
|
|
116
|
+
})).toMatchObject({
|
|
117
|
+
_tag: "Action",
|
|
118
|
+
actionId: "approve:12",
|
|
119
|
+
value: "a,b",
|
|
120
|
+
author: "7"
|
|
121
|
+
});
|
|
122
|
+
});
|
|
123
|
+
it("ignores interaction types that need a response of their own", () => {
|
|
124
|
+
globalExpect(interaction(2, { name: "search" })).toBeUndefined();
|
|
125
|
+
});
|
|
126
|
+
});
|
|
127
|
+
//#endregion
|
|
128
|
+
export {};
|
|
129
|
+
|
|
130
|
+
//# sourceMappingURL=events.test.mjs.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"events.test.mjs","names":[],"sources":["../../src/internal/events.test.ts"],"sourcesContent":["import { Schema } from \"effect\"\nimport { describe, expect, it } from \"vitest\"\nimport { type BotIdentity, Dispatch, toEvents } from \"./events.js\"\n\nconst bot: BotIdentity = { id: \"42\", username: \"HelperBot\" }\nconst events = toEvents(bot)\n\nconst alice = { id: \"7\", username: \"alice\" }\n\n/** One dispatch through the real decoder, as the gateway hands it over. */\nconst dispatch = (t: string, d: Record<string, unknown>) =>\n events(Schema.decodeUnknownSync(Dispatch)({ t, d }), d)[0]\n\nconst message = (fields: Record<string, unknown>) =>\n dispatch(\"MESSAGE_CREATE\", {\n id: \"10\",\n channel_id: \"555\",\n guild_id: \"100\",\n author: alice,\n ...fields,\n })\n\ndescribe(\"addressed\", () => {\n it(\"is a DM, a mention, or a reply to the bot, and nothing else\", () => {\n expect(message({ guild_id: undefined, content: \"hi\" })).toMatchObject({ addressed: true })\n expect(message({ content: \"<@42> ping\", mentions: [{ id: \"42\" }] })).toMatchObject({\n addressed: true,\n })\n expect(\n message({ content: \"and?\", referenced_message: { id: \"9\", author: { id: \"42\" } } }),\n ).toMatchObject({ addressed: true, replyTo: \"9\" })\n expect(\n message({ content: \"and?\", referenced_message: { id: \"9\", author: alice } }),\n ).toMatchObject({ addressed: false, replyTo: \"9\" })\n // `@everyone` and role pings never land in `mentions`.\n expect(message({ content: \"@everyone lunch?\", mention_everyone: true })).toMatchObject({\n addressed: false,\n })\n })\n\n it(\"strips both spellings of the bot's own mention and leaves other users'\", () => {\n expect(\n message({ content: \"<@!42> ask <@7> too\", mentions: [{ id: \"42\" }, alice] }),\n ).toMatchObject({ text: \"ask <@7> too\" })\n })\n\n it(\"drops messages another bot wrote, so two bots cannot answer each other\", () => {\n expect(message({ content: \"hello\", author: { id: \"99\", bot: true } })).toBeUndefined()\n })\n})\n\ndescribe(\"reactions\", () => {\n const add = (emoji: Record<string, unknown>, user_id = \"7\") =>\n dispatch(\"MESSAGE_REACTION_ADD\", { user_id, channel_id: \"555\", message_id: \"10\", emoji })\n\n it(\"names unicode by itself and custom emoji as `name:id`\", () => {\n expect(add({ id: null, name: \"👀\" })).toMatchObject({ _tag: \"Reaction\", emoji: \"👀\" })\n expect(add({ id: \"8\", name: \"blobcat\" })).toMatchObject({ emoji: \"blobcat:8\" })\n })\n\n it(\"drops the bot's own reactions\", () => {\n expect(add({ id: null, name: \"👀\" }, \"42\")).toBeUndefined()\n })\n})\n\ndescribe(\"interactions\", () => {\n const interaction = (type: number, data: Record<string, unknown>) =>\n dispatch(\"INTERACTION_CREATE\", {\n id: \"1\",\n token: \"tok\",\n type,\n channel_id: \"555\",\n member: { user: alice },\n data,\n })\n\n it(\"maps a component press to an Action, joining a select's values\", () => {\n expect(interaction(3, { custom_id: \"approve:12\", values: [\"a\", \"b\"] })).toMatchObject({\n _tag: \"Action\",\n actionId: \"approve:12\",\n value: \"a,b\",\n author: \"7\",\n })\n })\n\n it(\"ignores interaction types that need a response of their own\", () => {\n expect(interaction(2, { name: \"search\" })).toBeUndefined()\n })\n})\n"],"mappings":";;;;AAKA,MAAM,SAAS,SAAS;CADG,IAAI;CAAM,UAAU;AACrB,CAAC;AAE3B,MAAM,QAAQ;CAAE,IAAI;CAAK,UAAU;AAAQ;;AAG3C,MAAM,YAAY,GAAW,MAC3B,OAAO,OAAO,kBAAkB,QAAQ,CAAC,CAAC;CAAE;CAAG;AAAE,CAAC,GAAG,CAAC,CAAC,CAAC;AAE1D,MAAM,WAAW,WACf,SAAS,kBAAkB;CACzB,IAAI;CACJ,YAAY;CACZ,UAAU;CACV,QAAQ;CACR,GAAG;AACL,CAAC;AAEH,SAAS,mBAAmB;CAC1B,GAAG,qEAAqE;EACtE,aAAO,QAAQ;GAAE,UAAU,KAAA;GAAW,SAAS;EAAK,CAAC,CAAC,CAAC,CAAC,cAAc,EAAE,WAAW,KAAK,CAAC;EACzF,aAAO,QAAQ;GAAE,SAAS;GAAc,UAAU,CAAC,EAAE,IAAI,KAAK,CAAC;EAAE,CAAC,CAAC,CAAC,CAAC,cAAc,EACjF,WAAW,KACb,CAAC;EACD,aACE,QAAQ;GAAE,SAAS;GAAQ,oBAAoB;IAAE,IAAI;IAAK,QAAQ,EAAE,IAAI,KAAK;GAAE;EAAE,CAAC,CACpF,CAAC,CAAC,cAAc;GAAE,WAAW;GAAM,SAAS;EAAI,CAAC;EACjD,aACE,QAAQ;GAAE,SAAS;GAAQ,oBAAoB;IAAE,IAAI;IAAK,QAAQ;GAAM;EAAE,CAAC,CAC7E,CAAC,CAAC,cAAc;GAAE,WAAW;GAAO,SAAS;EAAI,CAAC;EAElD,aAAO,QAAQ;GAAE,SAAS;GAAoB,kBAAkB;EAAK,CAAC,CAAC,CAAC,CAAC,cAAc,EACrF,WAAW,MACb,CAAC;CACH,CAAC;CAED,GAAG,gFAAgF;EACjF,aACE,QAAQ;GAAE,SAAS;GAAuB,UAAU,CAAC,EAAE,IAAI,KAAK,GAAG,KAAK;EAAE,CAAC,CAC7E,CAAC,CAAC,cAAc,EAAE,MAAM,eAAe,CAAC;CAC1C,CAAC;CAED,GAAG,gFAAgF;EACjF,aAAO,QAAQ;GAAE,SAAS;GAAS,QAAQ;IAAE,IAAI;IAAM,KAAK;GAAK;EAAE,CAAC,CAAC,CAAC,CAAC,cAAc;CACvF,CAAC;AACH,CAAC;AAED,SAAS,mBAAmB;CAC1B,MAAM,OAAO,OAAgC,UAAU,QACrD,SAAS,wBAAwB;EAAE;EAAS,YAAY;EAAO,YAAY;EAAM;CAAM,CAAC;CAE1F,GAAG,+DAA+D;EAChE,aAAO,IAAI;GAAE,IAAI;GAAM,MAAM;EAAK,CAAC,CAAC,CAAC,CAAC,cAAc;GAAE,MAAM;GAAY,OAAO;EAAK,CAAC;EACrF,aAAO,IAAI;GAAE,IAAI;GAAK,MAAM;EAAU,CAAC,CAAC,CAAC,CAAC,cAAc,EAAE,OAAO,YAAY,CAAC;CAChF,CAAC;CAED,GAAG,uCAAuC;EACxC,aAAO,IAAI;GAAE,IAAI;GAAM,MAAM;EAAK,GAAG,IAAI,CAAC,CAAC,CAAC,cAAc;CAC5D,CAAC;AACH,CAAC;AAED,SAAS,sBAAsB;CAC7B,MAAM,eAAe,MAAc,SACjC,SAAS,sBAAsB;EAC7B,IAAI;EACJ,OAAO;EACP;EACA,YAAY;EACZ,QAAQ,EAAE,MAAM,MAAM;EACtB;CACF,CAAC;CAEH,GAAG,wEAAwE;EACzE,aAAO,YAAY,GAAG;GAAE,WAAW;GAAc,QAAQ,CAAC,KAAK,GAAG;EAAE,CAAC,CAAC,CAAC,CAAC,cAAc;GACpF,MAAM;GACN,UAAU;GACV,OAAO;GACP,QAAQ;EACV,CAAC;CACH,CAAC;CAED,GAAG,qEAAqE;EACtE,aAAO,YAAY,GAAG,EAAE,MAAM,SAAS,CAAC,CAAC,CAAC,CAAC,cAAc;CAC3D,CAAC;AACH,CAAC"}
|
|
@@ -0,0 +1,2 @@
|
|
|
1
|
+
import { a as Ready, c as closeReason, i as Incoming, l as connect, n as Config, o as Session, r as GatewayInfo, s as classifyClose, t as CloseAction } from "../gateway-C_Ae1HYB.mjs";
|
|
2
|
+
export { CloseAction, Config, GatewayInfo, Incoming, Ready, Session, classifyClose, closeReason, connect };
|
|
@@ -0,0 +1,216 @@
|
|
|
1
|
+
import { Dispatch } from "./events.mjs";
|
|
2
|
+
import { provider } from "./rest.mjs";
|
|
3
|
+
import { Deferred, Duration, Effect, Match, Option, Queue, Redacted, Ref, Schedule, Schema } from "effect";
|
|
4
|
+
import * as MessengerError from "@effect-uai/core/MessengerError";
|
|
5
|
+
import * as Socket from "effect/unstable/socket/Socket";
|
|
6
|
+
//#region src/internal/gateway.ts
|
|
7
|
+
const Payload = Schema.Struct({
|
|
8
|
+
op: Schema.Number,
|
|
9
|
+
d: Schema.optional(Schema.Unknown),
|
|
10
|
+
s: Schema.optional(Schema.NullOr(Schema.Number)),
|
|
11
|
+
t: Schema.optional(Schema.NullOr(Schema.String))
|
|
12
|
+
});
|
|
13
|
+
const decodeFrame = Schema.decodeUnknownEffect(Schema.fromJsonString(Payload));
|
|
14
|
+
const Hello = Schema.Struct({ heartbeat_interval: Schema.Number });
|
|
15
|
+
const Ready = Schema.Struct({
|
|
16
|
+
user: Schema.Struct({
|
|
17
|
+
id: Schema.String,
|
|
18
|
+
username: Schema.String
|
|
19
|
+
}),
|
|
20
|
+
session_id: Schema.String,
|
|
21
|
+
resume_gateway_url: Schema.String
|
|
22
|
+
});
|
|
23
|
+
/** `GET /gateway/bot`, the only REST call the session needs. */
|
|
24
|
+
const GatewayInfo = Schema.Struct({ url: Schema.String });
|
|
25
|
+
const OP = {
|
|
26
|
+
dispatch: 0,
|
|
27
|
+
heartbeat: 1,
|
|
28
|
+
identify: 2,
|
|
29
|
+
resume: 6,
|
|
30
|
+
reconnect: 7,
|
|
31
|
+
invalidSession: 9,
|
|
32
|
+
hello: 10,
|
|
33
|
+
heartbeatAck: 11
|
|
34
|
+
};
|
|
35
|
+
const fatalReasons = {
|
|
36
|
+
4004: "authentication failed: the bot token was rejected",
|
|
37
|
+
4010: "invalid shard",
|
|
38
|
+
4011: "sharding required",
|
|
39
|
+
4012: "invalid API version",
|
|
40
|
+
4013: "invalid intents",
|
|
41
|
+
4014: "disallowed intents: enable the privileged intent in the developer portal"
|
|
42
|
+
};
|
|
43
|
+
const staleSessionCodes = [4007, 4009];
|
|
44
|
+
/**
|
|
45
|
+
* Everything else, transport drops included, resumes: Discord only names the
|
|
46
|
+
* codes that must not be retried, so an unknown code is a reconnect.
|
|
47
|
+
*/
|
|
48
|
+
const classifyClose = (code) => Match.value(code).pipe(Match.when((c) => c in fatalReasons, () => "fatal"), Match.when((c) => staleSessionCodes.includes(c), () => "reidentify"), Match.orElse(() => "resume"));
|
|
49
|
+
/** Discord's own words for a fatal code, since its close frames carry no reason. */
|
|
50
|
+
const closeReason = (code, reason) => fatalReasons[code] ?? (reason === void 0 || reason === "" ? `gateway closed with ${code}` : reason);
|
|
51
|
+
const RESUMABLE_CLOSE = 4e3;
|
|
52
|
+
const socketCode = (error) => error.reason._tag === "SocketCloseError" ? error.reason.code : 1006;
|
|
53
|
+
const socketReason = (error) => error.reason._tag === "SocketCloseError" ? error.reason.closeReason ?? error.reason.message : error.reason.message;
|
|
54
|
+
const freshSessionDelay = Effect.sync(() => Duration.millis(1e3 + Math.random() * 4e3));
|
|
55
|
+
const backoff = (attempt) => Duration.millis(Math.min(6e4, 500 * 2 ** attempt));
|
|
56
|
+
const identity = {
|
|
57
|
+
os: "effect-uai",
|
|
58
|
+
browser: "effect-uai",
|
|
59
|
+
device: "effect-uai"
|
|
60
|
+
};
|
|
61
|
+
/**
|
|
62
|
+
* One gateway session: connect, identify, heartbeat, resume, reconnect.
|
|
63
|
+
*
|
|
64
|
+
* Returns once `READY` has arrived, so a bad token or a disallowed intent is
|
|
65
|
+
* a `MessengerConnectFailed` at layer build rather than a stream that dies a
|
|
66
|
+
* moment later. From then on reconnects are silent and only a fatal close
|
|
67
|
+
* ends `dispatches`.
|
|
68
|
+
*/
|
|
69
|
+
const connect = (cfg) => Effect.gen(function* () {
|
|
70
|
+
const dispatches = yield* Queue.unbounded();
|
|
71
|
+
const ready = yield* Deferred.make();
|
|
72
|
+
const resume = yield* Ref.make(Option.none());
|
|
73
|
+
const attempts = yield* Ref.make(0);
|
|
74
|
+
const once = Effect.gen(function* () {
|
|
75
|
+
const from = yield* Ref.get(resume);
|
|
76
|
+
const host = Option.match(from, {
|
|
77
|
+
onNone: () => cfg.url,
|
|
78
|
+
onSome: (r) => r.url
|
|
79
|
+
});
|
|
80
|
+
const socket = yield* Socket.makeWebSocket(`${host}/?v=10&encoding=json`, { closeCodeIsError: (code) => code !== 1e3 && code !== 1001 && code !== 1005 }).pipe(Effect.provide(Socket.layerWebSocketConstructorGlobal));
|
|
81
|
+
const write = yield* socket.writer;
|
|
82
|
+
const send = (payload) => write(JSON.stringify(payload));
|
|
83
|
+
const seq = yield* Ref.make(Option.none());
|
|
84
|
+
const acked = yield* Ref.make(true);
|
|
85
|
+
const requested = yield* Ref.make(Option.none());
|
|
86
|
+
const heartbeat = Effect.gen(function* () {
|
|
87
|
+
const s = yield* Ref.get(seq);
|
|
88
|
+
return yield* send({
|
|
89
|
+
op: OP.heartbeat,
|
|
90
|
+
d: Option.getOrNull(s)
|
|
91
|
+
});
|
|
92
|
+
});
|
|
93
|
+
const beat = Effect.gen(function* () {
|
|
94
|
+
if (!(yield* Ref.getAndSet(acked, false))) {
|
|
95
|
+
yield* write(new Socket.CloseEvent(RESUMABLE_CLOSE, "no heartbeat ack"));
|
|
96
|
+
return false;
|
|
97
|
+
}
|
|
98
|
+
yield* heartbeat;
|
|
99
|
+
return true;
|
|
100
|
+
});
|
|
101
|
+
const beating = (interval) => Effect.sleep(Duration.millis(interval * Math.random())).pipe(Effect.andThen(beat.pipe(Effect.repeat({
|
|
102
|
+
schedule: Schedule.spaced(Duration.millis(interval)),
|
|
103
|
+
while: (alive) => alive
|
|
104
|
+
}))), Effect.ignore, Effect.forkScoped);
|
|
105
|
+
const hello = (d) => Effect.gen(function* () {
|
|
106
|
+
const { heartbeat_interval } = yield* Schema.decodeUnknownEffect(Hello)(d);
|
|
107
|
+
yield* beating(heartbeat_interval);
|
|
108
|
+
const from = yield* Ref.get(resume);
|
|
109
|
+
const s = yield* Ref.get(seq);
|
|
110
|
+
return yield* Option.match(from, {
|
|
111
|
+
onNone: () => send({
|
|
112
|
+
op: OP.identify,
|
|
113
|
+
d: {
|
|
114
|
+
token: Redacted.value(cfg.token),
|
|
115
|
+
intents: cfg.intents,
|
|
116
|
+
properties: identity
|
|
117
|
+
}
|
|
118
|
+
}),
|
|
119
|
+
onSome: (r) => send({
|
|
120
|
+
op: OP.resume,
|
|
121
|
+
d: {
|
|
122
|
+
token: Redacted.value(cfg.token),
|
|
123
|
+
session_id: r.id,
|
|
124
|
+
seq: Option.getOrNull(s)
|
|
125
|
+
}
|
|
126
|
+
})
|
|
127
|
+
});
|
|
128
|
+
});
|
|
129
|
+
const onReady = (d) => Effect.gen(function* () {
|
|
130
|
+
const info = yield* Schema.decodeUnknownEffect(Ready)(d);
|
|
131
|
+
yield* Ref.set(resume, Option.some({
|
|
132
|
+
id: info.session_id,
|
|
133
|
+
url: info.resume_gateway_url
|
|
134
|
+
}));
|
|
135
|
+
yield* Ref.set(attempts, 0);
|
|
136
|
+
yield* Deferred.succeed(ready, info.user);
|
|
137
|
+
});
|
|
138
|
+
const onDispatch = (payload) => Effect.gen(function* () {
|
|
139
|
+
if (payload.s != null) yield* Ref.set(seq, Option.some(payload.s));
|
|
140
|
+
if (payload.t === "READY") return yield* onReady(payload.d);
|
|
141
|
+
if (payload.t === "RESUMED") return yield* Ref.set(attempts, 0);
|
|
142
|
+
const dispatch = yield* Schema.decodeUnknownEffect(Dispatch)({
|
|
143
|
+
t: payload.t,
|
|
144
|
+
d: payload.d
|
|
145
|
+
}).pipe(Effect.option);
|
|
146
|
+
if (Option.isSome(dispatch)) yield* Queue.offer(dispatches, {
|
|
147
|
+
dispatch: dispatch.value,
|
|
148
|
+
raw: payload.d
|
|
149
|
+
});
|
|
150
|
+
});
|
|
151
|
+
const invalidSession = (d) => Effect.gen(function* () {
|
|
152
|
+
if (d !== true) {
|
|
153
|
+
yield* Ref.set(resume, Option.none());
|
|
154
|
+
yield* Ref.set(requested, Option.some("reidentify"));
|
|
155
|
+
}
|
|
156
|
+
yield* write(new Socket.CloseEvent(RESUMABLE_CLOSE, "invalid session"));
|
|
157
|
+
});
|
|
158
|
+
const handle = (raw) => Effect.gen(function* () {
|
|
159
|
+
const payload = yield* decodeFrame(raw).pipe(Effect.option);
|
|
160
|
+
if (Option.isNone(payload)) return;
|
|
161
|
+
const message = payload.value;
|
|
162
|
+
yield* Match.value(message.op).pipe(Match.when(OP.dispatch, () => onDispatch(message)), Match.when(OP.hello, () => hello(message.d)), Match.when(OP.heartbeatAck, () => Ref.set(acked, true)), Match.when(OP.heartbeat, () => heartbeat), Match.when(OP.reconnect, () => write(new Socket.CloseEvent(RESUMABLE_CLOSE, "reconnect requested"))), Match.when(OP.invalidSession, () => invalidSession(message.d)), Match.orElse(() => Effect.void));
|
|
163
|
+
}).pipe(Effect.ignore);
|
|
164
|
+
const ended = yield* socket.runString(handle).pipe(Effect.as({
|
|
165
|
+
action: "resume",
|
|
166
|
+
code: 1e3,
|
|
167
|
+
reason: "closed"
|
|
168
|
+
}), Effect.catch((error) => {
|
|
169
|
+
const code = socketCode(error);
|
|
170
|
+
return Effect.succeed({
|
|
171
|
+
action: classifyClose(code),
|
|
172
|
+
code,
|
|
173
|
+
reason: closeReason(code, socketReason(error))
|
|
174
|
+
});
|
|
175
|
+
}));
|
|
176
|
+
const override = yield* Ref.get(requested);
|
|
177
|
+
return Option.match(override, {
|
|
178
|
+
onNone: () => ended,
|
|
179
|
+
onSome: (action) => ({
|
|
180
|
+
...ended,
|
|
181
|
+
action
|
|
182
|
+
})
|
|
183
|
+
});
|
|
184
|
+
}).pipe(Effect.scoped);
|
|
185
|
+
const cycle = Effect.gen(function* () {
|
|
186
|
+
const ended = yield* once;
|
|
187
|
+
if (ended.action === "fatal") return yield* new MessengerError.MessengerTransportClosed({
|
|
188
|
+
provider,
|
|
189
|
+
reason: ended.reason,
|
|
190
|
+
raw: { code: ended.code }
|
|
191
|
+
});
|
|
192
|
+
const attempt = yield* Ref.get(attempts);
|
|
193
|
+
yield* Ref.set(attempts, attempt + 1);
|
|
194
|
+
const wait = ended.action === "reidentify" ? yield* freshSessionDelay : backoff(attempt);
|
|
195
|
+
yield* Effect.logDebug("gateway reconnecting", {
|
|
196
|
+
code: ended.code,
|
|
197
|
+
action: ended.action,
|
|
198
|
+
attempt,
|
|
199
|
+
in: Duration.toSeconds(wait)
|
|
200
|
+
});
|
|
201
|
+
yield* Effect.sleep(wait);
|
|
202
|
+
});
|
|
203
|
+
yield* Effect.forever(cycle).pipe(Effect.catch((closed) => Deferred.fail(ready, new MessengerError.MessengerConnectFailed({
|
|
204
|
+
provider,
|
|
205
|
+
reason: closed.reason,
|
|
206
|
+
raw: closed.raw
|
|
207
|
+
})).pipe(Effect.andThen(Queue.fail(dispatches, closed)))), Effect.ensuring(Queue.end(dispatches)), Effect.forkScoped);
|
|
208
|
+
return {
|
|
209
|
+
bot: yield* Deferred.await(ready),
|
|
210
|
+
dispatches
|
|
211
|
+
};
|
|
212
|
+
});
|
|
213
|
+
//#endregion
|
|
214
|
+
export { GatewayInfo, Ready, classifyClose, closeReason, connect };
|
|
215
|
+
|
|
216
|
+
//# sourceMappingURL=gateway.mjs.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"gateway.mjs","names":["Events.Dispatch"],"sources":["../../src/internal/gateway.ts"],"sourcesContent":["import {\n type Cause,\n Deferred,\n Duration,\n Effect,\n Match,\n Option,\n Queue,\n Redacted,\n Ref,\n Schedule,\n Schema,\n type Scope,\n} from \"effect\"\nimport * as Socket from \"effect/unstable/socket/Socket\"\nimport * as MessengerError from \"@effect-uai/core/MessengerError\"\nimport * as Events from \"./events.js\"\nimport { provider } from \"./rest.js\"\n\n// ---------------------------------------------------------------------------\n// Wire shapes\n// ---------------------------------------------------------------------------\n\nconst Payload = Schema.Struct({\n op: Schema.Number,\n d: Schema.optional(Schema.Unknown),\n s: Schema.optional(Schema.NullOr(Schema.Number)),\n t: Schema.optional(Schema.NullOr(Schema.String)),\n})\ntype Payload = typeof Payload.Type\n\nconst decodeFrame = Schema.decodeUnknownEffect(Schema.fromJsonString(Payload))\n\nconst Hello = Schema.Struct({ heartbeat_interval: Schema.Number })\n\nexport const Ready = Schema.Struct({\n user: Schema.Struct({ id: Schema.String, username: Schema.String }),\n session_id: Schema.String,\n resume_gateway_url: Schema.String,\n})\nexport type Ready = typeof Ready.Type\n\n/** `GET /gateway/bot`, the only REST call the session needs. */\nexport const GatewayInfo = Schema.Struct({ url: Schema.String })\n\n// Send: 1 heartbeat, 2 identify, 6 resume. Receive: 0 dispatch, 7 reconnect,\n// 9 invalid session, 10 hello, 11 heartbeat ack.\nconst OP = {\n dispatch: 0,\n heartbeat: 1,\n identify: 2,\n resume: 6,\n reconnect: 7,\n invalidSession: 9,\n hello: 10,\n heartbeatAck: 11,\n} as const\n\n// ---------------------------------------------------------------------------\n// Close codes\n// ---------------------------------------------------------------------------\n\n/** What the session does once a connection is gone. */\nexport type CloseAction = \"resume\" | \"reidentify\" | \"fatal\"\n\n// Fatal: nothing about reconnecting would change the answer.\nconst fatalReasons: Record<number, string> = {\n 4004: \"authentication failed: the bot token was rejected\",\n 4010: \"invalid shard\",\n 4011: \"sharding required\",\n 4012: \"invalid API version\",\n 4013: \"invalid intents\",\n 4014: \"disallowed intents: enable the privileged intent in the developer portal\",\n}\n\n// The session is gone but the token is fine: identify fresh.\nconst staleSessionCodes = [4007, 4009]\n\n/**\n * Everything else, transport drops included, resumes: Discord only names the\n * codes that must not be retried, so an unknown code is a reconnect.\n */\nexport const classifyClose = (code: number): CloseAction =>\n Match.value(code).pipe(\n Match.when(\n (c) => c in fatalReasons,\n (): CloseAction => \"fatal\",\n ),\n Match.when(\n (c) => staleSessionCodes.includes(c),\n (): CloseAction => \"reidentify\",\n ),\n Match.orElse((): CloseAction => \"resume\"),\n )\n\n/** Discord's own words for a fatal code, since its close frames carry no reason. */\nexport const closeReason = (code: number, reason?: string): string =>\n fatalReasons[code] ??\n (reason === undefined || reason === \"\" ? `gateway closed with ${code}` : reason)\n\n// A close we send ourselves. Anything but 1000/1001 keeps the session alive on\n// Discord's side, which is what makes the next connection resumable.\nconst RESUMABLE_CLOSE = 4000\n\n// ---------------------------------------------------------------------------\n// Session\n// ---------------------------------------------------------------------------\n\nexport type Config = {\n readonly token: Redacted.Redacted\n readonly intents: number\n /** From `GET /gateway/bot`. Resumes go to `resume_gateway_url` instead. */\n readonly url: string\n}\n\n/** A decoded dispatch beside the payload it came from, which `raw` carries. */\nexport type Incoming = {\n readonly dispatch: Events.Dispatch\n readonly raw: unknown\n}\n\nexport type Session = {\n readonly bot: Events.BotIdentity\n /**\n * Dispatches in arrival order, ended when the scope closes and failed with\n * `MessengerTransportClosed` on a fatal gateway close.\n */\n readonly dispatches: Queue.Queue<Incoming, MessengerError.MessengerError | Cause.Done>\n}\n\ntype Resume = { readonly id: string; readonly url: string }\n\ntype Ended = { readonly action: CloseAction; readonly code: number; readonly reason: string }\n\nconst socketCode = (error: Socket.SocketError): number =>\n error.reason._tag === \"SocketCloseError\" ? error.reason.code : 1006\n\nconst socketReason = (error: Socket.SocketError): string =>\n error.reason._tag === \"SocketCloseError\"\n ? (error.reason.closeReason ?? error.reason.message)\n : error.reason.message\n\n// Discord asks for a random 1-5 s pause before identifying on a fresh session.\nconst freshSessionDelay = Effect.sync(() => Duration.millis(1000 + Math.random() * 4000))\n\n// Capped exponential, from a beat to a minute. Reset once a connection is live.\nconst backoff = (attempt: number): Duration.Duration =>\n Duration.millis(Math.min(60_000, 500 * 2 ** attempt))\n\nconst identity = { os: \"effect-uai\", browser: \"effect-uai\", device: \"effect-uai\" }\n\n/**\n * One gateway session: connect, identify, heartbeat, resume, reconnect.\n *\n * Returns once `READY` has arrived, so a bad token or a disallowed intent is\n * a `MessengerConnectFailed` at layer build rather than a stream that dies a\n * moment later. From then on reconnects are silent and only a fatal close\n * ends `dispatches`.\n */\nexport const connect = (\n cfg: Config,\n): Effect.Effect<Session, MessengerError.MessengerConnectFailed, Scope.Scope> =>\n Effect.gen(function* () {\n const dispatches = yield* Queue.unbounded<\n Incoming,\n MessengerError.MessengerError | Cause.Done\n >()\n const ready = yield* Deferred.make<Events.BotIdentity, MessengerError.MessengerConnectFailed>()\n const resume = yield* Ref.make(Option.none<Resume>())\n const attempts = yield* Ref.make(0)\n\n // -- one connection ----------------------------------------------------\n\n const once = Effect.gen(function* () {\n const from = yield* Ref.get(resume)\n // A resumable session has its own host; a fresh one starts at the URL\n // `GET /gateway/bot` gave us.\n const host = Option.match(from, { onNone: () => cfg.url, onSome: (r: Resume) => r.url })\n const socket = yield* Socket.makeWebSocket(`${host}/?v=10&encoding=json`, {\n // Effect treats every close as an error by default; the standard clean\n // codes are not, and Discord's 4xxx ones are what drives `classifyClose`.\n closeCodeIsError: (code: number) => code !== 1000 && code !== 1001 && code !== 1005,\n // The gateway takes its token in the identify payload, not a header,\n // so every runtime's global `WebSocket` is enough.\n }).pipe(Effect.provide(Socket.layerWebSocketConstructorGlobal))\n const write = yield* socket.writer\n const send = (payload: unknown) => write(JSON.stringify(payload))\n const seq = yield* Ref.make(Option.none<number>())\n const acked = yield* Ref.make(true)\n // Set by op 9 with `d: false`, the one case the close code cannot express.\n const requested = yield* Ref.make(Option.none<CloseAction>())\n\n const heartbeat = Effect.gen(function* () {\n const s = yield* Ref.get(seq)\n return yield* send({ op: OP.heartbeat, d: Option.getOrNull(s) })\n })\n\n // A beat with no ACK since the last one means the connection is a zombie:\n // close it non-cleanly so the next connection resumes.\n const beat = Effect.gen(function* () {\n const alive = yield* Ref.getAndSet(acked, false)\n if (!alive) {\n yield* write(new Socket.CloseEvent(RESUMABLE_CLOSE, \"no heartbeat ack\"))\n return false\n }\n yield* heartbeat\n return true\n })\n\n // Discord asks for the first beat to land at a random point in the\n // interval, so a fleet of bots does not beat in lockstep.\n const beating = (interval: number) =>\n Effect.sleep(Duration.millis(interval * Math.random())).pipe(\n Effect.andThen(\n beat.pipe(\n Effect.repeat({\n schedule: Schedule.spaced(Duration.millis(interval)),\n while: (alive: boolean) => alive,\n }),\n ),\n ),\n Effect.ignore,\n Effect.forkScoped,\n )\n\n const hello = (d: unknown) =>\n Effect.gen(function* () {\n const { heartbeat_interval } = yield* Schema.decodeUnknownEffect(Hello)(d)\n yield* beating(heartbeat_interval)\n const from = yield* Ref.get(resume)\n const s = yield* Ref.get(seq)\n return yield* Option.match(from, {\n onNone: () =>\n send({\n op: OP.identify,\n d: {\n token: Redacted.value(cfg.token),\n intents: cfg.intents,\n properties: identity,\n },\n }),\n onSome: (r) =>\n send({\n op: OP.resume,\n d: {\n token: Redacted.value(cfg.token),\n session_id: r.id,\n seq: Option.getOrNull(s),\n },\n }),\n })\n })\n\n const onReady = (d: unknown) =>\n Effect.gen(function* () {\n const info = yield* Schema.decodeUnknownEffect(Ready)(d)\n yield* Ref.set(resume, Option.some({ id: info.session_id, url: info.resume_gateway_url }))\n yield* Ref.set(attempts, 0)\n yield* Deferred.succeed(ready, info.user)\n })\n\n const onDispatch = (payload: Payload) =>\n Effect.gen(function* () {\n if (payload.s != null) yield* Ref.set(seq, Option.some(payload.s))\n if (payload.t === \"READY\") return yield* onReady(payload.d)\n if (payload.t === \"RESUMED\") return yield* Ref.set(attempts, 0)\n const dispatch = yield* Schema.decodeUnknownEffect(Events.Dispatch)({\n t: payload.t,\n d: payload.d,\n }).pipe(Effect.option)\n if (Option.isSome(dispatch)) {\n yield* Queue.offer(dispatches, { dispatch: dispatch.value, raw: payload.d })\n }\n })\n\n // `d: true` keeps the session, `d: false` drops it; both mean reconnect.\n const invalidSession = (d: unknown) =>\n Effect.gen(function* () {\n if (d !== true) {\n yield* Ref.set(resume, Option.none())\n yield* Ref.set(requested, Option.some<CloseAction>(\"reidentify\"))\n }\n yield* write(new Socket.CloseEvent(RESUMABLE_CLOSE, \"invalid session\"))\n })\n\n const handle = (raw: string) =>\n Effect.gen(function* () {\n const payload = yield* decodeFrame(raw).pipe(Effect.option)\n if (Option.isNone(payload)) return\n const message = payload.value\n yield* Match.value(message.op).pipe(\n Match.when(OP.dispatch, () => onDispatch(message)),\n Match.when(OP.hello, () => hello(message.d)),\n Match.when(OP.heartbeatAck, () => Ref.set(acked, true)),\n Match.when(OP.heartbeat, () => heartbeat),\n Match.when(OP.reconnect, () =>\n write(new Socket.CloseEvent(RESUMABLE_CLOSE, \"reconnect requested\")),\n ),\n Match.when(OP.invalidSession, () => invalidSession(message.d)),\n Match.orElse(() => Effect.void),\n )\n }).pipe(Effect.ignore)\n\n // A clean 1000 is only ever our own teardown; anything else carries the\n // close code the classifier reads.\n const ended = yield* socket.runString(handle).pipe(\n Effect.as<Ended>({ action: \"resume\", code: 1000, reason: \"closed\" }),\n Effect.catch((error: Socket.SocketError) => {\n const code = socketCode(error)\n return Effect.succeed<Ended>({\n action: classifyClose(code),\n code,\n reason: closeReason(code, socketReason(error)),\n })\n }),\n )\n const override = yield* Ref.get(requested)\n return Option.match(override, {\n onNone: (): Ended => ended,\n onSome: (action): Ended => ({ ...ended, action }),\n })\n }).pipe(Effect.scoped)\n\n // -- the reconnect loop ------------------------------------------------\n\n const cycle = Effect.gen(function* () {\n const ended = yield* once\n if (ended.action === \"fatal\") {\n return yield* new MessengerError.MessengerTransportClosed({\n provider,\n reason: ended.reason,\n raw: { code: ended.code },\n })\n }\n const attempt = yield* Ref.get(attempts)\n yield* Ref.set(attempts, attempt + 1)\n const wait = ended.action === \"reidentify\" ? yield* freshSessionDelay : backoff(attempt)\n // Reconnects never reach `events`, so this is the only sign of one. A\n // dropped connection is noticed only when a heartbeat goes unacked,\n // which is up to two intervals after the network actually went away.\n yield* Effect.logDebug(\"gateway reconnecting\", {\n code: ended.code,\n action: ended.action,\n attempt,\n in: Duration.toSeconds(wait),\n })\n yield* Effect.sleep(wait)\n })\n\n // Failing the deferred is a no-op once `READY` has landed, so the same\n // fatal close is a connect failure before it and a transport close after.\n yield* Effect.forever(cycle).pipe(\n Effect.catch((closed: MessengerError.MessengerTransportClosed) =>\n Deferred.fail(\n ready,\n new MessengerError.MessengerConnectFailed({\n provider,\n reason: closed.reason,\n raw: closed.raw,\n }),\n ).pipe(Effect.andThen(Queue.fail(dispatches, closed))),\n ),\n Effect.ensuring(Queue.end(dispatches)),\n Effect.forkScoped,\n )\n\n return { bot: yield* Deferred.await(ready), dispatches }\n })\n"],"mappings":"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"}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {}
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import { classifyClose, closeReason } from "./gateway.mjs";
|
|
2
|
+
import { n as describe, r as it, t as globalExpect } from "../test.DNmyFkvJ-N3QqMRwZ.mjs";
|
|
3
|
+
//#region src/internal/gateway.test.ts
|
|
4
|
+
describe("classifyClose", () => {
|
|
5
|
+
it("never retries a close reconnecting cannot fix", () => {
|
|
6
|
+
globalExpect([
|
|
7
|
+
4004,
|
|
8
|
+
4010,
|
|
9
|
+
4011,
|
|
10
|
+
4012,
|
|
11
|
+
4013,
|
|
12
|
+
4014
|
|
13
|
+
].map(classifyClose)).toEqual(Array(6).fill("fatal"));
|
|
14
|
+
});
|
|
15
|
+
it("identifies fresh only where the session itself is gone", () => {
|
|
16
|
+
globalExpect([4007, 4009].map(classifyClose)).toEqual(["reidentify", "reidentify"]);
|
|
17
|
+
});
|
|
18
|
+
it("resumes on everything else, transport drops included", () => {
|
|
19
|
+
globalExpect([
|
|
20
|
+
4e3,
|
|
21
|
+
4003,
|
|
22
|
+
4005,
|
|
23
|
+
4008,
|
|
24
|
+
1006,
|
|
25
|
+
1011
|
|
26
|
+
].map(classifyClose)).toEqual(Array(6).fill("resume"));
|
|
27
|
+
});
|
|
28
|
+
});
|
|
29
|
+
describe("closeReason", () => {
|
|
30
|
+
it("names a fatal code, since Discord's close frames carry no reason", () => {
|
|
31
|
+
globalExpect(closeReason(4014, "")).toContain("privileged intent");
|
|
32
|
+
});
|
|
33
|
+
it("falls back to whatever the socket reported", () => {
|
|
34
|
+
globalExpect(closeReason(1006, "connection reset")).toBe("connection reset");
|
|
35
|
+
});
|
|
36
|
+
});
|
|
37
|
+
//#endregion
|
|
38
|
+
export {};
|
|
39
|
+
|
|
40
|
+
//# sourceMappingURL=gateway.test.mjs.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"gateway.test.mjs","names":[],"sources":["../../src/internal/gateway.test.ts"],"sourcesContent":["import { describe, expect, it } from \"vitest\"\nimport { classifyClose, closeReason } from \"./gateway.js\"\n\ndescribe(\"classifyClose\", () => {\n it(\"never retries a close reconnecting cannot fix\", () => {\n expect([4004, 4010, 4011, 4012, 4013, 4014].map(classifyClose)).toEqual(Array(6).fill(\"fatal\"))\n })\n\n it(\"identifies fresh only where the session itself is gone\", () => {\n expect([4007, 4009].map(classifyClose)).toEqual([\"reidentify\", \"reidentify\"])\n })\n\n it(\"resumes on everything else, transport drops included\", () => {\n // 1006 is a dropped connection, 4000 the code the zombie check sends.\n expect([4000, 4003, 4005, 4008, 1006, 1011].map(classifyClose)).toEqual(Array(6).fill(\"resume\"))\n })\n})\n\ndescribe(\"closeReason\", () => {\n it(\"names a fatal code, since Discord's close frames carry no reason\", () => {\n expect(closeReason(4014, \"\")).toContain(\"privileged intent\")\n })\n\n it(\"falls back to whatever the socket reported\", () => {\n expect(closeReason(1006, \"connection reset\")).toBe(\"connection reset\")\n })\n})\n"],"mappings":";;;AAGA,SAAS,uBAAuB;CAC9B,GAAG,uDAAuD;EACxD,aAAO;GAAC;GAAM;GAAM;GAAM;GAAM;GAAM;EAAI,CAAC,CAAC,IAAI,aAAa,CAAC,CAAC,CAAC,QAAQ,MAAM,CAAC,CAAC,CAAC,KAAK,OAAO,CAAC;CAChG,CAAC;CAED,GAAG,gEAAgE;EACjE,aAAO,CAAC,MAAM,IAAI,CAAC,CAAC,IAAI,aAAa,CAAC,CAAC,CAAC,QAAQ,CAAC,cAAc,YAAY,CAAC;CAC9E,CAAC;CAED,GAAG,8DAA8D;EAE/D,aAAO;GAAC;GAAM;GAAM;GAAM;GAAM;GAAM;EAAI,CAAC,CAAC,IAAI,aAAa,CAAC,CAAC,CAAC,QAAQ,MAAM,CAAC,CAAC,CAAC,KAAK,QAAQ,CAAC;CACjG,CAAC;AACH,CAAC;AAED,SAAS,qBAAqB;CAC5B,GAAG,0EAA0E;EAC3E,aAAO,YAAY,MAAM,EAAE,CAAC,CAAC,CAAC,UAAU,mBAAmB;CAC7D,CAAC;CAED,GAAG,oDAAoD;EACrD,aAAO,YAAY,MAAM,kBAAkB,CAAC,CAAC,CAAC,KAAK,kBAAkB;CACvE,CAAC;AACH,CAAC"}
|