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,260 @@
1
+ import { lstat, realpath } from "node:fs/promises"
2
+ import { isAbsolute, resolve, sep } from "node:path"
3
+ import { Effect } from "effect"
4
+ import type { RuntimeConfig, ToolConfig } from "../Config.ts"
5
+ import type { DiscordService } from "../Discord/DiscordPort.ts"
6
+ import { sanitizeDiscordContent } from "../Discord/Safety.ts"
7
+ import type { DiscordScope, ToolRequest, ToolResponse } from "../Schema.ts"
8
+
9
+ type ToolRequestOptions = {
10
+ readonly allowedScopes?: ReadonlyArray<DiscordScope> | undefined
11
+ readonly botId?: string | undefined
12
+ }
13
+
14
+ const actionFlag = (action: string): keyof ToolConfig | undefined => {
15
+ switch (action) {
16
+ case "addReaction":
17
+ case "removeReaction":
18
+ return "reactions"
19
+ case "attachFile":
20
+ return "attachFiles"
21
+ case "fetchHistory":
22
+ return "fetchHistory"
23
+ case "followUpMessage":
24
+ return "followUpMessages"
25
+ case "createThread":
26
+ return "createThread"
27
+ case "editOwnMessage":
28
+ case "deleteOwnMessage":
29
+ return "editDeleteOwn"
30
+ case "postOtherChannel":
31
+ return "postOtherChannels"
32
+ case "pin":
33
+ case "unpin":
34
+ return "pin"
35
+ default:
36
+ return undefined
37
+ }
38
+ }
39
+
40
+ const scopeFromRequest = (request: ToolRequest): DiscordScope | string => {
41
+ const { guildId, channelId, threadId } = request.target
42
+ if (guildId === undefined || channelId === undefined) return "Discord target must include guildId and channelId"
43
+ const values = [guildId, channelId, threadId]
44
+ if (values.some((value) => value?.toLowerCase() === "@me" || value?.toLowerCase() === "dm")) {
45
+ return "Discord DMs are not supported"
46
+ }
47
+ return { guildId, channelId, ...(threadId === undefined ? {} : { threadId }) }
48
+ }
49
+
50
+ const scopeKey = (scope: DiscordScope): string => `${scope.guildId}:${scope.channelId}:${scope.threadId ?? ""}`
51
+
52
+ const isAllowedScope = (scope: DiscordScope, allowedScopes: ReadonlyArray<DiscordScope> | undefined): boolean =>
53
+ allowedScopes === undefined || allowedScopes.some((allowed) => scopeKey(allowed) === scopeKey(scope))
54
+
55
+ const stringArg = (request: ToolRequest, key: string): string | undefined => {
56
+ const value = request.args[key]
57
+ return typeof value === "string" ? value : undefined
58
+ }
59
+
60
+ const attachmentPath = Effect.fn("attachmentPath")(function* (projectDir: string, input: string, maxBytes: number) {
61
+ if (isAbsolute(input) || input.includes(".."))
62
+ return { ok: false, error: "Attachment path must stay inside the project directory" } satisfies ToolResponse
63
+ const project = yield* Effect.tryPromise(() => realpath(projectDir)).pipe(Effect.catch(() => Effect.succeed(resolve(projectDir))))
64
+ const target = resolve(project, input)
65
+ const actual = yield* Effect.tryPromise(() => realpath(target)).pipe(Effect.catch(() => Effect.succeed(undefined)))
66
+ if (actual === undefined || !(actual === project || actual.startsWith(`${project}${sep}`))) {
67
+ return { ok: false, error: "Attachment path must stay inside the project directory" } satisfies ToolResponse
68
+ }
69
+ const stat = yield* Effect.tryPromise(() => lstat(actual)).pipe(Effect.catch(() => Effect.succeed(undefined)))
70
+ if (stat === undefined || !stat.isFile()) return { ok: false, error: "Attachment path must be a readable file" } satisfies ToolResponse
71
+ if (stat.size > maxBytes) return { ok: false, error: "Attachment exceeds the configured size limit" } satisfies ToolResponse
72
+ return actual
73
+ })
74
+
75
+ const disabled = (action: string): ToolResponse => ({ ok: false, error: `Action ${action} is disabled` })
76
+
77
+ const followUp = Effect.fn("toolFollowUp")(function* (
78
+ request: ToolRequest,
79
+ scope: DiscordScope,
80
+ config: RuntimeConfig,
81
+ discord: DiscordService
82
+ ) {
83
+ const content = stringArg(request, "content")
84
+ if (content === undefined || content.trim() === "") return { ok: false, error: "content is required" } satisfies ToolResponse
85
+ const result = yield* discord.postMessage(scope, sanitizeDiscordContent(content, config.guards))
86
+ return { ok: true, result } satisfies ToolResponse
87
+ })
88
+
89
+ const reaction = Effect.fn("toolReaction")(function* (
90
+ request: ToolRequest,
91
+ scope: DiscordScope,
92
+ discord: DiscordService,
93
+ operation: "add" | "remove"
94
+ ) {
95
+ const messageId = request.target.messageId
96
+ const emoji = stringArg(request, "emoji")
97
+ if (messageId === undefined || emoji === undefined) return { ok: false, error: "messageId and emoji are required" } satisfies ToolResponse
98
+ if (operation === "add") {
99
+ yield* discord.addReaction(scope, messageId, emoji)
100
+ return { ok: true, result: { reacted: true } } satisfies ToolResponse
101
+ }
102
+ yield* discord.removeReaction(scope, messageId, emoji)
103
+ return { ok: true, result: { reacted: false } } satisfies ToolResponse
104
+ })
105
+
106
+ const fetchHistory = Effect.fn("toolFetchHistory")(function* (
107
+ request: ToolRequest,
108
+ scope: DiscordScope,
109
+ config: RuntimeConfig,
110
+ discord: DiscordService
111
+ ) {
112
+ const limit = typeof request.args.limit === "number" ? request.args.limit : config.context.messages
113
+ const result = yield* discord.fetchHistory(scope, limit)
114
+ return { ok: true, result } satisfies ToolResponse
115
+ })
116
+
117
+ const attachFile = Effect.fn("toolAttachFile")(function* (
118
+ request: ToolRequest,
119
+ scope: DiscordScope,
120
+ config: RuntimeConfig,
121
+ projectDir: string,
122
+ discord: DiscordService
123
+ ) {
124
+ const path = stringArg(request, "path")
125
+ if (path === undefined) return { ok: false, error: "path is required" } satisfies ToolResponse
126
+ const safePath = yield* attachmentPath(projectDir, path, config.context.attachmentMaxBytes)
127
+ if (typeof safePath !== "string") return safePath
128
+ const result = yield* discord.attachFile(scope, safePath)
129
+ return { ok: true, result } satisfies ToolResponse
130
+ })
131
+
132
+ const createThread = Effect.fn("toolCreateThread")(function* (request: ToolRequest, scope: DiscordScope, discord: DiscordService) {
133
+ const name = stringArg(request, "name")
134
+ if (name === undefined || name.trim() === "") return { ok: false, error: "name is required" } satisfies ToolResponse
135
+ const result = yield* discord.createThread(scope, name)
136
+ return { ok: true, result } satisfies ToolResponse
137
+ })
138
+
139
+ const ensureOwnMessage = Effect.fn("ensureOwnDiscordMessage")(function* (
140
+ messageId: string,
141
+ scope: DiscordScope,
142
+ config: RuntimeConfig,
143
+ discord: DiscordService,
144
+ options: ToolRequestOptions
145
+ ) {
146
+ if (options.botId === undefined) return "Bot identity is required to edit or delete bot-authored messages"
147
+ const history = yield* discord.fetchHistory(scope, config.context.messages)
148
+ const message = history.find((item) => item.id === messageId)
149
+ if (message?.author.id !== options.botId) return "messageId must refer to a bot-authored message"
150
+ return undefined
151
+ })
152
+
153
+ const editOwnMessage = Effect.fn("toolEditOwnMessage")(function* (
154
+ request: ToolRequest,
155
+ scope: DiscordScope,
156
+ config: RuntimeConfig,
157
+ discord: DiscordService,
158
+ options: ToolRequestOptions
159
+ ) {
160
+ const messageId = request.target.messageId
161
+ const content = stringArg(request, "content")
162
+ if (messageId === undefined || content === undefined || content.trim() === "") {
163
+ return { ok: false, error: "messageId and content are required" } satisfies ToolResponse
164
+ }
165
+ const ownMessageError = yield* ensureOwnMessage(messageId, scope, config, discord, options)
166
+ if (ownMessageError !== undefined) return { ok: false, error: ownMessageError } satisfies ToolResponse
167
+ yield* discord.editMessage(scope, messageId, sanitizeDiscordContent(content, config.guards))
168
+ return { ok: true, result: { edited: true } } satisfies ToolResponse
169
+ })
170
+
171
+ const deleteOwnMessage = Effect.fn("toolDeleteOwnMessage")(function* (
172
+ request: ToolRequest,
173
+ scope: DiscordScope,
174
+ config: RuntimeConfig,
175
+ discord: DiscordService,
176
+ options: ToolRequestOptions
177
+ ) {
178
+ const messageId = request.target.messageId
179
+ if (messageId === undefined) return { ok: false, error: "messageId is required" } satisfies ToolResponse
180
+ const ownMessageError = yield* ensureOwnMessage(messageId, scope, config, discord, options)
181
+ if (ownMessageError !== undefined) return { ok: false, error: ownMessageError } satisfies ToolResponse
182
+ yield* discord.deleteMessage(scope, messageId)
183
+ return { ok: true, result: { deleted: true } } satisfies ToolResponse
184
+ })
185
+
186
+ const postOtherChannel = Effect.fn("toolPostOtherChannel")(function* (
187
+ request: ToolRequest,
188
+ config: RuntimeConfig,
189
+ discord: DiscordService
190
+ ) {
191
+ const guildId = request.target.guildId
192
+ const channelId = request.target.channelId
193
+ const content = stringArg(request, "content")
194
+ if (guildId === undefined || channelId === undefined || content === undefined || content.trim() === "") {
195
+ return { ok: false, error: "guildId, channelId, and content are required" } satisfies ToolResponse
196
+ }
197
+ const result = yield* discord.postChannelMessage(guildId, channelId, sanitizeDiscordContent(content, config.guards))
198
+ return { ok: true, result } satisfies ToolResponse
199
+ })
200
+
201
+ const pin = Effect.fn("toolPin")(function* (
202
+ request: ToolRequest,
203
+ scope: DiscordScope,
204
+ discord: DiscordService,
205
+ operation: "pin" | "unpin"
206
+ ) {
207
+ const messageId = request.target.messageId
208
+ if (messageId === undefined) return { ok: false, error: "messageId is required" } satisfies ToolResponse
209
+ if (operation === "pin") {
210
+ yield* discord.pinMessage(scope, messageId)
211
+ return { ok: true, result: { pinned: true } } satisfies ToolResponse
212
+ }
213
+ yield* discord.unpinMessage(scope, messageId)
214
+ return { ok: true, result: { pinned: false } } satisfies ToolResponse
215
+ })
216
+
217
+ export const handleToolRequest = Effect.fn("handleToolRequest")(function* (
218
+ request: ToolRequest,
219
+ config: RuntimeConfig,
220
+ projectDir: string,
221
+ discord: DiscordService,
222
+ options: ToolRequestOptions = {}
223
+ ) {
224
+ if (!config.tools.enabled) return { ok: false, error: "Discord bridge tools are disabled" } satisfies ToolResponse
225
+ const flag = actionFlag(request.action)
226
+ if (flag === undefined) return { ok: false, error: `Unknown action ${request.action}` } satisfies ToolResponse
227
+ if (!config.tools[flag]) return disabled(request.action)
228
+
229
+ const scope = scopeFromRequest(request)
230
+ if (typeof scope === "string") return { ok: false, error: scope } satisfies ToolResponse
231
+ if (request.action !== "postOtherChannel" && !isAllowedScope(scope, options.allowedScopes)) {
232
+ return { ok: false, error: "Discord target is outside the active turn scope" } satisfies ToolResponse
233
+ }
234
+
235
+ switch (request.action) {
236
+ case "followUpMessage":
237
+ return yield* followUp(request, scope, config, discord)
238
+ case "addReaction":
239
+ return yield* reaction(request, scope, discord, "add")
240
+ case "removeReaction":
241
+ return yield* reaction(request, scope, discord, "remove")
242
+ case "fetchHistory":
243
+ return yield* fetchHistory(request, scope, config, discord)
244
+ case "attachFile":
245
+ return yield* attachFile(request, scope, config, projectDir, discord)
246
+ case "createThread":
247
+ return yield* createThread(request, scope, discord)
248
+ case "editOwnMessage":
249
+ return yield* editOwnMessage(request, scope, config, discord, options)
250
+ case "deleteOwnMessage":
251
+ return yield* deleteOwnMessage(request, scope, config, discord, options)
252
+ case "postOtherChannel":
253
+ return yield* postOtherChannel(request, config, discord)
254
+ case "pin":
255
+ return yield* pin(request, scope, discord, "pin")
256
+ case "unpin":
257
+ return yield* pin(request, scope, discord, "unpin")
258
+ }
259
+ return { ok: false, error: `Unknown action ${request.action}` } satisfies ToolResponse
260
+ })
@@ -0,0 +1,49 @@
1
+ import { expect, test } from "bun:test"
2
+ import { mkdtemp, rm } from "node:fs/promises"
3
+ import { tmpdir } from "node:os"
4
+ import { join } from "node:path"
5
+ import { Effect } from "effect"
6
+ import { defaultConfig } from "../Config.ts"
7
+ import { makeMemoryDiscord } from "../Discord/MemoryDiscord.ts"
8
+ import { handleToolRequest } from "./ToolControl.ts"
9
+
10
+ test("rejects missing attachment files inside the project", async () => {
11
+ const projectDir = await mkdtemp(join(tmpdir(), "ocdb-tool-edge-"))
12
+ try {
13
+ const result = await Effect.runPromise(
14
+ handleToolRequest(
15
+ { action: "attachFile", target: { guildId: "g1", channelId: "c1" }, args: { path: "missing.txt" } },
16
+ defaultConfig,
17
+ projectDir,
18
+ makeMemoryDiscord()
19
+ )
20
+ )
21
+
22
+ expect(result).toEqual({ ok: false, error: "Attachment path must stay inside the project directory" })
23
+ } finally {
24
+ await rm(projectDir, { recursive: true, force: true })
25
+ }
26
+ })
27
+
28
+ test("rejects incomplete high-risk tool payloads before dispatch", async () => {
29
+ const config = {
30
+ ...defaultConfig,
31
+ tools: { ...defaultConfig.tools, editDeleteOwn: true, postOtherChannels: true }
32
+ }
33
+ const discord = makeMemoryDiscord()
34
+
35
+ const edit = await Effect.runPromise(
36
+ handleToolRequest(
37
+ { action: "editOwnMessage", target: { guildId: "g1", channelId: "c1", messageId: "m1" }, args: {} },
38
+ config,
39
+ "/repo",
40
+ discord
41
+ )
42
+ )
43
+ const post = await Effect.runPromise(
44
+ handleToolRequest({ action: "postOtherChannel", target: { guildId: "g1", channelId: "c2" }, args: {} }, config, "/repo", discord)
45
+ )
46
+
47
+ expect(edit).toEqual({ ok: false, error: "messageId and content are required" })
48
+ expect(post).toEqual({ ok: false, error: "guildId, channelId, and content are required" })
49
+ })
@@ -0,0 +1,153 @@
1
+ import { describe, expect, test } from "bun:test"
2
+ import { Effect } from "effect"
3
+ import type { RuntimeConfig, ToolConfig } from "../Config.ts"
4
+ import { defaultConfig } from "../Config.ts"
5
+ import { makeMemoryDiscord } from "../Discord/MemoryDiscord.ts"
6
+ import { handleToolRequest } from "./ToolControl.ts"
7
+
8
+ const withTools = (tools: Partial<ToolConfig>): RuntimeConfig => ({
9
+ ...defaultConfig,
10
+ tools: { ...defaultConfig.tools, ...tools }
11
+ })
12
+
13
+ describe("handleToolRequest high-risk actions", () => {
14
+ test("dispatches opt-in high-risk actions through the Discord port", async () => {
15
+ const discord = makeMemoryDiscord({
16
+ context: [
17
+ {
18
+ id: "m1",
19
+ guildId: "g1",
20
+ channelId: "c1",
21
+ author: { id: "bot-1", displayName: "bot", isBot: true },
22
+ content: "old",
23
+ timestamp: "2026-06-05T14:03:00.000Z",
24
+ mentions: [],
25
+ roleMentions: [],
26
+ everyoneMention: false,
27
+ hereMention: false,
28
+ attachments: [],
29
+ reactions: [],
30
+ channelType: "guild"
31
+ }
32
+ ]
33
+ })
34
+ const config = withTools({ createThread: true, editDeleteOwn: true, postOtherChannels: true, pin: true })
35
+
36
+ const created = await Effect.runPromise(
37
+ handleToolRequest(
38
+ { action: "createThread", target: { guildId: "g1", channelId: "c1" }, args: { name: "work" } },
39
+ config,
40
+ "/repo",
41
+ discord,
42
+ { botId: "bot-1" }
43
+ )
44
+ )
45
+ const edited = await Effect.runPromise(
46
+ handleToolRequest(
47
+ { action: "editOwnMessage", target: { guildId: "g1", channelId: "c1", messageId: "m1" }, args: { content: "edited" } },
48
+ config,
49
+ "/repo",
50
+ discord,
51
+ { botId: "bot-1" }
52
+ )
53
+ )
54
+ const deleted = await Effect.runPromise(
55
+ handleToolRequest(
56
+ { action: "deleteOwnMessage", target: { guildId: "g1", channelId: "c1", messageId: "m1" }, args: {} },
57
+ config,
58
+ "/repo",
59
+ discord,
60
+ { botId: "bot-1" }
61
+ )
62
+ )
63
+ const posted = await Effect.runPromise(
64
+ handleToolRequest(
65
+ { action: "postOtherChannel", target: { guildId: "g1", channelId: "c2" }, args: { content: "elsewhere" } },
66
+ config,
67
+ "/repo",
68
+ discord
69
+ )
70
+ )
71
+ const pinned = await Effect.runPromise(
72
+ handleToolRequest({ action: "pin", target: { guildId: "g1", channelId: "c1", messageId: "m1" }, args: {} }, config, "/repo", discord)
73
+ )
74
+ const unpinned = await Effect.runPromise(
75
+ handleToolRequest(
76
+ { action: "unpin", target: { guildId: "g1", channelId: "c1", messageId: "m1" }, args: {} },
77
+ config,
78
+ "/repo",
79
+ discord
80
+ )
81
+ )
82
+
83
+ expect(created).toEqual({ ok: true, result: { id: "thread-1" } })
84
+ expect(edited).toEqual({ ok: true, result: { edited: true } })
85
+ expect(deleted).toEqual({ ok: true, result: { deleted: true } })
86
+ expect(posted).toEqual({ ok: true, result: { id: "posted-2" } })
87
+ expect(pinned).toEqual({ ok: true, result: { pinned: true } })
88
+ expect(unpinned).toEqual({ ok: true, result: { pinned: false } })
89
+ expect(discord.threads).toEqual([{ scope: { guildId: "g1", channelId: "c1" }, name: "work" }])
90
+ expect(discord.channelMessages).toEqual([{ guildId: "g1", channelId: "c2", content: "elsewhere" }])
91
+ expect(discord.pins.map((item) => item.op)).toEqual(["pin", "unpin"])
92
+ })
93
+
94
+ test("rejects edit/delete requests for messages not authored by the bot", async () => {
95
+ const discord = makeMemoryDiscord({
96
+ context: [
97
+ {
98
+ id: "m1",
99
+ guildId: "g1",
100
+ channelId: "c1",
101
+ author: { id: "user-1", displayName: "user", isBot: false },
102
+ content: "user text",
103
+ timestamp: "2026-06-05T14:03:00.000Z",
104
+ mentions: [],
105
+ roleMentions: [],
106
+ everyoneMention: false,
107
+ hereMention: false,
108
+ attachments: [],
109
+ reactions: [],
110
+ channelType: "guild"
111
+ }
112
+ ]
113
+ })
114
+ const config = withTools({ editDeleteOwn: true })
115
+
116
+ const edited = await Effect.runPromise(
117
+ handleToolRequest(
118
+ { action: "editOwnMessage", target: { guildId: "g1", channelId: "c1", messageId: "m1" }, args: { content: "edited" } },
119
+ config,
120
+ "/repo",
121
+ discord,
122
+ { botId: "bot-1" }
123
+ )
124
+ )
125
+ const deleted = await Effect.runPromise(
126
+ handleToolRequest(
127
+ { action: "deleteOwnMessage", target: { guildId: "g1", channelId: "c1", messageId: "m1" }, args: {} },
128
+ config,
129
+ "/repo",
130
+ discord,
131
+ { botId: "bot-1" }
132
+ )
133
+ )
134
+
135
+ expect(edited).toEqual({ ok: false, error: "messageId must refer to a bot-authored message" })
136
+ expect(deleted).toEqual({ ok: false, error: "messageId must refer to a bot-authored message" })
137
+ expect(discord.edits).toEqual([])
138
+ expect(discord.deletes).toEqual([])
139
+ })
140
+
141
+ test("rejects DM-like targets even when cross-channel posting is enabled", async () => {
142
+ const response = await Effect.runPromise(
143
+ handleToolRequest(
144
+ { action: "postOtherChannel", target: { guildId: "@me", channelId: "dm" }, args: { content: "nope" } },
145
+ withTools({ postOtherChannels: true }),
146
+ "/repo",
147
+ makeMemoryDiscord()
148
+ )
149
+ )
150
+
151
+ expect(response).toEqual({ ok: false, error: "Discord DMs are not supported" })
152
+ })
153
+ })
@@ -0,0 +1,142 @@
1
+ import { describe, expect, test } from "bun:test"
2
+ import { mkdtemp, rm, writeFile } from "node:fs/promises"
3
+ import { tmpdir } from "node:os"
4
+ import { join } from "node:path"
5
+ import { Duration, Effect, Redacted } from "effect"
6
+ import { defaultConfig, loadConfig, loadConfigFromSources } from "./Config.ts"
7
+
8
+ describe("loadConfigFromSources", () => {
9
+ test("merges defaults, JSONC config, and environment with env precedence", async () => {
10
+ const config = await Effect.runPromise(
11
+ loadConfigFromSources({
12
+ cwd: "/repo/bot",
13
+ env: {
14
+ DISCORD_TOKEN: "discord-token",
15
+ OPENCODE_PORT: "5050",
16
+ DISCORD_BRIDGE_PORT: "9999",
17
+ OPENCODE_AGENT: "discord-agent"
18
+ },
19
+ configText: `{
20
+ // file values are lower precedence than env
21
+ "contextMessages": 12,
22
+ "contextMaxChars": 1234,
23
+ "streaming": { "updateIntervalMs": 250, "showToolStatus": false },
24
+ "threads": { "activeByRecentBotParticipation": false },
25
+ "concurrency": { "strategy": "burst", "globalMaxActiveTurns": 4 },
26
+ "guards": { "ignoreBots": false, "stripMassMentions": false, "redactSecretsInErrors": false, "maxTurnMs": 1000 },
27
+ "tools": { "createThread": true, "pin": true }
28
+ }`
29
+ })
30
+ )
31
+
32
+ expect(Redacted.value(config.discordToken)).toBe("discord-token")
33
+ expect(config.opencode.baseUrl).toBe("http://127.0.0.1:5050")
34
+ expect(config.opencode.projectDir).toBe("/repo/bot")
35
+ expect(config.opencode.agent).toBe("discord-agent")
36
+ expect(config.bridge.port).toBe(9999)
37
+ expect(config.context.messages).toBe(12)
38
+ expect(config.context.maxChars).toBe(1234)
39
+ expect(config.streaming.updateInterval).toEqual(Duration.millis(250))
40
+ expect(config.streaming.showToolStatus).toBe(false)
41
+ expect(config.threads.activeByRecentBotParticipation).toBe(false)
42
+ expect(config.concurrency.strategy).toBe("burst")
43
+ expect(config.concurrency.globalMaxActiveTurns).toBe(4)
44
+ expect(config.guards.ignoreBots).toBe(false)
45
+ expect(config.guards.stripMassMentions).toBe(false)
46
+ expect(config.guards.redactSecretsInErrors).toBe(false)
47
+ expect(config.guards.maxTurn).toEqual(Duration.millis(1000))
48
+ expect(config.tools.reactions).toBe(true)
49
+ expect(config.tools.createThread).toBe(true)
50
+ expect(config.tools.pin).toBe(true)
51
+ })
52
+
53
+ test("uses the normative localhost defaults", async () => {
54
+ const config = await Effect.runPromise(loadConfigFromSources({ cwd: "/work", env: { DISCORD_TOKEN: "token" } }))
55
+
56
+ expect(config.opencode.baseUrl).toBe("http://127.0.0.1:4096")
57
+ expect(config.opencode.projectDir).toBe("/work")
58
+ expect(config.bridge.host).toBe(defaultConfig.bridge.host)
59
+ expect(config.bridge.port).toBe(8787)
60
+ expect(config.context.messages).toBe(30)
61
+ expect(config.tools.autoInstall).toBe(true)
62
+ expect(config.tools.followUpMessages).toBe(true)
63
+ expect(config.tools.postOtherChannels).toBe(false)
64
+ })
65
+
66
+ test("fails fast when DISCORD_TOKEN is missing", async () => {
67
+ await expect(loadConfigFromSources({ cwd: "/work", env: {} }).pipe(Effect.runPromise)).rejects.toMatchObject({
68
+ _tag: "ConfigError"
69
+ })
70
+ })
71
+
72
+ test("fails fast when config text is invalid JSONC", async () => {
73
+ await expect(
74
+ loadConfigFromSources({ cwd: "/work", env: { DISCORD_TOKEN: "token" }, configText: "{" }).pipe(Effect.runPromise)
75
+ ).rejects.toMatchObject({
76
+ _tag: "ConfigError",
77
+ message: "Config file must be valid JSONC"
78
+ })
79
+ })
80
+
81
+ test("fails fast when JSONC config fails schema validation", async () => {
82
+ await expect(
83
+ loadConfigFromSources({ cwd: "/work", env: { DISCORD_TOKEN: "token" }, configText: `{ "contextMessages": "many" }` }).pipe(
84
+ Effect.runPromise
85
+ )
86
+ ).rejects.toMatchObject({
87
+ _tag: "ConfigError",
88
+ message: "Config file failed schema validation"
89
+ })
90
+
91
+ await expect(
92
+ loadConfigFromSources({
93
+ cwd: "/work",
94
+ env: { DISCORD_TOKEN: "token" },
95
+ configText: `{ "streaming": { "updateIntervalMs": -1 } }`
96
+ }).pipe(Effect.runPromise)
97
+ ).rejects.toMatchObject({
98
+ _tag: "ConfigError",
99
+ message: "Config file failed schema validation"
100
+ })
101
+ })
102
+
103
+ test("loads .opencode-discord.jsonc from the working directory when present", async () => {
104
+ const cwd = await mkdtemp(join(tmpdir(), "ocdb-config-"))
105
+
106
+ try {
107
+ await writeFile(join(cwd, ".opencode-discord.jsonc"), `{ "contextMessages": 7, "tools": { "pin": true } }`)
108
+ const config = await Effect.runPromise(loadConfig({ cwd, env: { DISCORD_TOKEN: "token" } }))
109
+
110
+ expect(config.context.messages).toBe(7)
111
+ expect(config.tools.pin).toBe(true)
112
+ } finally {
113
+ await rm(cwd, { recursive: true, force: true })
114
+ }
115
+ })
116
+
117
+ test("treats a missing .opencode-discord.jsonc as an empty config", async () => {
118
+ const cwd = await mkdtemp(join(tmpdir(), "ocdb-config-missing-"))
119
+
120
+ try {
121
+ const config = await Effect.runPromise(loadConfig({ cwd, env: { DISCORD_TOKEN: "token" } }))
122
+
123
+ expect(config.context.messages).toBe(defaultConfig.context.messages)
124
+ expect(config.opencode.projectDir).toBe(cwd)
125
+ } finally {
126
+ await rm(cwd, { recursive: true, force: true })
127
+ }
128
+ })
129
+
130
+ test("reports unreadable config files", async () => {
131
+ const cwd = await mkdtemp(join(tmpdir(), "ocdb-config-unreadable-"))
132
+
133
+ try {
134
+ await expect(loadConfig({ cwd, env: { DISCORD_TOKEN: "token" }, configPath: cwd }).pipe(Effect.runPromise)).rejects.toMatchObject({
135
+ _tag: "ConfigError",
136
+ message: "Unable to read config file"
137
+ })
138
+ } finally {
139
+ await rm(cwd, { recursive: true, force: true })
140
+ }
141
+ })
142
+ })