@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.
Files changed (105) hide show
  1. package/CHANGELOG.md +23 -0
  2. package/README.md +47 -15
  3. package/container/package-lock.json +2 -2
  4. package/container/package.json +1 -1
  5. package/container/src/browser-tools.ts +1 -1
  6. package/container/src/index.ts +243 -14
  7. package/container/src/token-usage.ts +18 -2
  8. package/container/src/tools.ts +339 -1
  9. package/container/src/types.ts +28 -2
  10. package/dist/agent.d.ts +2 -2
  11. package/dist/agent.d.ts.map +1 -1
  12. package/dist/agent.js +2 -2
  13. package/dist/agent.js.map +1 -1
  14. package/dist/channels/discord/attachments.d.ts +9 -0
  15. package/dist/channels/discord/attachments.d.ts.map +1 -0
  16. package/dist/channels/discord/attachments.js +245 -0
  17. package/dist/channels/discord/attachments.js.map +1 -0
  18. package/dist/channels/discord/delivery.d.ts +31 -0
  19. package/dist/channels/discord/delivery.d.ts.map +1 -0
  20. package/dist/channels/discord/delivery.js +60 -0
  21. package/dist/channels/discord/delivery.js.map +1 -0
  22. package/dist/channels/discord/inbound.d.ts +20 -0
  23. package/dist/channels/discord/inbound.d.ts.map +1 -0
  24. package/dist/channels/discord/inbound.js +44 -0
  25. package/dist/channels/discord/inbound.js.map +1 -0
  26. package/dist/channels/discord/mentions.d.ts +14 -0
  27. package/dist/channels/discord/mentions.d.ts.map +1 -0
  28. package/dist/channels/discord/mentions.js +118 -0
  29. package/dist/channels/discord/mentions.js.map +1 -0
  30. package/dist/channels/discord/runtime.d.ts +22 -0
  31. package/dist/channels/discord/runtime.d.ts.map +1 -0
  32. package/dist/channels/discord/runtime.js +972 -0
  33. package/dist/channels/discord/runtime.js.map +1 -0
  34. package/dist/channels/discord/stream.d.ts +32 -0
  35. package/dist/channels/discord/stream.d.ts.map +1 -0
  36. package/dist/channels/discord/stream.js +196 -0
  37. package/dist/channels/discord/stream.js.map +1 -0
  38. package/dist/channels/discord/tool-actions.d.ts +31 -0
  39. package/dist/channels/discord/tool-actions.d.ts.map +1 -0
  40. package/dist/channels/discord/tool-actions.js +268 -0
  41. package/dist/channels/discord/tool-actions.js.map +1 -0
  42. package/dist/container-runner.d.ts +2 -2
  43. package/dist/container-runner.d.ts.map +1 -1
  44. package/dist/container-runner.js +12 -2
  45. package/dist/container-runner.js.map +1 -1
  46. package/dist/discord.basic.test.d.ts +2 -0
  47. package/dist/discord.basic.test.d.ts.map +1 -0
  48. package/dist/discord.basic.test.js +38 -0
  49. package/dist/discord.basic.test.js.map +1 -0
  50. package/dist/discord.d.ts +5 -44
  51. package/dist/discord.d.ts.map +1 -1
  52. package/dist/discord.js +3 -1468
  53. package/dist/discord.js.map +1 -1
  54. package/dist/gateway-service.d.ts +7 -1
  55. package/dist/gateway-service.d.ts.map +1 -1
  56. package/dist/gateway-service.js +111 -2
  57. package/dist/gateway-service.js.map +1 -1
  58. package/dist/gateway-service.media-routing.test.d.ts +2 -0
  59. package/dist/gateway-service.media-routing.test.d.ts.map +1 -0
  60. package/dist/gateway-service.media-routing.test.js +29 -0
  61. package/dist/gateway-service.media-routing.test.js.map +1 -0
  62. package/dist/gateway-types.d.ts +8 -0
  63. package/dist/gateway-types.d.ts.map +1 -1
  64. package/dist/gateway-types.js.map +1 -1
  65. package/dist/gateway.js +5 -2
  66. package/dist/gateway.js.map +1 -1
  67. package/dist/health.d.ts.map +1 -1
  68. package/dist/health.js +1 -1
  69. package/dist/health.js.map +1 -1
  70. package/dist/heartbeat.d.ts.map +1 -1
  71. package/dist/heartbeat.js +2 -0
  72. package/dist/heartbeat.js.map +1 -1
  73. package/dist/token-efficiency.basic.test.d.ts +2 -0
  74. package/dist/token-efficiency.basic.test.d.ts.map +1 -0
  75. package/dist/token-efficiency.basic.test.js +29 -0
  76. package/dist/token-efficiency.basic.test.js.map +1 -0
  77. package/dist/token-efficiency.d.ts.map +1 -1
  78. package/dist/token-efficiency.js +18 -1
  79. package/dist/token-efficiency.js.map +1 -1
  80. package/dist/types.d.ts +23 -1
  81. package/dist/types.d.ts.map +1 -1
  82. package/package.json +10 -2
  83. package/src/agent.ts +11 -1
  84. package/src/channels/discord/attachments.ts +282 -0
  85. package/src/channels/discord/delivery.ts +99 -0
  86. package/src/channels/discord/inbound.ts +72 -0
  87. package/src/channels/discord/mentions.ts +130 -0
  88. package/src/{discord.ts → channels/discord/runtime.ts} +77 -615
  89. package/src/{discord-stream.ts → channels/discord/stream.ts} +2 -2
  90. package/src/channels/discord/tool-actions.ts +332 -0
  91. package/src/container-runner.ts +24 -1
  92. package/src/gateway-service.ts +125 -1
  93. package/src/gateway-types.ts +8 -0
  94. package/src/gateway.ts +5 -5
  95. package/src/health.ts +2 -1
  96. package/src/heartbeat.ts +2 -0
  97. package/src/token-efficiency.ts +17 -1
  98. package/src/types.ts +27 -1
  99. package/tests/discord.basic.test.ts +43 -0
  100. package/tests/gateway-service.media-routing.test.ts +33 -0
  101. package/tests/token-efficiency.basic.test.ts +32 -0
  102. package/vitest.e2e.config.ts +15 -0
  103. package/vitest.integration.config.ts +15 -0
  104. package/vitest.live.config.ts +16 -0
  105. package/vitest.unit.config.ts +15 -0
