diff --git a/.changeset/sweet-planes-follow.md b/.changeset/sweet-planes-follow.md new file mode 100644 index 0000000000..6aa9556f09 --- /dev/null +++ b/.changeset/sweet-planes-follow.md @@ -0,0 +1,10 @@ +--- +--- + +Improve Addie's Slack message understanding + +- Fix Addie not seeing forwarded Slack messages (content is in `attachments`, not `text`) +- Add reaction-based confirmations: thumbs up on "should I proceed?" means yes +- Add file share awareness: Addie now sees file metadata when users share files +- Add `fetch_url` tool: Addie can read content from URLs shared in messages +- Add `read_slack_file` tool: Addie can read text files shared in Slack diff --git a/server/src/addie/bolt-app.ts b/server/src/addie/bolt-app.ts index 538f73e2b2..a835518a30 100644 --- a/server/src/addie/bolt-app.ts +++ b/server/src/addie/bolt-app.ts @@ -66,6 +66,137 @@ import { getThreadService, type ThreadContext } from './thread-service.js'; import { getThreadReplies, getSlackUser, getChannelInfo } from '../slack/client.js'; import { AddieRouter, type RoutingContext, type ExecutionPlan } from './router.js'; import { getCachedInsights, prefetchInsights } from './insights-cache.js'; +import { URL_TOOLS, createUrlToolHandlers } from './mcp/url-tools.js'; + +/** + * Slack attachment type for forwarded messages + */ +interface SlackAttachment { + author_name?: string; + pretext?: string; + text?: string; + footer?: string; + fallback?: string; + title?: string; + title_link?: string; +} + +/** + * Slack file type for file shares + */ +interface SlackFile { + id: string; + name?: string; + title?: string; + mimetype?: string; + filetype?: string; + size?: number; + url_private?: string; + permalink?: string; +} + +/** + * Reactions that mean "yes, proceed" or "approved" + */ +const POSITIVE_REACTIONS = new Set([ + 'thumbsup', '+1', 'white_check_mark', 'heavy_check_mark', 'ok', 'ok_hand', + 'the_horns', 'raised_hands', 'clap', 'fire', 'rocket', 'star', 'heart', + 'green_heart', 'blue_heart', 'tada', 'sparkles', 'muscle', 'pray', +]); + +/** + * Reactions that mean "no, don't proceed" or "rejected" + */ +const NEGATIVE_REACTIONS = new Set([ + 'thumbsdown', '-1', 'x', 'negative_squared_cross_mark', 'no_entry', + 'no_entry_sign', 'octagonal_sign', 'stop_sign', 'hand', 'raised_hand', +]); + +/** + * Extract text content from forwarded messages in Slack attachments. + * When users forward a message, Slack puts the forwarded content in the attachments array. + */ +function extractForwardedContent(attachments?: SlackAttachment[]): string { + if (!attachments || attachments.length === 0) { + return ''; + } + + const attachmentTexts: string[] = []; + for (const attachment of attachments) { + const parts: string[] = []; + if (attachment.author_name) { + parts.push(`From: ${attachment.author_name}`); + } + if (attachment.pretext) { + parts.push(attachment.pretext); + } + if (attachment.text) { + parts.push(attachment.text); + } + if (attachment.footer) { + parts.push(`(${attachment.footer})`); + } + if (parts.length > 0) { + attachmentTexts.push(parts.join('\n')); + } + } + + if (attachmentTexts.length === 0) { + return ''; + } + + logger.debug({ attachmentCount: attachments.length, extractedLength: attachmentTexts.join('').length }, 'Addie Bolt: Extracted forwarded message content from attachments'); + return `\n\n[Forwarded message]\n${attachmentTexts.join('\n---\n')}`; +} + +/** + * Extract file information from Slack file shares. + * Provides context about shared files so Claude knows what was shared. + */ +function extractFileInfo(files?: SlackFile[]): string { + if (!files || files.length === 0) { + return ''; + } + + const fileDescriptions: string[] = []; + for (const file of files) { + const parts: string[] = []; + const name = file.title || file.name || 'Unnamed file'; + parts.push(`File: ${name}`); + if (file.filetype) { + parts.push(`Type: ${file.filetype.toUpperCase()}`); + } + if (file.size) { + const sizeKB = Math.round(file.size / 1024); + parts.push(`Size: ${sizeKB > 1024 ? `${(sizeKB / 1024).toFixed(1)} MB` : `${sizeKB} KB`}`); + } + if (file.permalink) { + parts.push(`Link: ${file.permalink}`); + } + fileDescriptions.push(parts.join(' | ')); + } + + logger.debug({ fileCount: files.length }, 'Addie Bolt: Extracted file information'); + return `\n\n[Shared files]\n${fileDescriptions.join('\n')}`; +} + +/** + * Extract URLs from message text for context. + * Returns a list of URLs that could be fetched for more context. + */ +function extractUrls(text: string): string[] { + // Match URLs in Slack format or plain URLs + const slackUrlPattern = /<(https?:\/\/[^|>]+)(?:\|[^>]*)?>|(?]+)/gi; + const urls: string[] = []; + let match; + while ((match = slackUrlPattern.exec(text)) !== null) { + const url = match[1] || match[2]; + if (url && !urls.includes(url)) { + urls.push(url); + } + } + return urls; +} let boltApp: InstanceType | null = null; // eslint-disable-next-line @typescript-eslint/no-explicit-any @@ -148,6 +279,15 @@ export async function initializeAddieBolt(): Promise<{ app: InstanceType\\s*`, 'gi'), '').trim(); } + // Extract forwarded message content from attachments + const attachments = 'attachments' in event ? (event.attachments as SlackAttachment[]) : undefined; + const forwardedContent = extractForwardedContent(attachments); + + // Extract file information from file shares + const files = 'files' in event ? (event.files as SlackFile[]) : undefined; + const fileInfo = extractFileInfo(files); + // Handle empty mentions (just @Addie with no message) // This commonly happens when Addie is added to a channel - provide clear context to Claude - const isEmptyMention = rawText.length === 0; - const originalUserInput = rawText; // Preserve for audit logging + const isEmptyMention = rawText.length === 0 && forwardedContent.length === 0 && fileInfo.length === 0; + const originalUserInput = rawText + forwardedContent + fileInfo; // Preserve for audit logging if (isEmptyMention) { rawText = '[Empty mention - user tagged me without a question. Briefly introduce myself and offer help. Do not assume they are new to the channel.]'; + } else { + // Append forwarded content and file info to the user's message + rawText = rawText + forwardedContent + fileInfo; } const userId = event.user; @@ -1198,7 +1352,7 @@ async function indexChannelMessage( * similar to chatting with a human user. */ async function handleDirectMessage( - event: { channel: string; user?: string; text?: string; ts: string; thread_ts?: string; bot_id?: string }, + event: { channel: string; user?: string; text?: string; ts: string; thread_ts?: string; bot_id?: string; attachments?: SlackAttachment[]; files?: SlackFile[] }, _context: { botUserId?: string } ): Promise { if (!claudeClient || !boltApp) { @@ -1213,11 +1367,19 @@ async function handleDirectMessage( } const userId = event.user; - const messageText = event.text; const channelId = event.channel; const threadTs = event.thread_ts || event.ts; - if (!userId || !messageText) { + // Extract forwarded message content from attachments + const forwardedContent = extractForwardedContent(event.attachments); + + // Extract file information from file shares + const fileInfo = extractFileInfo(event.files); + + // Combine message text with any forwarded content and file info + const messageText = (event.text || '') + forwardedContent + fileInfo; + + if (!userId || !messageText.trim()) { logger.debug('Addie Bolt: Ignoring DM without user or text'); return; } @@ -1412,27 +1574,41 @@ async function handleChannelMessage({ return; } - // Type guard for message events - skip subtypes (edits, deletes, etc.) - if (!('text' in event) || !event.text || ('subtype' in event && event.subtype)) { - return; - } - // Skip bot messages (including our own) if ('bot_id' in event && event.bot_id) { return; } - // Skip if this is a mention (handled by handleAppMention) - if (context.botUserId && event.text.includes(`<@${context.botUserId}>`)) { - return; - } + // Skip subtypes (edits, deletes, etc.) - but only for non-DM messages + // DMs can have forwarded messages where text is empty but attachments have content + const hasText = 'text' in event && event.text; + const hasAttachments = 'attachments' in event && Array.isArray(event.attachments) && event.attachments.length > 0; + const hasFiles = 'files' in event && Array.isArray(event.files) && event.files.length > 0; + const hasSubtype = 'subtype' in event && event.subtype; const userId = 'user' in event ? event.user : undefined; // Handle DMs differently - route to the user message handler + // For DMs, allow messages with attachments or files even if text is empty if (event.channel_type === 'im') { + if (!hasText && !hasAttachments && !hasFiles) { + return; + } + if (hasSubtype) { + return; + } logger.debug({ channelId: event.channel, userId }, 'Addie Bolt: Routing DM to user message handler'); - await handleDirectMessage(event, context); + await handleDirectMessage(event as typeof event & { attachments?: SlackAttachment[]; files?: SlackFile[] }, context); + return; + } + + // For channel messages, require text and skip subtypes + if (!hasText || hasSubtype) { + return; + } + + // Skip if this is a mention (handled by handleAppMention) + if (context.botUserId && event.text && event.text.includes(`<@${context.botUserId}>`)) { return; } if (!userId) { @@ -1440,7 +1616,8 @@ async function handleChannelMessage({ } const channelId = event.channel; - const messageText = event.text; + // At this point we know hasText is true, so event.text exists + const messageText = event.text!; const threadTs = ('thread_ts' in event ? event.thread_ts : undefined) || event.ts; const isInThread = !!('thread_ts' in event && event.thread_ts); const startTime = Date.now(); @@ -1701,3 +1878,196 @@ export async function sendAccountLinkedMessage( return false; } } + +/** + * Handle reaction_added events + * When users react to Addie's messages, interpret the reaction as input: + * - Thumbs up / check = "yes, proceed" or positive feedback + * - Thumbs down / X = "no, don't do that" or negative feedback + */ +async function handleReactionAdded({ + event, + context, +}: SlackEventMiddlewareArgs<'reaction_added'> & { context: { botUserId?: string } }): Promise { + if (!claudeClient || !boltApp) { + return; + } + + // Use boltApp.client for API calls + const client = boltApp.client; + + const reaction = event.reaction; + const reactingUserId = event.user; + const itemChannel = event.item.channel; + const itemTs = event.item.ts; + const itemUser = event.item_user; // Who authored the message that received the reaction + + // Only process reactions on Addie's messages + if (!context.botUserId || itemUser !== context.botUserId) { + return; + } + + // Check if this is a meaningful reaction (positive or negative) + const isPositive = POSITIVE_REACTIONS.has(reaction); + const isNegative = NEGATIVE_REACTIONS.has(reaction); + + if (!isPositive && !isNegative) { + // Not a reaction we care about + return; + } + + logger.info( + { reaction, isPositive, isNegative, reactingUserId, itemChannel, itemTs }, + 'Addie Bolt: Received reaction on Addie message' + ); + + const threadService = getThreadService(); + + // Build external ID to find the thread + // For thread replies, itemTs is the reply ts; we need to find the thread + // First, try to get the message to find its thread_ts + let threadTs = itemTs; + try { + const result = await client.conversations.replies({ + channel: itemChannel, + ts: itemTs, + limit: 1, + inclusive: true, + }); + if (result.messages && result.messages.length > 0) { + // If the message has a thread_ts, use that; otherwise use the message ts + threadTs = result.messages[0].thread_ts || result.messages[0].ts || itemTs; + } + } catch (error) { + logger.debug({ error, itemChannel, itemTs }, 'Addie Bolt: Could not fetch message for thread_ts'); + } + + const externalId = `${itemChannel}:${threadTs}`; + + // Find the thread + const thread = await threadService.getThreadByExternalId('slack', externalId); + if (!thread) { + logger.debug({ externalId }, 'Addie Bolt: No thread found for reaction'); + return; + } + + // Get the last few messages to understand context + const messages = await threadService.getThreadMessages(thread.thread_id); + const lastAssistantMessage = messages + .filter(m => m.role === 'assistant') + .pop(); + + if (!lastAssistantMessage) { + return; + } + + // Check if the last assistant message was asking for confirmation + const messageContent = lastAssistantMessage.content.toLowerCase(); + const isConfirmationRequest = + messageContent.includes('should i') || + messageContent.includes('shall i') || + messageContent.includes('want me to') || + messageContent.includes('go ahead') || + messageContent.includes('proceed') || + messageContent.includes('confirm') || + messageContent.includes('would you like me to') || + messageContent.includes('do you want me to'); + + // Determine the user's intent + let userInput: string; + if (isConfirmationRequest) { + if (isPositive) { + userInput = '[User reacted with ' + reaction + ' emoji to confirm: Yes, go ahead]'; + } else { + userInput = '[User reacted with ' + reaction + ' emoji to decline: No, don\'t do that]'; + } + } else { + // Not a confirmation, just feedback + if (isPositive) { + userInput = '[User reacted with ' + reaction + ' emoji as positive feedback]'; + // Record as positive feedback + await threadService.addMessageFeedback(lastAssistantMessage.message_id, { + rating: 5, + rating_category: 'emoji_feedback', + rating_notes: `User reacted with :${reaction}:`, + rated_by: reactingUserId, + rating_source: 'user', + }); + // Don't respond to general positive feedback + return; + } else { + userInput = '[User reacted with ' + reaction + ' emoji as negative feedback]'; + // Record as negative feedback + await threadService.addMessageFeedback(lastAssistantMessage.message_id, { + rating: 1, + rating_category: 'emoji_feedback', + rating_notes: `User reacted with :${reaction}:`, + rated_by: reactingUserId, + rating_source: 'user', + }); + // Don't respond to general negative feedback + return; + } + } + + // Log the reaction as a user message + await threadService.addMessage({ + thread_id: thread.thread_id, + role: 'user', + content: userInput, + content_sanitized: userInput, + }); + + // Get member context + const { message: messageWithContext, memberContext } = await buildMessageWithMemberContext( + reactingUserId, + userInput + ); + + // Create user-scoped tools + const userTools = await createUserScopedTools(memberContext, reactingUserId); + + // Process with Claude + let response; + try { + response = await claudeClient.processMessage(messageWithContext, undefined, userTools); + } catch (error) { + logger.error({ error }, 'Addie Bolt: Error processing reaction response'); + response = { + text: isPositive ? "Got it, I'll proceed!" : "Understood, I won't do that.", + tools_used: [], + tool_executions: [], + flagged: false, + }; + } + + // Send response in thread + try { + await client.chat.postMessage({ + channel: itemChannel, + text: response.text, + thread_ts: threadTs, + }); + } catch (error) { + logger.error({ error }, 'Addie Bolt: Failed to send reaction response'); + } + + // Log assistant response + await threadService.addMessage({ + thread_id: thread.thread_id, + role: 'assistant', + content: response.text, + tools_used: response.tools_used, + tool_calls: response.tool_executions?.map(exec => ({ + name: exec.tool_name, + input: exec.parameters, + result: exec.result, + })), + model: AddieModelConfig.chat, + }); + + logger.info( + { threadId: thread.thread_id, reaction, isConfirmation: isConfirmationRequest }, + 'Addie Bolt: Processed reaction and responded' + ); +} diff --git a/server/src/addie/mcp/url-tools.ts b/server/src/addie/mcp/url-tools.ts new file mode 100644 index 0000000000..844d9e7cd6 --- /dev/null +++ b/server/src/addie/mcp/url-tools.ts @@ -0,0 +1,387 @@ +/** + * URL and File Fetching Tools for Addie + * + * These tools allow Addie to: + * 1. Fetch and read content from URLs shared in messages + * 2. Download and read files shared in Slack + * + * This gives Addie context about external content users reference. + */ + +import { logger } from '../../logger.js'; +import type { AddieTool } from '../types.js'; + +// Maximum content size to prevent memory issues (500KB) +const MAX_CONTENT_SIZE = 500 * 1024; + +// Maximum time to wait for fetch (10 seconds) +const FETCH_TIMEOUT_MS = 10000; + +/** + * Tool definitions for URL/file fetching + */ +export const URL_TOOLS: AddieTool[] = [ + { + name: 'fetch_url', + description: + 'Fetch and read the content of a web URL. Use this when a user shares a link and asks about it, or when you need to read external content. Returns the text content of the page. Note: Does not work for pages requiring authentication.', + usage_hints: 'use when user shares a link and asks "what is this?", "can you read this?", "summarize this article"', + input_schema: { + type: 'object', + properties: { + url: { + type: 'string', + description: 'The URL to fetch (must be http:// or https://)', + }, + extract_type: { + type: 'string', + enum: ['text', 'html', 'markdown'], + description: 'How to extract content: text (plain text), html (raw HTML), markdown (converted to markdown). Default: text', + }, + }, + required: ['url'], + }, + }, + { + name: 'read_slack_file', + description: + 'Download and read a file that was shared in Slack. Use this when a user shares a file (PDF, document, text file, etc.) and asks about its contents. Provide the file URL from the shared file info.', + usage_hints: 'use when user shares a file in Slack and asks "what is in this file?", "can you read this?"', + input_schema: { + type: 'object', + properties: { + file_url: { + type: 'string', + description: 'The url_private or permalink from the Slack file share', + }, + file_name: { + type: 'string', + description: 'The file name (helps with parsing)', + }, + }, + required: ['file_url'], + }, + }, +]; + +/** + * Fetch content from a URL + */ +async function fetchUrlContent( + url: string, + extractType: 'text' | 'html' | 'markdown' = 'text' +): Promise { + // Validate URL + let parsedUrl: URL; + try { + parsedUrl = new URL(url); + } catch { + return `Error: Invalid URL format: ${url}`; + } + + if (!['http:', 'https:'].includes(parsedUrl.protocol)) { + return `Error: Only HTTP and HTTPS URLs are supported`; + } + + // Block obvious problematic domains + const blockedDomains = ['localhost', '127.0.0.1', '0.0.0.0', '::1']; + if (blockedDomains.some(d => parsedUrl.hostname === d || parsedUrl.hostname.endsWith(`.${d}`))) { + return `Error: Cannot fetch from local addresses`; + } + + try { + const controller = new AbortController(); + const timeout = setTimeout(() => controller.abort(), FETCH_TIMEOUT_MS); + + const response = await fetch(url, { + signal: controller.signal, + headers: { + 'User-Agent': 'Addie/1.0 (AgenticAdvertising.org AI Assistant)', + 'Accept': 'text/html,application/xhtml+xml,application/xml;q=0.9,text/plain;q=0.8,*/*;q=0.7', + }, + redirect: 'follow', + }); + + clearTimeout(timeout); + + if (!response.ok) { + return `Error: HTTP ${response.status} ${response.statusText}`; + } + + // Check content length + const contentLength = response.headers.get('content-length'); + if (contentLength && parseInt(contentLength) > MAX_CONTENT_SIZE) { + return `Error: Content too large (${Math.round(parseInt(contentLength) / 1024)}KB > ${MAX_CONTENT_SIZE / 1024}KB limit)`; + } + + // Get content type + const contentType = response.headers.get('content-type') || ''; + + // Read content with size limit + const reader = response.body?.getReader(); + if (!reader) { + return 'Error: Could not read response body'; + } + + const chunks: Uint8Array[] = []; + let totalSize = 0; + + while (true) { + const { done, value } = await reader.read(); + if (done) break; + + totalSize += value.length; + if (totalSize > MAX_CONTENT_SIZE) { + reader.cancel(); + return `Error: Content too large (exceeded ${MAX_CONTENT_SIZE / 1024}KB limit during download)`; + } + chunks.push(value); + } + + const decoder = new TextDecoder(); + const rawContent = decoder.decode(new Uint8Array(chunks.flatMap(c => Array.from(c)))); + + // Extract content based on type + if (extractType === 'html') { + return rawContent; + } + + if (contentType.includes('text/plain') || contentType.includes('application/json')) { + return rawContent; + } + + // For HTML, extract text content + const textContent = extractTextFromHtml(rawContent); + + if (extractType === 'markdown') { + return convertHtmlToMarkdown(rawContent); + } + + return textContent; + } catch (error) { + if (error instanceof Error) { + if (error.name === 'AbortError') { + return `Error: Request timed out after ${FETCH_TIMEOUT_MS / 1000} seconds`; + } + return `Error: ${error.message}`; + } + return 'Error: Unknown fetch error'; + } +} + +/** + * Extract readable text from HTML + */ +function extractTextFromHtml(html: string): string { + // Remove script and style tags completely + let text = html + .replace(/]*>[\s\S]*?<\/script>/gi, '') + .replace(/]*>[\s\S]*?<\/style>/gi, '') + .replace(/]*>[\s\S]*?<\/noscript>/gi, ''); + + // Replace block elements with newlines + text = text + .replace(/<\/?(p|div|br|h[1-6]|li|tr|td|th|blockquote|pre|hr)[^>]*>/gi, '\n') + .replace(/<\/?(ul|ol|table|thead|tbody)[^>]*>/gi, '\n\n'); + + // Remove remaining tags + text = text.replace(/<[^>]+>/g, ''); + + // Decode HTML entities + text = text + .replace(/ /g, ' ') + .replace(/&/g, '&') + .replace(/</g, '<') + .replace(/>/g, '>') + .replace(/"/g, '"') + .replace(/'/g, "'") + .replace(/&#(\d+);/g, (_, num) => String.fromCharCode(parseInt(num))) + .replace(/&#x([0-9a-f]+);/gi, (_, hex) => String.fromCharCode(parseInt(hex, 16))); + + // Clean up whitespace + text = text + .replace(/[ \t]+/g, ' ') + .replace(/\n[ \t]+/g, '\n') + .replace(/[ \t]+\n/g, '\n') + .replace(/\n{3,}/g, '\n\n') + .trim(); + + return text; +} + +/** + * Convert HTML to basic Markdown + */ +function convertHtmlToMarkdown(html: string): string { + let md = html + // Remove script and style + .replace(/]*>[\s\S]*?<\/script>/gi, '') + .replace(/]*>[\s\S]*?<\/style>/gi, ''); + + // Headers + md = md.replace(/]*>(.*?)<\/h1>/gi, '# $1\n\n'); + md = md.replace(/]*>(.*?)<\/h2>/gi, '## $1\n\n'); + md = md.replace(/]*>(.*?)<\/h3>/gi, '### $1\n\n'); + md = md.replace(/]*>(.*?)<\/h4>/gi, '#### $1\n\n'); + md = md.replace(/]*>(.*?)<\/h5>/gi, '##### $1\n\n'); + md = md.replace(/]*>(.*?)<\/h6>/gi, '###### $1\n\n'); + + // Bold and italic + md = md.replace(/<(strong|b)[^>]*>(.*?)<\/\1>/gi, '**$2**'); + md = md.replace(/<(em|i)[^>]*>(.*?)<\/\1>/gi, '*$2*'); + + // Links + md = md.replace(/]*href="([^"]*)"[^>]*>(.*?)<\/a>/gi, '[$2]($1)'); + + // Lists + md = md.replace(/]*>(.*?)<\/li>/gi, '- $1\n'); + + // Paragraphs and breaks + md = md.replace(/<\/p>/gi, '\n\n'); + md = md.replace(//gi, '\n'); + + // Code + md = md.replace(/]*>(.*?)<\/code>/gi, '`$1`'); + md = md.replace(/]*>(.*?)<\/pre>/gis, '```\n$1\n```\n'); + + // Remove remaining tags + md = md.replace(/<[^>]+>/g, ''); + + // Decode entities and clean up + md = md + .replace(/ /g, ' ') + .replace(/&/g, '&') + .replace(/</g, '<') + .replace(/>/g, '>') + .replace(/"/g, '"') + .replace(/\n{3,}/g, '\n\n') + .trim(); + + return md; +} + +/** + * Read a file from Slack using the bot token + */ +async function readSlackFile( + fileUrl: string, + fileName?: string, + botToken?: string +): Promise { + if (!botToken) { + return 'Error: Slack bot token not available for file access'; + } + + // Validate it's a Slack URL + if (!fileUrl.includes('slack.com') && !fileUrl.includes('files.slack.com')) { + return 'Error: Not a valid Slack file URL'; + } + + try { + const controller = new AbortController(); + const timeout = setTimeout(() => controller.abort(), FETCH_TIMEOUT_MS); + + const response = await fetch(fileUrl, { + signal: controller.signal, + headers: { + 'Authorization': `Bearer ${botToken}`, + }, + redirect: 'follow', + }); + + clearTimeout(timeout); + + if (!response.ok) { + if (response.status === 404) { + return 'Error: File not found or no longer available'; + } + if (response.status === 403) { + return 'Error: Access denied - bot may not have permission to access this file'; + } + return `Error: HTTP ${response.status} ${response.statusText}`; + } + + // Check content length + const contentLength = response.headers.get('content-length'); + if (contentLength && parseInt(contentLength) > MAX_CONTENT_SIZE) { + return `Error: File too large (${Math.round(parseInt(contentLength) / 1024)}KB > ${MAX_CONTENT_SIZE / 1024}KB limit)`; + } + + const contentType = response.headers.get('content-type') || ''; + const ext = fileName?.split('.').pop()?.toLowerCase() || ''; + + // Handle text-based files + if ( + contentType.includes('text/') || + contentType.includes('application/json') || + contentType.includes('application/xml') || + ['txt', 'md', 'json', 'xml', 'csv', 'log', 'yaml', 'yml', 'html', 'htm', 'js', 'ts', 'py', 'rb', 'go', 'rs', 'java', 'c', 'cpp', 'h', 'css', 'sql'].includes(ext) + ) { + const text = await response.text(); + if (text.length > MAX_CONTENT_SIZE) { + return `File content (first ${MAX_CONTENT_SIZE / 1024}KB):\n\n${text.substring(0, MAX_CONTENT_SIZE)}...\n\n[Content truncated]`; + } + return `File content:\n\n${text}`; + } + + // Handle PDF - we can't read these directly, but we can acknowledge them + if (contentType.includes('application/pdf') || ext === 'pdf') { + return `This is a PDF file (${fileName || 'unnamed'}). I cannot read PDF content directly, but I can see it was shared. If you need me to understand the content, please copy and paste the relevant text.`; + } + + // Handle images - acknowledge but can't read + if (contentType.includes('image/') || ['png', 'jpg', 'jpeg', 'gif', 'webp', 'svg'].includes(ext)) { + return `This is an image file (${fileName || 'unnamed'}). I can see it was shared but cannot view image contents directly.`; + } + + // Handle other binary files + return `This is a ${contentType || 'binary'} file (${fileName || 'unnamed'}). I cannot read the contents of this file type directly.`; + } catch (error) { + if (error instanceof Error) { + if (error.name === 'AbortError') { + return `Error: Request timed out after ${FETCH_TIMEOUT_MS / 1000} seconds`; + } + return `Error: ${error.message}`; + } + return 'Error: Unknown error reading file'; + } +} + +/** + * Create tool handlers for URL/file fetching + */ +export function createUrlToolHandlers(slackBotToken?: string): Record) => Promise> { + return { + fetch_url: async (input: Record) => { + const url = input.url as string; + const extractType = (input.extract_type as 'text' | 'html' | 'markdown') || 'text'; + + logger.info({ url, extractType }, 'Addie: Fetching URL'); + + const result = await fetchUrlContent(url, extractType); + + // Truncate if too long + if (result.length > 10000) { + return result.substring(0, 10000) + '\n\n[Content truncated to 10,000 characters]'; + } + + return result; + }, + + read_slack_file: async (input: Record) => { + const fileUrl = input.file_url as string; + const fileName = input.file_name as string | undefined; + + logger.info({ fileUrl, fileName }, 'Addie: Reading Slack file'); + + const result = await readSlackFile(fileUrl, fileName, slackBotToken); + + // Truncate if too long + if (result.length > 10000) { + return result.substring(0, 10000) + '\n\n[Content truncated to 10,000 characters]'; + } + + return result; + }, + }; +}