diff --git a/src/channels/__tests__/email.test.ts b/src/channels/__tests__/email.test.ts index 85a326b..b86f2b9 100644 --- a/src/channels/__tests__/email.test.ts +++ b/src/channels/__tests__/email.test.ts @@ -23,14 +23,27 @@ const mockFetch = mock(function* () { const mockMessageFlagsAdd = mock(() => Promise.resolve()); const mockSendMail = mock(() => Promise.resolve({ messageId: "" })); -const MockImapFlow = mock((_opts: Record) => ({ - connect: mockConnect, - logout: mockLogout, - getMailboxLock: mockGetMailboxLock, - idle: mockIdle, - fetch: mockFetch, - messageFlagsAdd: mockMessageFlagsAdd, -})); +// Captured per-instance so tests can simulate a socket-timeout emission. +interface CapturedClient { + errorHandlers: Array<(err: unknown) => void>; +} +const capturedClients: CapturedClient[] = []; + +const MockImapFlow = mock((_opts: Record) => { + const handlers: CapturedClient = { errorHandlers: [] }; + capturedClients.push(handlers); + return { + on: mock((event: string, handler: (err: unknown) => void) => { + if (event === "error") handlers.errorHandlers.push(handler); + }), + connect: mockConnect, + logout: mockLogout, + getMailboxLock: mockGetMailboxLock, + idle: mockIdle, + fetch: mockFetch, + messageFlagsAdd: mockMessageFlagsAdd, + }; +}); const mockCreateTransport = mock((_opts: Record) => ({ sendMail: mockSendMail, @@ -70,6 +83,7 @@ describe("EmailChannel", () => { mockLogout.mockClear(); mockSendMail.mockClear(); mockGetMailboxLock.mockClear(); + capturedClients.length = 0; }); test("has correct id and capabilities", () => { @@ -192,4 +206,42 @@ describe("EmailChannel", () => { const id2 = calls[1][0].messageId; expect(id1).not.toBe(id2); }); + + test("attaches an ImapFlow error listener on connect (no crash on socket timeout)", async () => { + const channel = new EmailChannel(testConfig); + await channel.connect(); + + // The first constructed client must have an error handler registered. + expect(capturedClients.length).toBeGreaterThan(0); + expect(capturedClients[0].errorHandlers.length).toBe(1); + + // Emitting 'error' must not throw and must not reject any promise. + // Before the fix this is what crashed the Phantom process. + await expect( + Promise.resolve(capturedClients[0].errorHandlers[0](new Error("Socket timeout"))), + ).resolves.toBeUndefined(); + + await channel.disconnect(); + }); + + test("error emission triggers a reconnect that builds a fresh client", async () => { + const channel = new EmailChannel(testConfig); + await channel.connect(); + + expect(capturedClients.length).toBe(1); + const initialClientCount = capturedClients.length; + + // Simulate the socket-timeout emission from imapflow. + capturedClients[0].errorHandlers[0](new Error("Socket timeout")); + + // Allow the reconnect path (with its 1s backoff) to run. + await new Promise((resolve) => setTimeout(resolve, 1_500)); + + // A second client should have been constructed. + expect(capturedClients.length).toBe(initialClientCount + 1); + expect(capturedClients[capturedClients.length - 1].errorHandlers.length).toBe(1); + expect(mockConnect.mock.calls.length).toBeGreaterThanOrEqual(2); + + await channel.disconnect(); + }); }); diff --git a/src/channels/__tests__/interaction-adapter.test.ts b/src/channels/__tests__/interaction-adapter.test.ts new file mode 100644 index 0000000..66c3103 --- /dev/null +++ b/src/channels/__tests__/interaction-adapter.test.ts @@ -0,0 +1,118 @@ +import { describe, expect, mock, test } from "bun:test"; +import { + type ChannelInteractionFactory, + type ChannelInteractionInstance, + ChannelInteractionRegistry, +} from "../interaction-adapter.ts"; +import type { InboundMessage } from "../types.ts"; + +function makeMessage(overrides: Partial = {}): InboundMessage { + return { + id: "msg-1", + channelId: "test", + conversationId: "test:conv-1", + senderId: "user-1", + text: "hello", + timestamp: new Date(), + ...overrides, + }; +} + +describe("ChannelInteractionRegistry", () => { + test("starts empty", () => { + const registry = new ChannelInteractionRegistry(); + expect(registry.size()).toBe(0); + }); + + test("buildFor returns empty array when no factories registered", () => { + const registry = new ChannelInteractionRegistry(); + const instances = registry.buildFor(makeMessage()); + expect(instances).toEqual([]); + }); + + test("registers factories and reports size", () => { + const registry = new ChannelInteractionRegistry(); + registry.register(() => null); + registry.register(() => null); + expect(registry.size()).toBe(2); + }); + + test("buildFor calls each factory with the message", () => { + const registry = new ChannelInteractionRegistry(); + const factoryA = mock((_msg: InboundMessage) => null); + const factoryB = mock((_msg: InboundMessage) => null); + registry.register(factoryA); + registry.register(factoryB); + + const msg = makeMessage({ channelId: "slack" }); + registry.buildFor(msg); + + expect(factoryA).toHaveBeenCalledWith(msg); + expect(factoryB).toHaveBeenCalledWith(msg); + }); + + test("buildFor returns only non-null instances in registration order", () => { + const registry = new ChannelInteractionRegistry(); + const instanceA: ChannelInteractionInstance = { dispose: () => {} }; + const instanceC: ChannelInteractionInstance = { dispose: () => {} }; + + // A returns instance, B opts out (null), C returns instance + registry.register(() => instanceA); + registry.register(() => null); + registry.register(() => instanceC); + + const instances = registry.buildFor(makeMessage()); + expect(instances).toEqual([instanceA, instanceC]); + }); + + test("factory can decide based on the message channelId", () => { + const registry = new ChannelInteractionRegistry(); + const slackInstance: ChannelInteractionInstance = { dispose: () => {} }; + + const slackFactory: ChannelInteractionFactory = (msg) => (msg.channelId === "slack" ? slackInstance : null); + registry.register(slackFactory); + + const slackMsg = makeMessage({ channelId: "slack" }); + const telegramMsg = makeMessage({ channelId: "telegram" }); + + expect(registry.buildFor(slackMsg)).toEqual([slackInstance]); + expect(registry.buildFor(telegramMsg)).toEqual([]); + }); + + test("clearForTests empties the registry", () => { + const registry = new ChannelInteractionRegistry(); + registry.register(() => null); + registry.register(() => null); + expect(registry.size()).toBe(2); + registry.clearForTests(); + expect(registry.size()).toBe(0); + }); + + test("instances may have any subset of optional hooks", () => { + const registry = new ChannelInteractionRegistry(); + // All hooks present + const fullInstance: ChannelInteractionInstance = { + onTurnStart: () => {}, + onRuntimeEvent: () => {}, + onTurnEnd: () => {}, + deliverResponse: () => false, + dispose: () => {}, + }; + // Only one hook + const minimalInstance: ChannelInteractionInstance = { + dispose: () => {}, + }; + // Truly empty + const emptyInstance: ChannelInteractionInstance = {}; + + registry.register(() => fullInstance); + registry.register(() => minimalInstance); + registry.register(() => emptyInstance); + + const instances = registry.buildFor(makeMessage()); + expect(instances.length).toBe(3); + expect(instances[0].onTurnStart).toBeDefined(); + expect(instances[1].onTurnStart).toBeUndefined(); + expect(instances[2].dispose).toBeUndefined(); + }); +}); diff --git a/src/channels/__tests__/slack-interaction.test.ts b/src/channels/__tests__/slack-interaction.test.ts new file mode 100644 index 0000000..1465067 --- /dev/null +++ b/src/channels/__tests__/slack-interaction.test.ts @@ -0,0 +1,282 @@ +import { describe, expect, mock, test } from "bun:test"; +import { createSlackInteractionFactory } from "../slack-interaction.ts"; +import type { InboundMessage } from "../types.ts"; + +// Minimal SlackChannel mock — only the methods slack-interaction.ts touches. +function makeMockSlackChannel() { + const calls = { + addReaction: [] as Array<{ ch: string; ts: string; emoji: string }>, + removeReaction: [] as Array<{ ch: string; ts: string; emoji: string }>, + postThinking: [] as Array<{ ch: string; threadTs: string }>, + updateMessage: [] as Array<{ ch: string; ts: string; text: string }>, + updateWithFeedback: [] as Array<{ ch: string; ts: string; text: string }>, + }; + + let nextThinkingTs: string | null = "thinking-ts"; + + const channel = { + addReaction: mock(async (ch: string, ts: string, emoji: string) => { + calls.addReaction.push({ ch, ts, emoji }); + }), + removeReaction: mock(async (ch: string, ts: string, emoji: string) => { + calls.removeReaction.push({ ch, ts, emoji }); + }), + postThinking: mock(async (ch: string, threadTs: string) => { + calls.postThinking.push({ ch, threadTs }); + return nextThinkingTs; + }), + updateMessage: mock(async (ch: string, ts: string, text: string) => { + calls.updateMessage.push({ ch, ts, text }); + }), + updateWithFeedback: mock(async (ch: string, ts: string, text: string) => { + calls.updateWithFeedback.push({ ch, ts, text }); + }), + }; + + return { + channel: channel as unknown as Parameters[0], + calls, + setNextThinkingTs(value: string | null): void { + nextThinkingTs = value; + }, + }; +} + +function makeSlackMessage(overrides: Partial = {}): InboundMessage { + return { + id: "msg-id", + channelId: "slack", + conversationId: "slack:C123:ts-1", + senderId: "U123", + text: "hello", + timestamp: new Date(), + metadata: { + slackChannel: "C123", + slackThreadTs: "ts-1", + slackMessageTs: "ts-1", + ...overrides, + }, + }; +} + +describe("createSlackInteractionFactory", () => { + test("returns null when slackChannel is null", () => { + const factory = createSlackInteractionFactory(null); + expect(factory(makeSlackMessage())).toBeNull(); + }); + + test("returns null for non-slack messages", () => { + const { channel } = makeMockSlackChannel(); + const factory = createSlackInteractionFactory(channel); + + const nonSlack: InboundMessage = { + id: "x", + channelId: "telegram", + conversationId: "telegram:1", + senderId: "u", + text: "hi", + timestamp: new Date(), + metadata: { telegramChatId: 42 }, + }; + + expect(factory(nonSlack)).toBeNull(); + }); + + test("returns null for slack messages without metadata", () => { + const { channel } = makeMockSlackChannel(); + const factory = createSlackInteractionFactory(channel); + + const noMeta: InboundMessage = { + id: "x", + channelId: "slack", + conversationId: "slack:C:ts", + senderId: "u", + text: "hi", + timestamp: new Date(), + }; + expect(factory(noMeta)).toBeNull(); + }); + + test("creates an instance with both statusReactions and progressStream when metadata is complete", () => { + const { channel } = makeMockSlackChannel(); + const factory = createSlackInteractionFactory(channel); + + const instance = factory(makeSlackMessage()); + expect(instance).not.toBeNull(); + expect(instance?.statusReactions).toBeDefined(); + expect(instance?.progressStream).toBeDefined(); + }); + + test("setQueued is fired immediately on instance creation", async () => { + const { channel, calls } = makeMockSlackChannel(); + const factory = createSlackInteractionFactory(channel); + + factory(makeSlackMessage()); + // Allow the setQueued microtask + adapter promise chain to flush + await new Promise((r) => setTimeout(r, 50)); + expect(calls.addReaction.some((c) => c.emoji === "eyes")).toBe(true); + }); + + test("onTurnStart starts the progress stream", async () => { + const { channel, calls } = makeMockSlackChannel(); + const factory = createSlackInteractionFactory(channel); + + const instance = factory(makeSlackMessage()); + await instance?.onTurnStart?.(); + expect(calls.postThinking.length).toBe(1); + expect(calls.postThinking[0]).toEqual({ ch: "C123", threadTs: "ts-1" }); + }); + + test("onRuntimeEvent thinking sets thinking emoji on the user message", async () => { + const { channel, calls } = makeMockSlackChannel(); + const factory = createSlackInteractionFactory(channel); + + const instance = factory(makeSlackMessage()); + instance?.onRuntimeEvent?.({ type: "thinking" }); + await new Promise((r) => setTimeout(r, 600)); // debounce is 500ms + expect(calls.addReaction.some((c) => c.emoji === "brain")).toBe(true); + }); + + test("onRuntimeEvent tool_use updates both reactions and progress activity", async () => { + const { channel, calls } = makeMockSlackChannel(); + const factory = createSlackInteractionFactory(channel); + + const instance = factory(makeSlackMessage()); + await instance?.onTurnStart?.(); + instance?.onRuntimeEvent?.({ + type: "tool_use", + tool: "Read", + input: { file_path: "/x.ts" }, + }); + await new Promise((r) => setTimeout(r, 1200)); // progress throttle is 1000ms + expect(calls.updateMessage.length).toBeGreaterThanOrEqual(1); + const wroteActivity = calls.updateMessage.some((c) => c.text.includes("Reading /x.ts")); + expect(wroteActivity).toBe(true); + }); + + test("onRuntimeEvent error sets error reaction", async () => { + const { channel, calls } = makeMockSlackChannel(); + const factory = createSlackInteractionFactory(channel); + + const instance = factory(makeSlackMessage()); + instance?.onRuntimeEvent?.({ type: "error", message: "boom" }); + await new Promise((r) => setTimeout(r, 50)); + expect(calls.addReaction.some((c) => c.emoji === "warning")).toBe(true); + }); + + test("deliverResponse uses progressStream.finish path when stream is active", async () => { + const { channel, calls } = makeMockSlackChannel(); + const factory = createSlackInteractionFactory(channel); + + const instance = factory(makeSlackMessage()); + await instance?.onTurnStart?.(); + const claimed = await instance?.deliverResponse?.({ text: "Final answer", isError: false }); + expect(claimed).toBe(true); + expect(calls.updateWithFeedback.length).toBe(1); + expect(calls.updateWithFeedback[0].text).toBe("Final answer"); + }); + + test("deliverResponse uses post-then-update fallback when no progress stream", async () => { + const { channel, calls } = makeMockSlackChannel(); + const factory = createSlackInteractionFactory(channel); + + // Message with no threadTs in metadata still has slackChannel + messageTs + // but progress stream requires both channel and threadTs to be set. + // We need a case where progressStream is undefined but the fallback path works. + // In practice this happens when slackThreadTs is missing. + const msgWithoutThread = makeSlackMessage({ slackThreadTs: undefined }); + const instance = factory(msgWithoutThread); + expect(instance?.progressStream).toBeUndefined(); + + // The fallback path requires slackThreadTs to be defined, so without it, + // deliverResponse should not be able to claim. Verify: factory with no + // thread does not fall back through Slack delivery. + const claimed = await instance?.deliverResponse?.({ text: "F", isError: false }); + expect(claimed).toBe(false); + expect(calls.updateWithFeedback.length).toBe(0); + }); + + test("dispose disposes the status reactions controller", () => { + const { channel } = makeMockSlackChannel(); + const factory = createSlackInteractionFactory(channel); + + const instance = factory(makeSlackMessage()); + // dispose should not throw + expect(() => instance?.dispose?.()).not.toThrow(); + }); + + describe("onTurnEnd", () => { + test("sets done reaction for successful responses", async () => { + const { channel, calls } = makeMockSlackChannel(); + const factory = createSlackInteractionFactory(channel); + + const instance = factory(makeSlackMessage()); + await instance?.onTurnEnd?.({ text: "Here is your answer", isError: false }); + await new Promise((r) => setTimeout(r, 50)); + + // Should have white_check_mark reaction for success + expect(calls.addReaction.some((c) => c.emoji === "white_check_mark")).toBe(true); + }); + + test("sets error reaction for error responses", async () => { + const { channel, calls } = makeMockSlackChannel(); + const factory = createSlackInteractionFactory(channel); + + const instance = factory(makeSlackMessage()); + await instance?.onTurnEnd?.({ text: "Error: Something went wrong", isError: true }); + await new Promise((r) => setTimeout(r, 50)); + + // Should have warning reaction for errors + expect(calls.addReaction.some((c) => c.emoji === "warning")).toBe(true); + }); + + test("sets error reaction when text starts with Error: prefix", async () => { + const { channel, calls } = makeMockSlackChannel(); + const factory = createSlackInteractionFactory(channel); + + const instance = factory(makeSlackMessage()); + await instance?.onTurnEnd?.({ text: "Error: API rate limit exceeded", isError: false }); + await new Promise((r) => setTimeout(r, 50)); + + // Should detect Error: prefix and set error reaction + expect(calls.addReaction.some((c) => c.emoji === "warning")).toBe(true); + }); + + test("sets final reaction after intermediate states", async () => { + const { channel, calls } = makeMockSlackChannel(); + const factory = createSlackInteractionFactory(channel); + + const instance = factory(makeSlackMessage()); + + // First set a thinking reaction + instance?.onRuntimeEvent?.({ type: "thinking" }); + await new Promise((r) => setTimeout(r, 50)); + + const thinkingCalls = calls.addReaction.length; + + // Then finalize with successful response + await instance?.onTurnEnd?.({ text: "Success", isError: false }); + await new Promise((r) => setTimeout(r, 50)); + + // Should have added more reactions (thinking + done) + expect(calls.addReaction.length).toBeGreaterThan(thinkingCalls); + // Final reaction should be the done state + expect(calls.addReaction.some((c) => c.emoji === "white_check_mark")).toBe(true); + }); + + test("handles missing statusReactions gracefully", async () => { + const { channel, calls } = makeMockSlackChannel(); + const factory = createSlackInteractionFactory(channel); + + // Create a message without slackMessageTs, so no statusReactions controller + const msgWithoutReaction = makeSlackMessage({ slackMessageTs: undefined }); + const instance = factory(msgWithoutReaction); + expect(instance?.statusReactions).toBeUndefined(); + + // Should not throw when onTurnEnd is called with undefined statusReactions + instance?.onTurnEnd?.({ text: "Test", isError: false }); + // The function completes without error even though statusReactions is undefined + expect(calls.addReaction.length).toBe(0); // No reactions added + }); + }); +}); diff --git a/src/channels/email-helpers.ts b/src/channels/email-helpers.ts new file mode 100644 index 0000000..95fc776 --- /dev/null +++ b/src/channels/email-helpers.ts @@ -0,0 +1,77 @@ +/** + * Pure formatting and classification helpers for the Email channel. + * + * Extracted from email.ts so that file stays under the 300-line budget. + * These functions are stateless and have no IMAP/SMTP dependencies, so + * they are trivially testable in isolation. + */ + +/** + * Render plain text agent output as a simple HTML body with Phantom + * branding. Escapes HTML special chars first, then applies code block + * and inline code styling. + */ +export function textToHtml(text: string): string { + const html = text + .replace(/&/g, "&") + .replace(//g, ">") + .replace(/\n/g, "
") + .replace( + /```([\s\S]*?)```/g, + '
$1
', + ) + .replace( + /`([^`]+)`/g, + '$1', + ); + + return ` +
+${html} +

+
+${"\u2014"} Phantom, your AI co-worker +
+
`.trim(); +} + +/** + * Pull the body text out of a raw RFC 822 message source. Strips HTML + * tags and decodes a small set of common entities. Good enough for + * agent input; not a full MIME parser. + */ +export function extractBodyText(source: string): string { + // Simple extraction: get text after headers (double newline) + const headerEnd = source.indexOf("\r\n\r\n"); + if (headerEnd === -1) return source; + const body = source.slice(headerEnd + 4); + + // Strip HTML tags if present + return body + .replace(/<[^>]*>/g, "") + .replace(/ /g, " ") + .replace(/&/g, "&") + .replace(/</g, "<") + .replace(/>/g, ">") + .trim(); +} + +/** + * Detect auto-reply messages so Phantom can ignore them. Matches the + * common OOO / vacation / DSN phrases in subject and body combined. + */ +export function isAutoReply(subject: string, body: string): boolean { + const autoReplyIndicators = [ + "out of office", + "automatic reply", + "auto-reply", + "autoreply", + "vacation reply", + "delivery status notification", + "undeliverable", + "mailer-daemon", + ]; + const combined = `${subject} ${body}`.toLowerCase(); + return autoReplyIndicators.some((indicator) => combined.includes(indicator)); +} diff --git a/src/channels/email-idle-loop.ts b/src/channels/email-idle-loop.ts new file mode 100644 index 0000000..566b882 --- /dev/null +++ b/src/channels/email-idle-loop.ts @@ -0,0 +1,294 @@ +/** + * IMAP IDLE supervisor for the Email channel. + * + * Owns the ImapFlow client lifecycle: connect, IDLE-listen, refresh the + * IDLE before the server's idle timeout drops the socket, and rebuild + * the client with exponential backoff after a socket error. + */ + +// Re-issue IDLE well under the typical 30-minute server-side idle timeout +// (RFC 2177 §3 recommends clients refresh before 29 minutes). 20 minutes +// gives margin for slow networks and providers that enforce tighter limits. +const IDLE_REFRESH_MS = 20 * 60 * 1000; + +// Backoff for reconnect attempts after an IDLE session ends in error. +const RECONNECT_BASE_DELAY_MS = 1_000; +const RECONNECT_MAX_DELAY_MS = 60_000; + +// Minimal ImapFlow surface. `on` is included because ImapFlow extends +// EventEmitter and emits 'error' on socket timeouts; without a listener +// Node/Bun treat the emitted 'error' as an uncaught exception and crash. +export type ImapFlowClient = { + on: (event: "error", handler: (err: unknown) => void) => void; + connect: () => Promise; + logout: () => Promise; + getMailboxLock: (mailbox: string) => Promise<{ release: () => void }>; + idle: (options: { abort: AbortSignal }) => Promise; + fetch: (range: string, options: Record) => AsyncIterable; + messageFlagsAdd: (uid: string, flags: string[], options: Record) => Promise; +}; + +export type ImapMessage = { + uid: number; + flags: Set; + envelope: { + from?: Array<{ address?: string; name?: string }>; + subject?: string; + messageId?: string; + date?: Date; + inReplyTo?: string; + }; + source?: Buffer; +}; + +/** Subset of ImapFlowClient needed by EmailChannel.processUnread. */ +export type ImapReadClient = { + fetch: (range: string, options: Record) => AsyncIterable; + messageFlagsAdd: (uid: string, flags: string[], options: Record) => Promise; +}; + +export type ImapIdleConfig = { + host: string; + port: number; + auth: { user: string; pass: string }; + tls?: boolean; +}; + +export type ImapIdleHooks = { + /** True while the owning channel is connected. False exits the loop cleanly. */ + isConnected: () => boolean; + /** + * Called whenever the IDLE session can drain new mail: at session + * start, after each IDLE interruption, and after each refresh-abort. + * Receives the active client so the hook can fetch without coupling + * to the supervisor internals. + */ + onMail: (client: ImapFlowClient) => Promise; +}; + +/** + * Owns one IMAP IDLE connection and supervises its lifetime. + * + * Usage: + * const idle = new ImapIdleSupervisor(config, hooks); + * await idle.connect(); // builds client, attaches error listener + * idle.startLoop(); // begins IDLE supervisor (after owner is connected) + * ... + * await idle.disconnect(); // aborts IDLE, waits for loop, logs out + */ +export class ImapIdleSupervisor { + private config: ImapIdleConfig; + private hooks: ImapIdleHooks; + private client: ImapFlowClient | null = null; + private idleAbort: AbortController | null = null; + private loopPromise: Promise | null = null; + // Refresh timer that aborts the current IDLE call every IDLE_REFRESH_MS + // so the server never sees the socket as stale and drops it. + private idleRefreshTimer: ReturnType | null = null; + // Set by the ImapFlow 'error' listener when the underlying socket dies. + // Distinguishes a refresh-abort (still healthy) from an error-abort + // (must reconnect before re-issuing IDLE). + private idleBroken = false; + + constructor(config: ImapIdleConfig, hooks: ImapIdleHooks) { + this.config = config; + this.hooks = hooks; + } + + /** + * Build the first ImapFlow client and attach the error listener. + * Does NOT start the supervisor loop; call startLoop() once the + * owning channel has flipped its state to "connected" so the + * loop's isConnected hook returns true on its first check. + */ + async connect(): Promise { + await this.initClient(); + } + + /** + * Start the IDLE supervisor loop as a tracked background promise. + * Must be called after connect() and after the owner reports + * connected, otherwise the loop exits on its first check. + */ + startLoop(): void { + if (this.loopPromise) return; + this.loopPromise = this.runSupervisorLoop(); + } + + /** + * Cleanly tear down: abort any in-flight IDLE, await loop exit, then + * log out. Safe to call after connect() even if the loop has died. + * Caller flips the owner's connected flag first so the loop exits. + */ + async disconnect(): Promise { + this.cancelRefresh(); + this.idleAbort?.abort(); + + if (this.loopPromise) { + await this.loopPromise; + this.loopPromise = null; + } + + try { + await this.client?.logout(); + } catch (err: unknown) { + const msg = err instanceof Error ? err.message : String(err); + console.warn(`[email] Error during IMAP disconnect: ${msg}`); + } + } + + /** Current client, or null if not connected. */ + getClient(): ImapFlowClient | null { + return this.client; + } + + /** + * Build a fresh ImapFlow client, attach the error listener that + * prevents socket-timeout crashes, and connect. + */ + private async initClient(): Promise { + const { ImapFlow } = await import("imapflow"); + const client = new ImapFlow({ + host: this.config.host, + port: this.config.port, + auth: this.config.auth, + secure: this.config.tls ?? true, + logger: false, + }) as unknown as ImapFlowClient; + + this.attachErrorHandler(client); + this.client = client; + await client.connect(); + console.log("[email] IMAP connected"); + } + + /** + * ImapFlow emits 'error' on its EventEmitter for async socket failures + * (timeouts, TLS drops, server disconnects). The IDLE try/catch only + * sees promise rejections from idle(), not these emissions. Without a + * listener Node/Bun treat the emitted 'error' as fatal and crash the + * whole Phantom process. The handler marks the connection broken and + * aborts any in-flight IDLE so the supervisor triggers a reconnect. + */ + private attachErrorHandler(client: ImapFlowClient): void { + client.on("error", (err: unknown) => { + const msg = err instanceof Error ? err.message : String(err); + console.warn(`[email] IMAP socket error: ${msg}`); + this.idleBroken = true; + this.idleAbort?.abort(); + }); + } + + /** + * Outer IDLE supervisor. Runs one session; if it ends because the + * socket died, rebuilds the client with exponential backoff. Exits + * cleanly when the owner reports disconnected. + */ + private async runSupervisorLoop(): Promise { + let reconnectAttempts = 0; + + while (this.hooks.isConnected()) { + this.idleBroken = false; + const healthy = await this.runSession(); + + if (!this.hooks.isConnected()) return; + if (healthy) return; + + const delayMs = Math.min(RECONNECT_BASE_DELAY_MS * 2 ** reconnectAttempts, RECONNECT_MAX_DELAY_MS); + reconnectAttempts++; + console.warn(`[email] IMAP reconnecting in ${delayMs}ms (attempt ${reconnectAttempts})`); + await new Promise((resolve) => setTimeout(resolve, delayMs)); + + if (!this.hooks.isConnected()) return; + + try { + await this.reconnectClient(); + reconnectAttempts = 0; + } catch (err: unknown) { + const msg = err instanceof Error ? err.message : String(err); + console.error(`[email] IMAP reconnect failed: ${msg}; will retry`); + // Loop continues; backoff keeps growing up to the cap. + } + } + } + + /** + * One IDLE session: acquire INBOX lock, drain unread, then loop on + * idle() until disconnect, an error, or a refresh-abort. Returns true + * on clean exit (owner disconnected), false if caller should reconnect. + */ + private async runSession(): Promise { + if (!this.client) return false; + + try { + const lock = await this.client.getMailboxLock("INBOX"); + + try { + await this.hooks.onMail(this.client); + + while (this.hooks.isConnected() && !this.idleBroken) { + this.idleAbort = new AbortController(); + this.scheduleRefresh(); + + try { + await this.client.idle({ abort: this.idleAbort.signal }); + } catch (err: unknown) { + const msg = err instanceof Error ? err.message : String(err); + if (msg.includes("abort")) { + // Disconnect-abort or refresh-abort. Error handler + // sets idleBroken for the socket-death case. + if (this.idleBroken) return false; + if (!this.hooks.isConnected()) return true; + continue; // refresh: re-issue IDLE + } + console.warn(`[email] IDLE error: ${msg}`); + return false; + } finally { + this.cancelRefresh(); + } + + await this.hooks.onMail(this.client); + } + + return !this.idleBroken; + } finally { + lock.release(); + } + } catch (err: unknown) { + const msg = err instanceof Error ? err.message : String(err); + console.error(`[email] IDLE session error: ${msg}`); + return false; + } + } + + /** + * Tear down the old (likely broken) ImapFlow client and build a fresh + * one. Errors from the old logout are expected and ignored. + */ + private async reconnectClient(): Promise { + try { + await this.client?.logout(); + } catch { + // Socket is already dead; nothing to cleanly close. + } + await this.initClient(); + } + + /** + * Schedule an abort of the current IDLE call before the server's + * idle timeout drops the socket. The IDLE loop re-issues immediately. + */ + private scheduleRefresh(): void { + this.cancelRefresh(); + this.idleRefreshTimer = setTimeout(() => { + this.idleAbort?.abort(); + }, IDLE_REFRESH_MS); + } + + private cancelRefresh(): void { + if (this.idleRefreshTimer) { + clearTimeout(this.idleRefreshTimer); + this.idleRefreshTimer = null; + } + } +} diff --git a/src/channels/email.ts b/src/channels/email.ts index d5b4ebb..13e6cdd 100644 --- a/src/channels/email.ts +++ b/src/channels/email.ts @@ -2,9 +2,16 @@ * Email channel using ImapFlow (IMAP IDLE) and Nodemailer (SMTP). * Supports email threading via In-Reply-To/References headers, * HTML formatting, and attachment handling. + * + * The IMAP IDLE connection lifecycle (connect, IDLE-listen, refresh, + * reconnect) lives in email-idle-loop.ts. Formatting and classification + * helpers live in email-helpers.ts. This file holds only the Channel + * contract: send, receive, connect, disconnect. */ import { randomUUID } from "node:crypto"; +import { extractBodyText, isAutoReply, textToHtml } from "./email-helpers.ts"; +import { ImapIdleSupervisor, type ImapReadClient } from "./email-idle-loop.ts"; import type { Channel, ChannelCapabilities, InboundMessage, OutboundMessage, SentMessage } from "./types.ts"; export type EmailChannelConfig = { @@ -34,6 +41,10 @@ type EmailThread = { from: string; }; +type NodemailerTransport = { + sendMail: (options: Record) => Promise; +}; + export class EmailChannel implements Channel { readonly id = "email"; readonly name = "Email"; @@ -47,11 +58,9 @@ export class EmailChannel implements Channel { private config: EmailChannelConfig; private messageHandler: ((message: InboundMessage) => Promise) | null = null; private connectionState: ConnectionState = "disconnected"; - private imapClient: ImapFlowClient | null = null; + private idle: ImapIdleSupervisor | null = null; private transporter: NodemailerTransport | null = null; private threads = new Map(); - private idleAbort: AbortController | null = null; - private idleLoopPromise: Promise | null = null; constructor(config: EmailChannelConfig) { this.config = config; @@ -62,20 +71,20 @@ export class EmailChannel implements Channel { this.connectionState = "connecting"; try { - // Initialize IMAP - const { ImapFlow } = await import("imapflow"); - this.imapClient = new ImapFlow({ - host: this.config.imap.host, - port: this.config.imap.port, - auth: this.config.imap.auth, - secure: this.config.imap.tls ?? true, - logger: false, - }) as unknown as ImapFlowClient; - - await this.imapClient.connect(); - console.log("[email] IMAP connected"); - - // Initialize SMTP + this.idle = new ImapIdleSupervisor( + { + host: this.config.imap.host, + port: this.config.imap.port, + auth: this.config.imap.auth, + tls: this.config.imap.tls, + }, + { + isConnected: () => this.connectionState === "connected", + onMail: (client) => this.processUnread(client), + }, + ); + await this.idle.connect(); + const nodemailer = await import("nodemailer"); this.transporter = nodemailer.createTransport({ host: this.config.smtp.host, @@ -84,11 +93,11 @@ export class EmailChannel implements Channel { secure: this.config.smtp.tls ?? false, }) as unknown as NodemailerTransport; + // Flip state BEFORE starting the loop so the supervisor's + // isConnected hook returns true on its first check. this.connectionState = "connected"; console.log("[email] SMTP configured"); - - // Start IDLE listening (tracked so disconnect can await it) - this.idleLoopPromise = this.startIdleLoop(); + this.idle.startLoop(); } catch (err: unknown) { this.connectionState = "error"; const msg = err instanceof Error ? err.message : String(err); @@ -100,21 +109,13 @@ export class EmailChannel implements Channel { async disconnect(): Promise { if (this.connectionState === "disconnected") return; + // Flip state first so the supervisor's isConnected hook returns + // false and its loop exits cleanly without attempting reconnect. this.connectionState = "disconnected"; - this.idleAbort?.abort(); - - // Wait for the IDLE loop to finish and release the mailbox lock - // before logging out, so a subsequent connect() won't race. - if (this.idleLoopPromise) { - await this.idleLoopPromise; - this.idleLoopPromise = null; - } - try { - await this.imapClient?.logout(); - } catch (err: unknown) { - const msg = err instanceof Error ? err.message : String(err); - console.warn(`[email] Error during IMAP disconnect: ${msg}`); + if (this.idle) { + await this.idle.disconnect(); + this.idle = null; } console.log("[email] Disconnected"); @@ -166,45 +167,17 @@ export class EmailChannel implements Channel { return this.connectionState; } - private async startIdleLoop(): Promise { - if (!this.imapClient) return; + /** + * Drain unread messages from INBOX. Called by the IDLE supervisor + * at session start, after each IDLE interruption, and after each + * scheduled refresh. The current client is passed in by the + * supervisor so we never touch a stale client after reconnect. + */ + private async processUnread(client: ImapReadClient): Promise { + if (!this.messageHandler) return; try { - const lock = await this.imapClient.getMailboxLock("INBOX"); - - try { - // Process any unread messages first - await this.processUnread(); - - // Start IDLE loop - while (this.connectionState === "connected") { - this.idleAbort = new AbortController(); - try { - await this.imapClient.idle({ abort: this.idleAbort.signal }); - } catch (err: unknown) { - const msg = err instanceof Error ? err.message : String(err); - if (msg.includes("abort")) break; - console.warn(`[email] IDLE error: ${msg}`); - break; - } - - // IDLE was interrupted by new mail - await this.processUnread(); - } - } finally { - lock.release(); - } - } catch (err: unknown) { - const msg = err instanceof Error ? err.message : String(err); - console.error(`[email] IDLE loop error: ${msg}`); - } - } - - private async processUnread(): Promise { - if (!this.imapClient || !this.messageHandler) return; - - try { - const messages = this.imapClient.fetch("1:*", { + const messages = client.fetch("1:*", { uid: true, flags: true, envelope: true, @@ -222,16 +195,13 @@ export class EmailChannel implements Channel { const subject = envelope.subject ?? "(no subject)"; const messageIdHeader = envelope.messageId ?? ""; - // Extract body text from source const bodyText = extractBodyText(msg.source?.toString() ?? ""); if (!bodyText.trim()) continue; - // Skip auto-replies if (isAutoReply(subject, bodyText)) continue; const conversationId = `email:${fromAddress}:${subject.replace(/^Re:\s*/i, "")}`; - // Track the thread const references = envelope.inReplyTo ? [envelope.inReplyTo] : []; this.threads.set(conversationId, { messageId: messageIdHeader, @@ -255,9 +225,8 @@ export class EmailChannel implements Channel { }, }; - // Mark as seen try { - await this.imapClient?.messageFlagsAdd(String(msg.uid), ["\\Seen"], { uid: true }); + await client.messageFlagsAdd(String(msg.uid), ["\\Seen"], { uid: true }); } catch { // Non-critical } @@ -275,86 +244,3 @@ export class EmailChannel implements Channel { } } } - -function textToHtml(text: string): string { - const html = text - .replace(/&/g, "&") - .replace(//g, ">") - .replace(/\n/g, "
") - .replace( - /```([\s\S]*?)```/g, - '
$1
', - ) - .replace( - /`([^`]+)`/g, - '$1', - ); - - return ` -
-${html} -

-
-${"\u2014"} Phantom, your AI co-worker -
-
`.trim(); -} - -function extractBodyText(source: string): string { - // Simple extraction: get text after headers (double newline) - const headerEnd = source.indexOf("\r\n\r\n"); - if (headerEnd === -1) return source; - const body = source.slice(headerEnd + 4); - - // Strip HTML tags if present - return body - .replace(/<[^>]*>/g, "") - .replace(/ /g, " ") - .replace(/&/g, "&") - .replace(/</g, "<") - .replace(/>/g, ">") - .trim(); -} - -function isAutoReply(subject: string, body: string): boolean { - const autoReplyIndicators = [ - "out of office", - "automatic reply", - "auto-reply", - "autoreply", - "vacation reply", - "delivery status notification", - "undeliverable", - "mailer-daemon", - ]; - const combined = `${subject} ${body}`.toLowerCase(); - return autoReplyIndicators.some((indicator) => combined.includes(indicator)); -} - -// Minimal type interfaces for ImapFlow and Nodemailer -type ImapFlowClient = { - connect: () => Promise; - logout: () => Promise; - getMailboxLock: (mailbox: string) => Promise<{ release: () => void }>; - idle: (options: { abort: AbortSignal }) => Promise; - fetch: (range: string, options: Record) => AsyncIterable; - messageFlagsAdd: (uid: string, flags: string[], options: Record) => Promise; -}; - -type ImapMessage = { - uid: number; - flags: Set; - envelope: { - from?: Array<{ address?: string; name?: string }>; - subject?: string; - messageId?: string; - date?: Date; - inReplyTo?: string; - }; - source?: Buffer; -}; - -type NodemailerTransport = { - sendMail: (options: Record) => Promise; -}; diff --git a/src/channels/interaction-adapter.ts b/src/channels/interaction-adapter.ts new file mode 100644 index 0000000..f77c03b --- /dev/null +++ b/src/channels/interaction-adapter.ts @@ -0,0 +1,121 @@ +/** + * Channel-agnostic interaction adapter. + * + * Each channel that wants progress signaling, status reactions, typing + * indicators, or response-delivery customization (Slack progress streams, + * Nextcloud reactions, Telegram typing, etc.) registers a factory here. + * The orchestration in `src/index.ts` walks the registry once per inbound + * message and asks each factory whether it applies to that message. + * + * Phase 1 of the Telegram parity plan: extract the per-channel `if (isSlack)` + * / `if (isNextcloud)` / `if (isTelegram)` ladder in `src/index.ts` into + * adapter objects that share a uniform lifecycle. No behavior change in this + * step — the Slack and Nextcloud adapters are direct lifts of the existing + * code, and existing channel tests must continue to pass unchanged. + * + * Cardinal Rule: the agent decides what to say; channels render. This + * abstraction is pure rendering — every adapter method is best-effort and + * must never throw into the orchestration loop. + */ + +import type { RuntimeEvent } from "../agent/runtime.ts"; +import type { ProgressStream } from "./progress-stream.ts"; +import type { StatusReactionController } from "./status-reactions.ts"; +import type { InboundMessage } from "./types.ts"; + +/** + * Per-message adapter instance. Created once per inbound message by the + * factory; lives for the duration of one runtime turn. + * + * All methods are optional and best-effort. The orchestration calls each + * non-null hook in a fixed order: + * + * 1. `statusReactions.setQueued()` (if present) — fired immediately. + * 2. `progressStream.start()` (if present) — awaited before runtime. + * 3. `onTurnStart()` (if present) — awaited before runtime. + * 4. `onRuntimeEvent(event)` for each event from runtime.handleMessage. + * 5. `onTurnEnd({ text, isError })` — awaited after runtime. + * 6. `deliverResponse({ text, isError })` (if present) — awaited last. + * An adapter that returns `true` from `deliverResponse` claims the + * response; the orchestration will NOT fall back to router.send(). + * 7. `dispose()` (if present) — fired in the cleanup block, always. + * + * Any adapter method may return undefined/void; promises are awaited. + */ +export type ChannelInteractionInstance = { + /** Status reaction controller for this turn, if the channel supports reactions. */ + readonly statusReactions?: StatusReactionController; + + /** Progress stream for this turn, if the channel supports progressive updates. */ + readonly progressStream?: ProgressStream; + + /** Called once before runtime.handleMessage. Best-effort. */ + onTurnStart?: () => Promise | void; + + /** Called for each RuntimeEvent emitted by runtime.handleMessage. Best-effort. */ + onRuntimeEvent?: (event: RuntimeEvent) => void; + + /** + * Called once after runtime.handleMessage returns (or throws). + * `isError` reflects the combined error signal (event flag + text sniff). + * Best-effort. + */ + onTurnEnd?: (result: { text: string; isError: boolean }) => Promise | void; + + /** + * Optional channel-specific response delivery. Return `true` to claim + * the response (orchestration will skip the default router.send fallback). + * Return `false` or undefined to fall through to router.send. + * + * Slack uses this to attach feedback buttons via progressStream.finish. + * Most channels don't implement this and let the router deliver. + */ + deliverResponse?: (result: { text: string; isError: boolean }) => Promise | boolean; + + /** Cleanup hook, always called from the orchestration's cleanup block. */ + dispose?: () => void; +}; + +/** + * Factory: given an inbound message, decide whether to participate in this + * turn and return an instance. Return null to opt out. + */ +export type ChannelInteractionFactory = (msg: InboundMessage) => ChannelInteractionInstance | null; + +/** + * Registry of channel interaction factories. Iterated in registration order + * for each inbound message. The first factory whose `deliverResponse` returns + * true claims the response; other factories' `deliverResponse` hooks still + * fire (they may want to do non-delivery cleanup), but the router fallback + * is skipped. + */ +export class ChannelInteractionRegistry { + private factories: ChannelInteractionFactory[] = []; + + register(factory: ChannelInteractionFactory): void { + this.factories.push(factory); + } + + /** + * Build adapter instances for the given inbound message. Returns only + * the non-null instances, in registration order. + */ + buildFor(msg: InboundMessage): ChannelInteractionInstance[] { + const instances: ChannelInteractionInstance[] = []; + for (const factory of this.factories) { + const instance = factory(msg); + if (instance) instances.push(instance); + } + return instances; + } + + /** Test seam: number of registered factories. */ + size(): number { + return this.factories.length; + } + + /** Test seam: clear all factories. Production code never calls this. */ + clearForTests(): void { + this.factories = []; + } +} diff --git a/src/channels/slack-interaction.ts b/src/channels/slack-interaction.ts new file mode 100644 index 0000000..ce30891 --- /dev/null +++ b/src/channels/slack-interaction.ts @@ -0,0 +1,139 @@ +/** + * Slack channel interaction adapter. + * + * Phase 1 of the Telegram parity plan: extract the Slack-specific + * orchestration code from `src/index.ts` (status reactions on the user's + * message, progress streaming in the thread, response delivery with + * feedback buttons) into a single adapter factory. + * + * This is a direct lift of the existing logic — no behavior change. + * Existing Slack tests must continue to pass unchanged after the rewire + * in `src/index.ts`. + */ + +import type { ChannelInteractionFactory, ChannelInteractionInstance } from "./interaction-adapter.ts"; +import { type ProgressStream, createProgressStream, formatToolActivity } from "./progress-stream.ts"; +import type { SlackTransport } from "./slack-transport.ts"; +import { type StatusReactionController, createStatusReactionController } from "./status-reactions.ts"; +import type { InboundMessage } from "./types.ts"; + +/** + * Build a factory that produces Slack interaction adapters when the + * inbound message originates from the given Slack channel instance. + * + * Returns null for non-Slack messages or when the channel argument + * is null (Slack not configured). + */ +export function createSlackInteractionFactory(slackChannel: SlackTransport | null): ChannelInteractionFactory { + return (msg: InboundMessage): ChannelInteractionInstance | null => { + if (!slackChannel || msg.channelId !== "slack" || !msg.metadata) return null; + + const slackChannelId = msg.metadata.slackChannel as string | undefined; + const slackThreadTs = msg.metadata.slackThreadTs as string | undefined; + const slackMessageTs = msg.metadata.slackMessageTs as string | undefined; + + // Bind the channel locally so TypeScript narrowing survives the closures. + const sc = slackChannel; + + // Status reactions on the user's message + let statusReactions: StatusReactionController | undefined; + if (slackChannelId && slackMessageTs) { + const ch = slackChannelId; + const mts = slackMessageTs; + statusReactions = createStatusReactionController({ + adapter: { + addReaction: (emoji) => sc.addReaction(ch, mts, emoji), + removeReaction: (emoji) => sc.removeReaction(ch, mts, emoji), + }, + onError: (err) => { + const errMsg = err instanceof Error ? err.message : String(err); + console.warn(`[slack] Reaction error: ${errMsg}`); + }, + }); + statusReactions.setQueued(); + } + + // Progress streaming in the thread + let progressStream: ProgressStream | undefined; + if (slackChannelId && slackThreadTs) { + const ch = slackChannelId; + const tts = slackThreadTs; + progressStream = createProgressStream({ + adapter: { + postMessage: (_t) => sc.postThinking(ch, tts).then((ts) => ts ?? ""), + updateMessage: (msgId, updatedText) => sc.updateMessage(ch, msgId, updatedText), + }, + onFinish: async (messageId, text) => { + await sc.updateWithFeedback(ch, messageId, text); + }, + onError: (err) => { + const errMsg = err instanceof Error ? err.message : String(err); + console.warn(`[slack] Progress stream error: ${errMsg}`); + }, + }); + } + + const instance: ChannelInteractionInstance = { + statusReactions, + progressStream, + + async onTurnStart(): Promise { + if (progressStream) { + await progressStream.start(); + } + }, + + onRuntimeEvent(event): void { + switch (event.type) { + case "thinking": + statusReactions?.setThinking(); + break; + case "tool_use": + statusReactions?.setTool(event.tool); + if (progressStream) { + const summary = formatToolActivity(event.tool, event.input); + progressStream.addToolActivity(event.tool, summary); + } + break; + case "error": + statusReactions?.setError(); + break; + } + }, + + async onTurnEnd({ text }): Promise { + // Finalize the user-message reaction with done/error state + if (text.startsWith("Error:")) { + statusReactions?.setError(); + } else { + statusReactions?.setDone(); + } + }, + + async deliverResponse({ text }): Promise { + if (progressStream) { + // Slack happy path: update the progress message with the final + // response + feedback buttons. + await progressStream.finish(text); + return true; + } + if (slackChannelId && slackThreadTs) { + // Slack fallback: post a thinking indicator then upgrade it + // with the final response + feedback buttons in one shot. + const thinkingTs = await sc.postThinking(slackChannelId, slackThreadTs); + if (thinkingTs) { + await sc.updateWithFeedback(slackChannelId, thinkingTs, text); + return true; + } + } + return false; + }, + + dispose(): void { + statusReactions?.dispose(); + }, + }; + + return instance; + }; +} diff --git a/src/index.ts b/src/index.ts index fca36ce..2b04d9e 100644 --- a/src/index.ts +++ b/src/index.ts @@ -8,16 +8,15 @@ import type { RuntimeEvent } from "./agent/runtime.ts"; import { CliChannel } from "./channels/cli.ts"; import { EmailChannel } from "./channels/email.ts"; import { emitFeedback, setFeedbackHandler } from "./channels/feedback.ts"; -import { formatToolActivity } from "./channels/progress-stream.ts"; -import { createProgressStream } from "./channels/progress-stream.ts"; +import { ChannelInteractionRegistry } from "./channels/interaction-adapter.ts"; import { ChannelRouter } from "./channels/router.ts"; import { setActionFollowUpHandler } from "./channels/slack-actions.ts"; import { createSlackChannel, readSlackTransportFromEnv } from "./channels/slack-channel-factory.ts"; import { SlackHttpChannel } from "./channels/slack-http-receiver.ts"; import { setSlackHttpChannelProvider } from "./channels/slack-http-routes.ts"; +import { createSlackInteractionFactory } from "./channels/slack-interaction.ts"; import { SlackMetrics } from "./channels/slack-metrics.ts"; import type { SlackTransport } from "./channels/slack-transport.ts"; -import { createStatusReactionController } from "./channels/status-reactions.ts"; import { TelegramChannel } from "./channels/telegram.ts"; import { WebhookChannel } from "./channels/webhook.ts"; import { DEFAULT_METADATA_BASE_URL } from "./config/identity-fetcher.ts"; @@ -386,6 +385,10 @@ async function main(): Promise { }, }); + // Phase 1: Register interaction adapters for channel-specific features + // Declared here so router.onMessage can access it outside the slackChannel block + const interactionRegistry = new ChannelInteractionRegistry(); + if (slackChannel) { slackChannel.setPhantomName(config.name); @@ -413,6 +416,9 @@ async function main(): Promise { }); }); + // Phase 1: Register Slack interaction adapter + interactionRegistry.register(createSlackInteractionFactory(slackChannel)); + router.register(slackChannel); console.log(`[phantom] Slack channel registered (transport=${slackTransport})`); @@ -602,76 +608,33 @@ async function main(): Promise { existing.user.push(msg.text); conversationMessages.set(convKey, existing); - const isSlack = msg.channelId === "slack" && slackChannel && msg.metadata; + // Phase 1: Build interaction adapters for this message + const interactions = interactionRegistry.buildFor(msg); + + // Telegram: preserve existing typing indicator (not yet adapted) const isTelegram = msg.channelId === "telegram" && telegramChannel && msg.metadata; - const slackChannelId = isSlack ? (msg.metadata?.slackChannel as string) : null; - const slackThreadTs = isSlack ? (msg.metadata?.slackThreadTs as string) : null; - const slackMessageTs = isSlack ? (msg.metadata?.slackMessageTs as string) : null; const telegramChatId = isTelegram ? (msg.metadata?.telegramChatId as number) : null; - - // Slack: set up status reactions on the user's message - let statusReactions: ReturnType | null = null; - if (isSlack && slackChannel && slackChannelId && slackMessageTs) { - const sc = slackChannel; - const ch = slackChannelId; - const mts = slackMessageTs; - statusReactions = createStatusReactionController({ - adapter: { - addReaction: (emoji) => sc.addReaction(ch, mts, emoji), - removeReaction: (emoji) => sc.removeReaction(ch, mts, emoji), - }, - onError: (err) => { - const errMsg = err instanceof Error ? err.message : String(err); - console.warn(`[slack] Reaction error: ${errMsg}`); - }, - }); - statusReactions.setQueued(); - } - - // Slack: set up progress streaming in the thread - let progressStream: ReturnType | null = null; - if (isSlack && slackChannel && slackChannelId && slackThreadTs) { - const sc = slackChannel; - const ch = slackChannelId; - const tts = slackThreadTs; - progressStream = createProgressStream({ - adapter: { - postMessage: (_t) => sc.postThinking(ch, tts).then((ts) => ts ?? ""), - updateMessage: (msgId, updatedText) => sc.updateMessage(ch, msgId, updatedText), - }, - onFinish: async (messageId, text) => { - await sc.updateWithFeedback(ch, messageId, text); - }, - onError: (err) => { - const errMsg = err instanceof Error ? err.message : String(err); - console.warn(`[slack] Progress stream error: ${errMsg}`); - }, - }); - await progressStream.start(); - } - - // Telegram: start typing indicator if (isTelegram && telegramChannel && telegramChatId) { telegramChannel.startTyping(telegramChatId); } + // Invoke adapter lifecycle hooks + for (const interaction of interactions) { + await interaction.onTurnStart?.(); + } + const response = await runtime.handleMessage(msg.channelId, msg.conversationId, msg.text, (event: RuntimeEvent) => { switch (event.type) { case "init": console.log(`\n[phantom] Session: ${event.sessionId}`); break; case "thinking": - statusReactions?.setThinking(); - break; case "tool_use": - statusReactions?.setTool(event.tool); - if (progressStream) { - const summary = formatToolActivity(event.tool, event.input); - progressStream.addToolActivity(event.tool, summary); - } - break; case "error": - statusReactions?.setError(); + // Fan out runtime events to all adapters + for (const interaction of interactions) { + interaction.onRuntimeEvent?.(event); + } break; } }); @@ -681,36 +644,39 @@ async function main(): Promise { existing.assistant.push(response.text); } - // Finalize: set done reaction - if (response.text.startsWith("Error:")) { - await statusReactions?.setError(); - } else { - await statusReactions?.setDone(); + // Invoke adapter turn-end hooks + const isError = response.text.startsWith("Error:"); + for (const interaction of interactions) { + await interaction.onTurnEnd?.({ text: response.text, isError }); } - // Telegram: stop typing, send response + // Telegram: stop typing indicator (not yet adapted) if (isTelegram && telegramChannel && telegramChatId) { telegramChannel.stopTyping(telegramChatId); } - // Deliver the response - if (progressStream) { - // Slack: update the progress message with the final response + feedback buttons - await progressStream.finish(response.text); - } else if (isSlack && slackChannel && slackChannelId && slackThreadTs) { - // Slack fallback: send direct reply with feedback - const thinkingTs = await slackChannel.postThinking(slackChannelId, slackThreadTs); - if (thinkingTs) { - await slackChannel.updateWithFeedback(slackChannelId, thinkingTs, response.text); + // Deliver response: first adapter claiming delivery wins, otherwise router.send fallback + let delivered = false; + for (const interaction of interactions) { + const claimed = await interaction.deliverResponse?.({ text: response.text, isError }); + if (claimed) { + delivered = true; + break; } - } else { - // All other channels: send via router + } + + if (!delivered) { await router.send(msg.channelId, msg.conversationId, { text: response.text, threadId: msg.threadId, }); } + // Cleanup: dispose all adapters + for (const interaction of interactions) { + interaction.dispose?.(); + } + if (response.cost.totalUsd > 0) { console.log( `[phantom] Cost: $${response.cost.totalUsd.toFixed(4)} | ` + @@ -790,9 +756,6 @@ async function main(): Promise { console.warn(`[evolution] Post-session evolution failed: ${errMsg}`); }); } - - // Clean up - statusReactions?.dispose(); }); const server = startServer(config, startedAt);