opencode-discord-bot 0.0.8 → 0.0.10
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
|
@@ -99,7 +99,7 @@ describe("makeChatSdkDiscord", () => {
|
|
|
99
99
|
guildId: "g1",
|
|
100
100
|
channelId: "c1",
|
|
101
101
|
threadId: "t1",
|
|
102
|
-
author: { id: "u1", displayName: "Alice",
|
|
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
|
|
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:
|
|
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
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
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
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
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
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
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,7 +31,7 @@ describe("context assembly", () => {
|
|
|
31
31
|
})
|
|
32
32
|
)
|
|
33
33
|
|
|
34
|
-
expect(rendered).toContain("[Nick 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 |
|
|
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 |
|
|
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
|
-
|
|
76
|
-
|
|
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 = [`[${
|
|
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) =>
|
|
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
|
}
|