/** * Ticket loop — per-channel glue (specs/ticket-loop.md, Phase 1 §2). * * The intake service is channel-agnostic; this module holds what the channel * call sites share: * * - conversation / thread refs (`slack::`, `chatv2::`) * so a follow-up finds its thread's ticket and a top-level "不用记" finds * the latest ticket of its conversation; * - the owner test for chat-v2 rows (PR #786's authorship markers); * - the line added to a delivered message (`[TICKET:TKT-123 ] …`); * - a bounded wait on intake, so a slow disk never holds a message back; * - the two receipt sinks (Slack thread reply, chat-v2 system row). * * @module services/v3/ticket-channel-hooks */ import type { Request } from '../../types/v2/request.types.js'; import type { SlackBlock } from '../../types/slack.types.js'; import type { ChatChannelDTO, ChatMessageDTO } from '../chat-v2/types.js'; import type { TicketOriginChannel } from '../../types/v2/ticket.types.js'; import type { IntakeMessage, IntakeOutcome, TicketIntakeService, TicketReceiptSink } from './ticket-intake.service.js'; /** * Conversation ref of a Slack channel or DM. * * @param slackChannelId - Slack conversation id * @returns `slack:` */ export declare function slackConversationRef(slackChannelId: string): string; /** * Thread ref of a Slack thread (a top-level message is its own thread root). * * @param slackChannelId - Slack conversation id * @param threadRootTs - Thread root ts * @returns `slack::` */ export declare function slackThreadRef(slackChannelId: string, threadRootTs: string): string; /** * Conversation ref of a chat-v2 channel. * * @param chatChannelId - chat-v2 channel id * @returns `chatv2:` */ export declare function chatV2ConversationRef(chatChannelId: string): string; /** * Thread ref of a chat-v2 thread (a root message is its own thread). * * @param chatChannelId - chat-v2 channel id * @param rootMessageId - Thread root message id * @returns `chatv2::` */ export declare function chatV2ThreadRef(chatChannelId: string, rootMessageId: string): string; /** * Whether a chat-v2 row carries the owner's own words. * * Mirrors the commitment gate's read (PR #786): a `user` row counts only when * no agent authorship marker is set and its source is not an agent-reply * source. A colleague agent's Slack post and an agent calling a user-turn API * are stored as `user` rows but carry the marker. * * @param message - Persisted chat-v2 message * @returns True for the owner's message */ export declare function isOwnerChatMessage(message: Pick): boolean; /** * The ticket a delivered message belongs to, from an intake outcome. * * @param outcome - Intake outcome (or null when intake did not run) * @returns The ticket, or null */ export declare function ticketOfOutcome(outcome: IntakeOutcome | null): Request | null; /** * The line added to a message delivered to an agent: the machine-readable * marker plus how to link work to it. * * @param ticket - The ticket (null → '') * @returns The line, or '' when there is no numbered ticket */ export declare function ticketDeliveryLine(ticket: Pick | null): string; /** * Append the ticket line to a text delivered through the message queue. * * @param text - Text to deliver * @param ticket - Its ticket, if any * @returns The text, with the ticket line appended when there is one */ export declare function appendTicketLine(text: string, ticket: Pick | null): string; /** * A copy of a chat-v2 message carrying the ticket line in its metadata, for * the dispatcher to render into the agent's prompt. The persisted row is not * changed. * * @param message - Persisted message * @param ticket - Its ticket, if any * @returns The same message when there is no ticket, else a copy */ export declare function withTicketMarker(message: ChatMessageDTO, ticket: Pick | null): ChatMessageDTO; /** * {@link withTicketMarker}, and remember that this chat-v2 turn opened the * ticket, so the agent's answer in the same thread can be matched to it * (Phase 2 review). The link is fire-and-forget; delivery never waits on it. * * @param message - The persisted owner message about to be dispatched * @param ticket - The ticket it belongs to, or null * @returns The message to dispatch (see {@link withTicketMarker}) */ export declare function markAndLinkTicket(message: ChatMessageDTO, ticket: Pick | null): ChatMessageDTO; /** * The ticket line a dispatcher should render for a message (see {@link withTicketMarker}). * * @param message - Message about to be dispatched * @returns The line, or undefined */ export declare function ticketLineOf(message: Pick): string | undefined; /** * Run intake, but give up waiting after {@link TICKET_CONSTANTS.INTAKE_TIMEOUT_MS}. * Intake keeps running in the background (the ticket and its receipt still * appear); only the delivered copy goes out without the marker. * * @param intake - The intake service, or null when not wired * @param message - The message * @param timeoutMs - Override for tests * @param onLate - Told the outcome when intake finishes only after the * timeout (the caller links the late ticket to the copy it delivered) * @returns The outcome, or null when intake is not wired or timed out */ export declare function intakeWithin(intake: Pick | null, message: IntakeMessage, timeoutMs?: number, onLate?: (outcome: IntakeOutcome) => void): Promise; /** * Link a ticket intake filed only after the message was delivered without * its marker to the delivered copy, so the ticket's review sees that turn * (crewly#1015 follow-up L2). * * @param outcome - The late intake outcome * @param delivered - The chat-v2 row that was dispatched * @returns The ticket linked, or null */ export declare function linkLateTicket(outcome: IntakeOutcome, delivered: ChatMessageDTO): Request | null; /** * Receipt text for a new ticket. Just the fact — no "不用记?" question * (owner, 2026-09-24: asking every time is noise); 「不用记」 still works. * * @param ticket - The ticket * @returns `Recorded as TKT-123` */ export declare function receiptText(ticket: Pick): string; /** * Receipt text after "不用记". * * @param ticket - The ticket * @returns `TKT-123 is no longer tracked` */ export declare function dismissedReceiptText(ticket: Pick): string; /** * Receipt text once the ticket is accepted. * * @param ticket - The ticket * @returns `TKT-123 is done` */ export declare function doneReceiptText(ticket: Pick): string; /** The slice of SlackService the Slack receipt sink uses. */ export interface ReceiptSlackApi { addReaction?(channelId: string, messageTs: string, emoji: string, botToken?: string): Promise; removeReaction?(channelId: string, messageTs: string, emoji: string, botToken?: string): Promise; updateMessage(channelId: string, messageTs: string, text: string, blocks?: SlackBlock[], botToken?: string): Promise; } /** Slack receipt sink dependencies. */ export interface SlackReceiptSinkDeps { slack: ReceiptSlackApi; /** Bot token of an agent's own Slack app, when it has one */ botTokenFor?: (agentSession: string) => string | undefined; } /** * Receipts as a reply in the Slack thread the owner wrote in, through the same * SlackService the channel already posts with. The dismiss button is added * only when button clicks reach this process (socket mode); on the Cloud * transport they do not, and the receipt tells the owner to reply 「不用记」. * * @param deps - Slack API and bot-token lookup * @returns The sink */ export declare function createSlackReceiptSink(deps: SlackReceiptSinkDeps): TicketReceiptSink; /** The slice of ChatV2Service the chat-v2 receipt sink uses. */ export interface ReceiptChatApi { recordTurn(input: { channelId: string; senderType: 'system'; senderId: string; content: string; contentType?: 'system_note'; threadId?: string; metadata: { source: 'system'; [key: string]: unknown; }; }): { message: ChatMessageDTO; }; updateSystemMessage(messageId: string, content: string, metadataPatch?: Record): ChatMessageDTO | null; } /** chat-v2 receipt sink dependencies. */ export interface ChatV2ReceiptSinkDeps { chat: ReceiptChatApi; /** Push a row to live views (the chat-v2 WebSocket gateway), when wired */ broadcast?: (message: ChatMessageDTO) => void; } /** * Receipts as a system row under the owner's chat-v2 message. The row's * metadata names the ticket and the dismiss endpoint, so a client can render * a button; replying 「不用记」 works everywhere. * * @param deps - chat-v2 API and optional broadcaster * @returns The sink */ export declare function createChatV2ReceiptSink(deps: ChatV2ReceiptSinkDeps): TicketReceiptSink; /** * Canonical chat-v2 sourceConversationItemId the SLA subscriber parses * (`chatv2-__`). `__` because channel and message ids are * UUIDs with dashes in them. * * @param channelId - chat-v2 channel id * @param messageId - chat-v2 message id * @returns The source id */ export declare function buildChatV2SourceId(channelId: string, messageId: string): string; /** * Source id for a chat-v2 ticket NOT routed to the orchestrator. A different * prefix on purpose: the SLA subscriber closes any open Request whose source * starts with `chatv2-` as soon as an agent replies in that channel, which is * right for the orc's respond-to-user tracking and wrong for a ticket. * * @param channelId - chat-v2 channel id * @param messageId - chat-v2 message id * @returns `chatv2t-__` */ export declare function buildChatV2TicketSourceId(channelId: string, messageId: string): string; /** * Whether a chat-v2 channel is the owner's DM with the orchestrator — the * only chat-v2 channel whose tickets keep the SLA (`chat-v2` tag). * * @param channel - The channel * @returns True for the orc DM */ export declare function isOrchestratorRoutedChatV2Channel(channel: Pick): boolean; /** * Build the intake message for an owner's chat-v2 message. * * @param channel - Its channel * @param message - The persisted message * @param originChannel - `chat` (dashboard), `portal` (relay) or `mobile` * @returns The intake message, or null when it is not the owner's */ export declare function chatV2IntakeMessage(channel: ChatChannelDTO, message: ChatMessageDTO, originChannel: Extract): IntakeMessage | null; /** * Ticket intake for an owner's chat-v2 message (specs/ticket-loop.md §2), * shared by the REST controller and the portal relay adapter. Waits at most * `TICKET_CONSTANTS.INTAKE_TIMEOUT_MS`; never throws. * * @param intake - The intake service (null → no-op) * @param channel - The message's channel * @param message - The persisted message * @param origin - `chat`, `portal` or `mobile` * @returns The message to dispatch — a copy carrying the ticket line when it * belongs to a ticket, otherwise the message itself */ export declare function intakeChatV2OwnerMessage(intake: Pick | null, channel: ChatChannelDTO, message: ChatMessageDTO, origin: Extract): Promise; /** What the Slack call sites know about an inbound message. */ export interface SlackIntakeInput { text: string; slackChannelId: string; ts: string; threadTs?: string; userId: string; userName?: string; /** Set when a bot (one of the account's agents) wrote it */ authorAgentSession?: string; hasFiles?: boolean; /** The thread's root message is the owner's own (see IntakeMessage.threadRootIsOwner) */ threadRootIsOwner?: boolean; /** * Slack user id of the owner (the Cloud app's installer), when known. Then * only that user's messages file tickets; other people in the workspace do * not (Phase 1). Unknown (self-hosted socket mode) = any human. */ ownerUserId?: string | null; } /** * Build the intake message for a Slack message. * * The source id prefix is per surface: the legacy bridge keeps `slack-…` * (the SLA subscriber and status updater key on it), team channels use * `slackch-…` and agent DMs `slackdm-…` so an orc reply in the same thread * cannot close a ticket that belongs to a team. * * @param input - The Slack message * @param surface - Which Slack path received it * @param options - Assignee and where the receipt goes * @returns The intake message */ export declare function slackIntakeMessage(input: SlackIntakeInput, surface: 'legacy-bridge' | 'team-channel' | 'agent-dm', options?: { targetAgent?: string; receiptPostAs?: string; }): IntakeMessage; //# sourceMappingURL=ticket-channel-hooks.d.ts.map