opencode-discord-bot 0.0.9 → 0.0.11
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/package.json +1 -1
- package/src/Bridge/LoopbackServer.ts +1 -3
- package/src/Bridge/ToolControl.test.ts +1 -10
- package/src/Bridge/ToolControl.ts +4 -95
- package/src/Bridge/ToolControlEdges.test.ts +0 -19
- package/src/Bridge/ToolControlHighRisk.test.ts +3 -103
- package/src/Config.test.ts +7 -4
- package/src/Config.ts +2 -8
- package/src/ConfigSchema.ts +1 -3
- package/src/Discord/ChatSdkDiscord.test.ts +1 -15
- package/src/Discord/ChatSdkDiscord.ts +1 -8
- package/src/Discord/DiscordJsDiscord.test.ts +2 -30
- package/src/Discord/DiscordJsDiscord.ts +0 -23
- package/src/Discord/DiscordPort.ts +0 -3
- package/src/Discord/MemoryDiscord.test.ts +1 -2
- package/src/Discord/MemoryDiscord.ts +2 -9
- package/src/Main.test.ts +2 -5
- package/src/Main.ts +2 -4
- package/src/Orchestrator/ContextAssembly.test.ts +28 -3
- package/src/Orchestrator/ContextAssembly.ts +51 -5
- package/src/Tools/DiscordAddReactionTool.ts +29 -0
- package/src/Tools/DiscordAttachFileTool.ts +28 -0
- package/src/Tools/DiscordCreateThreadTool.ts +28 -0
- package/src/Tools/DiscordFetchHistoryTool.ts +28 -0
- package/src/Tools/Scaffolding.test.ts +60 -31
- package/src/Tools/Scaffolding.ts +34 -8
- package/src/Tools/DiscordBridgeTool.ts +0 -42
package/package.json
CHANGED
|
@@ -14,7 +14,6 @@ export type LoopbackServerOptions = {
|
|
|
14
14
|
readonly projectDir: string
|
|
15
15
|
readonly discord: DiscordService
|
|
16
16
|
readonly getAllowedScopes?: (() => ReadonlyArray<DiscordScope>) | undefined
|
|
17
|
-
readonly botId?: string | undefined
|
|
18
17
|
}
|
|
19
18
|
|
|
20
19
|
type LoopbackServer = {
|
|
@@ -48,8 +47,7 @@ const toolApiLayer = (options: LoopbackServerOptions) =>
|
|
|
48
47
|
Effect.gen(function* () {
|
|
49
48
|
const toolRequest = yield* parseBody(request)
|
|
50
49
|
return yield* handleToolRequest(toolRequest, options.config, options.projectDir, options.discord, {
|
|
51
|
-
allowedScopes: options.getAllowedScopes?.() ?? []
|
|
52
|
-
botId: options.botId
|
|
50
|
+
allowedScopes: options.getAllowedScopes?.() ?? []
|
|
53
51
|
})
|
|
54
52
|
}).pipe(Effect.catch((error) => Effect.succeed(toolFailure(error))))
|
|
55
53
|
)
|
|
@@ -137,14 +137,6 @@ describe("handleToolRequest action dispatch", () => {
|
|
|
137
137
|
discord
|
|
138
138
|
)
|
|
139
139
|
)
|
|
140
|
-
const remove = await Effect.runPromise(
|
|
141
|
-
handleToolRequest(
|
|
142
|
-
{ action: "removeReaction", target: { guildId: "g1", channelId: "c1", messageId: "m1" }, args: { emoji: "rocket" } },
|
|
143
|
-
defaultConfig,
|
|
144
|
-
projectDir,
|
|
145
|
-
discord
|
|
146
|
-
)
|
|
147
|
-
)
|
|
148
140
|
const history = await Effect.runPromise(
|
|
149
141
|
handleToolRequest(
|
|
150
142
|
{ action: "fetchHistory", target: { guildId: "g1", channelId: "c1" }, args: { limit: 1 } },
|
|
@@ -164,10 +156,9 @@ describe("handleToolRequest action dispatch", () => {
|
|
|
164
156
|
const attachmentRealpath = await realpath(join(projectDir, "out", "report.txt"))
|
|
165
157
|
|
|
166
158
|
expect(add).toEqual({ ok: true, result: { reacted: true } })
|
|
167
|
-
expect(remove).toEqual({ ok: true, result: { reacted: false } })
|
|
168
159
|
expect(history.ok).toBe(true)
|
|
169
160
|
expect(attach).toEqual({ ok: true, result: { path: attachmentRealpath } })
|
|
170
|
-
expect(discord.reactions.map((item) => item.op)).toEqual(["add"
|
|
161
|
+
expect(discord.reactions.map((item) => item.op)).toEqual(["add"])
|
|
171
162
|
expect(discord.attachments).toEqual([{ scope: { guildId: "g1", channelId: "c1" }, path: attachmentRealpath }])
|
|
172
163
|
} finally {
|
|
173
164
|
await rm(projectDir, { recursive: true, force: true })
|
|
@@ -3,18 +3,15 @@ import { isAbsolute, resolve, sep } from "node:path"
|
|
|
3
3
|
import { Effect } from "effect"
|
|
4
4
|
import type { RuntimeConfig, ToolConfig } from "../Config.ts"
|
|
5
5
|
import type { DiscordService } from "../Discord/DiscordPort.ts"
|
|
6
|
-
import { sanitizeDiscordContent } from "../Discord/Safety.ts"
|
|
7
6
|
import type { DiscordScope, ToolRequest, ToolResponse } from "../Schema.ts"
|
|
8
7
|
|
|
9
8
|
type ToolRequestOptions = {
|
|
10
9
|
readonly allowedScopes?: ReadonlyArray<DiscordScope> | undefined
|
|
11
|
-
readonly botId?: string | undefined
|
|
12
10
|
}
|
|
13
11
|
|
|
14
12
|
const actionFlag = (action: string): keyof ToolConfig | undefined => {
|
|
15
13
|
switch (action) {
|
|
16
14
|
case "addReaction":
|
|
17
|
-
case "removeReaction":
|
|
18
15
|
return "reactions"
|
|
19
16
|
case "attachFile":
|
|
20
17
|
return "attachFiles"
|
|
@@ -22,12 +19,6 @@ const actionFlag = (action: string): keyof ToolConfig | undefined => {
|
|
|
22
19
|
return "fetchHistory"
|
|
23
20
|
case "createThread":
|
|
24
21
|
return "createThread"
|
|
25
|
-
case "editOwnMessage":
|
|
26
|
-
case "deleteOwnMessage":
|
|
27
|
-
return "editDeleteOwn"
|
|
28
|
-
case "pin":
|
|
29
|
-
case "unpin":
|
|
30
|
-
return "pin"
|
|
31
22
|
default:
|
|
32
23
|
return undefined
|
|
33
24
|
}
|
|
@@ -70,21 +61,12 @@ const attachmentPath = Effect.fn("attachmentPath")(function* (projectDir: string
|
|
|
70
61
|
|
|
71
62
|
const disabled = (action: string): ToolResponse => ({ ok: false, error: `Action ${action} is disabled` })
|
|
72
63
|
|
|
73
|
-
const
|
|
74
|
-
request: ToolRequest,
|
|
75
|
-
scope: DiscordScope,
|
|
76
|
-
discord: DiscordService,
|
|
77
|
-
operation: "add" | "remove"
|
|
78
|
-
) {
|
|
64
|
+
const addReaction = Effect.fn("toolAddReaction")(function* (request: ToolRequest, scope: DiscordScope, discord: DiscordService) {
|
|
79
65
|
const messageId = request.target.messageId
|
|
80
66
|
const emoji = stringArg(request, "emoji")
|
|
81
67
|
if (messageId === undefined || emoji === undefined) return { ok: false, error: "messageId and emoji are required" } satisfies ToolResponse
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
return { ok: true, result: { reacted: true } } satisfies ToolResponse
|
|
85
|
-
}
|
|
86
|
-
yield* discord.removeReaction(scope, messageId, emoji)
|
|
87
|
-
return { ok: true, result: { reacted: false } } satisfies ToolResponse
|
|
68
|
+
yield* discord.addReaction(scope, messageId, emoji)
|
|
69
|
+
return { ok: true, result: { reacted: true } } satisfies ToolResponse
|
|
88
70
|
})
|
|
89
71
|
|
|
90
72
|
const fetchHistory = Effect.fn("toolFetchHistory")(function* (
|
|
@@ -120,69 +102,6 @@ const createThread = Effect.fn("toolCreateThread")(function* (request: ToolReque
|
|
|
120
102
|
return { ok: true, result } satisfies ToolResponse
|
|
121
103
|
})
|
|
122
104
|
|
|
123
|
-
const ensureOwnMessage = Effect.fn("ensureOwnDiscordMessage")(function* (
|
|
124
|
-
messageId: string,
|
|
125
|
-
scope: DiscordScope,
|
|
126
|
-
config: RuntimeConfig,
|
|
127
|
-
discord: DiscordService,
|
|
128
|
-
options: ToolRequestOptions
|
|
129
|
-
) {
|
|
130
|
-
if (options.botId === undefined) return "Bot identity is required to edit or delete bot-authored messages"
|
|
131
|
-
const history = yield* discord.fetchHistory(scope, config.context.messages)
|
|
132
|
-
const message = history.find((item) => item.id === messageId)
|
|
133
|
-
if (message?.author.id !== options.botId) return "messageId must refer to a bot-authored message"
|
|
134
|
-
return undefined
|
|
135
|
-
})
|
|
136
|
-
|
|
137
|
-
const editOwnMessage = Effect.fn("toolEditOwnMessage")(function* (
|
|
138
|
-
request: ToolRequest,
|
|
139
|
-
scope: DiscordScope,
|
|
140
|
-
config: RuntimeConfig,
|
|
141
|
-
discord: DiscordService,
|
|
142
|
-
options: ToolRequestOptions
|
|
143
|
-
) {
|
|
144
|
-
const messageId = request.target.messageId
|
|
145
|
-
const content = stringArg(request, "content")
|
|
146
|
-
if (messageId === undefined || content === undefined || content.trim() === "") {
|
|
147
|
-
return { ok: false, error: "messageId and content are required" } satisfies ToolResponse
|
|
148
|
-
}
|
|
149
|
-
const ownMessageError = yield* ensureOwnMessage(messageId, scope, config, discord, options)
|
|
150
|
-
if (ownMessageError !== undefined) return { ok: false, error: ownMessageError } satisfies ToolResponse
|
|
151
|
-
yield* discord.editMessage(scope, messageId, sanitizeDiscordContent(content, config.guards))
|
|
152
|
-
return { ok: true, result: { edited: true } } satisfies ToolResponse
|
|
153
|
-
})
|
|
154
|
-
|
|
155
|
-
const deleteOwnMessage = Effect.fn("toolDeleteOwnMessage")(function* (
|
|
156
|
-
request: ToolRequest,
|
|
157
|
-
scope: DiscordScope,
|
|
158
|
-
config: RuntimeConfig,
|
|
159
|
-
discord: DiscordService,
|
|
160
|
-
options: ToolRequestOptions
|
|
161
|
-
) {
|
|
162
|
-
const messageId = request.target.messageId
|
|
163
|
-
if (messageId === undefined) return { ok: false, error: "messageId is required" } satisfies ToolResponse
|
|
164
|
-
const ownMessageError = yield* ensureOwnMessage(messageId, scope, config, discord, options)
|
|
165
|
-
if (ownMessageError !== undefined) return { ok: false, error: ownMessageError } satisfies ToolResponse
|
|
166
|
-
yield* discord.deleteMessage(scope, messageId)
|
|
167
|
-
return { ok: true, result: { deleted: true } } satisfies ToolResponse
|
|
168
|
-
})
|
|
169
|
-
|
|
170
|
-
const pin = Effect.fn("toolPin")(function* (
|
|
171
|
-
request: ToolRequest,
|
|
172
|
-
scope: DiscordScope,
|
|
173
|
-
discord: DiscordService,
|
|
174
|
-
operation: "pin" | "unpin"
|
|
175
|
-
) {
|
|
176
|
-
const messageId = request.target.messageId
|
|
177
|
-
if (messageId === undefined) return { ok: false, error: "messageId is required" } satisfies ToolResponse
|
|
178
|
-
if (operation === "pin") {
|
|
179
|
-
yield* discord.pinMessage(scope, messageId)
|
|
180
|
-
return { ok: true, result: { pinned: true } } satisfies ToolResponse
|
|
181
|
-
}
|
|
182
|
-
yield* discord.unpinMessage(scope, messageId)
|
|
183
|
-
return { ok: true, result: { pinned: false } } satisfies ToolResponse
|
|
184
|
-
})
|
|
185
|
-
|
|
186
105
|
export const handleToolRequest = Effect.fn("handleToolRequest")(function* (
|
|
187
106
|
request: ToolRequest,
|
|
188
107
|
config: RuntimeConfig,
|
|
@@ -203,23 +122,13 @@ export const handleToolRequest = Effect.fn("handleToolRequest")(function* (
|
|
|
203
122
|
|
|
204
123
|
switch (request.action) {
|
|
205
124
|
case "addReaction":
|
|
206
|
-
return yield*
|
|
207
|
-
case "removeReaction":
|
|
208
|
-
return yield* reaction(request, scope, discord, "remove")
|
|
125
|
+
return yield* addReaction(request, scope, discord)
|
|
209
126
|
case "fetchHistory":
|
|
210
127
|
return yield* fetchHistory(request, scope, config, discord)
|
|
211
128
|
case "attachFile":
|
|
212
129
|
return yield* attachFile(request, scope, config, projectDir, discord)
|
|
213
130
|
case "createThread":
|
|
214
131
|
return yield* createThread(request, scope, discord)
|
|
215
|
-
case "editOwnMessage":
|
|
216
|
-
return yield* editOwnMessage(request, scope, config, discord, options)
|
|
217
|
-
case "deleteOwnMessage":
|
|
218
|
-
return yield* deleteOwnMessage(request, scope, config, discord, options)
|
|
219
|
-
case "pin":
|
|
220
|
-
return yield* pin(request, scope, discord, "pin")
|
|
221
|
-
case "unpin":
|
|
222
|
-
return yield* pin(request, scope, discord, "unpin")
|
|
223
132
|
}
|
|
224
133
|
return { ok: false, error: `Unknown action ${request.action}` } satisfies ToolResponse
|
|
225
134
|
})
|
|
@@ -24,22 +24,3 @@ test("rejects missing attachment files inside the project", async () => {
|
|
|
24
24
|
await rm(projectDir, { recursive: true, force: true })
|
|
25
25
|
}
|
|
26
26
|
})
|
|
27
|
-
|
|
28
|
-
test("rejects incomplete high-risk tool payloads before dispatch", async () => {
|
|
29
|
-
const config = {
|
|
30
|
-
...defaultConfig,
|
|
31
|
-
tools: { ...defaultConfig.tools, editDeleteOwn: 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
|
-
|
|
44
|
-
expect(edit).toEqual({ ok: false, error: "messageId and content are required" })
|
|
45
|
-
})
|
|
@@ -11,120 +11,20 @@ const withTools = (tools: Partial<ToolConfig>): RuntimeConfig => ({
|
|
|
11
11
|
})
|
|
12
12
|
|
|
13
13
|
describe("handleToolRequest high-risk actions", () => {
|
|
14
|
-
test("dispatches opt-in
|
|
15
|
-
const discord = makeMemoryDiscord(
|
|
16
|
-
|
|
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, pin: true })
|
|
14
|
+
test("dispatches opt-in thread creation through the Discord port", async () => {
|
|
15
|
+
const discord = makeMemoryDiscord()
|
|
16
|
+
const config = withTools({ createThread: true })
|
|
35
17
|
|
|
36
18
|
const created = await Effect.runPromise(
|
|
37
19
|
handleToolRequest(
|
|
38
20
|
{ action: "createThread", target: { guildId: "g1", channelId: "c1" }, args: { name: "work" } },
|
|
39
21
|
config,
|
|
40
22
|
"/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 pinned = await Effect.runPromise(
|
|
64
|
-
handleToolRequest({ action: "pin", target: { guildId: "g1", channelId: "c1", messageId: "m1" }, args: {} }, config, "/repo", discord)
|
|
65
|
-
)
|
|
66
|
-
const unpinned = await Effect.runPromise(
|
|
67
|
-
handleToolRequest(
|
|
68
|
-
{ action: "unpin", target: { guildId: "g1", channelId: "c1", messageId: "m1" }, args: {} },
|
|
69
|
-
config,
|
|
70
|
-
"/repo",
|
|
71
23
|
discord
|
|
72
24
|
)
|
|
73
25
|
)
|
|
74
26
|
|
|
75
27
|
expect(created).toEqual({ ok: true, result: { id: "thread-1" } })
|
|
76
|
-
expect(edited).toEqual({ ok: true, result: { edited: true } })
|
|
77
|
-
expect(deleted).toEqual({ ok: true, result: { deleted: true } })
|
|
78
|
-
expect(pinned).toEqual({ ok: true, result: { pinned: true } })
|
|
79
|
-
expect(unpinned).toEqual({ ok: true, result: { pinned: false } })
|
|
80
28
|
expect(discord.threads).toEqual([{ scope: { guildId: "g1", channelId: "c1" }, name: "work" }])
|
|
81
|
-
expect(discord.pins.map((item) => item.op)).toEqual(["pin", "unpin"])
|
|
82
|
-
})
|
|
83
|
-
|
|
84
|
-
test("rejects edit/delete requests for messages not authored by the bot", async () => {
|
|
85
|
-
const discord = makeMemoryDiscord({
|
|
86
|
-
context: [
|
|
87
|
-
{
|
|
88
|
-
id: "m1",
|
|
89
|
-
guildId: "g1",
|
|
90
|
-
channelId: "c1",
|
|
91
|
-
author: { id: "user-1", displayName: "user", isBot: false },
|
|
92
|
-
content: "user text",
|
|
93
|
-
timestamp: "2026-06-05T14:03:00.000Z",
|
|
94
|
-
mentions: [],
|
|
95
|
-
roleMentions: [],
|
|
96
|
-
everyoneMention: false,
|
|
97
|
-
hereMention: false,
|
|
98
|
-
attachments: [],
|
|
99
|
-
reactions: [],
|
|
100
|
-
channelType: "guild"
|
|
101
|
-
}
|
|
102
|
-
]
|
|
103
|
-
})
|
|
104
|
-
const config = withTools({ editDeleteOwn: true })
|
|
105
|
-
|
|
106
|
-
const edited = await Effect.runPromise(
|
|
107
|
-
handleToolRequest(
|
|
108
|
-
{ action: "editOwnMessage", target: { guildId: "g1", channelId: "c1", messageId: "m1" }, args: { content: "edited" } },
|
|
109
|
-
config,
|
|
110
|
-
"/repo",
|
|
111
|
-
discord,
|
|
112
|
-
{ botId: "bot-1" }
|
|
113
|
-
)
|
|
114
|
-
)
|
|
115
|
-
const deleted = await Effect.runPromise(
|
|
116
|
-
handleToolRequest(
|
|
117
|
-
{ action: "deleteOwnMessage", target: { guildId: "g1", channelId: "c1", messageId: "m1" }, args: {} },
|
|
118
|
-
config,
|
|
119
|
-
"/repo",
|
|
120
|
-
discord,
|
|
121
|
-
{ botId: "bot-1" }
|
|
122
|
-
)
|
|
123
|
-
)
|
|
124
|
-
|
|
125
|
-
expect(edited).toEqual({ ok: false, error: "messageId must refer to a bot-authored message" })
|
|
126
|
-
expect(deleted).toEqual({ ok: false, error: "messageId must refer to a bot-authored message" })
|
|
127
|
-
expect(discord.edits).toEqual([])
|
|
128
|
-
expect(discord.deletes).toEqual([])
|
|
129
29
|
})
|
|
130
30
|
})
|
package/src/Config.test.ts
CHANGED
|
@@ -24,7 +24,7 @@ describe("loadConfigFromSources", () => {
|
|
|
24
24
|
"threads": { "activeByRecentBotParticipation": false },
|
|
25
25
|
"concurrency": { "strategy": "burst", "globalMaxActiveTurns": 4 },
|
|
26
26
|
"guards": { "ignoreBots": false, "stripMassMentions": false, "redactSecretsInErrors": false, "maxTurnMs": 1000 },
|
|
27
|
-
"tools": { "createThread": true, "pin": true, "followUpMessages": true, "postOtherChannels": true }
|
|
27
|
+
"tools": { "createThread": true, "editDeleteOwn": true, "pin": true, "followUpMessages": true, "postOtherChannels": true }
|
|
28
28
|
}`
|
|
29
29
|
})
|
|
30
30
|
)
|
|
@@ -47,7 +47,8 @@ describe("loadConfigFromSources", () => {
|
|
|
47
47
|
expect(config.guards.maxTurn).toEqual(Duration.millis(1000))
|
|
48
48
|
expect(config.tools.reactions).toBe(true)
|
|
49
49
|
expect(config.tools.createThread).toBe(true)
|
|
50
|
-
expect(config.tools
|
|
50
|
+
expect("editDeleteOwn" in config.tools).toBe(false)
|
|
51
|
+
expect("pin" in config.tools).toBe(false)
|
|
51
52
|
expect("followUpMessages" in config.tools).toBe(false)
|
|
52
53
|
expect("postOtherChannels" in config.tools).toBe(false)
|
|
53
54
|
})
|
|
@@ -61,6 +62,8 @@ describe("loadConfigFromSources", () => {
|
|
|
61
62
|
expect(config.bridge.port).toBe(8787)
|
|
62
63
|
expect(config.context.messages).toBe(30)
|
|
63
64
|
expect(config.tools.autoInstall).toBe(true)
|
|
65
|
+
expect("editDeleteOwn" in config.tools).toBe(false)
|
|
66
|
+
expect("pin" in config.tools).toBe(false)
|
|
64
67
|
expect("followUpMessages" in config.tools).toBe(false)
|
|
65
68
|
expect("postOtherChannels" in config.tools).toBe(false)
|
|
66
69
|
})
|
|
@@ -106,11 +109,11 @@ describe("loadConfigFromSources", () => {
|
|
|
106
109
|
const cwd = await mkdtemp(join(tmpdir(), "ocdb-config-"))
|
|
107
110
|
|
|
108
111
|
try {
|
|
109
|
-
await writeFile(join(cwd, ".opencode-discord.jsonc"), `{ "contextMessages": 7, "tools": { "
|
|
112
|
+
await writeFile(join(cwd, ".opencode-discord.jsonc"), `{ "contextMessages": 7, "tools": { "createThread": true } }`)
|
|
110
113
|
const config = await Effect.runPromise(loadConfig({ cwd, env: { DISCORD_TOKEN: "token" } }))
|
|
111
114
|
|
|
112
115
|
expect(config.context.messages).toBe(7)
|
|
113
|
-
expect(config.tools.
|
|
116
|
+
expect(config.tools.createThread).toBe(true)
|
|
114
117
|
} finally {
|
|
115
118
|
await rm(cwd, { recursive: true, force: true })
|
|
116
119
|
}
|
package/src/Config.ts
CHANGED
|
@@ -18,8 +18,6 @@ export type ToolConfig = {
|
|
|
18
18
|
readonly attachFiles: boolean
|
|
19
19
|
readonly fetchHistory: boolean
|
|
20
20
|
readonly createThread: boolean
|
|
21
|
-
readonly editDeleteOwn: boolean
|
|
22
|
-
readonly pin: boolean
|
|
23
21
|
}
|
|
24
22
|
|
|
25
23
|
export type RuntimeConfig = {
|
|
@@ -94,9 +92,7 @@ export const defaultConfig: RuntimeConfig = {
|
|
|
94
92
|
reactions: true,
|
|
95
93
|
attachFiles: true,
|
|
96
94
|
fetchHistory: true,
|
|
97
|
-
createThread: false
|
|
98
|
-
editDeleteOwn: false,
|
|
99
|
-
pin: false
|
|
95
|
+
createThread: false
|
|
100
96
|
},
|
|
101
97
|
streaming: {
|
|
102
98
|
updateInterval: Duration.millis(500),
|
|
@@ -259,9 +255,7 @@ export const loadConfigFromSources = Effect.fn("loadConfigFromSources")(function
|
|
|
259
255
|
reactions: readBoolean(tools, "reactions", defaultConfig.tools.reactions),
|
|
260
256
|
attachFiles: readBoolean(tools, "attachFiles", defaultConfig.tools.attachFiles),
|
|
261
257
|
fetchHistory: readBoolean(tools, "fetchHistory", defaultConfig.tools.fetchHistory),
|
|
262
|
-
createThread: readBoolean(tools, "createThread", defaultConfig.tools.createThread)
|
|
263
|
-
editDeleteOwn: readBoolean(tools, "editDeleteOwn", defaultConfig.tools.editDeleteOwn),
|
|
264
|
-
pin: readBoolean(tools, "pin", defaultConfig.tools.pin)
|
|
258
|
+
createThread: readBoolean(tools, "createThread", defaultConfig.tools.createThread)
|
|
265
259
|
},
|
|
266
260
|
streaming: {
|
|
267
261
|
updateInterval: Duration.millis(readNumber(streaming, "updateIntervalMs", 500)),
|
package/src/ConfigSchema.ts
CHANGED
|
@@ -8,9 +8,7 @@ const RawToolsSchema = Schema.Struct({
|
|
|
8
8
|
reactions: OptionalBoolean,
|
|
9
9
|
attachFiles: OptionalBoolean,
|
|
10
10
|
fetchHistory: OptionalBoolean,
|
|
11
|
-
createThread: OptionalBoolean
|
|
12
|
-
editDeleteOwn: OptionalBoolean,
|
|
13
|
-
pin: OptionalBoolean
|
|
11
|
+
createThread: OptionalBoolean
|
|
14
12
|
})
|
|
15
13
|
|
|
16
14
|
export const RawConfigSchema = Schema.Struct({
|
|
@@ -47,11 +47,6 @@ class FakeDiscordAdapter {
|
|
|
47
47
|
return Promise.resolve()
|
|
48
48
|
}
|
|
49
49
|
|
|
50
|
-
removeReaction(threadId: string, messageId: string, emoji: string): Promise<void> {
|
|
51
|
-
this.calls.push(["removeReaction", { threadId, messageId, emoji }])
|
|
52
|
-
return Promise.resolve()
|
|
53
|
-
}
|
|
54
|
-
|
|
55
50
|
fetchMessages(threadId: string): Promise<FetchResult<unknown>> {
|
|
56
51
|
this.calls.push(["fetchMessages", { threadId }])
|
|
57
52
|
return Promise.resolve({
|
|
@@ -82,7 +77,6 @@ describe("makeChatSdkDiscord", () => {
|
|
|
82
77
|
await Effect.runPromise(discord.editMessage(scope, posted.id, "edited"))
|
|
83
78
|
await Effect.runPromise(discord.sendTyping(scope))
|
|
84
79
|
await Effect.runPromise(discord.addReaction(scope, "m1", "rocket"))
|
|
85
|
-
await Effect.runPromise(discord.removeReaction(scope, "m1", "rocket"))
|
|
86
80
|
const directory = await mkdtemp(join(tmpdir(), "ocdb-chat-"))
|
|
87
81
|
|
|
88
82
|
try {
|
|
@@ -126,8 +120,6 @@ describe("makeChatSdkDiscord", () => {
|
|
|
126
120
|
"encodeThreadId",
|
|
127
121
|
"addReaction",
|
|
128
122
|
"encodeThreadId",
|
|
129
|
-
"removeReaction",
|
|
130
|
-
"encodeThreadId",
|
|
131
123
|
"postMessage"
|
|
132
124
|
])
|
|
133
125
|
})
|
|
@@ -222,18 +214,12 @@ describe("makeChatSdkDiscord REST operations", () => {
|
|
|
222
214
|
|
|
223
215
|
await Effect.runPromise(discord.deleteMessage(scope, "m1"))
|
|
224
216
|
expect(await Effect.runPromise(discord.createThread(scope, "work"))).toEqual({ id: "thread-1" })
|
|
225
|
-
await Effect.runPromise(discord.pinMessage(scope, "m1"))
|
|
226
|
-
await Effect.runPromise(discord.unpinMessage(scope, "m1"))
|
|
227
217
|
} finally {
|
|
228
218
|
globalThis.fetch = originalFetch
|
|
229
219
|
}
|
|
230
220
|
|
|
231
221
|
expect(adapter.calls.map((item) => item[0])).toEqual(["encodeThreadId", "deleteMessage"])
|
|
232
|
-
expect(requests.map((request) => [request[0], request[1].method])).toEqual([
|
|
233
|
-
["https://discord.test/api/channels/c1/threads", "POST"],
|
|
234
|
-
["https://discord.test/api/channels/t1/pins/m1", "PUT"],
|
|
235
|
-
["https://discord.test/api/channels/t1/pins/m1", "DELETE"]
|
|
236
|
-
])
|
|
222
|
+
expect(requests.map((request) => [request[0], request[1].method])).toEqual([["https://discord.test/api/channels/c1/threads", "POST"]])
|
|
237
223
|
})
|
|
238
224
|
|
|
239
225
|
test("fails raw REST operations when no raw Discord client is configured", async () => {
|
|
@@ -13,7 +13,6 @@ type ChatDiscordAdapter = {
|
|
|
13
13
|
readonly deleteMessage: (threadId: string, messageId: string) => Promise<void>
|
|
14
14
|
readonly startTyping: (threadId: string, status?: string) => Promise<void>
|
|
15
15
|
readonly addReaction: (threadId: string, messageId: string, emoji: string) => Promise<void>
|
|
16
|
-
readonly removeReaction: (threadId: string, messageId: string, emoji: string) => Promise<void>
|
|
17
16
|
readonly fetchMessages: (threadId: string, options?: FetchOptions) => Promise<FetchResult<unknown>>
|
|
18
17
|
}
|
|
19
18
|
|
|
@@ -223,8 +222,6 @@ export const makeChatSdkDiscord = (adapter: ChatDiscordAdapter, raw: RawDiscordO
|
|
|
223
222
|
tryAdapter(() => adapter.deleteMessage(threadIdFromScope(adapter, scope), messageId)).pipe(Effect.asVoid),
|
|
224
223
|
addReaction: (scope, messageId, emoji) =>
|
|
225
224
|
tryAdapter(() => adapter.addReaction(threadIdFromScope(adapter, scope), messageId, emoji)).pipe(Effect.asVoid),
|
|
226
|
-
removeReaction: (scope, messageId, emoji) =>
|
|
227
|
-
tryAdapter(() => adapter.removeReaction(threadIdFromScope(adapter, scope), messageId, emoji)).pipe(Effect.asVoid),
|
|
228
225
|
attachFile: (scope, path) =>
|
|
229
226
|
tryAdapter(async () => {
|
|
230
227
|
const file: PostableRaw = { raw: "", files: [{ filename: basename(path), data: await readFile(path) }] }
|
|
@@ -235,11 +232,7 @@ export const makeChatSdkDiscord = (adapter: ChatDiscordAdapter, raw: RawDiscordO
|
|
|
235
232
|
rawDiscord(raw, `/channels/${scope.channelId}/threads`, {
|
|
236
233
|
method: "POST",
|
|
237
234
|
body: JSON.stringify({ name, type: 11 })
|
|
238
|
-
}).pipe(Effect.map((data) => ({ id: isRecord(data) && typeof data.id === "string" ? data.id : "" })))
|
|
239
|
-
pinMessage: (scope, messageId) =>
|
|
240
|
-
rawDiscord(raw, `/channels/${scope.threadId ?? scope.channelId}/pins/${messageId}`, { method: "PUT" }).pipe(Effect.asVoid),
|
|
241
|
-
unpinMessage: (scope, messageId) =>
|
|
242
|
-
rawDiscord(raw, `/channels/${scope.threadId ?? scope.channelId}/pins/${messageId}`, { method: "DELETE" }).pipe(Effect.asVoid)
|
|
235
|
+
}).pipe(Effect.map((data) => ({ id: isRecord(data) && typeof data.id === "string" ? data.id : "" })))
|
|
243
236
|
}
|
|
244
237
|
}
|
|
245
238
|
|
|
@@ -68,24 +68,11 @@ describe("makeDiscordJsDiscord", () => {
|
|
|
68
68
|
calls.push(["delete", {}])
|
|
69
69
|
return Promise.resolve({})
|
|
70
70
|
},
|
|
71
|
-
pin: () => {
|
|
72
|
-
calls.push(["pin", {}])
|
|
73
|
-
return Promise.resolve({})
|
|
74
|
-
},
|
|
75
|
-
unpin: () => {
|
|
76
|
-
calls.push(["unpin", {}])
|
|
77
|
-
return Promise.resolve({})
|
|
78
|
-
},
|
|
79
71
|
react: (emoji: string) => {
|
|
80
72
|
calls.push(["react", emoji])
|
|
81
73
|
return Promise.resolve({})
|
|
82
74
|
},
|
|
83
|
-
reactions: {
|
|
84
|
-
cache: collection([{ emoji: { name: "rocket" }, count: 2 }]),
|
|
85
|
-
resolve: (emoji: string) => ({
|
|
86
|
-
users: { remove: (userId: string) => Promise.resolve(calls.push(["removeReaction", { emoji, userId }])) }
|
|
87
|
-
})
|
|
88
|
-
}
|
|
75
|
+
reactions: { cache: collection([{ emoji: { name: "rocket" }, count: 2 }]) }
|
|
89
76
|
}
|
|
90
77
|
const channel = {
|
|
91
78
|
send: (content: unknown) => {
|
|
@@ -131,21 +118,15 @@ describe("makeDiscordJsDiscord", () => {
|
|
|
131
118
|
await Effect.runPromise(discord.editMessage(scope, "m1", "edited"))
|
|
132
119
|
await Effect.runPromise(discord.deleteMessage(scope, "m1"))
|
|
133
120
|
await Effect.runPromise(discord.addReaction(scope, "m1", "rocket"))
|
|
134
|
-
await Effect.runPromise(discord.removeReaction(scope, "m1", "rocket"))
|
|
135
121
|
const attached = await Effect.runPromise(discord.attachFile(scope, file))
|
|
136
122
|
expect(await Effect.runPromise(discord.createThread(scope, "work"))).toEqual({ id: "thread-1" })
|
|
137
|
-
await Effect.runPromise(discord.pinMessage(scope, "m1"))
|
|
138
|
-
await Effect.runPromise(discord.unpinMessage(scope, "m1"))
|
|
139
123
|
|
|
140
124
|
expect(context).toHaveLength(1)
|
|
141
125
|
expect(history).toHaveLength(1)
|
|
142
126
|
expect(posted).toEqual({ id: "posted-1" })
|
|
143
127
|
expect(attached).toEqual({ path: "posted-1" })
|
|
144
|
-
expect(calls.map((call) => call[0])).toContain("removeReaction")
|
|
145
128
|
expect(calls.map((call) => call[0])).toContain("delete")
|
|
146
129
|
expect(calls.map((call) => call[0])).toContain("createThread")
|
|
147
|
-
expect(calls.map((call) => call[0])).toContain("pin")
|
|
148
|
-
expect(calls.map((call) => call[0])).toContain("unpin")
|
|
149
130
|
} finally {
|
|
150
131
|
await rm(directory, { recursive: true, force: true })
|
|
151
132
|
}
|
|
@@ -178,8 +159,7 @@ describe("makeDiscordJsDiscord", () => {
|
|
|
178
159
|
const fetchedMessage = {
|
|
179
160
|
...baseMessage(),
|
|
180
161
|
edit: () => Promise.resolve({}),
|
|
181
|
-
react: () => Promise.resolve({})
|
|
182
|
-
reactions: { resolve: () => null }
|
|
162
|
+
react: () => Promise.resolve({})
|
|
183
163
|
}
|
|
184
164
|
const channel = {
|
|
185
165
|
send: () => Promise.resolve({ id: "posted-1" }),
|
|
@@ -191,14 +171,6 @@ describe("makeDiscordJsDiscord", () => {
|
|
|
191
171
|
_tag: "DiscordError",
|
|
192
172
|
message: "Discord channel cannot create threads"
|
|
193
173
|
})
|
|
194
|
-
await expect(Effect.runPromise(discord.pinMessage(scope, "m1"))).rejects.toMatchObject({
|
|
195
|
-
_tag: "DiscordError",
|
|
196
|
-
message: "Discord message is not pinnable"
|
|
197
|
-
})
|
|
198
|
-
await expect(Effect.runPromise(discord.unpinMessage(scope, "m1"))).rejects.toMatchObject({
|
|
199
|
-
_tag: "DiscordError",
|
|
200
|
-
message: "Discord message is not unpinnable"
|
|
201
|
-
})
|
|
202
174
|
await expect(Effect.runPromise(discord.deleteMessage(scope, "m1"))).rejects.toMatchObject({
|
|
203
175
|
_tag: "DiscordError",
|
|
204
176
|
message: "Discord message is not deletable"
|
|
@@ -57,11 +57,6 @@ type DiscordFetchedMessageLike = DiscordJsMessageLike & {
|
|
|
57
57
|
readonly edit: (content: string) => Promise<unknown>
|
|
58
58
|
readonly react: (emoji: string) => Promise<unknown>
|
|
59
59
|
readonly delete?: () => Promise<unknown>
|
|
60
|
-
readonly pin?: () => Promise<unknown>
|
|
61
|
-
readonly unpin?: () => Promise<unknown>
|
|
62
|
-
readonly reactions: DiscordJsMessageLike["reactions"] & {
|
|
63
|
-
readonly resolve: (emoji: string) => { readonly users: { readonly remove: (userId: string) => Promise<unknown> } } | null
|
|
64
|
-
}
|
|
65
60
|
}
|
|
66
61
|
|
|
67
62
|
export type DiscordJsChannelLike = {
|
|
@@ -227,12 +222,6 @@ export const makeDiscordJsDiscord = (client: DiscordJsClientLike): DiscordServic
|
|
|
227
222
|
const message = yield* fetchMessage(client, scope, messageId)
|
|
228
223
|
yield* tryDiscord(() => message.react(emoji))
|
|
229
224
|
}),
|
|
230
|
-
removeReaction: (scope, messageId, emoji) =>
|
|
231
|
-
Effect.gen(function* () {
|
|
232
|
-
const message = yield* fetchMessage(client, scope, messageId)
|
|
233
|
-
const reaction = message.reactions.resolve(emoji)
|
|
234
|
-
if (reaction !== null && client.user !== null) yield* tryDiscord(() => reaction.users.remove(client.user?.id ?? ""))
|
|
235
|
-
}),
|
|
236
225
|
attachFile: (scope, path) =>
|
|
237
226
|
Effect.gen(function* () {
|
|
238
227
|
const channel = yield* fetchTextChannel(client, scope)
|
|
@@ -245,17 +234,5 @@ export const makeDiscordJsDiscord = (client: DiscordJsClientLike): DiscordServic
|
|
|
245
234
|
const channel = yield* fetchTextChannel(client, scope)
|
|
246
235
|
if (channel.threads === undefined) return yield* Effect.fail(new DiscordError({ message: "Discord channel cannot create threads" }))
|
|
247
236
|
return yield* tryDiscord(() => channel.threads?.create({ name }) ?? Promise.resolve({ id: "" }))
|
|
248
|
-
}),
|
|
249
|
-
pinMessage: (scope, messageId) =>
|
|
250
|
-
Effect.gen(function* () {
|
|
251
|
-
const message = yield* fetchMessage(client, scope, messageId)
|
|
252
|
-
if (message.pin === undefined) return yield* Effect.fail(new DiscordError({ message: "Discord message is not pinnable" }))
|
|
253
|
-
yield* tryDiscord(() => message.pin?.() ?? Promise.resolve())
|
|
254
|
-
}),
|
|
255
|
-
unpinMessage: (scope, messageId) =>
|
|
256
|
-
Effect.gen(function* () {
|
|
257
|
-
const message = yield* fetchMessage(client, scope, messageId)
|
|
258
|
-
if (message.unpin === undefined) return yield* Effect.fail(new DiscordError({ message: "Discord message is not unpinnable" }))
|
|
259
|
-
yield* tryDiscord(() => message.unpin?.() ?? Promise.resolve())
|
|
260
237
|
})
|
|
261
238
|
})
|
|
@@ -17,13 +17,10 @@ export type DiscordService = {
|
|
|
17
17
|
readonly postMessage: (scope: DiscordScope, content: string) => Effect.Effect<{ readonly id: string }, DiscordError>
|
|
18
18
|
readonly editMessage: (scope: DiscordScope, messageId: string, content: string) => Effect.Effect<void, DiscordError>
|
|
19
19
|
readonly addReaction: (scope: DiscordScope, messageId: string, emoji: string) => Effect.Effect<void, DiscordError>
|
|
20
|
-
readonly removeReaction: (scope: DiscordScope, messageId: string, emoji: string) => Effect.Effect<void, DiscordError>
|
|
21
20
|
readonly fetchHistory: (scope: DiscordScope, limit: number) => Effect.Effect<ReadonlyArray<DiscordMessage>, DiscordError>
|
|
22
21
|
readonly attachFile: (scope: DiscordScope, path: string) => Effect.Effect<{ readonly path: string }, DiscordError>
|
|
23
22
|
readonly createThread: (scope: DiscordScope, name: string) => Effect.Effect<{ readonly id: string }, DiscordError>
|
|
24
23
|
readonly deleteMessage: (scope: DiscordScope, messageId: string) => Effect.Effect<void, DiscordError>
|
|
25
|
-
readonly pinMessage: (scope: DiscordScope, messageId: string) => Effect.Effect<void, DiscordError>
|
|
26
|
-
readonly unpinMessage: (scope: DiscordScope, messageId: string) => Effect.Effect<void, DiscordError>
|
|
27
24
|
}
|
|
28
25
|
|
|
29
26
|
export const Discord = Context.Service<DiscordService>("opencode-discord-bot/Discord")
|
|
@@ -30,7 +30,6 @@ describe("makeMemoryDiscord", () => {
|
|
|
30
30
|
const posted = await Effect.runPromise(discord.postMessage(scope, "hello"))
|
|
31
31
|
await Effect.runPromise(discord.editMessage(scope, posted.id, "updated"))
|
|
32
32
|
await Effect.runPromise(discord.addReaction(scope, "m1", "rocket"))
|
|
33
|
-
await Effect.runPromise(discord.removeReaction(scope, "m1", "rocket"))
|
|
34
33
|
const attached = await Effect.runPromise(discord.attachFile(scope, "/repo/out.txt"))
|
|
35
34
|
|
|
36
35
|
expect(context.map((item) => item.id)).toEqual(["2"])
|
|
@@ -38,7 +37,7 @@ describe("makeMemoryDiscord", () => {
|
|
|
38
37
|
expect(posted).toEqual({ id: "posted-1" })
|
|
39
38
|
expect(discord.messages).toEqual([{ scope, content: "hello" }])
|
|
40
39
|
expect(discord.edits).toEqual([{ scope, messageId: "posted-1", content: "updated" }])
|
|
41
|
-
expect(discord.reactions.map((item) => item.op)).toEqual(["add"
|
|
40
|
+
expect(discord.reactions.map((item) => item.op)).toEqual(["add"])
|
|
42
41
|
expect(attached).toEqual({ path: "/repo/out.txt" })
|
|
43
42
|
})
|
|
44
43
|
})
|
|
@@ -15,12 +15,11 @@ export type MemoryDiscord = DiscordService & {
|
|
|
15
15
|
readonly scope: DiscordScope
|
|
16
16
|
readonly messageId: string
|
|
17
17
|
readonly emoji: string
|
|
18
|
-
readonly op: "add"
|
|
18
|
+
readonly op: "add"
|
|
19
19
|
}>
|
|
20
20
|
readonly attachments: Array<{ readonly scope: DiscordScope; readonly path: string }>
|
|
21
21
|
readonly threads: Array<{ readonly scope: DiscordScope; readonly name: string }>
|
|
22
22
|
readonly deletes: Array<{ readonly scope: DiscordScope; readonly messageId: string }>
|
|
23
|
-
readonly pins: Array<{ readonly scope: DiscordScope; readonly messageId: string; readonly op: "pin" | "unpin" }>
|
|
24
23
|
}
|
|
25
24
|
|
|
26
25
|
export const makeMemoryDiscord = (options: MemoryOptions = {}): MemoryDiscord => {
|
|
@@ -33,7 +32,6 @@ export const makeMemoryDiscord = (options: MemoryOptions = {}): MemoryDiscord =>
|
|
|
33
32
|
const attachments: MemoryDiscord["attachments"] = []
|
|
34
33
|
const threads: MemoryDiscord["threads"] = []
|
|
35
34
|
const deletes: MemoryDiscord["deletes"] = []
|
|
36
|
-
const pins: MemoryDiscord["pins"] = []
|
|
37
35
|
|
|
38
36
|
return {
|
|
39
37
|
context,
|
|
@@ -44,7 +42,6 @@ export const makeMemoryDiscord = (options: MemoryOptions = {}): MemoryDiscord =>
|
|
|
44
42
|
attachments,
|
|
45
43
|
threads,
|
|
46
44
|
deletes,
|
|
47
|
-
pins,
|
|
48
45
|
fetchContext: (_scope, limit) => Effect.succeed(context.slice(Math.max(0, context.length - limit))),
|
|
49
46
|
sendTyping: (scope) => Effect.sync(() => typingScopes.push(scope)).pipe(Effect.asVoid),
|
|
50
47
|
postMessage: (scope, content) =>
|
|
@@ -55,8 +52,6 @@ export const makeMemoryDiscord = (options: MemoryOptions = {}): MemoryDiscord =>
|
|
|
55
52
|
}),
|
|
56
53
|
editMessage: (scope, messageId, content) => Effect.sync(() => edits.push({ scope, messageId, content })).pipe(Effect.asVoid),
|
|
57
54
|
addReaction: (scope, messageId, emoji) => Effect.sync(() => reactions.push({ scope, messageId, emoji, op: "add" })).pipe(Effect.asVoid),
|
|
58
|
-
removeReaction: (scope, messageId, emoji) =>
|
|
59
|
-
Effect.sync(() => reactions.push({ scope, messageId, emoji, op: "remove" })).pipe(Effect.asVoid),
|
|
60
55
|
fetchHistory: (_scope, limit) => Effect.succeed(context.slice(Math.max(0, context.length - limit))),
|
|
61
56
|
attachFile: (scope, path) =>
|
|
62
57
|
Effect.sync(() => {
|
|
@@ -69,8 +64,6 @@ export const makeMemoryDiscord = (options: MemoryOptions = {}): MemoryDiscord =>
|
|
|
69
64
|
threads.push({ scope, name })
|
|
70
65
|
return { id: `thread-${nextId}` }
|
|
71
66
|
}),
|
|
72
|
-
deleteMessage: (scope, messageId) => Effect.sync(() => deletes.push({ scope, messageId })).pipe(Effect.asVoid)
|
|
73
|
-
pinMessage: (scope, messageId) => Effect.sync(() => pins.push({ scope, messageId, op: "pin" })).pipe(Effect.asVoid),
|
|
74
|
-
unpinMessage: (scope, messageId) => Effect.sync(() => pins.push({ scope, messageId, op: "unpin" })).pipe(Effect.asVoid)
|
|
67
|
+
deleteMessage: (scope, messageId) => Effect.sync(() => deletes.push({ scope, messageId })).pipe(Effect.asVoid)
|
|
75
68
|
}
|
|
76
69
|
}
|
package/src/Main.test.ts
CHANGED
|
@@ -61,7 +61,7 @@ describe("makeProgram", () => {
|
|
|
61
61
|
|
|
62
62
|
try {
|
|
63
63
|
await Effect.runPromise(makeProgram(projectDir, makeTestEnv(projectDir, 18_787), factories))
|
|
64
|
-
const tool = await readFile(join(projectDir, ".opencode", "tools", "discord-
|
|
64
|
+
const tool = await readFile(join(projectDir, ".opencode", "tools", "discord-add-reaction.ts"), "utf8")
|
|
65
65
|
|
|
66
66
|
expect(tool).toContain("Generated by opencode-discord-bot")
|
|
67
67
|
expect(tool).toContain("http://127.0.0.1:18787/tool")
|
|
@@ -120,12 +120,9 @@ describe("makeApplication startup", () => {
|
|
|
120
120
|
postMessage: () => Effect.succeed({ id: "posted" }),
|
|
121
121
|
editMessage: () => Effect.void,
|
|
122
122
|
addReaction: () => Effect.void,
|
|
123
|
-
removeReaction: () => Effect.void,
|
|
124
123
|
attachFile: () => Effect.succeed({ path: "out.txt" }),
|
|
125
124
|
createThread: () => Effect.succeed({ id: "thread" }),
|
|
126
|
-
deleteMessage: () => Effect.void
|
|
127
|
-
pinMessage: () => Effect.void,
|
|
128
|
-
unpinMessage: () => Effect.void
|
|
125
|
+
deleteMessage: () => Effect.void
|
|
129
126
|
}
|
|
130
127
|
const app = makeApplication({
|
|
131
128
|
bot: { userId: "self" },
|
package/src/Main.ts
CHANGED
|
@@ -107,8 +107,7 @@ export const makeApplication = (options: ApplicationOptions) => {
|
|
|
107
107
|
handleStop: (scope: DiscordScope) => handleStopCommand(scope, turns, options.discord),
|
|
108
108
|
handleTool: (request: ToolRequest) =>
|
|
109
109
|
handleToolRequest(request, options.config, options.config.opencode.projectDir, options.discord, {
|
|
110
|
-
allowedScopes: [...activeToolScopes.values()]
|
|
111
|
-
botId: options.bot.userId
|
|
110
|
+
allowedScopes: [...activeToolScopes.values()]
|
|
112
111
|
}),
|
|
113
112
|
startLoopback: (port = options.config.bridge.port) =>
|
|
114
113
|
startLoopbackServer({
|
|
@@ -116,8 +115,7 @@ export const makeApplication = (options: ApplicationOptions) => {
|
|
|
116
115
|
config: options.config,
|
|
117
116
|
projectDir: options.config.opencode.projectDir,
|
|
118
117
|
discord: options.discord,
|
|
119
|
-
getAllowedScopes: () => [...activeToolScopes.values()]
|
|
120
|
-
botId: options.bot.userId
|
|
118
|
+
getAllowedScopes: () => [...activeToolScopes.values()]
|
|
121
119
|
})
|
|
122
120
|
}
|
|
123
121
|
}
|
|
@@ -31,7 +31,7 @@ describe("context assembly", () => {
|
|
|
31
31
|
})
|
|
32
32
|
)
|
|
33
33
|
|
|
34
|
-
expect(rendered).toContain("[Nick 1 |
|
|
34
|
+
expect(rendered).toContain("[Nick 1 | 2026-06-05 14:03 UTC | messageId=1]")
|
|
35
35
|
expect(rendered).toContain("Can you refactor this?")
|
|
36
36
|
expect(rendered).toContain("(discord target: guildId=g1 channelId=c1)")
|
|
37
37
|
expect(rendered).toContain("(attachments: screenshot.png [image/png; 12 bytes; https://cdn/a1])")
|
|
@@ -51,13 +51,17 @@ describe("context assembly", () => {
|
|
|
51
51
|
|
|
52
52
|
expect(prompt.messages.map((item) => item.id)).toEqual(["1", "2", "3"])
|
|
53
53
|
expect(prompt.text.match(/latest <@999>/g)).toHaveLength(1)
|
|
54
|
+
expect(prompt.text).toContain("(participants)\nNick 1 - <@u-1>\nNick 2 - <@u-2>\nNick 3 - <@u-3>")
|
|
55
|
+
expect(prompt.text.match(/<@u-3>/g)).toHaveLength(1)
|
|
54
56
|
expect(prompt.text).toContain("Plain assistant text is streamed to Discord automatically")
|
|
55
57
|
expect(prompt.text).toContain("do not use bridge tools to send messages")
|
|
56
58
|
expect(prompt.text).toContain("<@id> pings that user in Discord")
|
|
59
|
+
expect(prompt.text).toContain("use the participants list to map server nicknames/display names")
|
|
60
|
+
expect(prompt.text).toContain("When a display name is shared")
|
|
57
61
|
expect(prompt.text).toContain("combine the discord default scope or message target override with the header messageId")
|
|
58
62
|
expect(prompt.text).toContain("Do not emit @everyone, @here, or role pings")
|
|
59
63
|
expect(prompt.text).toContain("(discord default scope: guildId=g1 channelId=c1)")
|
|
60
|
-
expect(prompt.text).toContain("[Nick 3 |
|
|
64
|
+
expect(prompt.text).toContain("[Nick 3 | 2026-06-05 14:03 UTC | messageId=3]")
|
|
61
65
|
expect(prompt.text).not.toContain("(discord target: guildId=g1 channelId=c1 messageId=3)")
|
|
62
66
|
})
|
|
63
67
|
|
|
@@ -73,11 +77,32 @@ describe("context assembly", () => {
|
|
|
73
77
|
})
|
|
74
78
|
|
|
75
79
|
expect(prompt.text).toContain("(discord default scope: guildId=g1 channelId=c1)")
|
|
76
|
-
expect(prompt.text).toContain("[Nick 1 |
|
|
80
|
+
expect(prompt.text).toContain("[Nick 1 | 2026-06-05 14:03 UTC | messageId=1]")
|
|
77
81
|
expect(prompt.text).toContain("(discord target: guildId=g1 channelId=c2 threadId=t2)")
|
|
78
82
|
expect(prompt.text).not.toContain("(discord target: guildId=g1 channelId=c1)")
|
|
79
83
|
})
|
|
80
84
|
|
|
85
|
+
test("keeps author ids in headers when participant labels collide", () => {
|
|
86
|
+
const first = makeMessage("1", "from first Sam", {
|
|
87
|
+
author: { id: "sam-1", displayName: "Sam", nickname: "Sam", isBot: false }
|
|
88
|
+
})
|
|
89
|
+
const second = makeMessage("2", "from second Sam", {
|
|
90
|
+
author: { id: "sam-2", displayName: "Sam", nickname: "Sam", isBot: false }
|
|
91
|
+
})
|
|
92
|
+
const prompt = assembleContextPrompt({
|
|
93
|
+
botUserId: "999",
|
|
94
|
+
contextMessages: [first],
|
|
95
|
+
triggerMessage: second,
|
|
96
|
+
maxMessages: 30,
|
|
97
|
+
maxChars: 10_000,
|
|
98
|
+
maxAttachmentBytes: 10_000
|
|
99
|
+
})
|
|
100
|
+
|
|
101
|
+
expect(prompt.text).toContain("(participants)\nSam - <@sam-1>\nSam - <@sam-2>")
|
|
102
|
+
expect(prompt.text).toContain("[Sam | <@sam-1> | 2026-06-05 14:03 UTC | messageId=1]")
|
|
103
|
+
expect(prompt.text).toContain("[Sam | <@sam-2> | 2026-06-05 14:03 UTC | messageId=2]")
|
|
104
|
+
})
|
|
105
|
+
|
|
81
106
|
test("applies top-N and character budgets without dropping the trigger", () => {
|
|
82
107
|
const trigger = makeMessage("5", "trigger")
|
|
83
108
|
const prompt = assembleContextPrompt({
|
|
@@ -18,7 +18,7 @@ type ContextInput = {
|
|
|
18
18
|
}
|
|
19
19
|
|
|
20
20
|
const preamble = (botUserId: string) =>
|
|
21
|
-
`Discord bridge context for <@${botUserId}>. Plain assistant text is streamed to Discord automatically; do not use bridge tools to send messages. <@id> pings that user in Discord. For non-message bridge tools, combine the discord default scope or message target override with the header messageId. Do not emit @everyone, @here, or role pings unless explicitly allowed.`
|
|
21
|
+
`Discord bridge context for <@${botUserId}>. Plain assistant text is streamed to Discord automatically; do not use bridge tools to send messages. <@id> pings that user in Discord; use the participants list to map server nicknames/display names to their <@id> values. When a display name is shared, the message header also includes that author's <@id>. For non-message bridge tools, combine the discord default scope or message target override with the header messageId. Do not emit @everyone, @here, or role pings unless explicitly allowed.`
|
|
22
22
|
|
|
23
23
|
const timestamp = (value: string): string => {
|
|
24
24
|
const date = new Date(value)
|
|
@@ -72,10 +72,53 @@ const attachmentParts = (messages: ReadonlyArray<DiscordMessage>, maxBytes: numb
|
|
|
72
72
|
})
|
|
73
73
|
)
|
|
74
74
|
|
|
75
|
-
|
|
76
|
-
|
|
75
|
+
const authorLabel = (message: DiscordMessage): string => message.author.nickname ?? message.author.displayName
|
|
76
|
+
|
|
77
|
+
type ParticipantsSummary = {
|
|
78
|
+
readonly text: string | undefined
|
|
79
|
+
readonly ambiguousAuthorIds: ReadonlySet<string>
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
const participantsSummary = (messages: ReadonlyArray<DiscordMessage>): ParticipantsSummary => {
|
|
83
|
+
const participants: Array<{ readonly id: string; readonly label: string }> = []
|
|
84
|
+
const seenAuthorIds = new Set<string>()
|
|
85
|
+
const authorIdsByLabel = new Map<string, Set<string>>()
|
|
86
|
+
|
|
87
|
+
for (const message of messages) {
|
|
88
|
+
const id = message.author.id
|
|
89
|
+
if (seenAuthorIds.has(id)) continue
|
|
90
|
+
seenAuthorIds.add(id)
|
|
91
|
+
|
|
92
|
+
const label = authorLabel(message)
|
|
93
|
+
participants.push({ id, label })
|
|
94
|
+
|
|
95
|
+
const authorIds = authorIdsByLabel.get(label)
|
|
96
|
+
if (authorIds === undefined) authorIdsByLabel.set(label, new Set([id]))
|
|
97
|
+
else authorIds.add(id)
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
const ambiguousAuthorIds = new Set<string>()
|
|
101
|
+
for (const authorIds of authorIdsByLabel.values()) {
|
|
102
|
+
if (authorIds.size <= 1) continue
|
|
103
|
+
for (const id of authorIds) ambiguousAuthorIds.add(id)
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
return {
|
|
107
|
+
ambiguousAuthorIds,
|
|
108
|
+
text:
|
|
109
|
+
participants.length === 0 ? undefined : `(participants)\n${participants.map((item) => `${item.label} - <@${item.id}>`).join("\n")}`
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
export const formatDiscordMessage = (
|
|
114
|
+
message: DiscordMessage,
|
|
115
|
+
defaultScope?: DiscordScope,
|
|
116
|
+
ambiguousAuthorIds?: ReadonlySet<string>
|
|
117
|
+
): string => {
|
|
118
|
+
const label = authorLabel(message)
|
|
119
|
+
const author = ambiguousAuthorIds?.has(message.author.id) === true ? `${label} | <@${message.author.id}>` : label
|
|
77
120
|
const scope = scopeOf(message)
|
|
78
|
-
const lines = [`[${
|
|
121
|
+
const lines = [`[${author} | ${timestamp(message.timestamp)} | messageId=${message.id}]`, message.content]
|
|
79
122
|
if (defaultScope === undefined || !sameScope(scope, defaultScope)) lines.push(targetSummary(scope))
|
|
80
123
|
const attachments = attachmentSummary(message)
|
|
81
124
|
const reactions = reactionSummary(message)
|
|
@@ -115,11 +158,14 @@ const renderPrompt = (
|
|
|
115
158
|
const skippedIds = new Set(skippedMessages.map((message) => message.id))
|
|
116
159
|
const latestMessage = messages.at(-1)
|
|
117
160
|
const defaultScope = latestMessage === undefined ? undefined : scopeOf(latestMessage)
|
|
161
|
+
const participants = participantsSummary(messages)
|
|
118
162
|
return [
|
|
119
163
|
preamble(botUserId),
|
|
164
|
+
...(participants.text === undefined ? [] : [participants.text]),
|
|
120
165
|
...(defaultScope === undefined ? [] : [defaultScopeSummary(defaultScope)]),
|
|
121
166
|
...messages.map(
|
|
122
|
-
(message) =>
|
|
167
|
+
(message) =>
|
|
168
|
+
`${skippedIds.has(message.id) ? "(queued intermediate message)\n" : ""}${formatDiscordMessage(message, defaultScope, participants.ambiguousAuthorIds)}`
|
|
123
169
|
)
|
|
124
170
|
].join("\n\n")
|
|
125
171
|
}
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import { tool } from "@opencode-ai/plugin"
|
|
2
|
+
|
|
3
|
+
const loopbackToolUrl = "__OPENCODE_DISCORD_BOT_LOOPBACK_URL__/tool"
|
|
4
|
+
|
|
5
|
+
const targetSchema = tool.schema
|
|
6
|
+
.object({
|
|
7
|
+
guildId: tool.schema.string().describe("Discord guild ID."),
|
|
8
|
+
channelId: tool.schema.string().describe("Discord channel ID."),
|
|
9
|
+
threadId: tool.schema.string().optional().describe("Discord thread ID, when targeting a thread."),
|
|
10
|
+
messageId: tool.schema.string().describe("Discord message ID to react to.")
|
|
11
|
+
})
|
|
12
|
+
.describe("Discord message target for the reaction.")
|
|
13
|
+
|
|
14
|
+
export default tool({
|
|
15
|
+
description: "Add an emoji reaction to a Discord message through the local opencode-discord-bot process.",
|
|
16
|
+
args: {
|
|
17
|
+
target: targetSchema,
|
|
18
|
+
emoji: tool.schema.string().describe("Emoji to add as a reaction.")
|
|
19
|
+
},
|
|
20
|
+
async execute(request) {
|
|
21
|
+
const response = await fetch(loopbackToolUrl, {
|
|
22
|
+
method: "POST",
|
|
23
|
+
headers: { "content-type": "application/json" },
|
|
24
|
+
body: JSON.stringify({ action: "addReaction", target: request.target, args: { emoji: request.emoji } })
|
|
25
|
+
})
|
|
26
|
+
const payload = await response.json()
|
|
27
|
+
return JSON.stringify(payload, null, 2) ?? String(payload)
|
|
28
|
+
}
|
|
29
|
+
})
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import { tool } from "@opencode-ai/plugin"
|
|
2
|
+
|
|
3
|
+
const loopbackToolUrl = "__OPENCODE_DISCORD_BOT_LOOPBACK_URL__/tool"
|
|
4
|
+
|
|
5
|
+
const targetSchema = tool.schema
|
|
6
|
+
.object({
|
|
7
|
+
guildId: tool.schema.string().describe("Discord guild ID."),
|
|
8
|
+
channelId: tool.schema.string().describe("Discord channel ID."),
|
|
9
|
+
threadId: tool.schema.string().optional().describe("Discord thread ID, when targeting a thread.")
|
|
10
|
+
})
|
|
11
|
+
.describe("Discord channel or thread target for file attachment.")
|
|
12
|
+
|
|
13
|
+
export default tool({
|
|
14
|
+
description: "Attach a generated project file to Discord through the local opencode-discord-bot process.",
|
|
15
|
+
args: {
|
|
16
|
+
target: targetSchema,
|
|
17
|
+
path: tool.schema.string().describe("Project-relative file path to attach.")
|
|
18
|
+
},
|
|
19
|
+
async execute(request) {
|
|
20
|
+
const response = await fetch(loopbackToolUrl, {
|
|
21
|
+
method: "POST",
|
|
22
|
+
headers: { "content-type": "application/json" },
|
|
23
|
+
body: JSON.stringify({ action: "attachFile", target: request.target, args: { path: request.path } })
|
|
24
|
+
})
|
|
25
|
+
const payload = await response.json()
|
|
26
|
+
return JSON.stringify(payload, null, 2) ?? String(payload)
|
|
27
|
+
}
|
|
28
|
+
})
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import { tool } from "@opencode-ai/plugin"
|
|
2
|
+
|
|
3
|
+
const loopbackToolUrl = "__OPENCODE_DISCORD_BOT_LOOPBACK_URL__/tool"
|
|
4
|
+
|
|
5
|
+
const targetSchema = tool.schema
|
|
6
|
+
.object({
|
|
7
|
+
guildId: tool.schema.string().describe("Discord guild ID."),
|
|
8
|
+
channelId: tool.schema.string().describe("Discord channel ID."),
|
|
9
|
+
threadId: tool.schema.string().optional().describe("Discord parent thread ID, when creating inside a thread context.")
|
|
10
|
+
})
|
|
11
|
+
.describe("Discord channel target for thread creation.")
|
|
12
|
+
|
|
13
|
+
export default tool({
|
|
14
|
+
description: "Create a Discord thread through the local opencode-discord-bot process.",
|
|
15
|
+
args: {
|
|
16
|
+
target: targetSchema,
|
|
17
|
+
name: tool.schema.string().describe("Name for the new Discord thread.")
|
|
18
|
+
},
|
|
19
|
+
async execute(request) {
|
|
20
|
+
const response = await fetch(loopbackToolUrl, {
|
|
21
|
+
method: "POST",
|
|
22
|
+
headers: { "content-type": "application/json" },
|
|
23
|
+
body: JSON.stringify({ action: "createThread", target: request.target, args: { name: request.name } })
|
|
24
|
+
})
|
|
25
|
+
const payload = await response.json()
|
|
26
|
+
return JSON.stringify(payload, null, 2) ?? String(payload)
|
|
27
|
+
}
|
|
28
|
+
})
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import { tool } from "@opencode-ai/plugin"
|
|
2
|
+
|
|
3
|
+
const loopbackToolUrl = "__OPENCODE_DISCORD_BOT_LOOPBACK_URL__/tool"
|
|
4
|
+
|
|
5
|
+
const targetSchema = tool.schema
|
|
6
|
+
.object({
|
|
7
|
+
guildId: tool.schema.string().describe("Discord guild ID."),
|
|
8
|
+
channelId: tool.schema.string().describe("Discord channel ID."),
|
|
9
|
+
threadId: tool.schema.string().optional().describe("Discord thread ID, when targeting a thread.")
|
|
10
|
+
})
|
|
11
|
+
.describe("Discord channel or thread target for history fetching.")
|
|
12
|
+
|
|
13
|
+
export default tool({
|
|
14
|
+
description: "Fetch recent Discord message history through the local opencode-discord-bot process.",
|
|
15
|
+
args: {
|
|
16
|
+
target: targetSchema,
|
|
17
|
+
limit: tool.schema.number().int().positive().optional().describe("Maximum number of recent messages to fetch.")
|
|
18
|
+
},
|
|
19
|
+
async execute(request) {
|
|
20
|
+
const response = await fetch(loopbackToolUrl, {
|
|
21
|
+
method: "POST",
|
|
22
|
+
headers: { "content-type": "application/json" },
|
|
23
|
+
body: JSON.stringify({ action: "fetchHistory", target: request.target, args: { limit: request.limit } })
|
|
24
|
+
})
|
|
25
|
+
const payload = await response.json()
|
|
26
|
+
return JSON.stringify(payload, null, 2) ?? String(payload)
|
|
27
|
+
}
|
|
28
|
+
})
|
|
@@ -1,56 +1,85 @@
|
|
|
1
1
|
import { describe, expect, test } from "bun:test"
|
|
2
|
-
import { mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises"
|
|
2
|
+
import { mkdir, mkdtemp, readFile, rm, stat, writeFile } from "node:fs/promises"
|
|
3
3
|
import { tmpdir } from "node:os"
|
|
4
4
|
import { join } from "node:path"
|
|
5
5
|
import { ensureDiscordTools, renderDiscordToolFile } from "./Scaffolding.ts"
|
|
6
6
|
|
|
7
|
+
const toolFiles = ["discord-add-reaction.ts", "discord-fetch-history.ts", "discord-attach-file.ts", "discord-create-thread.ts"] as const
|
|
8
|
+
|
|
9
|
+
const sourceUrls = [
|
|
10
|
+
new URL("./DiscordAddReactionTool.ts", import.meta.url),
|
|
11
|
+
new URL("./DiscordFetchHistoryTool.ts", import.meta.url),
|
|
12
|
+
new URL("./DiscordAttachFileTool.ts", import.meta.url),
|
|
13
|
+
new URL("./DiscordCreateThreadTool.ts", import.meta.url)
|
|
14
|
+
] as const
|
|
15
|
+
|
|
16
|
+
const exists = async (path: string): Promise<boolean> =>
|
|
17
|
+
stat(path).then(
|
|
18
|
+
() => true,
|
|
19
|
+
() => false
|
|
20
|
+
)
|
|
21
|
+
|
|
7
22
|
describe("Discord tool scaffolding", () => {
|
|
8
|
-
test("renders
|
|
9
|
-
const
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
23
|
+
test("renders generated split opencode tool files with typed action arguments", async () => {
|
|
24
|
+
const sources = await Promise.all(sourceUrls.map((sourceUrl) => renderDiscordToolFile(sourceUrl, "http://127.0.0.1:8787")))
|
|
25
|
+
const combined = sources.join("\n")
|
|
26
|
+
|
|
27
|
+
for (const source of sources) {
|
|
28
|
+
expect(source).toContain("Generated by opencode-discord-bot")
|
|
29
|
+
expect(source).toContain("http://127.0.0.1:8787/tool")
|
|
30
|
+
expect(source).toContain('import { tool } from "@opencode-ai/plugin"')
|
|
31
|
+
expect(source).toContain("export default tool({")
|
|
32
|
+
expect(source).toContain("target: targetSchema")
|
|
33
|
+
expect(source).toContain("fetch")
|
|
34
|
+
expect(source).not.toContain("export const parameters")
|
|
35
|
+
expect(source).not.toContain("__OPENCODE_DISCORD_BOT_LOOPBACK_URL__")
|
|
36
|
+
expect(source).not.toContain("tool.schema.unknown")
|
|
37
|
+
expect(source).not.toContain(".record(")
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
expect(combined).toContain('action: "addReaction"')
|
|
41
|
+
expect(combined).toContain('action: "fetchHistory"')
|
|
42
|
+
expect(combined).toContain('action: "attachFile"')
|
|
43
|
+
expect(combined).toContain('action: "createThread"')
|
|
44
|
+
expect(combined).toContain("emoji:")
|
|
45
|
+
expect(combined).toContain("limit:")
|
|
46
|
+
expect(combined).toContain("path:")
|
|
47
|
+
expect(combined).toContain("name:")
|
|
48
|
+
expect(combined).not.toContain("removeReaction")
|
|
49
|
+
expect(combined).not.toContain("editOwnMessage")
|
|
50
|
+
expect(combined).not.toContain("deleteOwnMessage")
|
|
51
|
+
expect(combined).not.toContain('action: "pin"')
|
|
52
|
+
expect(combined).not.toContain('action: "unpin"')
|
|
25
53
|
})
|
|
26
54
|
|
|
27
|
-
test("creates and regenerates
|
|
55
|
+
test("creates and regenerates split tool files on every run", async () => {
|
|
28
56
|
const projectDir = await mkdtemp(join(tmpdir(), "ocdb-tools-"))
|
|
29
57
|
|
|
30
58
|
try {
|
|
31
59
|
const created = await ensureDiscordTools({ projectDir, bridgePort: 8787, enabled: true, autoInstall: true })
|
|
32
|
-
const
|
|
33
|
-
const first = await readFile(
|
|
60
|
+
const toolPaths = toolFiles.map((file) => join(projectDir, ".opencode", "tools", file))
|
|
61
|
+
const first = await readFile(toolPaths[0] ?? "", "utf8")
|
|
34
62
|
|
|
35
|
-
expect(created).toEqual(
|
|
63
|
+
expect(created).toEqual(toolPaths)
|
|
36
64
|
expect(first).toContain("http://127.0.0.1:8787/tool")
|
|
37
65
|
|
|
38
66
|
await ensureDiscordTools({ projectDir, bridgePort: 9999, enabled: true, autoInstall: true })
|
|
39
|
-
expect(await readFile(toolPath, "utf8")).toContain("http://127.0.0.1:9999/tool")
|
|
67
|
+
for (const toolPath of toolPaths) expect(await readFile(toolPath, "utf8")).toContain("http://127.0.0.1:9999/tool")
|
|
40
68
|
|
|
41
|
-
await writeFile(
|
|
69
|
+
await writeFile(toolPaths[0] ?? "", "// operator file\n")
|
|
42
70
|
const regenerated = await ensureDiscordTools({ projectDir, bridgePort: 7777, enabled: true, autoInstall: true })
|
|
43
71
|
|
|
44
|
-
expect(regenerated).toEqual(
|
|
45
|
-
expect(await readFile(
|
|
46
|
-
expect(await readFile(
|
|
72
|
+
expect(regenerated).toEqual(toolPaths)
|
|
73
|
+
expect(await readFile(toolPaths[0] ?? "", "utf8")).toContain("http://127.0.0.1:7777/tool")
|
|
74
|
+
expect(await readFile(toolPaths[0] ?? "", "utf8")).not.toContain("// operator file")
|
|
47
75
|
|
|
48
|
-
|
|
76
|
+
const deprecatedToolPath = join(projectDir, ".opencode", "tools", "discord-bridge.ts")
|
|
77
|
+
await writeFile(deprecatedToolPath, "// Generated by opencode-discord-bot. DO NOT EDIT.\nexport const parameters = {}\n")
|
|
49
78
|
const migrated = await ensureDiscordTools({ projectDir, bridgePort: 6666, enabled: true, autoInstall: true })
|
|
50
79
|
|
|
51
|
-
expect(migrated).toEqual(
|
|
52
|
-
expect(await readFile(toolPath, "utf8")).toContain("http://127.0.0.1:6666/tool")
|
|
53
|
-
expect(await
|
|
80
|
+
expect(migrated).toEqual(toolPaths)
|
|
81
|
+
for (const toolPath of toolPaths) expect(await readFile(toolPath, "utf8")).toContain("http://127.0.0.1:6666/tool")
|
|
82
|
+
expect(await exists(deprecatedToolPath)).toBe(false)
|
|
54
83
|
} finally {
|
|
55
84
|
await rm(projectDir, { recursive: true, force: true })
|
|
56
85
|
}
|
package/src/Tools/Scaffolding.ts
CHANGED
|
@@ -1,9 +1,16 @@
|
|
|
1
|
-
import { mkdir, readFile, writeFile } from "node:fs/promises"
|
|
1
|
+
import { mkdir, readFile, unlink, writeFile } from "node:fs/promises"
|
|
2
2
|
import { join } from "node:path"
|
|
3
3
|
|
|
4
4
|
const header = "// Generated by opencode-discord-bot. DO NOT EDIT."
|
|
5
5
|
const loopbackUrlPlaceholder = "__OPENCODE_DISCORD_BOT_LOOPBACK_URL__"
|
|
6
|
-
const
|
|
6
|
+
const deprecatedDiscordBridgeFile = "discord-bridge.ts"
|
|
7
|
+
|
|
8
|
+
const discordToolSources = [
|
|
9
|
+
{ fileName: "discord-add-reaction.ts", sourceUrl: new URL("./DiscordAddReactionTool.ts", import.meta.url) },
|
|
10
|
+
{ fileName: "discord-fetch-history.ts", sourceUrl: new URL("./DiscordFetchHistoryTool.ts", import.meta.url) },
|
|
11
|
+
{ fileName: "discord-attach-file.ts", sourceUrl: new URL("./DiscordAttachFileTool.ts", import.meta.url) },
|
|
12
|
+
{ fileName: "discord-create-thread.ts", sourceUrl: new URL("./DiscordCreateThreadTool.ts", import.meta.url) }
|
|
13
|
+
] as const
|
|
7
14
|
|
|
8
15
|
export type ToolScaffoldOptions = {
|
|
9
16
|
readonly projectDir: string
|
|
@@ -12,18 +19,37 @@ export type ToolScaffoldOptions = {
|
|
|
12
19
|
readonly autoInstall: boolean
|
|
13
20
|
}
|
|
14
21
|
|
|
15
|
-
|
|
16
|
-
|
|
22
|
+
const isMissingFile = (cause: unknown): boolean => typeof cause === "object" && cause !== null && "code" in cause && cause.code === "ENOENT"
|
|
23
|
+
|
|
24
|
+
const removeDeprecatedGeneratedTool = async (toolsDir: string): Promise<void> => {
|
|
25
|
+
const toolPath = join(toolsDir, deprecatedDiscordBridgeFile)
|
|
26
|
+
try {
|
|
27
|
+
const current = await readFile(toolPath, "utf8")
|
|
28
|
+
if (current.startsWith(header)) await unlink(toolPath)
|
|
29
|
+
} catch (cause) {
|
|
30
|
+
if (!isMissingFile(cause)) throw cause
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export const renderDiscordToolFile = async (sourceUrl: URL, loopbackUrl: string): Promise<string> => {
|
|
35
|
+
const source = await readFile(sourceUrl, "utf8")
|
|
17
36
|
return `${header}\n${source.replaceAll(loopbackUrlPlaceholder, loopbackUrl)}`
|
|
18
37
|
}
|
|
19
38
|
|
|
20
39
|
export const ensureDiscordTools = async (options: ToolScaffoldOptions): Promise<ReadonlyArray<string>> => {
|
|
21
40
|
if (!options.enabled || !options.autoInstall) return []
|
|
22
41
|
const toolsDir = join(options.projectDir, ".opencode", "tools")
|
|
23
|
-
const
|
|
24
|
-
const next = await renderDiscordToolFile(`http://127.0.0.1:${options.bridgePort}`)
|
|
42
|
+
const loopbackUrl = `http://127.0.0.1:${options.bridgePort}`
|
|
25
43
|
|
|
26
44
|
await mkdir(toolsDir, { recursive: true })
|
|
27
|
-
await
|
|
28
|
-
|
|
45
|
+
await removeDeprecatedGeneratedTool(toolsDir)
|
|
46
|
+
|
|
47
|
+
const toolPaths: Array<string> = []
|
|
48
|
+
for (const source of discordToolSources) {
|
|
49
|
+
const toolPath = join(toolsDir, source.fileName)
|
|
50
|
+
const next = await renderDiscordToolFile(source.sourceUrl, loopbackUrl)
|
|
51
|
+
await writeFile(toolPath, next)
|
|
52
|
+
toolPaths.push(toolPath)
|
|
53
|
+
}
|
|
54
|
+
return toolPaths
|
|
29
55
|
}
|
|
@@ -1,42 +0,0 @@
|
|
|
1
|
-
import { tool } from "@opencode-ai/plugin"
|
|
2
|
-
|
|
3
|
-
const loopbackToolUrl = "__OPENCODE_DISCORD_BOT_LOOPBACK_URL__/tool"
|
|
4
|
-
|
|
5
|
-
export default tool({
|
|
6
|
-
description: "Perform non-message Discord bridge actions through the local opencode-discord-bot process.",
|
|
7
|
-
args: {
|
|
8
|
-
action: tool.schema
|
|
9
|
-
.enum([
|
|
10
|
-
"addReaction",
|
|
11
|
-
"removeReaction",
|
|
12
|
-
"fetchHistory",
|
|
13
|
-
"attachFile",
|
|
14
|
-
"createThread",
|
|
15
|
-
"editOwnMessage",
|
|
16
|
-
"deleteOwnMessage",
|
|
17
|
-
"pin",
|
|
18
|
-
"unpin"
|
|
19
|
-
])
|
|
20
|
-
.describe("Discord bridge action to perform."),
|
|
21
|
-
target: tool.schema
|
|
22
|
-
.object({
|
|
23
|
-
guildId: tool.schema.string().optional(),
|
|
24
|
-
channelId: tool.schema.string().optional(),
|
|
25
|
-
threadId: tool.schema.string().optional(),
|
|
26
|
-
messageId: tool.schema.string().optional()
|
|
27
|
-
})
|
|
28
|
-
.describe("Discord target for the action."),
|
|
29
|
-
args: tool.schema
|
|
30
|
-
.record(tool.schema.string(), tool.schema.unknown())
|
|
31
|
-
.describe("Action-specific arguments, such as emoji, limit, path, name, or replacement content for editOwnMessage.")
|
|
32
|
-
},
|
|
33
|
-
async execute(request) {
|
|
34
|
-
const response = await fetch(loopbackToolUrl, {
|
|
35
|
-
method: "POST",
|
|
36
|
-
headers: { "content-type": "application/json" },
|
|
37
|
-
body: JSON.stringify(request)
|
|
38
|
-
})
|
|
39
|
-
const payload: unknown = await response.json()
|
|
40
|
-
return JSON.stringify(payload, null, 2) ?? String(payload)
|
|
41
|
-
}
|
|
42
|
-
})
|