diff --git a/apps/web/src/lib/bot/platforms/slack.tsx b/apps/web/src/lib/bot/platforms/slack.tsx index 142c592ccb..d23b23f85b 100644 --- a/apps/web/src/lib/bot/platforms/slack.tsx +++ b/apps/web/src/lib/bot/platforms/slack.tsx @@ -5,7 +5,6 @@ import { collectMessages, type ContextTriggerMessage, formatMessage, - formatUserMessage, MAX_MESSAGE_TEXT_LENGTH, sanitizeForDelimiters, truncate, @@ -34,6 +33,65 @@ import { const LINK_ACCOUNT_PATH = '/api/chat/link-account'; +const MAX_ATTACHMENT_TEXT_LENGTH = 300; + +// Slack attachment fields present on unfurled link previews but not on file uploads. +type SlackUnfurlAttachment = { + original_url?: string; + from_url?: string; + title?: string; + text?: string; + fallback?: string; + service_name?: string; + author_name?: string; +}; + +function formatSlackUnfurledAttachments(rawAttachments: unknown): string { + if (!Array.isArray(rawAttachments) || rawAttachments.length === 0) return ''; + + const lines: string[] = []; + for (const raw of rawAttachments) { + if (typeof raw !== 'object' || raw === null) continue; + const att = raw as SlackUnfurlAttachment; + // Only include link-preview / unfurled attachments — they have original_url or from_url. + // File-upload attachments have id/mimetype but no original_url. + const url = att.original_url ?? att.from_url; + if (!url) continue; + + const titlePart = att.title ? sanitizeForDelimiters(att.title) : null; + const textPart = att.text + ? sanitizeForDelimiters( + truncate(att.text.replace(/\s+/g, ' ').trim(), MAX_ATTACHMENT_TEXT_LENGTH) + ) + : att.fallback + ? sanitizeForDelimiters( + truncate(att.fallback.replace(/\s+/g, ' ').trim(), MAX_ATTACHMENT_TEXT_LENGTH) + ) + : null; + const servicePart = att.service_name ? sanitizeForDelimiters(att.service_name) : null; + const authorPart = att.author_name ? sanitizeForDelimiters(att.author_name) : null; + + const meta: string[] = []; + if (servicePart) meta.push(servicePart); + if (authorPart) meta.push(`by ${authorPart}`); + + const sanitizedUrl = sanitizeForDelimiters(url); + let line = `[Unfurled link: ${titlePart ?? sanitizedUrl} (${sanitizedUrl})`; + if (meta.length > 0) line += ` — ${meta.join(', ')}`; + if (textPart) line += ` — ${textPart}`; + line += ']'; + lines.push(line); + } + return lines.join('\n'); +} + +function formatMessageWithAttachments(msg: Message): string { + const formatted = formatMessage(msg); + const attachmentText = formatSlackUnfurledAttachments(msg.raw?.attachments); + const body = attachmentText ? `${formatted.text}\n${attachmentText}` : formatted.text; + return `${body}`; +} + const LINK_ACCOUNT_ACTION_PREFIX = `link-${APP_URL}${LINK_ACCOUNT_PATH}`; const SLACK_ASSISTANT_SUGGESTED_PROMPTS = [ @@ -172,12 +230,12 @@ async function getSlackConversationContext( const threadMessages = threadMessagesRaw .filter(m => m.id !== triggerMessage.id) - .map(m => formatMessage(m)) + .map(m => formatMessageWithAttachments(m as Message)) .reverse(); const channelMessages = channelMessagesRaw .filter(m => m.id !== triggerMessage.id) - .map(m => formatMessage(m)) + .map(m => formatMessageWithAttachments(m as Message)) .reverse(); const metadata = channelInfo?.metadata ?? {}; @@ -202,12 +260,12 @@ async function getSlackConversationContext( if (channelMessages.length > 0) { lines.push('', 'Recent channel messages (oldest first):'); - for (const msg of channelMessages) lines.push(formatUserMessage(msg)); + for (const msg of channelMessages) lines.push(msg); } if (threadMessages.length > 0) { lines.push('', 'Thread messages (oldest first):'); - for (const msg of threadMessages) lines.push(formatUserMessage(msg)); + for (const msg of threadMessages) lines.push(msg); } if (lines.length <= 2 && channelMessages.length === 0) return '';