@@ -1,10 +1,8 @@
1
1
  import {
2
2
  ActivityType,
3
3
  AttachmentBuilder,
4
- type ChatInputCommandInteraction,
5
4
  Client,
6
5
  GatewayIntentBits,
7
- type GuildMember,
8
6
  type Message as DiscordMessage,
9
7
  Partials,
10
8
  } from 'discord.js';
@@ -17,10 +15,37 @@ import {
17
15
  DISCORD_PREFIX,
18
16
  DISCORD_RESPOND_TO_ALL_MESSAGES,
19
17
  DISCORD_TOKEN,
20
- } from './config.js';
21
- import { chunkMessage } from './chunk.js';
22
- import { DiscordStreamManager } from './discord-stream.js';
23
- import { logger } from './logger.js';
18
+ } from '../../config.js';
19
+ import { buildAttachmentContext } from './attachments.js';
20
+ import {
21
+ buildSessionIdFromContext as buildSessionIdFromContextInbound,
22
+ cleanIncomingContent as cleanIncomingContentInbound,
23
+ hasPrefixInvocation as hasPrefixInvocationInbound,
24
+ isTrigger as isTriggerInbound,
25
+ parseCommand as parseCommandInbound,
26
+ type ParsedCommand,
27
+ } from './inbound.js';
28
+ import {
29
+ addMentionAlias,
30
+ extractMentionAliasHints,
31
+ normalizeMentionAlias,
32
+ type MentionLookup,
33
+ } from './mentions.js';
34
+ import {
35
+ formatError,
36
+ prepareChunkedPayloads,
37
+ sendChunkedDirectReply as sendChunkedDirectReplyFromDelivery,
38
+ sendChunkedInteractionReply as sendChunkedInteractionReplyFromDelivery,
39
+ sendChunkedReply as sendChunkedReplyFromDelivery,
40
+ } from './delivery.js';
41
+ import {
42
+ createDiscordToolActionRunner,
43
+ type CachedDiscordPresence,
44
+ type DiscordToolActionRequest,
45
+ } from './tool-actions.js';
46
+ import { DiscordStreamManager } from './stream.js';
47
+ import { logger } from '../../logger.js';
48
+ import type { MediaContextItem } from '../../types.js';
24
49
 
25
50
  export type ReplyFn = (content: string, files?: AttachmentBuilder[]) => Promise<void>;
26
51
 
@@ -39,15 +64,6 @@ interface ParticipantInfo {
39
64
  aliases: Set<string>;
40
65
  }
41
66
 
