Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion docs/deploy-checklist.md
Original file line number Diff line number Diff line change
Expand Up @@ -208,7 +208,7 @@ The user should have received a personalized DM from Phantom. Ask them to reply
| SSH key rejection | `ssh-keygen -R <IP>` |
| 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) |
Expand Down
1 change: 1 addition & 0 deletions docs/getting-started.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
25 changes: 24 additions & 1 deletion src/channels/__tests__/router.test.ts
Original file line number Diff line number Diff line change
@@ -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";
Expand Down Expand Up @@ -57,6 +57,12 @@ class MockChannel implements Channel {
}
}

class HangingChannel extends MockChannel {
override async connect(): Promise<void> {
await new Promise(() => {});
}
}

describe("ChannelRouter", () => {
test("registers a channel", () => {
const router = new ChannelRouter();
Expand Down Expand Up @@ -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");
Expand Down
14 changes: 13 additions & 1 deletion src/channels/__tests__/slack.test.ts
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -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();
Expand Down
28 changes: 25 additions & 3 deletions src/channels/router.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import type { Channel, InboundMessage, OutboundMessage, SentMessage } from "./types.ts";

type MessageHandler = (message: InboundMessage) => Promise<void>;
const DEFAULT_CONNECT_TIMEOUT_MS = 15_000;

export class ChannelRouter {
private channels = new Map<string, Channel>();
Expand All @@ -18,12 +19,13 @@ export class ChannelRouter {
this.handler = handler;
}

async connectAll(): Promise<void> {
const results = await Promise.allSettled([...this.channels.values()].map((ch) => ch.connect()));
async connectAll(connectTimeoutMs = DEFAULT_CONNECT_TIMEOUT_MS): Promise<void> {
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}`);
}
}
Expand Down Expand Up @@ -74,3 +76,23 @@ export class ChannelRouter {
}
}
}

async function connectWithTimeout(channel: Channel, timeoutMs: number): Promise<void> {
if (timeoutMs <= 0) {
await channel.connect();
return;
}

let timeoutId: ReturnType<typeof setTimeout> | null = null;
try {
const connectPromise = channel.connect();
await Promise.race([
connectPromise,
new Promise<never>((_, reject) => {
timeoutId = setTimeout(() => reject(new Error(`Timed out after ${timeoutMs}ms`)), timeoutMs);
}),
]);
} finally {
if (timeoutId) clearTimeout(timeoutId);
}
}
10 changes: 9 additions & 1 deletion src/channels/slack.ts
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@ export class SlackChannel implements Channel {
private messageHandler: ((message: InboundMessage) => Promise<void>) | 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;
Expand Down Expand Up @@ -93,8 +94,9 @@ export class SlackChannel implements Channel {
}

async connect(): Promise<void> {
if (this.connectionState === "connected") return;
if (this.connectionState === "connected" || this.connectionState === "connecting") return;
this.connectionState = "connecting";
this.lastConnectionError = null;

this.registerEventHandlers();
registerSlackActions(this.app);
Expand All @@ -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;
}
Expand All @@ -131,6 +134,7 @@ export class SlackChannel implements Channel {
}

this.connectionState = "disconnected";
this.lastConnectionError = null;
console.log("[slack] Disconnected");
}

Expand Down Expand Up @@ -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<string | null> {
const formattedText = toSlackMarkdown(text);
const chunks = splitMessage(formattedText);
Expand Down
8 changes: 8 additions & 0 deletions src/core/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ type MemoryHealthProvider = () => Promise<MemoryHealth>;
type EvolutionVersionProvider = () => number;
type McpServerProvider = () => PhantomMcpServer | null;
type ChannelHealthProvider = () => Record<string, boolean>;
type ChannelHealthDetailsProvider = () => Record<string, { connected: boolean; state?: string; error?: string }>;
type RoleInfoProvider = () => { id: string; name: string } | null;
type OnboardingStatusProvider = () => string;
type WebhookHandler = (req: Request) => Promise<Response>;
Expand All @@ -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;
Expand All @@ -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;
}
Expand Down Expand Up @@ -86,6 +92,7 @@ export function startServer(config: PhantomConfig, startedAt: number): ReturnTyp
: { qdrant: false, ollama: false, configured: false };

const channels: Record<string, boolean> = channelHealthProvider ? channelHealthProvider() : {};
const channelDetails = channelHealthDetailsProvider ? channelHealthDetailsProvider() : {};

const allHealthy = memory.qdrant && memory.ollama;
const someHealthy = memory.qdrant || memory.ollama;
Expand All @@ -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,
Expand Down
36 changes: 36 additions & 0 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -324,6 +325,26 @@ async function main(): Promise<void> {
if (webhookChannel) health.webhook = webhookChannel.isConnected();
return health;
});
setChannelHealthDetailsProvider(() => {
const details: Record<string, { connected: boolean; state?: string; error?: string }> = {};
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) => {
Expand Down Expand Up @@ -639,6 +660,9 @@ async function main(): Promise<void> {
}

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);

Expand All @@ -662,6 +686,18 @@ async function main(): Promise<void> {
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}`);
Expand Down
13 changes: 11 additions & 2 deletions src/secrets/__tests__/crypto.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();
});

Expand All @@ -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)}`;
}
Loading