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
9 changes: 8 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -525,7 +525,12 @@ Output includes channels sorted by mention count, then unread count:
}
```

Note: This feature uses the `client.counts` API which may be restricted in some Enterprise Grid workspaces (`team_is_restricted` error).
Unreads prefer a single `client.counts` call. On Enterprise Grids where that method returns `team_is_restricted`, the CLI falls back automatically:

- DMs / group DMs: `client.dms` with `count` 50 (the 50 most recently active conversations), then one `conversations.info` per id to compare `last_read` against the latest message ts. This stays bounded and does not walk the full IM list.
- Channels: `users.conversations` (public/private, max 50, newest `updated` first when Slack provides it), then `conversations.info` plus `conversations.history` (limit 1, or since `last_read` when fetching bodies).
- Thread unreads are omitted on the fallback path because they come from `client.counts`.
- `channel list --via-counts` and `later list` use the same `team_is_restricted` trigger: channels fall back to `users.conversations`, Later falls back to `search.messages` with `is:saved`.

### Later (saved messages)

Expand All @@ -538,6 +543,8 @@ agent-slack later list
# Show only counts per state
agent-slack later list --counts-only

# On grids where saved.list is team_is_restricted, later list falls back to search is:saved

# Filter by state: in_progress, completed, archived, all
agent-slack later list --state completed

Expand Down
2 changes: 1 addition & 1 deletion src/cli/channel-command.ts
Original file line number Diff line number Diff line change
Expand Up @@ -43,7 +43,7 @@ export function registerChannelCommand(input: { program: Command; ctx: CliContex
.option("--all", "List all conversations (conversations.list); incompatible with --user")
.option(
"--via-counts",
"List joined conversations via client.counts (works on Enterprise Grid where users.conversations/conversations.list are restricted for browser tokens; incompatible with --all/--user)",
"List joined conversations via client.counts (or users.conversations when client.counts is team_is_restricted); incompatible with --all/--user",
)
.option("--limit <n>", "Max conversations in one page (default 100)", "100")
.option("--cursor <cursor>", "Pagination cursor for the next page")
Expand Down
42 changes: 42 additions & 0 deletions src/slack/api-errors.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
import { isRecord } from "../lib/object-type-guards.ts";

/**
* True when a Slack API failure is the org-level `team_is_restricted` denial.
* Browser auth throws that string as `Error.message`; `@slack/web-api` puts it
* on `error.data.error` and wraps it as `An API error occurred: …`.
*/
export function isTeamRestrictedError(err: unknown): boolean {
const texts: string[] = [];
collectErrorText({ value: err, out: texts, depth: 0 });
return texts.some((text) => text === "team_is_restricted" || text.includes("team_is_restricted"));
}

function collectErrorText(input: { value: unknown; out: string[]; depth: number }): void {
const { value, out, depth } = input;
if (depth > 4 || value == null) {
return;
}
if (typeof value === "string") {
out.push(value);
return;
}
if (value instanceof Error) {
out.push(value.message);
const extra = value as Error & { data?: unknown; code?: unknown };
if (typeof extra.code === "string") {
out.push(extra.code);
}
collectErrorText({ value: extra.data, out, depth: depth + 1 });
collectErrorText({ value: value.cause, out, depth: depth + 1 });
return;
}
if (isRecord(value)) {
if (typeof value.error === "string") {
out.push(value.error);
}
if (typeof value.message === "string") {
out.push(value.message);
}
collectErrorText({ value: value.data, out, depth: depth + 1 });
}
}
19 changes: 16 additions & 3 deletions src/slack/channels.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import type { SlackApiClient } from "./client.ts";
import { asArray, getString, isRecord } from "../lib/object-type-guards.ts";
import { isTeamRestrictedError } from "./api-errors.ts";

const DEFAULT_CONVERSATION_TYPES = "public_channel,private_channel,im,mpim";

Expand Down Expand Up @@ -191,16 +192,28 @@ export async function listAllConversations(
* (`client.counts` returns everything at once and is sliced to `limit`
* locally), `--cursor` is not supported, and only the current user's
* conversations are available (`--user` is not supported).
*
* When `client.counts` returns `team_is_restricted` (org-level block on some
* Enterprise Grids), this falls back to `users.conversations`, which remains
* available on those same grids.
*/
export async function listConversationsViaCounts(
client: SlackApiClient,
options?: { limit?: number },
): Promise<ConversationsPage> {
const limit = normalizeConversationsLimit(options?.limit);

const resp = await client.api("client.counts", {
thread_count_by_channel: true,
});
let resp: Record<string, unknown>;
try {
resp = await client.api("client.counts", {
thread_count_by_channel: true,
});
} catch (err) {
if (!isTeamRestrictedError(err)) {
throw err;
}
return listUserConversations(client, { limit, excludeArchived: true });
}

const entries = [
...asArray(resp.channels).filter(isRecord),
Expand Down
99 changes: 95 additions & 4 deletions src/slack/later.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import type { SlackApiClient } from "./client.ts";
import { isTeamRestrictedError } from "./api-errors.ts";
import { renderSlackMessageContent } from "./render.ts";

export type LaterItem = {
Expand Down Expand Up @@ -46,10 +47,23 @@ export async function fetchLaterItems(
let nextCursor: string | undefined;

while (true) {
const resp = await client.api("saved.list", {
limit: 50,
cursor: currentCursor,
});
let resp: Record<string, unknown>;
try {
resp = await client.api("saved.list", {
limit: 50,
cursor: currentCursor,
});
} catch (err) {
if (!isTeamRestrictedError(err) || currentCursor) {
throw err;
}
return fetchLaterItemsViaSearch(client, {
stateFilter,
limit,
maxBodyChars,
countsOnly,
});
}

if (!currentCursor) {
counts = isRecord(resp.counts) ? resp.counts : {};
Expand Down Expand Up @@ -195,6 +209,83 @@ export async function fetchLaterItems(
return result;
}

async function fetchLaterItemsViaSearch(
client: SlackApiClient,
options: {
stateFilter: "in_progress" | "archived" | "completed" | "all";
limit: number;
maxBodyChars: number;
countsOnly: boolean;
},
): Promise<{
counts: {
in_progress: number;
archived: number;
completed: number;
total: number;
};
items: LaterItem[];
next_cursor?: string;
}> {
const resp = await client.api("search.messages", {
query: "is:saved",
count: Math.min(Math.max(options.limit, 1), 100),
sort: "timestamp",
sort_dir: "desc",
});

const messages = isRecord(resp.messages) ? resp.messages : null;
const total = getNumber(messages?.total) ?? asArray(messages?.matches).length;
const counts = {
in_progress: total,
archived: 0,
completed: 0,
total,
};

if (
options.countsOnly ||
options.stateFilter === "archived" ||
options.stateFilter === "completed"
) {
return { counts, items: [] };
}

const matches = messages ? asArray(messages.matches).filter(isRecord) : [];
const items: LaterItem[] = matches.slice(0, options.limit).map((match) => {
const channel = isRecord(match.channel) ? match.channel : null;
const rendered = renderSlackMessageContent(match);
const content =
options.maxBodyChars >= 0 && rendered.length > options.maxBodyChars
? `${rendered.slice(0, options.maxBodyChars)}\n…`
: rendered;
const ts = getString(match.ts) ?? "";
return {
channel_id: channel ? (getString(channel.id) ?? "") : "",
channel_name: channel
? (getString(channel.name) ?? getString(channel.name_normalized) ?? undefined)
: undefined,
ts,
state: "in_progress",
date_saved: ts ? Math.floor(Number.parseFloat(ts)) || 0 : 0,
message: {
author:
getString(match.user) || getString(match.bot_id)
? {
user_id: getString(match.user) ?? undefined,
bot_id: getString(match.bot_id) ?? undefined,
}
: undefined,
content: content || undefined,
thread_ts: getString(match.thread_ts) ?? undefined,
reply_count: getNumber(match.reply_count) ?? undefined,
},
};
});

return { counts, items };
}

/**
* Mark a saved item as completed, archived, or reopen it.
* Uses multipart/form-data with the `mark` param (not `state`).
Expand Down
Loading