@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.
- package/LICENSE +98 -0
- package/README.md +24 -0
- package/dist/DiscordChannelAdapter.d.ts +43 -0
- package/dist/DiscordChannelAdapter.d.ts.map +1 -1
- package/dist/DiscordChannelAdapter.js +1483 -2
- package/dist/DiscordChannelAdapter.js.map +1 -1
- package/dist/DiscordService.d.ts +63 -1
- package/dist/DiscordService.d.ts.map +1 -1
- package/dist/DiscordService.js +748 -4
- package/dist/DiscordService.js.map +1 -1
- package/dist/LocalState.d.ts +83 -0
- package/dist/LocalState.d.ts.map +1 -0
- package/dist/LocalState.js +291 -0
- package/dist/LocalState.js.map +1 -0
- package/dist/TriviaBank.d.ts +61 -0
- package/dist/TriviaBank.d.ts.map +1 -0
- package/dist/TriviaBank.js +467 -0
- package/dist/TriviaBank.js.map +1 -0
- package/dist/faq-matcher.d.ts +34 -0
- package/dist/faq-matcher.d.ts.map +1 -0
- package/dist/faq-matcher.js +408 -0
- package/dist/faq-matcher.js.map +1 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1 -0
- package/dist/index.js.map +1 -1
- package/dist/tools/sendMedia.d.ts.map +1 -1
- package/dist/tools/sendMedia.js +1 -0
- package/dist/tools/sendMedia.js.map +1 -1
- package/dist/tools/sendMessage.d.ts.map +1 -1
- package/dist/tools/sendMessage.js +1 -0
- package/dist/tools/sendMessage.js.map +1 -1
- package/manifest.json +1 -1
- package/package.json +39 -15
|
@@ -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:
|
|
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:
|
|
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
|