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
18 changes: 16 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -96,8 +96,10 @@ agent-slack
│ ├── new # create channel
│ └── invite # invite users to channel
├── user
│ ├── list
│ └── get <user>
│ ├── list # --refresh indexes the full directory
│ ├── get <user>
│ ├── dm-open <users...>
│ └── cache warm # prime handle/email → user-id cache
├── search
│ ├── all <query> # messages + files
│ ├── messages <query>
Expand Down Expand Up @@ -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-<sha256(hostname)[:16]>.json` when `XDG_RUNTIME_DIR` is set, otherwise `~/.agent-slack/users-cache-<sha256(hostname)[:16]>.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 .
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 @@ -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.

Expand Down
56 changes: 51 additions & 5 deletions src/cli/user-command.ts
Original file line number Diff line number Diff line change
@@ -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");
Expand All @@ -16,9 +21,19 @@ export function registerUserCommand(input: { program: Command; ctx: CliContext }
.option("--limit <n>", "Max users (default 200)", "200")
.option("--cursor <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);
Expand All @@ -31,6 +46,7 @@ export function registerUserCommand(input: { program: Command; ctx: CliContext }
limit,
cursor: options.cursor,
includeBots: Boolean(options.includeBots),
refresh: Boolean(options.refresh),
});
},
});
Expand All @@ -44,20 +60,23 @@ export function registerUserCommand(input: { program: Command; ctx: CliContext }
userCmd
.command("get")
.description("Get a single workspace user")
.argument("<user>", "User ID (U.../W...) or @handle/handle")
.argument("<user>", "User ID (U.../W...) or @handle/handle/email")
.option(
"--workspace <url>",
"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));
Expand Down Expand Up @@ -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 <url>",
"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;
}
});
}
8 changes: 8 additions & 0 deletions src/slack/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
36 changes: 36 additions & 0 deletions src/slack/compact-user.ts
Original file line number Diff line number Diff line change
@@ -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<string, unknown>): 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,
};
}
35 changes: 3 additions & 32 deletions src/slack/search-query.ts
Original file line number Diff line number Diff line change
@@ -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,
Expand Down Expand Up @@ -115,35 +116,5 @@ export async function resolveUserId(
client: SlackApiClient,
input: string,
): Promise<string | undefined> {
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;
}
Loading