/** * Slack Service * * Manages Slack bot connection and messaging using Bolt SDK. * Enables bidirectional communication between Slack and Crewly. * * @module services/slack */ import { EventEmitter } from 'events'; import type { SlackConfig, SlackIncomingMessage, SlackOutgoingMessage, SlackServiceStatus, SlackConversationContext, SlackNotification, SlackBlock, SlackChannelInfo, SlackTransport, SlackRawInboundEvent, SlackRawAttachment, SlackInboundMeta, SlackCloudEventEnvelope, SlackRoomJoinRequest, SlackRoomJoinResult, SlackInteractionEvent } from '../../types/slack.types.js'; /** * Events emitted by SlackService */ export interface SlackServiceEvents { message: (message: SlackIncomingMessage) => void; connected: () => void; disconnected: (error?: Error) => void; error: (error: Error) => void; } /** * Options for uploading a file to Slack. * Used by both uploadImage() and uploadFile(). */ interface FileUploadOptions { channelId: string; /** The agent session this upload is on behalf of — only for log attribution. */ senderSession?: string; filePath: string; filename?: string; title?: string; initialComment?: string; threadTs?: string; /** * Upload as this agent's own bot rather than the workspace bot. * * Without it a file an agent sends arrives from the workspace app while * its words arrive from its own bot — the same reply split across two * identities in one thread. */ botToken?: string; /** * The agent's cosmetic identity, used when it has no bot of its own. An * image is then uploaded privately and posted in a message under this * name/icon (an image block), instead of arriving from the workspace app * as "Crewly". Any other file is uploaded privately and its permalink * posted under this name/icon in the same thread. */ persona?: { username: string; iconEmoji?: string; iconUrl?: string; }; } export interface SlackCloudEventSource { on(event: 'message', handler: (msg: SlackCloudRelayMessage) => void): unknown; off(event: 'message', handler: (msg: SlackCloudRelayMessage) => void): unknown; } /** Minimal relay message shape (see `cloud-sync.types.ts` IncomingMessage). */ export interface SlackCloudRelayMessage { id?: string; type: string; payload: unknown; fromDeviceName?: string; } /** * Resolve the top-level `text` for an outgoing `chat.postMessage`. * * Slack rejects an empty `text` when no blocks are sent (`no_text`), and * when blocks are sent it uses `text` for push notifications and screen * readers. So an empty or whitespace-only `text` is replaced by the first * block text (header/section) when there is one, else a generic fallback. * * @param message - The outgoing message * @returns A non-empty text value to send to Slack * * @example * ```typescript * resolveOutgoingText({ channelId: 'C1', text: '', blocks: [{ type: 'divider' }] }); // 'New message' * ``` */ export declare function resolveOutgoingText(message: Pick): string; /** * Whether an outgoing message carries anything to post: non-blank text, at * least one block, or at least one attachment. * * A message with none of these must not be posted at all. Slack would reject * it (`no_text`), and padding it with the generic fallback text would send the * recipient a meaningless "New message". * * @param message - The outgoing message * @returns True when there is content to post * * @example * ```typescript * hasOutgoingContent({ text: ' ' }); // false * hasOutgoingContent({ text: '', blocks: [{ type: 'divider' }] }); // true * ``` */ export declare function hasOutgoingContent(message: Pick): boolean; /** * The content of messages the user shared (forwarded) into a message, as * text an agent can read. Slack puts a shared message in `attachments` * (`is_share` / `is_msg_unfurl`), not in `text`, so without this the agent * sees only the user's comment ("我这边只收到这句话,没看到链接", Milo, * 2026-10-07). Plain link previews are left out: the link is already in * `text`. * * @param attachments - Raw event attachments * @returns One block per shared message, or '' when there is none */ export declare function sharedMessageText(attachments: SlackRawAttachment[] | undefined): string; /** * Pull the Slack error code and message out of an error thrown by the Slack * SDK (or anything else). * * Platform errors carry the code in `data.error` ("invalid_auth", * "token_revoked", …); request errors carry the network cause in * `original.code` ("ECONNREFUSED", "ETIMEDOUT"); other SDK errors carry a * `code` string. Anything else reports "unknown". * * @param error - The thrown value * @returns The Slack error code (or network code) and the message * * @example * ```typescript * describeSlackError(platformError); // { code: 'invalid_auth', message: 'An API error occurred: invalid_auth' } * ``` */ export declare function describeSlackError(error: unknown): { code: string; message: string; }; /** * SlackService class for managing Slack bot operations */ /** Payload of the SlackService `outbound` event. */ export interface SlackOutboundPost { channelId: string; threadTs?: string; /** Harness chrome (placeholder, note, hint) — not an answer */ notAnAnswer: boolean; kind: 'text' | 'file'; } /** * The file id from a `files.uploadV2` result. The helper answers * `{ files: [ ] }`, so the id sits at * `files[0].files[0].id`; a flat `files[0].id` is accepted too. Reading only * the flat shape returned nothing, so every image an agent without its own * bot attached fell back to the workspace app's name (2026-10-10). * * @param result - `files.uploadV2` result * @returns The first file id, if any */ export declare function uploadedFileId(result: unknown): string | undefined; /** * The permalink from a `files.uploadV2` result (same nesting as * {@link uploadedFileId}). * * @param result - `files.uploadV2` result * @returns The first file's permalink, if any */ export declare function uploadedFilePermalink(result: unknown): string | undefined; export declare class SlackService extends EventEmitter { private logger; private app; private client; private config; /** Bot user id from `auth.test`, cached by getBotUserId(). */ private cachedBotUserId; /** Lower-cased handle/display name → Slack user id, filled by findUserByHandle(). */ private userHandleCache; private status; private conversationContexts; /** Cached Bolt App constructor from the first successful dynamic import */ private cachedAppConstructor; /** Cached Bolt LogLevel enum from the first successful dynamic import */ private cachedLogLevelEnum; /** * Message deduplication tracker. * Maps a fingerprint (channelId:threadTs:textHash) to the timestamp it was sent. * Prevents identical messages from being sent to the same thread within a time window. */ private recentMessageFingerprints; /** Inbound cloud events already handled, keyed `channel:ts:type` (a message reaches us once per app that can see it). */ private seenInboundKeys; /** Whether an agent session runs on this instance (set by the initializer; agent-to-agent routing). */ isLocalAgent: ((agentSession: string) => boolean) | null; /** Handles a Cloud-pushed room-join request (wired to the team-channel service). */ roomJoinHandler: ((join: SlackRoomJoinRequest) => Promise | unknown) | null; /** What to do with a room-join result Cloud relayed back to this (asking) machine (crewly#1430) */ roomJoinResultHandler: ((result: SlackRoomJoinResult) => Promise | unknown) | null; /** Whether a Slack conversation belongs to an agent's own app (the master bot cannot post there). Set by the initializer. */ isAgentOwnedConversation: ((channelId: string) => boolean) | null; /** Slack user id of the person who installed the app (owner notifications go to their DM). Set by the initializer. */ getOwnerUserId: (() => string | null) | null; /** Whether a reconnection attempt is currently in progress */ private reconnecting; /** Timer handle for the reconnection grace period */ private reconnectGraceTimer; /** Timer handle for the periodic health check */ private healthCheckTimer; /** Current consecutive reconnection attempt count */ private reconnectAttempts; /** Whether the service has been intentionally shut down (skip auto-reconnect) */ private intentionalDisconnect; /** Timestamp of the last successful Slack API ping */ private lastPingAt; /** Number of consecutive health-check ping failures */ private consecutivePingFailures; /** Outstanding outbound reachability failures (null = last post succeeded). */ private deliveryFailures; /** Whether `status.degraded` was set by outbound reachability (cleared by a successful post). */ private degradedByDelivery; /** Inbound transport the service was initialised with */ private transport; /** Relay source the cloud transport is attached to (see attachCloudTransport) */ private cloudSource; /** Bound relay listener so it can be detached */ private cloudListener; /** * Startup gap (2026-10-01: two owner DMs to the orchestrator received 7 s * after boot were never answered — the relay acks a `slack_event` the * moment it emits it, whether or not anything here is listening). * * - Relay events that arrive before the cloud transport is attached are * kept ({@link captureCloudEventsBeforeAttach}) and replayed on attach. * - Inbound messages emitted while the bridge and team channels are still * coming up ({@link holdInbound}) are kept and emitted on * {@link releaseInbound}. */ private earlyCloudEvents; private earlyCloudListener; private inboundHeld; private heldInbound; /** * Initialize the Slack service with configuration * * With `config.transport === 'cloud'` no Socket Mode connection is opened: * Crewly Cloud owns the Slack app and pushes events through the relay (see * {@link attachCloudTransport}); only a Web API client is created for * outbound calls, and the service reports connected as soon as a bot * token is present. * * @param config - Slack bot configuration * @returns Promise that resolves when connected */ initialize(config: SlackConfig): Promise; /** * Resolve the `WebClient` constructor from `@slack/web-api`. * * `@slack/web-api` ships with Bolt; the dynamic import keeps the CJS/ESM * interop identical to the Bolt import in {@link initialize}. * * @returns The `WebClient` class */ private loadWebClientConstructor; /** * Verify the bot token with `auth.test` BEFORE a Bolt App is built on it. * * Why this exists: `@slack/bolt` 3.x (`tokenVerificationEnabled` defaults * to true) calls `client.auth.test()` eagerly inside the App constructor * (`singleAuthorization` → `runAuthTestForBotToken`) and parks that promise * with no rejection handler until the first inbound event is authorized. * With a dead token the promise rejects a few hundred ms later with no * Crewly frame on its stack — an unhandled rejection that the process-level * handler treats as fatal once signal handlers are armed. Verifying first * means the App is never constructed on a bad token, so the parked promise * never exists. On success the ids are handed to Bolt, which then skips its * own `auth.test`: net API calls are unchanged (one `auth.test` either way). * * ANY failure — `invalid_auth`, `account_inactive`, `token_revoked`, or a * network error reaching Slack — marks the integration degraded and rethrows * so the caller's existing catch (initialize / attemptReconnect) tears down * cleanly. Nothing escapes past `connectSlack`. * * @param botToken - The bot token to verify * @returns Bot identity in the shape Bolt's App constructor accepts; empty * when Slack omitted either id (Bolt then verifies on its own) * @throws The `auth.test` error, after the integration was marked degraded */ private preflightBotToken; /** * Record that Slack is offline because of a Slack-side failure, and log it * once at WARN. The backend keeps running; only Slack features are off. * * @param label - What was being attempted when Slack failed * @param error - The failure (Slack SDK error or anything thrown) */ private markDegraded; /** * Clear the degraded flag after a successful connect. A (re)connect may be * a different bot identity, so outbound reachability starts over too. */ private clearDegraded; /** * Track whether posts made with the connection's own bot token reach * their channel (#753). A socket can be perfectly healthy while every * reply fails with `channel_not_found`: that is what connecting the wrong * Slack app looks like (self-hosted and Crewly Cloud are different bot * users). After {@link SLACK_DELIVERY_HEALTH_CONSTANTS.FAILURES_BEFORE_DEGRADED} * consecutive unreachable posts the status reports `degraded` with the * reason; the next successful post clears it. * * @param channelId - Channel the post targeted * @param error - The failure, or null for a successful post */ recordDeliveryOutcome(channelId: string, error: unknown): void; /** * Cloud transport bootstrap: build a bare Web API client from the bot * token (no Bolt app, no socket, no reconnect loop). The bot user id from * the Cloud config seeds the cache so routing never needs `auth.test`. * * @param config - Config with `transport: 'cloud'` * @throws When the Web API client cannot be constructed */ private initializeCloudTransport; /** * The inbound transport this service was initialised with. * * @returns `socket` (Bolt Socket Mode) or `cloud` (relay push) */ getTransport(): SlackTransport; /** * Whether Block Kit button clicks reach this process. Only a socket-mode * Bolt app receives interactive payloads; on the Cloud transport Slack * sends them to Cloud, which does not relay them. * * @returns True in socket mode with a running app */ supportsInteractivity(): boolean; /** * The config the service was last initialised with (a copy). * * @returns The config, or null before the first initialise */ getConfig(): SlackConfig | null; /** * Shared inbound handler for both transports. Turns a raw Slack `message` * or `app_mention` event into a {@link SlackIncomingMessage}, applies the * allow-list, bumps counters and emits `message` — exactly what the Socket * Mode listeners did before the split, so a Cloud-pushed event routes the * same way as one received over the socket. * * @param event - Raw Slack event object * @param meta - Which transport delivered it (defaults to `socket`) * @returns The emitted message, or null when the event was dropped */ handleInboundEvent(event: SlackRawInboundEvent, meta?: SlackInboundMeta): SlackIncomingMessage | null; /** * Hold inbound messages (not emit them) until {@link releaseInbound}: * used while Slack connects and the bridge / team channels subscribe. */ holdInbound(): void; /** * Emit every message held since {@link holdInbound}, in arrival order, and * stop holding. * * @returns How many were released */ releaseInbound(): number; /** * Keep relay `slack_event` messages that arrive while no cloud transport * is attached (Cloud signed in before Slack connected), and replay them * when {@link attachCloudTransport} runs. Idempotent per source. * * @param source - Emitter of relay `message` events */ captureCloudEventsBeforeAttach(source: SlackCloudEventSource): void; /** * Cloud transport entry point: unwrap a `slack_event` envelope and hand * the raw event to {@link handleInboundEvent}. Reproduces what Bolt does * for free on the socket path — its `ignoreSelf` middleware and the * subtype filtering — so the master bot never reacts to its own posts and * edits/joins/bot chatter never reach routing. * * @param envelope - The relay message `data` * @returns The emitted message, or null when dropped */ handleCloudEnvelope(envelope: SlackCloudEventEnvelope): SlackIncomingMessage | null; /** * Hand a Slack interactive payload to listeners (decision cards). The * HTTP endpoint `/api/slack/interactivity` calls this too. * * @param event - Payload and transport */ emitInteraction(event: SlackInteractionEvent): void; /** * Hand a `reaction_added` event to listeners (decision cards). Copies of * one reaction (master app + agent apps) are emitted once. * * @param event - Raw event * @param source - Transport */ private emitReaction; /** * Subscribe to a relay source (CloudSyncService) and route every * `slack_event` message into {@link handleCloudEnvelope}. Idempotent — * re-attaching replaces the previous subscription. * * @param source - Emitter of relay `message` events */ attachCloudTransport(source: SlackCloudEventSource): void; /** Undo {@link attachCloudTransport}. */ detachCloudTransport(): void; /** * Set up Slack event handlers */ private setupEventHandlers; /** * Attach listeners to the underlying SocketModeClient to track * connection lifecycle (disconnected, reconnecting, connected). * Updates `status.connected` so health checks reflect actual state. * * The Bolt App stores the receiver at `app.receiver`, and the * SocketModeReceiver exposes `client` (SocketModeClient), which * is an EventEmitter emitting: connected, reconnecting, disconnected, close. */ private setupConnectionMonitoring; /** * Start a periodic health check that verifies the Socket Mode connection * is alive and triggers reconnection if it has silently died. * * Uses an active Slack API ping (auth.test) to detect half-open sockets * that appear connected but are actually dead after WiFi/network drops. */ private startHealthCheck; /** * Stop the periodic health check timer. */ private stopHealthCheck; /** * Schedule a reconnection attempt after a grace period. * The grace period gives Bolt's built-in reconnect a chance to recover first. * If the connection comes back within the grace period, the reconnect is cancelled. */ private scheduleReconnect; /** * Cancel the reconnection grace period timer. */ private cancelReconnectGrace; /** * Attempt to reconnect by tearing down the current Bolt app and * re-initializing from scratch with the saved config. * Uses exponential backoff between attempts. */ private attemptReconnect; /** * Classify whether a reconnection error is fatal (non-recoverable) * or transient (may succeed on retry). * * Fatal errors: missing constructor, invalid config, auth errors. * Transient errors: network timeouts, temporary server errors. * * @param error - The error caught during reconnection * @returns True if the error is fatal and retrying would be futile */ private isFatalReconnectError; /** * Send a message to Slack with content-based deduplication. * * Generates a fingerprint from (channelId, threadTs, text) and suppresses * duplicate sends within a configurable time window (default 30s). * This is the single chokepoint for all Slack message sends — skill delivery, * bridge fallback, and direct API calls all converge here. * * @param message - Message to send * @returns Promise with message timestamp (empty string if deduplicated) */ /** * Post a message only the named user can see. * * An authorization card must not be visible to the rest of a channel: its * button is an ordinary link, so whoever opens it connects *their* Google * account into the owner's Crewly account. Slack cannot tell us who * clicked, so the defence is that nobody else is shown the card * (2026-09-21). * * @param channelId - Channel or DM to post into * @param userId - The only Slack user who will see it * @param text - Fallback text for notifications and screen readers * @param blocks - Block Kit payload * @param botToken - Post as this agent's bot instead of the workspace bot * @param threadTs - Show it inside this thread instead of the channel's main view * @returns True when Slack accepted it */ sendEphemeral(channelId: string, userId: string, text: string, blocks?: unknown[], botToken?: string, threadTs?: string): Promise; sendMessage(message: SlackOutgoingMessage): Promise; /** * Mirror an outbound Slack reply into the canonical chat-v2 store so the * Slack thread shows both inbound (user) and outbound (agent) messages in * the consolidated /team-chat surface. Records against the SAME channel the * inbound persisted to — `synthesizeSlackConversationId(channelId, threadTs)`. * * Best-effort: dynamic imports + try/catch so a chat-v2 failure never * affects Slack delivery. Only called for threaded replies (threadTs set). * * @param message - The outbound Slack message that was just sent. */ private recordOutboundToChatV2; /** * Build a deduplication fingerprint for a Slack message. * Combines channelId, threadTs, and a simple hash of the message text. * * @param message - Outgoing Slack message * @returns Fingerprint string */ private buildMessageFingerprint; /** * Track a message fingerprint and evict stale entries. * * @param fingerprint - Message fingerprint to track * @param timestamp - Current timestamp in ms */ private trackMessageFingerprint; /** * Send a notification to the default channel * * Resolves whether it was delivered: false when there is no channel to * send it to (no default channel, not connected, no reachable fallback). * A Slack error on an explicit channel still throws. A message the * dedup window suppressed counts as delivered (the identical text went out * moments ago). * * @param notification - Notification to send * @returns True when Slack accepted it */ sendNotification(notification: SlackNotification): Promise; /** * Format notification as Slack blocks * * @param notification - Notification to format * @returns Slack blocks for the notification */ private formatNotificationBlocks; /** * Update a message * * @param channelId - Channel ID * @param messageTs - Message timestamp * @param text - New text * @param blocks - Optional new blocks * @param botToken - Optional per-agent bot token (edit a message that bot posted) */ updateMessage(channelId: string, messageTs: string, text: string, blocks?: SlackBlock[], botToken?: string): Promise; /** * Delete a message. * * @param channelId - Channel ID * @param messageTs - Message timestamp * @param botToken - Per-agent bot token when that bot posted it (a bot may delete its own messages) */ deleteMessage(channelId: string, messageTs: string, botToken?: string): Promise; /** * Add a reaction to a message * * @param channelId - Channel ID * @param messageTs - Message timestamp * @param emoji - Emoji name (without colons) * @param botToken - Optional per-agent bot token (react in that bot's own DMs) */ addReaction(channelId: string, messageTs: string, emoji: string, botToken?: string): Promise; /** * Remove a reaction this bot added to a message. * * @param channelId - Channel ID * @param messageTs - Message timestamp * @param emoji - Emoji name (without colons) * @param botToken - Optional per-agent bot token (the bot that reacted) */ removeReaction(channelId: string, messageTs: string, emoji: string, botToken?: string): Promise; /** * Get or create conversation context for a thread * * @param threadTs - Thread timestamp * @param channelId - Channel ID * @param userId - User ID * @returns Conversation context */ getConversationContext(threadTs: string, channelId: string, userId: string): SlackConversationContext; /** * Get service status * * @returns Current service status */ getStatus(): SlackServiceStatus; /** * Check if service is connected * * @returns True if connected */ isConnected(): boolean; /** * Get the bot token used to initialize the Slack client. * Required for downloading private files from Slack. * * @returns The bot token string, or null if not initialized */ getBotToken(): string | null; /** * The bot user's Slack id when already known (no API call). * * @returns The cached bot user id, or null */ getCachedBotUserId(): string | null; /** * Resolve the bot user's Slack id (`U…`) via `auth.test`, cached after the * first call. Team-channel routing uses it to ignore `<@bot>` mentions in * inbound text and to invite the bot into linked channels. * * @returns The bot user id, or null when not connected or the API omits it */ getBotUserId(): Promise; /** * Create a public Slack channel. When Slack reports `name_taken`, the * existing channel of that name is looked up and returned instead, so a * team whose channel already exists (e.g. after a re-install) links to * it rather than failing. * * Requires the `channels:manage` scope (and `channels:read` for the * name-taken fallback). * * @param name - Channel name without `#`, already sanitised by the caller * @returns The created (or pre-existing) channel * @throws Error when the client is not initialised or Slack refuses */ createChannel(name: string): Promise; /** * Rename a channel. * * Used to keep an auto-created team channel in step with its Crewly team: * renaming the team used to leave the channel on its old name forever * (2026-09-20). * * Requires the `channels:manage` scope. * * @param channelId - The channel to rename * @param name - New name without `#`, already sanitised by the caller * @returns The channel's name after the call, or null when Slack refused * for a reason worth living with — the name is already taken, or the bot * is not allowed to rename this channel. A failed rename must never fail * the team update that triggered it. */ renameChannel(channelId: string, name: string): Promise; /** * Find a public or private channel by exact name (case-insensitive), * paging through `conversations.list`. * * @param name - Channel name without `#` * @returns The channel, or null when no channel has that name */ findChannelByName(name: string): Promise; /** * Fetch a channel by id. * * @param channelId - Slack channel id * @returns The channel, or null when Slack reports `channel_not_found` */ getChannelInfo(channelId: string): Promise; /** * Join a public channel as the bot (idempotent). Requires `channels:join`. * * @param channelId - Slack channel id */ joinChannel(channelId: string): Promise; /** * Invite users (or other bots) into a channel the bot is a member of. * * @param channelId - Slack channel id * @param userIds - Slack user ids to invite */ inviteToChannel(channelId: string, userIds: string[]): Promise; /** * Remove a user (an agent's bot) from a channel the workspace bot is in. * Requires `channels:manage` / `groups:write`. Throws Slack's error * (`not_in_channel` = the user is not there; `channel_not_found` = the * workspace bot cannot see the channel). * * @param channelId - Slack channel id * @param userId - Slack user id to remove */ kickFromChannel(channelId: string, userId: string): Promise; /** * Set a channel's purpose line. Requires `channels:manage`. * * @param channelId - Slack channel id * @param purpose - Purpose text (Slack caps it at 250 chars) */ setChannelPurpose(channelId: string, purpose: string): Promise; /** * Archive a channel. `already_archived` is treated as success so the * team-deleted path is idempotent. * * @param channelId - Slack channel id */ archiveChannel(channelId: string): Promise; /** * Open (or reuse) the DM channel with a person and return its id. * * Requires `im:write` on the token used. Pass `botToken` to open the DM as * an agent's own bot user — that is a different conversation from the one * the default Crewly bot has with the same person. * * @param userId - Slack user id of the person (`U…` / `W…`) * @param botToken - Optional per-call token override * @returns The DM channel id (`D…`) * @throws Error when the client is not initialised or Slack refuses */ openDirectMessage(userId: string, botToken?: string): Promise; /** * Resolve an `@handle` (or display / real name) to a Slack user id by * scanning the directory. Results are cached for the process lifetime — * handles change rarely and the listing is expensive. * * Bots and deactivated accounts are skipped so `@sam` never resolves to a * bot that happens to share the name. * * @param handle - Handle without `@`, case-insensitive * @returns The user id, or null when nobody matches * @throws Error when the client is not initialised or lacks `users:read` */ findUserByHandle(handle: string): Promise; /** * The conversations API of the live client, or throw when Slack is not * initialised. Centralised so every channel helper reports the same * error text. */ private requireConversationsApi; /** * Extract Slack's machine-readable error code (`name_taken`, * `channel_not_found`, …) from a thrown Web API error. * * @param error - The caught value * @returns The code, or null when the error carries none */ static slackErrorCode(error: unknown): string | null; /** * Normalise a raw conversations-API channel object. * * @param raw - Slack's channel object * @returns The trimmed descriptor, or null when the id is missing */ private static toChannelInfo; /** * Upload an image file to a Slack channel. * * Delegates to the shared upload-with-retry logic. * Kept as a separate public method for backward compatibility * with callers that use the image-specific endpoint. * * @param options - Upload configuration * @returns Object with the uploaded file ID * @throws Error if the client is not initialized or upload fails */ uploadImage(options: FileUploadOptions): Promise<{ fileId?: string; }>; /** * Upload any supported file to a Slack channel. * * Delegates to the shared upload-with-retry logic. * Works for PDFs, images, documents, and other file types supported by Slack. * * @param options - Upload configuration * @returns Object with the uploaded file ID * @throws Error if the client is not initialized or upload fails */ uploadFile(options: FileUploadOptions): Promise<{ fileId?: string; }>; /** * Upload a (non-image) file privately, then post its permalink in a * message under the agent's name/icon. Slack shows the file in that * message and shares it with the channel. Throws on any failure; the * caller falls back to the plain upload. * * @param options - Upload options with `persona` * @returns The uploaded file id */ private uploadFileAsPersona; /** * Upload an image privately, then post it in a message (image block) * under the agent's name/icon. Throws on any failure; the caller falls * back to the plain upload. * * @param options - Upload options with `persona` * @returns The uploaded file id */ private uploadImageAsPersona; /** * Tell listeners a message or file was posted (`outbound` event). The * unanswered-owner-message watchdog takes a post in an owner's thread as * the answer unless it is flagged `notAnAnswer`. Never throws. * * @param post - Where it went and whether it counts as an answer */ private emitOutbound; /** * Shared upload logic with retry/backoff for Slack rate limits. * * Reads the file from disk and uploads via files.uploadV2. * Retries on 429 responses with exponential backoff capped at 60s. * * @param options - Upload configuration (channel, path, filename, etc.) * @param constants - Constants object providing UPLOAD_MAX_RETRIES and UPLOAD_DEFAULT_BACKOFF_MS * @returns Object with the uploaded file ID * @throws Error if the client is not initialized, retries exhausted, or a non-rate-limit error occurs */ private uploadWithRetry; /** * Check if an error is a Slack 429 rate limit error. * * @param error - The caught error * @returns True if the error represents a 429 rate limit response */ private isRateLimitError; /** * Extract the retry-after delay from a Slack rate limit error. * * @param error - The caught error * @returns Retry delay in milliseconds, or null if not found */ private extractRetryAfterMs; /** * Disconnect from Slack */ disconnect(): Promise; /** * Get file info from Slack via the files.info API. * Requires the bot token to have `files:read` scope. * * @param fileId - Slack file ID (e.g., F0123ABC456) * @returns Object with private download URLs * @throws Error if the client is not initialized or API call fails */ getFileInfo(fileId: string, botToken?: string): Promise<{ url_private: string; url_private_download: string; }>; /** * Get user info from Slack * * @param userId - Slack user ID * @returns User info object */ /** * Member user ids of a channel, bots included (`conversations.members`, * paginated). * * @param channelId - Slack channel id * @returns User ids; empty when the bot is not in the channel */ listChannelMembers(channelId: string): Promise; /** * A user's display name and whether it is a bot (`users.info`). * * @param userId - Slack user id * @returns Name + bot flag, or null when Slack does not know the id */ getUserBasic(userId: string): Promise<{ name: string; isBot: boolean; } | null>; getUserInfo(userId: string): Promise<{ name: string; realName: string; email?: string; }>; } /** * Get the SlackService singleton instance * * @returns SlackService instance */ export declare function getSlackService(): SlackService; /** * Reset the SlackService instance (for testing) */ export declare function resetSlackService(): void; export {}; //# sourceMappingURL=slack.service.d.ts.map