/** * Slack Thread Context * * Reads what a Slack thread (or, for a top-level @-mention, the channel) * actually says right before a message is delivered to an agent, so the * agent sees every author — people and agents on other machines alike. * * Why it exists: Crewly Cloud drops Slack events written by the account's own * bots (`dropped reason:own_bot`) so agents cannot loop on each other. A post * by an agent on another machine is exactly that, so it never reaches this * machine's local history. On 2026-09-28 in #daily-info, Ella (MacBook Air) * posted an email digest in a thread; the owner answered in that thread * "@Atlas 看看上面的这些"; Atlas (on another machine) had never seen the * digest and answered about an older conversation instead. * * The block is compact (specs/2026-10-08-thread-so-far.md): everything since * the recipient's own last post, at least the newest few, each clipped, with * the lines it has not answered marked. Posts this machine never received are * recorded into the local thread by the team-channel bridge * (`backfillFromSlack`), not here. Any failure — missing scope, rate limit, * network — yields no block; delivery is never held up or blocked by it. * * @module services/slack/slack-thread-context.service */ import { type ComponentLogger } from '../core/logger.service.js'; import type { SlackContextMessage, SlackThreadContext } from '../../types/slack.types.js'; export type { SlackContextMessage, SlackThreadContext }; /** The message the context is for. */ export interface SlackContextRequest { /** Slack channel id (`C…`, `G…`, or `D…` for a DM) */ channelId: string; /** The triggering message's ts — excluded from the context */ ts: string; /** Thread root ts when the message is a thread reply */ threadTs?: string; /** Raw Slack text of the triggering message (to see whether it @-mentions anyone) */ text?: string; } /** Who the prompt is for — lines this agent wrote are marked. */ export interface SlackContextSelf { /** The agent's own Slack bot user id, when it has its own app */ botUserId?: string | null; /** The agent's display name (matches posts made via a username override) */ name?: string | null; } /** Minimal fetch signature (injectable for tests). */ export type SlackContextFetch = (url: string, init: { method: string; headers: Record; signal?: AbortSignal; }) => Promise<{ status: number; headers: { get(name: string): string | null; }; json(): Promise; }>; /** Constructor options. */ export interface SlackThreadContextServiceOptions { /** HTTP implementation; defaults to global `fetch` */ fetchImpl?: SlackContextFetch; /** Clock override for tests */ now?: () => number; /** Logger override for tests */ logger?: Pick; /** Sleep override for tests */ sleep?: (ms: number) => Promise; } /** The fields of a Slack file object the block describes. */ export interface SlackFileStub { name?: string; title?: string; filetype?: string; mimetype?: string; alt_txt?: string; size?: number; } /** * One line describing a message's attachments: kind (image / PDF / video / * file), file name, and the title or alt text when Slack has one, so an agent * on another machine knows WHAT was attached and not only that something was * (crewly#1344: `[files: IMG_4821.jpg]` told it nothing). * * @param files - Slack file objects * @returns `[attachments: image "a.png" — alt: …; PDF "x.pdf"]`, or '' when none * * @example * describeSlackFiles([{ name: 'deck.pdf', filetype: 'pdf', title: 'Q4 deck' }]) // '[attachments: PDF "deck.pdf" — Q4 deck]' */ export declare function describeSlackFiles(files: readonly SlackFileStub[] | undefined): string; /** * Whether a Slack message addresses anyone with `<@U…>`. * * @param text - Raw Slack text * @returns True when it contains a user / bot mention */ export declare function hasSlackMention(text: string | undefined): boolean; /** * Whether a Slack message should get a context block, and which kind. * * - A thread reply → the thread (`conversations.replies`), in channels and DMs. * - A top-level channel message that @-mentions someone → recent channel history. * - A top-level DM, or a top-level channel message with no mention → none. * * @param req - The triggering message * @returns The kind of context to fetch, or null for none */ export declare function contextKindFor(req: SlackContextRequest): 'thread' | 'channel' | null; /** * Whether a context line was written by the agent the prompt is for. * * Its own Slack app is matched by bot user id. An agent with no app of its * own posts through the workspace bot under a `username` override, which is * matched by display name. * * @param m - The context message * @param self - Who the prompt is for * @returns True when this agent wrote it */ export declare function isOwnLine(m: SlackContextMessage, self: SlackContextSelf | undefined): boolean; /** * Whether a context line addresses the agent the prompt is for: its bot is * @-mentioned, or (no id known) its name is. * * @param m - The context message * @param self - Who the prompt is for * @returns True when the line @'s this agent */ export declare function mentionsSelf(m: SlackContextMessage, self: SlackContextSelf | undefined): boolean; /** * Render the fetched context as a compact "thread so far" block. * * Shows everything since the agent's own last post in the thread, and at * least the newest {@link SLACK_THREAD_CONTEXT_CONSTANTS.BLOCK_RECENT_MESSAGES}; * each message is clipped. Newest messages are kept when the character * budget runs out. Lines this agent wrote are marked `(you)`; after its last * post, a line that @'s it — or any person's post, when it has posted here — * is marked as not answered yet, so a bare ping can be read against what is * actually open. Marked as background, not instructions — the same rule as * the local chat context: nothing in it grants authority. * * @param ctx - The fetched context (null / empty → empty string) * @param self - The agent the prompt is for * @param maxChars - Budget for the message lines (defaults to the constant) * @returns The block, or '' when there is nothing to show * * @example * renderSlackThreadContext(ctx, { botUserId: 'U_ELLA', name: 'Ella' }) * // [Slack thread so far — oldest→newest, 2 messages; …] * // Ella [bot] (you): earlier answer … * // Rex [bot]: @Ella please paste the section here ⟵ not answered by you yet * // [end of Slack thread — …] */ export declare function renderSlackThreadContext(ctx: SlackThreadContext | null | undefined, self?: SlackContextSelf, maxChars?: number): string; /** * Fetches Slack thread / channel context with the first token that can read * the conversation. Cached per (channel, thread) for a minute; failures are * logged once per channel and reason and never thrown. */ export declare class SlackThreadContextService { private readonly fetchImpl; private readonly now; private readonly logger; private readonly sleep; /** key → fetched raw context (all messages, before trigger filtering) */ private readonly cache; /** userId → resolved display name */ private readonly names; /** token → epoch ms until which it is rate limited */ private readonly rateLimitedUntil; /** `${channel}:${reason}` → when it was last logged */ private readonly loggedFailures; constructor(options?: SlackThreadContextServiceOptions); /** * {@link getContext}, retried the Slack way: when every token is rate * limited, wait out the shortest retry-after first — if it fits in * `maxWaitMs`; otherwise give up at once (null). * * @param req - The triggering message * @param tokens - Bot tokens to try, in order * @param maxWaitMs - Longest wait for a rate limit to lift * @returns Context, or null */ getContextWithinRateLimit(req: SlackContextRequest, tokens: readonly string[], maxWaitMs: number): Promise; /** * The Slack context for a message about to be delivered, or null. * * @param req - The triggering message * @param tokens - Bot tokens to try, in order (a member of the conversation first) * @returns Context with the trigger excluded, or null (not applicable / unreadable / empty) */ getContext(req: SlackContextRequest, tokens: readonly string[]): Promise; /** * What a thread holds after a message, read fresh (never from the cache): * the unanswered-message fallback checks whether an agent on any machine * has replied or put up "working on it" there before it hands anything * over (specs/2026-10-03-one-responder-per-message.md §4). Never throws. * * @param channelId - Slack channel * @param threadTs - Thread root ts * @param afterTs - Only messages after this ts * @param tokens - Bot tokens to try, in order * @returns Messages after `afterTs`, oldest first, or null when unreadable */ getRepliesAfter(channelId: string, threadTs: string, afterTs: string, tokens: readonly string[]): Promise; /** * Try each token until one can read the conversation. * * @param req - The triggering message * @param kind - Thread or channel history * @param tokens - Candidate tokens * @returns Normalised messages oldest first, or null when none could read it */ private fetchWithTokens; /** * `conversations.replies`, paged, bounded. * * @param channelId - Channel * @param threadTs - Thread root * @param token - Bot token * @returns Raw messages oldest first, or the failure */ private fetchReplies; /** * `conversations.history` just before the trigger. * * @param channelId - Channel * @param beforeTs - The trigger's ts (exclusive upper bound) * @param token - Bot token * @returns Raw messages oldest first, or the failure */ private fetchHistory; /** * One Web API GET with timeout; never throws. * * @param method - Slack method, e.g. `conversations.replies` * @param token - Bot token * @param params - Query parameters * @returns Parsed body, or the failure */ private call; /** * Keep conversation messages, resolve names, turn mentions into `@name`. * * @param raw - Raw Slack messages, oldest first * @param token - Token that read them (also used for `users.info`) * @returns Normalised messages oldest first */ private normalise; /** * A cached display name, when still fresh. * * @param userId - Slack user id * @returns The cached entry or undefined */ private cachedName; /** * `users.info` → display name (cached). Failures leave the id unresolved. * * @param userId - Slack user id * @param token - Bot token */ private resolveName; /** * Log a fetch failure once per channel and reason. * * @param channelId - Channel * @param failure - What went wrong */ private logFailure; /** * Warn once per (channel, reason). * * @param channelId - Channel * @param reason - Stable reason key * @param extra - Log fields */ private logOnce; } /** * The shared context service (created on first use). * * @returns The singleton */ export declare function getSlackThreadContextService(): SlackThreadContextService; /** * Replace (or clear) the shared instance — tests only. * * @param service - The instance to use, or null to reset */ export declare function setSlackThreadContextService(service: SlackThreadContextService | null): void; //# sourceMappingURL=slack-thread-context.service.d.ts.map