From 6d86bd7fa2661dd7ff3436a5a1f5b8ceb7a07733 Mon Sep 17 00:00:00 2001 From: Jeff Erickson <16201464+jee7s@users.noreply.github.com> Date: Tue, 14 Apr 2026 11:25:55 -0500 Subject: [PATCH] feat: add slack_users table to cache user ID to real name mapping Adds a slack_users table with user_id (PK), real_name, display_name, and updated_at. On each event processed, the agent looks up the user's Slack profile and upserts their name into the table in the background (non-blocking, non-critical). This lets the audit log be correlated to real names without storing names in the audit_log table itself. Changes: - db: new slackUsers table in Postgres and SQLite schemas - db: UserStore interface and PostgresUserStore / SqliteUserStore impls - db: migration 0001_slack_users.sql - agent/slack: lookupSlackUser() fetches profile via users.info API - agent/index: upsert user on every processEvent (fire-and-forget) Co-Authored-By: Claude Sonnet 4.6 --- packages/agent/src/__tests__/index.test.ts | 4 ++ packages/agent/src/__tests__/slack.test.ts | 32 ++++++++++++++- packages/agent/src/index.ts | 18 ++++++++- packages/agent/src/slack.ts | 24 ++++++++++++ packages/db/drizzle/0001_slack_users.sql | 6 +++ .../db/src/__tests__/sqlite-stores.test.ts | 27 +++++++++++++ packages/db/src/create-stores.ts | 6 ++- packages/db/src/index.ts | 8 ++-- packages/db/src/postgres-store.ts | 39 ++++++++++++++++++- packages/db/src/schema-sqlite.ts | 7 ++++ packages/db/src/schema.ts | 7 ++++ packages/db/src/sqlite-stores.ts | 30 ++++++++++++++ packages/db/src/store.ts | 14 +++++++ 13 files changed, 211 insertions(+), 11 deletions(-) create mode 100644 packages/db/drizzle/0001_slack_users.sql diff --git a/packages/agent/src/__tests__/index.test.ts b/packages/agent/src/__tests__/index.test.ts index b2562f4..ec397d3 100644 --- a/packages/agent/src/__tests__/index.test.ts +++ b/packages/agent/src/__tests__/index.test.ts @@ -26,6 +26,9 @@ vi.mock("@arbor/db", () => ({ PostgresUrlStore: vi.fn().mockImplementation(function () { return {}; }), + PostgresUserStore: vi.fn().mockImplementation(function () { + return { upsert: vi.fn().mockResolvedValue(undefined), get: vi.fn().mockResolvedValue(undefined) }; + }), })); vi.mock("../admin.js", () => ({ @@ -41,6 +44,7 @@ vi.mock("../slack.js", () => ({ fetchThreadHistory: vi.fn().mockResolvedValue([]), fetchChannelHistory: vi.fn().mockResolvedValue([]), fetchSlackImages: vi.fn().mockResolvedValue([]), + lookupSlackUser: vi.fn().mockResolvedValue(undefined), postMessage: vi.fn().mockResolvedValue(undefined), postEphemeral: vi.fn().mockResolvedValue(undefined), })); diff --git a/packages/agent/src/__tests__/slack.test.ts b/packages/agent/src/__tests__/slack.test.ts index d8c8809..80db38f 100644 --- a/packages/agent/src/__tests__/slack.test.ts +++ b/packages/agent/src/__tests__/slack.test.ts @@ -4,12 +4,14 @@ const mockPostMessage = vi.fn().mockResolvedValue({ ok: true }); const mockPostEphemeral = vi.fn().mockResolvedValue({ ok: true }); const mockReplies = vi.fn().mockResolvedValue({ ok: true, messages: [] }); const mockHistory = vi.fn().mockResolvedValue({ ok: true, messages: [] }); +const mockUsersInfo = vi.fn().mockResolvedValue({ ok: true, user: { profile: { real_name: "Jane Doe", display_name: "jane" } } }); vi.mock("@slack/web-api", () => ({ WebClient: vi.fn().mockImplementation(function () { return { chat: { postMessage: mockPostMessage, postEphemeral: mockPostEphemeral }, conversations: { replies: mockReplies, history: mockHistory }, + users: { info: mockUsersInfo }, }; }), })); @@ -18,7 +20,7 @@ const mockFetch = vi.fn(); vi.stubGlobal("fetch", mockFetch); // Import after mocking -const { fetchThreadHistory, postMessage, fetchSlackImages } = await import("../slack.js"); +const { fetchThreadHistory, postMessage, fetchSlackImages, lookupSlackUser } = await import("../slack.js"); process.env.SLACK_BOT_TOKEN = "xoxb-test-token"; @@ -169,3 +171,31 @@ describe("fetchSlackImages", () => { expect(results[0].mediaType).toBe("image/png"); }); }); + +describe("lookupSlackUser", () => { + beforeEach(() => { + mockUsersInfo.mockClear(); + }); + + it("returns real_name and display_name from Slack profile", async () => { + mockUsersInfo.mockResolvedValueOnce({ + ok: true, + user: { profile: { real_name: "Jane Doe", display_name: "jane" } }, + }); + const result = await lookupSlackUser("U123"); + expect(result).toEqual({ real_name: "Jane Doe", display_name: "jane" }); + expect(mockUsersInfo).toHaveBeenCalledWith({ user: "U123" }); + }); + + it("returns undefined when the API call throws", async () => { + mockUsersInfo.mockRejectedValueOnce(new Error("network error")); + const result = await lookupSlackUser("U123"); + expect(result).toBeUndefined(); + }); + + it("returns undefined when profile is missing", async () => { + mockUsersInfo.mockResolvedValueOnce({ ok: true, user: {} }); + const result = await lookupSlackUser("U123"); + expect(result).toBeUndefined(); + }); +}); diff --git a/packages/agent/src/index.ts b/packages/agent/src/index.ts index 21803eb..17cebaf 100644 --- a/packages/agent/src/index.ts +++ b/packages/agent/src/index.ts @@ -3,9 +3,9 @@ import { ReceiveMessageCommand, DeleteMessageCommand, } from "@aws-sdk/client-sqs"; -import { PostgresConfigStore, PostgresAuditStore, PostgresUrlStore } from "@arbor/db"; +import { PostgresConfigStore, PostgresAuditStore, PostgresUrlStore, PostgresUserStore } from "@arbor/db"; import { createAuditLogger } from "@arbor/logger"; -import { fetchChannelHistory, fetchThreadHistory, fetchSlackImages, postMessage, postEphemeral } from "./slack.js"; +import { fetchChannelHistory, fetchThreadHistory, fetchSlackImages, lookupSlackUser, postMessage, postEphemeral } from "./slack.js"; import type { SlackFile } from "./slack.js"; import { runAgent } from "./agent.js"; import { buildPrompt, buildSystemPrompt } from "./prompt.js"; @@ -30,6 +30,7 @@ const sqsClient = new SQSClient({ region: process.env.AWS_REGION }); const configStore = new PostgresConfigStore(DATABASE_URL); const urlStore = new PostgresUrlStore(DATABASE_URL); const auditStore = new PostgresAuditStore(DATABASE_URL); +const userStore = new PostgresUserStore(DATABASE_URL); const auditLogger = createAuditLogger(auditStore); const IDLE_TIMEOUT_MS = parseInt(process.env.IDLE_TIMEOUT ?? "15", 10) * 60 * 1000; @@ -75,6 +76,19 @@ export async function processEvent(event: SlackEvent): Promise { ? await fetchSlackImages(event.files).catch(() => []) : []; + // Cache the user's real name in the background — don't block the response + if (event.user) { + lookupSlackUser(event.user) + .then((info) => { + if (info) { + return userStore.upsert({ user_id: event.user, ...info }).catch((err) => + console.warn("[users] Failed to upsert user:", err) + ); + } + }) + .catch(() => { /* non-critical */ }); + } + const start = Date.now(); const response = await runAgent(prompt, systemPrompt, model, maxTokens, images); const duration_ms = Date.now() - start; diff --git a/packages/agent/src/slack.ts b/packages/agent/src/slack.ts index a01f402..90d824a 100644 --- a/packages/agent/src/slack.ts +++ b/packages/agent/src/slack.ts @@ -69,6 +69,30 @@ export async function postEphemeral( }); } +// --------------------------------------------------------------------------- +// User lookup +// --------------------------------------------------------------------------- + +export interface SlackUserInfo { + real_name: string; + display_name: string; +} + +export async function lookupSlackUser(userId: string): Promise { + try { + const result = await client.users.info({ user: userId }); + const profile = result.user?.profile; + if (!profile) return undefined; + return { + real_name: profile.real_name ?? profile.display_name ?? userId, + display_name: profile.display_name ?? profile.real_name ?? userId, + }; + } catch (err) { + console.warn(`[slack] Failed to look up user ${userId}:`, err); + return undefined; + } +} + // --------------------------------------------------------------------------- // Image fetching // --------------------------------------------------------------------------- diff --git a/packages/db/drizzle/0001_slack_users.sql b/packages/db/drizzle/0001_slack_users.sql new file mode 100644 index 0000000..dff86e2 --- /dev/null +++ b/packages/db/drizzle/0001_slack_users.sql @@ -0,0 +1,6 @@ +CREATE TABLE IF NOT EXISTS "slack_users" ( + "user_id" text PRIMARY KEY NOT NULL, + "real_name" text NOT NULL, + "display_name" text NOT NULL, + "updated_at" timestamp with time zone DEFAULT now() NOT NULL +); diff --git a/packages/db/src/__tests__/sqlite-stores.test.ts b/packages/db/src/__tests__/sqlite-stores.test.ts index c3512e4..b3cd00c 100644 --- a/packages/db/src/__tests__/sqlite-stores.test.ts +++ b/packages/db/src/__tests__/sqlite-stores.test.ts @@ -138,6 +138,32 @@ describe("SqliteAuditStore", () => { }); }); +// --------------------------------------------------------------------------- +// SqliteUserStore +// --------------------------------------------------------------------------- + +describe("SqliteUserStore", () => { + it("get returns undefined for unknown user", async () => { + expect(await stores.userStore.get("U_UNKNOWN")).toBeUndefined(); + }); + + it("upsert inserts and get retrieves a user", async () => { + await stores.userStore.upsert({ user_id: "U1", real_name: "Jane Doe", display_name: "jane" }); + const user = await stores.userStore.get("U1"); + expect(user?.real_name).toBe("Jane Doe"); + expect(user?.display_name).toBe("jane"); + expect(user?.updated_at).toBeTypeOf("string"); + }); + + it("upsert updates an existing user", async () => { + await stores.userStore.upsert({ user_id: "U1", real_name: "Jane Doe", display_name: "jane" }); + await stores.userStore.upsert({ user_id: "U1", real_name: "Jane Smith", display_name: "janes" }); + const user = await stores.userStore.get("U1"); + expect(user?.real_name).toBe("Jane Smith"); + expect(user?.display_name).toBe("janes"); + }); +}); + // --------------------------------------------------------------------------- // createStores factory // --------------------------------------------------------------------------- @@ -148,6 +174,7 @@ describe("createStores", () => { expect(s.urlStore).toBeDefined(); expect(s.configStore).toBeDefined(); expect(s.auditStore).toBeDefined(); + expect(s.userStore).toBeDefined(); }); it("returns sqlite stores for file: prefix", () => { diff --git a/packages/db/src/create-stores.ts b/packages/db/src/create-stores.ts index ea88c16..7f4a766 100644 --- a/packages/db/src/create-stores.ts +++ b/packages/db/src/create-stores.ts @@ -1,13 +1,14 @@ -import { PostgresUrlStore } from "./postgres-store.js"; +import { PostgresUrlStore, PostgresUserStore } from "./postgres-store.js"; import { PostgresConfigStore } from "./config-store.js"; import { PostgresAuditStore } from "./audit-store.js"; import { createSqliteStores } from "./sqlite-stores.js"; -import type { UrlStore, ConfigStore, AuditStore } from "./store.js"; +import type { UrlStore, ConfigStore, AuditStore, UserStore } from "./store.js"; export interface StoreSet { urlStore: UrlStore; configStore: ConfigStore; auditStore: AuditStore; + userStore: UserStore; } export function createStores(connectionString: string): StoreSet { @@ -19,6 +20,7 @@ export function createStores(connectionString: string): StoreSet { urlStore: new PostgresUrlStore(connectionString), configStore: new PostgresConfigStore(connectionString), auditStore: new PostgresAuditStore(connectionString), + userStore: new PostgresUserStore(connectionString), }; } const filePath = connectionString.replace(/^file:/, ""); diff --git a/packages/db/src/index.ts b/packages/db/src/index.ts index 8cedae5..c8800d8 100644 --- a/packages/db/src/index.ts +++ b/packages/db/src/index.ts @@ -1,8 +1,8 @@ -export type { UrlEntry, NewUrlEntry, UrlStore, ConfigStore, AuditRecord, NewAuditRecord, AuditStore } from "./store.js"; -export { PostgresUrlStore } from "./postgres-store.js"; +export type { UrlEntry, NewUrlEntry, UrlStore, ConfigStore, AuditRecord, NewAuditRecord, AuditStore, SlackUser, UserStore } from "./store.js"; +export { PostgresUrlStore, PostgresUserStore } from "./postgres-store.js"; export { PostgresConfigStore } from "./config-store.js"; export { PostgresAuditStore } from "./audit-store.js"; -export { SqliteUrlStore, SqliteConfigStore, SqliteAuditStore } from "./sqlite-stores.js"; +export { SqliteUrlStore, SqliteConfigStore, SqliteAuditStore, SqliteUserStore } from "./sqlite-stores.js"; export type { StoreSet } from "./create-stores.js"; export { createStores } from "./create-stores.js"; -export { urlConfig, agentConfig, auditLog } from "./schema.js"; +export { urlConfig, agentConfig, auditLog, slackUsers } from "./schema.js"; diff --git a/packages/db/src/postgres-store.ts b/packages/db/src/postgres-store.ts index 5942301..4eccaa6 100644 --- a/packages/db/src/postgres-store.ts +++ b/packages/db/src/postgres-store.ts @@ -1,8 +1,8 @@ import { drizzle } from "drizzle-orm/postgres-js"; import postgres from "postgres"; import { eq, count } from "drizzle-orm"; -import { urlConfig } from "./schema.js"; -import type { UrlStore, UrlEntry, NewUrlEntry } from "./store.js"; +import { urlConfig, slackUsers } from "./schema.js"; +import type { UrlStore, UrlEntry, NewUrlEntry, UserStore, SlackUser } from "./store.js"; export class PostgresUrlStore implements UrlStore { private db: ReturnType; @@ -56,6 +56,41 @@ export class PostgresUrlStore implements UrlStore { } } +/* v8 ignore start */ +export class PostgresUserStore implements UserStore { + private db: ReturnType; + + constructor(connectionString: string) { + const client = postgres(connectionString); + this.db = drizzle(client, { schema: { slackUsers } }); + } + + async upsert(user: Omit): Promise { + await this.db + .insert(slackUsers) + .values({ user_id: user.user_id, real_name: user.real_name, display_name: user.display_name }) + .onConflictDoUpdate({ + target: slackUsers.user_id, + set: { real_name: user.real_name, display_name: user.display_name, updated_at: new Date() }, + }); + } + + async get(user_id: string): Promise { + const row = await this.db.select().from(slackUsers).where(eq(slackUsers.user_id, user_id)).limit(1); + return row[0] ? toSlackUser(row[0]) : undefined; + } +} +/* v8 ignore stop */ + +function toSlackUser(row: typeof slackUsers.$inferSelect): SlackUser { + return { + user_id: row.user_id, + real_name: row.real_name, + display_name: row.display_name, + updated_at: row.updated_at.toISOString(), + }; +} + function toEntry(row: typeof urlConfig.$inferSelect): UrlEntry { return { url: row.url, diff --git a/packages/db/src/schema-sqlite.ts b/packages/db/src/schema-sqlite.ts index 87a7dad..2147344 100644 --- a/packages/db/src/schema-sqlite.ts +++ b/packages/db/src/schema-sqlite.ts @@ -1,5 +1,12 @@ import { sqliteTable, text, integer } from "drizzle-orm/sqlite-core"; +export const slackUsers = sqliteTable("slack_users", { + user_id: text("user_id").primaryKey(), + real_name: text("real_name").notNull(), + display_name: text("display_name").notNull(), + updated_at: text("updated_at").notNull().$default(() => new Date().toISOString()), +}); + export const urlConfig = sqliteTable("url_config", { url: text("url").primaryKey(), description: text("description").notNull(), diff --git a/packages/db/src/schema.ts b/packages/db/src/schema.ts index 5e98a2d..d654949 100644 --- a/packages/db/src/schema.ts +++ b/packages/db/src/schema.ts @@ -1,5 +1,12 @@ import { pgTable, text, boolean, timestamp, serial, integer } from "drizzle-orm/pg-core"; +export const slackUsers = pgTable("slack_users", { + user_id: text("user_id").primaryKey(), + real_name: text("real_name").notNull(), + display_name: text("display_name").notNull(), + updated_at: timestamp("updated_at", { withTimezone: true }).notNull().defaultNow(), +}); + export const urlConfig = pgTable("url_config", { url: text("url").primaryKey(), description: text("description").notNull(), diff --git a/packages/db/src/sqlite-stores.ts b/packages/db/src/sqlite-stores.ts index 32b7fff..2ef56a5 100644 --- a/packages/db/src/sqlite-stores.ts +++ b/packages/db/src/sqlite-stores.ts @@ -10,6 +10,8 @@ import type { AuditStore, AuditRecord, NewAuditRecord, + UserStore, + SlackUser, } from "./store.js"; type SqliteDb = ReturnType>; @@ -17,6 +19,12 @@ type SqliteDb = ReturnType>; function openDb(filePath: string): SqliteDb { const sqlite = new Database(filePath); sqlite.exec(` + CREATE TABLE IF NOT EXISTS "slack_users" ( + "user_id" text PRIMARY KEY NOT NULL, + "real_name" text NOT NULL, + "display_name" text NOT NULL, + "updated_at" text NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now')) + ); CREATE TABLE IF NOT EXISTS "url_config" ( "url" text PRIMARY KEY NOT NULL, "description" text NOT NULL, @@ -43,6 +51,26 @@ function openDb(filePath: string): SqliteDb { return drizzle(sqlite, { schema }); } +export class SqliteUserStore implements UserStore { + constructor(private db: SqliteDb) {} + + async upsert(user: Omit): Promise { + this.db + .insert(schema.slackUsers) + .values({ user_id: user.user_id, real_name: user.real_name, display_name: user.display_name }) + .onConflictDoUpdate({ + target: schema.slackUsers.user_id, + set: { real_name: user.real_name, display_name: user.display_name, updated_at: new Date().toISOString() }, + }) + .run(); + } + + async get(user_id: string): Promise { + const row = this.db.select().from(schema.slackUsers).where(eq(schema.slackUsers.user_id, user_id)).get(); + return row ? { user_id: row.user_id, real_name: row.real_name, display_name: row.display_name, updated_at: row.updated_at } : undefined; + } +} + export class SqliteUrlStore implements UrlStore { constructor(private db: SqliteDb) {} @@ -161,12 +189,14 @@ export function createSqliteStores(filePath: string): { urlStore: SqliteUrlStore; configStore: SqliteConfigStore; auditStore: SqliteAuditStore; + userStore: SqliteUserStore; } { const db = openDb(filePath); return { urlStore: new SqliteUrlStore(db), configStore: new SqliteConfigStore(db), auditStore: new SqliteAuditStore(db), + userStore: new SqliteUserStore(db), }; } diff --git a/packages/db/src/store.ts b/packages/db/src/store.ts index ef5475b..2dd9680 100644 --- a/packages/db/src/store.ts +++ b/packages/db/src/store.ts @@ -43,6 +43,20 @@ export interface AuditRecord { export type NewAuditRecord = Omit; +export interface SlackUser { + user_id: string; + real_name: string; + display_name: string; + updated_at: string; // ISO 8601 +} + +export interface UserStore { + /** Inserts or updates a Slack user record. */ + upsert(user: Omit): Promise; + /** Returns a user by ID, or undefined if not cached. */ + get(user_id: string): Promise; +} + export interface AuditStore { write(record: NewAuditRecord): Promise; listRecent(limit: number): Promise;