/** * Slack DMs to an agent's own bot user. * * Every Cloud-provisioned agent has a real Slack bot, so a person can open * a DM with "Ella" directly. Cloud tags such an event with the agent's * session (`SlackIncomingMessage.agentSession`); this service turns it * into a turn on the owner's chat-v2 DM channel with that agent — the same * channel the dashboard's team-chat uses — and dispatches it (activate-on- * send wakes an idle agent). The agent answers with its usual `reply-chat` * skill; the reply is mirrored back into the Slack DM under the agent's * bot token. The orchestrator never sees these conversations. * * @module services/slack/slack-agent-dm.service */ import { type SlackOutboundDelivery } from './slack-outbound-delivery.js'; import type { ChatMessageDTO } from '../chat-v2/types.js'; import type { SlackIncomingMessage, SlackOutgoingMessage } from '../../types/slack.types.js'; import type { Team } from '../../types/index.js'; import type { ChatV2Service } from '../chat-v2/chat-v2.service.js'; import type { ChatV2DispatcherService, DispatchMessageResult } from '../chat-v2/chat-v2.dispatcher.service.js'; import type { SlackAgentIdentityService } from './slack-agent-identity.service.js'; import type { SlackTypingPlaceholderService } from './slack-typing-placeholder.service.js'; import { type SlackAutoWorkingService } from './slack-auto-working.service.js'; /** The slice of SlackService this service uses. */ export interface AgentDmSlackApi { isConnected(): boolean; sendMessage(message: SlackOutgoingMessage): Promise; uploadFile(options: { channelId: string; filePath: string; filename?: string; title?: string; initialComment?: string; threadTs?: string; botToken?: string; }): Promise<{ fileId?: string; }>; addReaction(channelId: string, messageTs: string, emoji: string, botToken?: string): Promise; } /** The slice of ChatV2Service this service uses. */ export type AgentDmChatApi = Pick & Partial>; /** The slice of SlackAgentIdentityService this service uses. */ export type AgentDmIdentityApi = Pick; /** Dispatcher slice — resolved lazily because it is built after the bridge. */ export type AgentDmDispatcherApi = Pick; /** Constructor dependencies. */ export interface SlackAgentDmServiceDeps { slack: AgentDmSlackApi; chat: AgentDmChatApi; storage: { getTeams(): Promise; }; getDispatcher: () => AgentDmDispatcherApi | null; identities: AgentDmIdentityApi; /** Whether the agent runs on this instance (Cloud fans DMs out to the owner only, but be safe). */ isLocalAgent?: (agentSession: string) => boolean; /** "Is typing…" placeholders; optional (replies are posted plainly without it). */ typing?: (Pick & Partial>) | null; /** * Harness-posted "working on it": if the DM's placeholder is not showing * when the agent starts on the owner's message, it is posted then; optional. */ autoWorking?: Pick | null; /** Whether the agent's runtime session exists right now (false = it must be woken first). */ isAgentAwake?: (agentSession: string) => boolean; /** Slack user id of the owner, when known — only the owner's DMs file tickets. */ getOwnerUserId?: () => string | null; /** * The Slack thread the agent's current turn came from — for the whole * turn, however long it runs; undefined when unknown. crewly#1015 §8. */ turnOriginThread?: (agentSession: string) => { slackChannelId: string; threadTs: string; } | undefined; /** Link store path; defaults to `/slack-agent-dms.json`. */ storePath?: string; now?: () => Date; } /** One chat-v2 DM channel ↔ one Slack DM conversation with the agent's bot. */ export interface SlackAgentDmLink { chatChannelId: string; agentSession: string; /** Slack DM channel id (`D…`). */ slackChannelId: string; /** * Thread the reply belongs in. * * A threaded question keeps its own thread. A top-level DM gets one * rooted at the question itself, so a long conversation with an agent * reads as exchanges rather than one flat column (owner, 2026-09-21). */ replyThreadTs?: string; /** * Threads that still owe an answer, oldest first (thread root ts). * * `replyThreadTs` alone is the thread the owner wrote in *last*; used as * the reply target it sent an answer owed in an older thread into the * newest one (2026-09-28). An answer that names no thread goes to the * oldest entry here instead, and leaves it. */ openThreads?: SlackAgentDmOpenThread[]; /** Thread of the last answer posted, and when — a file sent right after its answer follows it. */ lastReply?: { threadTs: string; at: string; }; updatedAt: string; } /** A thread that still owes an answer. */ export interface SlackAgentDmOpenThread { /** Thread root ts */ threadTs: string; /** When the owner's (first unanswered) message in it arrived, ISO-8601 */ at: string; } /** Where an outbound post goes, and how that was decided (logged). */ export interface AgentDmReplyTarget { /** Thread root ts; undefined = top level (a DM with no thread yet) */ threadTs?: string; /** * - `key`: the agent named the thread (`--thread `) * - `thread-root`: the agent replied under a chat-v2 message (`--thread `) that came from that Slack thread * - `recent-reply`: a file following the answer just posted * - `turn-origin`: unattributed — the thread the agent's current turn came from (in this DM) * - `oldest-open`: unattributed, no turn origin here — the oldest thread still owed an answer (opened within OPEN_THREAD_MAX_AGE_MS) * - `latest`: unattributed and nothing owed — the thread the owner wrote in last */ via: 'key' | 'thread-root' | 'recent-reply' | 'oldest-open' | 'turn-origin' | 'latest'; } /** Result of {@link SlackAgentDmService.routeInbound}. */ export interface AgentDmRouteResult { link: SlackAgentDmLink; message: ChatMessageDTO; dispatch: DispatchMessageResult | null; } export declare class SlackAgentDmService { private readonly deps; private readonly logger; private readonly storePath; private store; /** What was last posted on each DM, so a repeat is not posted twice. */ private readonly lastSent; private loaded; private started; private readonly onChatMessage; /** * @param deps - Narrow service dependencies (see {@link SlackAgentDmServiceDeps}) */ constructor(deps: SlackAgentDmServiceDeps); /** Load the link store and subscribe to chat-v2 messages. Idempotent. */ start(): Promise; /** Unsubscribe from chat-v2 messages. */ stop(): void; /** * The link for a Slack DM channel, when this service owns it. * * @param slackChannelId - Slack conversation id * @returns The link or null */ findBySlackChannelId(slackChannelId: string): SlackAgentDmLink | null; /** * The link for a chat-v2 DM channel — the direction a skill needs. * * An agent only knows the chat channel it was addressed in; posting an * authorization card back into Slack needs the conversation that chat * channel came from. * * @param chatChannelId - chat-v2 channel id * @returns The link or null */ findByChatChannelId(chatChannelId: string): SlackAgentDmLink | null; /** * The newest link of an agent — its DM with the owner, for messages that * have no other place (specs/2026-10-02-harness-owned-routing.md §1, step 7). * * @param agentSession - Agent session * @returns The link or null */ findByAgentSession(agentSession: string): SlackAgentDmLink | null; /** * Put a file into the Slack DM an agent is answering in. * * The team-channel path has the same method for the same reason; an agent * talking to its owner one-to-one needs it at least as much, and this is * the path most conversations with a single agent actually take. Without * it, asked for a PDF, the agent uploads to Drive and sends a link — which * it will then correctly explain is all its reply interface can carry. * * The file goes out under the agent's own bot, into the thread it belongs * to: the one the agent names (`threadId` — a Slack thread key or the * chat-v2 message it answers), else the answer it just posted, else the * oldest thread still owed an answer. It used to go wherever the owner * wrote last, which put the EFT form into the HSA thread (2026-09-28). * * @param input - Chat channel the agent was given, plus the file and optional thread * @returns What was uploaded, or why it could not be */ attachFileForAgent(input: { chatChannelId: string; agentSession: string; filePath: string; filename?: string; title?: string; comment?: string; /** Slack thread key (`:`) or chat-v2 message id of the thread */ threadId?: string; }): Promise<{ ok: true; slackChannelId: string; threadTs?: string; fileId?: string; asAgentBot: boolean; } | { ok: false; reason: string; }>; /** * Route a DM to an agent's bot. Returns null (caller falls through to the * normal routing) when the message is not addressed to a local agent. * * @param message - The inbound Slack message; `agentSession` names the bot's agent * @returns Where it went, or null when not handled */ routeInbound(message: SlackIncomingMessage): Promise; /** * Whether this text repeats what was just sent on the same DM. * * Containment, not equality: the duplicate that prompted this was the * same answer with a line of self-report in front of it, so an exact * match would have let it through. The minimum length keeps a genuine * short reply ("ok", "done") from being swallowed because it happens to * appear inside a longer one. * * @param slackChannelId - The DM * @param text - What is about to be sent * @returns True when it should not be sent */ private isRepeatOfLastSent; /** * Record what was sent, for {@link isRepeatOfLastSent}. * * @param slackChannelId - The DM * @param text - What was sent */ private rememberSent; /** * Whether this turn is the other runtime's restatement of the answer just * sent — see {@link DM_CROSS_RUNTIME_WINDOW_MS}. * * @param slackChannelId - The DM * @param source - `metadata.source` of the turn about to be sent * @returns True when it should not be sent */ private isCrossRuntimeRestatement; /** * Post an agent's reply on a linked DM channel back into the Slack DM, * under the agent's own bot. Ignores anything that is not an agent * message on a linked channel. * * @param dto - The chat-v2 message * @returns True when a Slack post was attempted */ mirrorOutbound(dto: ChatMessageDTO): Promise; /** * {@link mirrorOutbound} with the outcome: where the reply landed (also * stored on the chat message) or Slack's error. * * @param dto - The chat-v2 message * @returns Whether a post was attempted, and its delivery (null when not meant for Slack) */ mirrorOutboundDetailed(dto: ChatMessageDTO): Promise<{ attempted: boolean; delivery: SlackOutboundDelivery | null; }>; /** * Store where a mirrored DM reply landed on its chat message. * * @param dto - The mirrored message * @param slackChannelId - Slack DM channel * @param ts - Slack ts of the message that carries the reply ('' when unknown) * @param threadTs - Thread the reply is in * @returns The delivery */ private recordDelivery; /** * Which thread of the DM an outbound post belongs in. * * In order: the Slack thread key the agent named (`--thread `, or the * key recorded on its reply); the Slack thread of the chat-v2 message it * replied under (`--thread ` — ticket nudges use this); for a * file, the answer posted moments ago; the OLDEST thread still owed an * answer; and only when nothing is owed, the thread the owner wrote in * last. A key naming another conversation is ignored rather than trusted. * * @param link - The DM link * @param opts - The reply being mirrored, or an explicit thread reference * @returns Target thread and how it was chosen */ resolveReplyTarget(link: SlackAgentDmLink, opts: { dto?: Pick; threadRef?: string; forAttachment?: boolean; }): AgentDmReplyTarget; /** * An answer went into a thread: it no longer owes one (an interim note * does not count), and a file sent right after follows it there. * * @param link - The DM link (updated in place and persisted) * @param threadTs - Where the answer went * @param interim - Whether it was only an interim note */ private noteAnswered; /** * The agent finished a turn: threads it left unanswered for longer than * SETTLE_MIN_AGE_MS are ones it chose not to answer ("ok", "谢谢") — the * same rule that takes their "working on it" placeholder down. They stop * counting as owed, so a later unattributed answer is not pulled back * into them. * * @param agentSession - Agent whose turn ended * @returns How many threads were settled */ settleOpenThreads(agentSession: string): Promise; /** * Ticket intake for a DM to an agent's bot. Never throws. * * @param message - The inbound DM * @param agentSession - The agent it was addressed to * @returns The ticket the message belongs to, or null */ private intakeTicket; private findMember; private load; private persist; private now; } /** * Add a thread to the owed list, keeping its original place when it is * already there (a follow-up in an owed thread does not make it newer). * * @param open - Current list, oldest first * @param threadTs - Thread that now owes an answer * @param now - Current time * @returns The new list, capped at MAX_OPEN_THREADS (oldest dropped) */ export declare function withOpenThread(open: readonly SlackAgentDmOpenThread[] | undefined, threadTs: string, now: Date): SlackAgentDmOpenThread[]; /** @returns The wired service, or null before Slack started. */ export declare function getSlackAgentDmService(): SlackAgentDmService | null; /** @param service - The service to expose (null to clear, for tests) */ export declare function setSlackAgentDmService(service: SlackAgentDmService | null): void; //# sourceMappingURL=slack-agent-dm.service.d.ts.map