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/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 ysm-dev
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
package/package.json
ADDED
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "opencode-discord-bot",
|
|
3
|
+
"version": "0.0.1",
|
|
4
|
+
"description": "Discord bot bridge for a self-hosted opencode instance",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"main": "src/Main.ts",
|
|
7
|
+
"bin": {
|
|
8
|
+
"opencode-discord-bot": "./src/Main.ts"
|
|
9
|
+
},
|
|
10
|
+
"exports": {
|
|
11
|
+
".": "./src/Main.ts"
|
|
12
|
+
},
|
|
13
|
+
"engines": {
|
|
14
|
+
"bun": ">=1.3.0"
|
|
15
|
+
},
|
|
16
|
+
"license": "MIT",
|
|
17
|
+
"author": "ysm-dev <i@ysm.dev>",
|
|
18
|
+
"repository": {
|
|
19
|
+
"type": "git",
|
|
20
|
+
"url": "git+https://github.com/ysm-dev/opencode-discord-bot.git"
|
|
21
|
+
},
|
|
22
|
+
"bugs": {
|
|
23
|
+
"url": "https://github.com/ysm-dev/opencode-discord-bot/issues"
|
|
24
|
+
},
|
|
25
|
+
"homepage": "https://github.com/ysm-dev/opencode-discord-bot#readme",
|
|
26
|
+
"keywords": [
|
|
27
|
+
"opencode",
|
|
28
|
+
"discord",
|
|
29
|
+
"discord-bot",
|
|
30
|
+
"bot",
|
|
31
|
+
"bridge"
|
|
32
|
+
],
|
|
33
|
+
"files": [
|
|
34
|
+
"src",
|
|
35
|
+
"LICENSE",
|
|
36
|
+
"README.md"
|
|
37
|
+
],
|
|
38
|
+
"publishConfig": {
|
|
39
|
+
"access": "public"
|
|
40
|
+
},
|
|
41
|
+
"scripts": {
|
|
42
|
+
"start": "bun src/Main.ts",
|
|
43
|
+
"typecheck": "tsgo --noEmit",
|
|
44
|
+
"test": "bun test",
|
|
45
|
+
"test:unit": "bun test src",
|
|
46
|
+
"test:integration": "bun test test/integration",
|
|
47
|
+
"test:e2e": "bun test test/e2e",
|
|
48
|
+
"format": "biome format --write .",
|
|
49
|
+
"format:check": "biome format --check .",
|
|
50
|
+
"lint": "biome lint .",
|
|
51
|
+
"check": "biome check .",
|
|
52
|
+
"knip": "knip",
|
|
53
|
+
"coverage": "bun test --coverage --coverage-reporter=lcov && bun scripts/CheckCoverage.ts",
|
|
54
|
+
"ci": "bun run typecheck && bun run check && bun run knip && bun test && bun run coverage"
|
|
55
|
+
},
|
|
56
|
+
"dependencies": {
|
|
57
|
+
"@chat-adapter/discord": "4.29.0",
|
|
58
|
+
"@effect/platform-bun": "4.0.0-beta.74",
|
|
59
|
+
"@opencode-ai/sdk": "1.16.2",
|
|
60
|
+
"chat": "4.29.0",
|
|
61
|
+
"discord.js": "14.25.1",
|
|
62
|
+
"effect": "4.0.0-beta.74",
|
|
63
|
+
"jsonc-parser": "^3.3.1"
|
|
64
|
+
},
|
|
65
|
+
"devDependencies": {
|
|
66
|
+
"@biomejs/biome": "^2.4.16",
|
|
67
|
+
"@changesets/cli": "^2.31.0",
|
|
68
|
+
"@commitlint/cli": "^21.0.2",
|
|
69
|
+
"@commitlint/config-conventional": "^21.0.2",
|
|
70
|
+
"@effect/vitest": "4.0.0-beta.74",
|
|
71
|
+
"@types/bun": "1.3.13",
|
|
72
|
+
"@typescript/native-preview": "^7.0.0-dev.20260606.1",
|
|
73
|
+
"@vitest/coverage-v8": "^4.1.8",
|
|
74
|
+
"knip": "^6.16.1",
|
|
75
|
+
"lefthook": "^2.1.9",
|
|
76
|
+
"typescript": "5.8.2"
|
|
77
|
+
}
|
|
78
|
+
}
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test"
|
|
2
|
+
import { Effect } from "effect"
|
|
3
|
+
import { defaultConfig } from "../Config.ts"
|
|
4
|
+
import { makeMemoryDiscord } from "../Discord/MemoryDiscord.ts"
|
|
5
|
+
import { startLoopbackServer } from "./LoopbackServer.ts"
|
|
6
|
+
|
|
7
|
+
describe("startLoopbackServer", () => {
|
|
8
|
+
test("binds to loopback and serves POST /tool", async () => {
|
|
9
|
+
const discord = makeMemoryDiscord()
|
|
10
|
+
|
|
11
|
+
await Effect.runPromise(
|
|
12
|
+
Effect.scoped(
|
|
13
|
+
Effect.gen(function* () {
|
|
14
|
+
const server = yield* startLoopbackServer({
|
|
15
|
+
port: 0,
|
|
16
|
+
config: defaultConfig,
|
|
17
|
+
projectDir: "/repo",
|
|
18
|
+
discord,
|
|
19
|
+
getAllowedScopes: () => [{ guildId: "g1", channelId: "c1" }]
|
|
20
|
+
})
|
|
21
|
+
const response = yield* Effect.tryPromise(() =>
|
|
22
|
+
fetch(`${server.url}/tool`, {
|
|
23
|
+
method: "POST",
|
|
24
|
+
headers: { "content-type": "application/json" },
|
|
25
|
+
body: JSON.stringify({
|
|
26
|
+
action: "followUpMessage",
|
|
27
|
+
target: { guildId: "g1", channelId: "c1" },
|
|
28
|
+
args: { content: "from http" }
|
|
29
|
+
})
|
|
30
|
+
})
|
|
31
|
+
)
|
|
32
|
+
const body = yield* Effect.tryPromise(() => response.json())
|
|
33
|
+
|
|
34
|
+
expect(server.url.startsWith("http://127.0.0.1:")).toBe(true)
|
|
35
|
+
expect(body).toEqual({ ok: true, result: { id: "posted-1" } })
|
|
36
|
+
expect(discord.messages).toEqual([{ scope: { guildId: "g1", channelId: "c1" }, content: "from http" }])
|
|
37
|
+
})
|
|
38
|
+
)
|
|
39
|
+
)
|
|
40
|
+
})
|
|
41
|
+
|
|
42
|
+
test("returns 404 for non-tool routes and contract errors for bad bodies", async () => {
|
|
43
|
+
await Effect.runPromise(
|
|
44
|
+
Effect.scoped(
|
|
45
|
+
Effect.gen(function* () {
|
|
46
|
+
const server = yield* startLoopbackServer({ port: 0, config: defaultConfig, projectDir: "/repo", discord: makeMemoryDiscord() })
|
|
47
|
+
const missing = yield* Effect.tryPromise(() => fetch(`${server.url}/missing`))
|
|
48
|
+
const badJson = yield* Effect.tryPromise(() =>
|
|
49
|
+
fetch(`${server.url}/tool`, { method: "POST", headers: { "content-type": "application/json" }, body: "not-json" })
|
|
50
|
+
)
|
|
51
|
+
const badContract = yield* Effect.tryPromise(() =>
|
|
52
|
+
fetch(`${server.url}/tool`, { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({}) })
|
|
53
|
+
)
|
|
54
|
+
const badJsonBody = yield* Effect.tryPromise(() => badJson.json())
|
|
55
|
+
const badContractBody = yield* Effect.tryPromise(() => badContract.json())
|
|
56
|
+
|
|
57
|
+
expect(missing.status).toBe(404)
|
|
58
|
+
expect(badJsonBody).toEqual({ ok: false, error: "Request body must be valid JSON" })
|
|
59
|
+
expect(badContractBody).toEqual({ ok: false, error: "Request body must match the tool contract" })
|
|
60
|
+
})
|
|
61
|
+
)
|
|
62
|
+
)
|
|
63
|
+
})
|
|
64
|
+
|
|
65
|
+
test("rejects tool targets outside the active turn scopes", async () => {
|
|
66
|
+
await Effect.runPromise(
|
|
67
|
+
Effect.scoped(
|
|
68
|
+
Effect.gen(function* () {
|
|
69
|
+
const server = yield* startLoopbackServer({
|
|
70
|
+
port: 0,
|
|
71
|
+
config: defaultConfig,
|
|
72
|
+
projectDir: "/repo",
|
|
73
|
+
discord: makeMemoryDiscord(),
|
|
74
|
+
getAllowedScopes: () => [{ guildId: "g1", channelId: "c1" }]
|
|
75
|
+
})
|
|
76
|
+
const response = yield* Effect.tryPromise(() =>
|
|
77
|
+
fetch(`${server.url}/tool`, {
|
|
78
|
+
method: "POST",
|
|
79
|
+
headers: { "content-type": "application/json" },
|
|
80
|
+
body: JSON.stringify({
|
|
81
|
+
action: "followUpMessage",
|
|
82
|
+
target: { guildId: "g1", channelId: "c2" },
|
|
83
|
+
args: { content: "outside" }
|
|
84
|
+
})
|
|
85
|
+
})
|
|
86
|
+
)
|
|
87
|
+
const body = yield* Effect.tryPromise(() => response.json())
|
|
88
|
+
|
|
89
|
+
expect(body).toEqual({ ok: false, error: "Discord target is outside the active turn scope" })
|
|
90
|
+
})
|
|
91
|
+
)
|
|
92
|
+
)
|
|
93
|
+
})
|
|
94
|
+
})
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
import { BunHttpServer } from "@effect/platform-bun"
|
|
2
|
+
import { Context, Effect, Layer, Schema } from "effect"
|
|
3
|
+
import { HttpRouter, HttpServer, type HttpServerRequest } from "effect/unstable/http"
|
|
4
|
+
import { HttpApi, HttpApiBuilder, HttpApiEndpoint, HttpApiGroup } from "effect/unstable/httpapi"
|
|
5
|
+
import type { RuntimeConfig } from "../Config.ts"
|
|
6
|
+
import type { DiscordService } from "../Discord/DiscordPort.ts"
|
|
7
|
+
import type { DiscordScope, ToolRequest, ToolResponse } from "../Schema.ts"
|
|
8
|
+
import { ToolRequestSchema, ToolResponseSchema } from "../Schema.ts"
|
|
9
|
+
import { handleToolRequest } from "./ToolControl.ts"
|
|
10
|
+
|
|
11
|
+
export type LoopbackServerOptions = {
|
|
12
|
+
readonly port: number
|
|
13
|
+
readonly config: RuntimeConfig
|
|
14
|
+
readonly projectDir: string
|
|
15
|
+
readonly discord: DiscordService
|
|
16
|
+
readonly getAllowedScopes?: (() => ReadonlyArray<DiscordScope>) | undefined
|
|
17
|
+
readonly botId?: string | undefined
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
type LoopbackServer = {
|
|
21
|
+
readonly url: string
|
|
22
|
+
readonly port: number
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
const invalidRequest = (message: string): ToolResponse => ({ ok: false, error: message })
|
|
26
|
+
|
|
27
|
+
const toolFailure = (error: ToolResponse | { readonly message?: unknown }): ToolResponse => {
|
|
28
|
+
if ("ok" in error) return error
|
|
29
|
+
return invalidRequest(typeof error.message === "string" ? error.message : "Discord bridge tool failed")
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
const parseBody = (request: HttpServerRequest.HttpServerRequest): Effect.Effect<ToolRequest, ToolResponse> =>
|
|
33
|
+
Effect.gen(function* () {
|
|
34
|
+
const body = yield* request.json.pipe(Effect.mapError(() => invalidRequest("Request body must be valid JSON")))
|
|
35
|
+
const decoded = yield* Schema.decodeUnknownEffect(ToolRequestSchema)(body).pipe(
|
|
36
|
+
Effect.mapError(() => invalidRequest("Request body must match the tool contract"))
|
|
37
|
+
)
|
|
38
|
+
return { action: decoded.action, target: decoded.target, args: decoded.args } satisfies ToolRequest
|
|
39
|
+
})
|
|
40
|
+
|
|
41
|
+
class ToolApiGroup extends HttpApiGroup.make("tool").add(HttpApiEndpoint.post("handleTool", "/tool", { success: ToolResponseSchema })) {}
|
|
42
|
+
|
|
43
|
+
class ToolApi extends HttpApi.make("opencode-discord-bot-tools").add(ToolApiGroup) {}
|
|
44
|
+
|
|
45
|
+
const toolApiLayer = (options: LoopbackServerOptions) =>
|
|
46
|
+
HttpApiBuilder.group(ToolApi, "tool", (handlers) =>
|
|
47
|
+
handlers.handleRaw("handleTool", ({ request }) =>
|
|
48
|
+
Effect.gen(function* () {
|
|
49
|
+
const toolRequest = yield* parseBody(request)
|
|
50
|
+
return yield* handleToolRequest(toolRequest, options.config, options.projectDir, options.discord, {
|
|
51
|
+
allowedScopes: options.getAllowedScopes?.() ?? [],
|
|
52
|
+
botId: options.botId
|
|
53
|
+
})
|
|
54
|
+
}).pipe(Effect.catch((error) => Effect.succeed(toolFailure(error))))
|
|
55
|
+
)
|
|
56
|
+
)
|
|
57
|
+
|
|
58
|
+
const httpLayer = (options: LoopbackServerOptions) =>
|
|
59
|
+
HttpRouter.serve(Layer.provide(HttpApiBuilder.layer(ToolApi), toolApiLayer(options)), {
|
|
60
|
+
disableListenLog: true,
|
|
61
|
+
disableLogger: true
|
|
62
|
+
}).pipe(
|
|
63
|
+
Layer.provideMerge(
|
|
64
|
+
BunHttpServer.layer({
|
|
65
|
+
hostname: "127.0.0.1",
|
|
66
|
+
port: options.port
|
|
67
|
+
})
|
|
68
|
+
)
|
|
69
|
+
)
|
|
70
|
+
|
|
71
|
+
export const startLoopbackServer = Effect.fn("startLoopbackServer")(function* (options: LoopbackServerOptions) {
|
|
72
|
+
const context = yield* Layer.build(httpLayer(options))
|
|
73
|
+
const server = Context.get(context, HttpServer.HttpServer)
|
|
74
|
+
|
|
75
|
+
const port = server.address._tag === "TcpAddress" ? server.address.port : options.port
|
|
76
|
+
return { url: `http://127.0.0.1:${port}`, port } satisfies LoopbackServer
|
|
77
|
+
})
|
|
@@ -0,0 +1,245 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test"
|
|
2
|
+
import { mkdir, mkdtemp, realpath, rm, writeFile } from "node:fs/promises"
|
|
3
|
+
import { tmpdir } from "node:os"
|
|
4
|
+
import { join } from "node:path"
|
|
5
|
+
import { Effect } from "effect"
|
|
6
|
+
import type { RuntimeConfig, ToolConfig } from "../Config.ts"
|
|
7
|
+
import { defaultConfig } from "../Config.ts"
|
|
8
|
+
import { makeMemoryDiscord } from "../Discord/MemoryDiscord.ts"
|
|
9
|
+
import { handleToolRequest } from "./ToolControl.ts"
|
|
10
|
+
|
|
11
|
+
const withTools = (tools: Partial<ToolConfig>): RuntimeConfig => ({
|
|
12
|
+
...defaultConfig,
|
|
13
|
+
tools: { ...defaultConfig.tools, ...tools }
|
|
14
|
+
})
|
|
15
|
+
|
|
16
|
+
describe("handleToolRequest", () => {
|
|
17
|
+
test("allows safe default actions through the Discord port", async () => {
|
|
18
|
+
const discord = makeMemoryDiscord()
|
|
19
|
+
const response = await Effect.runPromise(
|
|
20
|
+
handleToolRequest(
|
|
21
|
+
{
|
|
22
|
+
action: "followUpMessage",
|
|
23
|
+
target: { guildId: "g1", channelId: "c1" },
|
|
24
|
+
args: { content: "done" }
|
|
25
|
+
},
|
|
26
|
+
defaultConfig,
|
|
27
|
+
"/repo",
|
|
28
|
+
discord
|
|
29
|
+
)
|
|
30
|
+
)
|
|
31
|
+
|
|
32
|
+
expect(response.ok).toBe(true)
|
|
33
|
+
expect(discord.messages).toEqual([{ scope: { guildId: "g1", channelId: "c1" }, content: "done" }])
|
|
34
|
+
})
|
|
35
|
+
|
|
36
|
+
test("neutralizes mass mentions in follow-up tool content", async () => {
|
|
37
|
+
const discord = makeMemoryDiscord()
|
|
38
|
+
|
|
39
|
+
const response = await Effect.runPromise(
|
|
40
|
+
handleToolRequest(
|
|
41
|
+
{
|
|
42
|
+
action: "followUpMessage",
|
|
43
|
+
target: { guildId: "g1", channelId: "c1" },
|
|
44
|
+
args: { content: "@everyone @here <@&123>" }
|
|
45
|
+
},
|
|
46
|
+
defaultConfig,
|
|
47
|
+
"/repo",
|
|
48
|
+
discord
|
|
49
|
+
)
|
|
50
|
+
)
|
|
51
|
+
|
|
52
|
+
expect(response.ok).toBe(true)
|
|
53
|
+
expect(discord.messages).toEqual([{ scope: { guildId: "g1", channelId: "c1" }, content: "@ everyone @ here <@& 123>" }])
|
|
54
|
+
})
|
|
55
|
+
|
|
56
|
+
test("blocks higher-risk actions unless explicitly enabled", async () => {
|
|
57
|
+
const response = await Effect.runPromise(
|
|
58
|
+
handleToolRequest(
|
|
59
|
+
{ action: "createThread", target: { guildId: "g1", channelId: "c1" }, args: { name: "new" } },
|
|
60
|
+
defaultConfig,
|
|
61
|
+
"/repo",
|
|
62
|
+
makeMemoryDiscord()
|
|
63
|
+
)
|
|
64
|
+
)
|
|
65
|
+
|
|
66
|
+
expect(response).toEqual({ ok: false, error: "Action createThread is disabled" })
|
|
67
|
+
})
|
|
68
|
+
|
|
69
|
+
test("rejects DMs and unsafe attachment paths", async () => {
|
|
70
|
+
const dm = await Effect.runPromise(
|
|
71
|
+
handleToolRequest(
|
|
72
|
+
{ action: "followUpMessage", target: { channelId: "dm1" }, args: { content: "nope" } },
|
|
73
|
+
defaultConfig,
|
|
74
|
+
"/repo",
|
|
75
|
+
makeMemoryDiscord()
|
|
76
|
+
)
|
|
77
|
+
)
|
|
78
|
+
const unsafe = await Effect.runPromise(
|
|
79
|
+
handleToolRequest(
|
|
80
|
+
{ action: "attachFile", target: { guildId: "g1", channelId: "c1" }, args: { path: "../secret.txt" } },
|
|
81
|
+
defaultConfig,
|
|
82
|
+
"/repo",
|
|
83
|
+
makeMemoryDiscord()
|
|
84
|
+
)
|
|
85
|
+
)
|
|
86
|
+
|
|
87
|
+
expect(dm.ok).toBe(false)
|
|
88
|
+
expect(unsafe).toEqual({ ok: false, error: "Attachment path must stay inside the project directory" })
|
|
89
|
+
})
|
|
90
|
+
})
|
|
91
|
+
|
|
92
|
+
describe("handleToolRequest action dispatch", () => {
|
|
93
|
+
test("dispatches reactions, history, and safe attachments", async () => {
|
|
94
|
+
const projectDir = await mkdtemp(join(tmpdir(), "ocdb-tool-"))
|
|
95
|
+
await mkdir(join(projectDir, "out"), { recursive: true })
|
|
96
|
+
await writeFile(join(projectDir, "out", "report.txt"), "report")
|
|
97
|
+
const discord = makeMemoryDiscord({
|
|
98
|
+
context: [
|
|
99
|
+
{
|
|
100
|
+
id: "m1",
|
|
101
|
+
guildId: "g1",
|
|
102
|
+
channelId: "c1",
|
|
103
|
+
author: { id: "u1", displayName: "Alice", isBot: false },
|
|
104
|
+
content: "hi",
|
|
105
|
+
timestamp: "2026-06-05T14:03:00.000Z",
|
|
106
|
+
mentions: [],
|
|
107
|
+
roleMentions: [],
|
|
108
|
+
everyoneMention: false,
|
|
109
|
+
hereMention: false,
|
|
110
|
+
attachments: [],
|
|
111
|
+
reactions: [],
|
|
112
|
+
channelType: "guild"
|
|
113
|
+
}
|
|
114
|
+
]
|
|
115
|
+
})
|
|
116
|
+
|
|
117
|
+
try {
|
|
118
|
+
const add = await Effect.runPromise(
|
|
119
|
+
handleToolRequest(
|
|
120
|
+
{ action: "addReaction", target: { guildId: "g1", channelId: "c1", messageId: "m1" }, args: { emoji: "rocket" } },
|
|
121
|
+
defaultConfig,
|
|
122
|
+
projectDir,
|
|
123
|
+
discord
|
|
124
|
+
)
|
|
125
|
+
)
|
|
126
|
+
const remove = await Effect.runPromise(
|
|
127
|
+
handleToolRequest(
|
|
128
|
+
{ action: "removeReaction", target: { guildId: "g1", channelId: "c1", messageId: "m1" }, args: { emoji: "rocket" } },
|
|
129
|
+
defaultConfig,
|
|
130
|
+
projectDir,
|
|
131
|
+
discord
|
|
132
|
+
)
|
|
133
|
+
)
|
|
134
|
+
const history = await Effect.runPromise(
|
|
135
|
+
handleToolRequest(
|
|
136
|
+
{ action: "fetchHistory", target: { guildId: "g1", channelId: "c1" }, args: { limit: 1 } },
|
|
137
|
+
defaultConfig,
|
|
138
|
+
projectDir,
|
|
139
|
+
discord
|
|
140
|
+
)
|
|
141
|
+
)
|
|
142
|
+
const attach = await Effect.runPromise(
|
|
143
|
+
handleToolRequest(
|
|
144
|
+
{ action: "attachFile", target: { guildId: "g1", channelId: "c1" }, args: { path: "out/report.txt" } },
|
|
145
|
+
defaultConfig,
|
|
146
|
+
projectDir,
|
|
147
|
+
discord
|
|
148
|
+
)
|
|
149
|
+
)
|
|
150
|
+
const attachmentRealpath = await realpath(join(projectDir, "out", "report.txt"))
|
|
151
|
+
|
|
152
|
+
expect(add).toEqual({ ok: true, result: { reacted: true } })
|
|
153
|
+
expect(remove).toEqual({ ok: true, result: { reacted: false } })
|
|
154
|
+
expect(history.ok).toBe(true)
|
|
155
|
+
expect(attach).toEqual({ ok: true, result: { path: attachmentRealpath } })
|
|
156
|
+
expect(discord.reactions.map((item) => item.op)).toEqual(["add", "remove"])
|
|
157
|
+
expect(discord.attachments).toEqual([{ scope: { guildId: "g1", channelId: "c1" }, path: attachmentRealpath }])
|
|
158
|
+
} finally {
|
|
159
|
+
await rm(projectDir, { recursive: true, force: true })
|
|
160
|
+
}
|
|
161
|
+
})
|
|
162
|
+
|
|
163
|
+
test("rejects oversized attachments", async () => {
|
|
164
|
+
const projectDir = await mkdtemp(join(tmpdir(), "ocdb-tool-large-"))
|
|
165
|
+
await writeFile(join(projectDir, "large.txt"), "large")
|
|
166
|
+
|
|
167
|
+
try {
|
|
168
|
+
const response = await Effect.runPromise(
|
|
169
|
+
handleToolRequest(
|
|
170
|
+
{ action: "attachFile", target: { guildId: "g1", channelId: "c1" }, args: { path: "large.txt" } },
|
|
171
|
+
{ ...defaultConfig, context: { ...defaultConfig.context, attachmentMaxBytes: 1 } },
|
|
172
|
+
projectDir,
|
|
173
|
+
makeMemoryDiscord()
|
|
174
|
+
)
|
|
175
|
+
)
|
|
176
|
+
|
|
177
|
+
expect(response).toEqual({ ok: false, error: "Attachment exceeds the configured size limit" })
|
|
178
|
+
} finally {
|
|
179
|
+
await rm(projectDir, { recursive: true, force: true })
|
|
180
|
+
}
|
|
181
|
+
})
|
|
182
|
+
|
|
183
|
+
test("rejects default tool targets outside the active turn scope", async () => {
|
|
184
|
+
const response = await Effect.runPromise(
|
|
185
|
+
handleToolRequest(
|
|
186
|
+
{ action: "followUpMessage", target: { guildId: "g1", channelId: "other" }, args: { content: "nope" } },
|
|
187
|
+
defaultConfig,
|
|
188
|
+
"/repo",
|
|
189
|
+
makeMemoryDiscord(),
|
|
190
|
+
{ allowedScopes: [{ guildId: "g1", channelId: "c1" }] }
|
|
191
|
+
)
|
|
192
|
+
)
|
|
193
|
+
|
|
194
|
+
expect(response).toEqual({ ok: false, error: "Discord target is outside the active turn scope" })
|
|
195
|
+
})
|
|
196
|
+
|
|
197
|
+
test("returns validation errors for malformed or unsupported requests", async () => {
|
|
198
|
+
const disabled = await Effect.runPromise(
|
|
199
|
+
handleToolRequest(
|
|
200
|
+
{ action: "followUpMessage", target: { guildId: "g1", channelId: "c1" }, args: { content: "x" } },
|
|
201
|
+
withTools({ enabled: false }),
|
|
202
|
+
"/repo",
|
|
203
|
+
makeMemoryDiscord()
|
|
204
|
+
)
|
|
205
|
+
)
|
|
206
|
+
const unknown = await Effect.runPromise(
|
|
207
|
+
handleToolRequest(
|
|
208
|
+
{ action: "unknown", target: { guildId: "g1", channelId: "c1" }, args: {} },
|
|
209
|
+
defaultConfig,
|
|
210
|
+
"/repo",
|
|
211
|
+
makeMemoryDiscord()
|
|
212
|
+
)
|
|
213
|
+
)
|
|
214
|
+
const missingContent = await Effect.runPromise(
|
|
215
|
+
handleToolRequest(
|
|
216
|
+
{ action: "followUpMessage", target: { guildId: "g1", channelId: "c1" }, args: {} },
|
|
217
|
+
defaultConfig,
|
|
218
|
+
"/repo",
|
|
219
|
+
makeMemoryDiscord()
|
|
220
|
+
)
|
|
221
|
+
)
|
|
222
|
+
const missingReactionFields = await Effect.runPromise(
|
|
223
|
+
handleToolRequest(
|
|
224
|
+
{ action: "addReaction", target: { guildId: "g1", channelId: "c1" }, args: {} },
|
|
225
|
+
defaultConfig,
|
|
226
|
+
"/repo",
|
|
227
|
+
makeMemoryDiscord()
|
|
228
|
+
)
|
|
229
|
+
)
|
|
230
|
+
const missingPath = await Effect.runPromise(
|
|
231
|
+
handleToolRequest(
|
|
232
|
+
{ action: "attachFile", target: { guildId: "g1", channelId: "c1" }, args: {} },
|
|
233
|
+
defaultConfig,
|
|
234
|
+
"/repo",
|
|
235
|
+
makeMemoryDiscord()
|
|
236
|
+
)
|
|
237
|
+
)
|
|
238
|
+
|
|
239
|
+
expect(disabled).toEqual({ ok: false, error: "Discord bridge tools are disabled" })
|
|
240
|
+
expect(unknown).toEqual({ ok: false, error: "Unknown action unknown" })
|
|
241
|
+
expect(missingContent).toEqual({ ok: false, error: "content is required" })
|
|
242
|
+
expect(missingReactionFields).toEqual({ ok: false, error: "messageId and emoji are required" })
|
|
243
|
+
expect(missingPath).toEqual({ ok: false, error: "path is required" })
|
|
244
|
+
})
|
|
245
|
+
})
|