Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions .changeset/mighty-needles-count.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
---
---
9 changes: 9 additions & 0 deletions server/public/admin-addie.html
Original file line number Diff line number Diff line change
Expand Up @@ -3120,13 +3120,15 @@ <h4>Pattern Identified</h4>
let currentThreadData = null;
let currentConversationData = null;
let extractedLinks = [];
let currentUserNames = {}; // Map of Slack user IDs to display names

async function viewThread(threadId) {
try {
const response = await fetch(`/api/admin/addie/threads/${threadId}`);
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 =
Expand Down Expand Up @@ -3443,6 +3445,13 @@ <h4>Pattern Identified</h4>
}

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, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');

Expand Down
20 changes: 11 additions & 9 deletions server/src/addie/member-context.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down Expand Up @@ -255,22 +256,23 @@ export async function getMemberContext(slackUserId: string): Promise<MemberConte
};

try {
// Step 1: Look up Slack user mapping
const slackMapping = await slackDb.getBySlackUserId(slackUserId);
// Step 1: Look up Slack user (checks DB first, then API with persistence)
const resolved = await resolveSlackUserDisplayName(slackUserId);

if (!slackMapping) {
logger.debug({ slackUserId }, 'Addie: Slack user not found in mappings');
if (!resolved) {
logger.debug({ slackUserId }, 'Addie: Could not resolve Slack user');
return context;
}

context.slack_user = {
slack_user_id: slackMapping.slack_user_id,
display_name: slackMapping.slack_display_name || slackMapping.slack_real_name,
email: slackMapping.slack_email,
slack_user_id: resolved.slack_user_id,
display_name: resolved.display_name,
email: resolved.email,
};

// Step 2: Check if mapped to WorkOS user
if (!slackMapping.workos_user_id) {
// Step 2: Check if user is mapped to WorkOS (need full record for workos_user_id)
const slackMapping = await slackDb.getBySlackUserId(slackUserId);
if (!slackMapping || !slackMapping.workos_user_id) {
logger.debug({ slackUserId }, 'Addie: Slack user not mapped to WorkOS');
return context;
}
Expand Down
38 changes: 37 additions & 1 deletion server/src/routes/addie-admin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,10 @@ import { getAddieBoltApp } from "../addie/bolt-app.js";
import { AddieRouter, type RoutingContext } from "../addie/router.js";
import { sanitizeInput } from "../addie/security.js";
import { runSlackHistoryBackfill } from "../addie/jobs/slack-history-backfill.js";
import {
resolveSlackUserDisplayName,
resolveSlackUserDisplayNames,
} from "../slack/client.js";

const logger = createLogger("addie-admin-routes");
const addieDb = new AddieDatabase();
Expand Down Expand Up @@ -445,7 +449,39 @@ export function createAddieAdminRouter(): { pageRouter: Router; apiRouter: Route
return res.status(404).json({ error: "Thread not found" });
}

res.json(thread);
// Collect all Slack user IDs that need resolution
const userIdsToResolve: string[] = [];
const slackMentionRegex = /<@(U[A-Z0-9]+)>/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({
Expand Down
105 changes: 105 additions & 0 deletions server/src/slack/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,13 +6,23 @@
*/

import { logger } from '../logger.js';
import { SlackDatabase } from '../db/slack-db.js';
import type {
SlackUser,
SlackChannel,
SlackPaginatedResponse,
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';
Expand Down Expand Up @@ -208,6 +218,101 @@ export async function getSlackUser(userId: string): Promise<SlackUser | null> {
}
}

/**
* 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<ResolvedSlackUser | null> {
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<Record<string, string>> {
const results: Record<string, string> = {};
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
*/
Expand Down