diff --git a/README.md b/README.md index 7fb9c07..18ad874 100644 --- a/README.md +++ b/README.md @@ -96,8 +96,10 @@ agent-slack │ ├── new # create channel │ └── invite # invite users to channel ├── user -│ ├── list -│ └── get +│ ├── list # --refresh indexes the full directory +│ ├── get +│ ├── dm-open +│ └── cache warm # prime handle/email → user-id cache ├── search │ ├── all # messages + files │ ├── messages @@ -474,13 +476,25 @@ Tips: Treat Slack user IDs beginning with `U` or `W` equivalently. +Handle and email lookups (`user get`, `user dm-open`, `channel invite`, `search --user`) use a per-workspace reverse index stored alongside the id→profile cache: + +- Path: `$XDG_RUNTIME_DIR/agent-slack/users-cache-.json` when `XDG_RUNTIME_DIR` is set, otherwise `~/.agent-slack/users-cache-.json` +- TTL: 24 hours. Expired aliases and profiles are ignored and pruned on write. +- Warm: ordinary `users.list` / `users.info` traffic writes through the index. Prime the whole directory once with `agent-slack user cache warm` or `agent-slack user list --refresh`. +- Invalidate: `user get --refresh-users` (and `--refresh-users` on message/search reads) bypasses TTL and refreshes from Slack. Browser (`xoxc`) auth never calls `users.lookupByEmail`. + ```bash # List users (email requires appropriate Slack scopes; fields are pruned if missing) agent-slack user list --workspace "https://workspace.slack.com" --limit 200 | jq . +# Prime handle/email → user-id resolution so later `user get @handle` skips users.list +agent-slack user cache warm --workspace "https://workspace.slack.com" +agent-slack user list --refresh --workspace "https://workspace.slack.com" + # Get one user by id or handle agent-slack user get U12345678 --workspace "https://workspace.slack.com" | jq . agent-slack user get "@alice" --workspace "https://workspace.slack.com" | jq . +agent-slack user get "@alice" --refresh-users --workspace "https://workspace.slack.com" | jq . # Open a DM or group DM with one to eight other users (the caller is implicit) agent-slack user dm-open "@alice" "@bob" --workspace "https://workspace.slack.com" | jq . diff --git a/skills/agent-slack/references/output.md b/skills/agent-slack/references/output.md index 1234926..ee5a989 100644 --- a/skills/agent-slack/references/output.md +++ b/skills/agent-slack/references/output.md @@ -12,7 +12,7 @@ Immediate non-attachment sends return `ts` and usually a `permalink`. Attachment `canvas create` returns `canvas: { id, title?, channel_id? }`. `canvas get` returns `canvas: { id, title?, markdown }`. `canvas edit` returns `ok: true` and `canvas: { id, operation }` after Slack accepts the change. -Message payloads keep canonical user IDs. Pass `--resolve-users` to add display metadata under `referenced_users`, or `--refresh-users` to refresh the 24-hour per-workspace cache before resolving. +Message payloads keep canonical user IDs. Pass `--resolve-users` to add display metadata under `referenced_users`, or `--refresh-users` to refresh the 24-hour per-workspace cache before resolving. The same cache file also stores handle/email → user-id aliases used by `user get`, `user dm-open`, `channel invite`, and `search --user`. Prime it with `user cache warm` or `user list --refresh`. Path: `$XDG_RUNTIME_DIR/agent-slack/users-cache-*.json` or `~/.agent-slack/users-cache-*.json`. Use `--max-body-chars`, `--max-content-chars`, `--limit`, or a command's counts-only mode to keep results within the task's needs. diff --git a/src/cli/user-command.ts b/src/cli/user-command.ts index a47f71b..8b5f14d 100644 --- a/src/cli/user-command.ts +++ b/src/cli/user-command.ts @@ -1,7 +1,12 @@ import type { Command } from "commander"; import type { CliContext } from "./context.ts"; import { pruneEmpty } from "../lib/compact-json.ts"; -import { getDmChannelForUsers, getUser, listUsers } from "../slack/users.ts"; +import { + getDmChannelForUsers, + getUser, + listUsers, + warmUserResolutionCache, +} from "../slack/users.ts"; export function registerUserCommand(input: { program: Command; ctx: CliContext }): void { const userCmd = input.program.command("user").description("Workspace user directory"); @@ -16,9 +21,19 @@ export function registerUserCommand(input: { program: Command; ctx: CliContext } .option("--limit ", "Max users (default 200)", "200") .option("--cursor ", "Pagination cursor") .option("--include-bots", "Include bot users") + .option( + "--refresh", + "Rebuild the handle/email → user-id cache from the full directory (still returns --limit users)", + ) .action(async (...args) => { const [options] = args as [ - { workspace?: string; limit: string; cursor?: string; includeBots?: boolean }, + { + workspace?: string; + limit: string; + cursor?: string; + includeBots?: boolean; + refresh?: boolean; + }, ]; try { const workspaceUrl = input.ctx.effectiveWorkspaceUrl(options.workspace); @@ -31,6 +46,7 @@ export function registerUserCommand(input: { program: Command; ctx: CliContext } limit, cursor: options.cursor, includeBots: Boolean(options.includeBots), + refresh: Boolean(options.refresh), }); }, }); @@ -44,20 +60,23 @@ export function registerUserCommand(input: { program: Command; ctx: CliContext } userCmd .command("get") .description("Get a single workspace user") - .argument("", "User ID (U.../W...) or @handle/handle") + .argument("", "User ID (U.../W...) or @handle/handle/email") .option( "--workspace ", "Workspace selector (full URL or unique substring; required if you have multiple workspaces)", ) + .option("--refresh-users", "Bypass the handle/email → user-id cache and refresh it from Slack") .action(async (...args) => { - const [user, options] = args as [string, { workspace?: string }]; + const [user, options] = args as [string, { workspace?: string; refreshUsers?: boolean }]; try { const workspaceUrl = input.ctx.effectiveWorkspaceUrl(options.workspace); const payload = await input.ctx.withAutoRefresh({ workspaceUrl, work: async () => { const { client } = await input.ctx.getClientForWorkspace(workspaceUrl); - return await getUser(client, user); + return await getUser(client, user, { + forceRefresh: Boolean(options.refreshUsers), + }); }, }); console.log(JSON.stringify(pruneEmpty(payload), null, 2)); @@ -89,4 +108,31 @@ export function registerUserCommand(input: { program: Command; ctx: CliContext } process.exitCode = 1; } }); + + userCmd + .command("cache") + .description("Local per-workspace user resolution cache") + .command("warm") + .description("Prime the handle/email → user-id cache from the full directory") + .option( + "--workspace ", + "Workspace selector (full URL or unique substring; required if you have multiple workspaces)", + ) + .action(async (...args) => { + const [options] = args as [{ workspace?: string }]; + try { + const workspaceUrl = input.ctx.effectiveWorkspaceUrl(options.workspace); + const payload = await input.ctx.withAutoRefresh({ + workspaceUrl, + work: async () => { + const { client } = await input.ctx.getClientForWorkspace(workspaceUrl); + return await warmUserResolutionCache(client); + }, + }); + console.log(JSON.stringify(pruneEmpty(payload), null, 2)); + } catch (err: unknown) { + console.error(input.ctx.errorMessage(err)); + process.exitCode = 1; + } + }); } diff --git a/src/slack/client.ts b/src/slack/client.ts index fad8cbd..c78dcff 100644 --- a/src/slack/client.ts +++ b/src/slack/client.ts @@ -86,6 +86,14 @@ export class SlackApiClient { } } + getAuthType(): SlackAuth["auth_type"] { + return this.auth.auth_type; + } + + getWorkspaceUrl(): string | undefined { + return this.workspaceUrl; + } + /** * Call a Slack API method using multipart/form-data encoding. * Some internal Slack APIs (e.g. saved.update) require multipart encoding diff --git a/src/slack/compact-user.ts b/src/slack/compact-user.ts new file mode 100644 index 0000000..f61e8ca --- /dev/null +++ b/src/slack/compact-user.ts @@ -0,0 +1,36 @@ +import { getString, isRecord } from "../lib/object-type-guards.ts"; + +export type CompactSlackUser = { + id: string; + name?: string; // handle + real_name?: string; + display_name?: string; + email?: string; + title?: string; + tz?: string; + is_bot?: boolean; + deleted?: boolean; + dm_id?: string; + status_text?: string; + status_emoji?: string; + status_expiration?: number; +}; + +export function toCompactUser(u: Record): CompactSlackUser { + const profile = isRecord(u.profile) ? u.profile : {}; + return { + id: getString(u.id) ?? "", + name: getString(u.name) ?? undefined, + real_name: getString(u.real_name) ?? getString(profile.real_name) ?? undefined, + display_name: getString(profile.display_name) ?? undefined, + email: getString(profile.email) ?? undefined, + title: getString(profile.title) ?? undefined, + tz: getString(u.tz) ?? undefined, + is_bot: typeof u.is_bot === "boolean" ? u.is_bot : undefined, + deleted: typeof u.deleted === "boolean" ? u.deleted : undefined, + status_text: getString(profile.status_text) ?? undefined, + status_emoji: getString(profile.status_emoji) ?? undefined, + status_expiration: + typeof profile.status_expiration === "number" ? profile.status_expiration : undefined, + }; +} diff --git a/src/slack/search-query.ts b/src/slack/search-query.ts index c2f5341..2276383 100644 --- a/src/slack/search-query.ts +++ b/src/slack/search-query.ts @@ -1,7 +1,8 @@ import type { SlackApiClient } from "./client.ts"; import { normalizeChannelInput } from "./channels.ts"; -import { asArray, getString, isRecord } from "../lib/object-type-guards.ts"; +import { getString, isRecord } from "../lib/object-type-guards.ts"; import { isUserId } from "./user-id.ts"; +import { resolveUserId as resolveDirectoryUserId } from "./users.ts"; export async function buildSlackSearchQuery( client: SlackApiClient, @@ -115,35 +116,5 @@ export async function resolveUserId( client: SlackApiClient, input: string, ): Promise { - const trimmed = input.trim(); - if (!trimmed) { - return undefined; - } - if (isUserId(trimmed)) { - return trimmed; - } - const name = trimmed.startsWith("@") ? trimmed.slice(1) : trimmed; - - let cursor: string | undefined; - for (;;) { - const resp = await client.api("users.list", { limit: 200, cursor }); - const members = isRecord(resp) ? asArray(resp.members).filter(isRecord) : []; - const found = members.find((m) => { - const mName = getString(m.name); - const profile = isRecord(m.profile) ? m.profile : null; - const display = profile ? getString(profile.display_name) : undefined; - return mName === name || display === name; - }); - const foundId = found ? getString(found.id) : undefined; - if (foundId) { - return foundId; - } - const meta = isRecord(resp) ? resp.response_metadata : null; - const next = isRecord(meta) ? getString(meta.next_cursor) : undefined; - if (!next) { - break; - } - cursor = next; - } - return undefined; + return (await resolveDirectoryUserId(client, input)) ?? undefined; } diff --git a/src/slack/user-cache.ts b/src/slack/user-cache.ts index d0d00a8..14e2fb5 100644 --- a/src/slack/user-cache.ts +++ b/src/slack/user-cache.ts @@ -5,21 +5,29 @@ import { readJsonFile, writeJsonFile } from "../lib/fs.ts"; import { asArray, getString, isRecord } from "../lib/object-type-guards.ts"; import type { SlackApiClient } from "./client.ts"; import type { SlackMessageSummary } from "./messages.ts"; -import { toCompactUser, type CompactSlackUser } from "./users.ts"; +import { toCompactUser, type CompactSlackUser } from "./compact-user.ts"; import { isUserId } from "./user-id.ts"; -const CACHE_VERSION = 1; +const CACHE_VERSION = 2; const USER_TTL_MS = 24 * 60 * 60 * 1000; const USER_MENTION_PATTERN = /<@([^>|]+)(?:\|[^>]*)?>/g; +export { USER_TTL_MS as USER_CACHE_TTL_MS }; + type UserCacheEntry = { fetched_at: number; user: CompactSlackUser; }; +type UserAliasEntry = { + user_id: string; + fetched_at: number; +}; + type UserCacheFile = { version: number; entries: Record; + aliases: Record; }; export async function resolveUsersById(input: { @@ -39,9 +47,7 @@ export async function resolveUsersById(input: { const isUnknownWorkspace = workspaceKey === "unknown"; const cachePath = isUnknownWorkspace ? "" : join(getAppDir(), `users-cache-${workspaceKey}.json`); - const diskCache = cachePath - ? await loadCache(cachePath) - : { version: CACHE_VERSION, entries: {} }; + const diskCache = cachePath ? await loadCache(cachePath) : emptyCache(); const out = new Map(); const missing: string[] = []; @@ -71,11 +77,7 @@ export async function resolveUsersById(input: { if (!item.user) { continue; } - const entry: UserCacheEntry = { - fetched_at: now, - user: item.user, - }; - diskCache.entries[item.userId] = entry; + upsertUser(diskCache, item.user, now); out.set(item.userId, item.user); cacheChanged = true; } @@ -83,7 +85,10 @@ export async function resolveUsersById(input: { if (cachePath) { const prunedCache = pruneExpiredEntries(diskCache, now); - if (Object.keys(diskCache.entries).length !== Object.keys(prunedCache.entries).length) { + if ( + Object.keys(diskCache.entries).length !== Object.keys(prunedCache.entries).length || + Object.keys(diskCache.aliases).length !== Object.keys(prunedCache.aliases).length + ) { cacheChanged = true; } @@ -95,6 +100,65 @@ export async function resolveUsersById(input: { return out; } +export function userCachePath(workspaceUrl: string): string { + const workspaceKey = hashWorkspaceUrl(workspaceUrl); + if (workspaceKey === "unknown") { + return ""; + } + return join(getAppDir(), `users-cache-${workspaceKey}.json`); +} + +export async function lookupCachedUserId(input: { + workspaceUrl?: string; + handle?: string; + email?: string; +}): Promise { + const cachePath = input.workspaceUrl ? userCachePath(input.workspaceUrl) : ""; + if (!cachePath) { + return null; + } + const now = Date.now(); + const diskCache = await loadCache(cachePath); + for (const key of aliasKeysFromQuery(input)) { + const alias = diskCache.aliases[key]; + if (!alias) { + continue; + } + if (now - alias.fetched_at >= USER_TTL_MS) { + continue; + } + if (isUserId(alias.user_id)) { + return alias.user_id; + } + } + return null; +} + +export async function indexUsersInCache(input: { + workspaceUrl?: string; + users: CompactSlackUser[]; + fetchedAt?: number; +}): Promise { + const cachePath = input.workspaceUrl ? userCachePath(input.workspaceUrl) : ""; + if (!cachePath || input.users.length === 0) { + return; + } + const now = input.fetchedAt ?? Date.now(); + const diskCache = await loadCache(cachePath); + let changed = false; + for (const user of input.users) { + if (!isUserId(user.id)) { + continue; + } + upsertUser(diskCache, user, now); + changed = true; + } + if (!changed) { + return; + } + await writeCache(cachePath, pruneExpiredEntries(diskCache, now)); +} + export function collectReferencedUserIds( messages: SlackMessageSummary[], options?: { includeReactions?: boolean }, @@ -154,10 +218,14 @@ function hashWorkspaceUrl(workspaceUrl: string): string { return createHash("sha256").update(source).digest("hex").slice(0, 16); } +function emptyCache(): UserCacheFile { + return { version: CACHE_VERSION, entries: {}, aliases: {} }; +} + async function loadCache(path: string): Promise { const file = await readJsonFile(path); - if (!file || file.version !== CACHE_VERSION || !isRecord(file.entries)) { - return { version: CACHE_VERSION, entries: {} }; + if (!file || !isRecord(file.entries) || (file.version !== 1 && file.version !== CACHE_VERSION)) { + return emptyCache(); } const entries: Record = {}; @@ -173,9 +241,31 @@ async function loadCache(path: string): Promise { entries[userId] = { fetched_at: fetchedAt, user }; } + const aliases: Record = {}; + if (isRecord(file.aliases)) { + for (const [alias, rawAlias] of Object.entries(file.aliases)) { + if (!isRecord(rawAlias)) { + continue; + } + const userId = getString(rawAlias.user_id); + const fetchedAt = typeof rawAlias.fetched_at === "number" ? rawAlias.fetched_at : undefined; + if (!userId || !isUserId(userId) || !fetchedAt) { + continue; + } + aliases[alias] = { user_id: userId, fetched_at: fetchedAt }; + } + } else { + for (const entry of Object.values(entries)) { + for (const key of aliasKeysForUser(entry.user)) { + aliases[key] = { user_id: entry.user.id, fetched_at: entry.fetched_at }; + } + } + } + return { version: CACHE_VERSION, entries, + aliases, }; } @@ -188,14 +278,60 @@ async function writeCache(path: string, file: UserCacheFile): Promise { } function pruneExpiredEntries(file: UserCacheFile, now: number): UserCacheFile { - const next: Record = {}; + const nextEntries: Record = {}; for (const [userId, entry] of Object.entries(file.entries)) { if (now - entry.fetched_at >= USER_TTL_MS) { continue; } - next[userId] = entry; + nextEntries[userId] = entry; + } + const nextAliases: Record = {}; + for (const [alias, entry] of Object.entries(file.aliases)) { + if (now - entry.fetched_at >= USER_TTL_MS) { + continue; + } + nextAliases[alias] = entry; + } + return { version: CACHE_VERSION, entries: nextEntries, aliases: nextAliases }; +} + +function upsertUser(file: UserCacheFile, user: CompactSlackUser, fetchedAt: number): void { + file.entries[user.id] = { fetched_at: fetchedAt, user }; + const nextKeys = new Set(aliasKeysForUser(user)); + for (const [alias, entry] of Object.entries(file.aliases)) { + if (entry.user_id === user.id && !nextKeys.has(alias)) { + delete file.aliases[alias]; + } + } + for (const key of nextKeys) { + file.aliases[key] = { user_id: user.id, fetched_at: fetchedAt }; + } +} + +function aliasKeysForUser(user: CompactSlackUser): string[] { + const keys: string[] = []; + const handle = user.name?.trim().toLowerCase(); + if (handle) { + keys.push(`name:${handle}`); + } + const email = user.email?.trim().toLowerCase(); + if (email) { + keys.push(`email:${email}`); + } + return keys; +} + +function aliasKeysFromQuery(input: { handle?: string; email?: string }): string[] { + const keys: string[] = []; + const handle = input.handle?.trim().replace(/^@/, "").toLowerCase(); + if (handle) { + keys.push(`name:${handle}`); + } + const email = input.email?.trim().toLowerCase(); + if (email) { + keys.push(`email:${email}`); } - return { version: CACHE_VERSION, entries: next }; + return keys; } async function fetchUserById( diff --git a/src/slack/users.ts b/src/slack/users.ts index 2bb255e..59f3352 100644 --- a/src/slack/users.ts +++ b/src/slack/users.ts @@ -1,22 +1,11 @@ import type { SlackApiClient } from "./client.ts"; import { asArray, getString, isRecord } from "../lib/object-type-guards.ts"; +import { indexUsersInCache, lookupCachedUserId } from "./user-cache.ts"; import { isUserId } from "./user-id.ts"; +import { toCompactUser, type CompactSlackUser } from "./compact-user.ts"; -export type CompactSlackUser = { - id: string; - name?: string; // handle - real_name?: string; - display_name?: string; - email?: string; - title?: string; - tz?: string; - is_bot?: boolean; - deleted?: boolean; - dm_id?: string; - status_text?: string; - status_emoji?: string; - status_expiration?: number; -}; +export type { CompactSlackUser }; +export { toCompactUser }; export async function listUsers( client: SlackApiClient, @@ -24,40 +13,50 @@ export async function listUsers( limit?: number; cursor?: string; includeBots?: boolean; + refresh?: boolean; }, ): Promise<{ users: CompactSlackUser[]; next_cursor?: string }> { const limit = Math.min(Math.max(options?.limit ?? 200, 1), 1000); const includeBots = options?.includeBots ?? false; + const refresh = options?.refresh ?? false; + const workspaceUrl = clientWorkspaceUrl(client); let next_cursor: string | undefined; const [out, dmMap] = await Promise.all([ (async () => { const users: CompactSlackUser[] = []; let cursor = options?.cursor; - while (users.length < limit) { - const pageSize = Math.min(200, limit - users.length); + for (;;) { + const pageSize = refresh ? 200 : Math.min(200, Math.max(limit - users.length, 1)); const resp = await client.api("users.list", { limit: pageSize, cursor }); const members = asArray(resp.members).filter(isRecord); + const pageUsers: CompactSlackUser[] = []; for (const m of members) { const id = getString(m.id); if (!id) { continue; } + const compact = toCompactUser(m); + pageUsers.push(compact); if (!includeBots && m.is_bot) { continue; } - users.push(toCompactUser(m)); - if (users.length >= limit) { - break; + if (users.length < limit) { + users.push(compact); } } + await indexUsersInCache({ workspaceUrl, users: pageUsers }); const meta = isRecord(resp.response_metadata) ? resp.response_metadata : null; const next = meta ? getString(meta.next_cursor) : undefined; if (!next) { + next_cursor = undefined; break; } cursor = next; next_cursor = next; + if (!refresh && users.length >= limit) { + break; + } } return users; })(), @@ -74,13 +73,17 @@ export async function listUsers( return { users: out, next_cursor }; } -export async function getUser(client: SlackApiClient, input: string): Promise { +export async function getUser( + client: SlackApiClient, + input: string, + options?: { forceRefresh?: boolean }, +): Promise { const trimmed = input.trim(); if (!trimmed) { throw new Error("User is empty"); } - const userId = await resolveUserId(client, trimmed); + const userId = await resolveUserId(client, trimmed, options); if (!userId) { throw new Error(`Could not resolve user: ${input}`); } @@ -90,39 +93,70 @@ export async function getUser(client: SlackApiClient, input: string): Promise { +export async function resolveUserId( + client: SlackApiClient, + input: string, + options?: { forceRefresh?: boolean; workspaceUrl?: string }, +): Promise { const trimmed = input.trim(); if (isUserId(trimmed)) { return trimmed; } + const workspaceUrl = options?.workspaceUrl ?? clientWorkspaceUrl(client); + const forceRefresh = options?.forceRefresh ?? false; const looksLikeEmail = /^[^@\s]+@[^@\s]+\.[^@\s]+$/.test(trimmed) && !trimmed.startsWith("@"); - if (looksLikeEmail) { + const handle = trimmed.startsWith("@") ? trimmed.slice(1) : trimmed; + if (!handle) { + return null; + } + + if (!forceRefresh) { + const cached = await lookupCachedUserId({ + workspaceUrl, + handle: looksLikeEmail ? undefined : handle, + email: looksLikeEmail ? trimmed : undefined, + }); + if (cached) { + return cached; + } + } + + if (looksLikeEmail && !isBrowserAuth(client)) { try { const byEmail = await client.api("users.lookupByEmail", { email: trimmed }); const user = isRecord(byEmail.user) ? byEmail.user : null; - const userId = user ? getString(user.id) : undefined; - if (userId) { - return userId; + if (user) { + const compact = toCompactUser(user); + const userId = compact.id || getString(user.id); + if (userId) { + await indexUsersInCache({ workspaceUrl, users: [{ ...compact, id: userId }] }); + return userId; + } } } catch { // Fallback to users.list scan below. } } - const handle = trimmed.startsWith("@") ? trimmed.slice(1) : trimmed; - if (!handle) { - return null; - } - const handleLower = handle.toLowerCase(); + const emailLower = trimmed.toLowerCase(); let cursor: string | undefined; for (;;) { const resp = await client.api("users.list", { limit: 200, cursor }); const members = asArray(resp.members).filter(isRecord); + await indexUsersInCache({ + workspaceUrl, + users: members.map((m) => toCompactUser(m)), + }); const found = members.find((m) => { if (getString(m.name)?.toLowerCase() === handleLower) { return true; @@ -130,7 +164,7 @@ export async function resolveUserId(client: SlackApiClient, input: string): Prom if (looksLikeEmail) { const profile = isRecord(m.profile) ? m.profile : null; const email = profile ? getString(profile.email) : undefined; - return Boolean(email) && email?.toLowerCase() === trimmed.toLowerCase(); + return Boolean(email) && email?.toLowerCase() === emailLower; } return false; }); @@ -150,6 +184,44 @@ export async function resolveUserId(client: SlackApiClient, input: string): Prom return null; } +export async function warmUserResolutionCache( + client: SlackApiClient, +): Promise<{ users_indexed: number; pages: number }> { + const workspaceUrl = clientWorkspaceUrl(client); + let cursor: string | undefined; + let pages = 0; + let usersIndexed = 0; + for (;;) { + const resp = await client.api("users.list", { limit: 200, cursor }); + pages += 1; + const members = asArray(resp.members).filter(isRecord); + const users = members.map((m) => toCompactUser(m)); + usersIndexed += users.filter((u) => isUserId(u.id)).length; + await indexUsersInCache({ workspaceUrl, users }); + const meta = isRecord(resp.response_metadata) ? resp.response_metadata : null; + const next = meta ? getString(meta.next_cursor) : undefined; + if (!next) { + break; + } + cursor = next; + } + return { users_indexed: usersIndexed, pages }; +} + +function clientWorkspaceUrl(client: SlackApiClient): string | undefined { + if (typeof client.getWorkspaceUrl === "function") { + return client.getWorkspaceUrl(); + } + return undefined; +} + +function isBrowserAuth(client: SlackApiClient): boolean { + if (typeof client.getAuthType === "function") { + return client.getAuthType() === "browser"; + } + return false; +} + async function fetchDmMap(client: SlackApiClient): Promise> { const map = new Map(); let cursor: string | undefined; @@ -222,22 +294,3 @@ export async function getDmChannelForUsers( channel_type: channelType, }; } - -export function toCompactUser(u: Record): CompactSlackUser { - const profile = isRecord(u.profile) ? u.profile : {}; - return { - id: getString(u.id) ?? "", - name: getString(u.name) ?? undefined, - real_name: getString(u.real_name) ?? getString(profile.real_name) ?? undefined, - display_name: getString(profile.display_name) ?? undefined, - email: getString(profile.email) ?? undefined, - title: getString(profile.title) ?? undefined, - tz: getString(u.tz) ?? undefined, - is_bot: typeof u.is_bot === "boolean" ? u.is_bot : undefined, - deleted: typeof u.deleted === "boolean" ? u.deleted : undefined, - status_text: getString(profile.status_text) ?? undefined, - status_emoji: getString(profile.status_emoji) ?? undefined, - status_expiration: - typeof profile.status_expiration === "number" ? profile.status_expiration : undefined, - }; -} diff --git a/test/help-contracts.test.ts b/test/help-contracts.test.ts index db25d4c..422f8ff 100644 --- a/test/help-contracts.test.ts +++ b/test/help-contracts.test.ts @@ -112,4 +112,15 @@ describe("agent-facing help contracts", () => { expect(dmOpen.registeredArguments[0]?.description).toContain("One to 8 other user"); expect(dmOpen.registeredArguments[0]?.description).toContain("caller is implicit"); }); + + test("user cache warm and refresh flags document invalidation", () => { + const list = findCommand(buildProgram(), "user", "list"); + expect(optionDescription(list, "--refresh")).toContain("full directory"); + + const get = findCommand(buildProgram(), "user", "get"); + expect(optionDescription(get, "--refresh-users")).toContain("cache"); + + const warm = findCommand(buildProgram(), "user", "cache", "warm"); + expect(warm.description()).toContain("handle/email"); + }); }); diff --git a/test/users.test.ts b/test/users.test.ts index abcbfd7..d519e21 100644 --- a/test/users.test.ts +++ b/test/users.test.ts @@ -1,5 +1,32 @@ import { describe, expect, test } from "bun:test"; -import { resolveUserId } from "../src/slack/users.ts"; +import { mkdtemp } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { withEnvironment } from "./helpers/environment.ts"; +import { + indexUsersInCache, + lookupCachedUserId, + USER_CACHE_TTL_MS, +} from "../src/slack/user-cache.ts"; +import { resolveUserId, warmUserResolutionCache } from "../src/slack/users.ts"; + +const WORKSPACE_URL = "https://example.slack.com"; + +async function withUserCacheDir(work: () => Promise): Promise { + const dir = await mkdtemp(join(tmpdir(), "agent-slack-user-cache-")); + return await withEnvironment({ XDG_RUNTIME_DIR: dir }, work); +} + +function directoryClient(input: { + api: (method: string, params?: Record) => Promise>; + authType?: "standard" | "browser"; +}) { + return { + api: input.api, + getWorkspaceUrl: () => WORKSPACE_URL, + getAuthType: () => input.authType ?? "standard", + }; +} describe("resolveUserId", () => { test("returns user ids unchanged", async () => { @@ -74,4 +101,133 @@ describe("resolveUserId", () => { }; expect(await resolveUserId(client as never, "alice@example.com")).toBe("U04FALLBACK"); }); + + test("cache hit resolves @handle without users.list", async () => { + await withUserCacheDir(async () => { + await indexUsersInCache({ + workspaceUrl: WORKSPACE_URL, + users: [{ id: "U02HANDLE", name: "alice", email: "alice@example.com" }], + }); + const client = directoryClient({ + api: async () => { + throw new Error("should not call api on cache hit"); + }, + }); + expect(await resolveUserId(client as never, "@alice")).toBe("U02HANDLE"); + expect(await resolveUserId(client as never, "alice@example.com")).toBe("U02HANDLE"); + }); + }); + + test("cache miss write-through lets a later lookup skip users.list", async () => { + await withUserCacheDir(async () => { + let listCalls = 0; + const client = directoryClient({ + api: async (method: string) => { + expect(method).toBe("users.list"); + listCalls += 1; + return { + ok: true, + members: [{ id: "U02HANDLE", name: "alice", profile: { email: "alice@example.com" } }], + }; + }, + }); + expect(await resolveUserId(client as never, "@alice")).toBe("U02HANDLE"); + expect(await resolveUserId(client as never, "@Alice")).toBe("U02HANDLE"); + expect(listCalls).toBe(1); + }); + }); + + test("expired aliases fall through to users.list", async () => { + await withUserCacheDir(async () => { + await indexUsersInCache({ + workspaceUrl: WORKSPACE_URL, + users: [{ id: "U02HANDLE", name: "alice" }], + fetchedAt: Date.now() - USER_CACHE_TTL_MS - 1, + }); + let listCalls = 0; + const client = directoryClient({ + api: async (method: string) => { + expect(method).toBe("users.list"); + listCalls += 1; + return { ok: true, members: [{ id: "U09FRESH", name: "alice" }] }; + }, + }); + expect(await resolveUserId(client as never, "@alice")).toBe("U09FRESH"); + expect(listCalls).toBe(1); + }); + }); + + test("skips users.lookupByEmail for browser auth", async () => { + await withUserCacheDir(async () => { + const methods: string[] = []; + const client = directoryClient({ + authType: "browser", + api: async (method: string) => { + methods.push(method); + if (method === "users.lookupByEmail") { + throw new Error("not_allowed_token_type"); + } + return { + ok: true, + members: [{ id: "U03EMAIL", name: "alice", profile: { email: "alice@example.com" } }], + }; + }, + }); + expect(await resolveUserId(client as never, "alice@example.com")).toBe("U03EMAIL"); + expect(methods).toEqual(["users.list"]); + }); + }); + + test("forceRefresh bypasses a warm cache", async () => { + await withUserCacheDir(async () => { + await indexUsersInCache({ + workspaceUrl: WORKSPACE_URL, + users: [{ id: "U02HANDLE", name: "alice" }], + }); + let listCalls = 0; + const client = directoryClient({ + api: async (method: string) => { + expect(method).toBe("users.list"); + listCalls += 1; + return { ok: true, members: [{ id: "U09FRESH", name: "alice" }] }; + }, + }); + expect(await resolveUserId(client as never, "@alice", { forceRefresh: true })).toBe( + "U09FRESH", + ); + expect(listCalls).toBe(1); + }); + }); + + test("user cache warm indexes the full directory for later lookups", async () => { + await withUserCacheDir(async () => { + const methods: string[] = []; + const client = directoryClient({ + api: async (method: string, params?: Record) => { + methods.push(method); + expect(method).toBe("users.list"); + if (!params?.cursor) { + return { + ok: true, + members: [{ id: "U01PAGE11", name: "alice" }], + response_metadata: { next_cursor: "page2" }, + }; + } + expect(params.cursor).toBe("page2"); + return { ok: true, members: [{ id: "U02PAGE22", name: "bob" }] }; + }, + }); + expect(await warmUserResolutionCache(client as never)).toEqual({ + users_indexed: 2, + pages: 2, + }); + const cached = await lookupCachedUserId({ + workspaceUrl: WORKSPACE_URL, + handle: "bob", + }); + expect(cached).toBe("U02PAGE22"); + expect(await resolveUserId(client as never, "@bob")).toBe("U02PAGE22"); + expect(methods).toEqual(["users.list", "users.list"]); + }); + }); });