opencode-discord-bot 0.0.5 → 0.0.7
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.test.ts +9 -8
- package/src/Bridge/ToolControl.test.ts +27 -22
- package/src/Bridge/ToolControl.ts +1 -36
- package/src/Bridge/ToolControlEdges.test.ts +1 -5
- package/src/Bridge/ToolControlHighRisk.test.ts +1 -24
- package/src/Config.test.ts +5 -3
- package/src/Config.ts +0 -6
- package/src/ConfigSchema.ts +0 -2
- package/src/Discord/ChatSdkDiscord.test.ts +20 -35
- package/src/Discord/ChatSdkDiscord.ts +7 -30
- package/src/Discord/ChatSdkGatewayIntake.test.ts +1 -0
- package/src/Discord/ChatSdkGatewayIntake.ts +2 -2
- package/src/Discord/DiscordJsDiscord.test.ts +0 -1
- package/src/Discord/DiscordJsDiscord.ts +0 -6
- package/src/Discord/DiscordPort.ts +0 -1
- package/src/Discord/MemoryDiscord.ts +0 -9
- package/src/Main.test.ts +6 -6
- package/src/Orchestrator/ContextAssembly.test.ts +3 -1
- package/src/Orchestrator/ContextAssembly.ts +1 -1
- package/src/Tools/DiscordBridgeTool.ts +2 -4
- package/src/Tools/Scaffolding.test.ts +2 -0
package/package.json
CHANGED
|
@@ -23,17 +23,18 @@ describe("startLoopbackServer", () => {
|
|
|
23
23
|
method: "POST",
|
|
24
24
|
headers: { "content-type": "application/json" },
|
|
25
25
|
body: JSON.stringify({
|
|
26
|
-
action: "
|
|
27
|
-
target: { guildId: "g1", channelId: "c1" },
|
|
28
|
-
args: {
|
|
26
|
+
action: "addReaction",
|
|
27
|
+
target: { guildId: "g1", channelId: "c1", messageId: "m1" },
|
|
28
|
+
args: { emoji: "rocket" }
|
|
29
29
|
})
|
|
30
30
|
})
|
|
31
31
|
)
|
|
32
32
|
const body = yield* Effect.tryPromise(() => response.json())
|
|
33
33
|
|
|
34
34
|
expect(server.url.startsWith("http://127.0.0.1:")).toBe(true)
|
|
35
|
-
expect(body).toEqual({ ok: true, result: {
|
|
36
|
-
expect(discord.
|
|
35
|
+
expect(body).toEqual({ ok: true, result: { reacted: true } })
|
|
36
|
+
expect(discord.reactions).toEqual([{ scope: { guildId: "g1", channelId: "c1" }, messageId: "m1", emoji: "rocket", op: "add" }])
|
|
37
|
+
expect(discord.messages).toEqual([])
|
|
37
38
|
})
|
|
38
39
|
)
|
|
39
40
|
)
|
|
@@ -78,9 +79,9 @@ describe("startLoopbackServer", () => {
|
|
|
78
79
|
method: "POST",
|
|
79
80
|
headers: { "content-type": "application/json" },
|
|
80
81
|
body: JSON.stringify({
|
|
81
|
-
action: "
|
|
82
|
-
target: { guildId: "g1", channelId: "c2" },
|
|
83
|
-
args: {
|
|
82
|
+
action: "addReaction",
|
|
83
|
+
target: { guildId: "g1", channelId: "c2", messageId: "m1" },
|
|
84
|
+
args: { emoji: "rocket" }
|
|
84
85
|
})
|
|
85
86
|
})
|
|
86
87
|
)
|
|
@@ -14,14 +14,14 @@ const withTools = (tools: Partial<ToolConfig>): RuntimeConfig => ({
|
|
|
14
14
|
})
|
|
15
15
|
|
|
16
16
|
describe("handleToolRequest", () => {
|
|
17
|
-
test("allows safe default actions through the Discord port", async () => {
|
|
17
|
+
test("allows safe default non-message actions through the Discord port", async () => {
|
|
18
18
|
const discord = makeMemoryDiscord()
|
|
19
19
|
const response = await Effect.runPromise(
|
|
20
20
|
handleToolRequest(
|
|
21
21
|
{
|
|
22
|
-
action: "
|
|
23
|
-
target: { guildId: "g1", channelId: "c1" },
|
|
24
|
-
args: {
|
|
22
|
+
action: "addReaction",
|
|
23
|
+
target: { guildId: "g1", channelId: "c1", messageId: "m1" },
|
|
24
|
+
args: { emoji: "rocket" }
|
|
25
25
|
},
|
|
26
26
|
defaultConfig,
|
|
27
27
|
"/repo",
|
|
@@ -29,14 +29,15 @@ describe("handleToolRequest", () => {
|
|
|
29
29
|
)
|
|
30
30
|
)
|
|
31
31
|
|
|
32
|
-
expect(response
|
|
33
|
-
expect(discord.
|
|
32
|
+
expect(response).toEqual({ ok: true, result: { reacted: true } })
|
|
33
|
+
expect(discord.reactions).toEqual([{ scope: { guildId: "g1", channelId: "c1" }, messageId: "m1", emoji: "rocket", op: "add" }])
|
|
34
|
+
expect(discord.messages).toEqual([])
|
|
34
35
|
})
|
|
35
36
|
|
|
36
|
-
test("
|
|
37
|
+
test("rejects stale message-sending bridge actions", async () => {
|
|
37
38
|
const discord = makeMemoryDiscord()
|
|
38
39
|
|
|
39
|
-
const
|
|
40
|
+
const followUp = await Effect.runPromise(
|
|
40
41
|
handleToolRequest(
|
|
41
42
|
{
|
|
42
43
|
action: "followUpMessage",
|
|
@@ -48,9 +49,22 @@ describe("handleToolRequest", () => {
|
|
|
48
49
|
discord
|
|
49
50
|
)
|
|
50
51
|
)
|
|
52
|
+
const otherChannel = await Effect.runPromise(
|
|
53
|
+
handleToolRequest(
|
|
54
|
+
{
|
|
55
|
+
action: "postOtherChannel",
|
|
56
|
+
target: { guildId: "g1", channelId: "c2" },
|
|
57
|
+
args: { content: "elsewhere" }
|
|
58
|
+
},
|
|
59
|
+
defaultConfig,
|
|
60
|
+
"/repo",
|
|
61
|
+
discord
|
|
62
|
+
)
|
|
63
|
+
)
|
|
51
64
|
|
|
52
|
-
expect(
|
|
53
|
-
expect(
|
|
65
|
+
expect(followUp).toEqual({ ok: false, error: "Unknown action followUpMessage" })
|
|
66
|
+
expect(otherChannel).toEqual({ ok: false, error: "Unknown action postOtherChannel" })
|
|
67
|
+
expect(discord.messages).toEqual([])
|
|
54
68
|
})
|
|
55
69
|
|
|
56
70
|
test("blocks higher-risk actions unless explicitly enabled", async () => {
|
|
@@ -69,7 +83,7 @@ describe("handleToolRequest", () => {
|
|
|
69
83
|
test("rejects DMs and unsafe attachment paths", async () => {
|
|
70
84
|
const dm = await Effect.runPromise(
|
|
71
85
|
handleToolRequest(
|
|
72
|
-
{ action: "
|
|
86
|
+
{ action: "addReaction", target: { guildId: "@me", channelId: "dm1", messageId: "m1" }, args: { emoji: "rocket" } },
|
|
73
87
|
defaultConfig,
|
|
74
88
|
"/repo",
|
|
75
89
|
makeMemoryDiscord()
|
|
@@ -183,7 +197,7 @@ describe("handleToolRequest action dispatch", () => {
|
|
|
183
197
|
test("rejects default tool targets outside the active turn scope", async () => {
|
|
184
198
|
const response = await Effect.runPromise(
|
|
185
199
|
handleToolRequest(
|
|
186
|
-
{ action: "
|
|
200
|
+
{ action: "addReaction", target: { guildId: "g1", channelId: "other", messageId: "m1" }, args: { emoji: "rocket" } },
|
|
187
201
|
defaultConfig,
|
|
188
202
|
"/repo",
|
|
189
203
|
makeMemoryDiscord(),
|
|
@@ -197,7 +211,7 @@ describe("handleToolRequest action dispatch", () => {
|
|
|
197
211
|
test("returns validation errors for malformed or unsupported requests", async () => {
|
|
198
212
|
const disabled = await Effect.runPromise(
|
|
199
213
|
handleToolRequest(
|
|
200
|
-
{ action: "
|
|
214
|
+
{ action: "addReaction", target: { guildId: "g1", channelId: "c1", messageId: "m1" }, args: { emoji: "rocket" } },
|
|
201
215
|
withTools({ enabled: false }),
|
|
202
216
|
"/repo",
|
|
203
217
|
makeMemoryDiscord()
|
|
@@ -211,14 +225,6 @@ describe("handleToolRequest action dispatch", () => {
|
|
|
211
225
|
makeMemoryDiscord()
|
|
212
226
|
)
|
|
213
227
|
)
|
|
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
228
|
const missingReactionFields = await Effect.runPromise(
|
|
223
229
|
handleToolRequest(
|
|
224
230
|
{ action: "addReaction", target: { guildId: "g1", channelId: "c1" }, args: {} },
|
|
@@ -238,7 +244,6 @@ describe("handleToolRequest action dispatch", () => {
|
|
|
238
244
|
|
|
239
245
|
expect(disabled).toEqual({ ok: false, error: "Discord bridge tools are disabled" })
|
|
240
246
|
expect(unknown).toEqual({ ok: false, error: "Unknown action unknown" })
|
|
241
|
-
expect(missingContent).toEqual({ ok: false, error: "content is required" })
|
|
242
247
|
expect(missingReactionFields).toEqual({ ok: false, error: "messageId and emoji are required" })
|
|
243
248
|
expect(missingPath).toEqual({ ok: false, error: "path is required" })
|
|
244
249
|
})
|
|
@@ -20,15 +20,11 @@ const actionFlag = (action: string): keyof ToolConfig | undefined => {
|
|
|
20
20
|
return "attachFiles"
|
|
21
21
|
case "fetchHistory":
|
|
22
22
|
return "fetchHistory"
|
|
23
|
-
case "followUpMessage":
|
|
24
|
-
return "followUpMessages"
|
|
25
23
|
case "createThread":
|
|
26
24
|
return "createThread"
|
|
27
25
|
case "editOwnMessage":
|
|
28
26
|
case "deleteOwnMessage":
|
|
29
27
|
return "editDeleteOwn"
|
|
30
|
-
case "postOtherChannel":
|
|
31
|
-
return "postOtherChannels"
|
|
32
28
|
case "pin":
|
|
33
29
|
case "unpin":
|
|
34
30
|
return "pin"
|
|
@@ -74,18 +70,6 @@ const attachmentPath = Effect.fn("attachmentPath")(function* (projectDir: string
|
|
|
74
70
|
|
|
75
71
|
const disabled = (action: string): ToolResponse => ({ ok: false, error: `Action ${action} is disabled` })
|
|
76
72
|
|
|
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
73
|
const reaction = Effect.fn("toolReaction")(function* (
|
|
90
74
|
request: ToolRequest,
|
|
91
75
|
scope: DiscordScope,
|
|
@@ -183,21 +167,6 @@ const deleteOwnMessage = Effect.fn("toolDeleteOwnMessage")(function* (
|
|
|
183
167
|
return { ok: true, result: { deleted: true } } satisfies ToolResponse
|
|
184
168
|
})
|
|
185
169
|
|
|
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
170
|
const pin = Effect.fn("toolPin")(function* (
|
|
202
171
|
request: ToolRequest,
|
|
203
172
|
scope: DiscordScope,
|
|
@@ -228,13 +197,11 @@ export const handleToolRequest = Effect.fn("handleToolRequest")(function* (
|
|
|
228
197
|
|
|
229
198
|
const scope = scopeFromRequest(request)
|
|
230
199
|
if (typeof scope === "string") return { ok: false, error: scope } satisfies ToolResponse
|
|
231
|
-
if (
|
|
200
|
+
if (!isAllowedScope(scope, options.allowedScopes)) {
|
|
232
201
|
return { ok: false, error: "Discord target is outside the active turn scope" } satisfies ToolResponse
|
|
233
202
|
}
|
|
234
203
|
|
|
235
204
|
switch (request.action) {
|
|
236
|
-
case "followUpMessage":
|
|
237
|
-
return yield* followUp(request, scope, config, discord)
|
|
238
205
|
case "addReaction":
|
|
239
206
|
return yield* reaction(request, scope, discord, "add")
|
|
240
207
|
case "removeReaction":
|
|
@@ -249,8 +216,6 @@ export const handleToolRequest = Effect.fn("handleToolRequest")(function* (
|
|
|
249
216
|
return yield* editOwnMessage(request, scope, config, discord, options)
|
|
250
217
|
case "deleteOwnMessage":
|
|
251
218
|
return yield* deleteOwnMessage(request, scope, config, discord, options)
|
|
252
|
-
case "postOtherChannel":
|
|
253
|
-
return yield* postOtherChannel(request, config, discord)
|
|
254
219
|
case "pin":
|
|
255
220
|
return yield* pin(request, scope, discord, "pin")
|
|
256
221
|
case "unpin":
|
|
@@ -28,7 +28,7 @@ test("rejects missing attachment files inside the project", async () => {
|
|
|
28
28
|
test("rejects incomplete high-risk tool payloads before dispatch", async () => {
|
|
29
29
|
const config = {
|
|
30
30
|
...defaultConfig,
|
|
31
|
-
tools: { ...defaultConfig.tools, editDeleteOwn: true
|
|
31
|
+
tools: { ...defaultConfig.tools, editDeleteOwn: true }
|
|
32
32
|
}
|
|
33
33
|
const discord = makeMemoryDiscord()
|
|
34
34
|
|
|
@@ -40,10 +40,6 @@ test("rejects incomplete high-risk tool payloads before dispatch", async () => {
|
|
|
40
40
|
discord
|
|
41
41
|
)
|
|
42
42
|
)
|
|
43
|
-
const post = await Effect.runPromise(
|
|
44
|
-
handleToolRequest({ action: "postOtherChannel", target: { guildId: "g1", channelId: "c2" }, args: {} }, config, "/repo", discord)
|
|
45
|
-
)
|
|
46
43
|
|
|
47
44
|
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
45
|
})
|
|
@@ -31,7 +31,7 @@ describe("handleToolRequest high-risk actions", () => {
|
|
|
31
31
|
}
|
|
32
32
|
]
|
|
33
33
|
})
|
|
34
|
-
const config = withTools({ createThread: true, editDeleteOwn: true,
|
|
34
|
+
const config = withTools({ createThread: true, editDeleteOwn: true, pin: true })
|
|
35
35
|
|
|
36
36
|
const created = await Effect.runPromise(
|
|
37
37
|
handleToolRequest(
|
|
@@ -60,14 +60,6 @@ describe("handleToolRequest high-risk actions", () => {
|
|
|
60
60
|
{ botId: "bot-1" }
|
|
61
61
|
)
|
|
62
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
63
|
const pinned = await Effect.runPromise(
|
|
72
64
|
handleToolRequest({ action: "pin", target: { guildId: "g1", channelId: "c1", messageId: "m1" }, args: {} }, config, "/repo", discord)
|
|
73
65
|
)
|
|
@@ -83,11 +75,9 @@ describe("handleToolRequest high-risk actions", () => {
|
|
|
83
75
|
expect(created).toEqual({ ok: true, result: { id: "thread-1" } })
|
|
84
76
|
expect(edited).toEqual({ ok: true, result: { edited: true } })
|
|
85
77
|
expect(deleted).toEqual({ ok: true, result: { deleted: true } })
|
|
86
|
-
expect(posted).toEqual({ ok: true, result: { id: "posted-2" } })
|
|
87
78
|
expect(pinned).toEqual({ ok: true, result: { pinned: true } })
|
|
88
79
|
expect(unpinned).toEqual({ ok: true, result: { pinned: false } })
|
|
89
80
|
expect(discord.threads).toEqual([{ scope: { guildId: "g1", channelId: "c1" }, name: "work" }])
|
|
90
|
-
expect(discord.channelMessages).toEqual([{ guildId: "g1", channelId: "c2", content: "elsewhere" }])
|
|
91
81
|
expect(discord.pins.map((item) => item.op)).toEqual(["pin", "unpin"])
|
|
92
82
|
})
|
|
93
83
|
|
|
@@ -137,17 +127,4 @@ describe("handleToolRequest high-risk actions", () => {
|
|
|
137
127
|
expect(discord.edits).toEqual([])
|
|
138
128
|
expect(discord.deletes).toEqual([])
|
|
139
129
|
})
|
|
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
130
|
})
|
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 }
|
|
27
|
+
"tools": { "createThread": true, "pin": true, "followUpMessages": true, "postOtherChannels": true }
|
|
28
28
|
}`
|
|
29
29
|
})
|
|
30
30
|
)
|
|
@@ -48,6 +48,8 @@ describe("loadConfigFromSources", () => {
|
|
|
48
48
|
expect(config.tools.reactions).toBe(true)
|
|
49
49
|
expect(config.tools.createThread).toBe(true)
|
|
50
50
|
expect(config.tools.pin).toBe(true)
|
|
51
|
+
expect("followUpMessages" in config.tools).toBe(false)
|
|
52
|
+
expect("postOtherChannels" in config.tools).toBe(false)
|
|
51
53
|
})
|
|
52
54
|
|
|
53
55
|
test("uses the normative localhost defaults", async () => {
|
|
@@ -59,8 +61,8 @@ describe("loadConfigFromSources", () => {
|
|
|
59
61
|
expect(config.bridge.port).toBe(8787)
|
|
60
62
|
expect(config.context.messages).toBe(30)
|
|
61
63
|
expect(config.tools.autoInstall).toBe(true)
|
|
62
|
-
expect(config.tools
|
|
63
|
-
expect(config.tools
|
|
64
|
+
expect("followUpMessages" in config.tools).toBe(false)
|
|
65
|
+
expect("postOtherChannels" in config.tools).toBe(false)
|
|
64
66
|
})
|
|
65
67
|
|
|
66
68
|
test("fails fast when DISCORD_TOKEN is missing", async () => {
|
package/src/Config.ts
CHANGED
|
@@ -17,10 +17,8 @@ export type ToolConfig = {
|
|
|
17
17
|
readonly reactions: boolean
|
|
18
18
|
readonly attachFiles: boolean
|
|
19
19
|
readonly fetchHistory: boolean
|
|
20
|
-
readonly followUpMessages: boolean
|
|
21
20
|
readonly createThread: boolean
|
|
22
21
|
readonly editDeleteOwn: boolean
|
|
23
|
-
readonly postOtherChannels: boolean
|
|
24
22
|
readonly pin: boolean
|
|
25
23
|
}
|
|
26
24
|
|
|
@@ -96,10 +94,8 @@ export const defaultConfig: RuntimeConfig = {
|
|
|
96
94
|
reactions: true,
|
|
97
95
|
attachFiles: true,
|
|
98
96
|
fetchHistory: true,
|
|
99
|
-
followUpMessages: true,
|
|
100
97
|
createThread: false,
|
|
101
98
|
editDeleteOwn: false,
|
|
102
|
-
postOtherChannels: false,
|
|
103
99
|
pin: false
|
|
104
100
|
},
|
|
105
101
|
streaming: {
|
|
@@ -263,10 +259,8 @@ export const loadConfigFromSources = Effect.fn("loadConfigFromSources")(function
|
|
|
263
259
|
reactions: readBoolean(tools, "reactions", defaultConfig.tools.reactions),
|
|
264
260
|
attachFiles: readBoolean(tools, "attachFiles", defaultConfig.tools.attachFiles),
|
|
265
261
|
fetchHistory: readBoolean(tools, "fetchHistory", defaultConfig.tools.fetchHistory),
|
|
266
|
-
followUpMessages: readBoolean(tools, "followUpMessages", defaultConfig.tools.followUpMessages),
|
|
267
262
|
createThread: readBoolean(tools, "createThread", defaultConfig.tools.createThread),
|
|
268
263
|
editDeleteOwn: readBoolean(tools, "editDeleteOwn", defaultConfig.tools.editDeleteOwn),
|
|
269
|
-
postOtherChannels: readBoolean(tools, "postOtherChannels", defaultConfig.tools.postOtherChannels),
|
|
270
264
|
pin: readBoolean(tools, "pin", defaultConfig.tools.pin)
|
|
271
265
|
},
|
|
272
266
|
streaming: {
|
package/src/ConfigSchema.ts
CHANGED
|
@@ -8,10 +8,8 @@ const RawToolsSchema = Schema.Struct({
|
|
|
8
8
|
reactions: OptionalBoolean,
|
|
9
9
|
attachFiles: OptionalBoolean,
|
|
10
10
|
fetchHistory: OptionalBoolean,
|
|
11
|
-
followUpMessages: OptionalBoolean,
|
|
12
11
|
createThread: OptionalBoolean,
|
|
13
12
|
editDeleteOwn: OptionalBoolean,
|
|
14
|
-
postOtherChannels: OptionalBoolean,
|
|
15
13
|
pin: OptionalBoolean
|
|
16
14
|
})
|
|
17
15
|
|
|
@@ -3,7 +3,7 @@ import { mkdtemp, rm, writeFile } from "node:fs/promises"
|
|
|
3
3
|
import { tmpdir } from "node:os"
|
|
4
4
|
import { join } from "node:path"
|
|
5
5
|
import type { DiscordThreadId } from "@chat-adapter/discord"
|
|
6
|
-
import type { AdapterPostableMessage,
|
|
6
|
+
import type { AdapterPostableMessage, FetchResult, RawMessage } from "chat"
|
|
7
7
|
import { Message, parseMarkdown } from "chat"
|
|
8
8
|
import { Duration, Effect } from "effect"
|
|
9
9
|
import type { DiscordScope } from "../Schema.ts"
|
|
@@ -37,16 +37,6 @@ class FakeDiscordAdapter {
|
|
|
37
37
|
return Promise.resolve()
|
|
38
38
|
}
|
|
39
39
|
|
|
40
|
-
postChannelMessage(channelId: string, message: AdapterPostableMessage): Promise<RawMessage<unknown>> {
|
|
41
|
-
this.calls.push(["postChannelMessage", { channelId, message }])
|
|
42
|
-
return Promise.resolve({ id: "posted-channel-1", threadId: channelId, raw: {} })
|
|
43
|
-
}
|
|
44
|
-
|
|
45
|
-
fetchChannelInfo(channelId: string): Promise<ChannelInfo> {
|
|
46
|
-
this.calls.push(["fetchChannelInfo", { channelId }])
|
|
47
|
-
return Promise.resolve({ id: channelId, isDM: false, metadata: { raw: { guild_id: "g1" } } })
|
|
48
|
-
}
|
|
49
|
-
|
|
50
40
|
startTyping(threadId: string): Promise<void> {
|
|
51
41
|
this.calls.push(["startTyping", { threadId }])
|
|
52
42
|
return Promise.resolve()
|
|
@@ -142,7 +132,24 @@ describe("makeChatSdkDiscord", () => {
|
|
|
142
132
|
])
|
|
143
133
|
})
|
|
144
134
|
|
|
145
|
-
test("
|
|
135
|
+
test("normalizes Discord user mention wrappers before chat-sdk output conversion", async () => {
|
|
136
|
+
const adapter = new FakeDiscordAdapter()
|
|
137
|
+
const discord = makeChatSdkDiscord(adapter)
|
|
138
|
+
|
|
139
|
+
const posted = await Effect.runPromise(discord.postMessage(scope, "hello <@999> and <@!888>"))
|
|
140
|
+
await Effect.runPromise(discord.editMessage(scope, posted.id, "edited <@777>"))
|
|
141
|
+
|
|
142
|
+
expect(adapter.calls).toEqual([
|
|
143
|
+
["encodeThreadId", { guildId: "g1", channelId: "c1", threadId: "t1" }],
|
|
144
|
+
["postMessage", { threadId: "discord:g1:c1:t1", message: "hello @999 and @888" }],
|
|
145
|
+
["encodeThreadId", { guildId: "g1", channelId: "c1", threadId: "t1" }],
|
|
146
|
+
["editMessage", { threadId: "discord:g1:c1:t1", messageId: "posted-1", message: "edited @777" }]
|
|
147
|
+
])
|
|
148
|
+
})
|
|
149
|
+
})
|
|
150
|
+
|
|
151
|
+
describe("makeChatSdkDiscord REST operations", () => {
|
|
152
|
+
test("routes deletes and raw REST adapter gaps", async () => {
|
|
146
153
|
const adapter = new FakeDiscordAdapter()
|
|
147
154
|
const requests: Array<readonly [string, RequestInit]> = []
|
|
148
155
|
const originalFetch = globalThis.fetch
|
|
@@ -164,7 +171,6 @@ describe("makeChatSdkDiscord", () => {
|
|
|
164
171
|
const discord = makeChatSdkDiscord(adapter, { botToken: "token", apiUrl: "https://discord.test/api" })
|
|
165
172
|
|
|
166
173
|
await Effect.runPromise(discord.deleteMessage(scope, "m1"))
|
|
167
|
-
expect(await Effect.runPromise(discord.postChannelMessage("g1", "c2", "hello"))).toEqual({ id: "posted-channel-1" })
|
|
168
174
|
expect(await Effect.runPromise(discord.createThread(scope, "work"))).toEqual({ id: "thread-1" })
|
|
169
175
|
await Effect.runPromise(discord.pinMessage(scope, "m1"))
|
|
170
176
|
await Effect.runPromise(discord.unpinMessage(scope, "m1"))
|
|
@@ -172,13 +178,7 @@ describe("makeChatSdkDiscord", () => {
|
|
|
172
178
|
globalThis.fetch = originalFetch
|
|
173
179
|
}
|
|
174
180
|
|
|
175
|
-
expect(adapter.calls.map((item) => item[0])).toEqual([
|
|
176
|
-
"encodeThreadId",
|
|
177
|
-
"deleteMessage",
|
|
178
|
-
"encodeThreadId",
|
|
179
|
-
"fetchChannelInfo",
|
|
180
|
-
"postChannelMessage"
|
|
181
|
-
])
|
|
181
|
+
expect(adapter.calls.map((item) => item[0])).toEqual(["encodeThreadId", "deleteMessage"])
|
|
182
182
|
expect(requests.map((request) => [request[0], request[1].method])).toEqual([
|
|
183
183
|
["https://discord.test/api/channels/c1/threads", "POST"],
|
|
184
184
|
["https://discord.test/api/channels/t1/pins/m1", "PUT"],
|
|
@@ -213,21 +213,6 @@ describe("makeChatSdkDiscord", () => {
|
|
|
213
213
|
expect(Duration.toMillis(error.retryAfter)).toBe(123)
|
|
214
214
|
})
|
|
215
215
|
|
|
216
|
-
test("rejects DM channel info before cross-channel posting", async () => {
|
|
217
|
-
const adapter = new FakeDiscordAdapter()
|
|
218
|
-
adapter.fetchChannelInfo = (channelId: string) => {
|
|
219
|
-
adapter.calls.push(["fetchChannelInfo", { channelId }])
|
|
220
|
-
return Promise.resolve({ id: channelId, isDM: true, metadata: {} })
|
|
221
|
-
}
|
|
222
|
-
const discord = makeChatSdkDiscord(adapter)
|
|
223
|
-
|
|
224
|
-
await expect(Effect.runPromise(discord.postChannelMessage("g1", "dm1", "hello"))).rejects.toMatchObject({
|
|
225
|
-
_tag: "DiscordError",
|
|
226
|
-
message: "Discord DMs are not supported"
|
|
227
|
-
})
|
|
228
|
-
expect(adapter.calls.map((item) => item[0])).toEqual(["encodeThreadId", "fetchChannelInfo"])
|
|
229
|
-
})
|
|
230
|
-
|
|
231
216
|
test("preserves raw Discord REST retry-after metadata", async () => {
|
|
232
217
|
const originalFetch = globalThis.fetch
|
|
233
218
|
const fakeFetch: typeof fetch = Object.assign(
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { readFile } from "node:fs/promises"
|
|
2
2
|
import { basename } from "node:path"
|
|
3
3
|
import { createDiscordAdapter, type DiscordThreadId } from "@chat-adapter/discord"
|
|
4
|
-
import type { AdapterPostableMessage,
|
|
4
|
+
import type { AdapterPostableMessage, FetchOptions, FetchResult, Message, PostableRaw, RawMessage } from "chat"
|
|
5
5
|
import { Duration, Effect } from "effect"
|
|
6
6
|
import type { DiscordAttachment, DiscordMessage, DiscordScope } from "../Schema.ts"
|
|
7
7
|
import { DiscordError, type DiscordService } from "./DiscordPort.ts"
|
|
@@ -9,14 +9,12 @@ import { DiscordError, type DiscordService } from "./DiscordPort.ts"
|
|
|
9
9
|
type ChatDiscordAdapter = {
|
|
10
10
|
readonly encodeThreadId: (input: DiscordThreadId) => string
|
|
11
11
|
readonly postMessage: (threadId: string, message: AdapterPostableMessage) => Promise<RawMessage<unknown>>
|
|
12
|
-
readonly postChannelMessage: (channelId: string, message: AdapterPostableMessage) => Promise<RawMessage<unknown>>
|
|
13
12
|
readonly editMessage: (threadId: string, messageId: string, message: AdapterPostableMessage) => Promise<RawMessage<unknown>>
|
|
14
13
|
readonly deleteMessage: (threadId: string, messageId: string) => Promise<void>
|
|
15
14
|
readonly startTyping: (threadId: string, status?: string) => Promise<void>
|
|
16
15
|
readonly addReaction: (threadId: string, messageId: string, emoji: string) => Promise<void>
|
|
17
16
|
readonly removeReaction: (threadId: string, messageId: string, emoji: string) => Promise<void>
|
|
18
17
|
readonly fetchMessages: (threadId: string, options?: FetchOptions) => Promise<FetchResult<unknown>>
|
|
19
|
-
readonly fetchChannelInfo?: ((channelId: string) => Promise<ChannelInfo>) | undefined
|
|
20
18
|
}
|
|
21
19
|
|
|
22
20
|
type LiveDiscordOptions = {
|
|
@@ -37,11 +35,6 @@ const mentions = (content: string): ReadonlyArray<string> => [...content.matchAl
|
|
|
37
35
|
const isRecord = (value: unknown): value is Readonly<Record<string, unknown>> =>
|
|
38
36
|
typeof value === "object" && value !== null && !Array.isArray(value)
|
|
39
37
|
|
|
40
|
-
const stringField = (record: Readonly<Record<string, unknown>>, key: string): string | undefined => {
|
|
41
|
-
const value = record[key]
|
|
42
|
-
return typeof value === "string" ? value : undefined
|
|
43
|
-
}
|
|
44
|
-
|
|
45
38
|
const attachments = (message: Message<unknown>): ReadonlyArray<DiscordAttachment> =>
|
|
46
39
|
message.attachments.map((item, index) => ({
|
|
47
40
|
id: `${message.id}-${index}`,
|
|
@@ -87,17 +80,6 @@ const retryAfterFromCause = (cause: unknown) => {
|
|
|
87
80
|
return undefined
|
|
88
81
|
}
|
|
89
82
|
|
|
90
|
-
const validateGuildChannel = async (adapter: ChatDiscordAdapter, guildId: string, channelThreadId: string): Promise<void> => {
|
|
91
|
-
if (adapter.fetchChannelInfo === undefined) return
|
|
92
|
-
const info = await adapter.fetchChannelInfo(channelThreadId)
|
|
93
|
-
if (info.isDM === true) throw new DiscordError({ message: "Discord DMs are not supported" })
|
|
94
|
-
const raw = info.metadata.raw
|
|
95
|
-
const actualGuildId = isRecord(raw) ? stringField(raw, "guild_id") : undefined
|
|
96
|
-
if (actualGuildId !== undefined && actualGuildId !== guildId) {
|
|
97
|
-
throw new DiscordError({ message: "Discord channel does not belong to the requested guild" })
|
|
98
|
-
}
|
|
99
|
-
}
|
|
100
|
-
|
|
101
83
|
const tryAdapter = <A>(operation: () => Promise<A>): Effect.Effect<A, DiscordError> =>
|
|
102
84
|
Effect.tryPromise({
|
|
103
85
|
try: operation,
|
|
@@ -142,6 +124,8 @@ const rawDiscord = (options: RawDiscordOptions | undefined, path: string, init:
|
|
|
142
124
|
return await response.json()
|
|
143
125
|
})
|
|
144
126
|
|
|
127
|
+
const normalizeMentionsForChatAdapter = (content: string): string => content.replace(/<@!?(\w+)>/g, "@$1")
|
|
128
|
+
|
|
145
129
|
export const makeChatSdkDiscord = (adapter: ChatDiscordAdapter, raw: RawDiscordOptions | undefined = undefined): DiscordService => ({
|
|
146
130
|
fetchContext: (scope, limit) =>
|
|
147
131
|
tryAdapter(async () => {
|
|
@@ -158,11 +142,13 @@ export const makeChatSdkDiscord = (adapter: ChatDiscordAdapter, raw: RawDiscordO
|
|
|
158
142
|
sendTyping: (scope) => tryAdapter(() => adapter.startTyping(threadIdFromScope(adapter, scope))).pipe(Effect.asVoid),
|
|
159
143
|
postMessage: (scope, content) =>
|
|
160
144
|
tryAdapter(async () => {
|
|
161
|
-
const result = await adapter.postMessage(threadIdFromScope(adapter, scope), content)
|
|
145
|
+
const result = await adapter.postMessage(threadIdFromScope(adapter, scope), normalizeMentionsForChatAdapter(content))
|
|
162
146
|
return { id: result.id }
|
|
163
147
|
}),
|
|
164
148
|
editMessage: (scope, messageId, content) =>
|
|
165
|
-
tryAdapter(() => adapter.editMessage(threadIdFromScope(adapter, scope), messageId, content)).pipe(
|
|
149
|
+
tryAdapter(() => adapter.editMessage(threadIdFromScope(adapter, scope), messageId, normalizeMentionsForChatAdapter(content))).pipe(
|
|
150
|
+
Effect.asVoid
|
|
151
|
+
),
|
|
166
152
|
deleteMessage: (scope, messageId) =>
|
|
167
153
|
tryAdapter(() => adapter.deleteMessage(threadIdFromScope(adapter, scope), messageId)).pipe(Effect.asVoid),
|
|
168
154
|
addReaction: (scope, messageId, emoji) =>
|
|
@@ -180,21 +166,12 @@ export const makeChatSdkDiscord = (adapter: ChatDiscordAdapter, raw: RawDiscordO
|
|
|
180
166
|
method: "POST",
|
|
181
167
|
body: JSON.stringify({ name, type: 11 })
|
|
182
168
|
}).pipe(Effect.map((data) => ({ id: isRecord(data) && typeof data.id === "string" ? data.id : "" }))),
|
|
183
|
-
postChannelMessage: (guildId, channelId, content) =>
|
|
184
|
-
tryAdapter(async () => {
|
|
185
|
-
const encodedChannelId = adapter.encodeThreadId({ guildId, channelId })
|
|
186
|
-
await validateGuildChannel(adapter, guildId, encodedChannelId)
|
|
187
|
-
const result = await adapter.postChannelMessage(encodedChannelId, sanitizeGuildContent(guildId, content))
|
|
188
|
-
return { id: result.id }
|
|
189
|
-
}),
|
|
190
169
|
pinMessage: (scope, messageId) =>
|
|
191
170
|
rawDiscord(raw, `/channels/${scope.threadId ?? scope.channelId}/pins/${messageId}`, { method: "PUT" }).pipe(Effect.asVoid),
|
|
192
171
|
unpinMessage: (scope, messageId) =>
|
|
193
172
|
rawDiscord(raw, `/channels/${scope.threadId ?? scope.channelId}/pins/${messageId}`, { method: "DELETE" }).pipe(Effect.asVoid)
|
|
194
173
|
})
|
|
195
174
|
|
|
196
|
-
const sanitizeGuildContent = (_guildId: string, content: string): string => content
|
|
197
|
-
|
|
198
175
|
export const makeLiveChatSdkDiscord = (options: LiveDiscordOptions): DiscordService =>
|
|
199
176
|
makeChatSdkDiscord(
|
|
200
177
|
createDiscordAdapter({
|
|
@@ -42,6 +42,7 @@ test("maps Discord messages through the chat-sdk adapter facade", async () => {
|
|
|
42
42
|
const parsed = adapter.parseMessage(withAttachment)
|
|
43
43
|
|
|
44
44
|
expect(adapter.encodeThreadId({ guildId: "g1", channelId: "c1", threadId: "t1" })).toBe("discord:g1:c1:t1")
|
|
45
|
+
expect(adapter.userName).toBe("self")
|
|
45
46
|
expect(adapter.decodeThreadId("discord:g1:c1:t1")).toEqual({ guildId: "g1", channelId: "c1", threadId: "t1" })
|
|
46
47
|
expect(adapter.channelIdFromThreadId("discord:g1:c1:t1")).toBe("c1")
|
|
47
48
|
expect(parsed.threadId).toBe("discord:g1:c1")
|
|
@@ -175,7 +175,7 @@ const unsupported = (operation: string): Promise<never> => Promise.reject(new Er
|
|
|
175
175
|
|
|
176
176
|
export const makeGatewayAdapter = (bot: BotIdentity): Adapter<DiscordScope, DiscordMessage> => ({
|
|
177
177
|
name: "discord",
|
|
178
|
-
userName:
|
|
178
|
+
userName: bot.userId,
|
|
179
179
|
botUserId: bot.userId,
|
|
180
180
|
lockScope: "thread",
|
|
181
181
|
initialize: () => Promise.resolve(),
|
|
@@ -208,7 +208,7 @@ export const makeChatGatewayIntake = (options: ChatGatewayIntakeOptions): ChatGa
|
|
|
208
208
|
const chat = new Chat({
|
|
209
209
|
adapters: { discord: adapter },
|
|
210
210
|
state: makeTransientChatState(),
|
|
211
|
-
userName:
|
|
211
|
+
userName: options.bot.userId,
|
|
212
212
|
concurrency: "concurrent",
|
|
213
213
|
dedupeTtlMs: 5 * 60 * 1000
|
|
214
214
|
})
|
|
@@ -134,7 +134,6 @@ describe("makeDiscordJsDiscord", () => {
|
|
|
134
134
|
await Effect.runPromise(discord.removeReaction(scope, "m1", "rocket"))
|
|
135
135
|
const attached = await Effect.runPromise(discord.attachFile(scope, file))
|
|
136
136
|
expect(await Effect.runPromise(discord.createThread(scope, "work"))).toEqual({ id: "thread-1" })
|
|
137
|
-
expect(await Effect.runPromise(discord.postChannelMessage("g1", "c2", "hello"))).toEqual({ id: "posted-1" })
|
|
138
137
|
await Effect.runPromise(discord.pinMessage(scope, "m1"))
|
|
139
138
|
await Effect.runPromise(discord.unpinMessage(scope, "m1"))
|
|
140
139
|
|
|
@@ -246,12 +246,6 @@ export const makeDiscordJsDiscord = (client: DiscordJsClientLike): DiscordServic
|
|
|
246
246
|
if (channel.threads === undefined) return yield* Effect.fail(new DiscordError({ message: "Discord channel cannot create threads" }))
|
|
247
247
|
return yield* tryDiscord(() => channel.threads?.create({ name }) ?? Promise.resolve({ id: "" }))
|
|
248
248
|
}),
|
|
249
|
-
postChannelMessage: (_guildId, channelId, content) =>
|
|
250
|
-
Effect.gen(function* () {
|
|
251
|
-
const channel = yield* fetchTextChannel(client, { guildId: _guildId, channelId })
|
|
252
|
-
const result = yield* tryDiscord(() => channel.send(content))
|
|
253
|
-
return { id: result.id }
|
|
254
|
-
}),
|
|
255
249
|
pinMessage: (scope, messageId) =>
|
|
256
250
|
Effect.gen(function* () {
|
|
257
251
|
const message = yield* fetchMessage(client, scope, messageId)
|
|
@@ -22,7 +22,6 @@ export type DiscordService = {
|
|
|
22
22
|
readonly attachFile: (scope: DiscordScope, path: string) => Effect.Effect<{ readonly path: string }, DiscordError>
|
|
23
23
|
readonly createThread: (scope: DiscordScope, name: string) => Effect.Effect<{ readonly id: string }, DiscordError>
|
|
24
24
|
readonly deleteMessage: (scope: DiscordScope, messageId: string) => Effect.Effect<void, DiscordError>
|
|
25
|
-
readonly postChannelMessage: (guildId: string, channelId: string, content: string) => Effect.Effect<{ readonly id: string }, DiscordError>
|
|
26
25
|
readonly pinMessage: (scope: DiscordScope, messageId: string) => Effect.Effect<void, DiscordError>
|
|
27
26
|
readonly unpinMessage: (scope: DiscordScope, messageId: string) => Effect.Effect<void, DiscordError>
|
|
28
27
|
}
|
|
@@ -20,7 +20,6 @@ export type MemoryDiscord = DiscordService & {
|
|
|
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 channelMessages: Array<{ readonly guildId: string; readonly channelId: string; readonly content: string }>
|
|
24
23
|
readonly pins: Array<{ readonly scope: DiscordScope; readonly messageId: string; readonly op: "pin" | "unpin" }>
|
|
25
24
|
}
|
|
26
25
|
|
|
@@ -34,7 +33,6 @@ export const makeMemoryDiscord = (options: MemoryOptions = {}): MemoryDiscord =>
|
|
|
34
33
|
const attachments: MemoryDiscord["attachments"] = []
|
|
35
34
|
const threads: MemoryDiscord["threads"] = []
|
|
36
35
|
const deletes: MemoryDiscord["deletes"] = []
|
|
37
|
-
const channelMessages: MemoryDiscord["channelMessages"] = []
|
|
38
36
|
const pins: MemoryDiscord["pins"] = []
|
|
39
37
|
|
|
40
38
|
return {
|
|
@@ -46,7 +44,6 @@ export const makeMemoryDiscord = (options: MemoryOptions = {}): MemoryDiscord =>
|
|
|
46
44
|
attachments,
|
|
47
45
|
threads,
|
|
48
46
|
deletes,
|
|
49
|
-
channelMessages,
|
|
50
47
|
pins,
|
|
51
48
|
fetchContext: (_scope, limit) => Effect.succeed(context.slice(Math.max(0, context.length - limit))),
|
|
52
49
|
sendTyping: (scope) => Effect.sync(() => typingScopes.push(scope)).pipe(Effect.asVoid),
|
|
@@ -73,12 +70,6 @@ export const makeMemoryDiscord = (options: MemoryOptions = {}): MemoryDiscord =>
|
|
|
73
70
|
return { id: `thread-${nextId}` }
|
|
74
71
|
}),
|
|
75
72
|
deleteMessage: (scope, messageId) => Effect.sync(() => deletes.push({ scope, messageId })).pipe(Effect.asVoid),
|
|
76
|
-
postChannelMessage: (guildId, channelId, content) =>
|
|
77
|
-
Effect.sync(() => {
|
|
78
|
-
nextId += 1
|
|
79
|
-
channelMessages.push({ guildId, channelId, content })
|
|
80
|
-
return { id: `posted-${nextId}` }
|
|
81
|
-
}),
|
|
82
73
|
pinMessage: (scope, messageId) => Effect.sync(() => pins.push({ scope, messageId, op: "pin" })).pipe(Effect.asVoid),
|
|
83
74
|
unpinMessage: (scope, messageId) => Effect.sync(() => pins.push({ scope, messageId, op: "unpin" })).pipe(Effect.asVoid)
|
|
84
75
|
}
|
package/src/Main.test.ts
CHANGED
|
@@ -124,7 +124,6 @@ describe("makeApplication startup", () => {
|
|
|
124
124
|
attachFile: () => Effect.succeed({ path: "out.txt" }),
|
|
125
125
|
createThread: () => Effect.succeed({ id: "thread" }),
|
|
126
126
|
deleteMessage: () => Effect.void,
|
|
127
|
-
postChannelMessage: () => Effect.succeed({ id: "posted" }),
|
|
128
127
|
pinMessage: () => Effect.void,
|
|
129
128
|
unpinMessage: () => Effect.void
|
|
130
129
|
}
|
|
@@ -203,16 +202,17 @@ describe("makeApplication facade", () => {
|
|
|
203
202
|
await new Promise((resolve) => setTimeout(resolve, 0))
|
|
204
203
|
const response = await Effect.runPromise(
|
|
205
204
|
app.handleTool({
|
|
206
|
-
action: "
|
|
207
|
-
target: { guildId: "g1", channelId: "c1" },
|
|
208
|
-
args: {
|
|
205
|
+
action: "addReaction",
|
|
206
|
+
target: { guildId: "g1", channelId: "c1", messageId: "m1" },
|
|
207
|
+
args: { emoji: "rocket" }
|
|
209
208
|
})
|
|
210
209
|
)
|
|
211
210
|
releaseTurn?.()
|
|
212
211
|
await running
|
|
213
212
|
|
|
214
|
-
expect(response).toEqual({ ok: true, result: {
|
|
215
|
-
expect(discord.
|
|
213
|
+
expect(response).toEqual({ ok: true, result: { reacted: true } })
|
|
214
|
+
expect(discord.reactions).toEqual([{ scope: { guildId: "g1", channelId: "c1" }, messageId: "m1", emoji: "rocket", op: "add" }])
|
|
215
|
+
expect(discord.messages).toEqual([])
|
|
216
216
|
})
|
|
217
217
|
|
|
218
218
|
test("runs message turns and stop commands through the application facade", async () => {
|
|
@@ -51,8 +51,10 @@ 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("Plain assistant text is streamed to Discord automatically")
|
|
55
|
+
expect(prompt.text).toContain("do not use bridge tools to send messages")
|
|
54
56
|
expect(prompt.text).toContain("<@id> pings that user in Discord")
|
|
55
|
-
expect(prompt.text).toContain("Use discord target metadata when calling bridge tools")
|
|
57
|
+
expect(prompt.text).toContain("Use discord target metadata when calling non-message bridge tools")
|
|
56
58
|
expect(prompt.text).toContain("Do not emit @everyone, @here, or role pings")
|
|
57
59
|
})
|
|
58
60
|
|
|
@@ -18,7 +18,7 @@ type ContextInput = {
|
|
|
18
18
|
}
|
|
19
19
|
|
|
20
20
|
const preamble = (botUserId: string) =>
|
|
21
|
-
`Discord bridge context for <@${botUserId}>. <@id> pings that user in Discord. Use discord target metadata when calling bridge tools. 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 discord target metadata when calling non-message bridge tools. 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)
|
|
@@ -3,11 +3,10 @@ import { tool } from "@opencode-ai/plugin"
|
|
|
3
3
|
const loopbackToolUrl = "__OPENCODE_DISCORD_BOT_LOOPBACK_URL__/tool"
|
|
4
4
|
|
|
5
5
|
export default tool({
|
|
6
|
-
description: "Perform
|
|
6
|
+
description: "Perform non-message Discord bridge actions through the local opencode-discord-bot process.",
|
|
7
7
|
args: {
|
|
8
8
|
action: tool.schema
|
|
9
9
|
.enum([
|
|
10
|
-
"followUpMessage",
|
|
11
10
|
"addReaction",
|
|
12
11
|
"removeReaction",
|
|
13
12
|
"fetchHistory",
|
|
@@ -15,7 +14,6 @@ export default tool({
|
|
|
15
14
|
"createThread",
|
|
16
15
|
"editOwnMessage",
|
|
17
16
|
"deleteOwnMessage",
|
|
18
|
-
"postOtherChannel",
|
|
19
17
|
"pin",
|
|
20
18
|
"unpin"
|
|
21
19
|
])
|
|
@@ -30,7 +28,7 @@ export default tool({
|
|
|
30
28
|
.describe("Discord target for the action."),
|
|
31
29
|
args: tool.schema
|
|
32
30
|
.record(tool.schema.string(), tool.schema.unknown())
|
|
33
|
-
.describe("Action-specific arguments, such as
|
|
31
|
+
.describe("Action-specific arguments, such as emoji, limit, path, name, or replacement content for editOwnMessage.")
|
|
34
32
|
},
|
|
35
33
|
async execute(request) {
|
|
36
34
|
const response = await fetch(loopbackToolUrl, {
|
|
@@ -16,6 +16,8 @@ describe("Discord tool scaffolding", () => {
|
|
|
16
16
|
expect(source).toContain(".enum([")
|
|
17
17
|
expect(source).toContain('"fetchHistory"')
|
|
18
18
|
expect(source).toContain('"addReaction"')
|
|
19
|
+
expect(source).not.toContain('"followUpMessage"')
|
|
20
|
+
expect(source).not.toContain('"postOtherChannel"')
|
|
19
21
|
expect(source).toContain(".record(tool.schema.string(), tool.schema.unknown())")
|
|
20
22
|
expect(source).toContain("fetch")
|
|
21
23
|
expect(source).not.toContain("export const parameters")
|