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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "opencode-discord-bot",
3
- "version": "0.0.5",
3
+ "version": "0.0.7",
4
4
  "description": "Discord bot bridge for a self-hosted opencode instance",
5
5
  "type": "module",
6
6
  "main": "src/Main.ts",
@@ -23,17 +23,18 @@ describe("startLoopbackServer", () => {
23
23
  method: "POST",
24
24
  headers: { "content-type": "application/json" },
25
25
  body: JSON.stringify({
26
- action: "followUpMessage",
27
- target: { guildId: "g1", channelId: "c1" },
28
- args: { content: "from http" }
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: { id: "posted-1" } })
36
- expect(discord.messages).toEqual([{ scope: { guildId: "g1", channelId: "c1" }, content: "from http" }])
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: "followUpMessage",
82
- target: { guildId: "g1", channelId: "c2" },
83
- args: { content: "outside" }
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: "followUpMessage",
23
- target: { guildId: "g1", channelId: "c1" },
24
- args: { content: "done" }
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.ok).toBe(true)
33
- expect(discord.messages).toEqual([{ scope: { guildId: "g1", channelId: "c1" }, content: "done" }])
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("neutralizes mass mentions in follow-up tool content", async () => {
37
+ test("rejects stale message-sending bridge actions", async () => {
37
38
  const discord = makeMemoryDiscord()
38
39
 
39
- const response = await Effect.runPromise(
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(response.ok).toBe(true)
53
- expect(discord.messages).toEqual([{ scope: { guildId: "g1", channelId: "c1" }, content: "@ everyone @ here <@& 123>" }])
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: "followUpMessage", target: { channelId: "dm1" }, args: { content: "nope" } },
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: "followUpMessage", target: { guildId: "g1", channelId: "other" }, args: { content: "nope" } },
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: "followUpMessage", target: { guildId: "g1", channelId: "c1" }, args: { content: "x" } },
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 (request.action !== "postOtherChannel" && !isAllowedScope(scope, options.allowedScopes)) {
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, postOtherChannels: 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, postOtherChannels: true, pin: 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
  })
@@ -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.followUpMessages).toBe(true)
63
- expect(config.tools.postOtherChannels).toBe(false)
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: {
@@ -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, ChannelInfo, FetchResult, RawMessage } from "chat"
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("routes channel posts, deletes, and raw REST adapter gaps", async () => {
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, ChannelInfo, FetchOptions, FetchResult, Message, PostableRaw, RawMessage } from "chat"
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(Effect.asVoid),
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: `<@${bot.userId}>`,
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: `<@${options.bot.userId}>`,
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: "followUpMessage",
207
- target: { guildId: "g1", channelId: "c1" },
208
- args: { content: "hello @everyone <@&123>" }
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: { id: "posted-1" } })
215
- expect(discord.messages.map((item) => item.content)).toEqual(["hello @ everyone <@& 123>"])
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 safe Discord bridge actions through the local opencode-discord-bot process.",
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 content, emoji, limit, path, or name.")
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")