From 90a3181bf3a1eb0003ac75e7de634ae1c119fea9 Mon Sep 17 00:00:00 2001 From: Arnaud Prades Date: Sun, 20 Sep 2026 16:36:14 +0400 Subject: [PATCH] fix(unreads): fall back when client.counts is team_is_restricted Enterprise Grids that block client.counts can still serve unreads via a bounded client.dms + users.conversations path, with the same trigger for channel list --via-counts and later list. Co-Authored-By: Paperclip Co-authored-by: Cursor --- README.md | 9 +- src/cli/channel-command.ts | 2 +- src/slack/api-errors.ts | 42 +++ src/slack/channels.ts | 19 +- src/slack/later.ts | 99 ++++++- src/slack/unreads.ts | 590 ++++++++++++++++++++++++++----------- test/channels.test.ts | 38 +++ test/later.test.ts | 60 ++++ test/unreads.test.ts | 229 ++++++++++++++ 9 files changed, 915 insertions(+), 173 deletions(-) create mode 100644 src/slack/api-errors.ts create mode 100644 test/unreads.test.ts diff --git a/README.md b/README.md index 7fb9c07..7966547 100644 --- a/README.md +++ b/README.md @@ -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) @@ -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 diff --git a/src/cli/channel-command.ts b/src/cli/channel-command.ts index 500a4db..c0fdb78 100644 --- a/src/cli/channel-command.ts +++ b/src/cli/channel-command.ts @@ -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 ", "Max conversations in one page (default 100)", "100") .option("--cursor ", "Pagination cursor for the next page") diff --git a/src/slack/api-errors.ts b/src/slack/api-errors.ts new file mode 100644 index 0000000..b842794 --- /dev/null +++ b/src/slack/api-errors.ts @@ -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 }); + } +} diff --git a/src/slack/channels.ts b/src/slack/channels.ts index 6bc098c..cd10743 100644 --- a/src/slack/channels.ts +++ b/src/slack/channels.ts @@ -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"; @@ -191,6 +192,10 @@ 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, @@ -198,9 +203,17 @@ export async function listConversationsViaCounts( ): Promise { const limit = normalizeConversationsLimit(options?.limit); - const resp = await client.api("client.counts", { - thread_count_by_channel: true, - }); + let resp: Record; + 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), diff --git a/src/slack/later.ts b/src/slack/later.ts index 29d3860..66b8627 100644 --- a/src/slack/later.ts +++ b/src/slack/later.ts @@ -1,4 +1,5 @@ import type { SlackApiClient } from "./client.ts"; +import { isTeamRestrictedError } from "./api-errors.ts"; import { renderSlackMessageContent } from "./render.ts"; export type LaterItem = { @@ -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; + 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 : {}; @@ -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`). diff --git a/src/slack/unreads.ts b/src/slack/unreads.ts index 2a2f924..fd7a97b 100644 --- a/src/slack/unreads.ts +++ b/src/slack/unreads.ts @@ -1,4 +1,6 @@ import type { SlackApiClient } from "./client.ts"; +import { asArray, getNumber, getString, isRecord } from "../lib/object-type-guards.ts"; +import { isTeamRestrictedError } from "./api-errors.ts"; import { renderSlackMessageContent } from "./render.ts"; export type UnreadChannel = { @@ -28,30 +30,87 @@ type ClientCountsEntry = { last_read?: string; }; -export async function fetchUnreads( - client: SlackApiClient, - options?: { - includeMessages?: boolean; - maxMessagesPerChannel?: number; - maxBodyChars?: number; - skipSystemMessages?: boolean; - }, -): Promise<{ +type FetchUnreadsOptions = { + includeMessages?: boolean; + maxMessagesPerChannel?: number; + maxBodyChars?: number; + skipSystemMessages?: boolean; +}; + +type HydratedUnreads = { channels: UnreadChannel[]; threads: { has_unreads: boolean; mention_count: number; } | null; -}> { - const includeMessages = options?.includeMessages ?? true; - const maxMessages = options?.maxMessagesPerChannel ?? 10; - const maxBodyChars = options?.maxBodyChars ?? 4000; - const skipSystem = options?.skipSystemMessages ?? true; - - const resp = await client.api("client.counts", { - thread_count_by_channel: true, - }); +}; + +/** + * Max recently-active DMs/MPIMs requested from `client.dms` on the + * `team_is_restricted` fallback. Call volume is 1 `client.dms` plus at most + * this many `conversations.info` calls (and history only for unread DMs when + * message bodies are requested). It does not scan the full IM list. + */ +export const UNREADS_FALLBACK_DM_COUNT = 50; + +/** + * Max joined channels inspected on the fallback path. `users.conversations` + * is sliced (and sorted by `updated` when present) so we do not pair + * `conversations.info` + `conversations.history --limit 1` across the whole + * joined set. + */ +export const UNREADS_FALLBACK_CHANNEL_LIMIT = 50; +const SYSTEM_SUBTYPES = [ + "channel_join", + "channel_leave", + "channel_topic", + "channel_purpose", + "channel_name", + "channel_archive", + "channel_unarchive", + "group_join", + "group_leave", + "group_topic", + "group_purpose", + "group_name", + "group_archive", + "group_unarchive", +]; + +export async function fetchUnreads( + client: SlackApiClient, + options?: FetchUnreadsOptions, +): Promise { + const resolved = resolveOptions(options); + + try { + const resp = await client.api("client.counts", { + thread_count_by_channel: true, + }); + return hydrateFromCounts(client, resp, resolved); + } catch (err) { + if (!isTeamRestrictedError(err)) { + throw err; + } + return fetchUnreadsFallback(client, resolved); + } +} + +function resolveOptions(options?: FetchUnreadsOptions): Required { + return { + includeMessages: options?.includeMessages ?? true, + maxMessagesPerChannel: options?.maxMessagesPerChannel ?? 10, + maxBodyChars: options?.maxBodyChars ?? 4000, + skipSystemMessages: options?.skipSystemMessages ?? true, + }; +} + +async function hydrateFromCounts( + client: SlackApiClient, + resp: Record, + options: Required, +): Promise { const channels = asArray(resp.channels).filter(isRecord) as unknown as ClientCountsEntry[]; const mpims = asArray(resp.mpims).filter(isRecord) as unknown as ClientCountsEntry[]; const ims = asArray(resp.ims).filter(isRecord) as unknown as ClientCountsEntry[]; @@ -63,178 +122,381 @@ export async function fetchUnreads( ]; const withUnreads = allEntries.filter((c) => c.has_unreads); - - // Resolve channel info and fetch messages in parallel const channelInfos = await Promise.all( - withUnreads.map(async (entry) => { - // 1. Concurrently fetch channel info & user info (if DM) - const channelInfoPromise = (async () => { - let name: string | undefined; - let { type } = entry; - try { - const info = await client.api("conversations.info", { - channel: entry.id, - }); - const ch = isRecord(info.channel) ? info.channel : null; - if (ch) { - name = getString(ch.name) ?? getString(ch.name_normalized) ?? undefined; - if (ch.is_im) { - type = "dm"; - const userId = getString(ch.user); - if (userId && !name) { - try { - const userInfo = await client.api("users.info", { user: userId }); - const u = isRecord(userInfo.user) ? userInfo.user : null; - const profile = u && isRecord(u.profile) ? u.profile : null; - name = - getString(profile?.display_name) || - getString(u?.real_name) || - getString(u?.name) || - undefined; - } catch { - // ignore - } - } - } else if (ch.is_mpim) { - type = "mpim"; - } else if (ch.is_group || ch.is_private) { - type = "channel"; - } else { - type = "channel"; - } + withUnreads.map((entry) => hydrateCountsEntry(client, entry, options)), + ); + + sortUnreadChannels(channelInfos); + + const threads = isRecord(resp.threads) ? resp.threads : null; + const threadInfo = threads?.has_unreads + ? { + has_unreads: true, + mention_count: (threads.mention_count as number) ?? 0, + } + : null; + + return { channels: channelInfos, threads: threadInfo }; +} + +async function hydrateCountsEntry( + client: SlackApiClient, + entry: ClientCountsEntry & { type: UnreadChannel["channel_type"] }, + options: Required, +): Promise { + const channelInfoPromise = resolveConversationMeta(client, entry.id, entry.type); + + const historyPromise = (async () => { + let messages: UnreadMessage[] | undefined; + let unreadCount = + entry.unread_count_display ?? entry.unread_count ?? (entry.has_unreads ? 1 : 0); + + if (options.includeMessages && entry.last_read) { + try { + const history = await fetchHistorySince(client, entry.id, entry.last_read, options); + if (entry.unread_count_display === undefined && entry.unread_count === undefined) { + unreadCount = history.messages.length; + if (history.hasMore) { + unreadCount = Math.max(unreadCount, 2); } - } catch { - // ignore - name will remain undefined } - return { name, type }; - })(); + ({ messages } = history); + } catch { + // ignore + } + } + return { messages, unreadCount }; + })(); + + const [channelData, historyData] = await Promise.all([channelInfoPromise, historyPromise]); - // 2. Concurrently fetch message history - const historyPromise = (async () => { - let messages: UnreadMessage[] | undefined; - let unreadCount = - entry.unread_count_display ?? entry.unread_count ?? (entry.has_unreads ? 1 : 0); + return { + channel_id: entry.id, + channel_name: channelData.name, + channel_type: channelData.type, + unread_count: historyData.unreadCount, + mention_count: entry.mention_count ?? 0, + messages: historyData.messages, + }; +} + +async function fetchUnreadsFallback( + client: SlackApiClient, + options: Required, +): Promise { + const [dms, channels] = await Promise.all([ + fetchDmUnreadsFallback(client, options), + fetchChannelUnreadsFallback(client, options), + ]); - if (includeMessages && entry.last_read) { + const combined = [...channels, ...dms]; + sortUnreadChannels(combined); + return { channels: combined, threads: null }; +} + +async function fetchDmUnreadsFallback( + client: SlackApiClient, + options: Required, +): Promise { + const resp = await client.api("client.dms", { + count: UNREADS_FALLBACK_DM_COUNT, + }); + + const entries = [ + ...asArray(resp.ims) + .filter(isRecord) + .map((entry) => ({ entry, type: "dm" as const })), + ...asArray(resp.mpims) + .filter(isRecord) + .map((entry) => ({ entry, type: "mpim" as const })), + ].slice(0, UNREADS_FALLBACK_DM_COUNT); + + const hydrated: (UnreadChannel | null)[] = await Promise.all( + entries.map(async ({ entry, type }) => { + const id = getString(entry.id); + if (!id) { + return null; + } + const latestMessage = isRecord(entry.message) ? entry.message : null; + const latestTs = latestMessage ? getString(latestMessage.ts) : undefined; + const info = await safeConversationsInfo(client, id); + const lastRead = info ? getString(info.last_read) : undefined; + if (!isTsNewer(latestTs, lastRead)) { + return null; + } + + const meta = await resolveConversationMeta(client, id, type, info); + let messages: UnreadMessage[] | undefined; + let unreadCount = 1; + + if (options.includeMessages) { + if (lastRead) { try { - const history = await client.api("conversations.history", { - channel: entry.id, - oldest: entry.last_read, - limit: maxMessages, - inclusive: false, - }); - let msgs = asArray(history.messages).filter(isRecord); - if (skipSystem) { - msgs = msgs.filter((m) => { - const subtype = getString(m.subtype); - if (!subtype) { - return true; - } - const systemSubtypes = [ - "channel_join", - "channel_leave", - "channel_topic", - "channel_purpose", - "channel_name", - "channel_archive", - "channel_unarchive", - "group_join", - "group_leave", - "group_topic", - "group_purpose", - "group_name", - "group_archive", - "group_unarchive", - ]; - return !systemSubtypes.includes(subtype); - }); + const history = await fetchHistorySince(client, id, lastRead, options); + unreadCount = Math.max(history.messages.length, 1); + if (history.hasMore) { + unreadCount = Math.max(unreadCount, 2); } + ({ messages } = history); + } catch { + messages = latestMessage + ? [toUnreadMessage(latestMessage, options.maxBodyChars)] + : undefined; + } + } else if (latestMessage) { + messages = [toUnreadMessage(latestMessage, options.maxBodyChars)]; + } + } - // If API didn't provide a count, infer from messages fetched - if (entry.unread_count_display === undefined && entry.unread_count === undefined) { - unreadCount = msgs.length; - if (history.has_more) { - unreadCount = Math.max(unreadCount, 2); - } - } + return { + channel_id: id, + ...(meta.name ? { channel_name: meta.name } : {}), + channel_type: meta.type, + unread_count: unreadCount, + mention_count: 0, + messages, + }; + }), + ); - messages = msgs.map((m) => { - const rendered = renderSlackMessageContent(m); - const content = - maxBodyChars >= 0 && rendered.length > maxBodyChars - ? `${rendered.slice(0, maxBodyChars)}\n...` - : rendered; - - return { - ts: getString(m.ts) ?? "", - author: - getString(m.user) || getString(m.bot_id) - ? { - user_id: getString(m.user) ?? undefined, - bot_id: getString(m.bot_id) ?? undefined, - } - : undefined, - content: content || undefined, - thread_ts: getString(m.thread_ts) ?? undefined, - reply_count: getNumber(m.reply_count) ?? undefined, - }; - }); - - // Sort chronologically (oldest first) - messages.sort((a, b) => Number.parseFloat(a.ts) - Number.parseFloat(b.ts)); - } catch { - // ignore + return compactUnreadChannels(hydrated); +} + +async function fetchChannelUnreadsFallback( + client: SlackApiClient, + options: Required, +): Promise { + const resp = await client.api("users.conversations", { + types: "public_channel,private_channel", + exclude_archived: true, + limit: UNREADS_FALLBACK_CHANNEL_LIMIT, + }); + + const listed = asArray(resp.channels) + .filter(isRecord) + .filter((ch) => getString(ch.id)) + .sort((a, b) => (getNumber(b.updated) ?? 0) - (getNumber(a.updated) ?? 0)) + .slice(0, UNREADS_FALLBACK_CHANNEL_LIMIT); + + const hydrated: (UnreadChannel | null)[] = await Promise.all( + listed.map(async (listedChannel) => { + const id = getString(listedChannel.id); + if (!id) { + return null; + } + const info = (await safeConversationsInfo(client, id)) ?? listedChannel; + const lastRead = getString(info.last_read); + const latestFromInfo = latestTsFromChannel(info); + + let latestTs = latestFromInfo; + let historyMessages: UnreadMessage[] | undefined; + let historyHasMore = false; + let probedSinceLastRead = false; + + if (options.includeMessages && lastRead) { + try { + const history = await fetchHistorySince(client, id, lastRead, options); + probedSinceLastRead = true; + historyMessages = history.messages; + historyHasMore = history.hasMore; + latestTs = history.messages.at(-1)?.ts ?? latestTs; + } catch { + // fall through to a single latest-message probe + } + } + + if (!probedSinceLastRead && !latestTs) { + try { + const latest = await client.api("conversations.history", { + channel: id, + limit: 1, + }); + const msgs = asArray(latest.messages).filter(isRecord); + latestTs = msgs[0] ? getString(msgs[0].ts) : undefined; + if (options.includeMessages && msgs[0] && isTsNewer(latestTs, lastRead)) { + historyMessages = [toUnreadMessage(msgs[0], options.maxBodyChars)]; } + } catch { + return null; } - return { messages, unreadCount }; - })(); + } + + if (probedSinceLastRead && (!historyMessages || historyMessages.length === 0)) { + return null; + } - // 3. Await both sets of network requests at the same time - const [channelData, historyData] = await Promise.all([channelInfoPromise, historyPromise]); + if (!isTsNewer(latestTs, lastRead)) { + return null; + } + + const meta = await resolveConversationMeta(client, id, "channel", info); + let unreadCount = 1; + if (historyMessages) { + unreadCount = Math.max(historyMessages.length, 1); + if (historyHasMore) { + unreadCount = Math.max(unreadCount, 2); + } + } return { - channel_id: entry.id, - channel_name: channelData.name, - channel_type: channelData.type, - unread_count: historyData.unreadCount, - mention_count: entry.mention_count ?? 0, - messages: historyData.messages, - } satisfies UnreadChannel; + channel_id: id, + ...(meta.name ? { channel_name: meta.name } : {}), + channel_type: meta.type, + unread_count: unreadCount, + mention_count: 0, + messages: historyMessages, + }; }), ); - // Sort: mentions first, then by unread count - channelInfos.sort((a, b) => { - if (a.mention_count !== b.mention_count) { - return b.mention_count - a.mention_count; - } - return b.unread_count - a.unread_count; + return compactUnreadChannels(hydrated); +} + +async function fetchHistorySince( + client: SlackApiClient, + channelId: string, + lastRead: string, + options: Required, +): Promise<{ messages: UnreadMessage[]; hasMore: boolean }> { + const history = await client.api("conversations.history", { + channel: channelId, + oldest: lastRead, + limit: options.maxMessagesPerChannel, + inclusive: false, }); + let msgs = asArray(history.messages).filter(isRecord); + if (options.skipSystemMessages) { + msgs = msgs.filter((m) => { + const subtype = getString(m.subtype); + return !subtype || !SYSTEM_SUBTYPES.includes(subtype); + }); + } + const messages = msgs.map((m) => toUnreadMessage(m, options.maxBodyChars)); + messages.sort((a, b) => Number.parseFloat(a.ts) - Number.parseFloat(b.ts)); + return { messages, hasMore: history.has_more === true }; +} - // Process thread unreads - const threads = isRecord(resp.threads) ? resp.threads : null; - const threadInfo = threads?.has_unreads - ? { - has_unreads: true, - mention_count: (threads.mention_count as number) ?? 0, - } - : null; +function toUnreadMessage(m: Record, maxBodyChars: number): UnreadMessage { + const rendered = renderSlackMessageContent(m); + const content = + maxBodyChars >= 0 && rendered.length > maxBodyChars + ? `${rendered.slice(0, maxBodyChars)}\n...` + : rendered; - return { channels: channelInfos, threads: threadInfo }; + return { + ts: getString(m.ts) ?? "", + author: + getString(m.user) || getString(m.bot_id) + ? { + user_id: getString(m.user) ?? undefined, + bot_id: getString(m.bot_id) ?? undefined, + } + : undefined, + content: content || undefined, + thread_ts: getString(m.thread_ts) ?? undefined, + reply_count: getNumber(m.reply_count) ?? undefined, + }; +} + +async function resolveConversationMeta( + client: SlackApiClient, + channelId: string, + fallbackType: UnreadChannel["channel_type"], + existing?: Record | null, +): Promise<{ name: string | undefined; type: UnreadChannel["channel_type"] }> { + const ch = existing ?? (await safeConversationsInfo(client, channelId)); + if (!ch) { + return { name: undefined, type: fallbackType }; + } + + let name = getString(ch.name) ?? getString(ch.name_normalized) ?? undefined; + let type = fallbackType; + if (ch.is_im) { + type = "dm"; + const userId = getString(ch.user); + if (userId && !name) { + name = await resolveUserDisplayName(client, userId); + } + } else if (ch.is_mpim) { + type = "mpim"; + } else if (ch.is_group || ch.is_private || ch.is_channel) { + type = "channel"; + } + return { name, type }; +} + +async function resolveUserDisplayName( + client: SlackApiClient, + userId: string, +): Promise { + try { + const userInfo = await client.api("users.info", { user: userId }); + const u = isRecord(userInfo.user) ? userInfo.user : null; + const profile = u && isRecord(u.profile) ? u.profile : null; + return ( + getString(profile?.display_name) || getString(u?.real_name) || getString(u?.name) || undefined + ); + } catch { + return undefined; + } } -function isRecord(value: unknown): value is Record { - return typeof value === "object" && value !== null; +async function safeConversationsInfo( + client: SlackApiClient, + channelId: string, +): Promise | null> { + try { + const info = await client.api("conversations.info", { channel: channelId }); + return isRecord(info.channel) ? info.channel : null; + } catch { + return null; + } } -function asArray(value: unknown): unknown[] { - return Array.isArray(value) ? value : []; +function latestTsFromChannel(ch: Record): string | undefined { + const { latest } = ch; + if (typeof latest === "string") { + return latest; + } + if (isRecord(latest)) { + return getString(latest.ts); + } + return undefined; } -function getString(value: unknown): string | undefined { - return typeof value === "string" ? value : undefined; +export function isTsNewer(latestTs: string | undefined, lastRead: string | undefined): boolean { + if (!latestTs) { + return false; + } + const latestN = Number.parseFloat(latestTs); + if (!Number.isFinite(latestN)) { + return false; + } + if (!lastRead) { + return true; + } + const lastN = Number.parseFloat(lastRead); + if (!Number.isFinite(lastN) || lastN === 0) { + return true; + } + return latestN > lastN; } -function getNumber(value: unknown): number | undefined { - return typeof value === "number" ? value : undefined; +function sortUnreadChannels(channels: UnreadChannel[]): void { + channels.sort((a, b) => { + if (a.mention_count !== b.mention_count) { + return b.mention_count - a.mention_count; + } + return b.unread_count - a.unread_count; + }); +} + +function compactUnreadChannels(entries: (UnreadChannel | null)[]): UnreadChannel[] { + const out: UnreadChannel[] = []; + for (const entry of entries) { + if (entry) { + out.push(entry); + } + } + return out; } diff --git a/test/channels.test.ts b/test/channels.test.ts index 917346e..85fc41d 100644 --- a/test/channels.test.ts +++ b/test/channels.test.ts @@ -192,4 +192,42 @@ describe("conversations list helpers", () => { expect(infoCalls).toHaveLength(2); expect(page.channels.map((c) => c.id)).toEqual(["C1", "C2"]); }); + + test("listConversationsViaCounts falls back to users.conversations on team_is_restricted", async () => { + const calls: { method: string; params: Record }[] = []; + const client = { + api: async (method: string, params: Record) => { + calls.push({ method, params }); + if (method === "client.counts") { + throw new Error("team_is_restricted"); + } + if (method === "users.conversations") { + return { + channels: [{ id: "C9", name: "fallback" }], + response_metadata: { next_cursor: "" }, + }; + } + throw new Error(`unexpected method: ${method}`); + }, + } as unknown as SlackApiClient; + + const page = await listConversationsViaCounts(client, { limit: 25 }); + expect(calls[0]?.method).toBe("client.counts"); + expect(calls[1]?.method).toBe("users.conversations"); + expect(calls[1]?.params.limit).toBe(25); + expect(page.channels).toEqual([{ id: "C9", name: "fallback" }]); + }); + + test("listConversationsViaCounts does not fall back on other errors", async () => { + const client = { + api: async (method: string) => { + if (method === "client.counts") { + throw new Error("fatal_error"); + } + throw new Error(`unexpected method: ${method}`); + }, + } as unknown as SlackApiClient; + + await expect(listConversationsViaCounts(client, { limit: 10 })).rejects.toThrow("fatal_error"); + }); }); diff --git a/test/later.test.ts b/test/later.test.ts index df64291..ab81fe0 100644 --- a/test/later.test.ts +++ b/test/later.test.ts @@ -287,6 +287,66 @@ describe("fetchLaterItems", () => { expect(result.items[0]?.message?.content?.length).toBeLessThanOrEqual(103); // 100 + "\n…" }); + + test("falls back to search is:saved when saved.list is team_is_restricted", async () => { + const calls: { method: string; params: Record }[] = []; + const client = { + api: async (method: string, params: Record = {}) => { + calls.push({ method, params }); + if (method === "saved.list") { + throw new Error("team_is_restricted"); + } + if (method === "search.messages") { + return { + messages: { + total: 2, + matches: [ + { + ts: "9.1", + text: "remember this", + user: "U1", + channel: { id: "C9", name: "later-channel" }, + }, + ], + }, + }; + } + throw new Error(`unexpected ${method}`); + }, + apiMultipart: async () => ({ ok: true }), + } as unknown as SlackApiClient; + + const result = await fetchLaterItems(client, { limit: 20 }); + + expect(calls[0]?.method).toBe("saved.list"); + expect(calls[1]?.method).toBe("search.messages"); + expect(calls[1]?.params.query).toBe("is:saved"); + expect(result.counts.total).toBe(2); + expect(result.counts.in_progress).toBe(2); + expect(result.items).toHaveLength(1); + expect(result.items[0]?.channel_id).toBe("C9"); + expect(result.items[0]?.channel_name).toBe("later-channel"); + expect(result.items[0]?.message?.content).toBe("remember this"); + }); + + test("counts-only fallback still returns a usable payload on team_is_restricted", async () => { + const client = { + api: async (method: string) => { + if (method === "saved.list") { + throw new Error("team_is_restricted"); + } + if (method === "search.messages") { + return { messages: { total: 4, matches: [] } }; + } + throw new Error(`unexpected ${method}`); + }, + apiMultipart: async () => ({ ok: true }), + } as unknown as SlackApiClient; + + const result = await fetchLaterItems(client, { countsOnly: true }); + expect(result.items).toHaveLength(0); + expect(result.counts.total).toBe(4); + }); }); describe("updateLaterMark", () => { diff --git a/test/unreads.test.ts b/test/unreads.test.ts new file mode 100644 index 0000000..cf9b1b8 --- /dev/null +++ b/test/unreads.test.ts @@ -0,0 +1,229 @@ +import { describe, expect, test } from "bun:test"; +import type { SlackApiClient } from "../src/slack/client.ts"; +import { isTeamRestrictedError } from "../src/slack/api-errors.ts"; +import { + UNREADS_FALLBACK_CHANNEL_LIMIT, + UNREADS_FALLBACK_DM_COUNT, + fetchUnreads, + isTsNewer, +} from "../src/slack/unreads.ts"; + +function createClient( + handler: ( + method: string, + params: Record, + ) => Record | Promise>, +) { + const calls: { method: string; params: Record }[] = []; + const client = { + api: async (method: string, params: Record = {}) => { + calls.push({ method, params }); + return handler(method, params); + }, + } as unknown as SlackApiClient; + return { client, calls }; +} + +describe("isTeamRestrictedError", () => { + test("matches a bare Error message", () => { + expect(isTeamRestrictedError(new Error("team_is_restricted"))).toBe(true); + }); + + test("matches @slack/web-api wrapping", () => { + const err = new Error("An API error occurred: team_is_restricted") as Error & { + data: { error: string }; + }; + err.data = { error: "team_is_restricted" }; + expect(isTeamRestrictedError(err)).toBe(true); + }); + + test("ignores unrelated errors", () => { + expect(isTeamRestrictedError(new Error("channel_not_found"))).toBe(false); + }); +}); + +describe("fetchUnreads", () => { + test("uses client.counts as the primary path", async () => { + const { client, calls } = createClient((method) => { + if (method === "client.counts") { + return { + channels: [ + { + id: "C1", + has_unreads: true, + unread_count_display: 2, + mention_count: 1, + last_read: "1.0", + }, + ], + mpims: [], + ims: [], + threads: { has_unreads: true, mention_count: 3 }, + }; + } + if (method === "conversations.info") { + return { channel: { id: "C1", name: "general", is_channel: true } }; + } + if (method === "conversations.history") { + return { + messages: [ + { ts: "1.1", user: "U1", text: "hi" }, + { ts: "1.2", user: "U2", text: "there" }, + ], + }; + } + throw new Error(`unexpected ${method}`); + }); + + const result = await fetchUnreads(client, { includeMessages: true }); + + expect(calls[0]?.method).toBe("client.counts"); + expect(calls.some((c) => c.method === "client.dms")).toBe(false); + expect(result.threads).toEqual({ has_unreads: true, mention_count: 3 }); + expect(result.channels).toHaveLength(1); + expect(result.channels[0]?.channel_name).toBe("general"); + expect(result.channels[0]?.unread_count).toBe(2); + expect(result.channels[0]?.messages).toHaveLength(2); + }); + + test("does not fall back on unrelated client.counts errors", async () => { + const { client } = createClient(() => { + throw new Error("fatal_error"); + }); + await expect(fetchUnreads(client)).rejects.toThrow("fatal_error"); + }); + + test("falls back when client.counts is team_is_restricted", async () => { + const { client, calls } = createClient((method, params) => { + if (method === "client.counts") { + throw new Error("team_is_restricted"); + } + if (method === "client.dms") { + expect(params.count).toBe(UNREADS_FALLBACK_DM_COUNT); + return { + ims: [{ id: "D1", message: { ts: "20.0", user: "U9", text: "ping" } }], + mpims: [{ id: "G1", message: { ts: "19.0", user: "U8", text: "group" } }], + }; + } + if (method === "users.conversations") { + expect(params.limit).toBe(UNREADS_FALLBACK_CHANNEL_LIMIT); + expect(params.types).toBe("public_channel,private_channel"); + return { + channels: [ + { id: "C1", name: "alerts", updated: 50 }, + { id: "C2", name: "old", updated: 10 }, + ], + }; + } + if (method === "conversations.info") { + const id = params.channel as string; + if (id === "D1") { + return { channel: { id, is_im: true, user: "U9", last_read: "10.0" } }; + } + if (id === "G1") { + return { channel: { id, is_mpim: true, last_read: "19.0" } }; + } + if (id === "C1") { + return { channel: { id, name: "alerts", is_channel: true, last_read: "1.0" } }; + } + return { channel: { id, name: "old", is_channel: true, last_read: "30.0" } }; + } + if (method === "users.info") { + return { user: { id: "U9", profile: { display_name: "Ada" } } }; + } + if (method === "conversations.history") { + const id = params.channel as string; + if (id === "D1") { + return { messages: [{ ts: "20.0", user: "U9", text: "ping" }] }; + } + if (id === "C1") { + return { messages: [{ ts: "5.0", user: "U2", text: "alert" }] }; + } + if (id === "C2") { + return { messages: [{ ts: "2.0", user: "U3", text: "stale" }] }; + } + return { messages: [] }; + } + throw new Error(`unexpected ${method}`); + }); + + const result = await fetchUnreads(client, { includeMessages: true }); + + expect(calls[0]?.method).toBe("client.counts"); + expect(calls.some((c) => c.method === "client.dms")).toBe(true); + expect(calls.some((c) => c.method === "users.conversations")).toBe(true); + expect(result.threads).toBeNull(); + expect(result.channels.map((c) => c.channel_id).sort()).toEqual(["C1", "D1"]); + const dm = result.channels.find((c) => c.channel_id === "D1"); + expect(dm?.channel_type).toBe("dm"); + expect(dm?.channel_name).toBe("Ada"); + expect(dm?.messages?.[0]?.content).toBe("ping"); + const readMpim = result.channels.find((c) => c.channel_id === "G1"); + expect(readMpim).toBeUndefined(); + }); + + test("counts-only fallback keeps the DM path bounded to client.dms plus info", async () => { + const { client, calls } = createClient((method) => { + if (method === "client.counts") { + throw Object.assign(new Error("An API error occurred: team_is_restricted"), { + data: { error: "team_is_restricted" }, + }); + } + if (method === "client.dms") { + return { + ims: [ + { id: "D1", message: { ts: "2.0", text: "new" } }, + { id: "D2", message: { ts: "1.0", text: "old" } }, + ], + mpims: [], + }; + } + if (method === "users.conversations") { + return { channels: [] }; + } + if (method === "conversations.info") { + return { channel: { id: "D1", is_im: true, last_read: "1.5" } }; + } + throw new Error(`unexpected ${method}`); + }); + + const result = await fetchUnreads(client, { includeMessages: false }); + + expect(calls.filter((c) => c.method === "client.dms")).toHaveLength(1); + expect(calls.filter((c) => c.method === "conversations.info")).toHaveLength(2); + expect(calls.some((c) => c.method === "conversations.history")).toBe(false); + expect(result.channels).toHaveLength(1); + expect(result.channels[0]?.channel_id).toBe("D1"); + expect(result.channels[0]?.messages).toBeUndefined(); + expect(result.channels[0]?.unread_count).toBe(1); + }); + + test("caps client.dms count so the IM list is not swept", async () => { + const { client, calls } = createClient((method) => { + if (method === "client.counts") { + throw new Error("team_is_restricted"); + } + if (method === "client.dms") { + return { ims: [], mpims: [] }; + } + if (method === "users.conversations") { + return { channels: [] }; + } + throw new Error(`unexpected ${method}`); + }); + + await fetchUnreads(client, { includeMessages: false }); + const dmsCall = calls.find((c) => c.method === "client.dms"); + expect(dmsCall?.params.count).toBe(UNREADS_FALLBACK_DM_COUNT); + expect(UNREADS_FALLBACK_DM_COUNT).toBeLessThanOrEqual(50); + }); +}); + +describe("isTsNewer", () => { + test("treats a missing last_read as unread when a latest ts exists", () => { + expect(isTsNewer("1.2", undefined)).toBe(true); + expect(isTsNewer("1.2", "0")).toBe(true); + expect(isTsNewer("1.2", "1.2")).toBe(false); + expect(isTsNewer("1.3", "1.2")).toBe(true); + }); +});