- Add intelligent-router.sh hook for automatic agent routing - Add AUTO-TRIGGER-SUMMARY.md documentation - Add FINAL-INTEGRATION-SUMMARY.md documentation - Complete Prometheus integration (6 commands + 4 tools) - Complete Dexto integration (12 commands + 5 tools) - Enhanced Ralph with access to all agents - Fix /clawd command (removed disable-model-invocation) - Update hooks.json to v5 with intelligent routing - 291 total skills now available - All 21 commands with automatic routing 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
320 lines
13 KiB
TypeScript
320 lines
13 KiB
TypeScript
import dotenv from 'dotenv';
|
|
import { Client, GatewayIntentBits, Partials } from 'discord.js';
|
|
import https from 'https';
|
|
import http from 'http'; // ADDED for http support
|
|
import { DextoAgent, logger } from '@dexto/core';
|
|
|
|
// Load environment variables
|
|
dotenv.config();
|
|
const token = process.env.DISCORD_BOT_TOKEN;
|
|
|
|
// User-based cooldown system for Discord interactions
|
|
const userCooldowns = new Map<string, number>();
|
|
const RATE_LIMIT_ENABLED = process.env.DISCORD_RATE_LIMIT_ENABLED?.toLowerCase() !== 'false'; // default-on
|
|
let COOLDOWN_SECONDS = Number(process.env.DISCORD_RATE_LIMIT_SECONDS ?? 5);
|
|
|
|
if (Number.isNaN(COOLDOWN_SECONDS) || COOLDOWN_SECONDS < 0) {
|
|
console.error(
|
|
'DISCORD_RATE_LIMIT_SECONDS must be a non-negative number. Defaulting to 5 seconds.'
|
|
);
|
|
COOLDOWN_SECONDS = 5; // Default to a safe value
|
|
}
|
|
|
|
// Helper to detect MIME type from file extension
|
|
function getMimeTypeFromPath(filePath: string): string {
|
|
const ext = filePath.split('.').pop()?.toLowerCase() || '';
|
|
const mimeTypes: Record<string, string> = {
|
|
jpg: 'image/jpeg',
|
|
jpeg: 'image/jpeg',
|
|
png: 'image/png',
|
|
gif: 'image/gif',
|
|
webp: 'image/webp',
|
|
ogg: 'audio/ogg',
|
|
mp3: 'audio/mpeg',
|
|
wav: 'audio/wav',
|
|
m4a: 'audio/mp4',
|
|
};
|
|
return mimeTypes[ext] || 'application/octet-stream';
|
|
}
|
|
|
|
// Helper to download a file URL and convert it to base64
|
|
async function downloadFileAsBase64(
|
|
fileUrl: string,
|
|
fileName?: string
|
|
): Promise<{ base64: string; mimeType: string }> {
|
|
return new Promise((resolve, reject) => {
|
|
const protocol = fileUrl.startsWith('https:') ? https : http; // Determine protocol
|
|
const MAX_BYTES = 5 * 1024 * 1024; // 5 MB hard cap
|
|
let downloadedBytes = 0;
|
|
|
|
const req = protocol.get(fileUrl, (res) => {
|
|
// Store the request object
|
|
if (res.statusCode && res.statusCode >= 400) {
|
|
// Clean up response stream
|
|
res.resume();
|
|
return reject(
|
|
new Error(`Failed to download file: ${res.statusCode} ${res.statusMessage}`)
|
|
);
|
|
}
|
|
const chunks: Buffer[] = [];
|
|
res.on('data', (chunk) => {
|
|
downloadedBytes += chunk.length;
|
|
if (downloadedBytes > MAX_BYTES) {
|
|
// Clean up response stream before destroying request
|
|
res.resume();
|
|
req.destroy(new Error('Attachment exceeds 5 MB limit')); // Destroy the request
|
|
// No explicit reject here, as 'error' on req should handle it or timeout will occur
|
|
return;
|
|
}
|
|
chunks.push(chunk);
|
|
});
|
|
res.on('end', () => {
|
|
if (req.destroyed) return; // If request was destroyed due to size limit, do nothing
|
|
const buffer = Buffer.concat(chunks);
|
|
let contentType =
|
|
(res.headers['content-type'] as string) || 'application/octet-stream';
|
|
|
|
// If server returns generic octet-stream, try to detect from file name
|
|
if (contentType === 'application/octet-stream' && fileName) {
|
|
contentType = getMimeTypeFromPath(fileName);
|
|
}
|
|
|
|
resolve({ base64: buffer.toString('base64'), mimeType: contentType });
|
|
});
|
|
// Handle errors on the response stream itself (e.g., premature close)
|
|
res.on('error', (err) => {
|
|
if (!req.destroyed) {
|
|
// Avoid double-rejection if req.destroy() already called this
|
|
reject(err);
|
|
}
|
|
});
|
|
});
|
|
|
|
// Handle errors on the request object (e.g., socket hang up, DNS resolution error, or from req.destroy())
|
|
req.on('error', (err) => {
|
|
reject(err);
|
|
});
|
|
|
|
// Optional: Add a timeout for the request
|
|
req.setTimeout(30000, () => {
|
|
// 30 seconds timeout
|
|
if (!req.destroyed) {
|
|
req.destroy(new Error('File download timed out'));
|
|
}
|
|
});
|
|
});
|
|
}
|
|
|
|
// Insert initDiscordBot to wire up a Discord client given pre-initialized services
|
|
export function startDiscordBot(agent: DextoAgent) {
|
|
if (!token) {
|
|
throw new Error('DISCORD_BOT_TOKEN is not set');
|
|
}
|
|
|
|
const agentEventBus = agent.agentEventBus;
|
|
|
|
// Helper to get or create session for a Discord user
|
|
// Each Discord user gets their own persistent session
|
|
function getDiscordSessionId(userId: string): string {
|
|
return `discord-${userId}`;
|
|
}
|
|
|
|
// Create Discord client
|
|
const client = new Client({
|
|
intents: [
|
|
GatewayIntentBits.Guilds,
|
|
GatewayIntentBits.GuildMessages,
|
|
GatewayIntentBits.MessageContent,
|
|
GatewayIntentBits.DirectMessages,
|
|
],
|
|
partials: [Partials.Channel],
|
|
});
|
|
|
|
client.once('ready', () => {
|
|
console.log(`Discord bot logged in as ${client.user?.tag || 'Unknown'}`);
|
|
});
|
|
|
|
client.on('messageCreate', async (message) => {
|
|
// Ignore bots
|
|
if (message.author.bot) return;
|
|
|
|
if (RATE_LIMIT_ENABLED && COOLDOWN_SECONDS > 0) {
|
|
// Only apply cooldown if enabled and seconds > 0
|
|
const now = Date.now();
|
|
const cooldownEnd = userCooldowns.get(message.author.id) || 0;
|
|
|
|
if (now < cooldownEnd) {
|
|
const timeLeft = (cooldownEnd - now) / 1000;
|
|
try {
|
|
await message.reply(
|
|
`Please wait ${timeLeft.toFixed(1)} more seconds before using this command again.`
|
|
);
|
|
} catch (replyError) {
|
|
console.error('Error sending cooldown message:', replyError);
|
|
}
|
|
return;
|
|
}
|
|
}
|
|
|
|
let userText: string | undefined = message.content;
|
|
let imageDataInput: { image: string; mimeType: string } | undefined;
|
|
let fileDataInput: { data: string; mimeType: string; filename?: string } | undefined;
|
|
|
|
// Helper to determine if mime type is audio
|
|
const isAudioMimeType = (mimeType: string): boolean => {
|
|
return mimeType.startsWith('audio/');
|
|
};
|
|
|
|
// Handle attachments (images and audio)
|
|
if (message.attachments.size > 0) {
|
|
const attachment = message.attachments.first();
|
|
if (attachment && attachment.url) {
|
|
try {
|
|
const { base64, mimeType } = await downloadFileAsBase64(
|
|
attachment.url,
|
|
attachment.name || 'file'
|
|
);
|
|
|
|
if (isAudioMimeType(mimeType)) {
|
|
// Handle audio files
|
|
fileDataInput = {
|
|
data: base64,
|
|
mimeType,
|
|
filename: attachment.name || 'audio.wav',
|
|
};
|
|
// Add context if only audio (no text in message)
|
|
if (!userText) {
|
|
userText =
|
|
'(User sent an audio message for transcription and analysis)';
|
|
}
|
|
} else if (mimeType.startsWith('image/')) {
|
|
// Handle image files
|
|
imageDataInput = { image: base64, mimeType };
|
|
userText = message.content || '';
|
|
}
|
|
} catch (downloadError) {
|
|
console.error('Failed to download attachment:', downloadError);
|
|
try {
|
|
await message.reply(
|
|
`⚠️ Failed to download attachment: ${downloadError instanceof Error ? downloadError.message : 'Unknown error'}. Please try again or send the message without the attachment.`
|
|
);
|
|
} catch (replyError) {
|
|
console.error('Error sending attachment failure message:', replyError);
|
|
}
|
|
// Continue without the attachment - if there's text content, process that
|
|
if (!userText) {
|
|
return; // If there's no text and attachment failed, nothing to process
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// Only respond to !ask prefix or DMs
|
|
if (!message.guild || (userText && userText.startsWith('!ask '))) {
|
|
if (userText && userText.startsWith('!ask ')) {
|
|
userText = userText.substring(5).trim();
|
|
}
|
|
if (!userText) return;
|
|
|
|
// Subscribe for toolCall events
|
|
const toolCallHandler = (payload: {
|
|
toolName: string;
|
|
args: unknown;
|
|
callId?: string;
|
|
sessionId: string;
|
|
}) => {
|
|
message.channel.send(`🔧 Calling tool **${payload.toolName}**`).catch((error) => {
|
|
console.error(
|
|
`Failed to send tool call notification for ${payload.toolName} to channel ${message.channel.id}:`,
|
|
error
|
|
);
|
|
});
|
|
};
|
|
agentEventBus.on('llm:tool-call', toolCallHandler);
|
|
|
|
try {
|
|
const sessionId = getDiscordSessionId(message.author.id);
|
|
await message.channel.sendTyping();
|
|
|
|
// Build content array from message and attachments
|
|
const content: import('@dexto/core').ContentPart[] = [];
|
|
if (userText) {
|
|
content.push({ type: 'text', text: userText });
|
|
}
|
|
if (imageDataInput) {
|
|
content.push({
|
|
type: 'image',
|
|
image: imageDataInput.image,
|
|
mimeType: imageDataInput.mimeType,
|
|
});
|
|
}
|
|
if (fileDataInput) {
|
|
content.push({
|
|
type: 'file',
|
|
data: fileDataInput.data,
|
|
mimeType: fileDataInput.mimeType,
|
|
filename: fileDataInput.filename,
|
|
});
|
|
}
|
|
|
|
const response = await agent.generate(content, sessionId);
|
|
|
|
const responseText = response.content;
|
|
|
|
// Handle Discord's 2000 character limit
|
|
const MAX_LENGTH = 1900; // Leave some buffer
|
|
if (responseText && responseText.length <= MAX_LENGTH) {
|
|
await message.reply(responseText);
|
|
} else if (responseText) {
|
|
// Split into chunks and send multiple messages
|
|
let remaining = responseText;
|
|
let isFirst = true;
|
|
|
|
while (remaining && remaining.length > 0) {
|
|
const chunk = remaining.substring(0, MAX_LENGTH);
|
|
remaining = remaining.substring(MAX_LENGTH);
|
|
|
|
if (isFirst) {
|
|
await message.reply(chunk);
|
|
isFirst = false;
|
|
} else {
|
|
// For subsequent chunks, use message.channel.send to avoid a chain of replies
|
|
// Adding a small delay helps with ordering and rate limits
|
|
await new Promise((resolve) => setTimeout(resolve, 250)); // 250ms delay
|
|
await message.channel.send(chunk);
|
|
}
|
|
}
|
|
} else {
|
|
await message.reply(
|
|
'🤖 I received your message but could not generate a response.'
|
|
);
|
|
}
|
|
|
|
// Log token usage if available (optional analytics)
|
|
if (response.usage) {
|
|
logger.debug(
|
|
`Session ${sessionId} - Tokens: input=${response.usage.inputTokens}, output=${response.usage.outputTokens}`
|
|
);
|
|
}
|
|
} catch (error) {
|
|
console.error('Error handling Discord message', error);
|
|
try {
|
|
const errorMessage = error instanceof Error ? error.message : 'Unknown error';
|
|
await message.reply(`❌ Error: ${errorMessage}`);
|
|
} catch (replyError) {
|
|
console.error('Error sending error reply:', replyError);
|
|
}
|
|
} finally {
|
|
agentEventBus.off('llm:tool-call', toolCallHandler);
|
|
// Set cooldown for the user after processing
|
|
if (RATE_LIMIT_ENABLED && COOLDOWN_SECONDS > 0) {
|
|
userCooldowns.set(message.author.id, Date.now() + COOLDOWN_SECONDS * 1000);
|
|
}
|
|
}
|
|
}
|
|
});
|
|
|
|
client.login(token);
|
|
return client;
|
|
}
|