42
- export interface MentionLookup {
43
- byAlias: Map<string, Set<string>>;
44
- }
45
-
46
- interface MentionAliasHint {
47
- alias: string;
48
- userId: string;
49
- }
50
-
51
67
  export interface MessageRunContext {
52
68
  sourceMessage: DiscordMessage;
53
69
  batchedMessages: DiscordMessage[];
@@ -56,22 +72,6 @@ export interface MessageRunContext {
56
72
  mentionLookup: MentionLookup;
57
73
  }
58
74
 
59
- export type DiscordToolAction = 'read' | 'member-info' | 'channel-info';
60
-
61
- export interface DiscordToolActionRequest {
62
- action: DiscordToolAction;
63
- channelId?: string;
64
- guildId?: string;
65
- userId?: string;
66
- username?: string;
67
- user?: string;
68
- memberId?: string;
69
- limit?: number;
70
- before?: string;
71
- after?: string;
72
- around?: string;
73
- }
74
-
75
75
  export type MessageHandler = (
76
76
  sessionId: string,
77
77
  guildId: string | null,
@@ -79,6 +79,7 @@ export type MessageHandler = (
79
79
  userId: string,
80
80
  username: string,
81
81
  content: string,
82
+ media: MediaContextItem[],
82
83
  reply: ReplyFn,
83
84
  context: MessageRunContext,
84
85
  ) => Promise<void>;
@@ -97,9 +98,6 @@ let commandHandler: CommandHandler;
97
98
  let activeConversationRuns = 0;
98
99
  let botMentionRegex: RegExp | null = null;
99
100
  const MESSAGE_DEBOUNCE_MS = 2_500;
100
- const MAX_ATTACHMENT_BYTES = 10 * 1024 * 1024;
101
- const MAX_ATTACHMENT_CONTEXT_CHARS = 16_000;
102
- const MAX_SINGLE_ATTACHMENT_CHARS = 8_000;
103
101
  const DISCORD_RETRY_MAX_ATTEMPTS = 3;
104
102
  const DISCORD_RETRY_BASE_DELAY_MS = 500;
105
103
  const GUILD_INBOUND_HISTORY_LIMIT = 20;
@@ -108,21 +106,8 @@ const PARTICIPANT_CONTEXT_MAX_USERS = 30;
108
106
  const PARTICIPANT_MEMORY_MAX_CHANNELS = 200;
109
107
  const PARTICIPANT_MEMORY_MAX_USERS_PER_CHANNEL = 200;
110
108
  const PARTICIPANT_MEMORY_MAX_ALIASES_PER_USER = 8;
111
- const MENTION_ALIAS_LOOKUP_MAX = 8;
112
109
  const MAX_PRESENCE_CACHE_USERS = 5_000;
113
110
 
114
- interface CachedDiscordPresenceActivity {
115
- type: number;
116
- name: string;
117
- state: string | null;
118
- details: string | null;
119
- }
120
-
121
- interface CachedDiscordPresence {
122
- status: string;
123
- activities: CachedDiscordPresenceActivity[];
124
- }
125
-
126
111
  const discordPresenceCache = new Map<string, CachedDiscordPresence>();
127
112
 
128
113
  function setDiscordPresence(userId: string, data: CachedDiscordPresence): void {
@@ -139,54 +124,6 @@ function getDiscordPresence(userId: string): CachedDiscordPresence | undefined {
139
124
  return discordPresenceCache.get(userId);
140
125
  }
141
126
 
142
- function normalizeMentionAlias(raw: string | null | undefined): string {
143
- if (!raw) return '';
144
- const trimmed = raw.trim().replace(/^@+/, '');
145
- if (!trimmed) return '';
146
- const lowered = trimmed.toLowerCase();
147
- if (lowered === 'everyone' || lowered === 'here') return '';
148
- if (!/^[\p{L}\p{N}._-]{2,32}$/u.test(trimmed)) return '';
149
- return lowered;
150
- }
151
-
152
- function addMentionAlias(lookup: MentionLookup, rawAlias: string | null | undefined, userId: string): void {
153
- const alias = normalizeMentionAlias(rawAlias);
154
- if (!alias) return;
155
- let ids = lookup.byAlias.get(alias);
156
- if (!ids) {
157
- ids = new Set<string>();
158
- lookup.byAlias.set(alias, ids);
159
- }
160
- ids.add(userId);
161
- }
162
-
163
- function extractMentionAliasHints(text: string): MentionAliasHint[] {
164
- if (!text) return [];
165
-
166
- const hints = new Map<string, MentionAliasHint>();
167
- const collect = (rawAlias: string | null | undefined, rawUserId: string | null | undefined): void => {
168
- const userId = (rawUserId || '').trim();
169
- if (!/^\d{16,22}$/.test(userId)) return;
170
- const alias = normalizeMentionAlias(rawAlias);
171
- if (!alias) return;
172
- const key = `${alias}:${userId}`;
173
- if (!hints.has(key)) hints.set(key, { alias, userId });
174
- };
175
-
176
- const aliasToId = /(^|[\s,;:.!?])@?([\p{L}\p{N}._-]{2,32})\s*(?:ist|is|=|->|=>|means|heißt)\s*(?:<@!?(\d{16,22})>|(\d{16,22}))/giu;
177
- let match: RegExpExecArray | null;
178
- while ((match = aliasToId.exec(text)) !== null) {
179
- collect(match[2], match[3] || match[4]);
180
- }
181
-
182
- const idToAlias = /(?:<@!?(\d{16,22})>|(\d{16,22}))\s*(?:ist|is|=|->|=>|means|heißt)\s*@?([\p{L}\p{N}._-]{2,32})/giu;
183
- while ((match = idToAlias.exec(text)) !== null) {
184
- collect(match[3], match[1] || match[2]);
185
- }
186
-
187
- return Array.from(hints.values());
188
- }
189
-
190
127
  function buildMentionLookup(
191
128
  messages: DiscordMessage[],
192
129
  pendingHistory: PendingGuildHistoryEntry[],
@@ -235,76 +172,6 @@ function buildMentionLookup(
235
172
  return lookup;
236
173
  }
237
174
 
238
- export function rewriteUserMentions(text: string, lookup: MentionLookup): string {
239
- if (!text) return text;
240
- if (!lookup.byAlias.size) return text;
241
- return text.replace(/(^|[\s([{:>])@([\p{L}\p{N}._-]{2,32})\b/gu, (full, prefix: string, rawAlias: string) => {
242
- const alias = normalizeMentionAlias(rawAlias);
243
- if (!alias) return full;
244
- const ids = lookup.byAlias.get(alias);
245
- if (!ids || ids.size !== 1) return full;
246
- const [id] = Array.from(ids);
247
- if (!id) return full;
248
- return `${prefix}<@${id}>`;
249
- });
250
- }
251
-
252
- function extractMentionAliases(text: string): string[] {
253
- if (!text) return [];
254
- const aliases = new Set<string>();
255
- const re = /(^|[\s([{:>])@([\p{L}\p{N}._-]{2,32})\b/gu;
256
- let match: RegExpExecArray | null;
257
- while ((match = re.exec(text)) !== null) {
258
- const alias = normalizeMentionAlias(match[2]);
259
- if (!alias) continue;
260
- aliases.add(alias);
261
- if (aliases.size >= MENTION_ALIAS_LOOKUP_MAX) break;
262
- }
263
- return Array.from(aliases);
264
- }
265
-
266
- async function enrichMentionLookupFromGuild(
267
- msg: DiscordMessage,
268
- lookup: MentionLookup,
269
- aliases: string[],
270
- ): Promise<void> {
271
- if (!msg.guild || aliases.length === 0) return;
272
-
273
- for (const alias of aliases) {
274
- if (lookup.byAlias.has(alias)) continue;
275
- try {
276
- const members = await msg.guild.members.search({ query: alias, limit: 5 });
277
- const exactMatches = Array.from(members.values()).filter((member) => {
278
- const username = normalizeMentionAlias(member.user?.username || '');
279
- const displayName = normalizeMentionAlias(member.displayName || '');
280
- return username === alias || displayName === alias;
281
- });
282
- if (exactMatches.length !== 1) continue;
283
- const match = exactMatches[0];
284
- addMentionAlias(lookup, alias, match.id);
285
- addMentionAlias(lookup, match.user?.username || '', match.id);
286
- addMentionAlias(lookup, match.displayName || '', match.id);
287
- } catch (error) {
288
- logger.debug(
289
- { error, guildId: msg.guild.id, alias },
290
- 'Failed to resolve guild member alias for mention rewrite',
291
- );
292
- }
293
- }
294
- }
295
-
296
- export async function rewriteUserMentionsForMessage(
297
- text: string,
298
- msg: DiscordMessage,
299
- lookup: MentionLookup,
300
- ): Promise<string> {
301
- const aliases = extractMentionAliases(text);
302
- if (aliases.length > 0) {
303
- await enrichMentionLookupFromGuild(msg, lookup, aliases);
304
- }
305
- return rewriteUserMentions(text, lookup);
306
- }
307
-
308
175
  function summarizePendingHistoryEntry(entry: PendingGuildHistoryEntry): string {
309
176
  const author = entry.displayName || entry.username || 'user';
310
177
  const authorLabel = entry.isBot ? `${author} [bot]` : author;
@@ -527,27 +394,6 @@ interface DiscordErrorLike {
527
394
  };
528
395
  }
529
396
 
530
- /**
531
- * Format an agent response as plain text.
532
- * Appends a subtle tools line if any tools were used.
533
- */
534
- export function buildResponseText(text: string, toolsUsed?: string[]): string {
535
- let body = text;
536
- if (toolsUsed && toolsUsed.length > 0) {
537
- const toolsLine = `\n*Tools: ${toolsUsed.join(', ')}*`;
538
- body = `${text}${toolsLine}`;
539
- }
540
- return body;
541
- }
542
-
543
- export function formatInfo(title: string, body: string): string {
544
- return `**${title}**\n${body}`;
545
- }
546
-
547
- export function formatError(title: string, detail: string): string {
548
- return `**${title}:** ${detail}`;
549
- }
550
-
551
397
  function requireDiscordClientReady(): Client {
552
398
  if (!client) {
553
399
  throw new Error('Discord client is not initialized.');
@@ -558,303 +404,23 @@ function requireDiscordClientReady(): Client {
558
404
  return client;
559
405
  }
560
406
 
561
- function sanitizeDiscordId(rawValue: string | undefined, label: string): string {
562
- const value = (rawValue || '').trim();
563
- if (!/^\d{16,22}$/.test(value)) {
564
- throw new Error(`${label} must be a Discord snowflake id.`);
565
- }
566
- return value;
567
- }
568
-
569
- function normalizeDiscordUserLookupQuery(rawValue: string | undefined): string {
570
- const trimmed = (rawValue || '').trim();
571
- if (!trimmed) return '';
572
-
573
- const mentionMatch = trimmed.match(/^<@!?(\d{16,22})>$/);
574
- if (mentionMatch) return mentionMatch[1];
575
- const prefixedId = trimmed.match(/^(?:user:|discord:)?(\d{16,22})$/i);
576
- if (prefixedId) return prefixedId[1];
577
-
578
- return trimmed.replace(/^@+/, '').trim();
579
- }
580
-
581
- function scoreGuildMemberForLookup(member: GuildMember, query: string): number {
582
- const q = query.toLowerCase();
583
- const username = member.user.username?.toLowerCase() || '';
584
- const globalName = member.user.globalName?.toLowerCase() || '';
585
- const nickname = member.nickname?.toLowerCase() || '';
586
- const displayName = member.displayName?.toLowerCase() || '';
587
- const candidates = [username, globalName, nickname, displayName].filter(Boolean);
588
-
589
- let score = 0;
590
- if (candidates.some((value) => value === q)) score += 3;
591
- if (candidates.some((value) => value.includes(q))) score += 1;
592
- if (!member.user.bot) score += 1;
593
- return score;
594
- }
595
-
596
- async function resolveGuildMemberIdFromLookup(params: {
597
- guildId: string;
598
- rawUser: string;
599
- }): Promise<{ userId: string; note?: string }> {
600
- const activeClient = requireDiscordClientReady();
601
- const guildId = sanitizeDiscordId(params.guildId, 'guildId');
602
- const normalized = normalizeDiscordUserLookupQuery(params.rawUser);
603
- if (!normalized) {
604
- throw new Error('userId or username is required.');
605
- }
606
- if (/^\d{16,22}$/.test(normalized)) {
607
- return { userId: normalized };
608
- }
609
-
610
- const guild = await activeClient.guilds.fetch(guildId);
611
- const searchQuery = normalized.slice(0, 32);
612
- if (!searchQuery) {
613
- throw new Error('username query is empty after normalization.');
614
- }
615
-
616
- let members: Map<string, GuildMember>;
617
- try {
618
- members = await guild.members.search({ query: searchQuery, limit: 25 });
619
- } catch {
620
- const fetched = await guild.members.fetch({ query: searchQuery, limit: 25 });
621
- members = fetched;
622
- }
623
- let best: GuildMember | null = null;
624
- let bestScore = 0;
625
- let matchCount = 0;
626
- for (const member of members.values()) {
627
- const score = scoreGuildMemberForLookup(member, searchQuery);
628
- if (score <= 0) continue;
629
- matchCount += 1;
630
- if (!best || score > bestScore) {
631
- best = member;
632
- bestScore = score;
633
- }
634
- }
635
-
636
- if (!best) {
637
- throw new Error(`No guild member matched username "${searchQuery}".`);
638
- }
639
-
640
- return {
641
- userId: best.id,
642
- note: matchCount > 1 ? 'multiple matches; chose best' : undefined,
643
- };
644
- }
645
-
646
- function normalizeDate(value: Date | null | undefined): string | null {
647
- if (!value) return null;
648
- const ms = value.getTime();
649
- if (!Number.isFinite(ms)) return null;
650
- return value.toISOString();
651
- }
652
-
653
- async function runDiscordReadAction(request: DiscordToolActionRequest): Promise<Record<string, unknown>> {
654
- const activeClient = requireDiscordClientReady();
655
- const channelId = sanitizeDiscordId(request.channelId, 'channelId');
656
- const channel = await activeClient.channels.fetch(channelId);
657
- if (!channel || !('messages' in channel)) {
658
- throw new Error('Channel does not support message reads.');
659
- }
660
-
661
- const requestedLimit =
662
- typeof request.limit === 'number' && Number.isFinite(request.limit)
663
- ? Math.floor(request.limit)
664
- : 20;
665
- const limit = Math.max(1, Math.min(100, requestedLimit));
666
- const before = request.before?.trim();
667
- const after = request.after?.trim();
668
- const around = request.around?.trim();
669
-
670
- const query: { limit: number; before?: string; after?: string; around?: string } = { limit };
671
- if (before) query.before = before;
672
- if (after) query.after = after;
673
- if (around) query.around = around;
674
-
675
- const fetched = await channel.messages.fetch(query);
676
- const messages = Array.from(fetched.values())
677
- .sort((a, b) => a.createdTimestamp - b.createdTimestamp || a.id.localeCompare(b.id))
678
- .map((message) => ({
679
- id: message.id,
680
- channelId: message.channelId,
681
- guildId: message.guildId ?? null,
682
- content: message.content || '',
683
- createdAt: new Date(message.createdTimestamp).toISOString(),
684
- editedAt: normalizeDate(message.editedAt),
685
- author: {
686
- id: message.author?.id || 'unknown',
687
- username: message.author?.username || 'unknown',
688
- handle: message.author?.username ? `@${message.author.username}` : null,
689
- globalName: message.author?.globalName || null,
690
- bot: Boolean(message.author?.bot),
691
- },
692
- member: message.member
693
- ? {
694
- id: message.member.id,
695
- nickname: message.member.nickname || null,
696
- displayName: message.member.displayName || null,
697
- }
698
- : null,
699
- attachments: Array.from(message.attachments.values()).map((attachment) => ({
700
- id: attachment.id,
701
- name: attachment.name || null,
702
- url: attachment.url,
703
- contentType: attachment.contentType || null,
704
- size: attachment.size,
705
- })),
706
- mentions: {
707
- users: Array.from(message.mentions.users.values()).map((user) => ({
708
- id: user.id,
709
- username: user.username,
710
- bot: Boolean(user.bot),
711
- })),
712
- roles: Array.from(message.mentions.roles.values()).map((role) => ({
713
- id: role.id,
714
- name: role.name,
715
- })),
716
- channels: Array.from(message.mentions.channels.values()).map((mentionedChannel) => ({
717
- id: mentionedChannel.id,
718
- name: 'name' in mentionedChannel && typeof mentionedChannel.name === 'string'
719
- ? mentionedChannel.name
720
- : null,
721
- })),
722
- },
723
- }));
724
-
725
- return {
726
- ok: true,
727
- action: 'read',
728
- channelId,
729
- count: messages.length,
730
- messages,
731
- };
732
- }
733
-
734
- async function runDiscordMemberInfoAction(request: DiscordToolActionRequest): Promise<Record<string, unknown>> {
735
- const activeClient = requireDiscordClientReady();
736
- const guildId = sanitizeDiscordId(request.guildId, 'guildId');
737
- const userLookupRaw =
738
- request.userId
739
- || request.memberId
740
- || request.user
741
- || request.username;
742
- const resolvedUser = await resolveGuildMemberIdFromLookup({
743
- guildId,
744
- rawUser: userLookupRaw || '',
745
- });
746
- const userId = sanitizeDiscordId(resolvedUser.userId, 'userId');
747
-
748
- const guild = await activeClient.guilds.fetch(guildId);
749
- const member = await guild.members.fetch(userId);
750
- const presence = getDiscordPresence(userId);
751
-
752
- const roles = member.roles.cache
753
- .filter((role) => role.id !== guild.id)
754
- .map((role) => ({
755
- id: role.id,
756
- name: role.name,
757
- color: role.hexColor,
758
- position: role.position,
759
- }))
760
- .sort((a, b) => b.position - a.position || a.name.localeCompare(b.name));
761
-
762
- return {
763
- ok: true,
764
- action: 'member-info',
765
- guildId,
766
- userId,
767
- ...(resolvedUser.note ? { note: resolvedUser.note } : {}),
768
- member: {
769
- id: member.id,
770
- username: member.user.username,
771
- handle: member.user.username ? `@${member.user.username}` : null,
772
- globalName: member.user.globalName || null,
773
- bot: Boolean(member.user.bot),
774
- displayName: member.displayName,
775
- nickname: member.nickname || null,
776
- joinedAt: normalizeDate(member.joinedAt),
777
- premiumSince: normalizeDate(member.premiumSince),
778
- communicationDisabledUntil: normalizeDate(member.communicationDisabledUntil),
779
- roles,
780
- },
781
- ...(presence
782
- ? {
783
- status: presence.status,
784
- activities: presence.activities,
785
- }
786
- : {}),
787
- };
788
- }
789
-
790
- async function runDiscordChannelInfoAction(request: DiscordToolActionRequest): Promise<Record<string, unknown>> {
791
- const activeClient = requireDiscordClientReady();
792
- const channelId = sanitizeDiscordId(request.channelId, 'channelId');
793
- const channel = await activeClient.channels.fetch(channelId);
794
- if (!channel) {
795
- throw new Error('Channel not found.');
796
- }
797
-
798
- const channelData: Record<string, unknown> = {
799
- id: channel.id,
800
- type: channel.type,
801
- guildId: 'guildId' in channel ? channel.guildId || null : null,
802
- name: 'name' in channel && typeof channel.name === 'string' ? channel.name : null,
803
- parentId: 'parentId' in channel ? channel.parentId || null : null,
804
- topic: 'topic' in channel && typeof channel.topic === 'string' ? channel.topic : null,
805
- nsfw: 'nsfw' in channel && typeof channel.nsfw === 'boolean' ? channel.nsfw : null,
806
- rateLimitPerUser:
807
- 'rateLimitPerUser' in channel && typeof channel.rateLimitPerUser === 'number'
808
- ? channel.rateLimitPerUser
809
- : null,
810
- isTextBased: typeof channel.isTextBased === 'function' ? channel.isTextBased() : false,
811
- isDMBased: typeof channel.isDMBased === 'function' ? channel.isDMBased() : false,
812
- isThread: typeof channel.isThread === 'function' ? channel.isThread() : false,
813
- lastMessageId: 'lastMessageId' in channel ? channel.lastMessageId || null : null,
814
- };
815
-
816
- if (typeof channel.isThread === 'function' && channel.isThread()) {
817
- channelData.archived =
818
- 'archived' in channel && typeof channel.archived === 'boolean' ? channel.archived : null;
819
- channelData.locked =
820
- 'locked' in channel && typeof channel.locked === 'boolean' ? channel.locked : null;
821
- channelData.ownerId = 'ownerId' in channel ? channel.ownerId || null : null;
822
- }
823
-
824
- return {
825
- ok: true,
826
- action: 'channel-info',
827
- channel: channelData,
828
- };
829
- }
407
+ const runDiscordToolActionInternal = createDiscordToolActionRunner({
408
+ requireDiscordClientReady,
409
+ getDiscordPresence,
410
+ });
830
411
 
831
412
  export async function runDiscordToolAction(
832
413
  request: DiscordToolActionRequest,
833
414
  ): Promise<Record<string, unknown>> {
834
- switch (request.action) {
835
- case 'read':
836
- return await runDiscordReadAction(request);
837
- case 'member-info':
838
- return await runDiscordMemberInfoAction(request);
839
- case 'channel-info':
840
- return await runDiscordChannelInfoAction(request);
841
- default:
842
- throw new Error(`Unsupported Discord action: ${request.action as string}`);
843
- }
415
+ return await runDiscordToolActionInternal(request);
844
416
  }
845
417
 
846
418
  function getSessionId(msg: DiscordMessage): string {
847
419
  return buildSessionIdFromContext(msg.guild?.id ?? null, msg.channelId, msg.author.id);
848
420
  }
849
421
 
850
- function stripBotMentions(text: string): string {
851
- if (!botMentionRegex) return text;
852
- return text.replace(botMentionRegex, '').trim();
853
- }
854
-
855
422
  function hasPrefixInvocation(content: string): boolean {
856
- const text = stripBotMentions(content);
857
- return text.startsWith(DISCORD_PREFIX);
423
+ return hasPrefixInvocationInbound(content, botMentionRegex, DISCORD_PREFIX);
858
424
  }
859
425
 
860
426
  function isAuthorizedCommandUserId(userId: string): boolean {
@@ -864,33 +430,23 @@ function isAuthorizedCommandUserId(userId: string): boolean {
864
430
  }
865
431
 
866
432
  function buildSessionIdFromContext(guildId: string | null, channelId: string, userId: string): string {
867
- return guildId ? `${guildId}:${channelId}` : `dm:${userId}`;
433
+ return buildSessionIdFromContextInbound(guildId, channelId, userId);
868
434
  }
869
435
 
870
436
  function isTrigger(msg: DiscordMessage): boolean {
871
- if (DISCORD_COMMANDS_ONLY) return hasPrefixInvocation(msg.content);
872
- if (!msg.guild) return true;
873
- if (DISCORD_RESPOND_TO_ALL_MESSAGES) return true;
874
- if (client.user && msg.mentions.has(client.user)) return true;
875
- if (msg.content.startsWith(DISCORD_PREFIX)) return true;
876
- return false;
437
+ return isTriggerInbound({
438
+ content: msg.content,
439
+ isDm: !msg.guild,
440
+ commandsOnly: DISCORD_COMMANDS_ONLY,
441
+ respondToAllMessages: DISCORD_RESPOND_TO_ALL_MESSAGES,
442
+ prefix: DISCORD_PREFIX,
443
+ botMentionRegex,
444
+ hasBotMention: Boolean(client.user && msg.mentions.has(client.user)),
445
+ });
877
446
  }
878
447
 
879
- function parseCommand(content: string): { isCommand: boolean; command: string; args: string[] } {
880
- let text = stripBotMentions(content);
881
-
882
- if (text.startsWith(DISCORD_PREFIX)) {
883
- text = text.slice(DISCORD_PREFIX.length).trim();
884
- }
885
-
886
- const parts = text.split(/\s+/);
887
- const subcommands = ['bot', 'rag', 'model', 'sessions', 'audit', 'schedule', 'clear', 'help'];
888
-
889
- if (parts.length > 0 && subcommands.includes(parts[0].toLowerCase())) {
890
- return { isCommand: true, command: parts[0].toLowerCase(), args: parts.slice(1) };
891
- }
892
-
893
- return { isCommand: false, command: '', args: [] };
448
+ function parseCommand(content: string): ParsedCommand {
449
+ return parseCommandInbound(content, botMentionRegex, DISCORD_PREFIX);
894
450
  }
895
451
 
896
452
  function isRetryableDiscordError(error: unknown): boolean {
@@ -928,11 +484,7 @@ async function withDiscordRetry<T>(label: string, fn: () => Promise<T>): Promise
928
484
  }
929
485
 
930
486
  function cleanIncomingContent(content: string): string {
931
- let text = stripBotMentions(content);
932
- if (text.startsWith(DISCORD_PREFIX)) {
933
- text = text.slice(DISCORD_PREFIX.length).trim();
934
- }
935
- return text;
487
+ return cleanIncomingContentInbound(content, botMentionRegex, DISCORD_PREFIX);
936
488
  }
937
489
 
938
490
  function summarizeContextMessage(msg: DiscordMessage): string {
@@ -969,25 +521,6 @@ function buildChannelInfoContext(msg: DiscordMessage): string {
969
521
  return `${lines.join('\n')}\n`;
970
522
  }
971
523
 
972
- function looksLikeTextAttachment(name: string, contentType: string): boolean {
973
- if (contentType.startsWith('text/')) return true;
974
- if (contentType.includes('json') || contentType.includes('xml') || contentType.includes('yaml')) return true;
975
- 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);
976
- }
977
-
978
- async function fetchAttachmentText(url: string, maxChars: number): Promise<string | null> {
979
- try {
980
- const response = await fetch(url);
981
- if (!response.ok) return null;
982
- const text = await response.text();
983
- if (!text) return null;
984
- if (text.length <= maxChars) return text;
985
- return `${text.slice(0, Math.max(1_000, maxChars - 32))}\n...[truncated]`;
986
- } catch {
987
- return null;
988
- }
989
- }
990
-
991
524
  async function buildReplyContext(msg: DiscordMessage): Promise<string> {
992
525
  const blocks: string[] = [];
993
526
 
@@ -1023,52 +556,6 @@ async function buildReplyContext(msg: DiscordMessage): Promise<string> {
1023
556
  return `${blocks.join('\n\n')}\n\n`;
1024
557
  }
1025
558
 
1026
- async function buildAttachmentContext(messages: DiscordMessage[]): Promise<string> {
1027
- const lines: string[] = [];
1028
- let remainingChars = MAX_ATTACHMENT_CONTEXT_CHARS;
1029
-
1030
- for (const msg of messages) {
1031
- if (!msg.attachments || msg.attachments.size === 0) continue;
1032
- for (const attachment of msg.attachments.values()) {
1033
- const name = attachment.name || 'unnamed';
1034
- const size = attachment.size || 0;
1035
- const contentType = (attachment.contentType || '').toLowerCase();
1036
- if (size > MAX_ATTACHMENT_BYTES) {
1037
- lines.push(`- ${name}: skipped (size ${size} bytes exceeds 10MB limit)`);
1038
- continue;
1039
- }
1040
-
1041
- if (contentType.startsWith('image/')) {
1042
- lines.push(`- ${name}: image attachment (${size} bytes, ${contentType || 'unknown type'})`);
1043
- continue;
1044
- }
1045
-
1046
- if (looksLikeTextAttachment(name, contentType)) {
1047
- const maxChars = Math.min(MAX_SINGLE_ATTACHMENT_CHARS, Math.max(500, remainingChars));
1048
- const text = await fetchAttachmentText(attachment.url, maxChars);
1049
- if (!text) {
1050
- lines.push(`- ${name}: text attachment (failed to read content)`);
1051
- continue;
1052
- }
1053
-
1054
- const block = `- ${name} (text attachment):\n\`\`\`\n${text}\n\`\`\``;
1055
- remainingChars -= block.length;
1056
- lines.push(block);
1057
- if (remainingChars <= 0) {
1058
- lines.push('- Additional attachment content omitted (context budget reached).');
1059
- return `[Attachments]\n${lines.join('\n')}\n\n`;
1060
- }
1061
- continue;
1062
- }
1063
-
1064
- lines.push(`- ${name}: attachment (${size} bytes, ${contentType || 'unknown type'})`);
1065
- }
1066
- }
1067
-
1068
- if (lines.length === 0) return '';
1069
- return `[Attachments]\n${lines.join('\n')}\n\n`;
1070
- }
1071
-
1072
559
  async function addProcessingReaction(msg: DiscordMessage): Promise<() => Promise<void>> {
1073
560
  if (!client.user) return async () => {};
1074
561
  const botUserId = client.user.id;
@@ -1116,36 +603,19 @@ function startTypingLoop(msg: DiscordMessage): { stop: () => void } {
1116
603
  };
1117
604
  }
1118
605
 
1119
- function prepareChunkedPayloads(
1120
- text: string,
1121
- files?: AttachmentBuilder[],
1122
- mentionLookup?: MentionLookup,
1123
- ): { content: string; files?: AttachmentBuilder[] }[] {
1124
- const prepared = mentionLookup ? rewriteUserMentions(text, mentionLookup) : text;
1125
- const chunks = chunkMessage(prepared, { maxChars: 1_900, maxLines: 20 });
1126
- const safeChunks = chunks.length > 0 ? chunks : ['(no content)'];
1127
- return safeChunks.map((content, i) => ({
1128
- content,
1129
- ...(i === safeChunks.length - 1 && files && files.length > 0 ? { files } : {}),
1130
- }));
1131
- }
1132
-
1133
606
  async function sendChunkedReply(
1134
607
  msg: DiscordMessage,
1135
608
  text: string,
1136
609
  files?: AttachmentBuilder[],
1137
610
  mentionLookup?: MentionLookup,
1138
611
  ): Promise<void> {
1139
- const payloads = prepareChunkedPayloads(text, files, mentionLookup);
1140
- for (let i = 0; i < payloads.length; i += 1) {
1141
- if (i === 0) {
1142
- await withDiscordRetry('reply', () => msg.reply(payloads[i]));
1143
- } else {
1144
- await withDiscordRetry('send', () => (msg.channel as unknown as {
1145
- send: (next: { content: string; files?: AttachmentBuilder[] }) => Promise<void>;
1146
- }).send(payloads[i]));
1147
- }
1148
- }
612
+ await sendChunkedReplyFromDelivery({
613
+ msg,
614
+ text,
615
+ withRetry: withDiscordRetry,
616
+ ...(files ? { files } : {}),
617
+ ...(mentionLookup ? { mentionLookup } : {}),
618
+ });
1149
619
  }
1150
620
 
1151
621
  async function sendChunkedDirectReply(
@@ -1154,31 +624,26 @@ async function sendChunkedDirectReply(
1154
624
  files?: AttachmentBuilder[],
1155
625
  mentionLookup?: MentionLookup,
1156
626
  ): Promise<void> {
1157
- const payloads = prepareChunkedPayloads(text, files, mentionLookup);
1158
- const dm = await withDiscordRetry('dm-open', () => msg.author.createDM());
1159
- for (const payload of payloads) {
1160
- await withDiscordRetry('dm-send', () => dm.send(payload));
1161
- }
627
+ await sendChunkedDirectReplyFromDelivery({
628
+ msg,
629
+ text,
630
+ withRetry: withDiscordRetry,
631
+ ...(files ? { files } : {}),
632
+ ...(mentionLookup ? { mentionLookup } : {}),
633
+ });
1162
634
  }
1163
635
 
1164
636
  async function sendChunkedInteractionReply(
1165
- interaction: ChatInputCommandInteraction,
637
+ interaction: Parameters<typeof sendChunkedInteractionReplyFromDelivery>[0]['interaction'],
1166
638
  text: string,
1167
639
  files?: AttachmentBuilder[],
1168
640
  ): Promise<void> {
1169
- const payloads = prepareChunkedPayloads(text, files);
1170
- for (let i = 0; i < payloads.length; i += 1) {
1171
- const payload = { ...payloads[i], ephemeral: true };
1172
- if (i === 0) {
1173
- if (interaction.replied || interaction.deferred) {
1174
- await withDiscordRetry('interaction-followup', () => interaction.followUp(payload));
1175
- } else {
1176
- await withDiscordRetry('interaction-reply', () => interaction.reply(payload));
1177
- }
1178
- continue;
1179
- }
1180
- await withDiscordRetry('interaction-followup', () => interaction.followUp(payload));
1181
- }
641
+ await sendChunkedInteractionReplyFromDelivery({
642
+ interaction,
643
+ text,
644
+ withRetry: withDiscordRetry,
645
+ ...(files ? { files } : {}),
646
+ });
1182
647
  }
1183
648
 
1184
649
  async function ensureSlashStatusCommand(): Promise<void> {
@@ -1427,7 +892,7 @@ export function initDiscord(onMessage: MessageHandler, onCommand: CommandHandler
1427
892
  inboundHistory.entries,
1428
893
  rememberedParticipants,
1429
894
  );
1430
- const combinedContent = `${feedbackNote ? `[Reaction feedback]\n${feedbackNote}\n\n` : ''}${channelInfoContext}${replyContext}${inboundHistory.context}${attachmentContext}${participantContext}${batchedContent}`;
895
+ const combinedContent = `${feedbackNote ? `[Reaction feedback]\n${feedbackNote}\n\n` : ''}${channelInfoContext}${replyContext}${inboundHistory.context}${attachmentContext.context}${participantContext}${batchedContent}`;
1431
896
 
1432
897
  const abortController = new AbortController();
1433
898
  const typingLoop = startTypingLoop(msg);
@@ -1454,6 +919,7 @@ export function initDiscord(onMessage: MessageHandler, onCommand: CommandHandler
1454
919
  userId,
1455
920
  username,
1456
921
  combinedContent,
922
+ attachmentContext.media,
1457
923
  async (text, files) => {
1458
924
  typingLoop.stop();
1459
925
  await sendChunkedReply(msg, text, files, mentionLookup);
@@ -1714,16 +1180,12 @@ export function initDiscord(onMessage: MessageHandler, onCommand: CommandHandler
1714
1180
  export async function sendToChannel(channelId: string, text: string, files?: AttachmentBuilder[]): Promise<void> {
1715
1181
  const channel = await client.channels.fetch(channelId);
1716
1182
  if (channel && 'send' in channel) {
1717
- const chunks = chunkMessage(text, { maxChars: 1_900, maxLines: 20 });
1718
- const safeChunks = chunks.length > 0 ? chunks : ['(no content)'];
1183
+ const payloads = prepareChunkedPayloads(text, files);
1719
1184
  const send = (channel as unknown as {
1720
1185
  send: (payload: { content: string; files?: AttachmentBuilder[] }) => Promise<void>;
1721
1186
  }).send;
1722
- for (let i = 0; i < safeChunks.length; i += 1) {
1723
- await withDiscordRetry('send-channel', () => send({
1724
- content: safeChunks[i],
1725
- ...(i === safeChunks.length - 1 && files && files.length > 0 ? { files } : {}),
1726
- }));
1187
+ for (const payload of payloads) {
1188
+ await withDiscordRetry('send-channel', () => send(payload));
1727
1189
  }
1728
1190
  }
1729
1191
  }