ralph-cli-sandboxed 0.4.0 → 0.4.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (80) hide show
  1. package/README.md +30 -0
  2. package/dist/commands/action.js +47 -20
  3. package/dist/commands/chat.d.ts +1 -1
  4. package/dist/commands/chat.js +325 -62
  5. package/dist/commands/config.js +2 -1
  6. package/dist/commands/daemon.d.ts +2 -5
  7. package/dist/commands/daemon.js +118 -49
  8. package/dist/commands/docker.js +110 -73
  9. package/dist/commands/fix-config.js +2 -1
  10. package/dist/commands/fix-prd.js +2 -2
  11. package/dist/commands/help.js +19 -3
  12. package/dist/commands/init.js +78 -17
  13. package/dist/commands/listen.js +116 -5
  14. package/dist/commands/logo.d.ts +5 -0
  15. package/dist/commands/logo.js +41 -0
  16. package/dist/commands/notify.js +1 -1
  17. package/dist/commands/once.js +19 -9
  18. package/dist/commands/prd.js +20 -2
  19. package/dist/commands/run.js +111 -27
  20. package/dist/commands/slack.d.ts +10 -0
  21. package/dist/commands/slack.js +333 -0
  22. package/dist/config/responder-presets.json +69 -0
  23. package/dist/index.js +6 -1
  24. package/dist/providers/discord.d.ts +82 -0
  25. package/dist/providers/discord.js +697 -0
  26. package/dist/providers/slack.d.ts +79 -0
  27. package/dist/providers/slack.js +715 -0
  28. package/dist/providers/telegram.d.ts +30 -0
  29. package/dist/providers/telegram.js +190 -7
  30. package/dist/responders/claude-code-responder.d.ts +48 -0
  31. package/dist/responders/claude-code-responder.js +203 -0
  32. package/dist/responders/cli-responder.d.ts +62 -0
  33. package/dist/responders/cli-responder.js +298 -0
  34. package/dist/responders/llm-responder.d.ts +135 -0
  35. package/dist/responders/llm-responder.js +582 -0
  36. package/dist/templates/macos-scripts.js +2 -4
  37. package/dist/templates/prompts.js +4 -2
  38. package/dist/tui/ConfigEditor.js +42 -5
  39. package/dist/tui/components/ArrayEditor.js +1 -1
  40. package/dist/tui/components/EditorPanel.js +10 -6
  41. package/dist/tui/components/HelpPanel.d.ts +1 -1
  42. package/dist/tui/components/HelpPanel.js +1 -1
  43. package/dist/tui/components/JsonSnippetEditor.js +8 -5
  44. package/dist/tui/components/KeyValueEditor.js +69 -5
  45. package/dist/tui/components/LLMProvidersEditor.d.ts +22 -0
  46. package/dist/tui/components/LLMProvidersEditor.js +357 -0
  47. package/dist/tui/components/ObjectEditor.js +1 -1
  48. package/dist/tui/components/Preview.js +1 -1
  49. package/dist/tui/components/RespondersEditor.d.ts +22 -0
  50. package/dist/tui/components/RespondersEditor.js +437 -0
  51. package/dist/tui/components/SectionNav.js +27 -3
  52. package/dist/tui/utils/presets.js +15 -2
  53. package/dist/utils/chat-client.d.ts +33 -4
  54. package/dist/utils/chat-client.js +20 -1
  55. package/dist/utils/config.d.ts +100 -1
  56. package/dist/utils/config.js +78 -1
  57. package/dist/utils/daemon-actions.d.ts +19 -0
  58. package/dist/utils/daemon-actions.js +111 -0
  59. package/dist/utils/daemon-client.d.ts +21 -0
  60. package/dist/utils/daemon-client.js +28 -1
  61. package/dist/utils/llm-client.d.ts +82 -0
  62. package/dist/utils/llm-client.js +185 -0
  63. package/dist/utils/message-queue.js +6 -6
  64. package/dist/utils/notification.d.ts +10 -2
  65. package/dist/utils/notification.js +111 -4
  66. package/dist/utils/prd-validator.js +60 -19
  67. package/dist/utils/prompt.js +22 -12
  68. package/dist/utils/responder-logger.d.ts +47 -0
  69. package/dist/utils/responder-logger.js +129 -0
  70. package/dist/utils/responder-presets.d.ts +92 -0
  71. package/dist/utils/responder-presets.js +156 -0
  72. package/dist/utils/responder.d.ts +88 -0
  73. package/dist/utils/responder.js +207 -0
  74. package/dist/utils/stream-json.js +6 -6
  75. package/docs/CHAT-CLIENTS.md +520 -0
  76. package/docs/CHAT-RESPONDERS.md +785 -0
  77. package/docs/DEVELOPMENT.md +25 -0
  78. package/docs/USEFUL_ACTIONS.md +815 -0
  79. package/docs/chat-architecture.md +251 -0
  80. package/package.json +14 -1
