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
6 changes: 6 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -482,10 +482,16 @@ agent-slack user list --workspace "https://workspace.slack.com" --limit 200 | jq
agent-slack user get U12345678 --workspace "https://workspace.slack.com" | jq .
agent-slack user get "@alice" --workspace "https://workspace.slack.com" | jq .

# Verify active humans before constructing Slack mentions
agent-slack user resolve U12345678 bob@example.com \
--workspace "https://workspace.slack.com"

# 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 .
```

`user resolve` accepts at most 20 canonical U/W user IDs or email addresses. It verifies the authenticated workspace, deduplicates repeated identities, uses direct lookups, and emits mentions only when every input resolves to an active human; otherwise it exits nonzero and emits none.

### Unreads (inbox view)

See all unread messages across channels, DMs, and threads in one place:
Expand Down
3 changes: 2 additions & 1 deletion skills/agent-slack/SKILL.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
---
name: agent-slack
description: "Slack CLI for agents: read URLs/threads/history/unreads/later/canvases/workflows, create/edit canvases from Markdown, search messages/files, download attachments, lookup users, list/create/invite channels, open DMs, compose messages, manage Slack-native drafts, schedule sends, and explicit sends/edits/deletes/reactions/mark-read/uploads."
description: "Slack CLI for agents: read URLs/threads/history/unreads/later/canvases/workflows, create/edit canvases from Markdown, search messages/files, download attachments, lookup users, resolve verified human mentions, list/create/invite channels, open DMs, compose messages, manage Slack-native drafts, schedule sends, and explicit sends/edits/deletes/reactions/mark-read/uploads."
---

# agent-slack
Expand All @@ -20,6 +20,7 @@ If a capability named here is absent from installed help, report version skew in

- Read and search freely.
- Perform write actions only when explicitly requested: sends, edits, deletes, reactions, invitations, channel or canvas creation/editing, mark-read operations, scheduling or canceling delivery, uploads, Later state/reminder changes, DM/group-DM creation, and `workflow run`. Workflow runs can execute downstream actions.
- Never scan the full user directory to resolve a mention. `user resolve` accepts batches of at most 20 canonical user IDs and emails; use its mentions only when `safe_to_mention` is true.
- For compose- or review-only requests, return proposed text without invoking Slack, or use `message draft create` to add a Slack-native draft the user can review and send (nothing is posted). `message compose` is send-capable; use it only when the user explicitly asks to open the interactive editor. In CI or another noninteractive environment, do not invoke it without separate authorization to send immediately: CI skips the editor and sends supplied text.
- With `AGENT_SLACK_SAFE_MODE=1` (or the global `--safe-mode` flag) set, safe mode is enforced at the tool level: `message send` is redirected to the draft editor and `message edit`/`message delete` are blocked. Use it when nothing should post without human review.

Expand Down
92 changes: 92 additions & 0 deletions src/cli/user-command.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,16 @@ 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 {
resolveStrictUserIdentities,
type UserResolution,
validateStrictUserIdentityBatch,
} from "../slack/strict-user-resolution.ts";
import type { SlackApiClient } from "../slack/client.ts";

const USER_RESOLUTION_ERROR = "Unable to resolve users safely.";
const SLACK_WORKSPACE_HOST =
/^(?:[a-z0-9](?:[a-z0-9-]*[a-z0-9])?\.)+(?:slack\.com|slack-gov\.com)$/;

export function registerUserCommand(input: { program: Command; ctx: CliContext }): void {
const userCmd = input.program.command("user").description("Workspace user directory");
Expand Down Expand Up @@ -41,6 +51,42 @@ export function registerUserCommand(input: { program: Command; ctx: CliContext }
}
});

userCmd
.command("resolve")
.description("Verify active humans by Slack user ID or email")
.argument("<identities...>", "Canonical U/W user IDs or email addresses")
.option(
"--workspace <url>",
"Workspace selector (full URL or unique substring; required if you have multiple workspaces)",
)
.action(async (...args) => {
const [identities, options] = args as [string[], { workspace?: string }];
try {
validateStrictUserIdentityBatch(identities);
const workspaceUrl = input.ctx.effectiveWorkspaceUrl(options.workspace);
const output = await input.ctx.withAutoRefresh({
workspaceUrl,
work: async () => {
const { client, workspace_url } = await input.ctx.getClientForWorkspace(workspaceUrl);
const authenticated = await requireAuthenticatedSlackWorkspace(client, workspace_url);
const resolution = await resolveStrictUserIdentities({
client,
identities,
edgeCacheId: authenticated.edgeCacheId,
});
return { workspace: authenticated.workspace, resolution };
},
});
printUserResolution(output.workspace, output.resolution);
if (!output.resolution.safe_to_mention) {
process.exitCode = 1;
}
} catch {
console.error(USER_RESOLUTION_ERROR);
process.exitCode = 1;
}
});

userCmd
.command("get")
.description("Get a single workspace user")
Expand Down Expand Up @@ -90,3 +136,49 @@ export function registerUserCommand(input: { program: Command; ctx: CliContext }
}
});
}

function requireSlackWorkspaceOrigin(workspaceUrl: string | undefined): string {
const url = workspaceUrl && URL.canParse(workspaceUrl) ? new URL(workspaceUrl) : null;
if (
!url ||
url.protocol !== "https:" ||
url.origin !== workspaceUrl ||
!SLACK_WORKSPACE_HOST.test(url.hostname)
) {
throw new Error("Resolved workspace is not a canonical Slack origin");
}
return workspaceUrl;
}

async function requireAuthenticatedSlackWorkspace(
client: SlackApiClient,
configuredWorkspaceUrl: string | undefined,
): Promise<{ workspace: string; edgeCacheId: string }> {
const configuredWorkspace = configuredWorkspaceUrl
? requireSlackWorkspaceOrigin(configuredWorkspaceUrl)
: undefined;
const auth = await client.api("auth.test", {});
const authenticatedWorkspace = requireSlackWorkspaceOrigin(
typeof auth.url === "string" ? auth.url.replace(/\/$/, "") : undefined,
);
if (configuredWorkspace && configuredWorkspace !== authenticatedWorkspace) {
throw new Error("Authenticated Slack workspace does not match the selected workspace");
}
const teamId =
typeof auth.team_id === "string" && /^T[A-Z0-9]{8,}$/.test(auth.team_id)
? auth.team_id
: undefined;
const enterpriseId =
typeof auth.enterprise_id === "string" && /^E[A-Z0-9]{8,}$/.test(auth.enterprise_id)
? auth.enterprise_id
: undefined;
const edgeCacheId = enterpriseId ?? teamId;
if (!edgeCacheId) {
throw new Error("Slack auth.test returned no valid team or enterprise ID");
}
return { workspace: authenticatedWorkspace, edgeCacheId };
}

function printUserResolution(workspace: string, resolution: UserResolution): void {
console.log(JSON.stringify(pruneEmpty({ workspace, ...resolution }), null, 2));
}
84 changes: 84 additions & 0 deletions src/slack/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ export type SlackAuth =

const DEFAULT_SLACK_API_TIMEOUT_MS = 20_000;
const DEFAULT_SLACK_RATE_LIMIT_MAX_WAIT_MS = 0;
const SLACK_EDGE_CACHE_ID = /^[ET][A-Z0-9]{8,}$/;

function getSlackApiTimeoutMs(): number {
const raw =
Expand Down Expand Up @@ -86,6 +87,27 @@ export class SlackApiClient {
}
}

async lookupUserByEmail(email: string, edgeCacheId?: string): Promise<Record<string, unknown>> {
if (this.auth.auth_type === "standard") {
return this.api("users.lookupByEmail", { email });
}
if (!this.workspaceUrl) {
throw new Error("Browser email lookup requires a Slack workspace URL");
}
if (!edgeCacheId || !SLACK_EDGE_CACHE_ID.test(edgeCacheId)) {
throw new Error("Browser email lookup requires an authenticated Slack team or enterprise ID");
}
const atIndex = email.lastIndexOf("@");
if (atIndex <= 0) {
throw new Error("Browser email lookup requires a valid email address");
}
return this.browserEdgeUserSearch({
edgeCacheId,
auth: this.auth,
searchQuery: email.slice(0, atIndex),
});
}

/**
* Call a Slack API method using multipart/form-data encoding.
* Some internal Slack APIs (e.g. saved.update) require multipart encoding
Expand Down Expand Up @@ -261,6 +283,68 @@ export class SlackApiClient {
}
return data;
}

private async browserEdgeUserSearch(input: {
edgeCacheId: string;
auth: Extract<SlackAuth, { auth_type: "browser" }>;
searchQuery: string;
attempt?: number;
}): Promise<Record<string, unknown>> {
const attempt = input.attempt ?? 0;
const method = "users/search";
const govSlack = new URL(this.workspaceUrl!).hostname.endsWith(".slack-gov.com");
const url = `https://edgeapi.${govSlack ? "slack-gov.com" : "slack.com"}/cache/${input.edgeCacheId}/${method}`;
const timeoutMs = getSlackApiTimeoutMs();
let response: Response;
try {
response = await fetch(url, {
method: "POST",
redirect: "error",
headers: {
Authorization: `Bearer ${input.auth.xoxc_token}`,
Cookie: `d=${encodeURIComponent(input.auth.xoxd_cookie)}`,
"Content-Type": "application/json",
Origin: `https://app.${govSlack ? "slack-gov.com" : "slack.com"}`,
"User-Agent": getUserAgent(),
},
body: JSON.stringify({
query: input.searchQuery,
count: 25,
include_profile_only_users: false,
fuzz: 0,
uax29_tokenizer: false,
filter: "NOT deactivated",
}),
signal: timeoutSignal(timeoutMs),
});
} catch (error) {
if (isAbortOrTimeoutError(error)) {
throw slackApiTimeoutError(method, timeoutMs);
}
throw error;
}

if (response.status === 429 && attempt < 3) {
const retryAfter = Number(response.headers.get("Retry-After") ?? "5");
const delayMs = Math.min(Math.max(retryAfter, 1) * 1000, 30000);
const maxWaitMs = getSlackRateLimitMaxWaitMs();
if (delayMs > maxWaitMs) {
throw slackRateLimitError({ method, retryAfterSec: retryAfter, maxWaitMs });
}
await new Promise((resolve) => setTimeout(resolve, delayMs));
return this.browserEdgeUserSearch({ ...input, attempt: attempt + 1 });
}

const data: unknown = await response.json().catch(() => ({}));
if (!response.ok) {
throw new Error(`Slack HTTP ${response.status} calling ${method}`);
}
if (!isRecord(data) || data.ok !== true) {
const error = isRecord(data) && typeof data.error === "string" ? data.error : null;
throw new Error(error || `Slack API error calling ${method}`);
}
return data;
}
}

function isRecord(value: unknown): value is Record<string, unknown> {
Expand Down
Loading
Loading