opencode-discord-bot 0.0.6 → 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.6",
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()
@@ -148,22 +138,18 @@ describe("makeChatSdkDiscord", () => {
148
138
 
149
139
  const posted = await Effect.runPromise(discord.postMessage(scope, "hello <@999> and <@!888>"))
150
140
  await Effect.runPromise(discord.editMessage(scope, posted.id, "edited <@777>"))
151
- await Effect.runPromise(discord.postChannelMessage("g1", "c2", "channel <@666>"))
152
141
 
153
142
  expect(adapter.calls).toEqual([
154
143
  ["encodeThreadId", { guildId: "g1", channelId: "c1", threadId: "t1" }],
155
144
  ["postMessage", { threadId: "discord:g1:c1:t1", message: "hello @999 and @888" }],
156
145
  ["encodeThreadId", { guildId: "g1", channelId: "c1", threadId: "t1" }],
157
- ["editMessage", { threadId: "discord:g1:c1:t1", messageId: "posted-1", message: "edited @777" }],
158
- ["encodeThreadId", { guildId: "g1", channelId: "c2" }],
159
- ["fetchChannelInfo", { channelId: "discord:g1:c2" }],
160
- ["postChannelMessage", { channelId: "discord:g1:c2", message: "channel @666" }]
146
+ ["editMessage", { threadId: "discord:g1:c1:t1", messageId: "posted-1", message: "edited @777" }]
161
147
  ])
162
148
  })
163
149
  })
164
150
 
165
151
  describe("makeChatSdkDiscord REST operations", () => {
166
- test("routes channel posts, deletes, and raw REST adapter gaps", async () => {
152
+ test("routes deletes and raw REST adapter gaps", async () => {
167
153
  const adapter = new FakeDiscordAdapter()
168
154
  const requests: Array<readonly [string, RequestInit]> = []
169
155
  const originalFetch = globalThis.fetch
@@ -185,7 +171,6 @@ describe("makeChatSdkDiscord REST operations", () => {
185
171
  const discord = makeChatSdkDiscord(adapter, { botToken: "token", apiUrl: "https://discord.test/api" })
186
172
 
187
173
  await Effect.runPromise(discord.deleteMessage(scope, "m1"))
188
- expect(await Effect.runPromise(discord.postChannelMessage("g1", "c2", "hello"))).toEqual({ id: "posted-channel-1" })
189
174
  expect(await Effect.runPromise(discord.createThread(scope, "work"))).toEqual({ id: "thread-1" })
190
175
  await Effect.runPromise(discord.pinMessage(scope, "m1"))
191
176
  await Effect.runPromise(discord.unpinMessage(scope, "m1"))
@@ -193,13 +178,7 @@ describe("makeChatSdkDiscord REST operations", () => {
193
178
  globalThis.fetch = originalFetch
194
179
  }
195
180
 
196
- expect(adapter.calls.map((item) => item[0])).toEqual([
197
- "encodeThreadId",
198
- "deleteMessage",
199
- "encodeThreadId",
200
- "fetchChannelInfo",
201
- "postChannelMessage"
202
- ])
181
+ expect(adapter.calls.map((item) => item[0])).toEqual(["encodeThreadId", "deleteMessage"])
203
182
  expect(requests.map((request) => [request[0], request[1].method])).toEqual([
204
183
  ["https://discord.test/api/channels/c1/threads", "POST"],
205
184
  ["https://discord.test/api/channels/t1/pins/m1", "PUT"],
@@ -234,21 +213,6 @@ describe("makeChatSdkDiscord REST operations", () => {
234
213
  expect(Duration.toMillis(error.retryAfter)).toBe(123)
235
214
  })
236
215
 
237
- test("rejects DM channel info before cross-channel posting", async () => {
238
- const adapter = new FakeDiscordAdapter()
239
- adapter.fetchChannelInfo = (channelId: string) => {
240
- adapter.calls.push(["fetchChannelInfo", { channelId }])
241
- return Promise.resolve({ id: channelId, isDM: true, metadata: {} })
242
- }
243
- const discord = makeChatSdkDiscord(adapter)
244
-
245
- await expect(Effect.runPromise(discord.postChannelMessage("g1", "dm1", "hello"))).rejects.toMatchObject({
246
- _tag: "DiscordError",
247
- message: "Discord DMs are not supported"
248
- })
249
- expect(adapter.calls.map((item) => item[0])).toEqual(["encodeThreadId", "fetchChannelInfo"])
250
- })
251
-
252
216
  test("preserves raw Discord REST retry-after metadata", async () => {
253
217
  const originalFetch = globalThis.fetch
254
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,
@@ -184,24 +166,12 @@ export const makeChatSdkDiscord = (adapter: ChatDiscordAdapter, raw: RawDiscordO
184
166
  method: "POST",
185
167
  body: JSON.stringify({ name, type: 11 })
186
168
  }).pipe(Effect.map((data) => ({ id: isRecord(data) && typeof data.id === "string" ? data.id : "" }))),
187
- postChannelMessage: (guildId, channelId, content) =>
188
- tryAdapter(async () => {
189
- const encodedChannelId = adapter.encodeThreadId({ guildId, channelId })
190
- await validateGuildChannel(adapter, guildId, encodedChannelId)
191
- const result = await adapter.postChannelMessage(
192
- encodedChannelId,
193
- normalizeMentionsForChatAdapter(sanitizeGuildContent(guildId, content))
194
- )
195
- return { id: result.id }
196
- }),
197
169
  pinMessage: (scope, messageId) =>
198
170
  rawDiscord(raw, `/channels/${scope.threadId ?? scope.channelId}/pins/${messageId}`, { method: "PUT" }).pipe(Effect.asVoid),
199
171
  unpinMessage: (scope, messageId) =>
200
172
  rawDiscord(raw, `/channels/${scope.threadId ?? scope.channelId}/pins/${messageId}`, { method: "DELETE" }).pipe(Effect.asVoid)
201
173
  })
202
174
 
203
- const sanitizeGuildContent = (_guildId: string, content: string): string => content
204
-
205
175
  export const makeLiveChatSdkDiscord = (options: LiveDiscordOptions): DiscordService =>
206
176
  makeChatSdkDiscord(
207
177
  createDiscordAdapter({
@@ -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")