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.
- package/LICENSE +21 -0
- package/README.md +13 -0
- package/package.json +78 -0
- package/src/Bridge/LoopbackServer.test.ts +94 -0
- package/src/Bridge/LoopbackServer.ts +77 -0
- package/src/Bridge/ToolControl.test.ts +245 -0
- package/src/Bridge/ToolControl.ts +260 -0
- package/src/Bridge/ToolControlEdges.test.ts +49 -0
- package/src/Bridge/ToolControlHighRisk.test.ts +153 -0
- package/src/Config.test.ts +142 -0
- package/src/Config.ts +295 -0
- package/src/ConfigSchema.ts +46 -0
- package/src/ConfigTypes.ts +11 -0
- package/src/Discord/ChatSdkDiscord.test.ts +257 -0
- package/src/Discord/ChatSdkDiscord.ts +206 -0
- package/src/Discord/ChatSdkGatewayIntake.test.ts +121 -0
- package/src/Discord/ChatSdkGatewayIntake.ts +235 -0
- package/src/Discord/DiscordGateway.test.ts +215 -0
- package/src/Discord/DiscordGateway.ts +140 -0
- package/src/Discord/DiscordGatewayFailures.test.ts +148 -0
- package/src/Discord/DiscordJsDiscord.test.ts +208 -0
- package/src/Discord/DiscordJsDiscord.ts +267 -0
- package/src/Discord/DiscordPort.ts +30 -0
- package/src/Discord/MemoryDiscord.test.ts +44 -0
- package/src/Discord/MemoryDiscord.ts +85 -0
- package/src/Discord/Safety.ts +11 -0
- package/src/Main.test.ts +273 -0
- package/src/Main.ts +192 -0
- package/src/MainQueue.test.ts +124 -0
- package/src/Opencode/EventMapping.test.ts +188 -0
- package/src/Opencode/EventMapping.ts +232 -0
- package/src/Opencode/EventMappingState.ts +97 -0
- package/src/Opencode/MemoryOpencode.test.ts +18 -0
- package/src/Opencode/MemoryOpencode.ts +29 -0
- package/src/Opencode/OpencodePort.ts +30 -0
- package/src/Opencode/PromptParts.ts +47 -0
- package/src/Opencode/SdkOpencode.test.ts +280 -0
- package/src/Opencode/SdkOpencode.ts +270 -0
- package/src/Opencode/SdkOpencodeAttachments.test.ts +79 -0
- package/src/Opencode/SdkOpencodeFailures.test.ts +113 -0
- package/src/Orchestrator/ContextAssembly.test.ts +115 -0
- package/src/Orchestrator/ContextAssembly.ts +120 -0
- package/src/Orchestrator/Orchestrator.ts +67 -0
- package/src/Orchestrator/StopCommand.test.ts +20 -0
- package/src/Orchestrator/StopCommand.ts +14 -0
- package/src/Orchestrator/Triggering.test.ts +56 -0
- package/src/Orchestrator/Triggering.ts +26 -0
- package/src/Orchestrator/TurnManager.test.ts +180 -0
- package/src/Orchestrator/TurnManager.ts +179 -0
- package/src/Orchestrator/TurnManagerStrategy.test.ts +136 -0
- package/src/PublicContracts.test.ts +43 -0
- package/src/Render/Renderer.test.ts +249 -0
- package/src/Render/Renderer.ts +159 -0
- package/src/Render/Splitting.test.ts +30 -0
- package/src/Render/Splitting.ts +68 -0
- package/src/Schema.ts +93 -0
- package/src/Tools/Scaffolding.test.ts +56 -0
- 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
|
+
})
|