diff --git a/docs/deploy-checklist.md b/docs/deploy-checklist.md index 780bb71..d9146e7 100644 --- a/docs/deploy-checklist.md +++ b/docs/deploy-checklist.md @@ -208,7 +208,7 @@ The user should have received a personalized DM from Phantom. Ask them to reply | SSH key rejection | `ssh-keygen -R ` | | Docker pull fails | Retry, or check Docker Hub rate limits | | Ollama model pull slow | Wait, it's a 270MB download | -| Slack not connecting | Check bot token and app token are correct | +| Slack not connecting | Check bot/app tokens and inspect `channel_details.slack` in `/health` | | No DM received | Check OWNER_SLACK_USER_ID is correct | | Health endpoint 502 | Phantom may still be starting, wait 30 seconds | | "Already initialized" on init | Remove config files first (step 6 does this) | diff --git a/docs/getting-started.md b/docs/getting-started.md index cbd59de..91cb5d6 100644 --- a/docs/getting-started.md +++ b/docs/getting-started.md @@ -313,6 +313,7 @@ Then check health at `http://localhost:3200/health`. - Verify your `SLACK_BOT_TOKEN` starts with `xoxb-` and your `SLACK_APP_TOKEN` starts with `xapp-`. - Make sure Socket Mode is enabled on your Slack app (the manifest does this automatically). - Check that the app is installed to your workspace (not just created). +- Check your Phantom `/health` endpoint and inspect `channel_details.slack.state` and `channel_details.slack.error` for the current connection status. ### "Memory not available" or Qdrant/Ollama errors diff --git a/src/channels/__tests__/router.test.ts b/src/channels/__tests__/router.test.ts index fe60fc4..7addf82 100644 --- a/src/channels/__tests__/router.test.ts +++ b/src/channels/__tests__/router.test.ts @@ -1,4 +1,4 @@ -import { describe, expect, test } from "bun:test"; +import { describe, expect, spyOn, test } from "bun:test"; import { randomUUID } from "node:crypto"; import { ChannelRouter } from "../router.ts"; import type { Channel, ChannelCapabilities, InboundMessage, OutboundMessage, SentMessage } from "../types.ts"; @@ -57,6 +57,12 @@ class MockChannel implements Channel { } } +class HangingChannel extends MockChannel { + override async connect(): Promise { + await new Promise(() => {}); + } +} + describe("ChannelRouter", () => { test("registers a channel", () => { const router = new ChannelRouter(); @@ -92,6 +98,23 @@ describe("ChannelRouter", () => { expect(ch.connected).toBe(false); }); + test("connectAll times out slow channels and continues", async () => { + const router = new ChannelRouter(); + const fast = new MockChannel("fast"); + const slow = new HangingChannel("slow"); + const errorSpy = spyOn(console, "error").mockImplementation(() => {}); + router.register(fast); + router.register(slow); + + await router.connectAll(5); + + expect(fast.connected).toBe(true); + expect(slow.connected).toBe(false); + expect(errorSpy).toHaveBeenCalled(); + expect(errorSpy.mock.calls.some((call) => call.join(" ").includes("Timed out after 5ms"))).toBe(true); + errorSpy.mockRestore(); + }); + test("routes inbound messages to handler", async () => { const router = new ChannelRouter(); const ch = new MockChannel("test"); diff --git a/src/channels/__tests__/slack.test.ts b/src/channels/__tests__/slack.test.ts index fd9eab9..edf85f6 100644 --- a/src/channels/__tests__/slack.test.ts +++ b/src/channels/__tests__/slack.test.ts @@ -1,4 +1,4 @@ -import { beforeEach, describe, expect, mock, test } from "bun:test"; +import { beforeEach, describe, expect, mock, spyOn, test } from "bun:test"; import { SlackChannel, type SlackChannelConfig } from "../slack.ts"; // Mock the Slack Bolt App class @@ -100,6 +100,18 @@ describe("SlackChannel", () => { expect(mockStop).toHaveBeenCalledTimes(1); }); + test("connect failure exposes error state", async () => { + const channel = new SlackChannel(testConfig); + const errorSpy = spyOn(console, "error").mockImplementation(() => {}); + mockStart.mockImplementationOnce(() => Promise.reject(new Error("socket hang"))); + + await expect(channel.connect()).rejects.toThrow("socket hang"); + expect(channel.isConnected()).toBe(false); + expect(channel.getConnectionState()).toBe("error"); + expect(channel.getConnectionError()).toBe("socket hang"); + errorSpy.mockRestore(); + }); + test("registers event handlers on connect", async () => { const channel = new SlackChannel(testConfig); await channel.connect(); diff --git a/src/channels/router.ts b/src/channels/router.ts index 82aa275..b13399c 100644 --- a/src/channels/router.ts +++ b/src/channels/router.ts @@ -1,6 +1,7 @@ import type { Channel, InboundMessage, OutboundMessage, SentMessage } from "./types.ts"; type MessageHandler = (message: InboundMessage) => Promise; +const DEFAULT_CONNECT_TIMEOUT_MS = 15_000; export class ChannelRouter { private channels = new Map(); @@ -18,12 +19,13 @@ export class ChannelRouter { this.handler = handler; } - async connectAll(): Promise { - const results = await Promise.allSettled([...this.channels.values()].map((ch) => ch.connect())); + async connectAll(connectTimeoutMs = DEFAULT_CONNECT_TIMEOUT_MS): Promise { + const channels = [...this.channels.values()]; + const results = await Promise.allSettled(channels.map((ch) => connectWithTimeout(ch, connectTimeoutMs))); for (const [i, result] of results.entries()) { if (result.status === "rejected") { - const ch = [...this.channels.values()][i]; + const ch = channels[i]; console.error(`[router] Failed to connect channel ${ch.id}: ${result.reason}`); } } @@ -74,3 +76,23 @@ export class ChannelRouter { } } } + +async function connectWithTimeout(channel: Channel, timeoutMs: number): Promise { + if (timeoutMs <= 0) { + await channel.connect(); + return; + } + + let timeoutId: ReturnType | null = null; + try { + const connectPromise = channel.connect(); + await Promise.race([ + connectPromise, + new Promise((_, reject) => { + timeoutId = setTimeout(() => reject(new Error(`Timed out after ${timeoutMs}ms`)), timeoutMs); + }), + ]); + } finally { + if (timeoutId) clearTimeout(timeoutId); + } +} diff --git a/src/channels/slack.ts b/src/channels/slack.ts index 587a426..02d1de6 100644 --- a/src/channels/slack.ts +++ b/src/channels/slack.ts @@ -39,6 +39,7 @@ export class SlackChannel implements Channel { private messageHandler: ((message: InboundMessage) => Promise) | null = null; private reactionHandler: ReactionHandler | null = null; private connectionState: ConnectionState = "disconnected"; + private lastConnectionError: string | null = null; private botUserId: string | null = null; private ownerUserId: string | null; private phantomName: string; @@ -93,8 +94,9 @@ export class SlackChannel implements Channel { } async connect(): Promise { - if (this.connectionState === "connected") return; + if (this.connectionState === "connected" || this.connectionState === "connecting") return; this.connectionState = "connecting"; + this.lastConnectionError = null; this.registerEventHandlers(); registerSlackActions(this.app); @@ -115,6 +117,7 @@ export class SlackChannel implements Channel { } catch (err: unknown) { this.connectionState = "error"; const msg = err instanceof Error ? err.message : String(err); + this.lastConnectionError = msg; console.error(`[slack] Failed to connect: ${msg}`); throw err; } @@ -131,6 +134,7 @@ export class SlackChannel implements Channel { } this.connectionState = "disconnected"; + this.lastConnectionError = null; console.log("[slack] Disconnected"); } @@ -174,6 +178,10 @@ export class SlackChannel implements Channel { return this.connectionState; } + getConnectionError(): string | null { + return this.lastConnectionError; + } + async postToChannel(channelId: string, text: string): Promise { const formattedText = toSlackMarkdown(text); const chunks = splitMessage(formattedText); diff --git a/src/core/server.ts b/src/core/server.ts index ebab6fb..6894286 100644 --- a/src/core/server.ts +++ b/src/core/server.ts @@ -13,6 +13,7 @@ type MemoryHealthProvider = () => Promise; type EvolutionVersionProvider = () => number; type McpServerProvider = () => PhantomMcpServer | null; type ChannelHealthProvider = () => Record; +type ChannelHealthDetailsProvider = () => Record; type RoleInfoProvider = () => { id: string; name: string } | null; type OnboardingStatusProvider = () => string; type WebhookHandler = (req: Request) => Promise; @@ -27,6 +28,7 @@ let memoryHealthProvider: MemoryHealthProvider | null = null; let evolutionVersionProvider: EvolutionVersionProvider | null = null; let mcpServerProvider: McpServerProvider | null = null; let channelHealthProvider: ChannelHealthProvider | null = null; +let channelHealthDetailsProvider: ChannelHealthDetailsProvider | null = null; let roleInfoProvider: RoleInfoProvider | null = null; let onboardingStatusProvider: OnboardingStatusProvider | null = null; let webhookHandler: WebhookHandler | null = null; @@ -49,6 +51,10 @@ export function setChannelHealthProvider(provider: ChannelHealthProvider): void channelHealthProvider = provider; } +export function setChannelHealthDetailsProvider(provider: ChannelHealthDetailsProvider): void { + channelHealthDetailsProvider = provider; +} + export function setRoleInfoProvider(provider: RoleInfoProvider): void { roleInfoProvider = provider; } @@ -86,6 +92,7 @@ export function startServer(config: PhantomConfig, startedAt: number): ReturnTyp : { qdrant: false, ollama: false, configured: false }; const channels: Record = channelHealthProvider ? channelHealthProvider() : {}; + const channelDetails = channelHealthDetailsProvider ? channelHealthDetailsProvider() : {}; const allHealthy = memory.qdrant && memory.ollama; const someHealthy = memory.qdrant || memory.ollama; @@ -106,6 +113,7 @@ export function startServer(config: PhantomConfig, startedAt: number): ReturnTyp ...(config.public_url ? { public_url: config.public_url } : {}), role: roleInfo ?? { id: config.role, name: config.role }, channels, + ...(Object.keys(channelDetails).length > 0 ? { channel_details: channelDetails } : {}), memory, evolution: { generation: evolutionGeneration, diff --git a/src/index.ts b/src/index.ts index a6e0066..41e13de 100644 --- a/src/index.ts +++ b/src/index.ts @@ -17,6 +17,7 @@ import { WebhookChannel } from "./channels/webhook.ts"; import { loadChannelsConfig, loadConfig } from "./config/loader.ts"; import { installShutdownHandlers, onShutdown } from "./core/graceful.ts"; import { + setChannelHealthDetailsProvider, setChannelHealthProvider, setEvolutionVersionProvider, setMcpServerProvider, @@ -324,6 +325,26 @@ async function main(): Promise { if (webhookChannel) health.webhook = webhookChannel.isConnected(); return health; }); + setChannelHealthDetailsProvider(() => { + const details: Record = {}; + if (slackChannel) { + details.slack = buildChannelHealthDetail( + slackChannel.isConnected(), + slackChannel.getConnectionState(), + slackChannel.getConnectionError(), + ); + } + if (telegramChannel) { + details.telegram = buildChannelHealthDetail(telegramChannel.isConnected(), telegramChannel.getConnectionState()); + } + if (emailChannel) { + details.email = buildChannelHealthDetail(emailChannel.isConnected(), emailChannel.getConnectionState()); + } + if (webhookChannel) { + details.webhook = buildChannelHealthDetail(webhookChannel.isConnected()); + } + return details; + }); // Wire action follow-up handler (button clicks -> agent) setActionFollowUpHandler(async (params) => { @@ -639,6 +660,9 @@ async function main(): Promise { } if (target) { + if (!slackChannel.isConnected()) { + console.warn("[onboarding] Slack Socket Mode is not connected yet; attempting onboarding via Web API"); + } const slackClient = slackChannel.getClient(); const profile = await startOnboarding(slackChannel, target, config.name, activeRole, db, slackClient); @@ -662,6 +686,18 @@ async function main(): Promise { console.log(`[phantom] ${config.name} is ready.`); } +function buildChannelHealthDetail( + connected: boolean, + state?: string, + error?: string | null, +): { connected: boolean; state?: string; error?: string } { + return { + connected, + ...(state ? { state } : {}), + ...(error ? { error } : {}), + }; +} + main().catch((err: unknown) => { const msg = err instanceof Error ? err.message : String(err); console.error(`[phantom] Fatal: ${msg}`); diff --git a/src/secrets/__tests__/crypto.test.ts b/src/secrets/__tests__/crypto.test.ts index 51ddd4c..597703c 100644 --- a/src/secrets/__tests__/crypto.test.ts +++ b/src/secrets/__tests__/crypto.test.ts @@ -108,14 +108,14 @@ describe("encrypt / decrypt round-trip", () => { test("tampered ciphertext fails decryption", () => { process.env.SECRET_ENCRYPTION_KEY = TEST_KEY; const { encrypted, iv, authTag } = encryptSecret("sensitive-data"); - const tampered = `X${encrypted.slice(1)}`; + const tampered = replaceFirstBase64Char(encrypted); expect(() => decryptSecret(tampered, iv, authTag)).toThrow(); }); test("tampered auth tag fails decryption", () => { process.env.SECRET_ENCRYPTION_KEY = TEST_KEY; const { encrypted, iv, authTag } = encryptSecret("sensitive-data"); - const tampered = `X${authTag.slice(1)}`; + const tampered = replaceFirstBase64Char(authTag); expect(() => decryptSecret(encrypted, iv, tampered)).toThrow(); }); @@ -128,3 +128,12 @@ describe("encrypt / decrypt round-trip", () => { expect(() => decryptSecret(encrypted, iv, authTag)).toThrow(); }); }); + +function replaceFirstBase64Char(value: string): string { + if (value.length === 0) { + throw new Error("Cannot tamper with an empty encoded value"); + } + + const replacement = value[0] === "A" ? "B" : "A"; + return `${replacement}${value.slice(1)}`; +}