opencode-discord-bot 0.0.9 → 0.0.11

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