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
*/