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,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
|
+
})
|