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.
- package/README.md +30 -0
- package/dist/commands/action.js +47 -20
- package/dist/commands/chat.d.ts +1 -1
- package/dist/commands/chat.js +325 -62
- package/dist/commands/config.js +2 -1
- package/dist/commands/daemon.d.ts +2 -5
- package/dist/commands/daemon.js +118 -49
- package/dist/commands/docker.js +110 -73
- package/dist/commands/fix-config.js +2 -1
- package/dist/commands/fix-prd.js +2 -2
- package/dist/commands/help.js +19 -3
- package/dist/commands/init.js +78 -17
- package/dist/commands/listen.js +116 -5
- package/dist/commands/logo.d.ts +5 -0
- package/dist/commands/logo.js +41 -0
- package/dist/commands/notify.js +1 -1
- package/dist/commands/once.js +19 -9
- package/dist/commands/prd.js +20 -2
- package/dist/commands/run.js +111 -27
- package/dist/commands/slack.d.ts +10 -0
- package/dist/commands/slack.js +333 -0
- package/dist/config/responder-presets.json +69 -0
- package/dist/index.js +6 -1
- package/dist/providers/discord.d.ts +82 -0
- package/dist/providers/discord.js +697 -0
- package/dist/providers/slack.d.ts +79 -0
- package/dist/providers/slack.js +715 -0
- package/dist/providers/telegram.d.ts +30 -0
- package/dist/providers/telegram.js +190 -7
- package/dist/responders/claude-code-responder.d.ts +48 -0
- package/dist/responders/claude-code-responder.js +203 -0
- package/dist/responders/cli-responder.d.ts +62 -0
- package/dist/responders/cli-responder.js +298 -0
- package/dist/responders/llm-responder.d.ts +135 -0
- package/dist/responders/llm-responder.js +582 -0
- package/dist/templates/macos-scripts.js +2 -4
- package/dist/templates/prompts.js +4 -2
- package/dist/tui/ConfigEditor.js +42 -5
- package/dist/tui/components/ArrayEditor.js +1 -1
- package/dist/tui/components/EditorPanel.js +10 -6
- package/dist/tui/components/HelpPanel.d.ts +1 -1
- package/dist/tui/components/HelpPanel.js +1 -1
- package/dist/tui/components/JsonSnippetEditor.js +8 -5
- package/dist/tui/components/KeyValueEditor.js +69 -5
- package/dist/tui/components/LLMProvidersEditor.d.ts +22 -0
- package/dist/tui/components/LLMProvidersEditor.js +357 -0
- package/dist/tui/components/ObjectEditor.js +1 -1
- package/dist/tui/components/Preview.js +1 -1
- package/dist/tui/components/RespondersEditor.d.ts +22 -0
- package/dist/tui/components/RespondersEditor.js +437 -0
- package/dist/tui/components/SectionNav.js +27 -3
- package/dist/tui/utils/presets.js +15 -2
- package/dist/utils/chat-client.d.ts +33 -4
- package/dist/utils/chat-client.js +20 -1
- package/dist/utils/config.d.ts +100 -1
- package/dist/utils/config.js +78 -1
- package/dist/utils/daemon-actions.d.ts +19 -0
- package/dist/utils/daemon-actions.js +111 -0
- package/dist/utils/daemon-client.d.ts +21 -0
- package/dist/utils/daemon-client.js +28 -1
- package/dist/utils/llm-client.d.ts +82 -0
- package/dist/utils/llm-client.js +185 -0
- package/dist/utils/message-queue.js +6 -6
- package/dist/utils/notification.d.ts +10 -2
- package/dist/utils/notification.js +111 -4
- package/dist/utils/prd-validator.js +60 -19
- package/dist/utils/prompt.js +22 -12
- package/dist/utils/responder-logger.d.ts +47 -0
- package/dist/utils/responder-logger.js +129 -0
- package/dist/utils/responder-presets.d.ts +92 -0
- package/dist/utils/responder-presets.js +156 -0
- package/dist/utils/responder.d.ts +88 -0
- package/dist/utils/responder.js +207 -0
- package/dist/utils/stream-json.js +6 -6
- package/docs/CHAT-CLIENTS.md +520 -0
- package/docs/CHAT-RESPONDERS.md +785 -0
- package/docs/DEVELOPMENT.md +25 -0
- package/docs/USEFUL_ACTIONS.md +815 -0
- package/docs/chat-architecture.md +251 -0
- 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
|
+
}
|