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,273 @@
1
+ import { describe, expect, test } from "bun:test"
2
+ import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises"
3
+ import { tmpdir } from "node:os"
4
+ import { join } from "node:path"
5
+ import { Effect, Redacted, Stream } from "effect"
6
+ import { defaultConfig } from "./Config.ts"
7
+ import type { DiscordGatewayClient, DiscordGatewayOptions } from "./Discord/DiscordGateway.ts"
8
+ import { DiscordError, type DiscordService } from "./Discord/DiscordPort.ts"
9
+ import { makeMemoryDiscord } from "./Discord/MemoryDiscord.ts"
10
+ import { liveRuntimeFactories, makeApplication, makeProgram, type RuntimeFactories } from "./Main.ts"
11
+ import { makeMemoryOpencode } from "./Opencode/MemoryOpencode.ts"
12
+ import { OpencodeError, type OpencodeService } from "./Opencode/OpencodePort.ts"
13
+ import type { DiscordMessage } from "./Schema.ts"
14
+
15
+ const makeFakeGatewayClient = (): DiscordGatewayClient => {
16
+ const client: DiscordGatewayClient = {
17
+ user: { id: "self" },
18
+ application: { commands: { create: () => Promise.resolve({}) } },
19
+ channels: { fetch: () => Promise.resolve(null) },
20
+ on: () => client,
21
+ once: () => client,
22
+ login: () => Promise.resolve("token"),
23
+ destroy: () => Promise.resolve()
24
+ }
25
+ return client
26
+ }
27
+
28
+ const mentionMessage = {
29
+ id: "m1",
30
+ guildId: "g1",
31
+ channelId: "c1",
32
+ author: { id: "u1", displayName: "Alice", isBot: false },
33
+ content: "hello <@self>",
34
+ timestamp: "2026-06-05T14:03:00.000Z",
35
+ mentions: ["self"],
36
+ roleMentions: [],
37
+ everyoneMention: false,
38
+ hereMention: false,
39
+ attachments: [],
40
+ reactions: [],
41
+ channelType: "guild"
42
+ } satisfies DiscordMessage
43
+
44
+ describe("makeProgram", () => {
45
+ test("runs Bun preflight, loads config, and scaffolds generated Discord tools", async () => {
46
+ const projectDir = await mkdtemp(join(tmpdir(), "ocdb-program-"))
47
+ const discord = makeMemoryDiscord()
48
+ const opencode = makeMemoryOpencode([])
49
+ const factories: RuntimeFactories = {
50
+ makeDiscord: () => discord,
51
+ makeOpencode: () => opencode,
52
+ startGateway: () => Effect.succeed({ bot: { userId: "self" }, client: makeFakeGatewayClient() }),
53
+ keepAlive: Effect.void
54
+ }
55
+
56
+ try {
57
+ await Effect.runPromise(makeProgram(projectDir, { DISCORD_TOKEN: "token", OPENCODE_PROJECT_DIR: projectDir }, factories))
58
+ const tool = await readFile(join(projectDir, ".opencode", "tools", "discord-bridge.ts"), "utf8")
59
+
60
+ expect(tool).toContain("Generated by opencode-discord-bot")
61
+ expect(tool).toContain("http://127.0.0.1:8787/tool")
62
+ } finally {
63
+ await rm(projectDir, { recursive: true, force: true })
64
+ }
65
+ })
66
+ })
67
+
68
+ describe("makeApplication", () => {
69
+ test("starts the loopback server through the application facade", async () => {
70
+ const app = makeApplication({
71
+ bot: { userId: "self" },
72
+ config: defaultConfig,
73
+ discord: makeMemoryDiscord(),
74
+ opencode: makeMemoryOpencode([])
75
+ })
76
+
77
+ const server = await Effect.runPromise(app.startLoopback(0).pipe(Effect.scoped))
78
+
79
+ expect(server.url).toStartWith("http://127.0.0.1:")
80
+ expect(server.port).toBeGreaterThan(0)
81
+ })
82
+ })
83
+
84
+ describe("liveRuntimeFactories", () => {
85
+ test("constructs live opencode through runtime factories", () => {
86
+ expect(liveRuntimeFactories.makeOpencode(defaultConfig)).toBeDefined()
87
+ })
88
+ })
89
+
90
+ describe("makeApplication startup", () => {
91
+ test("reports application start scaffolding failures", async () => {
92
+ const projectDir = await mkdtemp(join(tmpdir(), "ocdb-start-fail-"))
93
+ const blockedPath = join(projectDir, "blocked")
94
+ await writeFile(blockedPath, "not a directory")
95
+ const app = makeApplication({
96
+ bot: { userId: "self" },
97
+ config: { ...defaultConfig, opencode: { ...defaultConfig.opencode, projectDir: blockedPath } },
98
+ discord: makeMemoryDiscord(),
99
+ opencode: makeMemoryOpencode([])
100
+ })
101
+
102
+ try {
103
+ await expect(Effect.runPromise(app.start)).rejects.toBeDefined()
104
+ } finally {
105
+ await rm(projectDir, { recursive: true, force: true })
106
+ }
107
+ })
108
+
109
+ test("swallows failed message turns from gateway dispatch", async () => {
110
+ const failingDiscord: DiscordService = {
111
+ fetchContext: () => Effect.fail(new DiscordError({ message: "context failed" })),
112
+ fetchHistory: () => Effect.succeed([]),
113
+ sendTyping: () => Effect.void,
114
+ postMessage: () => Effect.succeed({ id: "posted" }),
115
+ editMessage: () => Effect.void,
116
+ addReaction: () => Effect.void,
117
+ removeReaction: () => Effect.void,
118
+ attachFile: () => Effect.succeed({ path: "out.txt" }),
119
+ createThread: () => Effect.succeed({ id: "thread" }),
120
+ deleteMessage: () => Effect.void,
121
+ postChannelMessage: () => Effect.succeed({ id: "posted" }),
122
+ pinMessage: () => Effect.void,
123
+ unpinMessage: () => Effect.void
124
+ }
125
+ const app = makeApplication({
126
+ bot: { userId: "self" },
127
+ config: defaultConfig,
128
+ discord: failingDiscord,
129
+ opencode: makeMemoryOpencode([])
130
+ })
131
+
132
+ await expect(Effect.runPromise(app.startMessageTurn(mentionMessage))).resolves.toBeUndefined()
133
+ await new Promise((resolve) => setTimeout(resolve, 0))
134
+ })
135
+ })
136
+
137
+ describe("makeProgram callbacks", () => {
138
+ test("routes gateway callbacks through the initialized application", async () => {
139
+ const projectDir = await mkdtemp(join(tmpdir(), "ocdb-callbacks-"))
140
+ const discord = makeMemoryDiscord({ context: [mentionMessage] })
141
+ const opencode = makeMemoryOpencode([{ type: "text-delta", text: "callback ok" }, { type: "idle" }])
142
+ let gatewayOptions: DiscordGatewayOptions | undefined
143
+ const factories: RuntimeFactories = {
144
+ makeDiscord: () => discord,
145
+ makeOpencode: () => opencode,
146
+ startGateway: (options) =>
147
+ Effect.sync(() => {
148
+ gatewayOptions = options
149
+ return { bot: { userId: "self" }, client: makeFakeGatewayClient() }
150
+ }),
151
+ keepAlive: Effect.void
152
+ }
153
+
154
+ try {
155
+ await Effect.runPromise(makeProgram(projectDir, { DISCORD_TOKEN: "token", OPENCODE_PROJECT_DIR: projectDir }, factories))
156
+ if (gatewayOptions === undefined) throw new Error("gateway options were not captured")
157
+
158
+ await Effect.runPromise(gatewayOptions.onMessage(mentionMessage, { userId: "self" }))
159
+ await new Promise((resolve) => setTimeout(resolve, 0))
160
+ await Effect.runPromise(gatewayOptions.onStop({ guildId: "g1", channelId: "c1" }, { userId: "self" }))
161
+
162
+ expect(opencode.prompts).toHaveLength(1)
163
+ expect(discord.messages.map((item) => item.content)).toContain("callback ok")
164
+ } finally {
165
+ await rm(projectDir, { recursive: true, force: true })
166
+ }
167
+ })
168
+ })
169
+
170
+ describe("makeApplication facade", () => {
171
+ test("handles Discord tool requests through the application facade", async () => {
172
+ const discord = makeMemoryDiscord({ context: [mentionMessage] })
173
+ let releaseTurn: (() => void) | undefined
174
+ const waiting = new Promise<void>((resolve) => {
175
+ releaseTurn = resolve
176
+ })
177
+ const opencode: OpencodeService = {
178
+ checkHealth: Effect.void,
179
+ abort: () => Stream.empty,
180
+ runPrompt: () =>
181
+ Stream.fromAsyncIterable(
182
+ (async function* () {
183
+ await waiting
184
+ yield { type: "idle" as const }
185
+ })(),
186
+ () => new OpencodeError({ message: "stream failed" })
187
+ )
188
+ }
189
+ const app = makeApplication({
190
+ bot: { userId: "self" },
191
+ config: defaultConfig,
192
+ discord,
193
+ opencode
194
+ })
195
+
196
+ const running = Effect.runPromise(app.handleMessage(mentionMessage))
197
+ await new Promise((resolve) => setTimeout(resolve, 0))
198
+ const response = await Effect.runPromise(
199
+ app.handleTool({
200
+ action: "followUpMessage",
201
+ target: { guildId: "g1", channelId: "c1" },
202
+ args: { content: "hello @everyone <@&123>" }
203
+ })
204
+ )
205
+ releaseTurn?.()
206
+ await running
207
+
208
+ expect(response).toEqual({ ok: true, result: { id: "posted-1" } })
209
+ expect(discord.messages.map((item) => item.content)).toEqual(["hello @ everyone <@& 123>"])
210
+ })
211
+
212
+ test("runs message turns and stop commands through the application facade", async () => {
213
+ const discord = makeMemoryDiscord({
214
+ context: [
215
+ {
216
+ id: "m1",
217
+ guildId: "g1",
218
+ channelId: "c1",
219
+ author: { id: "u1", displayName: "Alice", isBot: false },
220
+ content: "hello <@self>",
221
+ timestamp: "2026-06-05T14:03:00.000Z",
222
+ mentions: ["self"],
223
+ roleMentions: [],
224
+ everyoneMention: false,
225
+ hereMention: false,
226
+ attachments: [],
227
+ reactions: [],
228
+ channelType: "guild"
229
+ }
230
+ ]
231
+ })
232
+ const opencode = makeMemoryOpencode([{ type: "text-delta", text: "ok" }, { type: "idle" }])
233
+ const app = makeApplication({
234
+ bot: { userId: "self" },
235
+ config: defaultConfig,
236
+ discord,
237
+ opencode
238
+ })
239
+ const trigger = discord.context[0]
240
+ if (trigger === undefined) throw new Error("missing trigger fixture")
241
+
242
+ await Effect.runPromise(app.startMessageTurn(trigger))
243
+ await new Promise((resolve) => setTimeout(resolve, 0))
244
+ await Effect.runPromise(app.handleStop({ guildId: "g1", channelId: "c1" }))
245
+
246
+ expect(discord.messages.map((item) => item.content)).toEqual(["ok", "There is no known active turn in this process."])
247
+ expect(opencode.aborted).toEqual([])
248
+ })
249
+ })
250
+
251
+ describe("liveRuntimeFactories Discord", () => {
252
+ test("uses chat-sdk Discord when production config includes a public key", () => {
253
+ const service = liveRuntimeFactories.makeDiscord(
254
+ { bot: { userId: "app-1" }, client: makeFakeGatewayClient() },
255
+ {
256
+ ...defaultConfig,
257
+ discordToken: Redacted.make("token"),
258
+ discord: { applicationId: "app-1", publicKey: "0".repeat(64) }
259
+ }
260
+ )
261
+
262
+ expect(service).toBeDefined()
263
+ })
264
+
265
+ test("uses chat-sdk Discord output even when no public key is configured", () => {
266
+ const service = liveRuntimeFactories.makeDiscord(
267
+ { bot: { userId: "app-1" }, client: makeFakeGatewayClient() },
268
+ { ...defaultConfig, discordToken: Redacted.make("token") }
269
+ )
270
+
271
+ expect(service).toBeDefined()
272
+ })
273
+ })
package/src/Main.ts ADDED
@@ -0,0 +1,192 @@
1
+ #!/usr/bin/env bun
2
+ import { BunRuntime } from "@effect/platform-bun"
3
+ import { Effect, Redacted, type Scope } from "effect"
4
+ import { startLoopbackServer } from "./Bridge/LoopbackServer.ts"
5
+ import { handleToolRequest } from "./Bridge/ToolControl.ts"
6
+ import { defaultConfig, loadConfig, type RuntimeConfig } from "./Config.ts"
7
+ import { makeLiveChatSdkDiscord } from "./Discord/ChatSdkDiscord.ts"
8
+ import { type ChatGatewayIntake, makeChatGatewayIntake } from "./Discord/ChatSdkGatewayIntake.ts"
9
+ import { type DiscordGateway, type DiscordGatewayOptions, startDiscordGateway } from "./Discord/DiscordGateway.ts"
10
+ import type { DiscordService } from "./Discord/DiscordPort.ts"
11
+ import type { OpencodeService } from "./Opencode/OpencodePort.ts"
12
+ import { makeLiveSdkOpencode } from "./Opencode/SdkOpencode.ts"
13
+ import { handleDiscordMessage } from "./Orchestrator/Orchestrator.ts"
14
+ import { handleStopCommand } from "./Orchestrator/StopCommand.ts"
15
+ import { shouldTriggerTurn, toDiscordScope } from "./Orchestrator/Triggering.ts"
16
+ import { createTurnManager, type TurnManager } from "./Orchestrator/TurnManager.ts"
17
+ import type { BotIdentity, DiscordMessage, DiscordScope, ToolRequest } from "./Schema.ts"
18
+ import { ensureDiscordTools } from "./Tools/Scaffolding.ts"
19
+
20
+ type ApplicationOptions = {
21
+ readonly bot: BotIdentity
22
+ readonly config: RuntimeConfig
23
+ readonly discord: DiscordService
24
+ readonly opencode: OpencodeService
25
+ readonly turns?: TurnManager | undefined
26
+ }
27
+
28
+ type QueuedMessage = {
29
+ readonly latest: DiscordMessage
30
+ readonly skipped: ReadonlyArray<DiscordMessage>
31
+ }
32
+
33
+ export const makeApplication = (options: ApplicationOptions) => {
34
+ const activeToolScopes = new Map<string, DiscordScope>()
35
+ const queuedMessages = new Map<string, QueuedMessage>()
36
+ const turns =
37
+ options.turns ??
38
+ createTurnManager(options.opencode, options.discord, {
39
+ strategy: options.config.concurrency.strategy,
40
+ globalMaxActiveTurns: options.config.concurrency.globalMaxActiveTurns,
41
+ maxTurn: options.config.guards.maxTurn
42
+ })
43
+ const activeScopeKey = (scope: DiscordScope): string => `${scope.guildId}:${scope.channelId}:${scope.threadId ?? ""}`
44
+ const handleMessage = (message: DiscordMessage, skippedMessages: ReadonlyArray<DiscordMessage> = []) => {
45
+ const scope = toDiscordScope(message)
46
+ const key = activeScopeKey(scope)
47
+ return Effect.gen(function* () {
48
+ yield* Effect.sync(() => activeToolScopes.set(key, scope))
49
+ return yield* handleDiscordMessage(message, options, skippedMessages)
50
+ }).pipe(Effect.ensuring(Effect.sync(() => activeToolScopes.delete(key))))
51
+ }
52
+
53
+ const runQueuedMessage = (key: string) =>
54
+ Effect.gen(function* () {
55
+ const queued = yield* Effect.sync(() => queuedMessages.get(key))
56
+ if (queued === undefined) return
57
+ yield* Effect.sync(() => queuedMessages.delete(key))
58
+ yield* handleMessage(queued.latest, queued.skipped).pipe(
59
+ Effect.asVoid,
60
+ Effect.catch(() => Effect.void)
61
+ )
62
+ }).pipe(Effect.asVoid)
63
+
64
+ const queueMessage = (key: string, message: DiscordMessage, skippedMessages: ReadonlyArray<DiscordMessage>) =>
65
+ Effect.sync(() => {
66
+ const existing = queuedMessages.get(key)
67
+ const skipped = (existing === undefined ? skippedMessages : [...existing.skipped, existing.latest, ...skippedMessages]).slice(
68
+ -options.config.context.messages
69
+ )
70
+ queuedMessages.set(key, { latest: message, skipped })
71
+ })
72
+
73
+ return {
74
+ start: Effect.tryPromise({
75
+ try: () =>
76
+ ensureDiscordTools({
77
+ projectDir: options.config.opencode.projectDir,
78
+ bridgePort: options.config.bridge.port,
79
+ enabled: options.config.tools.enabled,
80
+ autoInstall: options.config.tools.autoInstall
81
+ }),
82
+ catch: (cause) => cause
83
+ }),
84
+ handleMessage,
85
+ startMessageTurn: (message: DiscordMessage, skippedMessages: ReadonlyArray<DiscordMessage> = []) => {
86
+ const scope = toDiscordScope(message)
87
+ const key = activeScopeKey(scope)
88
+ return Effect.gen(function* () {
89
+ const busy = activeToolScopes.has(key) || queuedMessages.has(key) || (yield* turns.isActive(scope))
90
+ if (options.config.concurrency.strategy === "queue" && busy) {
91
+ if (!shouldTriggerTurn(message, options.bot, message.threadId !== undefined)) return
92
+ yield* queueMessage(key, message, skippedMessages)
93
+ return yield* turns.startTurn(scope, undefined, runQueuedMessage(key)).pipe(Effect.asVoid)
94
+ }
95
+ return yield* turns
96
+ .startTurn(
97
+ scope,
98
+ undefined,
99
+ handleMessage(message, skippedMessages).pipe(
100
+ Effect.asVoid,
101
+ Effect.catch(() => Effect.void)
102
+ )
103
+ )
104
+ .pipe(Effect.asVoid)
105
+ })
106
+ },
107
+ handleStop: (scope: DiscordScope) => handleStopCommand(scope, turns, options.discord),
108
+ handleTool: (request: ToolRequest) =>
109
+ handleToolRequest(request, options.config, options.config.opencode.projectDir, options.discord, {
110
+ allowedScopes: [...activeToolScopes.values()],
111
+ botId: options.bot.userId
112
+ }),
113
+ startLoopback: (port = options.config.bridge.port) =>
114
+ startLoopbackServer({
115
+ port,
116
+ config: options.config,
117
+ projectDir: options.config.opencode.projectDir,
118
+ discord: options.discord,
119
+ getAllowedScopes: () => [...activeToolScopes.values()],
120
+ botId: options.bot.userId
121
+ })
122
+ }
123
+ }
124
+
125
+ const preflight = Effect.sync(() => {
126
+ if (typeof Bun === "undefined") throw new Error("opencode-discord-bot requires the Bun runtime")
127
+ })
128
+
129
+ export type RuntimeFactories = {
130
+ readonly makeOpencode: (config: RuntimeConfig) => OpencodeService
131
+ readonly makeDiscord: (gateway: DiscordGateway, config: RuntimeConfig) => DiscordService
132
+ readonly startGateway: (options: DiscordGatewayOptions) => Effect.Effect<DiscordGateway, unknown, Scope.Scope>
133
+ readonly keepAlive: Effect.Effect<void>
134
+ }
135
+
136
+ const makeProductionDiscord = (gateway: DiscordGateway, config: RuntimeConfig): DiscordService => {
137
+ return makeLiveChatSdkDiscord({
138
+ botToken: Redacted.value(config.discordToken),
139
+ applicationId: config.discord.applicationId ?? gateway.bot.userId,
140
+ ...(config.discord.publicKey === undefined ? {} : { publicKey: config.discord.publicKey })
141
+ })
142
+ }
143
+
144
+ export const liveRuntimeFactories: RuntimeFactories = {
145
+ makeOpencode: (config) => makeLiveSdkOpencode({ baseUrl: config.opencode.baseUrl, projectDir: config.opencode.projectDir }),
146
+ makeDiscord: makeProductionDiscord,
147
+ startGateway: startDiscordGateway,
148
+ keepAlive: Effect.never
149
+ }
150
+
151
+ export const makeProgram = (
152
+ cwd: string,
153
+ env: Readonly<Record<string, string | undefined>>,
154
+ factories: RuntimeFactories = liveRuntimeFactories
155
+ ) =>
156
+ Effect.scoped(
157
+ Effect.gen(function* () {
158
+ yield* preflight
159
+ const config = yield* loadConfig({ cwd, env })
160
+ const opencode = factories.makeOpencode(config)
161
+ yield* opencode.checkHealth
162
+
163
+ let app: ReturnType<typeof makeApplication> | undefined
164
+ let intake: ChatGatewayIntake | undefined
165
+ const gateway = yield* factories.startGateway({
166
+ token: Redacted.value(config.discordToken),
167
+ guildId: config.discord.guildId,
168
+ onMessage: (message) => intake?.processMessage(message) ?? app?.startMessageTurn(message) ?? Effect.void,
169
+ onStop: (scope) => app?.handleStop(scope) ?? Effect.void
170
+ })
171
+ const discord = factories.makeDiscord(gateway, config)
172
+ app = makeApplication({ bot: gateway.bot, config, discord, opencode })
173
+ intake = makeChatGatewayIntake({
174
+ bot: gateway.bot,
175
+ onMessage: (message, skippedMessages) => app?.startMessageTurn(message, skippedMessages) ?? Effect.void
176
+ })
177
+
178
+ const scaffoldedTools = yield* app.start
179
+ const loopback = yield* app.startLoopback()
180
+ const tools = scaffoldedTools.length === 0 ? "none" : scaffoldedTools.join(", ")
181
+ yield* Effect.logInfo(
182
+ `ready: Discord gateway connected as <@${gateway.bot.userId}>, opencode ${config.opencode.baseUrl}, bridge ${loopback.url}, tools ${tools}`
183
+ )
184
+ yield* factories.keepAlive
185
+ })
186
+ )
187
+
188
+ if (import.meta.main) {
189
+ BunRuntime.runMain(makeProgram(process.cwd(), process.env), { disableErrorReporting: true })
190
+ }
191
+
192
+ export { defaultConfig }
@@ -0,0 +1,124 @@
1
+ import { expect, test } from "bun:test"
2
+ import { Effect, Stream } from "effect"
3
+ import { defaultConfig } from "./Config.ts"
4
+ import { makeMemoryDiscord } from "./Discord/MemoryDiscord.ts"
5
+ import { makeApplication } from "./Main.ts"
6
+ import { OpencodeError, type OpencodeService } from "./Opencode/OpencodePort.ts"
7
+ import type { DiscordMessage } from "./Schema.ts"
8
+
9
+ const mentionMessage = {
10
+ id: "m1",
11
+ guildId: "g1",
12
+ channelId: "c1",
13
+ author: { id: "u1", displayName: "Alice", isBot: false },
14
+ content: "first <@self>",
15
+ timestamp: "2026-06-05T14:03:00.000Z",
16
+ mentions: ["self"],
17
+ roleMentions: [],
18
+ everyoneMention: false,
19
+ hereMention: false,
20
+ attachments: [],
21
+ reactions: [],
22
+ channelType: "guild"
23
+ } satisfies DiscordMessage
24
+
25
+ test("queues only the latest busy-scope message and surfaces skipped context", async () => {
26
+ const secondMessage = { ...mentionMessage, id: "m2", content: "second <@self>" } satisfies DiscordMessage
27
+ const thirdMessage = { ...mentionMessage, id: "m3", content: "third <@self>" } satisfies DiscordMessage
28
+ const discord = makeMemoryDiscord({ context: [mentionMessage, secondMessage, thirdMessage] })
29
+ let releaseFirst: (() => void) | undefined
30
+ let markFirstPromptStarted: (() => void) | undefined
31
+ let markQueuedPromptStarted: (() => void) | undefined
32
+ const firstTurn = new Promise<void>((resolve) => {
33
+ releaseFirst = resolve
34
+ })
35
+ const firstPromptStarted = new Promise<void>((resolve) => {
36
+ markFirstPromptStarted = resolve
37
+ })
38
+ const queuedPromptStarted = new Promise<void>((resolve) => {
39
+ markQueuedPromptStarted = resolve
40
+ })
41
+ const prompts: Array<string> = []
42
+ const opencode: OpencodeService = {
43
+ checkHealth: Effect.void,
44
+ abort: () => Stream.empty,
45
+ runPrompt: (input) => {
46
+ prompts.push(input.prompt)
47
+ if (prompts.length === 1) {
48
+ markFirstPromptStarted?.()
49
+ return Stream.fromAsyncIterable(
50
+ (async function* () {
51
+ yield { type: "text-delta" as const, text: "first" }
52
+ await firstTurn
53
+ yield { type: "idle" as const }
54
+ })(),
55
+ () => new OpencodeError({ message: "stream failed" })
56
+ )
57
+ }
58
+ markQueuedPromptStarted?.()
59
+ return Stream.fromIterable([{ type: "text-delta", text: "queued" }, { type: "idle" }])
60
+ }
61
+ }
62
+ const app = makeApplication({ bot: { userId: "self" }, config: defaultConfig, discord, opencode })
63
+
64
+ await Effect.runPromise(app.startMessageTurn(mentionMessage))
65
+ await firstPromptStarted
66
+ await Effect.runPromise(app.startMessageTurn(secondMessage))
67
+ await Effect.runPromise(app.startMessageTurn(thirdMessage))
68
+ releaseFirst?.()
69
+ await queuedPromptStarted
70
+
71
+ const queuedPrompt = prompts[1]
72
+ if (queuedPrompt === undefined) throw new Error("missing queued prompt")
73
+ expect(prompts).toHaveLength(2)
74
+ expect(queuedPrompt).toContain("(queued intermediate message)")
75
+ expect(queuedPrompt).toContain("second <@self>")
76
+ expect(queuedPrompt).toContain("third <@self>")
77
+ expect(queuedPrompt.indexOf("second <@self>")).toBeLessThan(queuedPrompt.indexOf("third <@self>"))
78
+ })
79
+
80
+ test("does not let ignored busy-scope messages replace a queued mention", async () => {
81
+ const queuedMention = { ...mentionMessage, id: "m2", content: "queued <@self>" } satisfies DiscordMessage
82
+ const ignored = { ...mentionMessage, id: "m3", content: "noise", mentions: [] } satisfies DiscordMessage
83
+ const discord = makeMemoryDiscord({ context: [mentionMessage, queuedMention, ignored] })
84
+ let releaseFirst: (() => void) | undefined
85
+ let markQueuedPromptStarted: (() => void) | undefined
86
+ const firstTurn = new Promise<void>((resolve) => {
87
+ releaseFirst = resolve
88
+ })
89
+ const queuedPromptStarted = new Promise<void>((resolve) => {
90
+ markQueuedPromptStarted = resolve
91
+ })
92
+ const prompts: Array<string> = []
93
+ const opencode: OpencodeService = {
94
+ checkHealth: Effect.void,
95
+ abort: () => Stream.empty,
96
+ runPrompt: (input) => {
97
+ prompts.push(input.prompt)
98
+ if (prompts.length === 1) {
99
+ return Stream.fromAsyncIterable(
100
+ (async function* () {
101
+ await firstTurn
102
+ yield { type: "idle" as const }
103
+ })(),
104
+ () => new OpencodeError({ message: "stream failed" })
105
+ )
106
+ }
107
+ markQueuedPromptStarted?.()
108
+ return Stream.fromIterable([{ type: "idle" }])
109
+ }
110
+ }
111
+ const app = makeApplication({ bot: { userId: "self" }, config: defaultConfig, discord, opencode })
112
+
113
+ await Effect.runPromise(app.startMessageTurn(mentionMessage))
114
+ await Effect.runPromise(app.startMessageTurn(queuedMention))
115
+ await Effect.runPromise(app.startMessageTurn(ignored))
116
+ releaseFirst?.()
117
+ await queuedPromptStarted
118
+
119
+ const queuedPrompt = prompts[1]
120
+ if (queuedPrompt === undefined) throw new Error("missing queued prompt")
121
+ expect(prompts).toHaveLength(2)
122
+ expect(queuedPrompt).toContain("queued <@self>")
123
+ expect(queuedPrompt.indexOf("noise")).toBeLessThan(queuedPrompt.indexOf("queued <@self>"))
124
+ })