mirror of
https://github.com/stack-auth/stack.git
synced 2026-06-04 21:04:37 +08:00
449 lines
14 KiB
TypeScript
449 lines
14 KiB
TypeScript
/**
|
|
* Discord Bot API implementation with session threading support
|
|
*/
|
|
|
|
interface DiscordMessage {
|
|
id: string;
|
|
channel_id: string;
|
|
content: string;
|
|
timestamp: string;
|
|
thread?: {
|
|
id: string;
|
|
name: string;
|
|
};
|
|
}
|
|
|
|
interface DiscordThread {
|
|
id: string;
|
|
name: string;
|
|
parent_id: string;
|
|
}
|
|
|
|
export async function sendToDiscordWebhook(data: {
|
|
message: string;
|
|
username?: string;
|
|
metadata?: {
|
|
sessionId?: string;
|
|
messageNumber?: number;
|
|
pathname?: string;
|
|
timestamp?: string;
|
|
userAgent?: string;
|
|
messageType?: string;
|
|
timeOnPage?: number;
|
|
isFollowUp?: boolean;
|
|
};
|
|
}) {
|
|
const botToken = process.env.DISCORD_BOT_TOKEN;
|
|
const channelId = process.env.DISCORD_CHANNEL_ID;
|
|
|
|
if (!botToken || !channelId) {
|
|
console.warn('Discord bot token or channel ID not configured');
|
|
return;
|
|
}
|
|
|
|
try {
|
|
const { message, metadata } = data;
|
|
|
|
// Format message with clean text structure
|
|
const sessionPrefix = metadata?.sessionId ? metadata.sessionId.slice(-8) : 'unknown';
|
|
const messageNumber = metadata?.messageNumber || 1;
|
|
const messageType = metadata?.messageType === 'starter-prompt' ? '🟢' : '🔵';
|
|
const timeOnPage = metadata?.timeOnPage ? formatTime(metadata.timeOnPage) : 'N/A';
|
|
const browserInfo = extractBrowserInfo(metadata?.userAgent || '');
|
|
const page = formatPagePath(metadata?.pathname || '/');
|
|
|
|
// Check if an existing thread exists for this session
|
|
const existingThreadId = await findExistingThread(channelId, sessionPrefix);
|
|
|
|
if (existingThreadId) {
|
|
// Send to existing thread
|
|
await sendToThread(existingThreadId, message, {
|
|
messageNumber,
|
|
messageType,
|
|
page,
|
|
timeOnPage,
|
|
browserInfo
|
|
});
|
|
} else {
|
|
// Create new thread for first message
|
|
await createNewThread(channelId, message, {
|
|
sessionPrefix,
|
|
messageNumber,
|
|
messageType,
|
|
page,
|
|
timeOnPage,
|
|
browserInfo
|
|
});
|
|
}
|
|
} catch (error) {
|
|
if (error instanceof TypeError) {
|
|
// Network errors, CORS issues, or malformed URLs
|
|
console.error('Network error sending message to Discord:', error.message);
|
|
} else if (error instanceof SyntaxError) {
|
|
// JSON parsing errors
|
|
console.error('JSON parsing error in Discord webhook:', error.message);
|
|
} else if (error instanceof Error) {
|
|
// General errors with message
|
|
console.error('Error sending message to Discord:', error.message);
|
|
} else {
|
|
// Unknown error types
|
|
console.error('Unknown error sending message to Discord:', error);
|
|
}
|
|
}
|
|
}
|
|
|
|
export async function sendLLMResponseToDiscord(data: {
|
|
response: string;
|
|
metadata?: {
|
|
sessionId?: string;
|
|
model?: string;
|
|
};
|
|
}) {
|
|
const botToken = process.env.DISCORD_BOT_TOKEN;
|
|
const channelId = process.env.DISCORD_CHANNEL_ID;
|
|
|
|
if (!botToken || !channelId) {
|
|
console.warn('Discord bot token or channel ID not configured');
|
|
return;
|
|
}
|
|
|
|
try {
|
|
const { response, metadata } = data;
|
|
const sessionId = metadata?.sessionId || 'unknown';
|
|
const sessionPrefix = sessionId.slice(-8);
|
|
|
|
// Find the existing thread for this session
|
|
const existingThreadId = await findExistingThread(channelId, sessionPrefix);
|
|
|
|
if (!existingThreadId) {
|
|
console.warn(`No thread found for session ${sessionId}`);
|
|
return;
|
|
}
|
|
|
|
await sendResponseToThread(existingThreadId, response, {
|
|
model: metadata?.model
|
|
});
|
|
} catch (error) {
|
|
if (error instanceof TypeError) {
|
|
// Network errors, CORS issues, or malformed URLs
|
|
console.error('Network error sending LLM response to Discord:', error.message);
|
|
} else if (error instanceof SyntaxError) {
|
|
// JSON parsing errors
|
|
console.error('JSON parsing error in Discord LLM response:', error.message);
|
|
} else if (error instanceof Error) {
|
|
// General errors with message
|
|
console.error('Error sending LLM response to Discord:', error.message);
|
|
} else {
|
|
// Unknown error types
|
|
console.error('Unknown error sending LLM response to Discord:', error);
|
|
}
|
|
}
|
|
}
|
|
|
|
async function findExistingThread(channelId: string, sessionPrefix: string): Promise<string | null> {
|
|
const botToken = process.env.DISCORD_BOT_TOKEN;
|
|
|
|
if (!botToken) {
|
|
return null;
|
|
}
|
|
|
|
try {
|
|
// Get recent messages from the channel to find existing threads
|
|
const response = await fetch(`https://discord.com/api/v10/channels/${channelId}/messages?limit=50`, {
|
|
headers: {
|
|
'Authorization': `Bot ${botToken}`,
|
|
'Content-Type': 'application/json',
|
|
},
|
|
});
|
|
|
|
if (!response.ok) {
|
|
const errorText = await response.text();
|
|
console.error(`Failed to fetch recent messages (${response.status}):`, errorText);
|
|
return null;
|
|
}
|
|
|
|
const messages: DiscordMessage[] = await response.json();
|
|
|
|
// Look for a message that contains our session prefix and has an associated thread
|
|
for (const message of messages) {
|
|
if (message.content.includes(`\`${sessionPrefix}\``) && message.thread) {
|
|
console.log(`Found existing thread for session ${sessionPrefix}: ${message.thread.id}`);
|
|
return message.thread.id;
|
|
}
|
|
}
|
|
|
|
return null;
|
|
} catch (error) {
|
|
if (error instanceof TypeError) {
|
|
// Network errors, invalid URL, or connection issues
|
|
console.error('Network error finding existing thread:', error.message);
|
|
} else if (error instanceof SyntaxError) {
|
|
// JSON parsing errors from Discord API response
|
|
console.error('JSON parsing error in Discord API response:', error.message);
|
|
} else if (error instanceof Error) {
|
|
// General errors with message
|
|
console.error('Error finding existing thread:', error.message);
|
|
} else {
|
|
// Unknown error types
|
|
console.error('Unknown error finding existing thread:', error);
|
|
}
|
|
return null;
|
|
}
|
|
}
|
|
|
|
async function createNewThread(
|
|
channelId: string,
|
|
message: string,
|
|
context: {
|
|
sessionPrefix: string;
|
|
messageNumber: number;
|
|
messageType: string;
|
|
page: string;
|
|
timeOnPage: string;
|
|
browserInfo?: string;
|
|
}
|
|
): Promise<void> {
|
|
const botToken = process.env.DISCORD_BOT_TOKEN;
|
|
|
|
try {
|
|
// Clean, readable format
|
|
const initialMessage = `💬 ${message}
|
|
|
|
\`${context.sessionPrefix}\` ${context.messageType} • ${context.page} • Page time: ${context.timeOnPage}${context.browserInfo ? ` • ${context.browserInfo}` : ''}`;
|
|
|
|
const messageResponse = await fetch(`https://discord.com/api/v10/channels/${channelId}/messages`, {
|
|
method: 'POST',
|
|
headers: {
|
|
'Authorization': `Bot ${botToken}`,
|
|
'Content-Type': 'application/json',
|
|
},
|
|
body: JSON.stringify({
|
|
content: initialMessage,
|
|
}),
|
|
});
|
|
|
|
if (!messageResponse.ok) {
|
|
const errorText = await messageResponse.text();
|
|
console.error(`Failed to send initial message (${messageResponse.status}):`, errorText);
|
|
return;
|
|
}
|
|
|
|
const messageData: DiscordMessage = await messageResponse.json();
|
|
|
|
// Create a thread from this message
|
|
const threadName = `${context.sessionPrefix}: ${extractQuestionSummary(message)}`;
|
|
|
|
const threadResponse = await fetch(`https://discord.com/api/v10/channels/${messageData.channel_id}/messages/${messageData.id}/threads`, {
|
|
method: 'POST',
|
|
headers: {
|
|
'Authorization': `Bot ${botToken}`,
|
|
'Content-Type': 'application/json',
|
|
},
|
|
body: JSON.stringify({
|
|
name: threadName.slice(0, 100), // Discord thread names have a 100 character limit
|
|
auto_archive_duration: 1440, // 24 hours
|
|
}),
|
|
});
|
|
|
|
if (!threadResponse.ok) {
|
|
const errorText = await threadResponse.text();
|
|
console.error(`Failed to create thread (${threadResponse.status}):`, errorText);
|
|
return;
|
|
}
|
|
|
|
const threadData: DiscordThread = await threadResponse.json();
|
|
console.log(`Created new thread: ${threadData.name} (${threadData.id})`);
|
|
|
|
} catch (error) {
|
|
if (error instanceof TypeError) {
|
|
// Network errors, invalid URL, or connection issues
|
|
console.error('Network error creating new thread:', error.message);
|
|
} else if (error instanceof SyntaxError) {
|
|
// JSON parsing errors from Discord API response
|
|
console.error('JSON parsing error creating thread:', error.message);
|
|
} else if (error instanceof Error) {
|
|
// General errors with message
|
|
console.error('Error creating thread:', error.message);
|
|
} else {
|
|
// Unknown error types
|
|
console.error('Unknown error creating thread:', error);
|
|
}
|
|
}
|
|
}
|
|
|
|
async function sendToThread(
|
|
threadId: string,
|
|
message: string,
|
|
context: {
|
|
messageNumber: number;
|
|
messageType: string;
|
|
page: string;
|
|
timeOnPage: string;
|
|
browserInfo?: string;
|
|
}
|
|
): Promise<void> {
|
|
const botToken = process.env.DISCORD_BOT_TOKEN;
|
|
|
|
try {
|
|
// Clean follow-up format
|
|
const formattedMessage = `🔄 **${message}**
|
|
|
|
${context.messageType} • ${context.page} • Page time: ${context.timeOnPage}${context.browserInfo ? ` • ${context.browserInfo}` : ''}`;
|
|
|
|
const response = await fetch(`https://discord.com/api/v10/channels/${threadId}/messages`, {
|
|
method: 'POST',
|
|
headers: {
|
|
'Authorization': `Bot ${botToken}`,
|
|
'Content-Type': 'application/json',
|
|
},
|
|
body: JSON.stringify({
|
|
content: formattedMessage,
|
|
}),
|
|
});
|
|
|
|
if (!response.ok) {
|
|
const errorText = await response.text();
|
|
console.error(`Failed to send message to thread (${response.status}):`, errorText);
|
|
// If thread doesn't exist anymore, log it but continue
|
|
if (response.status === 404) {
|
|
console.warn(`Thread ${threadId} no longer exists`);
|
|
}
|
|
} else {
|
|
console.log(`Sent follow-up message to thread ${threadId}`);
|
|
}
|
|
} catch (error) {
|
|
if (error instanceof TypeError) {
|
|
// Network errors, invalid URL, or connection issues
|
|
console.error('Network error sending message to thread:', error.message);
|
|
} else if (error instanceof SyntaxError) {
|
|
// JSON parsing errors
|
|
console.error('JSON parsing error sending to thread:', error.message);
|
|
} else if (error instanceof Error) {
|
|
// General errors with message
|
|
console.error('Error sending message to thread:', error.message);
|
|
} else {
|
|
// Unknown error types
|
|
console.error('Unknown error sending message to thread:', error);
|
|
}
|
|
}
|
|
}
|
|
|
|
async function sendResponseToThread(
|
|
threadId: string,
|
|
response: string,
|
|
context: {
|
|
model?: string;
|
|
}
|
|
): Promise<void> {
|
|
const botToken = process.env.DISCORD_BOT_TOKEN;
|
|
|
|
try {
|
|
const model = context.model || 'N/A';
|
|
|
|
// Truncate response if too long for Discord (2000 char limit)
|
|
const truncatedResponse = response.length > 1500 ? response.slice(0, 1500) + '...' : response;
|
|
|
|
const formattedResponse = `🤖 **AI Response**
|
|
|
|
\`\`\`
|
|
${truncatedResponse}
|
|
\`\`\`
|
|
|
|
${model}`;
|
|
|
|
const discordResponse = await fetch(`https://discord.com/api/v10/channels/${threadId}/messages`, {
|
|
method: 'POST',
|
|
headers: {
|
|
'Authorization': `Bot ${botToken}`,
|
|
'Content-Type': 'application/json',
|
|
},
|
|
body: JSON.stringify({
|
|
content: formattedResponse,
|
|
}),
|
|
});
|
|
|
|
if (!discordResponse.ok) {
|
|
const errorText = await discordResponse.text();
|
|
console.error(`Failed to send response to thread (${discordResponse.status}):`, errorText);
|
|
// If thread doesn't exist anymore, log it but continue
|
|
if (discordResponse.status === 404) {
|
|
console.warn(`Thread ${threadId} no longer exists for AI response`);
|
|
}
|
|
} else {
|
|
console.log(`Sent AI response to thread ${threadId}`);
|
|
}
|
|
} catch (error) {
|
|
if (error instanceof TypeError) {
|
|
// Network errors, invalid URL, or connection issues
|
|
console.error('Network error sending response to thread:', error.message);
|
|
} else if (error instanceof SyntaxError) {
|
|
// JSON parsing errors
|
|
console.error('JSON parsing error sending response to thread:', error.message);
|
|
} else if (error instanceof Error) {
|
|
// General errors with message
|
|
console.error('Error sending response to thread:', error.message);
|
|
} else {
|
|
// Unknown error types
|
|
console.error('Unknown error sending response to thread:', error);
|
|
}
|
|
}
|
|
}
|
|
|
|
function extractQuestionSummary(message: string): string {
|
|
// Extract first few words of the question for thread naming
|
|
const words = message.split(' ').slice(0, 6).join(' ');
|
|
return words.length > 40 ? words.slice(0, 37) + '...' : words;
|
|
}
|
|
|
|
function formatPagePath(pathname: string): string {
|
|
// Simplify common paths for better readability
|
|
if (pathname === '/') return 'Home';
|
|
if (pathname.startsWith('/docs/')) {
|
|
const path = pathname.replace('/docs/', '');
|
|
if (path === '') return 'Docs';
|
|
// Show only the last part of the path for brevity
|
|
const parts = path.split('/');
|
|
return parts[parts.length - 1] || 'Docs';
|
|
}
|
|
return pathname.length > 20 ? '...' + pathname.slice(-17) : pathname;
|
|
}
|
|
|
|
function formatTime(seconds: number): string {
|
|
if (seconds < 60) return `${seconds}s`;
|
|
if (seconds < 3600) return `${Math.floor(seconds / 60)}m`;
|
|
return `${Math.floor(seconds / 3600)}h`;
|
|
}
|
|
|
|
/**
|
|
* Extract browser and OS info from user agent
|
|
*/
|
|
function extractBrowserInfo(userAgent: string): string | undefined {
|
|
if (!userAgent) return undefined;
|
|
|
|
// Browser detection patterns (order matters - Chrome must come before Safari)
|
|
const browserPatterns = [
|
|
{ pattern: 'Edge/', name: 'Edge' },
|
|
{ pattern: 'Chrome/', name: 'Chrome' },
|
|
{ pattern: 'Firefox/', name: 'Firefox' },
|
|
{ pattern: 'Safari/', name: 'Safari' },
|
|
];
|
|
|
|
// OS detection patterns
|
|
const osPatterns = [
|
|
{ pattern: 'Windows NT', name: 'Windows' },
|
|
{ pattern: 'Mac OS X', name: 'macOS' },
|
|
{ pattern: 'iPhone', name: 'iOS' },
|
|
{ pattern: 'iPad', name: 'iOS' },
|
|
{ pattern: 'Android', name: 'Android' },
|
|
{ pattern: 'Linux', name: 'Linux' },
|
|
];
|
|
|
|
// Find browser
|
|
const browser = browserPatterns.find(({ pattern }) => userAgent.includes(pattern))?.name || 'Unknown';
|
|
|
|
// Find OS
|
|
const os = osPatterns.find(({ pattern }) => userAgent.includes(pattern))?.name || 'Unknown';
|
|
|
|
return `${browser} on ${os}`;
|
|
}
|