Compare commits

...

3 Commits

Author SHA1 Message Date
Peter Steinberger
3762e876b3 feat(macos): load models from gateway 2025-12-20 23:24:09 +01:00
Peter Steinberger
9b4d06002a test(gateway): cover models.list 2025-12-20 23:24:04 +01:00
Peter Steinberger
d2d336fc3a feat(gateway): add models.list 2025-12-20 23:23:59 +01:00
10 changed files with 982 additions and 47 deletions

View File

@ -17,6 +17,7 @@ struct ConfigSettings: View {
@State private var models: [ModelChoice] = []
@State private var modelsLoading = false
@State private var modelError: String?
@State private var modelsSourceLabel: String?
@AppStorage(modelCatalogPathKey) private var modelCatalogPath: String = ModelCatalogLoader.defaultPath
@AppStorage(modelCatalogReloadKey) private var modelCatalogReloadBump: Int = 0
@State private var allowAutosave = false
@ -142,6 +143,12 @@ struct ConfigSettings: View {
.font(.footnote)
.foregroundStyle(.secondary)
}
if let modelsSourceLabel {
Text("Model catalog: \(modelsSourceLabel)")
.font(.footnote)
.foregroundStyle(.secondary)
}
}
private var anthropicAuthHelpText: String {
@ -410,10 +417,29 @@ struct ConfigSettings: View {
guard !self.modelsLoading else { return }
self.modelsLoading = true
self.modelError = nil
self.modelsSourceLabel = nil
do {
let res: ModelsListResult =
try await GatewayConnection.shared
.requestDecoded(
method: .modelsList,
timeoutMs: 15000)
self.models = res.models
self.modelsSourceLabel = "gateway"
if !self.configModel.isEmpty,
!res.models.contains(where: { $0.id == self.configModel })
{
self.customModel = self.configModel
self.configModel = "__custom__"
}
} catch {
do {
let loaded = try await ModelCatalogLoader.load(from: self.modelCatalogPath)
self.models = loaded
if !self.configModel.isEmpty, !loaded.contains(where: { $0.id == self.configModel }) {
self.modelsSourceLabel = "local fallback"
if !self.configModel.isEmpty,
!loaded.contains(where: { $0.id == self.configModel })
{
self.customModel = self.configModel
self.configModel = "__custom__"
}
@ -421,9 +447,14 @@ struct ConfigSettings: View {
self.modelError = error.localizedDescription
self.models = []
}
}
self.modelsLoading = false
}
private struct ModelsListResult: Decodable {
let models: [ModelChoice]
}
private var selectedContextLabel: String? {
let chosenId = (self.configModel == "__custom__") ? self.customModel : self.configModel
guard

View File

@ -404,7 +404,7 @@ struct DebugSettings: View {
.font(.footnote)
.foregroundStyle(.secondary)
}
Text("Used by the Config tab model picker; point at a different build when debugging.")
Text("Local fallback for model picker when gateway models.list is unavailable.")
.font(.footnote)
.foregroundStyle(.tertiary)
}

View File

@ -47,6 +47,14 @@ actor GatewayConnection {
case setHeartbeats = "set-heartbeats"
case systemEvent = "system-event"
case health
case providersStatus = "providers.status"
case configGet = "config.get"
case configSet = "config.set"
case webLoginStart = "web.login.start"
case webLoginWait = "web.login.wait"
case webLogout = "web.logout"
case telegramLogout = "telegram.logout"
case modelsList = "models.list"
case chatHistory = "chat.history"
case chatSend = "chat.send"
case chatAbort = "chat.abort"

View File

@ -163,7 +163,7 @@ extension SessionRow {
}
}
struct ModelChoice: Identifiable, Hashable {
struct ModelChoice: Identifiable, Hashable, Codable {
let id: String
let name: String
let provider: String

View File

@ -621,6 +621,98 @@ public struct ConfigSetParams: Codable {
}
}
public struct ProvidersStatusParams: Codable {
public let probe: Bool?
public let timeoutms: Int?
public init(
probe: Bool?,
timeoutms: Int?
) {
self.probe = probe
self.timeoutms = timeoutms
}
private enum CodingKeys: String, CodingKey {
case probe
case timeoutms = "timeoutMs"
}
}
public struct WebLoginStartParams: Codable {
public let force: Bool?
public let timeoutms: Int?
public let verbose: Bool?
public init(
force: Bool?,
timeoutms: Int?,
verbose: Bool?
) {
self.force = force
self.timeoutms = timeoutms
self.verbose = verbose
}
private enum CodingKeys: String, CodingKey {
case force
case timeoutms = "timeoutMs"
case verbose
}
}
public struct WebLoginWaitParams: Codable {
public let timeoutms: Int?
public init(
timeoutms: Int?
) {
self.timeoutms = timeoutms
}
private enum CodingKeys: String, CodingKey {
case timeoutms = "timeoutMs"
}
}
public struct ModelChoice: Codable {
public let id: String
public let name: String
public let provider: String
public let contextwindow: Int?
public init(
id: String,
name: String,
provider: String,
contextwindow: Int?
) {
self.id = id
self.name = name
self.provider = provider
self.contextwindow = contextwindow
}
private enum CodingKeys: String, CodingKey {
case id
case name
case provider
case contextwindow = "contextWindow"
}
}
public struct ModelsListParams: Codable {
}
public struct ModelsListResult: Codable {
public let models: [ModelChoice]
public init(
models: [ModelChoice]
) {
self.models = models
}
private enum CodingKeys: String, CodingKey {
case models
}
}
public struct SkillsStatusParams: Codable {
}

View File

@ -42,6 +42,8 @@ import {
GatewayFrameSchema,
type HelloOk,
HelloOkSchema,
type ModelsListParams,
ModelsListParamsSchema,
type NodeDescribeParams,
NodeDescribeParamsSchema,
type NodeInvokeParams,
@ -62,6 +64,8 @@ import {
type PresenceEntry,
PresenceEntrySchema,
ProtocolSchemas,
type ProvidersStatusParams,
ProvidersStatusParamsSchema,
type RequestFrame,
RequestFrameSchema,
type ResponseFrame,
@ -87,6 +91,10 @@ import {
TickEventSchema,
type WakeParams,
WakeParamsSchema,
type WebLoginStartParams,
WebLoginStartParamsSchema,
type WebLoginWaitParams,
WebLoginWaitParamsSchema,
} from "./schema.js";
const ajv = new (
@ -141,6 +149,12 @@ export const validateConfigGetParams = ajv.compile<ConfigGetParams>(
export const validateConfigSetParams = ajv.compile<ConfigSetParams>(
ConfigSetParamsSchema,
);
export const validateProvidersStatusParams = ajv.compile<ProvidersStatusParams>(
ProvidersStatusParamsSchema,
);
export const validateModelsListParams = ajv.compile<ModelsListParams>(
ModelsListParamsSchema,
);
export const validateSkillsStatusParams = ajv.compile<SkillsStatusParams>(
SkillsStatusParamsSchema,
);
@ -173,6 +187,12 @@ export const validateChatAbortParams = ajv.compile<ChatAbortParams>(
ChatAbortParamsSchema,
);
export const validateChatEvent = ajv.compile(ChatEventSchema);
export const validateWebLoginStartParams = ajv.compile<WebLoginStartParams>(
WebLoginStartParamsSchema,
);
export const validateWebLoginWaitParams = ajv.compile<WebLoginWaitParams>(
WebLoginWaitParamsSchema,
);
export function formatValidationErrors(
errors: ErrorObject[] | null | undefined,
@ -208,6 +228,10 @@ export {
SessionsPatchParamsSchema,
ConfigGetParamsSchema,
ConfigSetParamsSchema,
ProvidersStatusParamsSchema,
WebLoginStartParamsSchema,
WebLoginWaitParamsSchema,
ModelsListParamsSchema,
SkillsStatusParamsSchema,
SkillsInstallParamsSchema,
SkillsUpdateParamsSchema,
@ -250,6 +274,9 @@ export type {
NodePairApproveParams,
ConfigGetParams,
ConfigSetParams,
ProvidersStatusParams,
WebLoginStartParams,
WebLoginWaitParams,
SkillsStatusParams,
SkillsInstallParams,
SkillsUpdateParams,

View File

@ -305,6 +305,52 @@ export const ConfigSetParamsSchema = Type.Object(
{ additionalProperties: false },
);
export const ProvidersStatusParamsSchema = Type.Object(
{
probe: Type.Optional(Type.Boolean()),
timeoutMs: Type.Optional(Type.Integer({ minimum: 0 })),
},
{ additionalProperties: false },
);
export const WebLoginStartParamsSchema = Type.Object(
{
force: Type.Optional(Type.Boolean()),
timeoutMs: Type.Optional(Type.Integer({ minimum: 0 })),
verbose: Type.Optional(Type.Boolean()),
},
{ additionalProperties: false },
);
export const WebLoginWaitParamsSchema = Type.Object(
{
timeoutMs: Type.Optional(Type.Integer({ minimum: 0 })),
},
{ additionalProperties: false },
);
export const ModelChoiceSchema = Type.Object(
{
id: NonEmptyString,
name: NonEmptyString,
provider: NonEmptyString,
contextWindow: Type.Optional(Type.Integer({ minimum: 1 })),
},
{ additionalProperties: false },
);
export const ModelsListParamsSchema = Type.Object(
{},
{ additionalProperties: false },
);
export const ModelsListResultSchema = Type.Object(
{
models: Type.Array(ModelChoiceSchema),
},
{ additionalProperties: false },
);
export const SkillsStatusParamsSchema = Type.Object(
{},
{ additionalProperties: false },
@ -583,6 +629,12 @@ export const ProtocolSchemas: Record<string, TSchema> = {
SessionsPatchParams: SessionsPatchParamsSchema,
ConfigGetParams: ConfigGetParamsSchema,
ConfigSetParams: ConfigSetParamsSchema,
ProvidersStatusParams: ProvidersStatusParamsSchema,
WebLoginStartParams: WebLoginStartParamsSchema,
WebLoginWaitParams: WebLoginWaitParamsSchema,
ModelChoice: ModelChoiceSchema,
ModelsListParams: ModelsListParamsSchema,
ModelsListResult: ModelsListResultSchema,
SkillsStatusParams: SkillsStatusParamsSchema,
SkillsInstallParams: SkillsInstallParamsSchema,
SkillsUpdateParams: SkillsUpdateParamsSchema,
@ -629,6 +681,12 @@ export type SessionsListParams = Static<typeof SessionsListParamsSchema>;
export type SessionsPatchParams = Static<typeof SessionsPatchParamsSchema>;
export type ConfigGetParams = Static<typeof ConfigGetParamsSchema>;
export type ConfigSetParams = Static<typeof ConfigSetParamsSchema>;
export type ProvidersStatusParams = Static<typeof ProvidersStatusParamsSchema>;
export type WebLoginStartParams = Static<typeof WebLoginStartParamsSchema>;
export type WebLoginWaitParams = Static<typeof WebLoginWaitParamsSchema>;
export type ModelChoice = Static<typeof ModelChoiceSchema>;
export type ModelsListParams = Static<typeof ModelsListParamsSchema>;
export type ModelsListResult = Static<typeof ModelsListResultSchema>;
export type SkillsStatusParams = Static<typeof SkillsStatusParamsSchema>;
export type SkillsInstallParams = Static<typeof SkillsInstallParamsSchema>;
export type SkillsUpdateParams = Static<typeof SkillsUpdateParamsSchema>;

View File

@ -10,7 +10,10 @@ import { emitAgentEvent } from "../infra/agent-events.js";
import { GatewayLockError } from "../infra/gateway-lock.js";
import { emitHeartbeatEvent } from "../infra/heartbeat-events.js";
import { PROTOCOL_VERSION } from "./protocol/index.js";
import { startGatewayServer } from "./server.js";
import {
__resetModelCatalogCacheForTest,
startGatewayServer,
} from "./server.js";
type BridgeClientInfo = {
nodeId: string;
@ -58,6 +61,36 @@ const bridgeSendEvent = vi.hoisted(() => vi.fn());
const testTailnetIPv4 = vi.hoisted(() => ({
value: undefined as string | undefined,
}));
const piAiMock = vi.hoisted(() => ({
enabled: false,
getModelsCalls: [] as string[],
providers: ["openai", "anthropic"],
modelsByProvider: {} as Record<
string,
Array<{ id: string; name?: string; contextWindow?: number }>
>,
}));
vi.mock("@mariozechner/pi-ai", async () => {
const actual = await vi.importActual<{
getProviders: () => string[];
getModels: (
provider: string,
) => Array<{ id: string; name?: string; contextWindow?: number }>;
}>("@mariozechner/pi-ai");
return {
...actual,
getProviders: () =>
piAiMock.enabled ? piAiMock.providers : actual.getProviders(),
getModels: (provider: string) => {
if (!piAiMock.enabled) return actual.getModels(provider);
piAiMock.getModelsCalls.push(provider);
return piAiMock.modelsByProvider[provider] ?? [];
},
};
});
vi.mock("../infra/bridge/server.js", () => ({
startNodeBridgeServer: vi.fn(async (opts: BridgeStartOpts) => {
bridgeStartCalls.push(opts);
@ -141,6 +174,11 @@ beforeEach(async () => {
sessionStoreSaveDelayMs.value = 0;
testTailnetIPv4.value = undefined;
testGatewayBind = undefined;
__resetModelCatalogCacheForTest();
piAiMock.enabled = false;
piAiMock.getModelsCalls.length = 0;
piAiMock.providers = ["openai", "anthropic"];
piAiMock.modelsByProvider = { openai: [], anthropic: [] };
});
afterEach(async () => {
@ -346,6 +384,129 @@ describe("gateway server", () => {
}
});
test("models.list returns model catalog", async () => {
piAiMock.enabled = true;
piAiMock.providers = ["openai", "anthropic"];
piAiMock.modelsByProvider = {
openai: [
{ id: "gpt-test-z", contextWindow: 0 },
{ id: "gpt-test-a", name: "A-Model", contextWindow: 8000 },
],
anthropic: [
{ id: "claude-test-b", name: "B-Model", contextWindow: 1000 },
{ id: "claude-test-a", name: "A-Model", contextWindow: 200_000 },
],
};
const { server, ws } = await startServerWithClient();
await connectOk(ws);
const res1 = await rpcReq<{
models: Array<{
id: string;
name: string;
provider: string;
contextWindow?: number;
}>;
}>(ws, "models.list");
const res2 = await rpcReq<{
models: Array<{
id: string;
name: string;
provider: string;
contextWindow?: number;
}>;
}>(ws, "models.list");
expect(res1.ok).toBe(true);
expect(res2.ok).toBe(true);
const models = res1.payload?.models ?? [];
expect(models).toEqual([
{
id: "claude-test-a",
name: "A-Model",
provider: "anthropic",
contextWindow: 200_000,
},
{
id: "claude-test-b",
name: "B-Model",
provider: "anthropic",
contextWindow: 1000,
},
{
id: "gpt-test-a",
name: "A-Model",
provider: "openai",
contextWindow: 8000,
},
{
id: "gpt-test-z",
name: "gpt-test-z",
provider: "openai",
},
]);
// Cached across requests: should only call getModels once per provider.
expect(piAiMock.getModelsCalls).toEqual(["openai", "anthropic"]);
ws.close();
await server.close();
});
test("models.list rejects unknown params", async () => {
piAiMock.providers = ["openai"];
piAiMock.modelsByProvider = { openai: [{ id: "gpt-test-a", name: "A" }] };
const { server, ws } = await startServerWithClient();
await connectOk(ws);
const res = await rpcReq(ws, "models.list", { extra: true });
expect(res.ok).toBe(false);
expect(res.error?.message ?? "").toMatch(/invalid models\.list params/i);
ws.close();
await server.close();
});
test("bridge RPC supports models.list and validates params", async () => {
piAiMock.enabled = true;
piAiMock.providers = ["openai"];
piAiMock.modelsByProvider = { openai: [{ id: "gpt-test-a", name: "A" }] };
const { server, ws } = await startServerWithClient();
await connectOk(ws);
const startCall = bridgeStartCalls.at(-1);
expect(startCall).toBeTruthy();
const okRes = await startCall?.onRequest?.("n1", {
id: "1",
method: "models.list",
paramsJSON: "{}",
});
expect(okRes?.ok).toBe(true);
const okPayload = JSON.parse(String(okRes?.payloadJSON ?? "{}")) as {
models?: unknown;
};
expect(Array.isArray(okPayload.models)).toBe(true);
const badRes = await startCall?.onRequest?.("n1", {
id: "2",
method: "models.list",
paramsJSON: JSON.stringify({ extra: true }),
});
expect(badRes?.ok).toBe(false);
expect(badRes && "error" in badRes ? badRes.error.code : "").toBe(
"INVALID_REQUEST",
);
ws.close();
await server.close();
});
test("pushes voicewake.changed to nodes on connect and on updates", async () => {
const homeDir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdis-home-"));
const prevHome = process.env.HOME;
@ -1702,19 +1863,26 @@ describe("gateway server", () => {
ws,
(o) => o.type === "res" && o.id === "presence1",
);
const providersP = onceMessage(
ws,
(o) => o.type === "res" && o.id === "providers1",
);
const sendReq = (id: string, method: string) =>
ws.send(JSON.stringify({ type: "req", id, method }));
sendReq("health1", "health");
sendReq("status1", "status");
sendReq("presence1", "system-presence");
sendReq("providers1", "providers.status");
const health = await healthP;
const status = await statusP;
const presence = await presenceP;
const providers = await providersP;
expect(health.ok).toBe(true);
expect(status.ok).toBe(true);
expect(presence.ok).toBe(true);
expect(providers.ok).toBe(true);
expect(Array.isArray(presence.payload)).toBe(true);
ws.close();

View File

@ -101,11 +101,17 @@ import { runExec } from "../process/exec.js";
import { monitorWebProvider, webAuthExists } from "../providers/web/index.js";
import { defaultRuntime } from "../runtime.js";
import { monitorTelegramProvider } from "../telegram/monitor.js";
import { probeTelegram, type TelegramProbe } from "../telegram/probe.js";
import { sendMessageTelegram } from "../telegram/send.js";
import { normalizeE164, resolveUserPath } from "../utils.js";
import { setHeartbeatsEnabled } from "../web/auto-reply.js";
import {
setHeartbeatsEnabled,
type WebProviderStatus,
} from "../web/auto-reply.js";
import { startWebLoginWithQr, waitForWebLogin } from "../web/login-qr.js";
import { sendMessageWhatsApp } from "../web/outbound.js";
import { requestReplyHeartbeatNow } from "../web/reply-heartbeat-wake.js";
import { getWebAuthAgeMs, logoutWeb, readWebSelfId } from "../web/session.js";
import { buildMessageWithAttachments } from "./chat-attachments.js";
import { handleControlUiHttpRequest } from "./control-ui.js";
@ -156,6 +162,65 @@ async function startBrowserControlServerIfEnabled(): Promise<void> {
await mod.startBrowserControlServerFromConfig(defaultRuntime);
}
type GatewayModelChoice = {
id: string;
name: string;
provider: string;
contextWindow?: number;
};
let modelCatalogPromise: Promise<GatewayModelChoice[]> | null = null;
// Test-only escape hatch: model catalog is cached at module scope for the
// process lifetime, which is fine for the real gateway daemon, but makes
// isolated unit tests harder. Keep this intentionally obscure.
export function __resetModelCatalogCacheForTest() {
modelCatalogPromise = null;
}
async function loadGatewayModelCatalog(): Promise<GatewayModelChoice[]> {
if (modelCatalogPromise) return modelCatalogPromise;
modelCatalogPromise = (async () => {
const piAi = (await import("@mariozechner/pi-ai")) as unknown as {
getProviders: () => string[];
getModels: (provider: string) => Array<{
id: string;
name?: string;
contextWindow?: number;
}>;
};
const models: GatewayModelChoice[] = [];
for (const provider of piAi.getProviders()) {
let entries: Array<{ id: string; name?: string; contextWindow?: number }>;
try {
entries = piAi.getModels(provider);
} catch {
continue;
}
for (const entry of entries) {
const id = String(entry?.id ?? "").trim();
if (!id) continue;
const name = String(entry?.name ?? id).trim() || id;
const contextWindow =
typeof entry?.contextWindow === "number" && entry.contextWindow > 0
? entry.contextWindow
: undefined;
models.push({ id, name, provider, contextWindow });
}
}
return models.sort((a, b) => {
const p = a.provider.localeCompare(b.provider);
if (p !== 0) return p;
return a.name.localeCompare(b.name);
});
})();
return modelCatalogPromise;
}
import {
type ConnectParams,
ErrorCodes,
@ -181,6 +246,7 @@ import {
validateCronRunsParams,
validateCronStatusParams,
validateCronUpdateParams,
validateModelsListParams,
validateNodeDescribeParams,
validateNodeInvokeParams,
validateNodeListParams,
@ -189,6 +255,7 @@ import {
validateNodePairRejectParams,
validateNodePairRequestParams,
validateNodePairVerifyParams,
validateProvidersStatusParams,
validateRequestFrame,
validateSendParams,
validateSessionsListParams,
@ -197,6 +264,8 @@ import {
validateSkillsStatusParams,
validateSkillsUpdateParams,
validateWakeParams,
validateWebLoginStartParams,
validateWebLoginWaitParams,
} from "./protocol/index.js";
import { DEFAULT_WS_SLOW_MS, getGatewayWsLogStyle } from "./ws-logging.js";
@ -267,9 +336,11 @@ type SessionsPatchResult = {
const METHODS = [
"health",
"providers.status",
"status",
"config.get",
"config.set",
"models.list",
"skills.status",
"skills.install",
"skills.update",
@ -299,6 +370,10 @@ const METHODS = [
"system-event",
"send",
"agent",
"web.login.start",
"web.login.wait",
"web.logout",
"telegram.logout",
// WebChat WebSocket-native chat methods
"chat.history",
"chat.abort",
@ -1113,8 +1188,33 @@ export async function startGatewayServer(
wss.emit("connection", ws, req);
});
});
const providerAbort = new AbortController();
const providerTasks: Array<Promise<unknown>> = [];
let whatsappAbort: AbortController | null = null;
let telegramAbort: AbortController | null = null;
let whatsappTask: Promise<unknown> | null = null;
let telegramTask: Promise<unknown> | null = null;
let whatsappRuntime: WebProviderStatus = {
running: false,
connected: false,
reconnectAttempts: 0,
lastConnectedAt: null,
lastDisconnect: null,
lastMessageAt: null,
lastEventAt: null,
lastError: null,
};
let telegramRuntime: {
running: boolean;
lastStartAt?: number | null;
lastStopAt?: number | null;
lastError?: string | null;
mode?: "webhook" | "polling" | null;
} = {
running: false,
lastStartAt: null,
lastStopAt: null,
lastError: null,
mode: null,
};
const clients = new Set<Client>();
let seq = 0;
// Track per-run sequence to detect out-of-order/lost agent events.
@ -1185,49 +1285,150 @@ export async function startGatewayServer(
},
});
const startProviders = async () => {
const cfg = loadConfig();
const telegramToken =
process.env.TELEGRAM_BOT_TOKEN ?? cfg.telegram?.botToken ?? "";
const updateWhatsAppStatus = (next: WebProviderStatus) => {
whatsappRuntime = next;
};
if (await webAuthExists()) {
const startWhatsAppProvider = async () => {
if (whatsappTask) return;
if (!(await webAuthExists())) {
whatsappRuntime = {
...whatsappRuntime,
running: false,
connected: false,
lastError: "not linked",
};
defaultRuntime.log(
"gateway: skipping WhatsApp Web provider (no linked session)",
);
return;
}
defaultRuntime.log("gateway: starting WhatsApp Web provider");
providerTasks.push(
monitorWebProvider(
whatsappAbort = new AbortController();
whatsappRuntime = {
...whatsappRuntime,
running: true,
connected: false,
lastError: null,
};
const task = monitorWebProvider(
isVerbose(),
undefined,
true,
undefined,
defaultRuntime,
providerAbort.signal,
).catch((err) => logError(`web provider exited: ${formatError(err)}`)),
);
} else {
defaultRuntime.log(
"gateway: skipping WhatsApp Web provider (no linked session)",
);
}
whatsappAbort.signal,
{ statusSink: updateWhatsAppStatus },
)
.catch((err) => {
whatsappRuntime = {
...whatsappRuntime,
lastError: formatError(err),
};
logError(`web provider exited: ${formatError(err)}`);
})
.finally(() => {
whatsappAbort = null;
whatsappTask = null;
whatsappRuntime = {
...whatsappRuntime,
running: false,
connected: false,
};
});
whatsappTask = task;
};
if (telegramToken.trim().length > 0) {
const stopWhatsAppProvider = async () => {
if (!whatsappAbort && !whatsappTask) return;
whatsappAbort?.abort();
try {
await whatsappTask;
} catch {
// ignore
}
whatsappAbort = null;
whatsappTask = null;
whatsappRuntime = {
...whatsappRuntime,
running: false,
connected: false,
};
};
const startTelegramProvider = async () => {
if (telegramTask) return;
const cfg = loadConfig();
const telegramToken =
process.env.TELEGRAM_BOT_TOKEN ?? cfg.telegram?.botToken ?? "";
if (!telegramToken.trim()) {
telegramRuntime = {
...telegramRuntime,
running: false,
lastError: "not configured",
};
defaultRuntime.log(
"gateway: skipping Telegram provider (no TELEGRAM_BOT_TOKEN/config)",
);
return;
}
defaultRuntime.log("gateway: starting Telegram provider");
providerTasks.push(
monitorTelegramProvider({
telegramAbort = new AbortController();
telegramRuntime = {
...telegramRuntime,
running: true,
lastStartAt: Date.now(),
lastError: null,
mode: cfg.telegram?.webhookUrl ? "webhook" : "polling",
};
const task = monitorTelegramProvider({
token: telegramToken.trim(),
runtime: defaultRuntime,
abortSignal: providerAbort.signal,
abortSignal: telegramAbort.signal,
useWebhook: Boolean(cfg.telegram?.webhookUrl),
webhookUrl: cfg.telegram?.webhookUrl,
webhookSecret: cfg.telegram?.webhookSecret,
webhookPath: cfg.telegram?.webhookPath,
}).catch((err) =>
logError(`telegram provider exited: ${formatError(err)}`),
),
);
} else {
defaultRuntime.log(
"gateway: skipping Telegram provider (no TELEGRAM_BOT_TOKEN/config)",
);
})
.catch((err) => {
telegramRuntime = {
...telegramRuntime,
lastError: formatError(err),
};
logError(`telegram provider exited: ${formatError(err)}`);
})
.finally(() => {
telegramAbort = null;
telegramTask = null;
telegramRuntime = {
...telegramRuntime,
running: false,
lastStopAt: Date.now(),
};
});
telegramTask = task;
};
const stopTelegramProvider = async () => {
if (!telegramAbort && !telegramTask) return;
telegramAbort?.abort();
try {
await telegramTask;
} catch {
// ignore
}
telegramAbort = null;
telegramTask = null;
telegramRuntime = {
...telegramRuntime,
running: false,
lastStopAt: Date.now(),
};
};
const startProviders = async () => {
await startWhatsAppProvider();
await startTelegramProvider();
};
const broadcast = (
@ -1539,6 +1740,20 @@ export async function startGatewayServer(
}),
};
}
case "models.list": {
const params = parseParams();
if (!validateModelsListParams(params)) {
return {
ok: false,
error: {
code: ErrorCodes.INVALID_REQUEST,
message: `invalid models.list params: ${formatValidationErrors(validateModelsListParams.errors)}`,
},
};
}
const models = await loadGatewayModelCatalog();
return { ok: true, payloadJSON: JSON.stringify({ models }) };
}
case "sessions.list": {
const params = parseParams();
if (!validateSessionsListParams(params)) {
@ -2407,7 +2622,8 @@ export async function startGatewayServer(
const remoteAddr = (
socket as WebSocket & { _socket?: { remoteAddress?: string } }
)._socket?.remoteAddress;
const canvasHostPortForWs = canvasHostServer?.port ?? (canvasHost ? port : undefined);
const canvasHostPortForWs =
canvasHostServer?.port ?? (canvasHost ? port : undefined);
const canvasHostOverride =
bridgeHost && bridgeHost !== "0.0.0.0" && bridgeHost !== "::"
? bridgeHost
@ -2770,6 +2986,84 @@ export async function startGatewayServer(
}
break;
}
case "providers.status": {
const params = (req.params ?? {}) as Record<string, unknown>;
if (!validateProvidersStatusParams(params)) {
respond(
false,
undefined,
errorShape(
ErrorCodes.INVALID_REQUEST,
`invalid providers.status params: ${formatValidationErrors(validateProvidersStatusParams.errors)}`,
),
);
break;
}
const probe = (params as { probe?: boolean }).probe === true;
const timeoutMsRaw = (params as { timeoutMs?: unknown })
.timeoutMs;
const timeoutMs =
typeof timeoutMsRaw === "number"
? Math.max(1000, timeoutMsRaw)
: 10_000;
const cfg = loadConfig();
const envToken = process.env.TELEGRAM_BOT_TOKEN?.trim();
const configToken = cfg.telegram?.botToken?.trim();
const telegramToken = envToken || configToken || "";
const tokenSource = envToken
? "env"
: configToken
? "config"
: "none";
let telegramProbe: TelegramProbe | undefined;
let lastProbeAt: number | null = null;
if (probe && telegramToken) {
telegramProbe = await probeTelegram(
telegramToken,
timeoutMs,
cfg.telegram?.proxy,
);
lastProbeAt = Date.now();
}
const linked = await webAuthExists();
const authAgeMs = getWebAuthAgeMs();
const self = readWebSelfId();
respond(
true,
{
ts: Date.now(),
whatsapp: {
configured: linked,
linked,
authAgeMs,
self,
running: whatsappRuntime.running,
connected: whatsappRuntime.connected,
lastConnectedAt: whatsappRuntime.lastConnectedAt ?? null,
lastDisconnect: whatsappRuntime.lastDisconnect ?? null,
reconnectAttempts: whatsappRuntime.reconnectAttempts,
lastMessageAt: whatsappRuntime.lastMessageAt ?? null,
lastEventAt: whatsappRuntime.lastEventAt ?? null,
lastError: whatsappRuntime.lastError ?? null,
},
telegram: {
configured: Boolean(telegramToken),
tokenSource,
running: telegramRuntime.running,
mode: telegramRuntime.mode ?? null,
lastStartAt: telegramRuntime.lastStartAt ?? null,
lastStopAt: telegramRuntime.lastStopAt ?? null,
lastError: telegramRuntime.lastError ?? null,
probe: telegramProbe,
lastProbeAt,
},
},
undefined,
);
break;
}
case "chat.history": {
const params = (req.params ?? {}) as Record<string, unknown>;
if (!validateChatHistoryParams(params)) {
@ -3194,6 +3488,164 @@ export async function startGatewayServer(
respond(true, status, undefined);
break;
}
case "web.login.start": {
const params = (req.params ?? {}) as Record<string, unknown>;
if (!validateWebLoginStartParams(params)) {
respond(
false,
undefined,
errorShape(
ErrorCodes.INVALID_REQUEST,
`invalid web.login.start params: ${formatValidationErrors(validateWebLoginStartParams.errors)}`,
),
);
break;
}
try {
await stopWhatsAppProvider();
const result = await startWebLoginWithQr({
force: Boolean((params as { force?: boolean }).force),
timeoutMs:
typeof (params as { timeoutMs?: unknown }).timeoutMs ===
"number"
? (params as { timeoutMs?: number }).timeoutMs
: undefined,
verbose: Boolean((params as { verbose?: boolean }).verbose),
});
respond(true, result, undefined);
} catch (err) {
respond(
false,
undefined,
errorShape(ErrorCodes.UNAVAILABLE, formatForLog(err)),
);
}
break;
}
case "web.login.wait": {
const params = (req.params ?? {}) as Record<string, unknown>;
if (!validateWebLoginWaitParams(params)) {
respond(
false,
undefined,
errorShape(
ErrorCodes.INVALID_REQUEST,
`invalid web.login.wait params: ${formatValidationErrors(validateWebLoginWaitParams.errors)}`,
),
);
break;
}
try {
const result = await waitForWebLogin({
timeoutMs:
typeof (params as { timeoutMs?: unknown }).timeoutMs ===
"number"
? (params as { timeoutMs?: number }).timeoutMs
: undefined,
});
if (result.connected) {
await startWhatsAppProvider();
}
respond(true, result, undefined);
} catch (err) {
respond(
false,
undefined,
errorShape(ErrorCodes.UNAVAILABLE, formatForLog(err)),
);
}
break;
}
case "web.logout": {
try {
await stopWhatsAppProvider();
const cleared = await logoutWeb(defaultRuntime);
whatsappRuntime = {
...whatsappRuntime,
running: false,
connected: false,
lastError: cleared ? "logged out" : whatsappRuntime.lastError,
};
respond(true, { cleared }, undefined);
} catch (err) {
respond(
false,
undefined,
errorShape(ErrorCodes.UNAVAILABLE, formatForLog(err)),
);
}
break;
}
case "telegram.logout": {
try {
await stopTelegramProvider();
const snapshot = await readConfigFileSnapshot();
if (!snapshot.valid) {
respond(
false,
undefined,
errorShape(
ErrorCodes.INVALID_REQUEST,
"config invalid; fix it before logging out",
),
);
break;
}
const cfg = snapshot.config ?? {};
const envToken = process.env.TELEGRAM_BOT_TOKEN?.trim() ?? "";
const hadToken = Boolean(cfg.telegram?.botToken);
const nextTelegram = cfg.telegram
? { ...cfg.telegram }
: undefined;
if (nextTelegram) {
delete nextTelegram.botToken;
}
const nextCfg = { ...cfg } as ClawdisConfig;
if (nextTelegram && Object.keys(nextTelegram).length > 0) {
nextCfg.telegram = nextTelegram;
} else {
delete nextCfg.telegram;
}
await writeConfigFile(nextCfg);
respond(
true,
{ cleared: hadToken, envToken: Boolean(envToken) },
undefined,
);
} catch (err) {
respond(
false,
undefined,
errorShape(ErrorCodes.UNAVAILABLE, formatForLog(err)),
);
}
break;
}
case "models.list": {
const params = (req.params ?? {}) as Record<string, unknown>;
if (!validateModelsListParams(params)) {
respond(
false,
undefined,
errorShape(
ErrorCodes.INVALID_REQUEST,
`invalid models.list params: ${formatValidationErrors(validateModelsListParams.errors)}`,
),
);
break;
}
try {
const models = await loadGatewayModelCatalog();
respond(true, { models }, undefined);
} catch (err) {
respond(
false,
undefined,
errorShape(ErrorCodes.UNAVAILABLE, String(err)),
);
}
break;
}
case "config.get": {
const params = (req.params ?? {}) as Record<string, unknown>;
if (!validateConfigGetParams(params)) {
@ -4444,7 +4896,8 @@ export async function startGatewayServer(
/* ignore */
}
}
providerAbort.abort();
await stopWhatsAppProvider();
await stopTelegramProvider();
cron.stop();
broadcast("shutdown", {
reason: "gateway stopping",
@ -4480,7 +4933,9 @@ export async function startGatewayServer(
if (stopBrowserControlServerIfStarted) {
await stopBrowserControlServerIfStarted().catch(() => {});
}
await Promise.allSettled(providerTasks);
await Promise.allSettled(
[whatsappTask, telegramTask].filter(Boolean) as Array<Promise<unknown>>,
);
await new Promise<void>((resolve) => wss.close(() => resolve()));
await new Promise<void>((resolve, reject) =>
httpServer.close((err) => (err ? reject(err) : resolve())),

96
src/telegram/probe.ts Normal file
View File

@ -0,0 +1,96 @@
import { makeProxyFetch } from "./proxy.js";
const TELEGRAM_API_BASE = "https://api.telegram.org";
export 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 };
};
async function fetchWithTimeout(
url: string,
timeoutMs: number,
fetcher: typeof fetch,
): Promise<Response> {
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), timeoutMs);
try {
return await fetcher(url, { signal: controller.signal });
} finally {
clearTimeout(timer);
}
}
export async function probeTelegram(
token: string,
timeoutMs: number,
proxyUrl?: string,
): Promise<TelegramProbe> {
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 probe
}
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,
};
}
}