@@ -0,0 +1,697 @@
1
+ /**
2
+ * Discord chat client implementation.
3
+ * Uses discord.js for WebSocket-based real-time messaging via the Discord Gateway.
4
+ *
5
+ * Required packages (must be installed separately):
6
+ * npm install discord.js
7
+ */
8
+ import { parseCommand, } from "../utils/chat-client.js";
9
+ import { ResponderMatcher } from "../utils/responder.js";
10
+ import { loadConfig } from "../utils/config.js";
11
+ import { executeLLMResponder } from "../responders/llm-responder.js";
12
+ import { executeClaudeCodeResponder } from "../responders/claude-code-responder.js";
13
+ import { executeCLIResponder } from "../responders/cli-responder.js";
14
+ // Discord.js classes loaded dynamically
15
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
16
+ let ClientConstructor = null;
17
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
18
+ let GatewayIntentBits = null;
19
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
20
+ let ActionRowBuilder = null;
21
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
22
+ let ButtonBuilder = null;
23
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
24
+ let ButtonStyle = null;
25
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
26
+ let SlashCommandBuilder = null;
27
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
28
+ let REST = null;
29
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
30
+ let Routes = null;
31
+ async function loadDiscordModules() {
32
+ try {
33
+ // Dynamic import to avoid compile-time errors when packages aren't installed
34
+ const dynamicImport = new Function("specifier", "return import(specifier)");
35
+ const discordJs = await dynamicImport("discord.js");
36
+ ClientConstructor = discordJs.Client;
37
+ GatewayIntentBits = discordJs.GatewayIntentBits;
38
+ ActionRowBuilder = discordJs.ActionRowBuilder;
39
+ ButtonBuilder = discordJs.ButtonBuilder;
40
+ ButtonStyle = discordJs.ButtonStyle;
41
+ SlashCommandBuilder = discordJs.SlashCommandBuilder;
42
+ REST = discordJs.REST;
43
+ Routes = discordJs.Routes;
44
+ return true;
45
+ }
46
+ catch {
47
+ return false;
48
+ }
49
+ }
50
+ export class DiscordChatClient {
51
+ provider = "discord";
52
+ settings;
53
+ connected = false;
54
+ debug;
55
+ client = null;
56
+ onCommand = null;
57
+ onMessage = null;
58
+ responderMatcher = null;
59
+ respondersConfig = null;
60
+ botUserId = null;
61
+ constructor(settings, debug = false) {
62
+ this.settings = settings;
63
+ this.debug = debug;
64
+ // Initialize responders from config if available
65
+ this.initializeResponders();
66
+ }
67
+ /**
68
+ * Initialize responder matching from config.
69
+ */
70
+ initializeResponders() {
71
+ try {
72
+ const config = loadConfig();
73
+ if (config.chat?.responders) {
74
+ this.respondersConfig = config.chat.responders;
75
+ this.responderMatcher = new ResponderMatcher(config.chat.responders);
76
+ if (this.debug) {
77
+ console.log(`[discord] Initialized ${Object.keys(config.chat.responders).length} responders`);
78
+ }
79
+ }
80
+ }
81
+ catch {
82
+ // Config not available or responders not configured
83
+ if (this.debug) {
84
+ console.log("[discord] No responders configured");
85
+ }
86
+ }
87
+ }
88
+ /**
89
+ * Execute a responder and return the result.
90
+ */
91
+ async executeResponder(match, message) {
92
+ const { responder } = match;
93
+ switch (responder.type) {
94
+ case "llm":
95
+ return executeLLMResponder(message, responder);
96
+ case "claude-code":
97
+ return executeClaudeCodeResponder(message, responder);
98
+ case "cli":
99
+ return executeCLIResponder(message, responder);
100
+ default:
101
+ return {
102
+ success: false,
103
+ response: "",
104
+ error: `Unknown responder type: ${responder.type}`,
105
+ };
106
+ }
107
+ }
108
+ /**
109
+ * Handle a message that might match a responder.
110
+ * Returns true if a responder was matched and executed.
111
+ */
112
+ async handleResponderMessage(originalMessage, cleanedText) {
113
+ if (!this.responderMatcher) {
114
+ return false;
115
+ }
116
+ const match = this.responderMatcher.matchResponder(cleanedText);
117
+ if (!match) {
118
+ return false;
119
+ }
120
+ if (this.debug) {
121
+ console.log(`[discord] Matched responder: ${match.name} (type: ${match.responder.type})`);
122
+ }
123
+ // Execute the responder
124
+ const result = await this.executeResponder(match, match.args || cleanedText);
125
+ // Send the response (reply to the original message for context)
126
+ try {
127
+ if (result.success) {
128
+ // Truncate to Discord's 2000 char limit
129
+ const responseText = result.response.substring(0, 2000);
130
+ await originalMessage.reply(responseText);
131
+ }
132
+ else {
133
+ const errorMsg = result.error
134
+ ? `Error: ${result.error}`
135
+ : "An error occurred while processing your message.";
136
+ await originalMessage.reply(errorMsg);
137
+ }
138
+ }
139
+ catch (err) {
140
+ if (this.debug) {
141
+ console.error(`[discord] Failed to send responder reply: ${err}`);
142
+ }
143
+ }
144
+ return true;
145
+ }
146
+ /**
147
+ * Check if the bot is mentioned in a message.
148
+ */
149
+ isBotMentioned(message) {
150
+ if (!this.botUserId) {
151
+ return false;
152
+ }
153
+ // Check if the message mentions the bot user
154
+ if (message.mentions && typeof message.mentions.has === "function") {
155
+ return message.mentions.has(this.botUserId);
156
+ }
157
+ // Fallback: check text for <@BOT_ID> pattern
158
+ return message.content?.includes(`<@${this.botUserId}>`) || false;
159
+ }
160
+ /**
161
+ * Remove bot mention from message text.
162
+ */
163
+ removeBotMention(text) {
164
+ if (!this.botUserId) {
165
+ return text;
166
+ }
167
+ // Remove <@BOT_ID> and any surrounding whitespace
168
+ // Also handles <@!BOT_ID> format (with nickname)
169
+ return text.replace(new RegExp(`<@!?${this.botUserId}>\\s*`, "g"), "").trim();
170
+ }
171
+ /**
172
+ * Check if this is a DM (direct message) channel.
173
+ */
174
+ isDMChannel(message) {
175
+ // DM channels have type 1 (DM) or could check if guild is null
176
+ return message.channel?.type === 1 || !message.guild;
177
+ }
178
+ /**
179
+ * Check if a guild ID is allowed.
180
+ */
181
+ isGuildAllowed(guildId) {
182
+ // If no allowed guild IDs specified, allow all
183
+ if (!this.settings.allowedGuildIds || this.settings.allowedGuildIds.length === 0) {
184
+ return true;
185
+ }
186
+ if (!guildId)
187
+ return false;
188
+ return this.settings.allowedGuildIds.includes(guildId);
189
+ }
190
+ /**
191
+ * Check if a channel ID is allowed.
192
+ */
193
+ isChannelAllowed(channelId) {
194
+ // If no allowed channel IDs specified, allow all
195
+ if (!this.settings.allowedChannelIds || this.settings.allowedChannelIds.length === 0) {
196
+ return true;
197
+ }
198
+ return this.settings.allowedChannelIds.includes(channelId);
199
+ }
200
+ /**
201
+ * Convert a Discord message to our ChatMessage format.
202
+ */
203
+ toMessage(message) {
204
+ return {
205
+ text: message.content || "",
206
+ chatId: message.channel.id,
207
+ senderId: message.author?.id,
208
+ senderName: message.author?.username,
209
+ timestamp: message.createdAt || new Date(),
210
+ raw: message,
211
+ };
212
+ }
213
+ /**
214
+ * Register slash commands with Discord.
215
+ */
216
+ async registerSlashCommands() {
217
+ if (!this.client || !REST || !Routes || !SlashCommandBuilder)
218
+ return;
219
+ const commands = [
220
+ {
221
+ name: "run",
222
+ description: "Start ralph automation",
223
+ hasArgs: true,
224
+ argName: "category",
225
+ argDesc: "Optional category filter",
226
+ },
227
+ { name: "status", description: "Show PRD progress", hasArgs: false },
228
+ {
229
+ name: "add",
230
+ description: "Add new task to PRD",
231
+ hasArgs: true,
232
+ argName: "description",
233
+ argDesc: "Task description",
234
+ required: true,
235
+ },
236
+ {
237
+ name: "exec",
238
+ description: "Execute shell command",
239
+ hasArgs: true,
240
+ argName: "command",
241
+ argDesc: "Shell command to run",
242
+ required: true,
243
+ },
244
+ { name: "stop", description: "Stop running ralph process", hasArgs: false },
245
+ { name: "help", description: "Show help", hasArgs: false },
246
+ {
247
+ name: "action",
248
+ description: "Run daemon action",
249
+ hasArgs: true,
250
+ argName: "name",
251
+ argDesc: "Action name",
252
+ },
253
+ {
254
+ name: "claude",
255
+ description: "Run Claude Code with prompt",
256
+ hasArgs: true,
257
+ argName: "prompt",
258
+ argDesc: "Prompt for Claude Code",
259
+ required: true,
260
+ },
261
+ ];
262
+ const slashCommands = commands.map((cmd) => {
263
+ const builder = new SlashCommandBuilder().setName(cmd.name).setDescription(cmd.description);
264
+ if (cmd.hasArgs && cmd.argName) {
265
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
266
+ builder.addStringOption((option) => option
267
+ .setName(cmd.argName)
268
+ .setDescription(cmd.argDesc || "Argument")
269
+ .setRequired(cmd.required || false));
270
+ }
271
+ return builder;
272
+ });
273
+ const rest = new REST({ version: "10" }).setToken(this.settings.botToken);
274
+ try {
275
+ const clientId = this.client.user?.id;
276
+ if (!clientId) {
277
+ if (this.debug) {
278
+ console.log("[discord] Cannot register slash commands: client ID not available");
279
+ }
280
+ return;
281
+ }
282
+ if (this.debug) {
283
+ console.log("[discord] Registering slash commands...");
284
+ }
285
+ await rest.put(Routes.applicationCommands(clientId), {
286
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
287
+ body: slashCommands.map((cmd) => cmd.toJSON()),
288
+ });
289
+ if (this.debug) {
290
+ console.log("[discord] Slash commands registered successfully");
291
+ }
292
+ }
293
+ catch (err) {
294
+ if (this.debug) {
295
+ console.error(`[discord] Failed to register slash commands: ${err}`);
296
+ }
297
+ }
298
+ }
299
+ /**
300
+ * Handle incoming Discord messages.
301
+ */
302
+ async handleMessage(message) {
303
+ // Ignore messages from bots (including self)
304
+ if (message.author?.bot)
305
+ return;
306
+ // Check if guild is allowed (null guild is ok for DMs)
307
+ if (!this.isGuildAllowed(message.guild?.id)) {
308
+ if (this.debug) {
309
+ console.log(`[discord] Ignoring message from unauthorized guild: ${message.guild?.id}`);
310
+ }
311
+ return;
312
+ }
313
+ // Check if channel is allowed
314
+ if (!this.isChannelAllowed(message.channel.id)) {
315
+ if (this.debug) {
316
+ console.log(`[discord] Ignoring message from unauthorized channel: ${message.channel.id}`);
317
+ }
318
+ return;
319
+ }
320
+ // Check if bot is mentioned or if this is a DM
321
+ const isMention = this.isBotMentioned(message);
322
+ const isDM = this.isDMChannel(message);
323
+ // Clean the message text (remove bot mention if present)
324
+ let messageText = message.content || "";
325
+ if (isMention) {
326
+ messageText = this.removeBotMention(messageText);
327
+ }
328
+ const chatMessage = {
329
+ text: messageText,
330
+ chatId: message.channel.id,
331
+ senderId: message.author?.id,
332
+ senderName: message.author?.username,
333
+ timestamp: message.createdAt || new Date(),
334
+ raw: message,
335
+ };
336
+ // Call raw message handler if provided
337
+ if (this.onMessage) {
338
+ try {
339
+ await this.onMessage(chatMessage);
340
+ }
341
+ catch (err) {
342
+ if (this.debug) {
343
+ console.error(`[discord] Message handler error: ${err}`);
344
+ }
345
+ }
346
+ }
347
+ // Try to parse as a command first
348
+ const command = parseCommand(messageText, chatMessage);
349
+ if (command && this.onCommand) {
350
+ try {
351
+ await this.onCommand(command);
352
+ return; // Command handled, don't process as responder message
353
+ }
354
+ catch (err) {
355
+ if (this.debug) {
356
+ console.error(`[discord] Command handler error: ${err}`);
357
+ }
358
+ // Send error message to channel
359
+ try {
360
+ await message.reply(`Error executing command: ${err instanceof Error ? err.message : "Unknown error"}`);
361
+ }
362
+ catch {
363
+ // Ignore reply errors
364
+ }
365
+ return;
366
+ }
367
+ }
368
+ // For non-command messages, route through responders
369
+ // In guilds (servers): only respond if bot is mentioned
370
+ // In DMs: always respond if responders are configured
371
+ const shouldProcessAsResponder = isDM || isMention;
372
+ if (shouldProcessAsResponder && this.responderMatcher) {
373
+ // Check if there's a matching responder or a default responder
374
+ const match = this.responderMatcher.matchResponder(messageText);
375
+ if (match) {
376
+ try {
377
+ const handled = await this.handleResponderMessage(message, messageText);
378
+ if (handled) {
379
+ return;
380
+ }
381
+ }
382
+ catch (err) {
383
+ if (this.debug) {
384
+ console.error(`[discord] Responder error: ${err}`);
385
+ }
386
+ try {
387
+ await message.reply(`Error processing message: ${err instanceof Error ? err.message : "Unknown error"}`);
388
+ }
389
+ catch {
390
+ // Ignore reply errors
391
+ }
392
+ return;
393
+ }
394
+ }
395
+ else if (isMention && !this.responderMatcher.hasDefaultResponder()) {
396
+ // Bot was mentioned but no responder matched and no default responder
397
+ // Send a helpful message
398
+ try {
399
+ await message.reply("I received your message, but no responders are configured. Use `/help` for available commands.");
400
+ }
401
+ catch {
402
+ // Ignore reply errors
403
+ }
404
+ }
405
+ }
406
+ }
407
+ /**
408
+ * Handle slash command interactions.
409
+ */
410
+ async handleInteraction(interaction) {
411
+ // Handle button interactions
412
+ if (interaction.isButton && interaction.isButton()) {
413
+ await this.handleButtonInteraction(interaction);
414
+ return;
415
+ }
416
+ // Only handle slash commands
417
+ if (!interaction.isChatInputCommand || !interaction.isChatInputCommand())
418
+ return;
419
+ // Check if guild is allowed
420
+ if (!this.isGuildAllowed(interaction.guild?.id)) {
421
+ if (this.debug) {
422
+ console.log(`[discord] Ignoring interaction from unauthorized guild: ${interaction.guild?.id}`);
423
+ }
424
+ try {
425
+ await interaction.reply({
426
+ content: "This server is not authorized to use ralph commands.",
427
+ ephemeral: true,
428
+ });
429
+ }
430
+ catch {
431
+ // Ignore reply errors
432
+ }
433
+ return;
434
+ }
435
+ // Check if channel is allowed
436
+ if (!this.isChannelAllowed(interaction.channel?.id)) {
437
+ if (this.debug) {
438
+ console.log(`[discord] Ignoring interaction from unauthorized channel: ${interaction.channel?.id}`);
439
+ }
440
+ try {
441
+ await interaction.reply({
442
+ content: "This channel is not authorized to use ralph commands.",
443
+ ephemeral: true,
444
+ });
445
+ }
446
+ catch {
447
+ // Ignore reply errors
448
+ }
449
+ return;
450
+ }
451
+ const commandName = interaction.commandName;
452
+ const args = [];
453
+ // Extract arguments based on command
454
+ const argMappings = {
455
+ run: "category",
456
+ add: "description",
457
+ exec: "command",
458
+ action: "name",
459
+ claude: "prompt",
460
+ };
461
+ const argName = argMappings[commandName];
462
+ if (argName) {
463
+ const argValue = interaction.options?.getString(argName);
464
+ if (argValue) {
465
+ // Split the argument for commands that expect multiple args
466
+ if (commandName === "exec" || commandName === "add" || commandName === "claude") {
467
+ args.push(...argValue.split(/\s+/));
468
+ }
469
+ else {
470
+ args.push(argValue);
471
+ }
472
+ }
473
+ }
474
+ const chatMessage = {
475
+ text: `/${commandName} ${args.join(" ")}`.trim(),
476
+ chatId: interaction.channel?.id || "",
477
+ senderId: interaction.user?.id,
478
+ senderName: interaction.user?.username,
479
+ timestamp: new Date(),
480
+ raw: interaction,
481
+ };
482
+ const parsedCommand = {
483
+ projectId: "",
484
+ command: commandName,
485
+ args,
486
+ message: chatMessage,
487
+ };
488
+ // Acknowledge the interaction immediately (Discord requires response within 3 seconds)
489
+ try {
490
+ await interaction.deferReply();
491
+ }
492
+ catch (err) {
493
+ if (this.debug) {
494
+ console.error(`[discord] Failed to defer reply: ${err}`);
495
+ }
496
+ return;
497
+ }
498
+ if (this.onCommand) {
499
+ try {
500
+ await this.onCommand(parsedCommand);
501
+ }
502
+ catch (err) {
503
+ if (this.debug) {
504
+ console.error(`[discord] Slash command error: ${err}`);
505
+ }
506
+ try {
507
+ await interaction.editReply(`Error executing /${commandName}: ${err instanceof Error ? err.message : "Unknown error"}`);
508
+ }
509
+ catch {
510
+ // Ignore edit errors
511
+ }
512
+ }
513
+ }
514
+ }
515
+ /**
516
+ * Handle button interactions.
517
+ */
518
+ async handleButtonInteraction(interaction) {
519
+ // Check if guild is allowed
520
+ if (!this.isGuildAllowed(interaction.guild?.id)) {
521
+ if (this.debug) {
522
+ console.log(`[discord] Ignoring button from unauthorized guild: ${interaction.guild?.id}`);
523
+ }
524
+ return;
525
+ }
526
+ // Check if channel is allowed
527
+ if (!this.isChannelAllowed(interaction.channel?.id)) {
528
+ if (this.debug) {
529
+ console.log(`[discord] Ignoring button from unauthorized channel: ${interaction.channel?.id}`);
530
+ }
531
+ return;
532
+ }
533
+ // The custom_id contains the command (e.g., "/run feature")
534
+ const commandText = interaction.customId;
535
+ if (!commandText)
536
+ return;
537
+ const chatMessage = {
538
+ text: commandText,
539
+ chatId: interaction.channel?.id || "",
540
+ senderId: interaction.user?.id,
541
+ senderName: interaction.user?.username,
542
+ timestamp: new Date(),
543
+ raw: interaction,
544
+ };
545
+ // Parse and execute the command
546
+ const command = parseCommand(commandText, chatMessage);
547
+ // Acknowledge the button press
548
+ try {
549
+ await interaction.deferUpdate();
550
+ }
551
+ catch (err) {
552
+ if (this.debug) {
553
+ console.error(`[discord] Failed to defer button update: ${err}`);
554
+ }
555
+ }
556
+ if (command && this.onCommand) {
557
+ try {
558
+ await this.onCommand(command);
559
+ }
560
+ catch (err) {
561
+ if (this.debug) {
562
+ console.error(`[discord] Button action error: ${err}`);
563
+ }
564
+ }
565
+ }
566
+ }
567
+ async connect(onCommand, onMessage) {
568
+ if (this.connected) {
569
+ throw new Error("Already connected");
570
+ }
571
+ // Load Discord modules dynamically
572
+ const loaded = await loadDiscordModules();
573
+ if (!loaded || !ClientConstructor || !GatewayIntentBits) {
574
+ throw new Error("Failed to load Discord modules. Make sure discord.js is installed:\n" +
575
+ " npm install discord.js");
576
+ }
577
+ this.onCommand = onCommand;
578
+ this.onMessage = onMessage || null;
579
+ try {
580
+ // Create Discord client with required intents
581
+ this.client = new ClientConstructor({
582
+ intents: [
583
+ GatewayIntentBits.Guilds,
584
+ GatewayIntentBits.GuildMessages,
585
+ GatewayIntentBits.MessageContent,
586
+ GatewayIntentBits.DirectMessages,
587
+ ],
588
+ });
589
+ // Setup event handlers
590
+ this.client.on("messageCreate", (message) => this.handleMessage(message));
591
+ this.client.on("interactionCreate", (interaction) => this.handleInteraction(interaction));
592
+ // Handle ready event
593
+ await new Promise((resolve, reject) => {
594
+ const timeout = setTimeout(() => {
595
+ reject(new Error("Discord login timed out after 30 seconds"));
596
+ }, 30000);
597
+ this.client.once("ready", async () => {
598
+ clearTimeout(timeout);
599
+ // Store bot user ID for mention detection
600
+ this.botUserId = this.client.user?.id || null;
601
+ if (this.debug) {
602
+ console.log(`[discord] Connected as ${this.client.user?.tag} (ID: ${this.botUserId})`);
603
+ }
604
+ // Register slash commands after login
605
+ await this.registerSlashCommands();
606
+ resolve();
607
+ });
608
+ this.client.once("error", (err) => {
609
+ clearTimeout(timeout);
610
+ reject(err);
611
+ });
612
+ // Login to Discord
613
+ this.client.login(this.settings.botToken).catch((err) => {
614
+ clearTimeout(timeout);
615
+ reject(err);
616
+ });
617
+ });
618
+ this.connected = true;
619
+ }
620
+ catch (err) {
621
+ throw new Error(`Failed to connect to Discord: ${err instanceof Error ? err.message : "Unknown error"}`);
622
+ }
623
+ }
624
+ async sendMessage(chatId, text, options) {
625
+ if (!this.connected || !this.client) {
626
+ throw new Error("Not connected");
627
+ }
628
+ // Get the channel
629
+ let channel;
630
+ try {
631
+ channel = await this.client.channels.fetch(chatId);
632
+ }
633
+ catch (err) {
634
+ throw new Error(`Failed to fetch channel ${chatId}: ${err instanceof Error ? err.message : "Unknown error"}`);
635
+ }
636
+ if (!channel || !channel.send) {
637
+ throw new Error(`Channel ${chatId} is not a text channel or doesn't exist`);
638
+ }
639
+ // Build message payload
640
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
641
+ const payload = {
642
+ content: text.substring(0, 2000), // Discord has 2000 char limit
643
+ };
644
+ // Convert inline keyboard to Discord buttons
645
+ if (options?.inlineKeyboard &&
646
+ options.inlineKeyboard.length > 0 &&
647
+ ActionRowBuilder &&
648
+ ButtonBuilder &&
649
+ ButtonStyle) {
650
+ const components = [];
651
+ for (const row of options.inlineKeyboard) {
652
+ const actionRow = new ActionRowBuilder();
653
+ const buttons = [];
654
+ for (const button of row) {
655
+ const discordButton = new ButtonBuilder();
656
+ if (button.url) {
657
+ // URL buttons use Link style
658
+ discordButton
659
+ .setLabel(button.text.substring(0, 80)) // Discord button label limit
660
+ .setStyle(ButtonStyle.Link)
661
+ .setURL(button.url);
662
+ }
663
+ else {
664
+ // Regular buttons use Primary style with custom_id
665
+ discordButton
666
+ .setLabel(button.text.substring(0, 80))
667
+ .setStyle(ButtonStyle.Primary)
668
+ .setCustomId(button.callbackData || button.text);
669
+ }
670
+ buttons.push(discordButton);
671
+ }
672
+ actionRow.addComponents(...buttons);
673
+ components.push(actionRow);
674
+ }
675
+ payload.components = components;
676
+ }
677
+ await channel.send(payload);
678
+ }
679
+ async disconnect() {
680
+ if (this.client) {
681
+ this.client.destroy();
682
+ this.client = null;
683
+ }
684
+ this.connected = false;
685
+ this.onCommand = null;
686
+ this.onMessage = null;
687
+ }
688
+ isConnected() {
689
+ return this.connected;
690
+ }
691
+ }
692
+ /**
693
+ * Create a Discord chat client from settings.
694
+ */
695
+ export function createDiscordClient(settings, debug = false) {
696
+ return new DiscordChatClient(settings, debug);
697
+ }