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
package/src/Main.test.ts
ADDED
|
@@ -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
|
+
})
|