@framers/agentos-ext-channel-discord 0.1.0 โ†’ 0.1.1

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.
@@ -1,11 +1,48 @@
1
1
  "use strict";
2
+ // @ts-nocheck
2
3
  /**
3
4
  * @fileoverview IChannelAdapter implementation for Discord via discord.js.
4
5
  */
5
6
  Object.defineProperty(exports, "__esModule", { value: true });
6
7
  exports.DiscordChannelAdapter = void 0;
7
8
  const discord_js_1 = require("discord.js");
9
+ const faq_matcher_1 = require("./faq-matcher");
10
+ const TriviaBank_1 = require("./TriviaBank");
8
11
  class DiscordChannelAdapter {
12
+ /** Pre-filter obvious non-response messages before sending to LLM. */
13
+ static shouldIgnoreMessage(content) {
14
+ const trimmed = content.trim();
15
+ if (!trimmed)
16
+ return true;
17
+ return DiscordChannelAdapter.IGNORE_PATTERNS.some(p => p.test(trimmed));
18
+ }
19
+ static normalizeChannelName(name) {
20
+ return String(name ?? '')
21
+ .replace(/\p{Extended_Pictographic}/gu, '')
22
+ .replace(/[\u{FE0F}\u{200D}]/gu, '')
23
+ .replace(/^[#\s\-_]+/, '')
24
+ .replace(/[#\s\-_]+$/, '')
25
+ .trim()
26
+ .toLowerCase()
27
+ .replace(/\s+/g, '-');
28
+ }
29
+ static isProactiveOptOutChannel(message) {
30
+ const names = new Set();
31
+ if ('name' in message.channel) {
32
+ const currentName = DiscordChannelAdapter.normalizeChannelName(message.channel.name ?? '');
33
+ if (currentName)
34
+ names.add(currentName);
35
+ }
36
+ const parentName = DiscordChannelAdapter.normalizeChannelName(message.channel?.parent?.name ?? '');
37
+ if (parentName)
38
+ names.add(parentName);
39
+ for (const name of names) {
40
+ if (DiscordChannelAdapter.PROACTIVE_OPT_OUT_CHANNELS.has(name)) {
41
+ return true;
42
+ }
43
+ }
44
+ return false;
45
+ }
9
46
  constructor(service) {
10
47
  this.service = service;
11
48
  this.platform = 'discord';
@@ -26,17 +63,69 @@ class DiscordChannelAdapter {
26
63
  ];
27
64
  this.handlers = new Map();
28
65
  this.messageHandler = null;
66
+ this.interactionHandler = null;
67
+ this.triviaSessions = new Map();
68
+ this.triviaSessionFlows = new Map();
69
+ this.activeSessionUsers = new Set();
70
+ /** Optional external interaction handlers (e.g., Founders extension). */
71
+ this.externalInteractionHandlers = [];
72
+ /**
73
+ * Delayed response queue: for non-mention messages, wait before responding.
74
+ * If someone else messages in the same channel before the timer fires, cancel it.
75
+ * Key = channelId, Value = { timer, message event to emit }.
76
+ */
77
+ this.pendingResponses = new Map();
78
+ }
79
+ /** Expose the underlying DiscordService for integrations (e.g., Founders welcome post). */
80
+ getService() {
81
+ return this.service;
82
+ }
83
+ /**
84
+ * Register an external interaction handler that runs before built-in handling.
85
+ * The handler should return `true` if it consumed the interaction, `false` otherwise.
86
+ */
87
+ registerExternalInteractionHandler(handler) {
88
+ this.externalInteractionHandlers.push(handler);
29
89
  }
30
90
  async initialize(_auth) {
31
91
  this.messageHandler = (message) => this.handleInboundMessage(message);
32
92
  this.service.onMessage(this.messageHandler);
93
+ this.interactionHandler = (interaction) => {
94
+ void this.handleInboundInteraction(interaction);
95
+ };
96
+ this.service.onInteraction(this.interactionHandler);
33
97
  }
34
98
  async shutdown() {
35
99
  if (this.messageHandler) {
36
100
  this.service.offMessage(this.messageHandler);
37
101
  this.messageHandler = null;
38
102
  }
103
+ if (this.interactionHandler) {
104
+ this.service.offInteraction(this.interactionHandler);
105
+ this.interactionHandler = null;
106
+ }
39
107
  this.handlers.clear();
108
+ for (const s of this.triviaSessions.values()) {
109
+ try {
110
+ clearTimeout(s.cleanupTimer);
111
+ }
112
+ catch {
113
+ // ignore
114
+ }
115
+ }
116
+ this.triviaSessions.clear();
117
+ for (const s of this.triviaSessionFlows.values()) {
118
+ if (s.timeoutTimer)
119
+ clearTimeout(s.timeoutTimer);
120
+ if (s._sessionCleanup)
121
+ clearTimeout(s._sessionCleanup);
122
+ }
123
+ this.triviaSessionFlows.clear();
124
+ this.activeSessionUsers.clear();
125
+ for (const p of this.pendingResponses.values()) {
126
+ clearTimeout(p.timer);
127
+ }
128
+ this.pendingResponses.clear();
40
129
  }
41
130
  getConnectionInfo() {
42
131
  return {
@@ -135,6 +224,24 @@ class DiscordChannelAdapter {
135
224
  }
136
225
  // -- Private --
137
226
  handleInboundMessage(message) {
227
+ // No-DM policy: ignore direct messages / group DMs.
228
+ if (message.channel.isDMBased())
229
+ return;
230
+ const botId = this.service.getClient().user?.id;
231
+ const channelId = message.channelId;
232
+ // Determine if this is a direct invocation (mention or reply to bot).
233
+ const mentionsBot = botId ? message.mentions.users.has(botId) : false;
234
+ const repliesToBot = botId && message.reference?.messageId
235
+ ? message.channel.messages?.cache.get(message.reference.messageId)?.author?.id === botId
236
+ : false;
237
+ const isDirect = mentionsBot || repliesToBot;
238
+ // Any human message in a channel cancels the pending delayed response for that channel.
239
+ // Someone else is engaging, so the bot stays quiet.
240
+ const pending = this.pendingResponses.get(channelId);
241
+ if (pending) {
242
+ clearTimeout(pending.timer);
243
+ this.pendingResponses.delete(channelId);
244
+ }
138
245
  const sender = {
139
246
  id: message.author.id,
140
247
  displayName: message.member?.displayName ?? message.author.displayName ?? undefined,
@@ -144,7 +251,7 @@ class DiscordChannelAdapter {
144
251
  const channelMessage = {
145
252
  messageId: message.id,
146
253
  platform: 'discord',
147
- conversationId: message.channelId,
254
+ conversationId: channelId,
148
255
  conversationType,
149
256
  sender,
150
257
  content: [{ type: 'text', text: message.content ?? '' }],
@@ -156,7 +263,1342 @@ class DiscordChannelAdapter {
156
263
  const event = {
157
264
  type: 'message',
158
265
  platform: 'discord',
159
- conversationId: message.channelId,
266
+ conversationId: channelId,
267
+ timestamp: channelMessage.timestamp,
268
+ data: channelMessage,
269
+ };
270
+ if (isDirect) {
271
+ // Direct mention or reply to bot โ†’ respond immediately.
272
+ this.emit(event);
273
+ return;
274
+ }
275
+ // Feed channels should stay clean unless a human explicitly invokes the bot.
276
+ if (DiscordChannelAdapter.isProactiveOptOutChannel(message)) {
277
+ return;
278
+ }
279
+ // Pre-filter: skip messages that are obviously not worth sending to the LLM.
280
+ // This saves API costs and prevents the bot from responding to casual chatter.
281
+ if (DiscordChannelAdapter.shouldIgnoreMessage(message.content ?? '')) {
282
+ return;
283
+ }
284
+ // Non-direct message โ†’ queue a delayed response.
285
+ // If no one else messages in this channel for 2 minutes, emit to the LLM.
286
+ // The system prompt instructs the LLM to return empty if the message isn't worth responding to.
287
+ const timer = setTimeout(() => {
288
+ this.pendingResponses.delete(channelId);
289
+ this.emit(event);
290
+ }, DiscordChannelAdapter.RESPONSE_DELAY_MS);
291
+ this.pendingResponses.set(channelId, { timer, event });
292
+ }
293
+ async handleInboundInteraction(interaction) {
294
+ // Delegate to external handlers first (e.g., Founders extension).
295
+ for (const handler of this.externalInteractionHandlers) {
296
+ try {
297
+ const consumed = await handler(interaction);
298
+ if (consumed)
299
+ return;
300
+ }
301
+ catch (err) {
302
+ console.error('[Discord] External interaction handler error:', err);
303
+ }
304
+ }
305
+ if (interaction.isButton()) {
306
+ await this.handleButtonInteraction(interaction);
307
+ return;
308
+ }
309
+ if (!interaction.isChatInputCommand())
310
+ return;
311
+ // No-DM policy: ignore direct-message invocations.
312
+ if (!interaction.inGuild()) {
313
+ try {
314
+ await interaction.reply({ content: 'Use me in server channels (not DMs).', ephemeral: true });
315
+ }
316
+ catch {
317
+ // ignore
318
+ }
319
+ return;
320
+ }
321
+ const channel = interaction.channel;
322
+ if (!channel)
323
+ return;
324
+ if (channel.isDMBased())
325
+ return;
326
+ const command = interaction.commandName;
327
+ if (command === 'help') {
328
+ const tier = this.service.getTierFromInteraction(interaction);
329
+ const lines = [
330
+ '**Wunderland AI โ€” Help**',
331
+ '',
332
+ '__AI Commands__',
333
+ '`/ask question:<text>` โ€” ask the AI (daily quota)',
334
+ '`/deepdive question:<text>` โ€” deeper answer (separate quota)',
335
+ '`/summarize url:<link>` โ€” summarize a link (counts as /ask)',
336
+ '`/paper arxiv:<id-or-url>` โ€” summarize an arXiv paper (counts as /ask)',
337
+ '`/research topic:<text>` โ€” deep research (counts as /deepdive)',
338
+ '`/ask explain:true` โ€” include a tool/API trace after the answer',
339
+ '',
340
+ '__Tools__',
341
+ '`/search query:<text>` โ€” search the web',
342
+ '`/news query:<text>` โ€” search recent news',
343
+ '`/weather location:<city>` โ€” current weather and forecast',
344
+ '`/extract url:<link>` โ€” extract content from a URL',
345
+ '`/gif query:<text>` โ€” search for a GIF',
346
+ '`/image query:<text>` โ€” search for stock images',
347
+ '',
348
+ '__Community__',
349
+ '`/trivia` โ€” trivia from 17 categories (4,000+ questions)',
350
+ '`/trivia daily:True` โ€” daily challenge (hard, 50 pts, 1 attempt)',
351
+ '`/trivia_leaderboard` โ€” leaderboard (daily/weekly/monthly/all-time)',
352
+ '`/trivia_stats` โ€” your level, streaks, and category breakdown',
353
+ '',
354
+ '__Utilities__',
355
+ '`/quota` โ€” view your remaining daily quotas',
356
+ '`/status` โ€” bot health and connectivity',
357
+ '`/faq [key]` โ€” view FAQ entries',
358
+ '`/note_add`, `/note_list`, `/note_delete` โ€” personal notes',
359
+ '`/pair` โ€” request pairing/allowlist access',
360
+ ];
361
+ if (tier === 'team') {
362
+ lines.push('', '__Team Commands__', '`/clear [count]` โ€” clear messages from this channel', '`/faq_set key:<k> question:<q> answer:<a>` โ€” create/update FAQ entry');
363
+ }
364
+ lines.push('', '_DMs: this bot ignores DMs by default._');
365
+ await this.safeEphemeralReply(interaction, lines.join('\n'));
366
+ return;
367
+ }
368
+ if (command === 'pair') {
369
+ let pairDeferOk = false;
370
+ try {
371
+ await interaction.deferReply();
372
+ pairDeferOk = true;
373
+ }
374
+ catch (deferErr) {
375
+ console.warn('[DiscordChannel] deferReply failed for /pair:', deferErr instanceof Error ? deferErr.message : String(deferErr));
376
+ // Continue anyway โ€” channel.send fallback will handle delivery.
377
+ }
378
+ this.service.registerPendingInteraction(interaction);
379
+ if (pairDeferOk)
380
+ this.service.markInteractionDeferred(interaction.id);
381
+ this.emitSyntheticInteractionMessage(interaction, '!pair', { explicitInvocation: true });
382
+ return;
383
+ }
384
+ if (command === 'ask') {
385
+ // Defer IMMEDIATELY โ€” Discord only gives 3 seconds before the interaction token expires.
386
+ const interactionAge = Date.now() - interaction.createdTimestamp;
387
+ let deferOk = false;
388
+ try {
389
+ await interaction.deferReply();
390
+ deferOk = true;
391
+ }
392
+ catch (deferErr) {
393
+ console.warn(`[DiscordChannel] deferReply failed for /ask (age=${interactionAge}ms):`, deferErr instanceof Error ? deferErr.message : String(deferErr));
394
+ // Continue anyway โ€” channel.send() fallback in sendMessage will handle delivery.
395
+ }
396
+ const question = interaction.options.getString('question', true);
397
+ const explain = interaction.options.getBoolean('explain') ?? false;
398
+ const tier = this.service.getTierFromInteraction(interaction);
399
+ const quota = this.service.checkQuota(interaction.user.id, tier, 'ask');
400
+ if (!quota.allowed) {
401
+ try {
402
+ await interaction.editReply(`Daily quota reached for **/ask**.\nUsed: **${quota.used}/${quota.limit}** (resets daily โ€” ${this.quotaTzLabel()}).\nRun \`/quota\` to see your limits.`);
403
+ }
404
+ catch {
405
+ // Interaction dead โ€” not critical, user will notice via /quota
406
+ }
407
+ return;
408
+ }
409
+ this.service.registerPendingInteractionWithQuota(interaction, {
410
+ dayKey: quota.dayKey,
411
+ userId: interaction.user.id,
412
+ command: quota.bucket,
413
+ amount: 1,
414
+ tier,
415
+ });
416
+ if (deferOk)
417
+ this.service.markInteractionDeferred(interaction.id);
418
+ this.emitSyntheticInteractionMessage(interaction, question, {
419
+ explicitInvocation: true,
420
+ explain,
421
+ });
422
+ return;
423
+ }
424
+ if (command === 'summarize') {
425
+ let summarizeDeferOk = false;
426
+ try {
427
+ await interaction.deferReply();
428
+ summarizeDeferOk = true;
429
+ }
430
+ catch (deferErr) {
431
+ console.warn('[DiscordChannel] deferReply failed for /summarize:', deferErr instanceof Error ? deferErr.message : String(deferErr));
432
+ }
433
+ const url = interaction.options.getString('url', true);
434
+ const explain = interaction.options.getBoolean('explain') ?? false;
435
+ const tier = this.service.getTierFromInteraction(interaction);
436
+ const quota = this.service.checkQuota(interaction.user.id, tier, 'summarize');
437
+ if (!quota.allowed) {
438
+ try {
439
+ await interaction.editReply(`Daily quota reached for **/summarize** (counts as **/ask**).\nUsed: **${quota.used}/${quota.limit}** (resets daily โ€” ${this.quotaTzLabel()}).\nRun \`/quota\` to see your limits.`);
440
+ }
441
+ catch { /* ignore */ }
442
+ return;
443
+ }
444
+ this.service.registerPendingInteractionWithQuota(interaction, {
445
+ dayKey: quota.dayKey,
446
+ userId: interaction.user.id,
447
+ command: quota.bucket,
448
+ amount: 1,
449
+ tier,
450
+ });
451
+ if (summarizeDeferOk)
452
+ this.service.markInteractionDeferred(interaction.id);
453
+ this.emitSyntheticInteractionMessage(interaction, `Summarize this link:\n${url}`, {
454
+ explicitInvocation: true,
455
+ explain,
456
+ });
457
+ return;
458
+ }
459
+ if (command === 'deepdive') {
460
+ let deepdiveDeferOk = false;
461
+ try {
462
+ await interaction.deferReply();
463
+ deepdiveDeferOk = true;
464
+ }
465
+ catch (deferErr) {
466
+ console.warn('[DiscordChannel] deferReply failed for /deepdive:', deferErr instanceof Error ? deferErr.message : String(deferErr));
467
+ }
468
+ const question = interaction.options.getString('question', true);
469
+ const explain = interaction.options.getBoolean('explain') ?? false;
470
+ const tier = this.service.getTierFromInteraction(interaction);
471
+ const quota = this.service.checkQuota(interaction.user.id, tier, 'deepdive');
472
+ if (!quota.allowed) {
473
+ try {
474
+ await interaction.editReply(`Daily quota reached for **/deepdive**.\nUsed: **${quota.used}/${quota.limit}** (resets daily โ€” ${this.quotaTzLabel()}).\nRun \`/quota\` to see your limits.`);
475
+ }
476
+ catch { /* ignore */ }
477
+ return;
478
+ }
479
+ this.service.registerPendingInteractionWithQuota(interaction, {
480
+ dayKey: quota.dayKey,
481
+ userId: interaction.user.id,
482
+ command: quota.bucket,
483
+ amount: 1,
484
+ tier,
485
+ });
486
+ if (deepdiveDeferOk)
487
+ this.service.markInteractionDeferred(interaction.id);
488
+ this.emitSyntheticInteractionMessage(interaction, `Deep dive:\n${question}`, {
489
+ explicitInvocation: true,
490
+ explain,
491
+ });
492
+ return;
493
+ }
494
+ if (command === 'paper') {
495
+ let paperDeferOk = false;
496
+ try {
497
+ await interaction.deferReply();
498
+ paperDeferOk = true;
499
+ }
500
+ catch (deferErr) {
501
+ console.warn('[DiscordChannel] deferReply failed for /paper:', deferErr instanceof Error ? deferErr.message : String(deferErr));
502
+ }
503
+ const arxiv = interaction.options.getString('arxiv', true);
504
+ const explain = interaction.options.getBoolean('explain') ?? false;
505
+ const tier = this.service.getTierFromInteraction(interaction);
506
+ const quota = this.service.checkQuota(interaction.user.id, tier, 'paper');
507
+ if (!quota.allowed) {
508
+ try {
509
+ await interaction.editReply(`Daily quota reached for **/paper** (counts as **/ask**).\nUsed: **${quota.used}/${quota.limit}** (resets daily โ€” ${this.quotaTzLabel()}).\nRun \`/quota\` to see your limits.`);
510
+ }
511
+ catch { /* ignore */ }
512
+ return;
513
+ }
514
+ this.service.registerPendingInteractionWithQuota(interaction, {
515
+ dayKey: quota.dayKey,
516
+ userId: interaction.user.id,
517
+ command: quota.bucket,
518
+ amount: 1,
519
+ tier,
520
+ });
521
+ if (paperDeferOk)
522
+ this.service.markInteractionDeferred(interaction.id);
523
+ this.emitSyntheticInteractionMessage(interaction, `Summarize this arXiv paper for an AI/LLM practitioner:\n${arxiv}`, { explicitInvocation: true, explain });
524
+ return;
525
+ }
526
+ if (command === 'quota') {
527
+ const tier = this.service.getTierFromInteraction(interaction);
528
+ const ask = this.service.checkQuota(interaction.user.id, tier, 'ask');
529
+ const deepdive = this.service.checkQuota(interaction.user.id, tier, 'deepdive');
530
+ const lines = [
531
+ '**Wunderland AI โ€” Quotas**',
532
+ `Tier: **${tier.toUpperCase()}**`,
533
+ `Reset: daily (${this.quotaTzLabel()}) โ€” key: \`${ask.dayKey}\``,
534
+ '',
535
+ `- **Ask**: ${ask.used}/${ask.limit} used (remaining: ${Math.max(0, ask.limit - ask.used)})`,
536
+ `- **Deepdive**: ${deepdive.used}/${deepdive.limit} used (remaining: ${Math.max(0, deepdive.limit - deepdive.used)})`,
537
+ ];
538
+ await this.safeEphemeralReply(interaction, lines.join('\n'));
539
+ return;
540
+ }
541
+ if (command === 'status') {
542
+ const tier = this.service.getTierFromInteraction(interaction);
543
+ const bot = await this.service.getBotInfo();
544
+ const ollama = await this.probeOllama();
545
+ const statePath = this.service.getStateStorePath();
546
+ const text = [
547
+ '**Wunderland AI โ€” Status**',
548
+ `Bot: ${bot ? `**${bot.tag}** (\`${bot.id}\`)` : 'unknown'}`,
549
+ `Tier: **${tier.toUpperCase()}**`,
550
+ `State: \`${statePath}\``,
551
+ `Ollama: ${ollama.ok ? `โœ… ${ollama.baseUrl}` : `โŒ ${ollama.baseUrl} (${ollama.error})`}`,
552
+ 'DM policy: ignores DMs by default.',
553
+ ].join('\n');
554
+ await this.safeEphemeralReply(interaction, text);
555
+ return;
556
+ }
557
+ if (command === 'faq') {
558
+ const keyRaw = interaction.options.getString('key') ?? '';
559
+ const key = keyRaw.trim().toLowerCase();
560
+ if (key) {
561
+ // 1) Try exact slug match first
562
+ const exactEntry = this.service.getFaq(key);
563
+ if (exactEntry) {
564
+ const embed = {
565
+ title: `FAQ: ${exactEntry.key}`,
566
+ description: `**Q:** ${exactEntry.question}\n\n**A:** ${exactEntry.answer}`,
567
+ color: this.brandColor(),
568
+ footer: this.brandFooter() ? { text: this.brandFooter() } : undefined,
569
+ };
570
+ await this.safeEphemeralReply(interaction, undefined, { embeds: [embed] });
571
+ return;
572
+ }
573
+ // 2) Fuzzy match โ€” treat the key input as a free-text query
574
+ const allEntries = this.service.listFaq();
575
+ if (allEntries.length > 0) {
576
+ const matcher = new faq_matcher_1.FAQMatcher(allEntries);
577
+ const results = matcher.match(key, 5, 0.08);
578
+ if (results.length > 0) {
579
+ const best = results[0];
580
+ const embed = {
581
+ title: `FAQ: ${best.key}`,
582
+ description: `**Q:** ${best.question}\n\n**A:** ${best.answer}`,
583
+ color: this.brandColor(),
584
+ footer: this.brandFooter() ? { text: this.brandFooter() } : undefined,
585
+ };
586
+ // Build "related" suggestions from remaining matches
587
+ const related = results.slice(1, 4);
588
+ let text;
589
+ if (related.length > 0) {
590
+ const suggestions = related.map((r) => `\`${r.key}\` โ€” ${r.question}`).join('\n');
591
+ text = `**Related:**\n${suggestions}`;
592
+ }
593
+ const replyContent = text
594
+ ? `${text}\n\nUse \`/faq key:<key>\` to view another entry.`
595
+ : undefined;
596
+ await this.safeEphemeralReply(interaction, replyContent, { embeds: [embed] });
597
+ return;
598
+ }
599
+ }
600
+ // 3) No match at all โ€” suggest browsing
601
+ await this.safeEphemeralReply(interaction, `No FAQ entry found for: \`${key}\`.\nUse \`/faq\` without a key to browse all entries.`);
602
+ return;
603
+ }
604
+ const list = this.service.listFaq();
605
+ if (list.length === 0) {
606
+ await this.safeEphemeralReply(interaction, 'No FAQ entries yet.');
607
+ return;
608
+ }
609
+ // Group by category, show organized browse view in an embed (avoids 2000-char content limit)
610
+ const CATEGORY_LABELS = {
611
+ general: 'General',
612
+ features: 'Features & Platform',
613
+ billing: 'Billing & Pricing',
614
+ technical: 'Technical',
615
+ discord: 'Discord & Server',
616
+ founders: 'The Founders',
617
+ socialclub: 'Social Club',
618
+ security: 'Security & Privacy',
619
+ legal: 'Legal',
620
+ troubleshooting: 'Troubleshooting',
621
+ };
622
+ const grouped = {};
623
+ for (const entry of list) {
624
+ const cat = entry.category || 'general';
625
+ (grouped[cat] ?? (grouped[cat] = [])).push(entry);
626
+ }
627
+ for (const entries of Object.values(grouped)) {
628
+ entries.sort((a, b) => a.key.localeCompare(b.key));
629
+ }
630
+ const categoryOrder = Object.keys(CATEGORY_LABELS);
631
+ const sortedCats = Object.keys(grouped).sort((a, b) => {
632
+ const ai = categoryOrder.indexOf(a);
633
+ const bi = categoryOrder.indexOf(b);
634
+ return (ai === -1 ? 999 : ai) - (bi === -1 ? 999 : bi);
635
+ });
636
+ const lines = [];
637
+ for (const cat of sortedCats) {
638
+ const entries = grouped[cat];
639
+ const label = CATEGORY_LABELS[cat] || cat.charAt(0).toUpperCase() + cat.slice(1);
640
+ lines.push(`**${label}** (${entries.length})`);
641
+ const shown = entries.slice(0, 3);
642
+ for (const e of shown) {
643
+ lines.push(`\`${e.key}\` โ€” ${e.question}`);
644
+ }
645
+ if (entries.length > 3) {
646
+ lines.push(`_โ€ฆ+${entries.length - 3} more_`);
647
+ }
648
+ lines.push('');
649
+ }
650
+ // Trim to fit embed description limit (4096 chars)
651
+ let description = lines.join('\n');
652
+ if (description.length > 4000) {
653
+ description = description.slice(0, 3997) + 'โ€ฆ';
654
+ }
655
+ const browseEmbed = {
656
+ title: `Wunderland AI โ€” FAQ (${list.length} entries)`,
657
+ description,
658
+ color: this.brandColor(),
659
+ footer: { text: 'Use /faq key:<question> to search ยท e.g. /faq key:how do I deploy?' },
660
+ };
661
+ await this.safeEphemeralReply(interaction, undefined, { embeds: [browseEmbed] });
662
+ return;
663
+ }
664
+ if (command === 'faq_set') {
665
+ const tier = this.service.getTierFromInteraction(interaction);
666
+ if (tier !== 'team') {
667
+ await this.safeEphemeralReply(interaction, 'Only **Team** can set FAQ entries.');
668
+ return;
669
+ }
670
+ const key = (interaction.options.getString('key', true) || '').trim().toLowerCase();
671
+ const question = (interaction.options.getString('question', true) || '').trim();
672
+ const answer = (interaction.options.getString('answer', true) || '').trim();
673
+ if (!key) {
674
+ await this.safeEphemeralReply(interaction, 'FAQ key cannot be empty.');
675
+ return;
676
+ }
677
+ const entry = this.service.setFaq(key, question, answer, interaction.user.id);
678
+ await this.safeEphemeralReply(interaction, `Saved FAQ \`${entry.key}\` (updated ${entry.updatedAt}).`);
679
+ return;
680
+ }
681
+ if (command === 'clear') {
682
+ const tier = this.service.getTierFromInteraction(interaction);
683
+ if (tier !== 'team') {
684
+ await this.safeEphemeralReply(interaction, 'Only **Team** can clear messages.');
685
+ return;
686
+ }
687
+ try {
688
+ await interaction.deferReply({ ephemeral: true });
689
+ }
690
+ catch (deferErr) {
691
+ console.warn('[DiscordChannel] deferReply failed for /clear:', deferErr instanceof Error ? deferErr.message : String(deferErr));
692
+ }
693
+ const count = Math.min(interaction.options.getInteger('count') ?? 50, 100);
694
+ const channel = interaction.channel;
695
+ if (!channel || !('messages' in channel)) {
696
+ try {
697
+ await interaction.editReply('Cannot access this channel.');
698
+ }
699
+ catch { /* ignore */ }
700
+ return;
701
+ }
702
+ try {
703
+ // bulkDelete with filterOld=true skips messages older than 14 days instead of throwing
704
+ const deleted = await channel.bulkDelete(count, true);
705
+ try {
706
+ await interaction.editReply(`Cleared **${deleted.size}** message(s).`);
707
+ }
708
+ catch { /* ignore */ }
709
+ }
710
+ catch (err) {
711
+ console.error('[DiscordChannel] /clear error:', err instanceof Error ? err.message : String(err));
712
+ try {
713
+ await interaction.editReply('Failed to clear messages. Check bot permissions (Manage Messages).');
714
+ }
715
+ catch { /* ignore */ }
716
+ }
717
+ return;
718
+ }
719
+ if (command === 'note_add') {
720
+ const title = (interaction.options.getString('title') || '').trim();
721
+ const body = (interaction.options.getString('body', true) || '').trim();
722
+ if (!body) {
723
+ await this.safeEphemeralReply(interaction, 'Note body cannot be empty.');
724
+ return;
725
+ }
726
+ const note = this.service.addNote(interaction.user.id, title, body);
727
+ await this.safeEphemeralReply(interaction, `Saved note **#${note.id}**: ${note.title}`);
728
+ return;
729
+ }
730
+ if (command === 'note_list') {
731
+ const notes = this.service.listNotes(interaction.user.id);
732
+ if (notes.length === 0) {
733
+ await this.safeEphemeralReply(interaction, 'No notes yet. Use `/note_add` to create one.');
734
+ return;
735
+ }
736
+ const top = notes.slice(-25).reverse();
737
+ const lines = [
738
+ '**Your Notes**',
739
+ ...top.map((n) => `- **#${n.id}** โ€” ${n.title} (\`${n.createdAt.slice(0, 10)}\`)`),
740
+ '\nDelete with `/note_delete id:<id>`.',
741
+ ];
742
+ await this.safeEphemeralReply(interaction, lines.join('\n'));
743
+ return;
744
+ }
745
+ if (command === 'note_delete') {
746
+ const id = interaction.options.getInteger('id', true);
747
+ const ok = this.service.deleteNote(interaction.user.id, id);
748
+ await this.safeEphemeralReply(interaction, ok ? `Deleted note **#${id}**.` : `Note **#${id}** not found.`);
749
+ return;
750
+ }
751
+ if (command === 'search') {
752
+ let searchDeferOk = false;
753
+ try {
754
+ await interaction.deferReply();
755
+ searchDeferOk = true;
756
+ }
757
+ catch (deferErr) {
758
+ console.warn('[DiscordChannel] deferReply failed for /search:', deferErr instanceof Error ? deferErr.message : String(deferErr));
759
+ }
760
+ const query = interaction.options.getString('query', true);
761
+ const tier = this.service.getTierFromInteraction(interaction);
762
+ const quota = this.service.checkQuota(interaction.user.id, tier, 'ask');
763
+ if (!quota.allowed) {
764
+ try {
765
+ await interaction.editReply(`Daily quota reached for **/search** (counts as **/ask**).\nUsed: **${quota.used}/${quota.limit}** (resets daily โ€” ${this.quotaTzLabel()}).\nRun \`/quota\` to see your limits.`);
766
+ }
767
+ catch { /* ignore */ }
768
+ return;
769
+ }
770
+ this.service.registerPendingInteractionWithQuota(interaction, {
771
+ dayKey: quota.dayKey,
772
+ userId: interaction.user.id,
773
+ command: quota.bucket,
774
+ amount: 1,
775
+ tier,
776
+ });
777
+ if (searchDeferOk)
778
+ this.service.markInteractionDeferred(interaction.id);
779
+ this.emitSyntheticInteractionMessage(interaction, `Search the web for: ${query}`, {
780
+ explicitInvocation: true,
781
+ });
782
+ return;
783
+ }
784
+ if (command === 'gif') {
785
+ let gifDeferOk = false;
786
+ try {
787
+ await interaction.deferReply();
788
+ gifDeferOk = true;
789
+ }
790
+ catch (deferErr) {
791
+ console.warn('[DiscordChannel] deferReply failed for /gif:', deferErr instanceof Error ? deferErr.message : String(deferErr));
792
+ }
793
+ const query = interaction.options.getString('query', true);
794
+ const tier = this.service.getTierFromInteraction(interaction);
795
+ const quota = this.service.checkQuota(interaction.user.id, tier, 'ask');
796
+ if (!quota.allowed) {
797
+ try {
798
+ await interaction.editReply(`Daily quota reached for **/gif** (counts as **/ask**).\nUsed: **${quota.used}/${quota.limit}** (resets daily โ€” ${this.quotaTzLabel()}).\nRun \`/quota\` to see your limits.`);
799
+ }
800
+ catch { /* ignore */ }
801
+ return;
802
+ }
803
+ this.service.registerPendingInteractionWithQuota(interaction, {
804
+ dayKey: quota.dayKey,
805
+ userId: interaction.user.id,
806
+ command: quota.bucket,
807
+ amount: 1,
808
+ tier,
809
+ });
810
+ if (gifDeferOk)
811
+ this.service.markInteractionDeferred(interaction.id);
812
+ this.emitSyntheticInteractionMessage(interaction, `Find a GIF for: ${query}\n\nUse the giphy_search tool and respond with only the GIF URL, no other text.`, { explicitInvocation: true });
813
+ return;
814
+ }
815
+ if (command === 'image') {
816
+ let imageDeferOk = false;
817
+ try {
818
+ await interaction.deferReply();
819
+ imageDeferOk = true;
820
+ }
821
+ catch (deferErr) {
822
+ console.warn('[DiscordChannel] deferReply failed for /image:', deferErr instanceof Error ? deferErr.message : String(deferErr));
823
+ }
824
+ const query = interaction.options.getString('query', true);
825
+ const tier = this.service.getTierFromInteraction(interaction);
826
+ const quota = this.service.checkQuota(interaction.user.id, tier, 'ask');
827
+ if (!quota.allowed) {
828
+ try {
829
+ await interaction.editReply(`Daily quota reached for **/image** (counts as **/ask**).\nUsed: **${quota.used}/${quota.limit}** (resets daily โ€” ${this.quotaTzLabel()}).\nRun \`/quota\` to see your limits.`);
830
+ }
831
+ catch { /* ignore */ }
832
+ return;
833
+ }
834
+ this.service.registerPendingInteractionWithQuota(interaction, {
835
+ dayKey: quota.dayKey,
836
+ userId: interaction.user.id,
837
+ command: quota.bucket,
838
+ amount: 1,
839
+ tier,
840
+ });
841
+ if (imageDeferOk)
842
+ this.service.markInteractionDeferred(interaction.id);
843
+ this.emitSyntheticInteractionMessage(interaction, `Find stock images for: ${query}\n\nUse the image_search tool and respond with the image URLs.`, { explicitInvocation: true });
844
+ return;
845
+ }
846
+ if (command === 'news') {
847
+ let newsDeferOk = false;
848
+ try {
849
+ await interaction.deferReply();
850
+ newsDeferOk = true;
851
+ }
852
+ catch (deferErr) {
853
+ console.warn('[DiscordChannel] deferReply failed for /news:', deferErr instanceof Error ? deferErr.message : String(deferErr));
854
+ }
855
+ const query = interaction.options.getString('query', true);
856
+ const tier = this.service.getTierFromInteraction(interaction);
857
+ const quota = this.service.checkQuota(interaction.user.id, tier, 'ask');
858
+ if (!quota.allowed) {
859
+ try {
860
+ await interaction.editReply(`Daily quota reached for **/news** (counts as **/ask**).\nUsed: **${quota.used}/${quota.limit}** (resets daily โ€” ${this.quotaTzLabel()}).\nRun \`/quota\` to see your limits.`);
861
+ }
862
+ catch { /* ignore */ }
863
+ return;
864
+ }
865
+ this.service.registerPendingInteractionWithQuota(interaction, {
866
+ dayKey: quota.dayKey,
867
+ userId: interaction.user.id,
868
+ command: quota.bucket,
869
+ amount: 1,
870
+ tier,
871
+ });
872
+ if (newsDeferOk)
873
+ this.service.markInteractionDeferred(interaction.id);
874
+ this.emitSyntheticInteractionMessage(interaction, `Search for recent news about: ${query}`, {
875
+ explicitInvocation: true,
876
+ });
877
+ return;
878
+ }
879
+ if (command === 'extract') {
880
+ let extractDeferOk = false;
881
+ try {
882
+ await interaction.deferReply();
883
+ extractDeferOk = true;
884
+ }
885
+ catch (deferErr) {
886
+ console.warn('[DiscordChannel] deferReply failed for /extract:', deferErr instanceof Error ? deferErr.message : String(deferErr));
887
+ }
888
+ const url = interaction.options.getString('url', true);
889
+ const tier = this.service.getTierFromInteraction(interaction);
890
+ const quota = this.service.checkQuota(interaction.user.id, tier, 'ask');
891
+ if (!quota.allowed) {
892
+ try {
893
+ await interaction.editReply(`Daily quota reached for **/extract** (counts as **/ask**).\nUsed: **${quota.used}/${quota.limit}** (resets daily โ€” ${this.quotaTzLabel()}).\nRun \`/quota\` to see your limits.`);
894
+ }
895
+ catch { /* ignore */ }
896
+ return;
897
+ }
898
+ this.service.registerPendingInteractionWithQuota(interaction, {
899
+ dayKey: quota.dayKey,
900
+ userId: interaction.user.id,
901
+ command: quota.bucket,
902
+ amount: 1,
903
+ tier,
904
+ });
905
+ if (extractDeferOk)
906
+ this.service.markInteractionDeferred(interaction.id);
907
+ this.emitSyntheticInteractionMessage(interaction, `Extract and summarize the content from this URL:\n${url}`, {
908
+ explicitInvocation: true,
909
+ });
910
+ return;
911
+ }
912
+ if (command === 'research') {
913
+ let researchDeferOk = false;
914
+ try {
915
+ await interaction.deferReply();
916
+ researchDeferOk = true;
917
+ }
918
+ catch (deferErr) {
919
+ console.warn('[DiscordChannel] deferReply failed for /research:', deferErr instanceof Error ? deferErr.message : String(deferErr));
920
+ }
921
+ const topic = interaction.options.getString('topic', true);
922
+ const tier = this.service.getTierFromInteraction(interaction);
923
+ const quota = this.service.checkQuota(interaction.user.id, tier, 'deepdive');
924
+ if (!quota.allowed) {
925
+ try {
926
+ await interaction.editReply(`Daily quota reached for **/research** (counts as **/deepdive**).\nUsed: **${quota.used}/${quota.limit}** (resets daily โ€” ${this.quotaTzLabel()}).\nRun \`/quota\` to see your limits.`);
927
+ }
928
+ catch { /* ignore */ }
929
+ return;
930
+ }
931
+ this.service.registerPendingInteractionWithQuota(interaction, {
932
+ dayKey: quota.dayKey,
933
+ userId: interaction.user.id,
934
+ command: quota.bucket,
935
+ amount: 1,
936
+ tier,
937
+ });
938
+ if (researchDeferOk)
939
+ this.service.markInteractionDeferred(interaction.id);
940
+ this.emitSyntheticInteractionMessage(interaction, `Research this topic thoroughly using web search, academic sources, and social/news trends:\n${topic}`, { explicitInvocation: true });
941
+ return;
942
+ }
943
+ if (command === 'weather') {
944
+ let weatherDeferOk = false;
945
+ try {
946
+ await interaction.deferReply();
947
+ weatherDeferOk = true;
948
+ }
949
+ catch (deferErr) {
950
+ console.warn('[DiscordChannel] deferReply failed for /weather:', deferErr instanceof Error ? deferErr.message : String(deferErr));
951
+ }
952
+ const location = interaction.options.getString('location', true);
953
+ const tier = this.service.getTierFromInteraction(interaction);
954
+ const quota = this.service.checkQuota(interaction.user.id, tier, 'ask');
955
+ if (!quota.allowed) {
956
+ try {
957
+ await interaction.editReply(`Daily quota reached for **/weather** (counts as **/ask**).\nUsed: **${quota.used}/${quota.limit}** (resets daily โ€” ${this.quotaTzLabel()}).\nRun \`/quota\` to see your limits.`);
958
+ }
959
+ catch { /* ignore */ }
960
+ return;
961
+ }
962
+ this.service.registerPendingInteractionWithQuota(interaction, {
963
+ dayKey: quota.dayKey,
964
+ userId: interaction.user.id,
965
+ command: quota.bucket,
966
+ amount: 1,
967
+ tier,
968
+ });
969
+ if (weatherDeferOk)
970
+ this.service.markInteractionDeferred(interaction.id);
971
+ this.emitSyntheticInteractionMessage(interaction, `What is the current weather in ${location}? Use the weather_lookup tool.`, { explicitInvocation: true });
972
+ return;
973
+ }
974
+ if (command === 'trivia' || command === 'trivia_leaderboard' || command === 'trivia_stats' || command === 'trivia_end') {
975
+ // Trivia commands restricted to #games channel
976
+ const channelName = DiscordChannelAdapter.normalizeChannelName(interaction.channel?.name ?? '');
977
+ if (channelName !== 'games') {
978
+ await this.safeEphemeralReply(interaction, 'Trivia commands are only available in **#games**! Head over there to play.');
979
+ return;
980
+ }
981
+ }
982
+ if (command === 'trivia') {
983
+ const isDaily = interaction.options.getBoolean('daily') ?? false;
984
+ const categoryInput = interaction.options.getString('category') ?? undefined;
985
+ const difficultyInput = (interaction.options.getString('difficulty') ?? undefined);
986
+ // Daily challenge: 1 attempt per day
987
+ if (isDaily) {
988
+ const today = new Date().toISOString().slice(0, 10);
989
+ if (this.service.hasDoneDaily(interaction.user.id, today)) {
990
+ await this.safeEphemeralReply(interaction, "You've already completed today's daily challenge. Come back tomorrow!");
991
+ return;
992
+ }
993
+ }
994
+ const isSession = interaction.options.getBoolean('session') ?? false;
995
+ if (isSession) {
996
+ if (this.activeSessionUsers.has(interaction.user.id)) {
997
+ await this.safeEphemeralReply(interaction, 'You already have an active trivia session. Finish it first!');
998
+ return;
999
+ }
1000
+ const cat = categoryInput ? (0, TriviaBank_1.categoryByName)(categoryInput) : undefined;
1001
+ const catName = cat?.name ?? 'Mixed';
1002
+ const diff = difficultyInput ?? 'medium';
1003
+ const diffLabel = diff.charAt(0).toUpperCase() + diff.slice(1);
1004
+ const questions = await (0, TriviaBank_1.fetchOpenTDB)(10, cat?.id, diff);
1005
+ if (questions.length < 5) {
1006
+ await this.safeEphemeralReply(interaction, 'Could not fetch enough questions. Try again in a moment.');
1007
+ return;
1008
+ }
1009
+ await interaction.deferReply();
1010
+ const channel = interaction.channel;
1011
+ if (!channel || !('threads' in channel)) {
1012
+ await interaction.editReply('Cannot create threads in this channel.');
1013
+ return;
1014
+ }
1015
+ const threadName = `๐Ÿง  Trivia: ${catName} (${diffLabel}) โ€” ${interaction.user.displayName}`;
1016
+ const thread = await channel.threads.create({
1017
+ name: threadName.slice(0, 100),
1018
+ autoArchiveDuration: 60,
1019
+ type: discord_js_1.ChannelType.PublicThread,
1020
+ });
1021
+ await interaction.editReply(`Session started! Head to ${thread}`);
1022
+ this.activeSessionUsers.add(interaction.user.id);
1023
+ const session = {
1024
+ threadId: thread.id,
1025
+ userId: interaction.user.id,
1026
+ questions,
1027
+ currentIndex: 0,
1028
+ results: [],
1029
+ startedAtMs: Date.now(),
1030
+ questionPostedAtMs: Date.now(),
1031
+ currentMessageId: '',
1032
+ timeoutTimer: null,
1033
+ category: cat?.name ?? '',
1034
+ difficulty: diff,
1035
+ };
1036
+ this.triviaSessionFlows.set(thread.id, session);
1037
+ // 10-minute overall session inactivity timeout
1038
+ session._sessionCleanup = setTimeout(() => {
1039
+ if (this.triviaSessionFlows.has(thread.id)) {
1040
+ this.triviaSessionFlows.delete(thread.id);
1041
+ this.activeSessionUsers.delete(interaction.user.id);
1042
+ thread.send({ content: 'โฐ Session expired due to inactivity.' }).catch(() => { });
1043
+ thread.setArchived(true).catch(() => { });
1044
+ }
1045
+ }, 10 * 60000);
1046
+ await this.postSessionQuestion(thread, session);
1047
+ return;
1048
+ }
1049
+ let q;
1050
+ try {
1051
+ q = isDaily
1052
+ ? await (0, TriviaBank_1.getDailyQuestion)()
1053
+ : await (0, TriviaBank_1.getQuestion)(categoryInput, difficultyInput);
1054
+ }
1055
+ catch {
1056
+ q = (0, TriviaBank_1.randomTriviaQuestion)(); // last-resort fallback
1057
+ }
1058
+ const catEmoji = TriviaBank_1.TRIVIA_CATEGORIES.find((c) => q.category.toLowerCase().includes(c.name.toLowerCase().split(' ')[0]))?.emoji ?? '๐Ÿง ';
1059
+ const diffLabel = q.difficulty.charAt(0).toUpperCase() + q.difficulty.slice(1);
1060
+ const pts = isDaily ? 50 : TriviaBank_1.DIFFICULTY_POINTS[q.difficulty];
1061
+ const row = new discord_js_1.ActionRowBuilder().addComponents(new discord_js_1.ButtonBuilder().setCustomId(`rh_trivia:${q.id}:0`).setLabel('A').setStyle(discord_js_1.ButtonStyle.Secondary), new discord_js_1.ButtonBuilder().setCustomId(`rh_trivia:${q.id}:1`).setLabel('B').setStyle(discord_js_1.ButtonStyle.Secondary), new discord_js_1.ButtonBuilder().setCustomId(`rh_trivia:${q.id}:2`).setLabel('C').setStyle(discord_js_1.ButtonStyle.Secondary), new discord_js_1.ButtonBuilder().setCustomId(`rh_trivia:${q.id}:3`).setLabel('D').setStyle(discord_js_1.ButtonStyle.Secondary));
1062
+ const titlePrefix = isDaily ? '๐Ÿ“… Daily Challenge' : `${catEmoji} Trivia`;
1063
+ const embed = {
1064
+ title: titlePrefix,
1065
+ description: [
1066
+ `**${q.question}**`,
1067
+ '',
1068
+ `**A.** ${q.choices[0]}`,
1069
+ `**B.** ${q.choices[1]}`,
1070
+ `**C.** ${q.choices[2]}`,
1071
+ `**D.** ${q.choices[3]}`,
1072
+ '',
1073
+ `${catEmoji} ${q.category} ยท **${diffLabel}** ยท ${pts} pts${isDaily ? ' ยท 1 attempt' : ''}`,
1074
+ '_Click a button to answer โ€” speed bonus for fast answers!_',
1075
+ ].join('\n'),
1076
+ color: this.brandColor(),
1077
+ footer: this.brandFooter() ? { text: this.brandFooter() } : undefined,
1078
+ };
1079
+ try {
1080
+ await interaction.reply({ embeds: [embed], components: [row], ephemeral: false });
1081
+ }
1082
+ catch {
1083
+ return;
1084
+ }
1085
+ try {
1086
+ const msg = await interaction.fetchReply();
1087
+ const ttlMs = 10 * 60000;
1088
+ const cleanupTimer = setTimeout(() => {
1089
+ this.triviaSessions.delete(msg.id);
1090
+ try {
1091
+ void msg.edit({ components: [] });
1092
+ }
1093
+ catch {
1094
+ // ignore
1095
+ }
1096
+ }, ttlMs);
1097
+ this.triviaSessions.set(msg.id, {
1098
+ question: q,
1099
+ answeredUserIds: new Set(),
1100
+ expiresAtMs: Date.now() + ttlMs,
1101
+ cleanupTimer,
1102
+ startedAtMs: Date.now(),
1103
+ isDaily,
1104
+ });
1105
+ }
1106
+ catch {
1107
+ // ignore
1108
+ }
1109
+ return;
1110
+ }
1111
+ if (command === 'trivia_leaderboard') {
1112
+ const categoryInput = interaction.options.getString('category') ?? undefined;
1113
+ if (categoryInput) {
1114
+ const catEmoji = TriviaBank_1.TRIVIA_CATEGORIES.find((c) => categoryInput.toLowerCase().includes(c.name.toLowerCase().split(' ')[0]))?.emoji ?? '๐Ÿ“‹';
1115
+ const catName = TriviaBank_1.TRIVIA_CATEGORIES.find((c) => categoryInput.toLowerCase().includes(c.name.toLowerCase().split(' ')[0]))?.name ?? categoryInput;
1116
+ const catRows = this.service.triviaCategoryLeaderboard(catName, 10);
1117
+ if (catRows.length === 0) {
1118
+ await this.safeEphemeralReply(interaction, `No one has answered 5+ questions in **${catName}** yet. Be the first!`);
1119
+ return;
1120
+ }
1121
+ const lines = [
1122
+ `**${catEmoji} ${catName} โ€” Trivia Leaderboard**`,
1123
+ '',
1124
+ ...catRows.map((r, idx) => {
1125
+ const medal = idx === 0 ? '๐Ÿฅ‡' : idx === 1 ? '๐Ÿฅˆ' : idx === 2 ? '๐Ÿฅ‰' : `**${idx + 1}.**`;
1126
+ return `${medal} <@${r.userId}> โ€” ${r.wins}/${r.plays} (${r.winRate}%)`;
1127
+ }),
1128
+ '',
1129
+ '_Minimum 5 questions to qualify._',
1130
+ ];
1131
+ await this.safeEphemeralReply(interaction, lines.join('\n'));
1132
+ return;
1133
+ }
1134
+ const periodInput = interaction.options.getString('period') ?? 'all';
1135
+ const period = periodInput === 'all' ? undefined : periodInput;
1136
+ const rows = this.service.triviaLeaderboard(10, period);
1137
+ if (rows.length === 0) {
1138
+ const msg = period
1139
+ ? `No trivia activity ${period === 'daily' ? 'today' : period === 'weekly' ? 'this week' : 'this month'} yet. Be the first โ€” run \`/trivia\`!`
1140
+ : 'No trivia stats yet. Run `/trivia` to start!';
1141
+ await this.safeEphemeralReply(interaction, msg);
1142
+ return;
1143
+ }
1144
+ const periodLabels = {
1145
+ daily: "Today's",
1146
+ weekly: "This Week's",
1147
+ monthly: "This Month's",
1148
+ };
1149
+ const title = period ? `${periodLabels[period]} Trivia Leaderboard` : 'All-Time Trivia Leaderboard';
1150
+ const lines = [
1151
+ `**${title}**`,
1152
+ '',
1153
+ ...rows.map((r, idx) => {
1154
+ const medal = idx === 0 ? '๐Ÿฅ‡' : idx === 1 ? '๐Ÿฅˆ' : idx === 2 ? '๐Ÿฅ‰' : `**${idx + 1}.**`;
1155
+ const lvl = (0, TriviaBank_1.triviaLevelForPoints)(r.points);
1156
+ return `${medal} <@${r.userId}> โ€” **${r.points}** pts ยท ${r.wins}W/${r.plays}P ยท ${lvl.emoji} ${lvl.name}`;
1157
+ }),
1158
+ ];
1159
+ await this.safeEphemeralReply(interaction, lines.join('\n'));
1160
+ return;
1161
+ }
1162
+ if (command === 'trivia_end') {
1163
+ // Find this user's active session
1164
+ let foundSession = null;
1165
+ let foundThread = null;
1166
+ for (const [threadId, session] of this.triviaSessionFlows.entries()) {
1167
+ if (session.userId === interaction.user.id) {
1168
+ foundSession = session;
1169
+ try {
1170
+ foundThread = await interaction.client.channels.fetch(threadId);
1171
+ }
1172
+ catch { /* ignore */ }
1173
+ break;
1174
+ }
1175
+ }
1176
+ if (!foundSession) {
1177
+ await this.safeEphemeralReply(interaction, 'You don\'t have an active trivia session.');
1178
+ return;
1179
+ }
1180
+ await this.safeEphemeralReply(interaction, 'Ending your session...');
1181
+ if (foundThread) {
1182
+ await this.endSession(foundThread, foundSession);
1183
+ }
1184
+ else {
1185
+ // Thread gone, just cleanup
1186
+ this.triviaSessionFlows.delete(foundSession.threadId);
1187
+ this.activeSessionUsers.delete(foundSession.userId);
1188
+ if (foundSession.timeoutTimer)
1189
+ clearTimeout(foundSession.timeoutTimer);
1190
+ if (foundSession._sessionCleanup)
1191
+ clearTimeout(foundSession._sessionCleanup);
1192
+ }
1193
+ return;
1194
+ }
1195
+ if (command === 'trivia_stats') {
1196
+ const stats = this.service.getTriviaStats(interaction.user.id);
1197
+ if (!stats || stats.plays === 0) {
1198
+ await this.safeEphemeralReply(interaction, "You haven't played any trivia yet! Run `/trivia` to get started.");
1199
+ return;
1200
+ }
1201
+ const lvl = (0, TriviaBank_1.triviaLevelForPoints)(stats.points);
1202
+ const nxt = (0, TriviaBank_1.nextTriviaLevel)(stats.points);
1203
+ const winRate = stats.plays > 0 ? Math.round((stats.wins / stats.plays) * 100) : 0;
1204
+ const lines = [
1205
+ `${lvl.emoji} **${lvl.name}** (Level ${lvl.level})`,
1206
+ '',
1207
+ `**Points:** ${stats.points}${nxt ? ` ยท ${nxt.minPoints - stats.points} pts to ${nxt.emoji} ${nxt.name}` : ' ยท MAX LEVEL'}`,
1208
+ `**Record:** ${stats.wins}W / ${stats.plays}P (${winRate}% win rate)`,
1209
+ `**Streak:** ${stats.streak} current ยท ${stats.bestStreak} best`,
1210
+ `**Daily Challenges:** ${stats.dailyChallenges?.length ?? 0} completed`,
1211
+ ];
1212
+ // Category breakdown (top 5)
1213
+ const cats = Object.entries(stats.categories || {})
1214
+ .sort(([, a], [, b]) => b.plays - a.plays)
1215
+ .slice(0, 5);
1216
+ if (cats.length > 0) {
1217
+ lines.push('', '**Top Categories:**');
1218
+ for (const [catName, catStats] of cats) {
1219
+ const catEmoji = TriviaBank_1.TRIVIA_CATEGORIES.find((c) => catName.toLowerCase().includes(c.name.toLowerCase().split(' ')[0]))?.emoji ?? '๐Ÿ“‹';
1220
+ const catRate = catStats.plays > 0 ? Math.round((catStats.wins / catStats.plays) * 100) : 0;
1221
+ lines.push(`${catEmoji} ${catName}: ${catStats.wins}/${catStats.plays} (${catRate}%)`);
1222
+ }
1223
+ }
1224
+ const embed = {
1225
+ title: '๐Ÿ“Š Your Trivia Stats',
1226
+ description: lines.join('\n'),
1227
+ color: this.brandColor(),
1228
+ footer: this.brandFooter() ? { text: this.brandFooter() } : undefined,
1229
+ };
1230
+ await this.safeEphemeralReply(interaction, undefined, { embeds: [embed] });
1231
+ return;
1232
+ }
1233
+ }
1234
+ async handleButtonInteraction(interaction) {
1235
+ if (!interaction.inGuild()) {
1236
+ try {
1237
+ await interaction.reply({ content: 'Use me in server channels (not DMs).', ephemeral: true });
1238
+ }
1239
+ catch {
1240
+ // ignore
1241
+ }
1242
+ return;
1243
+ }
1244
+ const messageId = interaction.message?.id;
1245
+ if (!messageId)
1246
+ return;
1247
+ // End Session button
1248
+ if (interaction.customId.startsWith('rh_session_end:')) {
1249
+ const sessionThreadId = interaction.customId.split(':')[1] ?? '';
1250
+ const sessionFlow = this.triviaSessionFlows.get(sessionThreadId);
1251
+ if (!sessionFlow) {
1252
+ try {
1253
+ await interaction.reply({ content: 'No active session.', ephemeral: true });
1254
+ }
1255
+ catch { }
1256
+ return;
1257
+ }
1258
+ if (interaction.user.id !== sessionFlow.userId) {
1259
+ try {
1260
+ await interaction.reply({ content: 'Only the session starter can end it.', ephemeral: true });
1261
+ }
1262
+ catch { }
1263
+ return;
1264
+ }
1265
+ try {
1266
+ await interaction.reply({ content: 'Ending session...', ephemeral: true });
1267
+ }
1268
+ catch { }
1269
+ try {
1270
+ const thread = await interaction.client.channels.fetch(sessionThreadId);
1271
+ await this.endSession(thread, sessionFlow);
1272
+ }
1273
+ catch {
1274
+ this.triviaSessionFlows.delete(sessionThreadId);
1275
+ this.activeSessionUsers.delete(sessionFlow.userId);
1276
+ if (sessionFlow.timeoutTimer)
1277
+ clearTimeout(sessionFlow.timeoutTimer);
1278
+ if (sessionFlow._sessionCleanup)
1279
+ clearTimeout(sessionFlow._sessionCleanup);
1280
+ }
1281
+ return;
1282
+ }
1283
+ if (interaction.customId.startsWith('rh_session:')) {
1284
+ const parts = interaction.customId.split(':');
1285
+ const sessionThreadId = parts[1] ?? '';
1286
+ const choiceIdx = parseInt(parts[2] ?? '', 10);
1287
+ const sessionFlow = this.triviaSessionFlows.get(sessionThreadId);
1288
+ if (!sessionFlow) {
1289
+ try {
1290
+ await interaction.reply({ content: 'This session has expired.', ephemeral: true });
1291
+ }
1292
+ catch { }
1293
+ return;
1294
+ }
1295
+ if (interaction.user.id !== sessionFlow.userId) {
1296
+ try {
1297
+ await interaction.reply({ content: 'Only the session starter can answer.', ephemeral: true });
1298
+ }
1299
+ catch { }
1300
+ return;
1301
+ }
1302
+ if (sessionFlow.timeoutTimer)
1303
+ clearTimeout(sessionFlow.timeoutTimer);
1304
+ const q = sessionFlow.questions[sessionFlow.currentIndex];
1305
+ const correct = choiceIdx === q.answerIndex;
1306
+ const elapsed = Date.now() - sessionFlow.questionPostedAtMs;
1307
+ let pts = 0;
1308
+ if (correct) {
1309
+ pts = TriviaBank_1.DIFFICULTY_POINTS[q.difficulty];
1310
+ if (elapsed < 5000)
1311
+ pts += 10;
1312
+ else if (elapsed < 10000)
1313
+ pts += 5;
1314
+ }
1315
+ sessionFlow.results.push({ correct, skipped: false, timeMs: elapsed, pointsEarned: pts });
1316
+ const correctLetter = ['A', 'B', 'C', 'D'][q.answerIndex] || 'A';
1317
+ const feedback = correct
1318
+ ? `โœ… Correct! +${pts} pts`
1319
+ : `โŒ Wrong. Answer: **${correctLetter}**`;
1320
+ try {
1321
+ await interaction.reply({ content: `${feedback}\n${q.explanation}`, ephemeral: true });
1322
+ }
1323
+ catch { }
1324
+ try {
1325
+ const thread = await interaction.client.channels.fetch(sessionThreadId);
1326
+ await this.advanceSession(thread, sessionFlow);
1327
+ }
1328
+ catch { }
1329
+ return;
1330
+ }
1331
+ const session = this.triviaSessions.get(messageId);
1332
+ if (!session)
1333
+ return;
1334
+ const now = Date.now();
1335
+ if (now > session.expiresAtMs) {
1336
+ this.triviaSessions.delete(messageId);
1337
+ try {
1338
+ await interaction.reply({ content: 'This trivia question has expired. Start a new one with `/trivia`.', ephemeral: true });
1339
+ }
1340
+ catch {
1341
+ // ignore
1342
+ }
1343
+ return;
1344
+ }
1345
+ if (session.answeredUserIds.has(interaction.user.id)) {
1346
+ try {
1347
+ await interaction.reply({ content: 'You already answered this question.', ephemeral: true });
1348
+ }
1349
+ catch {
1350
+ // ignore
1351
+ }
1352
+ return;
1353
+ }
1354
+ const { choiceIndex, questionId } = parseTriviaCustomId(interaction.customId);
1355
+ if (questionId !== session.question.id || choiceIndex == null) {
1356
+ try {
1357
+ await interaction.reply({ content: 'That trivia button is no longer valid.', ephemeral: true });
1358
+ }
1359
+ catch {
1360
+ // ignore
1361
+ }
1362
+ return;
1363
+ }
1364
+ session.answeredUserIds.add(interaction.user.id);
1365
+ const correct = choiceIndex === session.question.answerIndex;
1366
+ const q = session.question;
1367
+ // Calculate points
1368
+ let earnedPoints = 0;
1369
+ const bonusParts = [];
1370
+ if (correct) {
1371
+ // Base points
1372
+ const base = session.isDaily ? 50 : TriviaBank_1.DIFFICULTY_POINTS[q.difficulty];
1373
+ earnedPoints += base;
1374
+ bonusParts.push(`Base: +${base}`);
1375
+ // Speed bonus
1376
+ const elapsed = (Date.now() - session.startedAtMs) / 1000;
1377
+ if (elapsed < 5) {
1378
+ earnedPoints += 10;
1379
+ bonusParts.push('โšก Speed: +10');
1380
+ }
1381
+ else if (elapsed < 10) {
1382
+ earnedPoints += 5;
1383
+ bonusParts.push('โšก Speed: +5');
1384
+ }
1385
+ // Streak bonus (based on pre-update streak)
1386
+ const prevStats = this.service.getTriviaStats(interaction.user.id);
1387
+ const currentStreak = (prevStats?.streak ?? 0) + 1;
1388
+ const streakBonus = Math.min(currentStreak * 5, 25);
1389
+ if (streakBonus > 0) {
1390
+ earnedPoints += streakBonus;
1391
+ bonusParts.push(`๐Ÿ”ฅ Streak (${currentStreak}): +${streakBonus}`);
1392
+ }
1393
+ }
1394
+ let updatedStats;
1395
+ try {
1396
+ updatedStats = this.service.recordTriviaPlay(interaction.user.id, correct, earnedPoints, q.category);
1397
+ if (session.isDaily && correct) {
1398
+ const today = new Date().toISOString().slice(0, 10);
1399
+ this.service.recordDailyChallenge(interaction.user.id, today);
1400
+ }
1401
+ }
1402
+ catch {
1403
+ // ignore
1404
+ }
1405
+ const correctLetter = ['A', 'B', 'C', 'D'][q.answerIndex] || 'A';
1406
+ const lines = [];
1407
+ if (correct) {
1408
+ lines.push(`โœ… **Correct!** +${earnedPoints} pts`);
1409
+ if (bonusParts.length > 1)
1410
+ lines.push(bonusParts.join(' ยท '));
1411
+ }
1412
+ else {
1413
+ lines.push(`โŒ Not quite. Correct answer: **${correctLetter}**`);
1414
+ if (updatedStats && (updatedStats.bestStreak ?? 0) > 0) {
1415
+ lines.push(`Streak reset (best: ${updatedStats.bestStreak})`);
1416
+ }
1417
+ }
1418
+ lines.push('', q.explanation);
1419
+ if (updatedStats) {
1420
+ const lvl = (0, TriviaBank_1.triviaLevelForPoints)(updatedStats.points);
1421
+ const nxt = (0, TriviaBank_1.nextTriviaLevel)(updatedStats.points);
1422
+ lines.push('', `${lvl.emoji} ${lvl.name} ยท **${updatedStats.points}** pts total${nxt ? ` ยท ${nxt.minPoints - updatedStats.points} to ${nxt.emoji}` : ''}`);
1423
+ // Level up notification
1424
+ const prevPts = updatedStats.points - earnedPoints;
1425
+ const prevLvl = (0, TriviaBank_1.triviaLevelForPoints)(prevPts);
1426
+ if (lvl.level > prevLvl.level) {
1427
+ lines.push(`\n๐ŸŽ‰ **LEVEL UP!** You are now ${lvl.emoji} **${lvl.name}**!`);
1428
+ }
1429
+ }
1430
+ try {
1431
+ await interaction.reply({ content: lines.join('\n'), ephemeral: true });
1432
+ }
1433
+ catch {
1434
+ // ignore
1435
+ }
1436
+ }
1437
+ async postSessionQuestion(thread, session) {
1438
+ const q = session.questions[session.currentIndex];
1439
+ const num = session.currentIndex + 1;
1440
+ const total = session.questions.length;
1441
+ const pts = TriviaBank_1.DIFFICULTY_POINTS[q.difficulty];
1442
+ const catEmoji = TriviaBank_1.TRIVIA_CATEGORIES.find((c) => q.category.toLowerCase().includes(c.name.toLowerCase().split(' ')[0]))?.emoji ?? '๐Ÿง ';
1443
+ const answerRow = new discord_js_1.ActionRowBuilder().addComponents(new discord_js_1.ButtonBuilder().setCustomId(`rh_session:${session.threadId}:0`).setLabel('A').setStyle(discord_js_1.ButtonStyle.Secondary), new discord_js_1.ButtonBuilder().setCustomId(`rh_session:${session.threadId}:1`).setLabel('B').setStyle(discord_js_1.ButtonStyle.Secondary), new discord_js_1.ButtonBuilder().setCustomId(`rh_session:${session.threadId}:2`).setLabel('C').setStyle(discord_js_1.ButtonStyle.Secondary), new discord_js_1.ButtonBuilder().setCustomId(`rh_session:${session.threadId}:3`).setLabel('D').setStyle(discord_js_1.ButtonStyle.Secondary));
1444
+ const endRow = new discord_js_1.ActionRowBuilder().addComponents(new discord_js_1.ButtonBuilder().setCustomId(`rh_session_end:${session.threadId}`).setLabel('End Session').setStyle(discord_js_1.ButtonStyle.Danger).setEmoji('๐Ÿ›‘'));
1445
+ const embed = {
1446
+ title: `Question ${num}/${total}`,
1447
+ description: [
1448
+ `**${q.question}**`,
1449
+ '',
1450
+ `**A.** ${q.choices[0]}`,
1451
+ `**B.** ${q.choices[1]}`,
1452
+ `**C.** ${q.choices[2]}`,
1453
+ `**D.** ${q.choices[3]}`,
1454
+ '',
1455
+ `${catEmoji} ${q.category} ยท ${pts} pts ยท 30s`,
1456
+ ].join('\n'),
1457
+ color: this.brandColor(),
1458
+ };
1459
+ const msg = await thread.send({ embeds: [embed], components: [answerRow, endRow] });
1460
+ session.currentMessageId = msg.id;
1461
+ session.questionPostedAtMs = Date.now();
1462
+ if (session.timeoutTimer)
1463
+ clearTimeout(session.timeoutTimer);
1464
+ session.timeoutTimer = setTimeout(() => {
1465
+ session.results.push({ correct: false, skipped: true, timeMs: 30000, pointsEarned: 0 });
1466
+ this.advanceSession(thread, session);
1467
+ }, 30000);
1468
+ }
1469
+ async advanceSession(thread, session) {
1470
+ session.currentIndex++;
1471
+ try {
1472
+ const prevMsg = await thread.messages.fetch(session.currentMessageId);
1473
+ await prevMsg.edit({ components: [] });
1474
+ }
1475
+ catch { /* ignore */ }
1476
+ if (session.currentIndex >= session.questions.length) {
1477
+ await this.endSession(thread, session);
1478
+ return;
1479
+ }
1480
+ await this.postSessionQuestion(thread, session);
1481
+ }
1482
+ async endSession(thread, session) {
1483
+ if (session.timeoutTimer)
1484
+ clearTimeout(session.timeoutTimer);
1485
+ if (session._sessionCleanup)
1486
+ clearTimeout(session._sessionCleanup);
1487
+ const correct = session.results.filter((r) => r.correct).length;
1488
+ const skipped = session.results.filter((r) => r.skipped).length;
1489
+ const basePoints = session.results.reduce((sum, r) => sum + r.pointsEarned, 0);
1490
+ const allAnswered = skipped === 0 && session.results.length === session.questions.length;
1491
+ const bonusMultiplier = allAnswered ? 1.5 : 1.0;
1492
+ const totalPoints = Math.round(basePoints * bonusMultiplier);
1493
+ const bonusPoints = totalPoints - basePoints;
1494
+ // Record points โ€” empty category string for mixed sessions intentionally
1495
+ // skips category stat updates in LocalState.recordTriviaPlay
1496
+ const answeredCount = session.results.filter((r) => !r.skipped).length;
1497
+ const pointsPerAnswer = answeredCount > 0 ? totalPoints / answeredCount : 0;
1498
+ let distributed = 0;
1499
+ for (let i = 0; i < session.results.length; i++) {
1500
+ const r = session.results[i];
1501
+ if (!r.skipped) {
1502
+ const isLast = distributed === answeredCount - 1;
1503
+ const pts = isLast ? totalPoints - (distributed > 0 ? Math.round(pointsPerAnswer) * distributed : 0) : Math.round(pointsPerAnswer);
1504
+ this.service.recordTriviaPlay(session.userId, r.correct, pts, session.category);
1505
+ distributed++;
1506
+ }
1507
+ }
1508
+ const stats = this.service.getTriviaStats(session.userId);
1509
+ const lvl = stats ? (0, TriviaBank_1.triviaLevelForPoints)(stats.points) : (0, TriviaBank_1.triviaLevelForPoints)(0);
1510
+ const lines = [
1511
+ `**Session Complete!**`,
1512
+ '',
1513
+ `**Score:** ${correct}/${session.questions.length} correct`,
1514
+ `**Points:** ${basePoints}${allAnswered ? ` + ${bonusPoints} completion bonus = **${totalPoints}**` : ` = **${totalPoints}**`}`,
1515
+ skipped > 0 ? `**Skipped:** ${skipped} (no completion bonus)` : '**Bonus:** 1.5x completion bonus applied!',
1516
+ '',
1517
+ stats ? `${lvl.emoji} **${lvl.name}** ยท ${stats.points} pts total` : '',
1518
+ ];
1519
+ const embed = {
1520
+ title: '๐Ÿ Session Results',
1521
+ description: lines.filter(Boolean).join('\n'),
1522
+ color: this.brandColor(),
1523
+ footer: this.brandFooter() ? { text: this.brandFooter() } : undefined,
1524
+ };
1525
+ await thread.send({ embeds: [embed] });
1526
+ this.triviaSessionFlows.delete(session.threadId);
1527
+ this.activeSessionUsers.delete(session.userId);
1528
+ try {
1529
+ await thread.setArchived(true);
1530
+ }
1531
+ catch { /* ignore */ }
1532
+ }
1533
+ quotaTzLabel() {
1534
+ return String(process.env['WUNDERLAND_TZ'] || 'America/Los_Angeles').trim() || 'UTC';
1535
+ }
1536
+ brandColor() {
1537
+ const raw = String(process.env['DISCORD_BRAND_COLOR'] || '').trim();
1538
+ if (!raw)
1539
+ return 0x8B6914;
1540
+ if (raw.startsWith('0x')) {
1541
+ const n = Number.parseInt(raw.slice(2), 16);
1542
+ return Number.isFinite(n) ? n : 0x8B6914;
1543
+ }
1544
+ const n = Number(raw);
1545
+ return Number.isFinite(n) ? n : 0x8B6914;
1546
+ }
1547
+ brandFooter() {
1548
+ return String(process.env['DISCORD_BRAND_FOOTER'] || 'Powered by Wunderland | wunderland.sh').trim();
1549
+ }
1550
+ async safeEphemeralReply(interaction, content, extra) {
1551
+ try {
1552
+ await interaction.reply({ content: content || undefined, embeds: extra?.embeds, ephemeral: true });
1553
+ }
1554
+ catch (err) {
1555
+ console.error(`[DiscordChannel] safeEphemeralReply failed for /${interaction.commandName}:`, err?.message ?? err);
1556
+ }
1557
+ }
1558
+ async probeOllama() {
1559
+ const raw = String(process.env['OLLAMA_BASE_URL'] || '').trim() || 'http://localhost:11434';
1560
+ const base = raw.endsWith('/') ? raw.slice(0, -1) : raw;
1561
+ const baseNoV1 = base.endsWith('/v1') ? base.slice(0, -3) : base;
1562
+ const url = `${baseNoV1}/api/version`;
1563
+ try {
1564
+ const res = await fetch(url, { method: 'GET' });
1565
+ if (!res.ok)
1566
+ return { ok: false, baseUrl: baseNoV1, error: `HTTP ${res.status}` };
1567
+ return { ok: true, baseUrl: baseNoV1 };
1568
+ }
1569
+ catch (err) {
1570
+ return { ok: false, baseUrl: baseNoV1, error: err instanceof Error ? err.message : String(err) };
1571
+ }
1572
+ }
1573
+ emitSyntheticInteractionMessage(interaction, text, rawMeta) {
1574
+ if (!interaction.isChatInputCommand())
1575
+ return;
1576
+ const sender = {
1577
+ id: interaction.user.id,
1578
+ displayName: interaction.member?.displayName ?? interaction.user.displayName ?? undefined,
1579
+ username: interaction.user.username,
1580
+ };
1581
+ const channelType = interaction.channel?.type;
1582
+ const conversationType = channelType === discord_js_1.ChannelType.PublicThread ||
1583
+ channelType === discord_js_1.ChannelType.PrivateThread ||
1584
+ channelType === discord_js_1.ChannelType.AnnouncementThread
1585
+ ? 'thread'
1586
+ : 'group';
1587
+ const channelMessage = {
1588
+ messageId: `interaction:${interaction.id}`,
1589
+ platform: 'discord',
1590
+ conversationId: interaction.channelId,
1591
+ conversationType,
1592
+ sender,
1593
+ content: [{ type: 'text', text: text ?? '' }],
1594
+ text: text ?? '',
1595
+ timestamp: interaction.createdAt.toISOString(),
1596
+ rawEvent: { interaction, ...rawMeta },
1597
+ };
1598
+ const event = {
1599
+ type: 'message',
1600
+ platform: 'discord',
1601
+ conversationId: interaction.channelId,
160
1602
  timestamp: channelMessage.timestamp,
161
1603
  data: channelMessage,
162
1604
  };
@@ -185,4 +1627,43 @@ class DiscordChannelAdapter {
185
1627
  }
186
1628
  }
187
1629
  exports.DiscordChannelAdapter = DiscordChannelAdapter;
1630
+ DiscordChannelAdapter.RESPONSE_DELAY_MS = 2 * 60 * 1000; // 2 minutes
1631
+ /** Greeting/filler patterns that should never trigger a bot response (non-mention). */
1632
+ DiscordChannelAdapter.IGNORE_PATTERNS = [
1633
+ /^(hey|hi|hello|yo|sup|gm|gn|good\s*(morning|night|evening|afternoon)|howdy|what'?s?\s*up|welcome|wb|brb|afk|ttyl|cya|bye|later|peace|cheers|thanks|thx|ty|np|gg|lol|lmao|haha|heh|nice|cool|dope|sick|bet|facts|fr|real|true|based|mood|same|word|damn|wow|oof|rip|f\b|w\b|l\b)/i,
1634
+ /^.{0,5}$/, // Very short messages (1-5 chars) like "k", "ok", "ya"
1635
+ /^<a?:\w+:\d+>$/, // Just a custom emoji
1636
+ /^https?:\/\/\S+$/, // Just a link with no commentary
1637
+ ];
1638
+ /**
1639
+ * Feed / board channels where the bot may answer direct mentions, but must
1640
+ * never proactively jump into the conversation on its own.
1641
+ */
1642
+ DiscordChannelAdapter.PROACTIVE_OPT_OUT_CHANNELS = new Set([
1643
+ 'us-news',
1644
+ 'world-news',
1645
+ 'tech-news',
1646
+ 'finance-news',
1647
+ 'science-news',
1648
+ 'media-news',
1649
+ 'threat-intel',
1650
+ 'ai-papers',
1651
+ 'udemy-deals',
1652
+ 'short-squeeze',
1653
+ 'crypto-trending',
1654
+ 'trending-crypto',
1655
+ 'uniswap-sniper',
1656
+ ]);
1657
+ function parseTriviaCustomId(customId) {
1658
+ // Format: rh_trivia:<questionId>:<choiceIndex>
1659
+ const parts = String(customId || '').split(':');
1660
+ if (parts.length !== 3)
1661
+ return { questionId: '', choiceIndex: null };
1662
+ if (parts[0] !== 'rh_trivia')
1663
+ return { questionId: '', choiceIndex: null };
1664
+ const questionId = String(parts[1] || '').trim();
1665
+ const idx = Number(parts[2]);
1666
+ const choiceIndex = Number.isFinite(idx) ? Math.max(0, Math.min(3, Math.floor(idx))) : null;
1667
+ return { questionId, choiceIndex };
1668
+ }
188
1669
  //# sourceMappingURL=DiscordChannelAdapter.js.map