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: 3 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -468,8 +468,10 @@ Treat Slack user IDs beginning with `U` or `W` equivalently.
# List users (email requires appropriate Slack scopes; fields are pruned if missing)
agent-slack user list --workspace "https://workspace.slack.com" --limit 200 | jq .

# Get one user by id or handle
# Get one user by id or handle (exact IDs use the 24-hour profile cache)
agent-slack user get U12345678 --workspace "https://workspace.slack.com" | jq .
agent-slack user get U12345678 --refresh --workspace "https://workspace.slack.com" | jq .
agent-slack user get U12345678 --no-cache --workspace "https://workspace.slack.com" | jq .
agent-slack user get "@alice" --workspace "https://workspace.slack.com" | jq .

# Open a DM or group DM with one to eight other users (the caller is implicit)
Expand Down
2 changes: 1 addition & 1 deletion skills/agent-slack/references/output.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,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 }`.

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 credential-scoped cache before resolving. Exact-ID `user get` reuses that cache; pass `--refresh` to replace one entry or `--no-cache` to bypass persistence. Never use cached profile fields to choose a mention or write target.

Use `--max-body-chars`, `--max-content-chars`, `--limit`, or a command's counts-only mode to keep results within the task's needs.

Expand Down
26 changes: 23 additions & 3 deletions src/cli/user-command.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
import type { Command } from "commander";
import type { CliContext } from "./context.ts";
import { pruneEmpty } from "../lib/compact-json.ts";
import { getCachedUserById } from "../slack/user-cache.ts";
import { isUserId } from "../slack/user-id.ts";
import { getDmChannelForUsers, getUser, listUsers } from "../slack/users.ts";

export function registerUserCommand(input: { program: Command; ctx: CliContext }): void {
Expand Down Expand Up @@ -49,15 +51,33 @@ export function registerUserCommand(input: { program: Command; ctx: CliContext }
"--workspace <url>",
"Workspace selector (full URL or unique substring; required if you have multiple workspaces)",
)
.option("--refresh", "Refresh an exact-ID profile instead of using the cache")
.option("--no-cache", "Fetch without reading or writing the profile cache")
.action(async (...args) => {
const [user, options] = args as [string, { workspace?: string }];
const [user, options] = args as [
string,
{ workspace?: string; refresh?: boolean; cache?: 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);
const { client, workspace_url } = await input.ctx.getClientForWorkspace(workspaceUrl);
const userId = user.trim();
if (!isUserId(userId) || options.cache === false) {
return await getUser(client, user);
}
const profile = await getCachedUserById({
client,
workspaceUrl: workspace_url ?? "",
userId,
forceRefresh: Boolean(options.refresh),
});
if (!profile) {
throw new Error("users.info returned no user");
}
return profile;
},
});
console.log(JSON.stringify(pruneEmpty(payload), null, 2));
Expand Down
11 changes: 11 additions & 0 deletions src/slack/client.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { createHash } from "node:crypto";
import { WebClient } from "@slack/web-api";
import { getUserAgent } from "../lib/version.ts";

Expand Down Expand Up @@ -73,10 +74,16 @@ export class SlackApiClient {
private auth: SlackAuth;
private web?: WebClient;
private workspaceUrl?: string;
private readonly cacheScope: string;

constructor(auth: SlackAuth, options?: { workspaceUrl?: string }) {
this.auth = auth;
this.workspaceUrl = options?.workspaceUrl;
const credentials =
auth.auth_type === "standard" ? [auth.token] : [auth.xoxc_token, auth.xoxd_cookie];
this.cacheScope = createHash("sha256")
.update([auth.auth_type, ...credentials].join("\0"))
.digest("hex");
if (auth.auth_type === "standard") {
this.web = new WebClient(auth.token, {
timeout: getSlackApiTimeoutMs(),
Expand All @@ -86,6 +93,10 @@ export class SlackApiClient {
}
}

cacheScopeKey(): string {
return this.cacheScope;
}

/**
* Call a Slack API method using multipart/form-data encoding.
* Some internal Slack APIs (e.g. saved.update) require multipart encoding
Expand Down
133 changes: 106 additions & 27 deletions src/slack/user-cache.ts
Original file line number Diff line number Diff line change
@@ -1,14 +1,15 @@
import { createHash } from "node:crypto";
import { join } from "node:path";
import { createHash, randomUUID } from "node:crypto";
import { mkdir, rename, rm, writeFile } from "node:fs/promises";
import { dirname, join } from "node:path";
import { getAppDir } from "../lib/app-dir.ts";
import { readJsonFile, writeJsonFile } from "../lib/fs.ts";
import { asArray, getString, isRecord } from "../lib/object-type-guards.ts";
import { readJsonFile } from "../lib/fs.ts";
import { asArray, 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 { 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;

Expand All @@ -19,15 +20,27 @@ type UserCacheEntry = {

type UserCacheFile = {
version: number;
scope: string;
entries: Record<string, UserCacheEntry>;
};

export async function resolveUsersById(input: {
type ResolveUsersInput = {
client: SlackApiClient;
workspaceUrl: string;
userIds: string[];
forceRefresh?: boolean;
}): Promise<Map<string, CompactSlackUser>> {
};

export async function resolveUsersById(
input: ResolveUsersInput,
): Promise<Map<string, CompactSlackUser>> {
return resolveUsersByIdInternal(input, false);
}

async function resolveUsersByIdInternal(
input: ResolveUsersInput,
throwOnError: boolean,
): Promise<Map<string, CompactSlackUser>> {
const uniqueIds = dedupeUserIds(input.userIds);
if (uniqueIds.length === 0) {
return new Map<string, CompactSlackUser>();
Expand All @@ -37,13 +50,15 @@ export async function resolveUsersById(input: {
const now = Date.now();
const workspaceKey = hashWorkspaceUrl(input.workspaceUrl);
const isUnknownWorkspace = workspaceKey === "unknown";
const cacheScope = input.client.cacheScopeKey();
const cachePath = isUnknownWorkspace ? "" : join(getAppDir(), `users-cache-${workspaceKey}.json`);

const diskCache = cachePath
? await loadCache(cachePath)
: { version: CACHE_VERSION, entries: {} };
? await loadCacheBestEffort(cachePath, { now, scope: cacheScope })
: emptyCache(cacheScope);
const out = new Map<string, CompactSlackUser>();
const missing: string[] = [];
let cacheChanged = false;

for (const userId of uniqueIds) {
const cached = diskCache.entries[userId];
Expand All @@ -54,15 +69,20 @@ export async function resolveUsersById(input: {
missing.push(userId);
}

let cacheChanged = false;

if (missing.length > 0) {
const fetched: { userId: string; user: CompactSlackUser | undefined }[] = [];
const concurrency = 5;
for (let i = 0; i < missing.length; i += concurrency) {
const chunk = missing.slice(i, i + concurrency);
const results = await Promise.all(
chunk.map(async (userId) => ({ userId, user: await fetchUserById(input.client, userId) })),
chunk.map(async (userId) => ({
userId,
user: await fetchUserById({
client: input.client,
userId,
throwOnError,
}),
})),
);
fetched.push(...results);
}
Expand All @@ -86,7 +106,6 @@ export async function resolveUsersById(input: {
if (Object.keys(diskCache.entries).length !== Object.keys(prunedCache.entries).length) {
cacheChanged = true;
}

if (cacheChanged) {
await writeCache(cachePath, prunedCache);
}
Expand All @@ -95,6 +114,25 @@ export async function resolveUsersById(input: {
return out;
}

export async function getCachedUserById(input: {
client: SlackApiClient;
userId: string;
workspaceUrl: string;
forceRefresh?: boolean;
}): Promise<CompactSlackUser | undefined> {
const userId = input.userId.trim();
const users = await resolveUsersByIdInternal(
{
client: input.client,
workspaceUrl: input.workspaceUrl,
userIds: [userId],
forceRefresh: input.forceRefresh,
},
true,
);
return users.get(userId);
}

export function collectReferencedUserIds(
messages: SlackMessageSummary[],
options?: { includeReactions?: boolean },
Expand Down Expand Up @@ -154,10 +192,32 @@ function hashWorkspaceUrl(workspaceUrl: string): string {
return createHash("sha256").update(source).digest("hex").slice(0, 16);
}

async function loadCache(path: string): Promise<UserCacheFile> {
function emptyCache(scope: string): UserCacheFile {
return { version: CACHE_VERSION, scope, entries: {} };
}

async function loadCacheBestEffort(
path: string,
options: { now: number; scope: string },
): Promise<UserCacheFile> {
try {
return await loadCache(path, options);
} catch {
return emptyCache(options.scope);
}
}

async function loadCache(
path: string,
options: { now: number; scope: string },
): Promise<UserCacheFile> {
const file = await readJsonFile<UserCacheFile>(path);
if (!file || file.version !== CACHE_VERSION || !isRecord(file.entries)) {
return { version: CACHE_VERSION, entries: {} };
if (!file) {
return emptyCache(options.scope);
}
if (file.version !== CACHE_VERSION || file.scope !== options.scope || !isRecord(file.entries)) {
await rm(path, { force: true });
return emptyCache(options.scope);
}

const entries: Record<string, UserCacheEntry> = {};
Expand All @@ -166,24 +226,38 @@ async function loadCache(path: string): Promise<UserCacheFile> {
continue;
}
const fetchedAt = typeof rawEntry.fetched_at === "number" ? rawEntry.fetched_at : undefined;
const user = isRecord(rawEntry.user) ? toCompactUser(rawEntry.user) : null;
if (!fetchedAt || !user) {
const user = isRecord(rawEntry.user)
? toCompactUser({ ...rawEntry.user, profile: rawEntry.user })
: null;
if (
!fetchedAt ||
!Number.isFinite(fetchedAt) ||
fetchedAt > options.now ||
!user ||
user.id !== userId
) {
continue;
}
entries[userId] = { fetched_at: fetchedAt, user };
}

return {
version: CACHE_VERSION,
scope: options.scope,
entries,
};
}

async function writeCache(path: string, file: UserCacheFile): Promise<void> {
const tempPath = `${path}.${process.pid}.${randomUUID()}.tmp`;
try {
await writeJsonFile(path, file);
await mkdir(dirname(path), { recursive: true });
await writeFile(tempPath, `${JSON.stringify(file, null, 2)}\n`, { mode: 0o600, flag: "wx" });
await rename(tempPath, path);
} catch {
// Cache writes are best effort.
} finally {
await rm(tempPath, { force: true }).catch(() => {});
}
}

Expand All @@ -195,21 +269,26 @@ function pruneExpiredEntries(file: UserCacheFile, now: number): UserCacheFile {
}
next[userId] = entry;
}
return { version: CACHE_VERSION, entries: next };
return { version: CACHE_VERSION, scope: file.scope, entries: next };
}

async function fetchUserById(
client: SlackApiClient,
userId: string,
): Promise<CompactSlackUser | undefined> {
async function fetchUserById(input: {
client: SlackApiClient;
userId: string;
throwOnError: boolean;
}): Promise<CompactSlackUser | undefined> {
try {
const resp = await client.api("users.info", { user: userId });
const resp = await input.client.api("users.info", { user: input.userId });
const user = isRecord(resp.user) ? resp.user : null;
if (!user) {
return undefined;
}
return toCompactUser(user);
} catch {
const compact = toCompactUser(user);
return compact.id === input.userId ? compact : undefined;
} catch (error) {
if (input.throwOnError) {
throw error;
}
return undefined;
}
}
Expand Down
25 changes: 25 additions & 0 deletions test/client.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,31 @@ afterEach(() => {
delete process.env.AGENT_SLACK_RATE_LIMIT_MAX_WAIT_MS;
});

describe("SlackApiClient cache scope", () => {
test("changes when either browser credential changes", () => {
const first = new SlackApiClient({
auth_type: "browser",
xoxc_token: "xoxc-first",
xoxd_cookie: "xoxd-first",
});
const same = new SlackApiClient({
auth_type: "browser",
xoxc_token: "xoxc-first",
xoxd_cookie: "xoxd-first",
});
const rotatedCookie = new SlackApiClient({
auth_type: "browser",
xoxc_token: "xoxc-first",
xoxd_cookie: "xoxd-second",
});

expect(first.cacheScopeKey()).toBe(same.cacheScopeKey());
expect(first.cacheScopeKey()).not.toBe(rotatedCookie.cacheScopeKey());
expect(first.cacheScopeKey()).not.toContain("xoxc-first");
expect(first.cacheScopeKey()).not.toContain("xoxd-first");
});
});

describe("SlackApiClient browser multipart transport", () => {
test("retries HTTP 429 responses using Retry-After", async () => {
// Fail-fast defaults to 0ms; opt in to waiting so the retry path runs.
Expand Down
1 change: 1 addition & 0 deletions test/search-command.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ const mockUsersById: Record<

function createClient(calls: ApiCall[]) {
return {
cacheScopeKey: () => "test-principal",
api: async (method: string, params: Record<string, unknown>) => {
calls.push({ method, params });

Expand Down
Loading
Loading