@hybridaione/hybridclaw 0.2.1 → 0.2.2
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/CHANGELOG.md +23 -0
- package/README.md +47 -15
- package/container/package-lock.json +2 -2
- package/container/package.json +1 -1
- package/container/src/browser-tools.ts +1 -1
- package/container/src/index.ts +243 -14
- package/container/src/token-usage.ts +18 -2
- package/container/src/tools.ts +339 -1
- package/container/src/types.ts +28 -2
- package/dist/agent.d.ts +2 -2
- package/dist/agent.d.ts.map +1 -1
- package/dist/agent.js +2 -2
- package/dist/agent.js.map +1 -1
- package/dist/channels/discord/attachments.d.ts +9 -0
- package/dist/channels/discord/attachments.d.ts.map +1 -0
- package/dist/channels/discord/attachments.js +245 -0
- package/dist/channels/discord/attachments.js.map +1 -0
- package/dist/channels/discord/delivery.d.ts +31 -0
- package/dist/channels/discord/delivery.d.ts.map +1 -0
- package/dist/channels/discord/delivery.js +60 -0
- package/dist/channels/discord/delivery.js.map +1 -0
- package/dist/channels/discord/inbound.d.ts +20 -0
- package/dist/channels/discord/inbound.d.ts.map +1 -0
- package/dist/channels/discord/inbound.js +44 -0
- package/dist/channels/discord/inbound.js.map +1 -0
- package/dist/channels/discord/mentions.d.ts +14 -0
- package/dist/channels/discord/mentions.d.ts.map +1 -0
- package/dist/channels/discord/mentions.js +118 -0
- package/dist/channels/discord/mentions.js.map +1 -0
- package/dist/channels/discord/runtime.d.ts +22 -0
- package/dist/channels/discord/runtime.d.ts.map +1 -0
- package/dist/channels/discord/runtime.js +972 -0
- package/dist/channels/discord/runtime.js.map +1 -0
- package/dist/channels/discord/stream.d.ts +32 -0
- package/dist/channels/discord/stream.d.ts.map +1 -0
- package/dist/channels/discord/stream.js +196 -0
- package/dist/channels/discord/stream.js.map +1 -0
- package/dist/channels/discord/tool-actions.d.ts +31 -0
- package/dist/channels/discord/tool-actions.d.ts.map +1 -0
- package/dist/channels/discord/tool-actions.js +268 -0
- package/dist/channels/discord/tool-actions.js.map +1 -0
- package/dist/container-runner.d.ts +2 -2
- package/dist/container-runner.d.ts.map +1 -1
- package/dist/container-runner.js +12 -2
- package/dist/container-runner.js.map +1 -1
- package/dist/discord.basic.test.d.ts +2 -0
- package/dist/discord.basic.test.d.ts.map +1 -0
- package/dist/discord.basic.test.js +38 -0
- package/dist/discord.basic.test.js.map +1 -0
- package/dist/discord.d.ts +5 -44
- package/dist/discord.d.ts.map +1 -1
- package/dist/discord.js +3 -1468
- package/dist/discord.js.map +1 -1
- package/dist/gateway-service.d.ts +7 -1
- package/dist/gateway-service.d.ts.map +1 -1
- package/dist/gateway-service.js +111 -2
- package/dist/gateway-service.js.map +1 -1
- package/dist/gateway-service.media-routing.test.d.ts +2 -0
- package/dist/gateway-service.media-routing.test.d.ts.map +1 -0
- package/dist/gateway-service.media-routing.test.js +29 -0
- package/dist/gateway-service.media-routing.test.js.map +1 -0
- package/dist/gateway-types.d.ts +8 -0
- package/dist/gateway-types.d.ts.map +1 -1
- package/dist/gateway-types.js.map +1 -1
- package/dist/gateway.js +5 -2
- package/dist/gateway.js.map +1 -1
- package/dist/health.d.ts.map +1 -1
- package/dist/health.js +1 -1
- package/dist/health.js.map +1 -1
- package/dist/heartbeat.d.ts.map +1 -1
- package/dist/heartbeat.js +2 -0
- package/dist/heartbeat.js.map +1 -1
- package/dist/token-efficiency.basic.test.d.ts +2 -0
- package/dist/token-efficiency.basic.test.d.ts.map +1 -0
- package/dist/token-efficiency.basic.test.js +29 -0
- package/dist/token-efficiency.basic.test.js.map +1 -0
- package/dist/token-efficiency.d.ts.map +1 -1
- package/dist/token-efficiency.js +18 -1
- package/dist/token-efficiency.js.map +1 -1
- package/dist/types.d.ts +23 -1
- package/dist/types.d.ts.map +1 -1
- package/package.json +10 -2
- package/src/agent.ts +11 -1
- package/src/channels/discord/attachments.ts +282 -0
- package/src/channels/discord/delivery.ts +99 -0
- package/src/channels/discord/inbound.ts +72 -0
- package/src/channels/discord/mentions.ts +130 -0
- package/src/{discord.ts → channels/discord/runtime.ts} +77 -615
- package/src/{discord-stream.ts → channels/discord/stream.ts} +2 -2
- package/src/channels/discord/tool-actions.ts +332 -0
- package/src/container-runner.ts +24 -1
- package/src/gateway-service.ts +125 -1
- package/src/gateway-types.ts +8 -0
- package/src/gateway.ts +5 -5
- package/src/health.ts +2 -1
- package/src/heartbeat.ts +2 -0
- package/src/token-efficiency.ts +17 -1
- package/src/types.ts +27 -1
- package/tests/discord.basic.test.ts +43 -0
- package/tests/gateway-service.media-routing.test.ts +33 -0
- package/tests/token-efficiency.basic.test.ts +32 -0
- package/vitest.e2e.config.ts +15 -0
- package/vitest.integration.config.ts +15 -0
- package/vitest.live.config.ts +16 -0
- package/vitest.unit.config.ts +15 -0
package/dist/discord.js
CHANGED
|
@@ -1,1469 +1,4 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
import { DiscordStreamManager } from './discord-stream.js';
|
|
5
|
-
import { logger } from './logger.js';
|
|
6
|
-
let client;
|
|
7
|
-
let messageHandler;
|
|
8
|
-
let commandHandler;
|
|
9
|
-
let activeConversationRuns = 0;
|
|
10
|
-
let botMentionRegex = null;
|
|
11
|
-
const MESSAGE_DEBOUNCE_MS = 2_500;
|
|
12
|
-
const MAX_ATTACHMENT_BYTES = 10 * 1024 * 1024;
|
|
13
|
-
const MAX_ATTACHMENT_CONTEXT_CHARS = 16_000;
|
|
14
|
-
const MAX_SINGLE_ATTACHMENT_CHARS = 8_000;
|
|
15
|
-
const DISCORD_RETRY_MAX_ATTEMPTS = 3;
|
|
16
|
-
const DISCORD_RETRY_BASE_DELAY_MS = 500;
|
|
17
|
-
const GUILD_INBOUND_HISTORY_LIMIT = 20;
|
|
18
|
-
const GUILD_INBOUND_HISTORY_MAX_CHARS = 6_000;
|
|
19
|
-
const PARTICIPANT_CONTEXT_MAX_USERS = 30;
|
|
20
|
-
const PARTICIPANT_MEMORY_MAX_CHANNELS = 200;
|
|
21
|
-
const PARTICIPANT_MEMORY_MAX_USERS_PER_CHANNEL = 200;
|
|
22
|
-
const PARTICIPANT_MEMORY_MAX_ALIASES_PER_USER = 8;
|
|
23
|
-
const MENTION_ALIAS_LOOKUP_MAX = 8;
|
|
24
|
-
const MAX_PRESENCE_CACHE_USERS = 5_000;
|
|
25
|
-
const discordPresenceCache = new Map();
|
|
26
|
-
function setDiscordPresence(userId, data) {
|
|
27
|
-
discordPresenceCache.set(userId, data);
|
|
28
|
-
if (discordPresenceCache.size > MAX_PRESENCE_CACHE_USERS) {
|
|
29
|
-
const oldestUserId = discordPresenceCache.keys().next().value;
|
|
30
|
-
if (oldestUserId) {
|
|
31
|
-
discordPresenceCache.delete(oldestUserId);
|
|
32
|
-
}
|
|
33
|
-
}
|
|
34
|
-
}
|
|
35
|
-
function getDiscordPresence(userId) {
|
|
36
|
-
return discordPresenceCache.get(userId);
|
|
37
|
-
}
|
|
38
|
-
function normalizeMentionAlias(raw) {
|
|
39
|
-
if (!raw)
|
|
40
|
-
return '';
|
|
41
|
-
const trimmed = raw.trim().replace(/^@+/, '');
|
|
42
|
-
if (!trimmed)
|
|
43
|
-
return '';
|
|
44
|
-
const lowered = trimmed.toLowerCase();
|
|
45
|
-
if (lowered === 'everyone' || lowered === 'here')
|
|
46
|
-
return '';
|
|
47
|
-
if (!/^[\p{L}\p{N}._-]{2,32}$/u.test(trimmed))
|
|
48
|
-
return '';
|
|
49
|
-
return lowered;
|
|
50
|
-
}
|
|
51
|
-
function addMentionAlias(lookup, rawAlias, userId) {
|
|
52
|
-
const alias = normalizeMentionAlias(rawAlias);
|
|
53
|
-
if (!alias)
|
|
54
|
-
return;
|
|
55
|
-
let ids = lookup.byAlias.get(alias);
|
|
56
|
-
if (!ids) {
|
|
57
|
-
ids = new Set();
|
|
58
|
-
lookup.byAlias.set(alias, ids);
|
|
59
|
-
}
|
|
60
|
-
ids.add(userId);
|
|
61
|
-
}
|
|
62
|
-
function extractMentionAliasHints(text) {
|
|
63
|
-
if (!text)
|
|
64
|
-
return [];
|
|
65
|
-
const hints = new Map();
|
|
66
|
-
const collect = (rawAlias, rawUserId) => {
|
|
67
|
-
const userId = (rawUserId || '').trim();
|
|
68
|
-
if (!/^\d{16,22}$/.test(userId))
|
|
69
|
-
return;
|
|
70
|
-
const alias = normalizeMentionAlias(rawAlias);
|
|
71
|
-
if (!alias)
|
|
72
|
-
return;
|
|
73
|
-
const key = `${alias}:${userId}`;
|
|
74
|
-
if (!hints.has(key))
|
|
75
|
-
hints.set(key, { alias, userId });
|
|
76
|
-
};
|
|
77
|
-
const aliasToId = /(^|[\s,;:.!?])@?([\p{L}\p{N}._-]{2,32})\s*(?:ist|is|=|->|=>|means|heißt)\s*(?:<@!?(\d{16,22})>|(\d{16,22}))/giu;
|
|
78
|
-
let match;
|
|
79
|
-
while ((match = aliasToId.exec(text)) !== null) {
|
|
80
|
-
collect(match[2], match[3] || match[4]);
|
|
81
|
-
}
|
|
82
|
-
const idToAlias = /(?:<@!?(\d{16,22})>|(\d{16,22}))\s*(?:ist|is|=|->|=>|means|heißt)\s*@?([\p{L}\p{N}._-]{2,32})/giu;
|
|
83
|
-
while ((match = idToAlias.exec(text)) !== null) {
|
|
84
|
-
collect(match[3], match[1] || match[2]);
|
|
85
|
-
}
|
|
86
|
-
return Array.from(hints.values());
|
|
87
|
-
}
|
|
88
|
-
function buildMentionLookup(messages, pendingHistory, rememberedParticipants) {
|
|
89
|
-
const lookup = { byAlias: new Map() };
|
|
90
|
-
const botUserId = client.user?.id || '';
|
|
91
|
-
const addUser = (userId, aliases) => {
|
|
92
|
-
if (!userId || userId === botUserId)
|
|
93
|
-
return;
|
|
94
|
-
for (const alias of aliases) {
|
|
95
|
-
addMentionAlias(lookup, alias, userId);
|
|
96
|
-
}
|
|
97
|
-
};
|
|
98
|
-
for (const msg of messages) {
|
|
99
|
-
const authorAliases = [msg.author?.username];
|
|
100
|
-
if (msg.member?.displayName)
|
|
101
|
-
authorAliases.push(msg.member.displayName);
|
|
102
|
-
addUser(msg.author.id, authorAliases);
|
|
103
|
-
for (const mentioned of msg.mentions.users.values()) {
|
|
104
|
-
const aliases = [mentioned.username];
|
|
105
|
-
const mentionedMember = msg.mentions.members?.get(mentioned.id);
|
|
106
|
-
if (mentionedMember?.displayName)
|
|
107
|
-
aliases.push(mentionedMember.displayName);
|
|
108
|
-
addUser(mentioned.id, aliases);
|
|
109
|
-
}
|
|
110
|
-
for (const hint of extractMentionAliasHints(msg.content || '')) {
|
|
111
|
-
addMentionAlias(lookup, hint.alias, hint.userId);
|
|
112
|
-
}
|
|
113
|
-
}
|
|
114
|
-
for (const entry of pendingHistory) {
|
|
115
|
-
addUser(entry.userId, [entry.username, entry.displayName]);
|
|
116
|
-
for (const hint of extractMentionAliasHints(entry.content)) {
|
|
117
|
-
addMentionAlias(lookup, hint.alias, hint.userId);
|
|
118
|
-
}
|
|
119
|
-
}
|
|
120
|
-
if (rememberedParticipants) {
|
|
121
|
-
for (const [userId, aliases] of rememberedParticipants) {
|
|
122
|
-
addUser(userId, Array.from(aliases));
|
|
123
|
-
}
|
|
124
|
-
}
|
|
125
|
-
return lookup;
|
|
126
|
-
}
|
|
127
|
-
export function rewriteUserMentions(text, lookup) {
|
|
128
|
-
if (!text)
|
|
129
|
-
return text;
|
|
130
|
-
if (!lookup.byAlias.size)
|
|
131
|
-
return text;
|
|
132
|
-
return text.replace(/(^|[\s([{:>])@([\p{L}\p{N}._-]{2,32})\b/gu, (full, prefix, rawAlias) => {
|
|
133
|
-
const alias = normalizeMentionAlias(rawAlias);
|
|
134
|
-
if (!alias)
|
|
135
|
-
return full;
|
|
136
|
-
const ids = lookup.byAlias.get(alias);
|
|
137
|
-
if (!ids || ids.size !== 1)
|
|
138
|
-
return full;
|
|
139
|
-
const [id] = Array.from(ids);
|
|
140
|
-
if (!id)
|
|
141
|
-
return full;
|
|
142
|
-
return `${prefix}<@${id}>`;
|
|
143
|
-
});
|
|
144
|
-
}
|
|
145
|
-
function extractMentionAliases(text) {
|
|
146
|
-
if (!text)
|
|
147
|
-
return [];
|
|
148
|
-
const aliases = new Set();
|
|
149
|
-
const re = /(^|[\s([{:>])@([\p{L}\p{N}._-]{2,32})\b/gu;
|
|
150
|
-
let match;
|
|
151
|
-
while ((match = re.exec(text)) !== null) {
|
|
152
|
-
const alias = normalizeMentionAlias(match[2]);
|
|
153
|
-
if (!alias)
|
|
154
|
-
continue;
|
|
155
|
-
aliases.add(alias);
|
|
156
|
-
if (aliases.size >= MENTION_ALIAS_LOOKUP_MAX)
|
|
157
|
-
break;
|
|
158
|
-
}
|
|
159
|
-
return Array.from(aliases);
|
|
160
|
-
}
|
|
161
|
-
async function enrichMentionLookupFromGuild(msg, lookup, aliases) {
|
|
162
|
-
if (!msg.guild || aliases.length === 0)
|
|
163
|
-
return;
|
|
164
|
-
for (const alias of aliases) {
|
|
165
|
-
if (lookup.byAlias.has(alias))
|
|
166
|
-
continue;
|
|
167
|
-
try {
|
|
168
|
-
const members = await msg.guild.members.search({ query: alias, limit: 5 });
|
|
169
|
-
const exactMatches = Array.from(members.values()).filter((member) => {
|
|
170
|
-
const username = normalizeMentionAlias(member.user?.username || '');
|
|
171
|
-
const displayName = normalizeMentionAlias(member.displayName || '');
|
|
172
|
-
return username === alias || displayName === alias;
|
|
173
|
-
});
|
|
174
|
-
if (exactMatches.length !== 1)
|
|
175
|
-
continue;
|
|
176
|
-
const match = exactMatches[0];
|
|
177
|
-
addMentionAlias(lookup, alias, match.id);
|
|
178
|
-
addMentionAlias(lookup, match.user?.username || '', match.id);
|
|
179
|
-
addMentionAlias(lookup, match.displayName || '', match.id);
|
|
180
|
-
}
|
|
181
|
-
catch (error) {
|
|
182
|
-
logger.debug({ error, guildId: msg.guild.id, alias }, 'Failed to resolve guild member alias for mention rewrite');
|
|
183
|
-
}
|
|
184
|
-
}
|
|
185
|
-
}
|
|
186
|
-
export async function rewriteUserMentionsForMessage(text, msg, lookup) {
|
|
187
|
-
const aliases = extractMentionAliases(text);
|
|
188
|
-
if (aliases.length > 0) {
|
|
189
|
-
await enrichMentionLookupFromGuild(msg, lookup, aliases);
|
|
190
|
-
}
|
|
191
|
-
return rewriteUserMentions(text, lookup);
|
|
192
|
-
}
|
|
193
|
-
function summarizePendingHistoryEntry(entry) {
|
|
194
|
-
const author = entry.displayName || entry.username || 'user';
|
|
195
|
-
const authorLabel = entry.isBot ? `${author} [bot]` : author;
|
|
196
|
-
const content = entry.content.trim();
|
|
197
|
-
const snippet = content.length > 300 ? `${content.slice(0, 297)}...` : content;
|
|
198
|
-
return `${authorLabel}: ${snippet}`;
|
|
199
|
-
}
|
|
200
|
-
function buildPendingHistoryContext(entries) {
|
|
201
|
-
if (entries.length === 0)
|
|
202
|
-
return '';
|
|
203
|
-
const selected = [];
|
|
204
|
-
let totalChars = 0;
|
|
205
|
-
for (let i = entries.length - 1; i >= 0; i -= 1) {
|
|
206
|
-
const line = summarizePendingHistoryEntry(entries[i]);
|
|
207
|
-
if (!line)
|
|
208
|
-
continue;
|
|
209
|
-
if (totalChars + line.length > GUILD_INBOUND_HISTORY_MAX_CHARS && selected.length > 0)
|
|
210
|
-
break;
|
|
211
|
-
selected.push(line);
|
|
212
|
-
totalChars += line.length + 1;
|
|
213
|
-
}
|
|
214
|
-
if (selected.length === 0)
|
|
215
|
-
return '';
|
|
216
|
-
selected.reverse();
|
|
217
|
-
return [
|
|
218
|
-
'[InboundHistory]',
|
|
219
|
-
'Recent channel messages (most recent last):',
|
|
220
|
-
...selected,
|
|
221
|
-
'',
|
|
222
|
-
'',
|
|
223
|
-
].join('\n');
|
|
224
|
-
}
|
|
225
|
-
async function buildInboundHistorySnapshot(msg, excludeMessageIds) {
|
|
226
|
-
if (!msg.guild || !('messages' in msg.channel))
|
|
227
|
-
return { entries: [], context: '' };
|
|
228
|
-
try {
|
|
229
|
-
const recentMessages = await msg.channel.messages.fetch({ limit: GUILD_INBOUND_HISTORY_LIMIT });
|
|
230
|
-
const entries = [];
|
|
231
|
-
let hiddenTextCount = 0;
|
|
232
|
-
let hiddenBotTextCount = 0;
|
|
233
|
-
const summarizeHistoryMessageContent = (recent) => {
|
|
234
|
-
const plainText = cleanIncomingContent(recent.content || '').trim();
|
|
235
|
-
if (plainText)
|
|
236
|
-
return plainText;
|
|
237
|
-
const embedChunks = recent.embeds
|
|
238
|
-
.map((embed) => [embed.title?.trim(), embed.description?.trim()].filter(Boolean).join(' — '))
|
|
239
|
-
.map((part) => part.trim())
|
|
240
|
-
.filter(Boolean)
|
|
241
|
-
.slice(0, 3);
|
|
242
|
-
if (embedChunks.length > 0) {
|
|
243
|
-
return `[embed] ${embedChunks.join(' | ')}`;
|
|
244
|
-
}
|
|
245
|
-
const attachmentNames = Array.from(recent.attachments.values())
|
|
246
|
-
.map((attachment) => attachment.name?.trim())
|
|
247
|
-
.filter((name) => Boolean(name))
|
|
248
|
-
.slice(0, 5);
|
|
249
|
-
if (attachmentNames.length > 0) {
|
|
250
|
-
return `[attachments] ${attachmentNames.join(', ')}`;
|
|
251
|
-
}
|
|
252
|
-
const systemContent = recent.system ? (recent.cleanContent || '').trim() : '';
|
|
253
|
-
if (systemContent)
|
|
254
|
-
return `[system] ${systemContent}`;
|
|
255
|
-
hiddenTextCount += 1;
|
|
256
|
-
if (recent.author?.bot)
|
|
257
|
-
hiddenBotTextCount += 1;
|
|
258
|
-
return '[no visible text]';
|
|
259
|
-
};
|
|
260
|
-
for (const recent of recentMessages.values()) {
|
|
261
|
-
if (excludeMessageIds.has(recent.id))
|
|
262
|
-
continue;
|
|
263
|
-
if (!recent.author?.id)
|
|
264
|
-
continue;
|
|
265
|
-
if (recent.author.id === client.user?.id)
|
|
266
|
-
continue;
|
|
267
|
-
const content = summarizeHistoryMessageContent(recent);
|
|
268
|
-
if (!content)
|
|
269
|
-
continue;
|
|
270
|
-
entries.push({
|
|
271
|
-
messageId: recent.id,
|
|
272
|
-
userId: recent.author.id,
|
|
273
|
-
username: recent.author.username || 'user',
|
|
274
|
-
displayName: recent.member?.displayName || null,
|
|
275
|
-
isBot: Boolean(recent.author.bot),
|
|
276
|
-
timestampMs: Number.isFinite(recent.createdTimestamp) ? recent.createdTimestamp : 0,
|
|
277
|
-
content,
|
|
278
|
-
});
|
|
279
|
-
}
|
|
280
|
-
entries.sort((a, b) => a.timestampMs - b.timestampMs || a.messageId.localeCompare(b.messageId));
|
|
281
|
-
let context = buildPendingHistoryContext(entries);
|
|
282
|
-
if (hiddenTextCount > 0) {
|
|
283
|
-
const visibilityNote = [
|
|
284
|
-
'[Discord visibility note]',
|
|
285
|
-
`${hiddenTextCount} recent message(s) had no visible text via API${hiddenBotTextCount > 0 ? ` (${hiddenBotTextCount} from bot users)` : ''}.`,
|
|
286
|
-
'If asked for exact wording of those messages, say text was not visible in this snapshot.',
|
|
287
|
-
'',
|
|
288
|
-
'',
|
|
289
|
-
].join('\n');
|
|
290
|
-
context = `${visibilityNote}${context}`;
|
|
291
|
-
}
|
|
292
|
-
return {
|
|
293
|
-
entries,
|
|
294
|
-
context,
|
|
295
|
-
};
|
|
296
|
-
}
|
|
297
|
-
catch (error) {
|
|
298
|
-
logger.debug({ error, guildId: msg.guild.id, channelId: msg.channelId }, 'Failed to build inbound channel history snapshot');
|
|
299
|
-
return { entries: [], context: '' };
|
|
300
|
-
}
|
|
301
|
-
}
|
|
302
|
-
function addParticipantAlias(info, alias) {
|
|
303
|
-
const normalized = normalizeMentionAlias(alias);
|
|
304
|
-
if (!normalized)
|
|
305
|
-
return;
|
|
306
|
-
info.aliases.add(normalized);
|
|
307
|
-
}
|
|
308
|
-
function formatDiscordHandleFromAlias(alias) {
|
|
309
|
-
const normalized = normalizeMentionAlias(alias);
|
|
310
|
-
if (!normalized)
|
|
311
|
-
return null;
|
|
312
|
-
return `@${normalized}`;
|
|
313
|
-
}
|
|
314
|
-
function buildParticipantContext(messages, pendingHistory, rememberedParticipants) {
|
|
315
|
-
const participants = new Map();
|
|
316
|
-
const botUserId = client.user?.id || '';
|
|
317
|
-
const botParticipantIds = new Set();
|
|
318
|
-
const upsert = (userId) => {
|
|
319
|
-
let info = participants.get(userId);
|
|
320
|
-
if (!info) {
|
|
321
|
-
info = { id: userId, aliases: new Set() };
|
|
322
|
-
participants.set(userId, info);
|
|
323
|
-
}
|
|
324
|
-
return info;
|
|
325
|
-
};
|
|
326
|
-
for (const msg of messages) {
|
|
327
|
-
if (!msg.author?.id || msg.author.id === botUserId)
|
|
328
|
-
continue;
|
|
329
|
-
const info = upsert(msg.author.id);
|
|
330
|
-
if (msg.author.bot) {
|
|
331
|
-
botParticipantIds.add(msg.author.id);
|
|
332
|
-
}
|
|
333
|
-
addParticipantAlias(info, msg.author.username);
|
|
334
|
-
addParticipantAlias(info, msg.member?.displayName);
|
|
335
|
-
for (const mentioned of msg.mentions.users.values()) {
|
|
336
|
-
if (!mentioned.id || mentioned.id === botUserId)
|
|
337
|
-
continue;
|
|
338
|
-
const mentionedInfo = upsert(mentioned.id);
|
|
339
|
-
if (mentioned.bot) {
|
|
340
|
-
botParticipantIds.add(mentioned.id);
|
|
341
|
-
}
|
|
342
|
-
addParticipantAlias(mentionedInfo, mentioned.username);
|
|
343
|
-
const mentionedMember = msg.mentions.members?.get(mentioned.id);
|
|
344
|
-
addParticipantAlias(mentionedInfo, mentionedMember?.displayName);
|
|
345
|
-
}
|
|
346
|
-
for (const hint of extractMentionAliasHints(msg.content || '')) {
|
|
347
|
-
if (hint.userId === botUserId)
|
|
348
|
-
continue;
|
|
349
|
-
const hintedInfo = upsert(hint.userId);
|
|
350
|
-
addParticipantAlias(hintedInfo, hint.alias);
|
|
351
|
-
}
|
|
352
|
-
}
|
|
353
|
-
for (const entry of pendingHistory) {
|
|
354
|
-
if (!entry.userId || entry.userId === botUserId)
|
|
355
|
-
continue;
|
|
356
|
-
const info = upsert(entry.userId);
|
|
357
|
-
if (entry.isBot) {
|
|
358
|
-
botParticipantIds.add(entry.userId);
|
|
359
|
-
}
|
|
360
|
-
addParticipantAlias(info, entry.username);
|
|
361
|
-
addParticipantAlias(info, entry.displayName);
|
|
362
|
-
for (const hint of extractMentionAliasHints(entry.content)) {
|
|
363
|
-
if (hint.userId === botUserId)
|
|
364
|
-
continue;
|
|
365
|
-
const hintedInfo = upsert(hint.userId);
|
|
366
|
-
addParticipantAlias(hintedInfo, hint.alias);
|
|
367
|
-
}
|
|
368
|
-
}
|
|
369
|
-
if (rememberedParticipants) {
|
|
370
|
-
for (const [userId, aliases] of rememberedParticipants) {
|
|
371
|
-
if (!userId || userId === botUserId)
|
|
372
|
-
continue;
|
|
373
|
-
const info = upsert(userId);
|
|
374
|
-
for (const alias of aliases) {
|
|
375
|
-
addParticipantAlias(info, alias);
|
|
376
|
-
}
|
|
377
|
-
}
|
|
378
|
-
}
|
|
379
|
-
if (participants.size === 0)
|
|
380
|
-
return '';
|
|
381
|
-
const lines = Array.from(participants.values())
|
|
382
|
-
.filter((entry) => entry.aliases.size > 0)
|
|
383
|
-
.sort((a, b) => a.id.localeCompare(b.id))
|
|
384
|
-
.slice(0, PARTICIPANT_CONTEXT_MAX_USERS)
|
|
385
|
-
.map((entry) => {
|
|
386
|
-
const aliases = Array.from(entry.aliases).slice(0, 3);
|
|
387
|
-
const preferredHandle = formatDiscordHandleFromAlias(aliases[0]) || `id:${entry.id}`;
|
|
388
|
-
const botSuffix = botParticipantIds.has(entry.id) ? ' [bot]' : '';
|
|
389
|
-
return `- ${preferredHandle}${botSuffix} id:${entry.id} aliases: ${aliases.join(', ')}`;
|
|
390
|
-
});
|
|
391
|
-
if (lines.length === 0)
|
|
392
|
-
return '';
|
|
393
|
-
return [
|
|
394
|
-
'[Known participants]',
|
|
395
|
-
'Use @handles from this list in normal replies.',
|
|
396
|
-
'Use raw <@id> mention syntax only when the user explicitly asks for mention IDs/tokens.',
|
|
397
|
-
'This list is derived from recent and remembered context; it may be incomplete.',
|
|
398
|
-
...lines,
|
|
399
|
-
'',
|
|
400
|
-
].join('\n');
|
|
401
|
-
}
|
|
402
|
-
/**
|
|
403
|
-
* Format an agent response as plain text.
|
|
404
|
-
* Appends a subtle tools line if any tools were used.
|
|
405
|
-
*/
|
|
406
|
-
export function buildResponseText(text, toolsUsed) {
|
|
407
|
-
let body = text;
|
|
408
|
-
if (toolsUsed && toolsUsed.length > 0) {
|
|
409
|
-
const toolsLine = `\n*Tools: ${toolsUsed.join(', ')}*`;
|
|
410
|
-
body = `${text}${toolsLine}`;
|
|
411
|
-
}
|
|
412
|
-
return body;
|
|
413
|
-
}
|
|
414
|
-
export function formatInfo(title, body) {
|
|
415
|
-
return `**${title}**\n${body}`;
|
|
416
|
-
}
|
|
417
|
-
export function formatError(title, detail) {
|
|
418
|
-
return `**${title}:** ${detail}`;
|
|
419
|
-
}
|
|
420
|
-
function requireDiscordClientReady() {
|
|
421
|
-
if (!client) {
|
|
422
|
-
throw new Error('Discord client is not initialized.');
|
|
423
|
-
}
|
|
424
|
-
if (!client.isReady()) {
|
|
425
|
-
throw new Error('Discord client is not ready yet.');
|
|
426
|
-
}
|
|
427
|
-
return client;
|
|
428
|
-
}
|
|
429
|
-
function sanitizeDiscordId(rawValue, label) {
|
|
430
|
-
const value = (rawValue || '').trim();
|
|
431
|
-
if (!/^\d{16,22}$/.test(value)) {
|
|
432
|
-
throw new Error(`${label} must be a Discord snowflake id.`);
|
|
433
|
-
}
|
|
434
|
-
return value;
|
|
435
|
-
}
|
|
436
|
-
function normalizeDiscordUserLookupQuery(rawValue) {
|
|
437
|
-
const trimmed = (rawValue || '').trim();
|
|
438
|
-
if (!trimmed)
|
|
439
|
-
return '';
|
|
440
|
-
const mentionMatch = trimmed.match(/^<@!?(\d{16,22})>$/);
|
|
441
|
-
if (mentionMatch)
|
|
442
|
-
return mentionMatch[1];
|
|
443
|
-
const prefixedId = trimmed.match(/^(?:user:|discord:)?(\d{16,22})$/i);
|
|
444
|
-
if (prefixedId)
|
|
445
|
-
return prefixedId[1];
|
|
446
|
-
return trimmed.replace(/^@+/, '').trim();
|
|
447
|
-
}
|
|
448
|
-
function scoreGuildMemberForLookup(member, query) {
|
|
449
|
-
const q = query.toLowerCase();
|
|
450
|
-
const username = member.user.username?.toLowerCase() || '';
|
|
451
|
-
const globalName = member.user.globalName?.toLowerCase() || '';
|
|
452
|
-
const nickname = member.nickname?.toLowerCase() || '';
|
|
453
|
-
const displayName = member.displayName?.toLowerCase() || '';
|
|
454
|
-
const candidates = [username, globalName, nickname, displayName].filter(Boolean);
|
|
455
|
-
let score = 0;
|
|
456
|
-
if (candidates.some((value) => value === q))
|
|
457
|
-
score += 3;
|
|
458
|
-
if (candidates.some((value) => value.includes(q)))
|
|
459
|
-
score += 1;
|
|
460
|
-
if (!member.user.bot)
|
|
461
|
-
score += 1;
|
|
462
|
-
return score;
|
|
463
|
-
}
|
|
464
|
-
async function resolveGuildMemberIdFromLookup(params) {
|
|
465
|
-
const activeClient = requireDiscordClientReady();
|
|
466
|
-
const guildId = sanitizeDiscordId(params.guildId, 'guildId');
|
|
467
|
-
const normalized = normalizeDiscordUserLookupQuery(params.rawUser);
|
|
468
|
-
if (!normalized) {
|
|
469
|
-
throw new Error('userId or username is required.');
|
|
470
|
-
}
|
|
471
|
-
if (/^\d{16,22}$/.test(normalized)) {
|
|
472
|
-
return { userId: normalized };
|
|
473
|
-
}
|
|
474
|
-
const guild = await activeClient.guilds.fetch(guildId);
|
|
475
|
-
const searchQuery = normalized.slice(0, 32);
|
|
476
|
-
if (!searchQuery) {
|
|
477
|
-
throw new Error('username query is empty after normalization.');
|
|
478
|
-
}
|
|
479
|
-
let members;
|
|
480
|
-
try {
|
|
481
|
-
members = await guild.members.search({ query: searchQuery, limit: 25 });
|
|
482
|
-
}
|
|
483
|
-
catch {
|
|
484
|
-
const fetched = await guild.members.fetch({ query: searchQuery, limit: 25 });
|
|
485
|
-
members = fetched;
|
|
486
|
-
}
|
|
487
|
-
let best = null;
|
|
488
|
-
let bestScore = 0;
|
|
489
|
-
let matchCount = 0;
|
|
490
|
-
for (const member of members.values()) {
|
|
491
|
-
const score = scoreGuildMemberForLookup(member, searchQuery);
|
|
492
|
-
if (score <= 0)
|
|
493
|
-
continue;
|
|
494
|
-
matchCount += 1;
|
|
495
|
-
if (!best || score > bestScore) {
|
|
496
|
-
best = member;
|
|
497
|
-
bestScore = score;
|
|
498
|
-
}
|
|
499
|
-
}
|
|
500
|
-
if (!best) {
|
|
501
|
-
throw new Error(`No guild member matched username "${searchQuery}".`);
|
|
502
|
-
}
|
|
503
|
-
return {
|
|
504
|
-
userId: best.id,
|
|
505
|
-
note: matchCount > 1 ? 'multiple matches; chose best' : undefined,
|
|
506
|
-
};
|
|
507
|
-
}
|
|
508
|
-
function normalizeDate(value) {
|
|
509
|
-
if (!value)
|
|
510
|
-
return null;
|
|
511
|
-
const ms = value.getTime();
|
|
512
|
-
if (!Number.isFinite(ms))
|
|
513
|
-
return null;
|
|
514
|
-
return value.toISOString();
|
|
515
|
-
}
|
|
516
|
-
async function runDiscordReadAction(request) {
|
|
517
|
-
const activeClient = requireDiscordClientReady();
|
|
518
|
-
const channelId = sanitizeDiscordId(request.channelId, 'channelId');
|
|
519
|
-
const channel = await activeClient.channels.fetch(channelId);
|
|
520
|
-
if (!channel || !('messages' in channel)) {
|
|
521
|
-
throw new Error('Channel does not support message reads.');
|
|
522
|
-
}
|
|
523
|
-
const requestedLimit = typeof request.limit === 'number' && Number.isFinite(request.limit)
|
|
524
|
-
? Math.floor(request.limit)
|
|
525
|
-
: 20;
|
|
526
|
-
const limit = Math.max(1, Math.min(100, requestedLimit));
|
|
527
|
-
const before = request.before?.trim();
|
|
528
|
-
const after = request.after?.trim();
|
|
529
|
-
const around = request.around?.trim();
|
|
530
|
-
const query = { limit };
|
|
531
|
-
if (before)
|
|
532
|
-
query.before = before;
|
|
533
|
-
if (after)
|
|
534
|
-
query.after = after;
|
|
535
|
-
if (around)
|
|
536
|
-
query.around = around;
|
|
537
|
-
const fetched = await channel.messages.fetch(query);
|
|
538
|
-
const messages = Array.from(fetched.values())
|
|
539
|
-
.sort((a, b) => a.createdTimestamp - b.createdTimestamp || a.id.localeCompare(b.id))
|
|
540
|
-
.map((message) => ({
|
|
541
|
-
id: message.id,
|
|
542
|
-
channelId: message.channelId,
|
|
543
|
-
guildId: message.guildId ?? null,
|
|
544
|
-
content: message.content || '',
|
|
545
|
-
createdAt: new Date(message.createdTimestamp).toISOString(),
|
|
546
|
-
editedAt: normalizeDate(message.editedAt),
|
|
547
|
-
author: {
|
|
548
|
-
id: message.author?.id || 'unknown',
|
|
549
|
-
username: message.author?.username || 'unknown',
|
|
550
|
-
handle: message.author?.username ? `@${message.author.username}` : null,
|
|
551
|
-
globalName: message.author?.globalName || null,
|
|
552
|
-
bot: Boolean(message.author?.bot),
|
|
553
|
-
},
|
|
554
|
-
member: message.member
|
|
555
|
-
? {
|
|
556
|
-
id: message.member.id,
|
|
557
|
-
nickname: message.member.nickname || null,
|
|
558
|
-
displayName: message.member.displayName || null,
|
|
559
|
-
}
|
|
560
|
-
: null,
|
|
561
|
-
attachments: Array.from(message.attachments.values()).map((attachment) => ({
|
|
562
|
-
id: attachment.id,
|
|
563
|
-
name: attachment.name || null,
|
|
564
|
-
url: attachment.url,
|
|
565
|
-
contentType: attachment.contentType || null,
|
|
566
|
-
size: attachment.size,
|
|
567
|
-
})),
|
|
568
|
-
mentions: {
|
|
569
|
-
users: Array.from(message.mentions.users.values()).map((user) => ({
|
|
570
|
-
id: user.id,
|
|
571
|
-
username: user.username,
|
|
572
|
-
bot: Boolean(user.bot),
|
|
573
|
-
})),
|
|
574
|
-
roles: Array.from(message.mentions.roles.values()).map((role) => ({
|
|
575
|
-
id: role.id,
|
|
576
|
-
name: role.name,
|
|
577
|
-
})),
|
|
578
|
-
channels: Array.from(message.mentions.channels.values()).map((mentionedChannel) => ({
|
|
579
|
-
id: mentionedChannel.id,
|
|
580
|
-
name: 'name' in mentionedChannel && typeof mentionedChannel.name === 'string'
|
|
581
|
-
? mentionedChannel.name
|
|
582
|
-
: null,
|
|
583
|
-
})),
|
|
584
|
-
},
|
|
585
|
-
}));
|
|
586
|
-
return {
|
|
587
|
-
ok: true,
|
|
588
|
-
action: 'read',
|
|
589
|
-
channelId,
|
|
590
|
-
count: messages.length,
|
|
591
|
-
messages,
|
|
592
|
-
};
|
|
593
|
-
}
|
|
594
|
-
async function runDiscordMemberInfoAction(request) {
|
|
595
|
-
const activeClient = requireDiscordClientReady();
|
|
596
|
-
const guildId = sanitizeDiscordId(request.guildId, 'guildId');
|
|
597
|
-
const userLookupRaw = request.userId
|
|
598
|
-
|| request.memberId
|
|
599
|
-
|| request.user
|
|
600
|
-
|| request.username;
|
|
601
|
-
const resolvedUser = await resolveGuildMemberIdFromLookup({
|
|
602
|
-
guildId,
|
|
603
|
-
rawUser: userLookupRaw || '',
|
|
604
|
-
});
|
|
605
|
-
const userId = sanitizeDiscordId(resolvedUser.userId, 'userId');
|
|
606
|
-
const guild = await activeClient.guilds.fetch(guildId);
|
|
607
|
-
const member = await guild.members.fetch(userId);
|
|
608
|
-
const presence = getDiscordPresence(userId);
|
|
609
|
-
const roles = member.roles.cache
|
|
610
|
-
.filter((role) => role.id !== guild.id)
|
|
611
|
-
.map((role) => ({
|
|
612
|
-
id: role.id,
|
|
613
|
-
name: role.name,
|
|
614
|
-
color: role.hexColor,
|
|
615
|
-
position: role.position,
|
|
616
|
-
}))
|
|
617
|
-
.sort((a, b) => b.position - a.position || a.name.localeCompare(b.name));
|
|
618
|
-
return {
|
|
619
|
-
ok: true,
|
|
620
|
-
action: 'member-info',
|
|
621
|
-
guildId,
|
|
622
|
-
userId,
|
|
623
|
-
...(resolvedUser.note ? { note: resolvedUser.note } : {}),
|
|
624
|
-
member: {
|
|
625
|
-
id: member.id,
|
|
626
|
-
username: member.user.username,
|
|
627
|
-
handle: member.user.username ? `@${member.user.username}` : null,
|
|
628
|
-
globalName: member.user.globalName || null,
|
|
629
|
-
bot: Boolean(member.user.bot),
|
|
630
|
-
displayName: member.displayName,
|
|
631
|
-
nickname: member.nickname || null,
|
|
632
|
-
joinedAt: normalizeDate(member.joinedAt),
|
|
633
|
-
premiumSince: normalizeDate(member.premiumSince),
|
|
634
|
-
communicationDisabledUntil: normalizeDate(member.communicationDisabledUntil),
|
|
635
|
-
roles,
|
|
636
|
-
},
|
|
637
|
-
...(presence
|
|
638
|
-
? {
|
|
639
|
-
status: presence.status,
|
|
640
|
-
activities: presence.activities,
|
|
641
|
-
}
|
|
642
|
-
: {}),
|
|
643
|
-
};
|
|
644
|
-
}
|
|
645
|
-
async function runDiscordChannelInfoAction(request) {
|
|
646
|
-
const activeClient = requireDiscordClientReady();
|
|
647
|
-
const channelId = sanitizeDiscordId(request.channelId, 'channelId');
|
|
648
|
-
const channel = await activeClient.channels.fetch(channelId);
|
|
649
|
-
if (!channel) {
|
|
650
|
-
throw new Error('Channel not found.');
|
|
651
|
-
}
|
|
652
|
-
const channelData = {
|
|
653
|
-
id: channel.id,
|
|
654
|
-
type: channel.type,
|
|
655
|
-
guildId: 'guildId' in channel ? channel.guildId || null : null,
|
|
656
|
-
name: 'name' in channel && typeof channel.name === 'string' ? channel.name : null,
|
|
657
|
-
parentId: 'parentId' in channel ? channel.parentId || null : null,
|
|
658
|
-
topic: 'topic' in channel && typeof channel.topic === 'string' ? channel.topic : null,
|
|
659
|
-
nsfw: 'nsfw' in channel && typeof channel.nsfw === 'boolean' ? channel.nsfw : null,
|
|
660
|
-
rateLimitPerUser: 'rateLimitPerUser' in channel && typeof channel.rateLimitPerUser === 'number'
|
|
661
|
-
? channel.rateLimitPerUser
|
|
662
|
-
: null,
|
|
663
|
-
isTextBased: typeof channel.isTextBased === 'function' ? channel.isTextBased() : false,
|
|
664
|
-
isDMBased: typeof channel.isDMBased === 'function' ? channel.isDMBased() : false,
|
|
665
|
-
isThread: typeof channel.isThread === 'function' ? channel.isThread() : false,
|
|
666
|
-
lastMessageId: 'lastMessageId' in channel ? channel.lastMessageId || null : null,
|
|
667
|
-
};
|
|
668
|
-
if (typeof channel.isThread === 'function' && channel.isThread()) {
|
|
669
|
-
channelData.archived =
|
|
670
|
-
'archived' in channel && typeof channel.archived === 'boolean' ? channel.archived : null;
|
|
671
|
-
channelData.locked =
|
|
672
|
-
'locked' in channel && typeof channel.locked === 'boolean' ? channel.locked : null;
|
|
673
|
-
channelData.ownerId = 'ownerId' in channel ? channel.ownerId || null : null;
|
|
674
|
-
}
|
|
675
|
-
return {
|
|
676
|
-
ok: true,
|
|
677
|
-
action: 'channel-info',
|
|
678
|
-
channel: channelData,
|
|
679
|
-
};
|
|
680
|
-
}
|
|
681
|
-
export async function runDiscordToolAction(request) {
|
|
682
|
-
switch (request.action) {
|
|
683
|
-
case 'read':
|
|
684
|
-
return await runDiscordReadAction(request);
|
|
685
|
-
case 'member-info':
|
|
686
|
-
return await runDiscordMemberInfoAction(request);
|
|
687
|
-
case 'channel-info':
|
|
688
|
-
return await runDiscordChannelInfoAction(request);
|
|
689
|
-
default:
|
|
690
|
-
throw new Error(`Unsupported Discord action: ${request.action}`);
|
|
691
|
-
}
|
|
692
|
-
}
|
|
693
|
-
function getSessionId(msg) {
|
|
694
|
-
return buildSessionIdFromContext(msg.guild?.id ?? null, msg.channelId, msg.author.id);
|
|
695
|
-
}
|
|
696
|
-
function stripBotMentions(text) {
|
|
697
|
-
if (!botMentionRegex)
|
|
698
|
-
return text;
|
|
699
|
-
return text.replace(botMentionRegex, '').trim();
|
|
700
|
-
}
|
|
701
|
-
function hasPrefixInvocation(content) {
|
|
702
|
-
const text = stripBotMentions(content);
|
|
703
|
-
return text.startsWith(DISCORD_PREFIX);
|
|
704
|
-
}
|
|
705
|
-
function isAuthorizedCommandUserId(userId) {
|
|
706
|
-
const configuredUserId = DISCORD_COMMAND_USER_ID.trim();
|
|
707
|
-
if (!configuredUserId)
|
|
708
|
-
return true;
|
|
709
|
-
return userId === configuredUserId;
|
|
710
|
-
}
|
|
711
|
-
function buildSessionIdFromContext(guildId, channelId, userId) {
|
|
712
|
-
return guildId ? `${guildId}:${channelId}` : `dm:${userId}`;
|
|
713
|
-
}
|
|
714
|
-
function isTrigger(msg) {
|
|
715
|
-
if (DISCORD_COMMANDS_ONLY)
|
|
716
|
-
return hasPrefixInvocation(msg.content);
|
|
717
|
-
if (!msg.guild)
|
|
718
|
-
return true;
|
|
719
|
-
if (DISCORD_RESPOND_TO_ALL_MESSAGES)
|
|
720
|
-
return true;
|
|
721
|
-
if (client.user && msg.mentions.has(client.user))
|
|
722
|
-
return true;
|
|
723
|
-
if (msg.content.startsWith(DISCORD_PREFIX))
|
|
724
|
-
return true;
|
|
725
|
-
return false;
|
|
726
|
-
}
|
|
727
|
-
function parseCommand(content) {
|
|
728
|
-
let text = stripBotMentions(content);
|
|
729
|
-
if (text.startsWith(DISCORD_PREFIX)) {
|
|
730
|
-
text = text.slice(DISCORD_PREFIX.length).trim();
|
|
731
|
-
}
|
|
732
|
-
const parts = text.split(/\s+/);
|
|
733
|
-
const subcommands = ['bot', 'rag', 'model', 'sessions', 'audit', 'schedule', 'clear', 'help'];
|
|
734
|
-
if (parts.length > 0 && subcommands.includes(parts[0].toLowerCase())) {
|
|
735
|
-
return { isCommand: true, command: parts[0].toLowerCase(), args: parts.slice(1) };
|
|
736
|
-
}
|
|
737
|
-
return { isCommand: false, command: '', args: [] };
|
|
738
|
-
}
|
|
739
|
-
function isRetryableDiscordError(error) {
|
|
740
|
-
const maybe = error;
|
|
741
|
-
const status = maybe.status ?? maybe.httpStatus;
|
|
742
|
-
return status === 429 || (typeof status === 'number' && status >= 500 && status <= 599);
|
|
743
|
-
}
|
|
744
|
-
function retryDelayMs(error, fallbackMs) {
|
|
745
|
-
const maybe = error;
|
|
746
|
-
const retryAfterSeconds = maybe.retryAfter ?? maybe.data?.retry_after;
|
|
747
|
-
if (typeof retryAfterSeconds === 'number' && Number.isFinite(retryAfterSeconds) && retryAfterSeconds > 0) {
|
|
748
|
-
return Math.max(50, Math.ceil(retryAfterSeconds * 1_000));
|
|
749
|
-
}
|
|
750
|
-
return fallbackMs + Math.floor(Math.random() * 250);
|
|
751
|
-
}
|
|
752
|
-
async function withDiscordRetry(label, fn) {
|
|
753
|
-
let attempt = 0;
|
|
754
|
-
let delayMs = DISCORD_RETRY_BASE_DELAY_MS;
|
|
755
|
-
while (true) {
|
|
756
|
-
attempt += 1;
|
|
757
|
-
try {
|
|
758
|
-
return await fn();
|
|
759
|
-
}
|
|
760
|
-
catch (error) {
|
|
761
|
-
if (attempt >= DISCORD_RETRY_MAX_ATTEMPTS || !isRetryableDiscordError(error)) {
|
|
762
|
-
throw error;
|
|
763
|
-
}
|
|
764
|
-
const waitMs = retryDelayMs(error, delayMs);
|
|
765
|
-
logger.warn({ label, attempt, waitMs, error }, 'Discord API call failed; retrying');
|
|
766
|
-
await new Promise((resolve) => setTimeout(resolve, waitMs));
|
|
767
|
-
delayMs = Math.min(delayMs * 2, 4_000);
|
|
768
|
-
}
|
|
769
|
-
}
|
|
770
|
-
}
|
|
771
|
-
function cleanIncomingContent(content) {
|
|
772
|
-
let text = stripBotMentions(content);
|
|
773
|
-
if (text.startsWith(DISCORD_PREFIX)) {
|
|
774
|
-
text = text.slice(DISCORD_PREFIX.length).trim();
|
|
775
|
-
}
|
|
776
|
-
return text;
|
|
777
|
-
}
|
|
778
|
-
function summarizeContextMessage(msg) {
|
|
779
|
-
const author = msg.author?.username || 'user';
|
|
780
|
-
const content = (msg.content || '').trim();
|
|
781
|
-
const snippet = content.length > 500 ? `${content.slice(0, 497)}...` : content;
|
|
782
|
-
return `${author}: ${snippet || '(no text)'}`;
|
|
783
|
-
}
|
|
784
|
-
function buildChannelInfoContext(msg) {
|
|
785
|
-
if (!msg.guild)
|
|
786
|
-
return '';
|
|
787
|
-
const lines = [
|
|
788
|
-
'[Channel info]',
|
|
789
|
-
`- guild_id: ${msg.guild.id}`,
|
|
790
|
-
`- channel_id: ${msg.channelId}`,
|
|
791
|
-
];
|
|
792
|
-
const namedChannel = msg.channel;
|
|
793
|
-
const channelName = typeof namedChannel.name === 'string' ? namedChannel.name.trim() : '';
|
|
794
|
-
if (channelName) {
|
|
795
|
-
lines.push(`- channel_name: #${channelName}`);
|
|
796
|
-
}
|
|
797
|
-
const channelTopic = typeof namedChannel.topic === 'string' ? namedChannel.topic.trim() : '';
|
|
798
|
-
if (channelTopic) {
|
|
799
|
-
lines.push(`- channel_topic: ${channelTopic}`);
|
|
800
|
-
}
|
|
801
|
-
const parentName = typeof namedChannel.parent?.name === 'string' ? namedChannel.parent.name.trim() : '';
|
|
802
|
-
if (parentName) {
|
|
803
|
-
lines.push(`- parent_channel: ${parentName}`);
|
|
804
|
-
}
|
|
805
|
-
lines.push('');
|
|
806
|
-
return `${lines.join('\n')}\n`;
|
|
807
|
-
}
|
|
808
|
-
function looksLikeTextAttachment(name, contentType) {
|
|
809
|
-
if (contentType.startsWith('text/'))
|
|
810
|
-
return true;
|
|
811
|
-
if (contentType.includes('json') || contentType.includes('xml') || contentType.includes('yaml'))
|
|
812
|
-
return true;
|
|
813
|
-
return /\.(txt|md|markdown|json|ya?ml|js|jsx|ts|tsx|py|rb|go|rs|java|c|cpp|h|hpp|cs|php|html?|css|scss|sql|log|csv)$/i.test(name);
|
|
814
|
-
}
|
|
815
|
-
async function fetchAttachmentText(url, maxChars) {
|
|
816
|
-
try {
|
|
817
|
-
const response = await fetch(url);
|
|
818
|
-
if (!response.ok)
|
|
819
|
-
return null;
|
|
820
|
-
const text = await response.text();
|
|
821
|
-
if (!text)
|
|
822
|
-
return null;
|
|
823
|
-
if (text.length <= maxChars)
|
|
824
|
-
return text;
|
|
825
|
-
return `${text.slice(0, Math.max(1_000, maxChars - 32))}\n...[truncated]`;
|
|
826
|
-
}
|
|
827
|
-
catch {
|
|
828
|
-
return null;
|
|
829
|
-
}
|
|
830
|
-
}
|
|
831
|
-
async function buildReplyContext(msg) {
|
|
832
|
-
const blocks = [];
|
|
833
|
-
if ('isThread' in msg.channel && typeof msg.channel.isThread === 'function' && msg.channel.isThread()) {
|
|
834
|
-
try {
|
|
835
|
-
const starter = await msg.channel.fetchStarterMessage();
|
|
836
|
-
if (starter) {
|
|
837
|
-
blocks.push(`[Thread starter]\n${summarizeContextMessage(starter)}`);
|
|
838
|
-
}
|
|
839
|
-
}
|
|
840
|
-
catch (error) {
|
|
841
|
-
logger.debug({ error, channelId: msg.channelId }, 'Failed to fetch thread starter message');
|
|
842
|
-
}
|
|
843
|
-
}
|
|
844
|
-
const replyLines = [];
|
|
845
|
-
let replyId = msg.reference?.messageId || null;
|
|
846
|
-
let depth = 0;
|
|
847
|
-
while (replyId && depth < 5) {
|
|
848
|
-
try {
|
|
849
|
-
const referenced = await msg.channel.messages.fetch(replyId);
|
|
850
|
-
replyLines.push(summarizeContextMessage(referenced));
|
|
851
|
-
replyId = referenced.reference?.messageId || null;
|
|
852
|
-
depth += 1;
|
|
853
|
-
}
|
|
854
|
-
catch {
|
|
855
|
-
break;
|
|
856
|
-
}
|
|
857
|
-
}
|
|
858
|
-
if (replyLines.length > 0) {
|
|
859
|
-
blocks.push(`[Reply context]\n${replyLines.reverse().join('\n')}`);
|
|
860
|
-
}
|
|
861
|
-
if (blocks.length === 0)
|
|
862
|
-
return '';
|
|
863
|
-
return `${blocks.join('\n\n')}\n\n`;
|
|
864
|
-
}
|
|
865
|
-
async function buildAttachmentContext(messages) {
|
|
866
|
-
const lines = [];
|
|
867
|
-
let remainingChars = MAX_ATTACHMENT_CONTEXT_CHARS;
|
|
868
|
-
for (const msg of messages) {
|
|
869
|
-
if (!msg.attachments || msg.attachments.size === 0)
|
|
870
|
-
continue;
|
|
871
|
-
for (const attachment of msg.attachments.values()) {
|
|
872
|
-
const name = attachment.name || 'unnamed';
|
|
873
|
-
const size = attachment.size || 0;
|
|
874
|
-
const contentType = (attachment.contentType || '').toLowerCase();
|
|
875
|
-
if (size > MAX_ATTACHMENT_BYTES) {
|
|
876
|
-
lines.push(`- ${name}: skipped (size ${size} bytes exceeds 10MB limit)`);
|
|
877
|
-
continue;
|
|
878
|
-
}
|
|
879
|
-
if (contentType.startsWith('image/')) {
|
|
880
|
-
lines.push(`- ${name}: image attachment (${size} bytes, ${contentType || 'unknown type'})`);
|
|
881
|
-
continue;
|
|
882
|
-
}
|
|
883
|
-
if (looksLikeTextAttachment(name, contentType)) {
|
|
884
|
-
const maxChars = Math.min(MAX_SINGLE_ATTACHMENT_CHARS, Math.max(500, remainingChars));
|
|
885
|
-
const text = await fetchAttachmentText(attachment.url, maxChars);
|
|
886
|
-
if (!text) {
|
|
887
|
-
lines.push(`- ${name}: text attachment (failed to read content)`);
|
|
888
|
-
continue;
|
|
889
|
-
}
|
|
890
|
-
const block = `- ${name} (text attachment):\n\`\`\`\n${text}\n\`\`\``;
|
|
891
|
-
remainingChars -= block.length;
|
|
892
|
-
lines.push(block);
|
|
893
|
-
if (remainingChars <= 0) {
|
|
894
|
-
lines.push('- Additional attachment content omitted (context budget reached).');
|
|
895
|
-
return `[Attachments]\n${lines.join('\n')}\n\n`;
|
|
896
|
-
}
|
|
897
|
-
continue;
|
|
898
|
-
}
|
|
899
|
-
lines.push(`- ${name}: attachment (${size} bytes, ${contentType || 'unknown type'})`);
|
|
900
|
-
}
|
|
901
|
-
}
|
|
902
|
-
if (lines.length === 0)
|
|
903
|
-
return '';
|
|
904
|
-
return `[Attachments]\n${lines.join('\n')}\n\n`;
|
|
905
|
-
}
|
|
906
|
-
async function addProcessingReaction(msg) {
|
|
907
|
-
if (!client.user)
|
|
908
|
-
return async () => { };
|
|
909
|
-
const botUserId = client.user.id;
|
|
910
|
-
try {
|
|
911
|
-
await withDiscordRetry('react', () => msg.react('👀'));
|
|
912
|
-
}
|
|
913
|
-
catch (error) {
|
|
914
|
-
logger.debug({ error, channelId: msg.channelId, messageId: msg.id }, 'Failed to add processing reaction');
|
|
915
|
-
return async () => { };
|
|
916
|
-
}
|
|
917
|
-
return async () => {
|
|
918
|
-
try {
|
|
919
|
-
const reaction = msg.reactions.resolve('👀');
|
|
920
|
-
if (!reaction)
|
|
921
|
-
return;
|
|
922
|
-
await withDiscordRetry('reaction-remove', () => reaction.users.remove(botUserId));
|
|
923
|
-
}
|
|
924
|
-
catch (error) {
|
|
925
|
-
logger.debug({ error, channelId: msg.channelId, messageId: msg.id }, 'Failed to remove processing reaction');
|
|
926
|
-
}
|
|
927
|
-
};
|
|
928
|
-
}
|
|
929
|
-
function startTypingLoop(msg) {
|
|
930
|
-
let stopped = false;
|
|
931
|
-
const sendTyping = async () => {
|
|
932
|
-
if (stopped)
|
|
933
|
-
return;
|
|
934
|
-
if (!('sendTyping' in msg.channel))
|
|
935
|
-
return;
|
|
936
|
-
try {
|
|
937
|
-
await msg.channel.sendTyping();
|
|
938
|
-
}
|
|
939
|
-
catch (error) {
|
|
940
|
-
logger.debug({ error, channelId: msg.channelId }, 'Failed to send typing indicator');
|
|
941
|
-
}
|
|
942
|
-
};
|
|
943
|
-
void sendTyping();
|
|
944
|
-
const timer = setInterval(() => {
|
|
945
|
-
void sendTyping();
|
|
946
|
-
}, 8_000);
|
|
947
|
-
return {
|
|
948
|
-
stop: () => {
|
|
949
|
-
if (stopped)
|
|
950
|
-
return;
|
|
951
|
-
stopped = true;
|
|
952
|
-
clearInterval(timer);
|
|
953
|
-
},
|
|
954
|
-
};
|
|
955
|
-
}
|
|
956
|
-
function prepareChunkedPayloads(text, files, mentionLookup) {
|
|
957
|
-
const prepared = mentionLookup ? rewriteUserMentions(text, mentionLookup) : text;
|
|
958
|
-
const chunks = chunkMessage(prepared, { maxChars: 1_900, maxLines: 20 });
|
|
959
|
-
const safeChunks = chunks.length > 0 ? chunks : ['(no content)'];
|
|
960
|
-
return safeChunks.map((content, i) => ({
|
|
961
|
-
content,
|
|
962
|
-
...(i === safeChunks.length - 1 && files && files.length > 0 ? { files } : {}),
|
|
963
|
-
}));
|
|
964
|
-
}
|
|
965
|
-
async function sendChunkedReply(msg, text, files, mentionLookup) {
|
|
966
|
-
const payloads = prepareChunkedPayloads(text, files, mentionLookup);
|
|
967
|
-
for (let i = 0; i < payloads.length; i += 1) {
|
|
968
|
-
if (i === 0) {
|
|
969
|
-
await withDiscordRetry('reply', () => msg.reply(payloads[i]));
|
|
970
|
-
}
|
|
971
|
-
else {
|
|
972
|
-
await withDiscordRetry('send', () => msg.channel.send(payloads[i]));
|
|
973
|
-
}
|
|
974
|
-
}
|
|
975
|
-
}
|
|
976
|
-
async function sendChunkedDirectReply(msg, text, files, mentionLookup) {
|
|
977
|
-
const payloads = prepareChunkedPayloads(text, files, mentionLookup);
|
|
978
|
-
const dm = await withDiscordRetry('dm-open', () => msg.author.createDM());
|
|
979
|
-
for (const payload of payloads) {
|
|
980
|
-
await withDiscordRetry('dm-send', () => dm.send(payload));
|
|
981
|
-
}
|
|
982
|
-
}
|
|
983
|
-
async function sendChunkedInteractionReply(interaction, text, files) {
|
|
984
|
-
const payloads = prepareChunkedPayloads(text, files);
|
|
985
|
-
for (let i = 0; i < payloads.length; i += 1) {
|
|
986
|
-
const payload = { ...payloads[i], ephemeral: true };
|
|
987
|
-
if (i === 0) {
|
|
988
|
-
if (interaction.replied || interaction.deferred) {
|
|
989
|
-
await withDiscordRetry('interaction-followup', () => interaction.followUp(payload));
|
|
990
|
-
}
|
|
991
|
-
else {
|
|
992
|
-
await withDiscordRetry('interaction-reply', () => interaction.reply(payload));
|
|
993
|
-
}
|
|
994
|
-
continue;
|
|
995
|
-
}
|
|
996
|
-
await withDiscordRetry('interaction-followup', () => interaction.followUp(payload));
|
|
997
|
-
}
|
|
998
|
-
}
|
|
999
|
-
async function ensureSlashStatusCommand() {
|
|
1000
|
-
const definition = {
|
|
1001
|
-
name: 'status',
|
|
1002
|
-
description: 'Show HybridClaw runtime status (only visible to you)',
|
|
1003
|
-
};
|
|
1004
|
-
if (!client.application)
|
|
1005
|
-
return;
|
|
1006
|
-
await Promise.allSettled([...client.guilds.cache.values()].map(async (guild) => {
|
|
1007
|
-
try {
|
|
1008
|
-
const existing = await guild.commands.fetch();
|
|
1009
|
-
const current = existing.find((command) => command.name === definition.name);
|
|
1010
|
-
if (!current) {
|
|
1011
|
-
await guild.commands.create(definition);
|
|
1012
|
-
logger.info({ guildId: guild.id }, 'Registered slash command /status');
|
|
1013
|
-
return;
|
|
1014
|
-
}
|
|
1015
|
-
if (current.description !== definition.description) {
|
|
1016
|
-
await guild.commands.edit(current.id, definition);
|
|
1017
|
-
logger.info({ guildId: guild.id }, 'Updated slash command /status');
|
|
1018
|
-
}
|
|
1019
|
-
}
|
|
1020
|
-
catch (error) {
|
|
1021
|
-
logger.warn({ error, guildId: guild.id }, 'Failed to register slash command /status');
|
|
1022
|
-
}
|
|
1023
|
-
}));
|
|
1024
|
-
}
|
|
1025
|
-
function updatePresence() {
|
|
1026
|
-
if (!client.user)
|
|
1027
|
-
return;
|
|
1028
|
-
if (activeConversationRuns > 0) {
|
|
1029
|
-
client.user.setPresence({
|
|
1030
|
-
activities: [{ name: 'Thinking...', type: ActivityType.Playing }],
|
|
1031
|
-
status: 'online',
|
|
1032
|
-
});
|
|
1033
|
-
return;
|
|
1034
|
-
}
|
|
1035
|
-
client.user.setPresence({
|
|
1036
|
-
activities: [{ name: `in ${client.guilds.cache.size} servers`, type: ActivityType.Listening }],
|
|
1037
|
-
status: 'online',
|
|
1038
|
-
});
|
|
1039
|
-
}
|
|
1040
|
-
export function initDiscord(onMessage, onCommand) {
|
|
1041
|
-
messageHandler = onMessage;
|
|
1042
|
-
commandHandler = onCommand;
|
|
1043
|
-
const pendingBatches = new Map();
|
|
1044
|
-
const inFlightByMessageId = new Map();
|
|
1045
|
-
const negativeFeedbackByChannel = new Map();
|
|
1046
|
-
const participantMemoryByChannel = new Map();
|
|
1047
|
-
const touchParticipantMemoryChannel = (channelId) => {
|
|
1048
|
-
const existing = participantMemoryByChannel.get(channelId);
|
|
1049
|
-
if (existing) {
|
|
1050
|
-
participantMemoryByChannel.delete(channelId);
|
|
1051
|
-
participantMemoryByChannel.set(channelId, existing);
|
|
1052
|
-
return existing;
|
|
1053
|
-
}
|
|
1054
|
-
const created = new Map();
|
|
1055
|
-
participantMemoryByChannel.set(channelId, created);
|
|
1056
|
-
while (participantMemoryByChannel.size > PARTICIPANT_MEMORY_MAX_CHANNELS) {
|
|
1057
|
-
const oldestKey = participantMemoryByChannel.keys().next().value;
|
|
1058
|
-
if (!oldestKey)
|
|
1059
|
-
break;
|
|
1060
|
-
participantMemoryByChannel.delete(oldestKey);
|
|
1061
|
-
}
|
|
1062
|
-
return created;
|
|
1063
|
-
};
|
|
1064
|
-
const rememberParticipantAliasForChannel = (channelId, userId, rawAlias) => {
|
|
1065
|
-
if (!userId || userId === client.user?.id)
|
|
1066
|
-
return;
|
|
1067
|
-
const alias = normalizeMentionAlias(rawAlias);
|
|
1068
|
-
if (!alias)
|
|
1069
|
-
return;
|
|
1070
|
-
const channelMemory = touchParticipantMemoryChannel(channelId);
|
|
1071
|
-
let aliases = channelMemory.get(userId);
|
|
1072
|
-
if (!aliases) {
|
|
1073
|
-
aliases = new Set();
|
|
1074
|
-
channelMemory.set(userId, aliases);
|
|
1075
|
-
while (channelMemory.size > PARTICIPANT_MEMORY_MAX_USERS_PER_CHANNEL) {
|
|
1076
|
-
const oldestUserId = channelMemory.keys().next().value;
|
|
1077
|
-
if (!oldestUserId)
|
|
1078
|
-
break;
|
|
1079
|
-
channelMemory.delete(oldestUserId);
|
|
1080
|
-
}
|
|
1081
|
-
}
|
|
1082
|
-
aliases.add(alias);
|
|
1083
|
-
if (aliases.size > PARTICIPANT_MEMORY_MAX_ALIASES_PER_USER) {
|
|
1084
|
-
const kept = new Set(Array.from(aliases).slice(-PARTICIPANT_MEMORY_MAX_ALIASES_PER_USER));
|
|
1085
|
-
channelMemory.set(userId, kept);
|
|
1086
|
-
}
|
|
1087
|
-
// Refresh user recency.
|
|
1088
|
-
const refreshed = channelMemory.get(userId);
|
|
1089
|
-
if (refreshed) {
|
|
1090
|
-
channelMemory.delete(userId);
|
|
1091
|
-
channelMemory.set(userId, refreshed);
|
|
1092
|
-
}
|
|
1093
|
-
};
|
|
1094
|
-
const rememberParticipantForChannel = (channelId, userId, aliases) => {
|
|
1095
|
-
if (!userId || userId === client.user?.id)
|
|
1096
|
-
return;
|
|
1097
|
-
for (const alias of aliases) {
|
|
1098
|
-
rememberParticipantAliasForChannel(channelId, userId, alias);
|
|
1099
|
-
}
|
|
1100
|
-
};
|
|
1101
|
-
const observeMessageParticipants = (msg, content) => {
|
|
1102
|
-
if (!msg.guild)
|
|
1103
|
-
return;
|
|
1104
|
-
rememberParticipantForChannel(msg.channelId, msg.author.id, [
|
|
1105
|
-
msg.author.username,
|
|
1106
|
-
msg.member?.displayName,
|
|
1107
|
-
]);
|
|
1108
|
-
for (const mentioned of msg.mentions.users.values()) {
|
|
1109
|
-
const mentionedMember = msg.mentions.members?.get(mentioned.id);
|
|
1110
|
-
rememberParticipantForChannel(msg.channelId, mentioned.id, [
|
|
1111
|
-
mentioned.username,
|
|
1112
|
-
mentionedMember?.displayName,
|
|
1113
|
-
]);
|
|
1114
|
-
}
|
|
1115
|
-
for (const hint of extractMentionAliasHints(content)) {
|
|
1116
|
-
rememberParticipantAliasForChannel(msg.channelId, hint.userId, hint.alias);
|
|
1117
|
-
}
|
|
1118
|
-
};
|
|
1119
|
-
const intents = [
|
|
1120
|
-
GatewayIntentBits.Guilds,
|
|
1121
|
-
GatewayIntentBits.GuildMessages,
|
|
1122
|
-
GatewayIntentBits.GuildMessageReactions,
|
|
1123
|
-
GatewayIntentBits.MessageContent,
|
|
1124
|
-
GatewayIntentBits.DirectMessages,
|
|
1125
|
-
];
|
|
1126
|
-
if (DISCORD_GUILD_MEMBERS_INTENT)
|
|
1127
|
-
intents.push(GatewayIntentBits.GuildMembers);
|
|
1128
|
-
if (DISCORD_PRESENCE_INTENT)
|
|
1129
|
-
intents.push(GatewayIntentBits.GuildPresences);
|
|
1130
|
-
client = new Client({
|
|
1131
|
-
intents,
|
|
1132
|
-
partials: [Partials.Channel, Partials.Message, Partials.Reaction, Partials.User],
|
|
1133
|
-
});
|
|
1134
|
-
client.on('presenceUpdate', (_oldPresence, nextPresence) => {
|
|
1135
|
-
const userId = nextPresence.userId || nextPresence.user?.id;
|
|
1136
|
-
if (!userId)
|
|
1137
|
-
return;
|
|
1138
|
-
setDiscordPresence(userId, {
|
|
1139
|
-
status: nextPresence.status,
|
|
1140
|
-
activities: nextPresence.activities.map((activity) => ({
|
|
1141
|
-
type: activity.type,
|
|
1142
|
-
name: activity.name,
|
|
1143
|
-
state: activity.state || null,
|
|
1144
|
-
details: activity.details || null,
|
|
1145
|
-
})),
|
|
1146
|
-
});
|
|
1147
|
-
});
|
|
1148
|
-
client.on('clientReady', () => {
|
|
1149
|
-
logger.info({ user: client.user?.tag }, 'Discord bot connected');
|
|
1150
|
-
if (client.user) {
|
|
1151
|
-
botMentionRegex = new RegExp(`<@!?${client.user.id}>`, 'g');
|
|
1152
|
-
}
|
|
1153
|
-
updatePresence();
|
|
1154
|
-
void ensureSlashStatusCommand();
|
|
1155
|
-
});
|
|
1156
|
-
client.on('interactionCreate', async (interaction) => {
|
|
1157
|
-
if (!interaction.isChatInputCommand())
|
|
1158
|
-
return;
|
|
1159
|
-
if (interaction.commandName !== 'status')
|
|
1160
|
-
return;
|
|
1161
|
-
if (!isAuthorizedCommandUserId(interaction.user.id)) {
|
|
1162
|
-
await sendChunkedInteractionReply(interaction, 'You are not authorized to run commands for this bot.');
|
|
1163
|
-
return;
|
|
1164
|
-
}
|
|
1165
|
-
const guildId = interaction.guildId ?? null;
|
|
1166
|
-
const channelId = interaction.channelId;
|
|
1167
|
-
const sessionId = buildSessionIdFromContext(guildId, channelId, interaction.user.id);
|
|
1168
|
-
try {
|
|
1169
|
-
await commandHandler(sessionId, guildId, channelId, ['status'], async (text, files) => sendChunkedInteractionReply(interaction, text, files));
|
|
1170
|
-
}
|
|
1171
|
-
catch (error) {
|
|
1172
|
-
const detail = error instanceof Error ? error.message : String(error);
|
|
1173
|
-
logger.error({ error, guildId, channelId, userId: interaction.user.id }, 'Discord slash /status command failed');
|
|
1174
|
-
await sendChunkedInteractionReply(interaction, formatError('Gateway Error', detail));
|
|
1175
|
-
}
|
|
1176
|
-
});
|
|
1177
|
-
const dispatchConversationBatch = async (batchKey) => {
|
|
1178
|
-
const pending = pendingBatches.get(batchKey);
|
|
1179
|
-
if (!pending)
|
|
1180
|
-
return;
|
|
1181
|
-
pendingBatches.delete(batchKey);
|
|
1182
|
-
const items = pending.items;
|
|
1183
|
-
if (items.length === 0)
|
|
1184
|
-
return;
|
|
1185
|
-
const sourceItem = items[items.length - 1];
|
|
1186
|
-
const msg = sourceItem.msg;
|
|
1187
|
-
const sessionId = getSessionId(msg);
|
|
1188
|
-
const guildId = msg.guild?.id || null;
|
|
1189
|
-
const channelId = msg.channelId;
|
|
1190
|
-
const userId = msg.author.id;
|
|
1191
|
-
const username = msg.author.username;
|
|
1192
|
-
const batchedContent = items.length > 1
|
|
1193
|
-
? items.map((item, index) => `Message ${index + 1}:\n${item.content}`).join('\n\n')
|
|
1194
|
-
: sourceItem.content;
|
|
1195
|
-
const channelInfoContext = buildChannelInfoContext(msg);
|
|
1196
|
-
const replyContext = await buildReplyContext(msg);
|
|
1197
|
-
const feedbackNote = negativeFeedbackByChannel.get(channelId) || '';
|
|
1198
|
-
if (feedbackNote) {
|
|
1199
|
-
negativeFeedbackByChannel.delete(channelId);
|
|
1200
|
-
}
|
|
1201
|
-
const currentBatchMessageIds = new Set(items.map((item) => item.msg.id));
|
|
1202
|
-
const inboundHistory = await buildInboundHistorySnapshot(msg, currentBatchMessageIds);
|
|
1203
|
-
const attachmentContext = await buildAttachmentContext(items.map((item) => item.msg));
|
|
1204
|
-
const rememberedParticipants = participantMemoryByChannel.get(msg.channelId);
|
|
1205
|
-
const participantContext = buildParticipantContext(items.map((item) => item.msg), inboundHistory.entries, rememberedParticipants);
|
|
1206
|
-
const mentionLookup = buildMentionLookup(items.map((item) => item.msg), inboundHistory.entries, rememberedParticipants);
|
|
1207
|
-
const combinedContent = `${feedbackNote ? `[Reaction feedback]\n${feedbackNote}\n\n` : ''}${channelInfoContext}${replyContext}${inboundHistory.context}${attachmentContext}${participantContext}${batchedContent}`;
|
|
1208
|
-
const abortController = new AbortController();
|
|
1209
|
-
const typingLoop = startTypingLoop(msg);
|
|
1210
|
-
const stream = new DiscordStreamManager(msg, {
|
|
1211
|
-
onFirstMessage: () => typingLoop.stop(),
|
|
1212
|
-
});
|
|
1213
|
-
const inFlight = {
|
|
1214
|
-
abortController,
|
|
1215
|
-
stream,
|
|
1216
|
-
messageIds: new Set(items.map((item) => item.msg.id)),
|
|
1217
|
-
aborted: false,
|
|
1218
|
-
};
|
|
1219
|
-
for (const messageId of inFlight.messageIds) {
|
|
1220
|
-
inFlightByMessageId.set(messageId, inFlight);
|
|
1221
|
-
}
|
|
1222
|
-
try {
|
|
1223
|
-
activeConversationRuns += 1;
|
|
1224
|
-
updatePresence();
|
|
1225
|
-
await messageHandler(sessionId, guildId, channelId, userId, username, combinedContent, async (text, files) => {
|
|
1226
|
-
typingLoop.stop();
|
|
1227
|
-
await sendChunkedReply(msg, text, files, mentionLookup);
|
|
1228
|
-
}, {
|
|
1229
|
-
sourceMessage: msg,
|
|
1230
|
-
batchedMessages: items.map((item) => item.msg),
|
|
1231
|
-
abortSignal: abortController.signal,
|
|
1232
|
-
stream,
|
|
1233
|
-
mentionLookup,
|
|
1234
|
-
});
|
|
1235
|
-
}
|
|
1236
|
-
catch (error) {
|
|
1237
|
-
logger.error({ error, channelId, sessionId }, 'Conversation batch handling failed');
|
|
1238
|
-
const detail = error instanceof Error ? error.message : String(error);
|
|
1239
|
-
if (stream.hasSentMessages()) {
|
|
1240
|
-
await stream.fail(formatError('Gateway Error', detail));
|
|
1241
|
-
}
|
|
1242
|
-
else {
|
|
1243
|
-
await sendChunkedReply(msg, formatError('Gateway Error', detail), undefined, mentionLookup);
|
|
1244
|
-
}
|
|
1245
|
-
}
|
|
1246
|
-
finally {
|
|
1247
|
-
activeConversationRuns = Math.max(0, activeConversationRuns - 1);
|
|
1248
|
-
updatePresence();
|
|
1249
|
-
for (const messageId of inFlight.messageIds) {
|
|
1250
|
-
if (inFlightByMessageId.get(messageId) === inFlight) {
|
|
1251
|
-
inFlightByMessageId.delete(messageId);
|
|
1252
|
-
}
|
|
1253
|
-
}
|
|
1254
|
-
typingLoop.stop();
|
|
1255
|
-
await Promise.all(items.map(async (item) => {
|
|
1256
|
-
await item.clearReaction();
|
|
1257
|
-
}));
|
|
1258
|
-
}
|
|
1259
|
-
};
|
|
1260
|
-
const queueConversationMessage = async (msg, content) => {
|
|
1261
|
-
const key = `${msg.channelId}:${msg.author.id}`;
|
|
1262
|
-
const clearReaction = await addProcessingReaction(msg);
|
|
1263
|
-
const queued = { msg, content, clearReaction };
|
|
1264
|
-
const existing = pendingBatches.get(key);
|
|
1265
|
-
if (!existing) {
|
|
1266
|
-
const timer = setTimeout(() => {
|
|
1267
|
-
void dispatchConversationBatch(key);
|
|
1268
|
-
}, MESSAGE_DEBOUNCE_MS);
|
|
1269
|
-
pendingBatches.set(key, {
|
|
1270
|
-
items: [queued],
|
|
1271
|
-
timer,
|
|
1272
|
-
});
|
|
1273
|
-
return;
|
|
1274
|
-
}
|
|
1275
|
-
clearTimeout(existing.timer);
|
|
1276
|
-
existing.items.push(queued);
|
|
1277
|
-
existing.timer = setTimeout(() => {
|
|
1278
|
-
void dispatchConversationBatch(key);
|
|
1279
|
-
}, MESSAGE_DEBOUNCE_MS);
|
|
1280
|
-
};
|
|
1281
|
-
const dropPendingMessage = async (messageId) => {
|
|
1282
|
-
for (const [key, pending] of pendingBatches) {
|
|
1283
|
-
const index = pending.items.findIndex((item) => item.msg.id === messageId);
|
|
1284
|
-
if (index === -1)
|
|
1285
|
-
continue;
|
|
1286
|
-
const [removed] = pending.items.splice(index, 1);
|
|
1287
|
-
await removed.clearReaction();
|
|
1288
|
-
if (pending.items.length === 0) {
|
|
1289
|
-
clearTimeout(pending.timer);
|
|
1290
|
-
pendingBatches.delete(key);
|
|
1291
|
-
}
|
|
1292
|
-
return;
|
|
1293
|
-
}
|
|
1294
|
-
};
|
|
1295
|
-
const updatePendingMessage = async (messageId, nextMsg, nextContent) => {
|
|
1296
|
-
for (const [key, pending] of pendingBatches) {
|
|
1297
|
-
const index = pending.items.findIndex((item) => item.msg.id === messageId);
|
|
1298
|
-
if (index === -1)
|
|
1299
|
-
continue;
|
|
1300
|
-
if (!nextContent) {
|
|
1301
|
-
const [removed] = pending.items.splice(index, 1);
|
|
1302
|
-
await removed.clearReaction();
|
|
1303
|
-
}
|
|
1304
|
-
else {
|
|
1305
|
-
pending.items[index].msg = nextMsg;
|
|
1306
|
-
pending.items[index].content = nextContent;
|
|
1307
|
-
}
|
|
1308
|
-
if (pending.items.length === 0) {
|
|
1309
|
-
clearTimeout(pending.timer);
|
|
1310
|
-
pendingBatches.delete(key);
|
|
1311
|
-
}
|
|
1312
|
-
return true;
|
|
1313
|
-
}
|
|
1314
|
-
return false;
|
|
1315
|
-
};
|
|
1316
|
-
client.on('messageCreate', async (msg) => {
|
|
1317
|
-
if (msg.author.bot)
|
|
1318
|
-
return;
|
|
1319
|
-
const sessionId = getSessionId(msg);
|
|
1320
|
-
const guildId = msg.guild?.id || null;
|
|
1321
|
-
const channelId = msg.channelId;
|
|
1322
|
-
const content = cleanIncomingContent(msg.content);
|
|
1323
|
-
observeMessageParticipants(msg, content);
|
|
1324
|
-
const immediateMentionLookup = buildMentionLookup([msg], [], msg.guild ? participantMemoryByChannel.get(msg.channelId) : undefined);
|
|
1325
|
-
const reply = async (text, files) => {
|
|
1326
|
-
await sendChunkedReply(msg, text, files, immediateMentionLookup);
|
|
1327
|
-
};
|
|
1328
|
-
const commandReply = async (text, files) => {
|
|
1329
|
-
try {
|
|
1330
|
-
await sendChunkedDirectReply(msg, text, files, immediateMentionLookup);
|
|
1331
|
-
}
|
|
1332
|
-
catch (error) {
|
|
1333
|
-
logger.warn({ error, userId: msg.author.id, channelId: msg.channelId }, 'Failed to send command reply via DM; command response dropped');
|
|
1334
|
-
}
|
|
1335
|
-
};
|
|
1336
|
-
const parsed = parseCommand(msg.content);
|
|
1337
|
-
const prefixedToken = hasPrefixInvocation(msg.content)
|
|
1338
|
-
? cleanIncomingContent(msg.content).split(/\s+/)[0]?.toLowerCase() || ''
|
|
1339
|
-
: '';
|
|
1340
|
-
const ignorePrefixCommand = prefixedToken === 'status';
|
|
1341
|
-
if (DISCORD_COMMANDS_ONLY) {
|
|
1342
|
-
if (!hasPrefixInvocation(msg.content))
|
|
1343
|
-
return;
|
|
1344
|
-
if (!isAuthorizedCommandUserId(msg.author.id)) {
|
|
1345
|
-
logger.debug({ userId: msg.author.id, channelId: msg.channelId }, 'Ignoring unauthorized Discord command in commands-only mode');
|
|
1346
|
-
return;
|
|
1347
|
-
}
|
|
1348
|
-
if (ignorePrefixCommand) {
|
|
1349
|
-
return;
|
|
1350
|
-
}
|
|
1351
|
-
if (!parsed.isCommand) {
|
|
1352
|
-
if (!content) {
|
|
1353
|
-
await commandReply(`How can I help? Try \`${DISCORD_PREFIX} help\`.`);
|
|
1354
|
-
}
|
|
1355
|
-
else {
|
|
1356
|
-
await commandReply(`Unknown command. Try \`${DISCORD_PREFIX} help\`.`);
|
|
1357
|
-
}
|
|
1358
|
-
return;
|
|
1359
|
-
}
|
|
1360
|
-
await commandHandler(sessionId, guildId, channelId, [parsed.command, ...parsed.args], commandReply);
|
|
1361
|
-
return;
|
|
1362
|
-
}
|
|
1363
|
-
if (!isTrigger(msg))
|
|
1364
|
-
return;
|
|
1365
|
-
if (ignorePrefixCommand) {
|
|
1366
|
-
return;
|
|
1367
|
-
}
|
|
1368
|
-
if (parsed.isCommand && hasPrefixInvocation(msg.content)) {
|
|
1369
|
-
if (!isAuthorizedCommandUserId(msg.author.id)) {
|
|
1370
|
-
logger.debug({ userId: msg.author.id, channelId: msg.channelId }, 'Ignoring unauthorized Discord command; processing as normal chat message');
|
|
1371
|
-
}
|
|
1372
|
-
else {
|
|
1373
|
-
await commandHandler(sessionId, guildId, channelId, [parsed.command, ...parsed.args], commandReply);
|
|
1374
|
-
return;
|
|
1375
|
-
}
|
|
1376
|
-
}
|
|
1377
|
-
if (!content) {
|
|
1378
|
-
await reply('How can I help? Send me a message or try `!claw help`.');
|
|
1379
|
-
return;
|
|
1380
|
-
}
|
|
1381
|
-
await queueConversationMessage(msg, content);
|
|
1382
|
-
});
|
|
1383
|
-
client.on('messageUpdate', async (_oldMsg, nextMsg) => {
|
|
1384
|
-
if (DISCORD_COMMANDS_ONLY)
|
|
1385
|
-
return;
|
|
1386
|
-
const fetched = nextMsg.partial
|
|
1387
|
-
? await nextMsg.fetch().catch(() => null)
|
|
1388
|
-
: nextMsg;
|
|
1389
|
-
if (!fetched)
|
|
1390
|
-
return;
|
|
1391
|
-
if (fetched.author?.bot)
|
|
1392
|
-
return;
|
|
1393
|
-
const updatedContent = cleanIncomingContent(fetched.content || '');
|
|
1394
|
-
observeMessageParticipants(fetched, updatedContent);
|
|
1395
|
-
if (!isTrigger(fetched))
|
|
1396
|
-
return;
|
|
1397
|
-
await updatePendingMessage(fetched.id, fetched, updatedContent);
|
|
1398
|
-
const inFlight = inFlightByMessageId.get(fetched.id);
|
|
1399
|
-
if (!inFlight || inFlight.aborted)
|
|
1400
|
-
return;
|
|
1401
|
-
inFlight.aborted = true;
|
|
1402
|
-
inFlight.abortController.abort();
|
|
1403
|
-
for (const messageId of inFlight.messageIds) {
|
|
1404
|
-
if (inFlightByMessageId.get(messageId) === inFlight) {
|
|
1405
|
-
inFlightByMessageId.delete(messageId);
|
|
1406
|
-
}
|
|
1407
|
-
}
|
|
1408
|
-
await inFlight.stream.discard();
|
|
1409
|
-
if (updatedContent) {
|
|
1410
|
-
await queueConversationMessage(fetched, updatedContent);
|
|
1411
|
-
}
|
|
1412
|
-
});
|
|
1413
|
-
client.on('messageDelete', async (msg) => {
|
|
1414
|
-
await dropPendingMessage(msg.id);
|
|
1415
|
-
const inFlight = inFlightByMessageId.get(msg.id);
|
|
1416
|
-
if (!inFlight || inFlight.aborted)
|
|
1417
|
-
return;
|
|
1418
|
-
inFlight.aborted = true;
|
|
1419
|
-
inFlight.abortController.abort();
|
|
1420
|
-
for (const messageId of inFlight.messageIds) {
|
|
1421
|
-
if (inFlightByMessageId.get(messageId) === inFlight) {
|
|
1422
|
-
inFlightByMessageId.delete(messageId);
|
|
1423
|
-
}
|
|
1424
|
-
}
|
|
1425
|
-
await inFlight.stream.discard();
|
|
1426
|
-
});
|
|
1427
|
-
client.on('messageReactionAdd', async (reaction, user) => {
|
|
1428
|
-
if (user.bot)
|
|
1429
|
-
return;
|
|
1430
|
-
const fullReaction = reaction.partial
|
|
1431
|
-
? await reaction.fetch().catch(() => null)
|
|
1432
|
-
: reaction;
|
|
1433
|
-
if (!fullReaction)
|
|
1434
|
-
return;
|
|
1435
|
-
if (fullReaction.emoji.name !== '👎')
|
|
1436
|
-
return;
|
|
1437
|
-
const message = fullReaction.message.partial
|
|
1438
|
-
? await fullReaction.message.fetch().catch(() => null)
|
|
1439
|
-
: fullReaction.message;
|
|
1440
|
-
if (!message)
|
|
1441
|
-
return;
|
|
1442
|
-
if (!client.user || message.author?.id !== client.user.id)
|
|
1443
|
-
return;
|
|
1444
|
-
negativeFeedbackByChannel.set(message.channelId, `${user.username} reacted with 👎 to assistant message ${message.id}.`);
|
|
1445
|
-
});
|
|
1446
|
-
if (!DISCORD_TOKEN) {
|
|
1447
|
-
throw new Error('DISCORD_TOKEN is required to start the Discord bot');
|
|
1448
|
-
}
|
|
1449
|
-
client.login(DISCORD_TOKEN);
|
|
1450
|
-
return client;
|
|
1451
|
-
}
|
|
1452
|
-
/**
|
|
1453
|
-
* Send a message to a channel by ID (used by scheduler).
|
|
1454
|
-
*/
|
|
1455
|
-
export async function sendToChannel(channelId, text, files) {
|
|
1456
|
-
const channel = await client.channels.fetch(channelId);
|
|
1457
|
-
if (channel && 'send' in channel) {
|
|
1458
|
-
const chunks = chunkMessage(text, { maxChars: 1_900, maxLines: 20 });
|
|
1459
|
-
const safeChunks = chunks.length > 0 ? chunks : ['(no content)'];
|
|
1460
|
-
const send = channel.send;
|
|
1461
|
-
for (let i = 0; i < safeChunks.length; i += 1) {
|
|
1462
|
-
await withDiscordRetry('send-channel', () => send({
|
|
1463
|
-
content: safeChunks[i],
|
|
1464
|
-
...(i === safeChunks.length - 1 && files && files.length > 0 ? { files } : {}),
|
|
1465
|
-
}));
|
|
1466
|
-
}
|
|
1467
|
-
}
|
|
1468
|
-
}
|
|
1
|
+
export { buildResponseText, formatError, formatInfo } from './channels/discord/delivery.js';
|
|
2
|
+
export { rewriteUserMentions, rewriteUserMentionsForMessage } from './channels/discord/mentions.js';
|
|
3
|
+
export { initDiscord, runDiscordToolAction, sendToChannel, } from './channels/discord/runtime.js';
|
|
1469
4
|
//# sourceMappingURL=discord.js.map
|