opencode-discord-bot 0.0.1

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 (58) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +13 -0
  3. package/package.json +78 -0
  4. package/src/Bridge/LoopbackServer.test.ts +94 -0
  5. package/src/Bridge/LoopbackServer.ts +77 -0
  6. package/src/Bridge/ToolControl.test.ts +245 -0
  7. package/src/Bridge/ToolControl.ts +260 -0
  8. package/src/Bridge/ToolControlEdges.test.ts +49 -0
  9. package/src/Bridge/ToolControlHighRisk.test.ts +153 -0
  10. package/src/Config.test.ts +142 -0
  11. package/src/Config.ts +295 -0
  12. package/src/ConfigSchema.ts +46 -0
  13. package/src/ConfigTypes.ts +11 -0
  14. package/src/Discord/ChatSdkDiscord.test.ts +257 -0
  15. package/src/Discord/ChatSdkDiscord.ts +206 -0
  16. package/src/Discord/ChatSdkGatewayIntake.test.ts +121 -0
  17. package/src/Discord/ChatSdkGatewayIntake.ts +235 -0
  18. package/src/Discord/DiscordGateway.test.ts +215 -0
  19. package/src/Discord/DiscordGateway.ts +140 -0
  20. package/src/Discord/DiscordGatewayFailures.test.ts +148 -0
  21. package/src/Discord/DiscordJsDiscord.test.ts +208 -0
  22. package/src/Discord/DiscordJsDiscord.ts +267 -0
  23. package/src/Discord/DiscordPort.ts +30 -0
  24. package/src/Discord/MemoryDiscord.test.ts +44 -0
  25. package/src/Discord/MemoryDiscord.ts +85 -0
  26. package/src/Discord/Safety.ts +11 -0
  27. package/src/Main.test.ts +273 -0
  28. package/src/Main.ts +192 -0
  29. package/src/MainQueue.test.ts +124 -0
  30. package/src/Opencode/EventMapping.test.ts +188 -0
  31. package/src/Opencode/EventMapping.ts +232 -0
  32. package/src/Opencode/EventMappingState.ts +97 -0
  33. package/src/Opencode/MemoryOpencode.test.ts +18 -0
  34. package/src/Opencode/MemoryOpencode.ts +29 -0
  35. package/src/Opencode/OpencodePort.ts +30 -0
  36. package/src/Opencode/PromptParts.ts +47 -0
  37. package/src/Opencode/SdkOpencode.test.ts +280 -0
  38. package/src/Opencode/SdkOpencode.ts +270 -0
  39. package/src/Opencode/SdkOpencodeAttachments.test.ts +79 -0
  40. package/src/Opencode/SdkOpencodeFailures.test.ts +113 -0
  41. package/src/Orchestrator/ContextAssembly.test.ts +115 -0
  42. package/src/Orchestrator/ContextAssembly.ts +120 -0
  43. package/src/Orchestrator/Orchestrator.ts +67 -0
  44. package/src/Orchestrator/StopCommand.test.ts +20 -0
  45. package/src/Orchestrator/StopCommand.ts +14 -0
  46. package/src/Orchestrator/Triggering.test.ts +56 -0
  47. package/src/Orchestrator/Triggering.ts +26 -0
  48. package/src/Orchestrator/TurnManager.test.ts +180 -0
  49. package/src/Orchestrator/TurnManager.ts +179 -0
  50. package/src/Orchestrator/TurnManagerStrategy.test.ts +136 -0
  51. package/src/PublicContracts.test.ts +43 -0
  52. package/src/Render/Renderer.test.ts +249 -0
  53. package/src/Render/Renderer.ts +159 -0
  54. package/src/Render/Splitting.test.ts +30 -0
  55. package/src/Render/Splitting.ts +68 -0
  56. package/src/Schema.ts +93 -0
  57. package/src/Tools/Scaffolding.test.ts +56 -0
  58. package/src/Tools/Scaffolding.ts +60 -0
