Skip to content
Open
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
4 changes: 4 additions & 0 deletions packages/agent/src/__tests__/index.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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", () => ({
Expand All @@ -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),
}));
Expand Down
32 changes: 31 additions & 1 deletion packages/agent/src/__tests__/slack.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 },
};
}),
}));
Expand All @@ -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";

Expand Down Expand Up @@ -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();
});
});
18 changes: 16 additions & 2 deletions packages/agent/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand All @@ -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;
Expand Down Expand Up @@ -75,6 +76,19 @@ export async function processEvent(event: SlackEvent): Promise<void> {
? 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;
Expand Down
24 changes: 24 additions & 0 deletions packages/agent/src/slack.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<SlackUserInfo | undefined> {
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
// ---------------------------------------------------------------------------
Expand Down
6 changes: 6 additions & 0 deletions packages/db/drizzle/0001_slack_users.sql
Original file line number Diff line number Diff line change
@@ -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
);
27 changes: 27 additions & 0 deletions packages/db/src/__tests__/sqlite-stores.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
// ---------------------------------------------------------------------------
Expand All @@ -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", () => {
Expand Down
6 changes: 4 additions & 2 deletions packages/db/src/create-stores.ts
Original file line number Diff line number Diff line change
@@ -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 {
Expand All @@ -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:/, "");
Expand Down
8 changes: 4 additions & 4 deletions packages/db/src/index.ts
Original file line number Diff line number Diff line change
@@ -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";
39 changes: 37 additions & 2 deletions packages/db/src/postgres-store.ts
Original file line number Diff line number Diff line change
@@ -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<typeof drizzle>;
Expand Down Expand Up @@ -56,6 +56,41 @@ export class PostgresUrlStore implements UrlStore {
}
}

/* v8 ignore start */
export class PostgresUserStore implements UserStore {
private db: ReturnType<typeof drizzle>;

constructor(connectionString: string) {
const client = postgres(connectionString);
this.db = drizzle(client, { schema: { slackUsers } });
}

async upsert(user: Omit<SlackUser, "updated_at">): Promise<void> {
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<SlackUser | undefined> {
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,
Expand Down
7 changes: 7 additions & 0 deletions packages/db/src/schema-sqlite.ts
Original file line number Diff line number Diff line change
@@ -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(),
Expand Down
7 changes: 7 additions & 0 deletions packages/db/src/schema.ts
Original file line number Diff line number Diff line change
@@ -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(),
Expand Down
30 changes: 30 additions & 0 deletions packages/db/src/sqlite-stores.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,13 +10,21 @@ import type {
AuditStore,
AuditRecord,
NewAuditRecord,
UserStore,
SlackUser,
} from "./store.js";

type SqliteDb = ReturnType<typeof drizzle<typeof schema>>;

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,
Expand All @@ -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<SlackUser, "updated_at">): Promise<void> {
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<SlackUser | undefined> {
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) {}

Expand Down Expand Up @@ -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),
};
}

Expand Down
14 changes: 14 additions & 0 deletions packages/db/src/store.ts
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,20 @@ export interface AuditRecord {

export type NewAuditRecord = Omit<AuditRecord, "id" | "created_at">;

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<SlackUser, "updated_at">): Promise<void>;
/** Returns a user by ID, or undefined if not cached. */
get(user_id: string): Promise<SlackUser | undefined>;
}

export interface AuditStore {
write(record: NewAuditRecord): Promise<void>;
listRecent(limit: number): Promise<AuditRecord[]>;
Expand Down
Loading