/** * Slack-Orchestrator Bridge * * Routes messages between Slack and the Crewly orchestrator, * enabling mobile control of AI teams. * * @module services/slack/bridge */ import { EventEmitter } from 'events'; import type { SourceMetadata } from '../../types/messaging.types.js'; import { SlackIncomingMessage, SlackNotification, SlackConversationContext, ParsedSlackCommand } from '../../types/slack.types.js'; import type { MessageQueueService } from '../messaging/message-queue.service.js'; import type { SlackThreadStoreService } from './slack-thread-store.service.js'; import { suppressTrivialOrShort, suppressFileOnly } from '../v3/ticket-intake.service.js'; import type { ThreadStatusQueueService } from '../messaging/thread-status-queue.service.js'; /** * Bridge configuration */ export interface SlackBridgeConfig { /** Orchestrator session name */ orchestratorSession: string; /** Enable typing indicators */ showTypingIndicator: boolean; /** Maximum response length before truncation */ maxResponseLength: number; /** Enable proactive notifications */ enableNotifications: boolean; /** Response timeout in ms */ responseTimeoutMs: number; /** Time to wait for reply-slack skill before fallback delivery (ms) */ skillDeliveryWaitMs: number; } export { suppressTrivialOrShort, suppressFileOnly }; /** * Derive a short, readable chat-v2 channel name from a Slack thread's first * message — so the consolidated chat shows a meaningful title rather than the * synthesized `slack--` id (which is identical across a channel's * threads). Collapses whitespace and truncates; falls back to "Slack thread". * * @param content - The first inbound message of the thread. * @returns A title like `Slack: ship the v2 launch deck`. */ export declare function deriveSlackThreadName(content: string | undefined): string; /** * SlackOrchestratorBridge class * * Routes messages between Slack and the Crewly orchestrator. * Handles command parsing, response formatting, and proactive notifications. * * @example * ```typescript * const bridge = getSlackOrchestratorBridge(); * await bridge.initialize(); * * // Send notification when task completes * await bridge.notifyTaskCompleted('Fix bug', 'Developer', 'MyProject'); * ``` */ /** * The Slack bot user ids a message `@`-mentions, in order. * * @param text - Raw Slack message text * @returns Distinct `Uxxxx` ids * @example * mentionedBotUserIds('<@U01> hi <@U02>') // ['U01', 'U02'] */ export declare function mentionedBotUserIds(text: string | undefined): string[]; /** * The acting-for part of a queued Slack message's source metadata (issue * #968): the human who sent this very message (`actingForUserId`; * `userId` is the thread starter), or — for a post an agent wrote — that * agent (`authorAgentSession`), so its bot user id is never taken for a person. * * @param context - Conversation context (`messageUserId` = this message's sender) * @param authorAgentSession - Set when an agent wrote the message * @returns Metadata fields to spread into `sourceMetadata` */ export declare function slackActingForMetadata(context: Pick | undefined | null, authorAgentSession: string | undefined): Pick; /** * Put the Slack context block in front of the text an agent is handed. * Only the delivered copy carries it; the persisted chat row does not, so * the fetched messages are never recorded locally. * * @param text - The message text * @param block - Rendered Slack context, or empty * @returns The text to deliver */ export declare function withContextBlock(text: string, block: string | undefined): string; export declare class SlackOrchestratorBridge extends EventEmitter { private logger; private slackService; private chatV2; private messageQueueService; private threadStatusQueue; private threadStore; private config; private initialized; /** Track if we've already logged the missing scope warning */ private loggedMissingScope; /** * Offered every inbound message before anything else (logging, file * download, thread store, routing). Returns true when it consumed the * message — used by the harness re-login so an owner's login code is * never logged, stored or forwarded to the orchestrator. */ private inboundInterceptor; /** * Further interceptors, offered the message after {@link inboundInterceptor} * (e.g. the owner's "skip all old cards" command in the orc DM). */ private extraInterceptors; /** * Pending completion reactions keyed by "channelId:threadTs". * Stores the original message ts so that the ✅ reaction is added to the * correct message when the reply-slack skill delivers a response. */ private pendingReactions; /** * Track channel+thread pairs where Slack delivery was already handled * by the reply-slack skill (execute.sh). Used by sendSlackResponse to * avoid sending a duplicate fallback message to the same thread. */ /** * Create a new SlackOrchestratorBridge * * @param config - Partial configuration to override defaults */ constructor(config?: Partial); /** * Initialize the bridge * * Sets up message listeners and orchestrator subscriptions. * * @returns Promise that resolves when initialized */ initialize(): Promise; /** * Check if bridge is initialized * * @returns True if initialized */ isInitialized(): boolean; /** * Set (or clear) the inbound interceptor. See {@link inboundInterceptor}. * * @param interceptor - Returns true when it consumed the message, or null to clear */ setInboundInterceptor(interceptor: ((message: SlackIncomingMessage) => boolean) | null): void; /** * Offer a message to the inbound interceptor. Never logs the text. * * @param message - Inbound Slack message * @returns True when the interceptor consumed it */ private interceptInbound; /** * Add an interceptor offered every inbound message after the primary one. * * @param name - What it is, for the log ("the skip-all command") * @param interceptor - Returns true when it consumed the message * @returns Remove function */ addInboundInterceptor(name: string, interceptor: (message: SlackIncomingMessage) => boolean): () => void; /** * Set the message queue service for enqueuing messages to the orchestrator. * Called during server initialization. * * @param service - The MessageQueueService instance */ setMessageQueueService(service: MessageQueueService): void; /** * Set the thread status queue service for tracking inbound message lifecycle. * Called during server initialization. * * @param service - The ThreadStatusQueueService instance */ setThreadStatusQueue(service: ThreadStatusQueueService): void; /** * Set the Slack thread store service for persisting thread conversations. * Called during server initialization. * * @param store - The SlackThreadStoreService instance */ setSlackThreadStore(store: SlackThreadStoreService): void; /** * Get current configuration * * @returns A copy of the current configuration */ getConfig(): SlackBridgeConfig; /** * Handle incoming Slack message * * Parses the command, routes to appropriate handler, and sends response. * * @param message - Incoming Slack message */ private handleSlackMessage; /** * Handle an incoming cross-machine message. * Routes the message payload to the local orchestrator via the message queue. * * @param msg - Parsed cross-machine message */ private handleCrossMachineMessage; /** * Parse command from message text * * Cleans the message and extracts the command intent. * * @param text - Raw message text * @returns Parsed command with intent and parameters */ parseCommand(text: string): ParsedSlackCommand; /** * Extract parameters from command text * * @param text - Command text * @returns Extracted parameters */ private extractParameters; /** * Get help message * * @returns Formatted help message for Slack */ getHelpMessage(): string; /** * Handle status command * * @param command - Parsed command * @param context - Conversation context * @returns Status response */ private handleStatusCommand; /** * Handle list commands (projects, teams, agents) * * @param command - Parsed command * @param context - Conversation context * @returns List response */ private handleListCommand; /** * Handle control commands (pause, resume) * * @param command - Parsed command * @param context - Conversation context * @returns Control response */ private handleControlCommand; /** * Attempt to auto-recover the orchestrator with a hard timeout. * * Calls `triggerOrchestratorSetup()` from the orchestrator service module * directly (bypassing HTTP). Wraps the attempt in a 5-second deadline so * a wedged setup cannot stall the Slack message ingress thread. * * Behavior: * - Timeout: rejects with a TimeoutError-like message after 5s. * - Setup error: rejects with the underlying error. * - Setup returning `success: false`: rejects with the recorded error * (the caller treats this the same as a thrown error and falls * through to the offline path). * * Caller is expected to wrap the call in try/catch and continue down * the offline path on rejection — auto-recovery is best-effort. * * @returns Resolves when setup completes successfully (or was skipped * because the orc was already healthy) * @throws Error on timeout or setup failure * @internal Visible for tests via class member access. */ private attemptAutoRecovery; /** * Decline a message to a dedicated agent from anyone but its person * (issue #968): post the polite pointer from the agent's own bot, in the * message's thread. * * @param message - Inbound message * @param agentSession - The addressed agent * @returns True when declined (the caller stops routing) */ private declineForDedicatedAgent; /** * Send message to orchestrator via the message queue and wait for response. * * Checks if the orchestrator is active before sending. Enqueues the message * and waits for the response via a resolve callback injected into sourceMetadata. * * @param message - Message to send * @param context - Optional conversation context * @param authorAgentSession - Set when a Crewly agent (not a human) wrote * the Slack message; persisted so the owner-approval gate ignores it * @param slackContextBlock - Slack thread context for the delivered copy * @param ticket - The message's ticket, if any. Its line is added to the * copy delivered to the agent only — the chat-v2 row keeps what the * person wrote. * @returns Orchestrator response or offline/error message */ private sendToOrchestrator; /** * Route a message to the Auditor agent as a fallback when the orchestrator is offline. * * Enqueues the message targeting the Auditor's session so the queue processor * delivers it to the Auditor PTY instead of the orchestrator. Includes Slack * context (channel, thread) so the Auditor can respond. * * **Thread file enrichment (parity with `sendToOrchestrator` ~line 712):** * When a `threadStore` and Slack `context` are available, the enriched * message also carries a `[Thread context file: ]` line. Without * this, the Auditor receives only the bare `[SLACK_CONTEXT:...]` prefix * and cannot read prior thread history, causing context-blind replies. * This is the bug Steve hit on 2026-04-25 (StevesPrompt deploy thread): * orchestrator dropped, Slack fell through here, Auditor responded with * "I cannot see the rest of the conversation" because the thread file * pointer was missing. Bringing the enrichment in line with the * orchestrator path closes the gap. * * @param message - Original user message * @param context - Slack conversation context * @param authorAgentSession - Set when an agent wrote the Slack message * @param ticket - The message's ticket, if any (delivered copy only) * @returns Acknowledgement message to the user */ private sendToAuditorFallback; /** * Read the Slack thread (or, for a top-level @-mention, the channel) * before this message and attach the pending read as `message.threadContext`. * * Cloud drops events written by the account's own bots, so a post by an * agent on another machine never reaches this one; without this, "look at * the items above" pointed at something the agent had never seen * (2026-09-28, #daily-info). Never throws; the promise never rejects. * * @param message - The inbound message (mutated: `threadContext`) */ private loadThreadContext; /** * Tokens that can read this message's conversation, best first: the app * that delivered this copy (in the channel by definition), then the same * order file downloads use (DM bot, @-mentioned bots, room agents, the * workspace bot). * * @param message - The inbound message * @returns Distinct tokens */ private contextTokenCandidates; /** * The Slack context block as the orchestrator should see it (its own * lines marked: its per-machine app when installed, else the workspace bot). * * @param message - The inbound message * @returns The rendered block, or '' */ private orchestratorContextBlock; /** * The token that can actually read this message's files. * * A file lives in the channel it was posted to, and Slack only serves it * to an app that is in that channel. When an agent's own Slack app is the * one that was invited — which is how a private channel usually works, * since the owner invites the agent they want, not the workspace bot — * the workspace token gets a flat 403 on the download while the event * itself arrives perfectly well through the agent's app. The result was a * message the agent could read but an image it could not * (2026-09-20, #steamfun-portal: "Download failed with status 403"). * * `agentSession` is only set for a DM to an agent's own bot. A channel * message has none, so it fell straight through to the workspace token — * and in a private channel the workspace bot is not a member, so * `files.info` answered `file_not_found` and the download took a 403. The * agent got the text with no sign that an image had been attached, and * answered about the previous topic (2026-09-21, #daily-info). An agent * the message @-mentions is in that channel by definition, so its token * can read the file. * * @param message - The inbound message * @returns Tokens to try, in order: the agent the DM was for, @-mentioned * agents, the agents in the channel's room, then the workspace token */ private fileTokenCandidates; /** * Pick the first token that can read these files (via `files.info`) and * refresh their URLs with it. * * @param message - The inbound message * @param files - Files to read (URLs mutated in place) * @returns The token to download with, or null when downloads can't proceed */ private resolveFileToken; /** * Refresh file download URLs via the Slack files.info API. * The event payload URLs may not work with just the bot token — the * files.info API returns authenticated URLs and validates scope. * * @param files - Slack file objects to refresh URLs for (mutated in place) * @param botToken - Token with access to these files; see {@link resolveFileToken} * @returns true if downloads can proceed, false if scope is missing */ private refreshFileUrls; /** * Download image files from a Slack message using SlackImageService. * Populates `message.images` with successfully downloaded image info. * Downloads are batched with a concurrency limit of MAX_CONCURRENT_DOWNLOADS. * * @param message - Incoming message to attach downloaded images to * @param imageFiles - Pre-filtered list of image files to download */ private downloadMessageImages; /** * Download non-image file attachments from a Slack message. * Uses authenticated fetch (same pattern as SlackImageService) to download * files to a temp directory. Populates `message.attachments` with results. * Skips magic-byte validation since these are generic files. * * @param message - Incoming message to populate with downloaded file info * @param nonImageFiles - Non-image SlackFile objects to download */ private downloadMessageFiles; /** * Fetch Slack files linked in a message's text, like attachments. * * Ella posted `longform-en.md` in #pro-crewly-marketing and handed it to * Rex on another machine by link; Rex's bot was not in that channel and * he asked her to paste the text (2026-10-08). The link is now fetched * here — local bots first, then Cloud, which holds every bot of the * account — so the hand-off carries the file. Best effort and capped: * file links only (message links are left to `slack-file get`), at most * {@link SLACK_FILE_FETCH_CONSTANTS.MAX_LINKED_PER_MESSAGE}, each under * {@link SLACK_FILE_FETCH_CONSTANTS.MAX_LINKED_BYTES}. Never throws. * * @param message - Inbound message; `linkedFiles` / `linkedFileFailures` are set on it */ fetchLinkedFiles(message: SlackIncomingMessage): Promise; /** * Enrich message text with file path references so agents * can read them via their file-reading tools (Claude Code Read, Gemini @file). * Includes both images and non-image file attachments. * * @param message - Message with optional downloaded images and files * @returns Enriched text with file references appended */ private enrichTextWithFiles; /** * Extract text content from a downloaded PDF file. * Returns undefined if extraction fails or produces no text. * * @param filePath - Absolute path to the downloaded PDF * @param fileName - Original file name (for logging) * @returns Extracted text (possibly truncated) or undefined */ extractPdfText(filePath: string, fileName: string): Promise; /** * Add typing indicator to message * * @param message - Message to add indicator to */ private addTypingIndicator; /** * Mark message as complete * * @param message - Message to mark complete */ private markComplete; /** * Add a completion reaction (✅) to a message that was previously * stored as a pending reaction. Consumes the pending reaction. * * @param channelId - Slack channel ID * @param threadTs - Thread timestamp (key for lookup) */ addCompletionReaction(channelId: string, threadTs: string): Promise; /** * Record response to thread store. Slack delivery is handled exclusively * by the reply-slack skill via the API — no terminal output fallback needed. * * The `fromOrcReply` flag gates the V3 SLA auto-resolve hook * (`markResolvedByThread`). Only real orc replies (delivered via the * orc's reply-slack skill → `slackResolve` callback) may auto-resolve * the SLA tracker. Placeholder / timeout / offline / fallback responses * must NOT trigger the cascade — doing so closes the parent Request to * `done` even though the orc never actually replied (Steve 2026-04-30 * incident: false-done Request, paired with Bug 1 silent-drop in * agent-registration.service.ts). * * Default `fromOrcReply = true` preserves the legacy behaviour for the * mention-routing call site (line ~327) which currently has no * placeholder semantics — its `sendToAgent` path delivers a direct * agent reply when reachable. If that path later grows a timeout * fallback, the call site should pass `false` explicitly. * * @param originalMessage - Original incoming message * @param response - Response content (real reply or placeholder) * @param fromOrcReply - `true` only when the response came from a * real agent/orc reply path. `false` for any timeout / fallback / * offline acknowledgement. Drives whether the SLA tracker is * auto-resolved for the matching Slack thread. */ private sendSlackResponse; /** * Send error response * * @param message - Original message * @param error - Error that occurred */ private sendErrorResponse; /** * Format response for Slack * * Converts markdown to Slack-compatible formatting. * * @param text - Text to format * @returns Formatted text */ formatForSlack(text: string): string; /** * Persist Slack reply content to the thread store when available. */ private recordThreadReply; /** * Send proactive notification to Slack * * @param notification - Notification to send * @returns True when it was delivered (false: notifications off, or no channel) */ sendNotification(notification: SlackNotification): Promise; /** * Send a content approval notification to Slack with Block Kit buttons. * * Posts a rich message with content preview and approve/reject buttons. * Links the Slack message back to the approval record so interactive * button clicks can resolve the approval. * * @param approvalId - The content approval ID * @param channelId - Slack channel ID * @param threadTs - Optional thread timestamp * @returns The Slack message timestamp, or null if failed */ sendContentApproval(approvalId: string, channelId?: string, threadTs?: string): Promise; /** * Notify about task completion * * @param taskTitle - Title of completed task * @param agentName - Name of agent that completed the task * @param projectName - Name of the project */ notifyTaskCompleted(taskTitle: string, agentName: string, projectName: string): Promise; /** * Notify about agent needing assistance * * @param agentName - Name of agent with question * @param question - The question from the agent * @param projectName - Name of the project */ notifyAgentQuestion(agentName: string, question: string, projectName: string): Promise; /** * Notify about errors * * @param errorMessage - Error message * @param agentName - Optional agent name * @param projectName - Optional project name */ notifyError(errorMessage: string, agentName?: string, projectName?: string): Promise; /** * Notify about daily summary * * @param summary - Summary content */ notifyDailySummary(summary: string): Promise; /** * Resolve @mention in message text to an agent session (#177). * * Matches patterns: @assistant, @auditor, @sam, @leo, etc. * Looks up the agent name in team configs to find the session name. * * @param text - Message text to scan for @mentions * @returns Target agent info, or null if no mention found */ private resolveMentionTarget; /** * Send a message to a specific agent session via terminal deliver (#177). * * @param sessionName - Target agent session * @param message - Message to send (what the person wrote; this is what * chat-v2 stores) * @param context - Slack context for response routing * @param authorAgentSession - Set when an agent wrote the Slack message * @param slackContextBlock - Slack thread context for the delivered copy * @param ticket - The message's ticket, if any. Its line is added to the * delivered copy only. * @returns OrcResponse envelope — `fromOrcReply` is `true` ONLY when the * reply came from the agent's `slackResolve` callback (a real reply * via the reply-slack skill). Timeout / direct-delivery / error * placeholders return `fromOrcReply: false` so callers can gate the * SLA cascade. Issue #394. */ private sendToAgent; /** * The owner's Slack message now waits for an answer from `responsible`: * hand it to the unanswered-owner-message watchdog * (specs/2026-09-30-owner-message-guarantee.md). Agent-authored messages * and other people's messages are not watched. Never throws. * * @param context - Slack ids of the message * @param authorAgentSession - Set when an agent wrote it * @param text - What the owner wrote * @param conversationId - chat-v2 conversation it was recorded in * @param responsible - Who must answer */ private watchOwnerMessage; /** * Persist a Slack inbound user message to chat-v2 and return the * `(conversationId, messageId)` pair callers thread downstream * into `MessageQueueService.enqueue` and `ThreadStatusQueueService.trackInbound`. * * Phase 6c of unified-chat-message-store spec: replaces the * legacy `chatService.sendMessage` call sites on this bridge. * Synthesizes a stable conversationId from Slack channel + thread * ts when the caller didn't supply one, so re-enqueue paths * (offline replay, recovery) reuse the same channel. * * @param args - Persistence inputs (content + slack context + recipient agent) * @returns The chat-v2 channelId and the freshly persisted messageId */ private persistSlackInbound; } /** * Get bridge singleton * * @returns SlackOrchestratorBridge instance */ export declare function getSlackOrchestratorBridge(): SlackOrchestratorBridge; /** * Reset bridge (for testing) */ export declare function resetSlackOrchestratorBridge(): void; /** * The per-message Slack ids the conversation log keeps for an inbound turn * (specs/unified-conversations-cloud-store.md §A.3 G5): the message ts, its * author and the workspace. Absent fields are left out. * * @param context - The bridge's copy of the conversation context, if any * @returns Fields to spread into `persistSlackInbound` */ export declare function persistIdsOf(context: SlackConversationContext | undefined): { messageTs?: string; slackUserId?: string; slackTeamId?: string; }; //# sourceMappingURL=slack-orchestrator-bridge.d.ts.map