opencode-discord-bot 0.0.7 → 0.0.9

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.7",
3
+ "version": "0.0.9",
4
4
  "description": "Discord bot bridge for a self-hosted opencode instance",
5
5
  "type": "module",
6
6
  "main": "src/Main.ts",
@@ -99,7 +99,7 @@ describe("makeChatSdkDiscord", () => {
99
99
  guildId: "g1",
100
100
  channelId: "c1",
101
101
  threadId: "t1",
102
- author: { id: "u1", displayName: "Alice", nickname: "alice", isBot: false },
102
+ author: { id: "u1", displayName: "Alice", isBot: false },
103
103
  content: "hello <@999>",
104
104
  timestamp: "2026-06-05T14:03:00.000Z",
105
105
  mentions: ["999"],
@@ -149,6 +149,56 @@ describe("makeChatSdkDiscord", () => {
149
149
  })
150
150
 
151
151
  describe("makeChatSdkDiscord REST operations", () => {
152
+ test("resolves server nicknames for fetched message authors and caches them", async () => {
153
+ const adapter = new FakeDiscordAdapter()
154
+ const requests: Array<string> = []
155
+ const originalFetch = globalThis.fetch
156
+ const fakeFetch: typeof fetch = Object.assign(
157
+ (input: URL | RequestInfo) => {
158
+ const url = String(input)
159
+ requests.push(url)
160
+ return Promise.resolve(
161
+ new Response(JSON.stringify({ nick: "Ali the Great" }), { status: 200, headers: { "content-type": "application/json" } })
162
+ )
163
+ },
164
+ { preconnect: originalFetch.preconnect }
165
+ )
166
+ globalThis.fetch = fakeFetch
167
+
168
+ try {
169
+ const discord = makeChatSdkDiscord(adapter, { botToken: "token", apiUrl: "https://discord.test/api" })
170
+
171
+ const context = await Effect.runPromise(discord.fetchContext(scope, 30))
172
+ const history = await Effect.runPromise(discord.fetchHistory(scope, 30))
173
+
174
+ expect(context[0]?.author).toEqual({ id: "u1", displayName: "Alice", nickname: "Ali the Great", isBot: false })
175
+ expect(history[0]?.author).toEqual({ id: "u1", displayName: "Alice", nickname: "Ali the Great", isBot: false })
176
+ } finally {
177
+ globalThis.fetch = originalFetch
178
+ }
179
+
180
+ expect(requests).toEqual(["https://discord.test/api/guilds/g1/members/u1"])
181
+ })
182
+
183
+ test("omits server nickname when the member lookup fails", async () => {
184
+ const adapter = new FakeDiscordAdapter()
185
+ const originalFetch = globalThis.fetch
186
+ const fakeFetch: typeof fetch = Object.assign(() => Promise.resolve(new Response("missing", { status: 404 })), {
187
+ preconnect: originalFetch.preconnect
188
+ })
189
+ globalThis.fetch = fakeFetch
190
+
191
+ try {
192
+ const discord = makeChatSdkDiscord(adapter, { botToken: "token", apiUrl: "https://discord.test/api" })
193
+
194
+ const context = await Effect.runPromise(discord.fetchContext(scope, 30))
195
+
196
+ expect(context[0]?.author).toEqual({ id: "u1", displayName: "Alice", isBot: false })
197
+ } finally {
198
+ globalThis.fetch = originalFetch
199
+ }
200
+ })
201
+
152
202
  test("routes deletes and raw REST adapter gaps", async () => {
153
203
  const adapter = new FakeDiscordAdapter()
154
204
  const requests: Array<readonly [string, RequestInit]> = []
@@ -23,6 +23,15 @@ type LiveDiscordOptions = {
23
23
  readonly publicKey?: string
24
24
  }
25
25
 
26
+ type NicknameCacheEntry = {
27
+ readonly nickname: string | undefined
28
+ readonly expiresAt: number
29
+ }
30
+
31
+ const defaultNicknameCacheTtlMs = 7 * 24 * 60 * 60 * 1000
32
+ const nicknameLookupFailureCacheTtlMs = 5 * 60 * 1000
33
+ const nicknameResolveConcurrency = 5
34
+
26
35
  const threadIdFromScope = (adapter: ChatDiscordAdapter, scope: DiscordScope): string =>
27
36
  adapter.encodeThreadId({
28
37
  guildId: scope.guildId,
@@ -44,7 +53,7 @@ const attachments = (message: Message<unknown>): ReadonlyArray<DiscordAttachment
44
53
  url: item.url ?? ""
45
54
  }))
46
55
 
47
- const fromChatMessage = (scope: DiscordScope, message: Message<unknown>): DiscordMessage => ({
56
+ const fromChatMessage = (scope: DiscordScope, message: Message<unknown>, nickname?: string | undefined): DiscordMessage => ({
48
57
  id: message.id,
49
58
  guildId: scope.guildId,
50
59
  channelId: scope.channelId,
@@ -52,7 +61,7 @@ const fromChatMessage = (scope: DiscordScope, message: Message<unknown>): Discor
52
61
  author: {
53
62
  id: message.author.userId,
54
63
  displayName: message.author.fullName,
55
- nickname: message.author.userName,
64
+ ...(nickname === undefined ? {} : { nickname }),
56
65
  isBot: message.author.isBot === true
57
66
  },
58
67
  content: message.text,
@@ -102,75 +111,137 @@ const retryAfterHeader = (response: Response) => {
102
111
  type RawDiscordOptions = {
103
112
  readonly botToken: string
104
113
  readonly apiUrl?: string | undefined
114
+ readonly nicknameCacheTtlMs?: number | undefined
105
115
  }
106
116
 
107
- const rawDiscord = (options: RawDiscordOptions | undefined, path: string, init: RequestInit): Effect.Effect<unknown, DiscordError> =>
108
- tryAdapter(async () => {
109
- if (options === undefined) throw new Error("Discord adapter does not expose this operation")
110
- const response = await fetch(`${options.apiUrl ?? "https://discord.com/api/v10"}${path}`, {
111
- ...init,
112
- headers: {
113
- authorization: `Bot ${options.botToken}`,
114
- "content-type": "application/json",
115
- ...init.headers
116
- }
117
- })
118
- if (!response.ok)
119
- throw new DiscordError({
120
- message: `Discord REST ${response.status}: ${await response.text()}`,
121
- retryAfter: retryAfterHeader(response)
122
- })
123
- if (response.status === 204) return {}
124
- return await response.json()
117
+ const rawDiscordRequest = async (options: RawDiscordOptions | undefined, path: string, init: RequestInit): Promise<unknown> => {
118
+ if (options === undefined) throw new Error("Discord adapter does not expose this operation")
119
+ const response = await fetch(`${options.apiUrl ?? "https://discord.com/api/v10"}${path}`, {
120
+ ...init,
121
+ headers: {
122
+ authorization: `Bot ${options.botToken}`,
123
+ "content-type": "application/json",
124
+ ...init.headers
125
+ }
125
126
  })
127
+ if (!response.ok)
128
+ throw new DiscordError({
129
+ message: `Discord REST ${response.status}: ${await response.text()}`,
130
+ retryAfter: retryAfterHeader(response)
131
+ })
132
+ if (response.status === 204) return {}
133
+ return await response.json()
134
+ }
135
+
136
+ const rawDiscord = (options: RawDiscordOptions | undefined, path: string, init: RequestInit): Effect.Effect<unknown, DiscordError> =>
137
+ tryAdapter(() => rawDiscordRequest(options, path, init))
138
+
139
+ const memberNickname = (data: unknown): string | undefined => {
140
+ if (!isRecord(data)) return undefined
141
+ const nick = data.nick
142
+ return typeof nick === "string" && nick.length > 0 ? nick : undefined
143
+ }
126
144
 
127
145
  const normalizeMentionsForChatAdapter = (content: string): string => content.replace(/<@!?(\w+)>/g, "@$1")
128
146
 
129
- export const makeChatSdkDiscord = (adapter: ChatDiscordAdapter, raw: RawDiscordOptions | undefined = undefined): DiscordService => ({
130
- fetchContext: (scope, limit) =>
131
- tryAdapter(async () => {
132
- const threadId = threadIdFromScope(adapter, scope)
133
- const result = await adapter.fetchMessages(threadId, { limit })
134
- return result.messages.map((message) => fromChatMessage(scope, message))
135
- }),
136
- fetchHistory: (scope, limit) =>
147
+ export const makeChatSdkDiscord = (adapter: ChatDiscordAdapter, raw: RawDiscordOptions | undefined = undefined): DiscordService => {
148
+ const nicknameCache = new Map<string, NicknameCacheEntry>()
149
+ const nicknameInflight = new Map<string, Promise<string | undefined>>()
150
+ const nicknameCacheTtlMs =
151
+ raw?.nicknameCacheTtlMs !== undefined && Number.isFinite(raw.nicknameCacheTtlMs) && raw.nicknameCacheTtlMs > 0
152
+ ? raw.nicknameCacheTtlMs
153
+ : defaultNicknameCacheTtlMs
154
+
155
+ const cacheNickname = (key: string, nickname: string | undefined, ttlMs = nicknameCacheTtlMs): void => {
156
+ nicknameCache.set(key, { nickname, expiresAt: Date.now() + ttlMs })
157
+ }
158
+
159
+ const resolveNickname = async (scope: DiscordScope, userId: string): Promise<string | undefined> => {
160
+ if (raw === undefined || scope.guildId === "@me") return undefined
161
+ const key = `${scope.guildId}:${userId}`
162
+ const cached = nicknameCache.get(key)
163
+ if (cached !== undefined && cached.expiresAt > Date.now()) return cached.nickname
164
+
165
+ const inflight = nicknameInflight.get(key)
166
+ if (inflight !== undefined) return inflight
167
+
168
+ const request = rawDiscordRequest(raw, `/guilds/${scope.guildId}/members/${userId}`, { method: "GET" })
169
+ .then(memberNickname)
170
+ .then((nickname) => {
171
+ cacheNickname(key, nickname)
172
+ return nickname
173
+ })
174
+ .catch(() => {
175
+ cacheNickname(key, undefined, nicknameLookupFailureCacheTtlMs)
176
+ return undefined
177
+ })
178
+ .finally(() => nicknameInflight.delete(key))
179
+ nicknameInflight.set(key, request)
180
+ return request
181
+ }
182
+
183
+ const resolveNicknames = async (
184
+ scope: DiscordScope,
185
+ messages: ReadonlyArray<Message<unknown>>
186
+ ): Promise<ReadonlyMap<string, string | undefined>> => {
187
+ if (raw === undefined || scope.guildId === "@me") return new Map()
188
+ const pending = [...new Set(messages.map((message) => message.author.userId))]
189
+ const resolved = new Map<string, string | undefined>()
190
+ const workers = Array.from({ length: Math.min(nicknameResolveConcurrency, pending.length) }, async () => {
191
+ while (true) {
192
+ const userId = pending.shift()
193
+ if (userId === undefined) return
194
+ resolved.set(userId, await resolveNickname(scope, userId))
195
+ }
196
+ })
197
+ await Promise.all(workers)
198
+ return resolved
199
+ }
200
+
201
+ const fetchMessages = (scope: DiscordScope, limit: number) =>
137
202
  tryAdapter(async () => {
138
203
  const threadId = threadIdFromScope(adapter, scope)
139
204
  const result = await adapter.fetchMessages(threadId, { limit })
140
- return result.messages.map((message) => fromChatMessage(scope, message))
141
- }),
142
- sendTyping: (scope) => tryAdapter(() => adapter.startTyping(threadIdFromScope(adapter, scope))).pipe(Effect.asVoid),
143
- postMessage: (scope, content) =>
144
- tryAdapter(async () => {
145
- const result = await adapter.postMessage(threadIdFromScope(adapter, scope), normalizeMentionsForChatAdapter(content))
146
- return { id: result.id }
147
- }),
148
- editMessage: (scope, messageId, content) =>
149
- tryAdapter(() => adapter.editMessage(threadIdFromScope(adapter, scope), messageId, normalizeMentionsForChatAdapter(content))).pipe(
150
- Effect.asVoid
151
- ),
152
- deleteMessage: (scope, messageId) =>
153
- tryAdapter(() => adapter.deleteMessage(threadIdFromScope(adapter, scope), messageId)).pipe(Effect.asVoid),
154
- addReaction: (scope, messageId, emoji) =>
155
- tryAdapter(() => adapter.addReaction(threadIdFromScope(adapter, scope), messageId, emoji)).pipe(Effect.asVoid),
156
- removeReaction: (scope, messageId, emoji) =>
157
- tryAdapter(() => adapter.removeReaction(threadIdFromScope(adapter, scope), messageId, emoji)).pipe(Effect.asVoid),
158
- attachFile: (scope, path) =>
159
- tryAdapter(async () => {
160
- const file: PostableRaw = { raw: "", files: [{ filename: basename(path), data: await readFile(path) }] }
161
- const result = await adapter.postMessage(threadIdFromScope(adapter, scope), file)
162
- return { path: result.id }
163
- }),
164
- createThread: (scope, name) =>
165
- rawDiscord(raw, `/channels/${scope.channelId}/threads`, {
166
- method: "POST",
167
- body: JSON.stringify({ name, type: 11 })
168
- }).pipe(Effect.map((data) => ({ id: isRecord(data) && typeof data.id === "string" ? data.id : "" }))),
169
- pinMessage: (scope, messageId) =>
170
- rawDiscord(raw, `/channels/${scope.threadId ?? scope.channelId}/pins/${messageId}`, { method: "PUT" }).pipe(Effect.asVoid),
171
- unpinMessage: (scope, messageId) =>
172
- rawDiscord(raw, `/channels/${scope.threadId ?? scope.channelId}/pins/${messageId}`, { method: "DELETE" }).pipe(Effect.asVoid)
173
- })
205
+ const nicknames = await resolveNicknames(scope, result.messages)
206
+ return result.messages.map((message) => fromChatMessage(scope, message, nicknames.get(message.author.userId)))
207
+ })
208
+
209
+ return {
210
+ fetchContext: fetchMessages,
211
+ fetchHistory: fetchMessages,
212
+ sendTyping: (scope) => tryAdapter(() => adapter.startTyping(threadIdFromScope(adapter, scope))).pipe(Effect.asVoid),
213
+ postMessage: (scope, content) =>
214
+ tryAdapter(async () => {
215
+ const result = await adapter.postMessage(threadIdFromScope(adapter, scope), normalizeMentionsForChatAdapter(content))
216
+ return { id: result.id }
217
+ }),
218
+ editMessage: (scope, messageId, content) =>
219
+ tryAdapter(() => adapter.editMessage(threadIdFromScope(adapter, scope), messageId, normalizeMentionsForChatAdapter(content))).pipe(
220
+ Effect.asVoid
221
+ ),
222
+ deleteMessage: (scope, messageId) =>
223
+ tryAdapter(() => adapter.deleteMessage(threadIdFromScope(adapter, scope), messageId)).pipe(Effect.asVoid),
224
+ addReaction: (scope, messageId, emoji) =>
225
+ 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
+ attachFile: (scope, path) =>
229
+ tryAdapter(async () => {
230
+ const file: PostableRaw = { raw: "", files: [{ filename: basename(path), data: await readFile(path) }] }
231
+ const result = await adapter.postMessage(threadIdFromScope(adapter, scope), file)
232
+ return { path: result.id }
233
+ }),
234
+ createThread: (scope, name) =>
235
+ rawDiscord(raw, `/channels/${scope.channelId}/threads`, {
236
+ method: "POST",
237
+ 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)
243
+ }
244
+ }
174
245
 
175
246
  export const makeLiveChatSdkDiscord = (options: LiveDiscordOptions): DiscordService =>
176
247
  makeChatSdkDiscord(
@@ -31,9 +31,9 @@ describe("context assembly", () => {
31
31
  })
32
32
  )
33
33
 
34
- expect(rendered).toContain("[Nick 1 | <@u-1> | 2026-06-05 14:03 UTC]")
34
+ expect(rendered).toContain("[Nick 1 | <@u-1> | 2026-06-05 14:03 UTC | messageId=1]")
35
35
  expect(rendered).toContain("Can you refactor this?")
36
- expect(rendered).toContain("(discord target: guildId=g1 channelId=c1 messageId=1)")
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])")
38
38
  expect(rendered).toContain("(reactions: thumbs_up x2, tada x1)")
39
39
  })
@@ -54,8 +54,28 @@ describe("context assembly", () => {
54
54
  expect(prompt.text).toContain("Plain assistant text is streamed to Discord automatically")
55
55
  expect(prompt.text).toContain("do not use bridge tools to send messages")
56
56
  expect(prompt.text).toContain("<@id> pings that user in Discord")
57
- expect(prompt.text).toContain("Use discord target metadata when calling non-message bridge tools")
57
+ expect(prompt.text).toContain("combine the discord default scope or message target override with the header messageId")
58
58
  expect(prompt.text).toContain("Do not emit @everyone, @here, or role pings")
59
+ 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]")
61
+ expect(prompt.text).not.toContain("(discord target: guildId=g1 channelId=c1 messageId=3)")
62
+ })
63
+
64
+ test("renders a target override when a message scope differs from the default", () => {
65
+ const trigger = makeMessage("2", "trigger")
66
+ const prompt = assembleContextPrompt({
67
+ botUserId: "999",
68
+ contextMessages: [makeMessage("1", "different channel", { channelId: "c2", threadId: "t2" })],
69
+ triggerMessage: trigger,
70
+ maxMessages: 30,
71
+ maxChars: 10_000,
72
+ maxAttachmentBytes: 10_000
73
+ })
74
+
75
+ 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]")
77
+ expect(prompt.text).toContain("(discord target: guildId=g1 channelId=c2 threadId=t2)")
78
+ expect(prompt.text).not.toContain("(discord target: guildId=g1 channelId=c1)")
59
79
  })
60
80
 
61
81
  test("applies top-N and character budgets without dropping the trigger", () => {
@@ -1,5 +1,5 @@
1
1
  import type { OpencodePromptFilePart } from "../Opencode/OpencodePort.ts"
2
- import type { DiscordMessage } from "../Schema.ts"
2
+ import type { DiscordMessage, DiscordScope } from "../Schema.ts"
3
3
 
4
4
  export type ContextPrompt = {
5
5
  readonly text: string
@@ -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. Use discord target metadata when calling non-message 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. 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)
@@ -42,11 +42,24 @@ const reactionSummary = (message: DiscordMessage): string | undefined => {
42
42
  return `(reactions: ${message.reactions.map((item) => `${item.emoji} x${item.count}`).join(", ")})`
43
43
  }
44
44
 
45
- const targetSummary = (message: DiscordMessage): string => {
46
- const thread = message.threadId === undefined ? "" : ` threadId=${message.threadId}`
47
- return `(discord target: guildId=${message.guildId} channelId=${message.channelId}${thread} messageId=${message.id})`
45
+ const scopeOf = (message: DiscordMessage): DiscordScope => ({
46
+ guildId: message.guildId,
47
+ channelId: message.channelId,
48
+ ...(message.threadId === undefined ? {} : { threadId: message.threadId })
49
+ })
50
+
51
+ const sameScope = (left: DiscordScope, right: DiscordScope): boolean =>
52
+ left.guildId === right.guildId && left.channelId === right.channelId && left.threadId === right.threadId
53
+
54
+ const scopeSummary = (scope: DiscordScope): string => {
55
+ const thread = scope.threadId === undefined ? "" : ` threadId=${scope.threadId}`
56
+ return `guildId=${scope.guildId} channelId=${scope.channelId}${thread}`
48
57
  }
49
58
 
59
+ const defaultScopeSummary = (scope: DiscordScope): string => `(discord default scope: ${scopeSummary(scope)})`
60
+
61
+ const targetSummary = (scope: DiscordScope): string => `(discord target: ${scopeSummary(scope)})`
62
+
50
63
  const isForwardableMime = (mime: string): boolean =>
51
64
  mime.startsWith("image/") || mime === "application/pdf" || mime.startsWith("audio/") || mime.startsWith("text/")
52
65
 
@@ -59,10 +72,11 @@ const attachmentParts = (messages: ReadonlyArray<DiscordMessage>, maxBytes: numb
59
72
  })
60
73
  )
61
74
 
62
- export const formatDiscordMessage = (message: DiscordMessage): string => {
75
+ export const formatDiscordMessage = (message: DiscordMessage, defaultScope?: DiscordScope): string => {
63
76
  const label = message.author.nickname ?? message.author.displayName
64
- const lines = [`[${label} | <@${message.author.id}> | ${timestamp(message.timestamp)}]`, message.content]
65
- lines.push(targetSummary(message))
77
+ const scope = scopeOf(message)
78
+ const lines = [`[${label} | <@${message.author.id}> | ${timestamp(message.timestamp)} | messageId=${message.id}]`, message.content]
79
+ if (defaultScope === undefined || !sameScope(scope, defaultScope)) lines.push(targetSummary(scope))
66
80
  const attachments = attachmentSummary(message)
67
81
  const reactions = reactionSummary(message)
68
82
  if (attachments !== undefined) lines.push(attachments)
@@ -99,9 +113,14 @@ const renderPrompt = (
99
113
  skippedMessages: ReadonlyArray<DiscordMessage>
100
114
  ): string => {
101
115
  const skippedIds = new Set(skippedMessages.map((message) => message.id))
116
+ const latestMessage = messages.at(-1)
117
+ const defaultScope = latestMessage === undefined ? undefined : scopeOf(latestMessage)
102
118
  return [
103
119
  preamble(botUserId),
104
- ...messages.map((message) => `${skippedIds.has(message.id) ? "(queued intermediate message)\n" : ""}${formatDiscordMessage(message)}`)
120
+ ...(defaultScope === undefined ? [] : [defaultScopeSummary(defaultScope)]),
121
+ ...messages.map(
122
+ (message) => `${skippedIds.has(message.id) ? "(queued intermediate message)\n" : ""}${formatDiscordMessage(message, defaultScope)}`
123
+ )
105
124
  ].join("\n\n")
106
125
  }
107
126