diff --git a/.changeset/mighty-needles-count.md b/.changeset/mighty-needles-count.md new file mode 100644 index 0000000000..a845151cc8 --- /dev/null +++ b/.changeset/mighty-needles-count.md @@ -0,0 +1,2 @@ +--- +--- diff --git a/server/public/admin-addie.html b/server/public/admin-addie.html index 8ac3d24e60..82b38238c4 100644 --- a/server/public/admin-addie.html +++ b/server/public/admin-addie.html @@ -3120,6 +3120,7 @@

Pattern Identified

let currentThreadData = null; let currentConversationData = null; let extractedLinks = []; + let currentUserNames = {}; // Map of Slack user IDs to display names async function viewThread(threadId) { try { @@ -3127,6 +3128,7 @@

Pattern Identified

const data = await response.json(); currentThreadData = data; currentConversationData = data; + currentUserNames = data.user_names || {}; const channelLabel = data.channel.charAt(0).toUpperCase() + data.channel.slice(1); document.getElementById('conversation-modal-title').textContent = @@ -3443,6 +3445,13 @@

Pattern Identified

} function renderMarkdown(text) { + // Replace Slack user mentions with display names before escaping HTML + // Format: <@U1234567> -> @DisplayName + text = text.replace(/<@(U[A-Z0-9]+)>/g, (match, userId) => { + const displayName = currentUserNames[userId]; + return displayName ? `@${displayName}` : match; + }); + // Escape HTML first text = text.replace(/&/g, '&').replace(//g, '>'); diff --git a/server/src/addie/member-context.ts b/server/src/addie/member-context.ts index b07df117e0..cb22cc6182 100644 --- a/server/src/addie/member-context.ts +++ b/server/src/addie/member-context.ts @@ -15,6 +15,7 @@ import { getThreadService } from './thread-service.js'; import { workos } from '../auth/workos-client.js'; import { logger } from '../logger.js'; import { getPool } from '../db/client.js'; +import { resolveSlackUserDisplayName } from '../slack/client.js'; const slackDb = new SlackDatabase(); const memberDb = new MemberDatabase(); @@ -255,22 +256,23 @@ export async function getMemberContext(slackUserId: string): Promise/g; + + // Add thread owner + if (thread.user_id && thread.channel === "slack") { + userIdsToResolve.push(thread.user_id); + } + + // Extract from all message content + for (const msg of thread.messages) { + if (msg.content) { + let match; + while ((match = slackMentionRegex.exec(msg.content)) !== null) { + userIdsToResolve.push(match[1]); + } + } + } + + // Resolve all user names with concurrency limiting + const userNames = await resolveSlackUserDisplayNames(userIdsToResolve); + + // Get display name for thread owner (may already be resolved above) + let displayName: string | null = thread.user_display_name; + if (!displayName && thread.user_id && thread.channel === "slack") { + displayName = userNames[thread.user_id] ?? null; + } + + res.json({ + ...thread, + user_display_name: displayName || thread.user_display_name, + user_names: userNames, + }); } catch (error) { logger.error({ err: error }, "Error fetching thread"); res.status(500).json({ diff --git a/server/src/slack/client.ts b/server/src/slack/client.ts index e4faa766cb..8494c497dd 100644 --- a/server/src/slack/client.ts +++ b/server/src/slack/client.ts @@ -6,6 +6,7 @@ */ import { logger } from '../logger.js'; +import { SlackDatabase } from '../db/slack-db.js'; import type { SlackUser, SlackChannel, @@ -13,6 +14,15 @@ import type { SlackBlockMessage, } from './types.js'; +// Lazy-initialized database instance for user persistence +let slackDb: SlackDatabase | null = null; +function getSlackDb(): SlackDatabase { + if (!slackDb) { + slackDb = new SlackDatabase(); + } + return slackDb; +} + // Use ADDIE_BOT_TOKEN as the primary token (fall back to SLACK_BOT_TOKEN for migration) const SLACK_BOT_TOKEN = process.env.ADDIE_BOT_TOKEN || process.env.SLACK_BOT_TOKEN; const SLACK_API_BASE = 'https://slack.com/api'; @@ -208,6 +218,101 @@ export async function getSlackUser(userId: string): Promise { } } +/** + * Result from resolving a Slack user's display name + */ +export interface ResolvedSlackUser { + slack_user_id: string; + display_name: string | null; + email: string | null; +} + +/** + * Resolve a Slack user ID to display name, checking database first then API. + * Persists to database for future lookups. + */ +export async function resolveSlackUserDisplayName( + slackUserId: string +): Promise { + const db = getSlackDb(); + + // Check database first + const existing = await db.getBySlackUserId(slackUserId); + if (existing) { + return { + slack_user_id: existing.slack_user_id, + display_name: existing.slack_display_name || existing.slack_real_name, + email: existing.slack_email, + }; + } + + // Fetch from Slack API and persist + try { + const slackUser = await getSlackUser(slackUserId); + if (!slackUser) { + return null; + } + + const displayName = slackUser.profile?.display_name || + slackUser.profile?.real_name || + slackUser.real_name || + null; + const email = slackUser.profile?.email || null; + + // Persist for future requests + await db.upsertSlackUser({ + slack_user_id: slackUserId, + slack_email: email, + slack_display_name: slackUser.profile?.display_name || null, + slack_real_name: slackUser.profile?.real_name || slackUser.real_name || null, + slack_is_bot: slackUser.is_bot, + slack_is_deleted: slackUser.deleted, + }); + + logger.debug({ slackUserId, displayName }, 'Resolved and persisted Slack user from API'); + + return { + slack_user_id: slackUserId, + display_name: displayName, + email: email, + }; + } catch (error) { + logger.debug({ slackUserId, error }, 'Failed to resolve Slack user'); + return null; + } +} + +/** + * Resolve multiple Slack user IDs to display names with concurrency limiting. + * Returns a map of user ID -> display name. + */ +export async function resolveSlackUserDisplayNames( + slackUserIds: string[], + concurrency = 5 +): Promise> { + const results: Record = {}; + const uniqueIds = [...new Set(slackUserIds)]; + + // Process in batches to avoid rate limiting + for (let i = 0; i < uniqueIds.length; i += concurrency) { + const batch = uniqueIds.slice(i, i + concurrency); + const batchResults = await Promise.all( + batch.map(async (userId) => { + const resolved = await resolveSlackUserDisplayName(userId); + return { userId, displayName: resolved?.display_name }; + }) + ); + + for (const { userId, displayName } of batchResults) { + if (displayName) { + results[userId] = displayName; + } + } + } + + return results; +} + /** * Look up a user by email address */