@@ -0,0 +1,215 @@
1
+ import { describe, expect, test } from "bun:test"
2
+ import { Events, GatewayIntentBits, Partials } from "discord.js"
3
+ import { Effect } from "effect"
4
+ import type { DiscordScope } from "../Schema.ts"
5
+ import {
6
+ contextReactionPartials,
7
+ type DiscordGatewayClient,
8
+ makeDiscordGatewayClient,
9
+ requiredGatewayIntents,
10
+ startDiscordGateway
11
+ } from "./DiscordGateway.ts"
12
+
13
+ const collection = <A>(items: ReadonlyArray<A>) => ({ values: () => items[Symbol.iterator]() })
14
+
15
+ class FakeGatewayClient implements DiscordGatewayClient {
16
+ readonly user: { readonly id: string } | null
17
+ readonly channels = { fetch: () => Promise.resolve(null) }
18
+ readonly createdCommands: Array<readonly [{ readonly name: string; readonly description: string }, string | undefined]> = []
19
+ readonly application = {
20
+ commands: {
21
+ create: (command: { readonly name: string; readonly description: string }, guildId?: string) => {
22
+ this.createdCommands.push([command, guildId])
23
+ return Promise.resolve({})
24
+ }
25
+ }
26
+ }
27
+ readonly listeners = new Map<string, Array<(value: unknown) => void>>()
28
+ destroyed = false
29
+
30
+ constructor(user: { readonly id: string } | null = { id: "bot-1" }) {
31
+ this.user = user
32
+ }
33
+
34
+ on(event: string, listener: (value: unknown) => void): DiscordGatewayClient {
35
+ this.listeners.set(event, [...(this.listeners.get(event) ?? []), listener])
36
+ return this
37
+ }
38
+
39
+ once(event: string, listener: (value: unknown) => void): DiscordGatewayClient {
40
+ const wrapped = (value: unknown) => {
41
+ this.listeners.set(
42
+ event,
43
+ (this.listeners.get(event) ?? []).filter((item) => item !== wrapped)
44
+ )
45
+ listener(value)
46
+ }
47
+ return this.on(event, wrapped)
48
+ }
49
+
50
+ emit(event: string, value: unknown): void {
51
+ for (const listener of this.listeners.get(event) ?? []) listener(value)
52
+ }
53
+
54
+ login(): Promise<string> {
55
+ this.emit(Events.ClientReady, this)
56
+ return Promise.resolve("token")
57
+ }
58
+
59
+ destroy(): Promise<void> {
60
+ this.destroyed = true
61
+ return Promise.resolve()
62
+ }
63
+ }
64
+
65
+ const message = (
66
+ overrides: {
67
+ readonly channel?: {
68
+ readonly id: string
69
+ readonly parentId?: string | null
70
+ readonly isDMBased?: () => boolean
71
+ readonly isThread?: () => boolean
72
+ }
73
+ readonly guildId?: string | null
74
+ readonly channelId?: string
75
+ readonly authorBot?: boolean
76
+ } = {}
77
+ ) => ({
78
+ id: "m1",
79
+ guildId: overrides.guildId ?? "g1",
80
+ channelId: overrides.channelId ?? "c1",
81
+ channel: overrides.channel ?? { id: "c1", isDMBased: () => false, isThread: () => false },
82
+ author: { id: "u1", username: "alice", displayName: "Alice", bot: overrides.authorBot ?? false },
83
+ member: { nickname: "ali" },
84
+ content: "hello <@bot-1>",
85
+ createdAt: new Date("2026-06-05T14:03:00.000Z"),
86
+ mentions: { users: collection([{ id: "bot-1" }]), roles: collection([{ id: "r1" }]), everyone: false },
87
+ attachments: collection([{ id: "a1", name: "a.txt", contentType: "text/plain", size: 1, url: "https://example.test/a.txt" }]),
88
+ reactions: { cache: collection([{ emoji: { name: "rocket", identifier: "rocket" }, count: 2 }]) },
89
+ system: false,
90
+ inGuild: () => overrides.guildId !== null
91
+ })
92
+
93
+ describe("Discord gateway constants", () => {
94
+ test("requests guild message intents but no DM intents", () => {
95
+ expect(requiredGatewayIntents).toEqual([
96
+ GatewayIntentBits.Guilds,
97
+ GatewayIntentBits.GuildMessages,
98
+ GatewayIntentBits.MessageContent,
99
+ GatewayIntentBits.GuildMessageReactions
100
+ ])
101
+ expect(requiredGatewayIntents.includes(GatewayIntentBits.DirectMessages)).toBe(false)
102
+ expect(contextReactionPartials).toEqual([Partials.Message, Partials.Reaction, Partials.User])
103
+ })
104
+
105
+ test("constructs the production discord.js client", () => {
106
+ const client = makeDiscordGatewayClient()
107
+
108
+ expect(client).toBeDefined()
109
+ client.destroy()
110
+ })
111
+ })
112
+
113
+ describe("startDiscordGateway", () => {
114
+ test("logs in, registers stop, maps guild messages, and destroys on scope close", async () => {
115
+ const client = new FakeGatewayClient()
116
+ const messages: Array<unknown> = []
117
+
118
+ await Effect.runPromise(
119
+ Effect.scoped(
120
+ Effect.gen(function* () {
121
+ const gateway = yield* startDiscordGateway({
122
+ token: "token",
123
+ guildId: "g1",
124
+ createClient: () => client,
125
+ onMessage: (item, bot) => Effect.sync(() => messages.push({ item, bot })),
126
+ onStop: () => Effect.void
127
+ })
128
+
129
+ client.emit(Events.MessageCreate, message())
130
+ yield* Effect.promise(() => new Promise((resolve) => setTimeout(resolve, 0)))
131
+
132
+ expect(gateway.bot).toEqual({ userId: "bot-1" })
133
+ expect(client.createdCommands[0]).toEqual([
134
+ { name: "stop", description: "Stop the active opencode turn in this Discord scope." },
135
+ "g1"
136
+ ])
137
+ expect(messages).toEqual([
138
+ {
139
+ bot: { userId: "bot-1" },
140
+ item: {
141
+ id: "m1",
142
+ guildId: "g1",
143
+ channelId: "c1",
144
+ author: { id: "u1", displayName: "Alice", nickname: "ali", isBot: false },
145
+ content: "hello <@bot-1>",
146
+ timestamp: "2026-06-05T14:03:00.000Z",
147
+ mentions: ["bot-1"],
148
+ roleMentions: ["r1"],
149
+ everyoneMention: false,
150
+ hereMention: false,
151
+ attachments: [{ id: "a1", filename: "a.txt", contentType: "text/plain", size: 1, url: "https://example.test/a.txt" }],
152
+ reactions: [{ emoji: "rocket", count: 2 }],
153
+ channelType: "guild",
154
+ isSystem: false
155
+ }
156
+ }
157
+ ])
158
+ })
159
+ )
160
+ )
161
+
162
+ expect(client.destroyed).toBe(true)
163
+ })
164
+
165
+ test("preserves thread scopes, ignores DMs, and dispatches slash stop", async () => {
166
+ const client = new FakeGatewayClient()
167
+ const messages: Array<unknown> = []
168
+ const stops: Array<DiscordScope> = []
169
+ let replied = false
170
+
171
+ await Effect.runPromise(
172
+ Effect.scoped(
173
+ Effect.gen(function* () {
174
+ yield* startDiscordGateway({
175
+ token: "token",
176
+ createClient: () => client,
177
+ onMessage: (item) => Effect.sync(() => messages.push(item)),
178
+ onStop: (scope) => Effect.sync(() => stops.push(scope))
179
+ })
180
+
181
+ client.emit(Events.MessageCreate, message({ channelId: "t1", channel: { id: "t1", parentId: "c1", isThread: () => true } }))
182
+ client.emit(Events.MessageCreate, message({ guildId: null, channel: { id: "dm1", isDMBased: () => true } }))
183
+ client.emit(Events.InteractionCreate, {
184
+ commandName: "stop",
185
+ guildId: "g1",
186
+ channelId: "t1",
187
+ channel: { id: "t1", parentId: "c1", isThread: () => true },
188
+ isChatInputCommand: () => true,
189
+ inGuild: () => true,
190
+ reply: () => {
191
+ replied = true
192
+ return Promise.resolve({})
193
+ }
194
+ })
195
+ client.emit(Events.InteractionCreate, {
196
+ commandName: "stop",
197
+ guildId: "g1",
198
+ channelId: "t2",
199
+ channel: { id: "t2", isThread: () => true },
200
+ isChatInputCommand: () => true,
201
+ inGuild: () => true
202
+ })
203
+ yield* Effect.promise(() => new Promise((resolve) => setTimeout(resolve, 0)))
204
+
205
+ expect(messages).toEqual([expect.objectContaining({ guildId: "g1", channelId: "c1", threadId: "t1" })])
206
+ expect(stops).toEqual([
207
+ { guildId: "g1", channelId: "c1", threadId: "t1" },
208
+ { guildId: "g1", channelId: "t2", threadId: "t2" }
209
+ ])
210
+ expect(replied).toBe(true)
211
+ })
212
+ )
213
+ )
214
+ })
215
+ })
@@ -0,0 +1,140 @@
1
+ import { Client, Events, GatewayIntentBits, Partials } from "discord.js"
2
+ import { Effect } from "effect"
3
+ import type { BotIdentity, DiscordMessage, DiscordScope } from "../Schema.ts"
4
+ import { type DiscordJsChannelLike, type DiscordJsClientLike, type DiscordJsMessageLike, fromDiscordJsMessage } from "./DiscordJsDiscord.ts"
5
+
6
+ export const requiredGatewayIntents: ReadonlyArray<GatewayIntentBits> = [
7
+ GatewayIntentBits.Guilds,
8
+ GatewayIntentBits.GuildMessages,
9
+ GatewayIntentBits.MessageContent,
10
+ GatewayIntentBits.GuildMessageReactions
11
+ ] as const
12
+
13
+ export const contextReactionPartials: ReadonlyArray<Partials> = [Partials.Message, Partials.Reaction, Partials.User]
14
+
15
+ type InteractionLike = {
16
+ readonly commandName: string
17
+ readonly guildId: string | null
18
+ readonly channelId: string | null
19
+ readonly channel: DiscordJsChannelLike | null
20
+ readonly isChatInputCommand: () => boolean
21
+ readonly inGuild?: () => boolean
22
+ readonly reply?: (input: { readonly content: string; readonly ephemeral: boolean }) => Promise<unknown>
23
+ }
24
+
25
+ export type DiscordGatewayClient = DiscordJsClientLike & {
26
+ readonly application: {
27
+ readonly commands: {
28
+ readonly create: (command: { readonly name: string; readonly description: string }, guildId?: string) => Promise<unknown>
29
+ }
30
+ } | null
31
+ readonly on: (event: string, listener: (value: unknown) => void) => DiscordGatewayClient
32
+ readonly once: (event: string, listener: (value: unknown) => void) => DiscordGatewayClient
33
+ readonly login: (token?: string) => Promise<string>
34
+ readonly destroy: () => Promise<void> | void
35
+ }
36
+
37
+ export type DiscordGateway = {
38
+ readonly bot: BotIdentity
39
+ readonly client: DiscordGatewayClient
40
+ }
41
+
42
+ export type DiscordGatewayOptions = {
43
+ readonly token: string
44
+ readonly guildId?: string | undefined
45
+ readonly createClient?: (() => DiscordGatewayClient) | undefined
46
+ readonly onMessage: (message: DiscordMessage, bot: BotIdentity) => Effect.Effect<void, unknown>
47
+ readonly onStop: (scope: DiscordScope, bot: BotIdentity) => Effect.Effect<void, unknown>
48
+ }
49
+
50
+ const stopCommand = {
51
+ name: "stop",
52
+ description: "Stop the active opencode turn in this Discord scope."
53
+ } as const
54
+
55
+ export const makeDiscordGatewayClient = (): DiscordGatewayClient =>
56
+ new Client({
57
+ intents: [...requiredGatewayIntents],
58
+ partials: [...contextReactionPartials]
59
+ })
60
+
61
+ const runCallback = (effect: Effect.Effect<void, unknown>): void => {
62
+ void Effect.runPromise(effect.pipe(Effect.catch((error) => Effect.logError(`Discord gateway handler failed: ${String(error)}`))))
63
+ }
64
+
65
+ const botFromReady = (ready: unknown, client: DiscordGatewayClient): BotIdentity => {
66
+ const candidate = typeof ready === "object" && ready !== null && "user" in ready ? ready.user : client.user
67
+ if (typeof candidate === "object" && candidate !== null && "id" in candidate && typeof candidate.id === "string")
68
+ return { userId: candidate.id }
69
+ if (client.user !== null) return { userId: client.user.id }
70
+ throw new Error("Discord gateway became ready without a bot user id")
71
+ }
72
+
73
+ const interactionScope = (interaction: InteractionLike): DiscordScope | undefined => {
74
+ if (interaction.inGuild?.() === false || interaction.guildId === null || interaction.channelId === null) return undefined
75
+ const channel = interaction.channel
76
+ if (channel?.isDMBased?.() === true) return undefined
77
+ if (channel?.isThread?.() === true) {
78
+ return { guildId: interaction.guildId, channelId: channel.parentId ?? interaction.channelId, threadId: interaction.channelId }
79
+ }
80
+ return { guildId: interaction.guildId, channelId: interaction.channelId }
81
+ }
82
+
83
+ const isInteraction = (value: unknown): value is InteractionLike =>
84
+ typeof value === "object" &&
85
+ value !== null &&
86
+ "isChatInputCommand" in value &&
87
+ typeof value.isChatInputCommand === "function" &&
88
+ "commandName" in value &&
89
+ typeof value.commandName === "string"
90
+
91
+ const isGatewayMessage = (value: unknown): value is DiscordJsMessageLike =>
92
+ typeof value === "object" && value !== null && "author" in value && "mentions" in value && "channel" in value
93
+
94
+ const registerStopCommand = (client: DiscordGatewayClient, guildId: string | undefined): Effect.Effect<void> =>
95
+ Effect.tryPromise({
96
+ try: async () => {
97
+ if (client.application === null) return
98
+ await client.application.commands.create(stopCommand, guildId)
99
+ },
100
+ catch: () => undefined
101
+ }).pipe(Effect.catch(() => Effect.void))
102
+
103
+ export const startDiscordGateway = Effect.fn("startDiscordGateway")(function* (options: DiscordGatewayOptions) {
104
+ const client = options.createClient?.() ?? makeDiscordGatewayClient()
105
+ const bot = yield* Effect.acquireRelease(
106
+ Effect.tryPromise({
107
+ try: async () => {
108
+ const ready = new Promise<BotIdentity>((resolve, reject) => {
109
+ client.once(Events.ClientReady, (value) => {
110
+ try {
111
+ resolve(botFromReady(value, client))
112
+ } catch (cause) {
113
+ reject(cause)
114
+ }
115
+ })
116
+ })
117
+
118
+ client.on(Events.MessageCreate, (raw) => {
119
+ if (!isGatewayMessage(raw)) return
120
+ const mapped = fromDiscordJsMessage(raw)
121
+ if (mapped !== undefined && client.user !== null) runCallback(options.onMessage(mapped, { userId: client.user.id }))
122
+ })
123
+ client.on(Events.InteractionCreate, (raw) => {
124
+ if (!isInteraction(raw) || !raw.isChatInputCommand() || raw.commandName !== "stop") return
125
+ const scope = interactionScope(raw)
126
+ if (scope === undefined || client.user === null) return
127
+ if (raw.reply !== undefined) void raw.reply({ content: "Stop request received.", ephemeral: true }).catch(() => undefined)
128
+ runCallback(options.onStop(scope, { userId: client.user.id }))
129
+ })
130
+ await client.login(options.token)
131
+ return await ready
132
+ },
133
+ catch: (cause) => (cause instanceof Error ? cause : new Error("Discord gateway startup failed"))
134
+ }),
135
+ () => Effect.promise(() => Promise.resolve(client.destroy())).pipe(Effect.catch(() => Effect.void))
136
+ )
137
+
138
+ yield* registerStopCommand(client, options.guildId)
139
+ return { bot, client } satisfies DiscordGateway
140
+ })
@@ -0,0 +1,148 @@
1
+ import { describe, expect, test } from "bun:test"
2
+ import { Events } from "discord.js"
3
+ import { Effect } from "effect"
4
+ import { type DiscordGatewayClient, startDiscordGateway } from "./DiscordGateway.ts"
5
+
6
+ const emptyCollection = { values: () => [][Symbol.iterator]() }
7
+
8
+ class FakeGatewayClient implements DiscordGatewayClient {
9
+ readonly channels = { fetch: () => Promise.resolve(null) }
10
+ readonly listeners = new Map<string, Array<(value: unknown) => void>>()
11
+ readonly application: DiscordGatewayClient["application"]
12
+
13
+ constructor(
14
+ readonly user: { readonly id: string } | null = { id: "bot-1" },
15
+ application: DiscordGatewayClient["application"] = { commands: { create: () => Promise.resolve({}) } }
16
+ ) {
17
+ this.application = application
18
+ }
19
+
20
+ on(event: string, listener: (value: unknown) => void): DiscordGatewayClient {
21
+ this.listeners.set(event, [...(this.listeners.get(event) ?? []), listener])
22
+ return this
23
+ }
24
+
25
+ once(event: string, listener: (value: unknown) => void): DiscordGatewayClient {
26
+ const wrapped = (value: unknown) => {
27
+ this.listeners.set(
28
+ event,
29
+ (this.listeners.get(event) ?? []).filter((item) => item !== wrapped)
30
+ )
31
+ listener(value)
32
+ }
33
+ return this.on(event, wrapped)
34
+ }
35
+
36
+ emit(event: string, value: unknown): void {
37
+ for (const listener of this.listeners.get(event) ?? []) listener(value)
38
+ }
39
+
40
+ login(): Promise<string> {
41
+ this.emit(Events.ClientReady, this)
42
+ return Promise.resolve("token")
43
+ }
44
+
45
+ destroy(): void {}
46
+ }
47
+
48
+ const message = () => ({
49
+ id: "m1",
50
+ guildId: "g1",
51
+ channelId: "c1",
52
+ channel: { id: "c1", isDMBased: () => false, isThread: () => false },
53
+ author: { id: "u1", username: "alice", bot: false },
54
+ content: "hello",
55
+ createdAt: new Date("2026-06-05T14:03:00.000Z"),
56
+ mentions: { users: emptyCollection, roles: emptyCollection, everyone: false },
57
+ attachments: emptyCollection,
58
+ reactions: { cache: emptyCollection },
59
+ system: false
60
+ })
61
+
62
+ describe("startDiscordGateway failure paths", () => {
63
+ test("falls back to the client user when ready payload has no user", async () => {
64
+ const client = new FakeGatewayClient()
65
+ client.login = () => {
66
+ client.emit(Events.ClientReady, {})
67
+ return Promise.resolve("token")
68
+ }
69
+
70
+ await Effect.runPromise(
71
+ Effect.scoped(
72
+ Effect.gen(function* () {
73
+ const gateway = yield* startDiscordGateway({
74
+ token: "token",
75
+ createClient: () => client,
76
+ onMessage: () => Effect.void,
77
+ onStop: () => Effect.void
78
+ })
79
+
80
+ expect(gateway.bot).toEqual({ userId: "bot-1" })
81
+ })
82
+ )
83
+ )
84
+ })
85
+
86
+ test("handles callback failures and unavailable command registration", async () => {
87
+ const client = new FakeGatewayClient({ id: "bot-1" }, null)
88
+
89
+ await Effect.runPromise(
90
+ Effect.scoped(
91
+ Effect.gen(function* () {
92
+ yield* startDiscordGateway({
93
+ token: "token",
94
+ createClient: () => client,
95
+ onMessage: () => Effect.fail("message failed"),
96
+ onStop: () => Effect.fail("stop failed")
97
+ })
98
+
99
+ client.emit(Events.MessageCreate, message())
100
+ client.emit(Events.InteractionCreate, {
101
+ commandName: "stop",
102
+ guildId: "g1",
103
+ channelId: "c1",
104
+ channel: { id: "c1" },
105
+ isChatInputCommand: () => true,
106
+ inGuild: () => true
107
+ })
108
+ yield* Effect.promise(() => new Promise((resolve) => setTimeout(resolve, 0)))
109
+ })
110
+ )
111
+ )
112
+ })
113
+
114
+ test("fails startup when login fails or ready has no bot identity", async () => {
115
+ const loginFailure = new FakeGatewayClient()
116
+ loginFailure.login = () => Promise.reject(new Error("bad token"))
117
+ await expect(
118
+ Effect.runPromise(
119
+ Effect.scoped(
120
+ startDiscordGateway({
121
+ token: "token",
122
+ createClient: () => loginFailure,
123
+ onMessage: () => Effect.void,
124
+ onStop: () => Effect.void
125
+ })
126
+ )
127
+ )
128
+ ).rejects.toMatchObject({ message: "bad token" })
129
+
130
+ const missingIdentity = new FakeGatewayClient(null)
131
+ missingIdentity.login = () => {
132
+ missingIdentity.emit(Events.ClientReady, {})
133
+ return Promise.resolve("token")
134
+ }
135
+ await expect(
136
+ Effect.runPromise(
137
+ Effect.scoped(
138
+ startDiscordGateway({
139
+ token: "token",
140
+ createClient: () => missingIdentity,
141
+ onMessage: () => Effect.void,
142
+ onStop: () => Effect.void
143
+ })
144
+ )
145
+ )
146
+ ).rejects.toMatchObject({ message: "Discord gateway became ready without a bot user id" })
147
+ })
148
+ })