diff --git a/apps/macos/Sources/Clawdis/AgentRPC.swift b/apps/macos/Sources/Clawdis/AgentRPC.swift index fed0e8e78..7742ee273 100644 --- a/apps/macos/Sources/Clawdis/AgentRPC.swift +++ b/apps/macos/Sources/Clawdis/AgentRPC.swift @@ -13,9 +13,8 @@ actor AgentRPC { private var configured = false private var gatewayURL: URL { - let port = UserDefaults.standard.integer(forKey: "gatewayPort") - let effectivePort = port > 0 ? port : 18789 - return URL(string: "ws://127.0.0.1:\(effectivePort)")! + let port = RelayEnvironment.gatewayPort() + return URL(string: "ws://127.0.0.1:\(port)")! } private var gatewayToken: String? { diff --git a/apps/macos/Sources/Clawdis/ControlChannel.swift b/apps/macos/Sources/Clawdis/ControlChannel.swift index f11d8d43a..66182756f 100644 --- a/apps/macos/Sources/Clawdis/ControlChannel.swift +++ b/apps/macos/Sources/Clawdis/ControlChannel.swift @@ -57,9 +57,8 @@ final class ControlChannel: ObservableObject { private let logger = Logger(subsystem: "com.steipete.clawdis", category: "control") private let gateway = GatewayChannel() private var gatewayURL: URL { - let port = UserDefaults.standard.integer(forKey: "gatewayPort") - let effectivePort = port > 0 ? port : 18789 - return URL(string: "ws://127.0.0.1:\(effectivePort)")! + let port = RelayEnvironment.gatewayPort() + return URL(string: "ws://127.0.0.1:\(port)")! } private var gatewayToken: String? { diff --git a/apps/macos/Sources/Clawdis/GeneralSettings.swift b/apps/macos/Sources/Clawdis/GeneralSettings.swift index d4de3b6e6..a0e0e8dbd 100644 --- a/apps/macos/Sources/Clawdis/GeneralSettings.swift +++ b/apps/macos/Sources/Clawdis/GeneralSettings.swift @@ -8,6 +8,9 @@ struct GeneralSettings: View { @State private var cliStatus: String? @State private var cliInstalled = false @State private var cliInstallLocation: String? + @State private var relayStatus: RelayEnvironmentStatus = .checking + @State private var relayInstallMessage: String? + @State private var relayInstalling = false @State private var remoteStatus: RemoteStatus = .idle @State private var showRemoteAdvanced = false @@ -61,7 +64,10 @@ struct GeneralSettings: View { .padding(.horizontal, 22) .padding(.bottom, 16) } - .onAppear { self.refreshCLIStatus() } + .onAppear { + self.refreshCLIStatus() + self.refreshRelayStatus() + } } private var activeBinding: Binding { @@ -84,6 +90,7 @@ struct GeneralSettings: View { .frame(width: 380, alignment: .leading) if self.state.connectionMode == .local { + self.relayInstallerCard self.healthRow } @@ -239,6 +246,64 @@ struct GeneralSettings: View { } } + private var relayInstallerCard: some View { + VStack(alignment: .leading, spacing: 8) { + HStack(spacing: 10) { + Circle() + .fill(self.relayStatusColor) + .frame(width: 10, height: 10) + Text(self.relayStatus.message) + .font(.callout) + .frame(maxWidth: .infinity, alignment: .leading) + } + + if let relayVersion = self.relayStatus.relayVersion, + let required = self.relayStatus.requiredRelay, + relayVersion != required + { + Text("Installed: \(relayVersion) · Required: \(required)") + .font(.caption) + .foregroundStyle(.secondary) + } else if let relayVersion = self.relayStatus.relayVersion { + Text("Relay \(relayVersion) detected") + .font(.caption) + .foregroundStyle(.secondary) + } + + if let node = self.relayStatus.nodeVersion { + Text("Node \(node)") + .font(.caption) + .foregroundStyle(.secondary) + } + + HStack(spacing: 10) { + Button { + Task { await self.installRelay() } + } label: { + if self.relayInstalling { + ProgressView().controlSize(.small) + } else { + Text("Install/Update relay") + } + } + .buttonStyle(.borderedProminent) + .disabled(self.relayInstalling) + + Button("Recheck") { self.refreshRelayStatus() } + .buttonStyle(.bordered) + .disabled(self.relayInstalling) + } + + Text(self.relayInstallMessage ?? "Installs the global \"clawdis\" package and expects the gateway on port 18789.") + .font(.caption) + .foregroundStyle(.secondary) + .lineLimit(2) + } + .padding(12) + .background(Color.gray.opacity(0.08)) + .cornerRadius(10) + } + private func installCLI() async { guard !self.isInstallingCLI else { return } self.isInstallingCLI = true @@ -257,6 +322,30 @@ struct GeneralSettings: View { self.cliInstalled = installLocation != nil } + private func refreshRelayStatus() { + self.relayStatus = RelayEnvironment.check() + } + + private func installRelay() async { + guard !self.relayInstalling else { return } + self.relayInstalling = true + defer { self.relayInstalling = false } + self.relayInstallMessage = nil + let expected = RelayEnvironment.expectedRelayVersion() + await RelayEnvironment.installGlobal(version: expected) { message in + Task { @MainActor in self.relayInstallMessage = message } + } + self.refreshRelayStatus() + } + + private var relayStatusColor: Color { + switch self.relayStatus.kind { + case .ok: .green + case .checking: .secondary + case .missingNode, .missingRelay, .incompatible, .error: .orange + } + } + private var healthCard: some View { let snapshot = self.healthStore.snapshot return VStack(alignment: .leading, spacing: 6) { diff --git a/apps/macos/Sources/Clawdis/HealthStore.swift b/apps/macos/Sources/Clawdis/HealthStore.swift index 0db1a2b68..9aad27013 100644 --- a/apps/macos/Sources/Clawdis/HealthStore.swift +++ b/apps/macos/Sources/Clawdis/HealthStore.swift @@ -151,15 +151,15 @@ final class HealthStore: ObservableObject { /// Short, human-friendly detail for the last failure, used in the UI. var detailLine: String? { - if let error = self.lastError, !error.isEmpty { - let lower = error.lowercased() - if lower.contains("connection refused") { - return "The relay control port (127.0.0.1:18789) isn’t listening — restart Clawdis to bring it back." - } - if lower.contains("timeout") { - return "Timed out waiting for the control server; the relay may be crashed or still starting." - } - return error + if let error = self.lastError, !error.isEmpty { + let lower = error.lowercased() + if lower.contains("connection refused") { + return "The gateway control port (127.0.0.1:18789) isn’t listening — restart Clawdis to bring it back." + } + if lower.contains("timeout") { + return "Timed out waiting for the control server; the relay may be crashed or still starting." + } + return error } return nil } diff --git a/apps/macos/Sources/Clawdis/Onboarding.swift b/apps/macos/Sources/Clawdis/Onboarding.swift index 45487c4a4..86bc4740c 100644 --- a/apps/macos/Sources/Clawdis/Onboarding.swift +++ b/apps/macos/Sources/Clawdis/Onboarding.swift @@ -46,13 +46,16 @@ struct OnboardingView: View { @State private var monitoringPermissions = false @State private var cliInstalled = false @State private var cliInstallLocation: String? + @State private var relayStatus: RelayEnvironmentStatus = .checking + @State private var relayInstalling = false + @State private var relayInstallMessage: String? @ObservedObject private var state = AppStateStore.shared @ObservedObject private var permissionMonitor = PermissionMonitor.shared private let pageWidth: CGFloat = 680 private let contentHeight: CGFloat = 520 - private let permissionsPageIndex = 2 - private var pageCount: Int { 6 } + private let permissionsPageIndex = 3 + private var pageCount: Int { 7 } private var buttonTitle: String { self.currentPage == self.pageCount - 1 ? "Finish" : "Next" } private let devLinkCommand = "ln -sf $(pwd)/apps/macos/.build/debug/ClawdisCLI /usr/local/bin/clawdis-mac" @@ -67,6 +70,7 @@ struct OnboardingView: View { HStack(spacing: 0) { self.welcomePage().frame(width: self.pageWidth) self.connectionPage().frame(width: self.pageWidth) + self.relayPage().frame(width: self.pageWidth) self.permissionsPage().frame(width: self.pageWidth) self.cliPage().frame(width: self.pageWidth) self.whatsappPage().frame(width: self.pageWidth) @@ -96,6 +100,7 @@ struct OnboardingView: View { .task { await self.refreshPerms() self.refreshCLIStatus() + self.refreshRelayStatus() } } @@ -172,6 +177,81 @@ struct OnboardingView: View { } } + private func relayPage() -> some View { + self.onboardingPage { + Text("Install the relay") + .font(.largeTitle.weight(.semibold)) + Text("Clawdis now runs the WebSocket gateway from the global \"clawdis\" package. Install/update it here and we’ll check Node for you.") + .font(.body) + .foregroundStyle(.secondary) + .multilineTextAlignment(.center) + .frame(maxWidth: 520) + .fixedSize(horizontal: false, vertical: true) + + self.onboardingCard(spacing: 10, padding: 14) { + VStack(alignment: .leading, spacing: 8) { + HStack(spacing: 10) { + Circle() + .fill(self.relayStatusColor) + .frame(width: 10, height: 10) + Text(self.relayStatus.message) + .font(.callout.weight(.semibold)) + .frame(maxWidth: .infinity, alignment: .leading) + } + + if let relayVersion = self.relayStatus.relayVersion, + let required = self.relayStatus.requiredRelay, + relayVersion != required + { + Text("Installed: \(relayVersion) · Required: \(required)") + .font(.caption) + .foregroundStyle(.secondary) + } else if let relayVersion = self.relayStatus.relayVersion { + Text("Relay \(relayVersion) detected") + .font(.caption) + .foregroundStyle(.secondary) + } + + if let node = self.relayStatus.nodeVersion { + Text("Node \(node)") + .font(.caption) + .foregroundStyle(.secondary) + } + + HStack(spacing: 12) { + Button { + Task { await self.installRelay() } + } label: { + if self.relayInstalling { + ProgressView() + } else { + Text("Install / Update relay") + } + } + .buttonStyle(.borderedProminent) + .disabled(self.relayInstalling) + + Button("Recheck") { self.refreshRelayStatus() } + .buttonStyle(.bordered) + .disabled(self.relayInstalling) + } + + if let relayInstallMessage { + Text(relayInstallMessage) + .font(.caption) + .foregroundStyle(.secondary) + .lineLimit(2) + } else { + Text("Uses \"pnpm add -g clawdis@\" on your PATH. We keep the gateway on port 18789.") + .font(.caption) + .foregroundStyle(.secondary) + .lineLimit(2) + } + } + } + } + } + private func permissionsPage() -> some View { self.onboardingPage { Text("Grant permissions") @@ -481,6 +561,30 @@ struct OnboardingView: View { self.cliInstalled = installLocation != nil } + private func refreshRelayStatus() { + self.relayStatus = RelayEnvironment.check() + } + + private func installRelay() async { + guard !self.relayInstalling else { return } + self.relayInstalling = true + defer { self.relayInstalling = false } + self.relayInstallMessage = nil + let expected = RelayEnvironment.expectedRelayVersion() + await RelayEnvironment.installGlobal(version: expected) { message in + Task { @MainActor in self.relayInstallMessage = message } + } + self.refreshRelayStatus() + } + + private var relayStatusColor: Color { + switch self.relayStatus.kind { + case .ok: .green + case .checking: .secondary + case .missingNode, .missingRelay, .incompatible, .error: .orange + } + } + private func copyToPasteboard(_ text: String) { let pb = NSPasteboard.general pb.clearContents() diff --git a/apps/macos/Sources/Clawdis/RelayEnvironment.swift b/apps/macos/Sources/Clawdis/RelayEnvironment.swift new file mode 100644 index 000000000..6e7e03f72 --- /dev/null +++ b/apps/macos/Sources/Clawdis/RelayEnvironment.swift @@ -0,0 +1,162 @@ +import Foundation +import ClawdisIPC + +// Lightweight SemVer helper (major.minor.patch only) for relay compatibility checks. +struct Semver: Comparable, CustomStringConvertible, Sendable { + let major: Int + let minor: Int + let patch: Int + + var description: String { "\(self.major).\(self.minor).\(self.patch)" } + + static func < (lhs: Semver, rhs: Semver) -> Bool { + if lhs.major != rhs.major { return lhs.major < rhs.major } + if lhs.minor != rhs.minor { return lhs.minor < rhs.minor } + return lhs.patch < rhs.patch + } + + static func parse(_ raw: String?) -> Semver? { + guard let raw, !raw.isEmpty else { return nil } + let cleaned = raw.trimmingCharacters(in: .whitespacesAndNewlines) + .replacingOccurrences(of: "^v", with: "", options: .regularExpression) + let parts = cleaned.split(separator: ".") + guard parts.count >= 3, + let major = Int(parts[0]), + let minor = Int(parts[1]) + else { return nil } + let patch = Int(parts[2]) ?? 0 + return Semver(major: major, minor: minor, patch: patch) + } + + func compatible(with required: Semver) -> Bool { + // Same major and not older than required. + self.major == required.major && self >= required + } +} + +enum RelayEnvironmentKind: Equatable { + case checking + case ok + case missingNode + case missingRelay + case incompatible(found: String, required: String) + case error(String) +} + +struct RelayEnvironmentStatus: Equatable { + let kind: RelayEnvironmentKind + let nodeVersion: String? + let relayVersion: String? + let requiredRelay: String? + let message: String + + static var checking: Self { + .init(kind: .checking, nodeVersion: nil, relayVersion: nil, requiredRelay: nil, message: "Checking…") + } +} + +struct RelayCommandResolution { + let status: RelayEnvironmentStatus + let command: [String]? +} + +enum RelayEnvironment { + static func gatewayPort() -> Int { + let stored = UserDefaults.standard.integer(forKey: "gatewayPort") + return stored > 0 ? stored : 18789 + } + + static func expectedRelayVersion() -> Semver? { + let bundleVersion = Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String + return Semver.parse(bundleVersion) + } + + static func check() -> RelayEnvironmentStatus { + let expected = self.expectedRelayVersion() + + switch RuntimeLocator.resolve(searchPaths: CommandResolver.preferredPaths()) { + case let .failure(err): + return RelayEnvironmentStatus( + kind: .missingNode, + nodeVersion: nil, + relayVersion: nil, + requiredRelay: expected?.description, + message: RuntimeLocator.describeFailure(err)) + case let .success(runtime): + guard let relayBin = CommandResolver.clawdisExecutable() else { + return RelayEnvironmentStatus( + kind: .missingRelay, + nodeVersion: runtime.version.description, + relayVersion: nil, + requiredRelay: expected?.description, + message: "clawdis CLI not found in PATH; install the global package.") + } + + let installedRelay = self.readRelayVersion(binary: relayBin) + if let expected, let installed = installedRelay, !installed.compatible(with: expected) { + return RelayEnvironmentStatus( + kind: .incompatible(found: installed.description, required: expected.description), + nodeVersion: runtime.version.description, + relayVersion: installed.description, + requiredRelay: expected.description, + message: "Relay version \(installed.description) is incompatible with app \(expected.description); install/update the global package.") + } + + return RelayEnvironmentStatus( + kind: .ok, + nodeVersion: runtime.version.description, + relayVersion: installedRelay?.description, + requiredRelay: expected?.description, + message: "Node \(runtime.version.description); relay \(installedRelay?.description ?? "unknown")") + } + } + + static func resolveGatewayCommand() -> RelayCommandResolution { + let status = self.check() + guard case .ok = status.kind, let relayBin = CommandResolver.clawdisExecutable() else { + return RelayCommandResolution(status: status, command: nil) + } + + let port = self.gatewayPort() + let cmd = [relayBin, "gateway", "--port", "\(port)"] + return RelayCommandResolution(status: status, command: cmd) + } + + static func installGlobal(version: Semver?, statusHandler: @escaping @Sendable (String) -> Void) async { + let preferred = CommandResolver.preferredPaths().joined(separator: ":") + let target = version?.description ?? "latest" + let pnpm = CommandResolver.findExecutable(named: "pnpm") ?? "pnpm" + let cmd = [pnpm, "add", "-g", "clawdis@\(target)"] + + statusHandler("Installing clawdis@\(target) via pnpm…") + let response = await ShellRunner.run(command: cmd, cwd: nil, env: ["PATH": preferred], timeout: 300) + if response.ok { + statusHandler("Installed clawdis@\(target)") + } else { + let detail = response.message ?? "install failed" + statusHandler("Install failed: \(detail)") + } + } + + // MARK: - Internals + + private static func readRelayVersion(binary: String) -> Semver? { + let process = Process() + process.executableURL = URL(fileURLWithPath: binary) + process.arguments = ["--version"] + process.environment = ["PATH": CommandResolver.preferredPaths().joined(separator: ":")] + + let pipe = Pipe() + process.standardOutput = pipe + process.standardError = pipe + do { + try process.run() + process.waitUntilExit() + let data = pipe.fileHandleForReading.readDataToEndOfFile() + let raw = String(data: data, encoding: .utf8)?.trimmingCharacters(in: .whitespacesAndNewlines) + return Semver.parse(raw) + } catch { + return nil + } + } +} diff --git a/apps/macos/Sources/Clawdis/RelayProcessManager.swift b/apps/macos/Sources/Clawdis/RelayProcessManager.swift index 4e8802b42..d13639e0e 100644 --- a/apps/macos/Sources/Clawdis/RelayProcessManager.swift +++ b/apps/macos/Sources/Clawdis/RelayProcessManager.swift @@ -35,6 +35,7 @@ final class RelayProcessManager: ObservableObject { @Published private(set) var status: Status = .stopped @Published private(set) var log: String = "" @Published private(set) var restartCount: Int = 0 + @Published private(set) var environmentStatus: RelayEnvironmentStatus = .checking private var execution: Execution? private var desiredActive = false @@ -48,6 +49,7 @@ final class RelayProcessManager: ObservableObject { func setActive(_ active: Bool) { self.desiredActive = active + self.refreshEnvironmentStatus() if active { self.startIfNeeded() } else { @@ -82,10 +84,22 @@ final class RelayProcessManager: ObservableObject { self.execution = nil } + func refreshEnvironmentStatus() { + self.environmentStatus = RelayEnvironment.check() + } + // MARK: - Internals private func spawnRelay() async { - let command = self.resolveCommand() + let resolution = RelayEnvironment.resolveGatewayCommand() + await MainActor.run { self.environmentStatus = resolution.status } + guard let command = resolution.command else { + await MainActor.run { + self.status = .failed(resolution.status.message) + } + return + } + let cwd = self.defaultProjectRoot().path self.appendLog("[relay] starting: \(command.joined(separator: " ")) (cwd: \(cwd))\n") @@ -192,29 +206,6 @@ final class RelayProcessManager: ObservableObject { } } - private func resolveCommand() -> [String] { - // Force the app-managed relay to use system Node (no bundled runtime, no bun). - let runtimeResult = CommandResolver.runtimeResolution() - guard case let .success(runtime) = runtimeResult else { - if case let .failure(err) = runtimeResult { - return CommandResolver.runtimeErrorCommand(err) - } - return ["/bin/sh", "-c", "echo 'runtime resolution failed' >&2; exit 1"] - } - - let relayRoot = CommandResolver.projectRoot() - if let entry = CommandResolver.relayEntrypoint(in: relayRoot) { - return CommandResolver.makeRuntimeCommand( - runtime: runtime, - entrypoint: entry, - subcommand: "relay", - extraArgs: []) - } - - return CommandResolver.errorCommand( - with: "clawdis entrypoint missing (looked for dist/index.js or bin/clawdis.js); run pnpm build.") - } - private func makeEnvironment() -> Environment { let merged = CommandResolver.preferredPaths().joined(separator: ":") return .inherit.updating([ diff --git a/apps/macos/Sources/Clawdis/Utilities.swift b/apps/macos/Sources/Clawdis/Utilities.swift index bc48434be..3791c3031 100644 --- a/apps/macos/Sources/Clawdis/Utilities.swift +++ b/apps/macos/Sources/Clawdis/Utilities.swift @@ -201,12 +201,6 @@ enum CommandResolver { private static let projectRootDefaultsKey = "clawdis.relayProjectRootPath" private static let helperName = "clawdis" - private static func bundledRelayRoot() -> URL? { - guard let resource = Bundle.main.resourceURL else { return nil } - let relay = resource.appendingPathComponent("Relay") - return FileManager.default.fileExists(atPath: relay.path) ? relay : nil - } - static func relayEntrypoint(in root: URL) -> String? { let distEntry = root.appendingPathComponent("dist/index.js").path if FileManager.default.isReadableFile(atPath: distEntry) { return distEntry } @@ -244,11 +238,9 @@ enum CommandResolver { } static func projectRoot() -> URL { - if let bundled = self.bundledRelayRoot() { - return bundled - } if let stored = UserDefaults.standard.string(forKey: self.projectRootDefaultsKey), - let url = self.expandPath(stored) + let url = self.expandPath(stored), + FileManager.default.fileExists(atPath: url.path) { return url } @@ -272,16 +264,13 @@ enum CommandResolver { let current = ProcessInfo.processInfo.environment["PATH"]? .split(separator: ":").map(String.init) ?? [] var extras = [ - self.projectRoot().appendingPathComponent("node_modules/.bin").path, FileManager.default.homeDirectoryForCurrentUser.appendingPathComponent("Library/pnpm").path, "/opt/homebrew/bin", "/usr/local/bin", "/usr/bin", "/bin", ] - if let relay = self.bundledRelayRoot() { - extras.insert(relay.appendingPathComponent("node_modules/.bin").path, at: 0) - } + extras.insert(self.projectRoot().appendingPathComponent("node_modules/.bin").path, at: 0) var seen = Set() // Preserve order while stripping duplicates so PATH lookups remain deterministic. return (extras + current).filter { seen.insert($0).inserted } @@ -327,14 +316,8 @@ enum CommandResolver { switch runtimeResult { case let .success(runtime): - if let relay = self.bundledRelayRoot(), - let entry = self.relayEntrypoint(in: relay) - { - return self.makeRuntimeCommand( - runtime: runtime, - entrypoint: entry, - subcommand: subcommand, - extraArgs: extraArgs) + if let clawdisPath = self.clawdisExecutable() { + return [clawdisPath, subcommand] + extraArgs } if let entry = self.relayEntrypoint(in: self.projectRoot()) { @@ -344,10 +327,6 @@ enum CommandResolver { subcommand: subcommand, extraArgs: extraArgs) } - - if let clawdisPath = self.clawdisExecutable() { - return [clawdisPath, subcommand] + extraArgs - } if let pnpm = self.findExecutable(named: "pnpm") { // Use --silent to avoid pnpm lifecycle banners that would corrupt JSON outputs. return [pnpm, "--silent", "clawdis", subcommand] + extraArgs diff --git a/src/cli/program.ts b/src/cli/program.ts index 1ee188885..7e1db9783 100644 --- a/src/cli/program.ts +++ b/src/cli/program.ts @@ -397,18 +397,39 @@ Examples: .command("status") .description("Show web session health and recent session recipients") .option("--json", "Output JSON instead of text", false) + .option("--deep", "Probe providers (WA connect + Telegram API)", false) + .option("--timeout ", "Probe timeout in milliseconds", "10000") .option("--verbose", "Verbose logging", false) .addHelpText( "after", ` Examples: clawdis status # show linked account + session store summary - clawdis status --json # machine-readable output`, + clawdis status --json # machine-readable output + clawdis status --deep # run provider probes (WA + Telegram) + clawdis status --deep --timeout 5000 # tighten probe timeout`, ) .action(async (opts) => { setVerbose(Boolean(opts.verbose)); + const timeout = opts.timeout + ? Number.parseInt(String(opts.timeout), 10) + : undefined; + if (timeout !== undefined && (Number.isNaN(timeout) || timeout <= 0)) { + defaultRuntime.error( + "--timeout must be a positive integer (milliseconds)", + ); + defaultRuntime.exit(1); + return; + } try { - await statusCommand(opts, defaultRuntime); + await statusCommand( + { + json: Boolean(opts.json), + deep: Boolean(opts.deep), + timeoutMs: timeout, + }, + defaultRuntime, + ); } catch (err) { defaultRuntime.error(String(err)); defaultRuntime.exit(1); diff --git a/src/commands/health.test.ts b/src/commands/health.test.ts index 61d55e838..105dafde6 100644 --- a/src/commands/health.test.ts +++ b/src/commands/health.test.ts @@ -21,6 +21,9 @@ vi.mock("../config/sessions.js", () => ({ const waitForWaConnection = vi.fn(); const webAuthExists = vi.fn(); +const fetchMock = vi.fn(); + +vi.stubGlobal("fetch", fetchMock); vi.mock("../web/session.js", () => ({ createWaSocket: vi.fn(async () => ({ @@ -41,11 +44,25 @@ vi.mock("../web/reconnect.js", () => ({ describe("healthCommand", () => { beforeEach(() => { vi.clearAllMocks(); + delete process.env.TELEGRAM_BOT_TOKEN; + fetchMock.mockReset(); }); it("outputs JSON when linked and connect succeeds", async () => { webAuthExists.mockResolvedValue(true); waitForWaConnection.mockResolvedValue(undefined); + process.env.TELEGRAM_BOT_TOKEN = "123:abc"; + fetchMock + .mockResolvedValueOnce({ + ok: true, + status: 200, + json: async () => ({ ok: true, result: { id: 1, username: "bot" } }), + }) + .mockResolvedValueOnce({ + ok: true, + status: 200, + json: async () => ({ ok: true, result: { url: "https://hook" } }), + }); await healthCommand({ json: true, timeoutMs: 5000 }, runtime as never); @@ -54,6 +71,8 @@ describe("healthCommand", () => { const parsed = JSON.parse(logged); expect(parsed.web.linked).toBe(true); expect(parsed.web.connect.ok).toBe(true); + expect(parsed.telegram.configured).toBe(true); + expect(parsed.telegram.probe.ok).toBe(true); expect(parsed.sessions.count).toBe(1); }); @@ -75,4 +94,24 @@ describe("healthCommand", () => { expect(parsed.web.connect.ok).toBe(false); expect(parsed.web.connect.status).toBe(440); }); + + it("exits non-zero when telegram probe fails", async () => { + webAuthExists.mockResolvedValue(true); + waitForWaConnection.mockResolvedValue(undefined); + process.env.TELEGRAM_BOT_TOKEN = "123:abc"; + fetchMock.mockResolvedValue({ + ok: false, + status: 401, + json: async () => ({ ok: false, description: "unauthorized" }), + }); + + await healthCommand({ json: true }, runtime as never); + + expect(runtime.exit).toHaveBeenCalledWith(1); + const logged = runtime.log.mock.calls[0][0] as string; + const parsed = JSON.parse(logged); + expect(parsed.telegram.configured).toBe(true); + expect(parsed.telegram.probe.ok).toBe(false); + expect(parsed.telegram.probe.status).toBe(401); + }); }); diff --git a/src/commands/health.ts b/src/commands/health.ts index f51deadbc..f4f3ad567 100644 --- a/src/commands/health.ts +++ b/src/commands/health.ts @@ -4,6 +4,7 @@ import path from "node:path"; import { loadConfig } from "../config/config.js"; import { loadSessionStore, resolveStorePath } from "../config/sessions.js"; import { info } from "../globals.js"; +import { makeProxyFetch } from "../telegram/proxy.js"; import type { RuntimeEnv } from "../runtime.js"; import { resolveHeartbeatSeconds } from "../web/reconnect.js"; import { @@ -22,6 +23,15 @@ type HealthConnect = { elapsedMs: number; }; +type TelegramProbe = { + ok: boolean; + status?: number | null; + error?: string | null; + elapsedMs: number; + bot?: { id?: number | null; username?: string | null }; + webhook?: { url?: string | null; hasCustomCert?: boolean | null }; +}; + export type HealthSummary = { ts: number; durationMs: number; @@ -30,6 +40,10 @@ export type HealthSummary = { authAgeMs: number | null; connect?: HealthConnect; }; + telegram: { + configured: boolean; + probe?: TelegramProbe; + }; heartbeatSeconds: number; sessions: { path: string; @@ -44,6 +58,7 @@ export type HealthSummary = { }; const DEFAULT_TIMEOUT_MS = 10_000; +const TELEGRAM_API_BASE = "https://api.telegram.org"; async function probeWebConnect(timeoutMs: number): Promise { const started = Date.now(); @@ -77,6 +92,93 @@ async function probeWebConnect(timeoutMs: number): Promise { } } +async function fetchWithTimeout( + url: string, + timeoutMs: number, + fetcher: typeof fetch, +): Promise { + const controller = new AbortController(); + const timer = setTimeout(() => controller.abort(), timeoutMs); + try { + return await fetcher(url, { signal: controller.signal }); + } finally { + clearTimeout(timer); + } +} + +async function probeTelegram( + token: string, + timeoutMs: number, + proxyUrl?: string, +): Promise { + const started = Date.now(); + const fetcher = proxyUrl ? makeProxyFetch(proxyUrl) : fetch; + const base = `${TELEGRAM_API_BASE}/bot${token}`; + + const result: TelegramProbe = { + ok: false, + status: null, + error: null, + elapsedMs: 0, + }; + + try { + const meRes = await fetchWithTimeout(`${base}/getMe`, timeoutMs, fetcher); + const meJson = (await meRes.json()) as { + ok?: boolean; + description?: string; + result?: { id?: number; username?: string }; + }; + if (!meRes.ok || !meJson?.ok) { + result.status = meRes.status; + result.error = meJson?.description ?? `getMe failed (${meRes.status})`; + return { ...result, elapsedMs: Date.now() - started }; + } + + result.bot = { + id: meJson.result?.id ?? null, + username: meJson.result?.username ?? null, + }; + + // Try to fetch webhook info, but don't fail health if it errors + try { + const webhookRes = await fetchWithTimeout( + `${base}/getWebhookInfo`, + timeoutMs, + fetcher, + ); + const webhookJson = (await webhookRes.json()) as { + ok?: boolean; + result?: { + url?: string; + has_custom_certificate?: boolean; + }; + }; + if (webhookRes.ok && webhookJson?.ok) { + result.webhook = { + url: webhookJson.result?.url ?? null, + hasCustomCert: webhookJson.result?.has_custom_certificate ?? null, + }; + } + } catch { + // ignore webhook errors for health + } + + result.ok = true; + result.status = null; + result.error = null; + result.elapsedMs = Date.now() - started; + return result; + } catch (err) { + return { + ...result, + status: err instanceof Response ? err.status : result.status, + error: err instanceof Error ? err.message : String(err), + elapsedMs: Date.now() - started, + }; + } +} + export async function getHealthSnapshot( timeoutMs?: number, ): Promise { @@ -103,10 +205,19 @@ export async function getHealthSnapshot( const cappedTimeout = Math.max(1000, timeoutMs ?? DEFAULT_TIMEOUT_MS); const connect = linked ? await probeWebConnect(cappedTimeout) : undefined; + const telegramToken = + process.env.TELEGRAM_BOT_TOKEN ?? cfg.telegram?.botToken ?? ""; + const telegramConfigured = telegramToken.trim().length > 0; + const telegramProxy = cfg.telegram?.proxy; + const telegramProbe = telegramConfigured + ? await probeTelegram(telegramToken.trim(), cappedTimeout, telegramProxy) + : undefined; + const summary: HealthSummary = { ts: Date.now(), durationMs: Date.now() - start, web: { linked, authAgeMs, connect }, + telegram: { configured: telegramConfigured, probe: telegramProbe }, heartbeatSeconds, sessions: { path: storePath, @@ -125,7 +236,11 @@ export async function healthCommand( ) { const summary = await getHealthSnapshot(opts.timeoutMs); const fatal = - !summary.web.linked || (summary.web.connect && !summary.web.connect.ok); + !summary.web.linked || + (summary.web.connect && !summary.web.connect.ok) || + (summary.telegram.configured && + summary.telegram.probe && + !summary.telegram.probe.ok); if (opts.json) { runtime.log(JSON.stringify(summary, null, 2)); @@ -147,6 +262,19 @@ export async function healthCommand( (summary.web.connect.error ? ` - ${summary.web.connect.error}` : ""), ); } + + const tgLabel = summary.telegram.configured + ? summary.telegram.probe?.ok + ? info( + `Telegram: ok${summary.telegram.probe.bot?.username ? ` (@${summary.telegram.probe.bot.username})` : ""} (${summary.telegram.probe.elapsedMs}ms)` + + (summary.telegram.probe.webhook?.url + ? ` - webhook ${summary.telegram.probe.webhook.url}` + : ""), + ) + : `Telegram: failed (${summary.telegram.probe?.status ?? "unknown"})${summary.telegram.probe?.error ? ` - ${summary.telegram.probe.error}` : ""}` + : "Telegram: not configured"; + runtime.log(tgLabel); + runtime.log(info(`Heartbeat interval: ${summary.heartbeatSeconds}s`)); runtime.log( info( diff --git a/src/commands/status.ts b/src/commands/status.ts index 49bd0cf8e..32091a815 100644 --- a/src/commands/status.ts +++ b/src/commands/status.ts @@ -7,6 +7,7 @@ import { type SessionEntry, } from "../config/sessions.js"; import { info } from "../globals.js"; +import { getHealthSnapshot, type HealthSummary } from "./health.js"; import { buildProviderSummary } from "../infra/provider-summary.js"; import { peekSystemEvents } from "../infra/system-events.js"; import type { RuntimeEnv } from "../runtime.js"; @@ -187,13 +188,22 @@ const buildFlags = (entry: SessionEntry): string[] => { }; export async function statusCommand( - opts: { json?: boolean }, + opts: { json?: boolean; deep?: boolean; timeoutMs?: number }, runtime: RuntimeEnv, ) { const summary = await getStatusSummary(); + const health: HealthSummary | undefined = opts.deep + ? await getHealthSnapshot(opts.timeoutMs) + : undefined; if (opts.json) { - runtime.log(JSON.stringify(summary, null, 2)); + runtime.log( + JSON.stringify( + health ? { ...summary, health } : summary, + null, + 2, + ), + ); return; } @@ -204,6 +214,28 @@ export async function statusCommand( logWebSelfId(runtime, true); } runtime.log(info(`System: ${summary.providerSummary}`)); + if (health) { + const waLine = health.web.connect + ? health.web.connect.ok + ? info(`WA connect: ok (${health.web.connect.elapsedMs}ms)`) + : `WA connect: failed (${health.web.connect.status ?? "unknown"})${health.web.connect.error ? ` - ${health.web.connect.error}` : ""}` + : info("WA connect: skipped (not linked)"); + runtime.log(waLine); + + const tgLine = health.telegram.configured + ? health.telegram.probe?.ok + ? info( + `Telegram: ok${health.telegram.probe.bot?.username ? ` (@${health.telegram.probe.bot.username})` : ""} (${health.telegram.probe.elapsedMs}ms)` + + (health.telegram.probe.webhook?.url + ? ` - webhook ${health.telegram.probe.webhook.url}` + : ""), + ) + : `Telegram: failed (${health.telegram.probe?.status ?? "unknown"})${health.telegram.probe?.error ? ` - ${health.telegram.probe.error}` : ""}` + : info("Telegram: not configured"); + runtime.log(tgLine); + } else { + runtime.log(info("Provider probes: skipped (use --deep)")); + } if (summary.queuedSystemEvents.length > 0) { const preview = summary.queuedSystemEvents.slice(0, 3).join(" | "); runtime.log(