Compare commits
3 Commits
main
...
codex/mode
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3762e876b3 | ||
|
|
9b4d06002a | ||
|
|
d2d336fc3a |
@ -17,6 +17,7 @@ struct ConfigSettings: View {
|
|||||||
@State private var models: [ModelChoice] = []
|
@State private var models: [ModelChoice] = []
|
||||||
@State private var modelsLoading = false
|
@State private var modelsLoading = false
|
||||||
@State private var modelError: String?
|
@State private var modelError: String?
|
||||||
|
@State private var modelsSourceLabel: String?
|
||||||
@AppStorage(modelCatalogPathKey) private var modelCatalogPath: String = ModelCatalogLoader.defaultPath
|
@AppStorage(modelCatalogPathKey) private var modelCatalogPath: String = ModelCatalogLoader.defaultPath
|
||||||
@AppStorage(modelCatalogReloadKey) private var modelCatalogReloadBump: Int = 0
|
@AppStorage(modelCatalogReloadKey) private var modelCatalogReloadBump: Int = 0
|
||||||
@State private var allowAutosave = false
|
@State private var allowAutosave = false
|
||||||
@ -142,6 +143,12 @@ struct ConfigSettings: View {
|
|||||||
.font(.footnote)
|
.font(.footnote)
|
||||||
.foregroundStyle(.secondary)
|
.foregroundStyle(.secondary)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if let modelsSourceLabel {
|
||||||
|
Text("Model catalog: \(modelsSourceLabel)")
|
||||||
|
.font(.footnote)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private var anthropicAuthHelpText: String {
|
private var anthropicAuthHelpText: String {
|
||||||
@ -410,20 +417,44 @@ struct ConfigSettings: View {
|
|||||||
guard !self.modelsLoading else { return }
|
guard !self.modelsLoading else { return }
|
||||||
self.modelsLoading = true
|
self.modelsLoading = true
|
||||||
self.modelError = nil
|
self.modelError = nil
|
||||||
|
self.modelsSourceLabel = nil
|
||||||
do {
|
do {
|
||||||
let loaded = try await ModelCatalogLoader.load(from: self.modelCatalogPath)
|
let res: ModelsListResult =
|
||||||
self.models = loaded
|
try await GatewayConnection.shared
|
||||||
if !self.configModel.isEmpty, !loaded.contains(where: { $0.id == self.configModel }) {
|
.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.customModel = self.configModel
|
||||||
self.configModel = "__custom__"
|
self.configModel = "__custom__"
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
self.modelError = error.localizedDescription
|
do {
|
||||||
self.models = []
|
let loaded = try await ModelCatalogLoader.load(from: self.modelCatalogPath)
|
||||||
|
self.models = loaded
|
||||||
|
self.modelsSourceLabel = "local fallback"
|
||||||
|
if !self.configModel.isEmpty,
|
||||||
|
!loaded.contains(where: { $0.id == self.configModel })
|
||||||
|
{
|
||||||
|
self.customModel = self.configModel
|
||||||
|
self.configModel = "__custom__"
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
self.modelError = error.localizedDescription
|
||||||
|
self.models = []
|
||||||
|
}
|
||||||
}
|
}
|
||||||
self.modelsLoading = false
|
self.modelsLoading = false
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private struct ModelsListResult: Decodable {
|
||||||
|
let models: [ModelChoice]
|
||||||
|
}
|
||||||
|
|
||||||
private var selectedContextLabel: String? {
|
private var selectedContextLabel: String? {
|
||||||
let chosenId = (self.configModel == "__custom__") ? self.customModel : self.configModel
|
let chosenId = (self.configModel == "__custom__") ? self.customModel : self.configModel
|
||||||
guard
|
guard
|
||||||
|
|||||||
@ -404,7 +404,7 @@ struct DebugSettings: View {
|
|||||||
.font(.footnote)
|
.font(.footnote)
|
||||||
.foregroundStyle(.secondary)
|
.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)
|
.font(.footnote)
|
||||||
.foregroundStyle(.tertiary)
|
.foregroundStyle(.tertiary)
|
||||||
}
|
}
|
||||||
|
|||||||
@ -47,6 +47,14 @@ actor GatewayConnection {
|
|||||||
case setHeartbeats = "set-heartbeats"
|
case setHeartbeats = "set-heartbeats"
|
||||||
case systemEvent = "system-event"
|
case systemEvent = "system-event"
|
||||||
case health
|
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 chatHistory = "chat.history"
|
||||||
case chatSend = "chat.send"
|
case chatSend = "chat.send"
|
||||||
case chatAbort = "chat.abort"
|
case chatAbort = "chat.abort"
|
||||||
|
|||||||
@ -163,7 +163,7 @@ extension SessionRow {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
struct ModelChoice: Identifiable, Hashable {
|
struct ModelChoice: Identifiable, Hashable, Codable {
|
||||||
let id: String
|
let id: String
|
||||||
let name: String
|
let name: String
|
||||||
let provider: String
|
let provider: String
|
||||||
|
|||||||
@ -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 {
|
public struct SkillsStatusParams: Codable {
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -42,6 +42,8 @@ import {
|
|||||||
GatewayFrameSchema,
|
GatewayFrameSchema,
|
||||||
type HelloOk,
|
type HelloOk,
|
||||||
HelloOkSchema,
|
HelloOkSchema,
|
||||||
|
type ModelsListParams,
|
||||||
|
ModelsListParamsSchema,
|
||||||
type NodeDescribeParams,
|
type NodeDescribeParams,
|
||||||
NodeDescribeParamsSchema,
|
NodeDescribeParamsSchema,
|
||||||
type NodeInvokeParams,
|
type NodeInvokeParams,
|
||||||
@ -62,6 +64,8 @@ import {
|
|||||||
type PresenceEntry,
|
type PresenceEntry,
|
||||||
PresenceEntrySchema,
|
PresenceEntrySchema,
|
||||||
ProtocolSchemas,
|
ProtocolSchemas,
|
||||||
|
type ProvidersStatusParams,
|
||||||
|
ProvidersStatusParamsSchema,
|
||||||
type RequestFrame,
|
type RequestFrame,
|
||||||
RequestFrameSchema,
|
RequestFrameSchema,
|
||||||
type ResponseFrame,
|
type ResponseFrame,
|
||||||
@ -87,6 +91,10 @@ import {
|
|||||||
TickEventSchema,
|
TickEventSchema,
|
||||||
type WakeParams,
|
type WakeParams,
|
||||||
WakeParamsSchema,
|
WakeParamsSchema,
|
||||||
|
type WebLoginStartParams,
|
||||||
|
WebLoginStartParamsSchema,
|
||||||
|
type WebLoginWaitParams,
|
||||||
|
WebLoginWaitParamsSchema,
|
||||||
} from "./schema.js";
|
} from "./schema.js";
|
||||||
|
|
||||||
const ajv = new (
|
const ajv = new (
|
||||||
@ -141,6 +149,12 @@ export const validateConfigGetParams = ajv.compile<ConfigGetParams>(
|
|||||||
export const validateConfigSetParams = ajv.compile<ConfigSetParams>(
|
export const validateConfigSetParams = ajv.compile<ConfigSetParams>(
|
||||||
ConfigSetParamsSchema,
|
ConfigSetParamsSchema,
|
||||||
);
|
);
|
||||||
|
export const validateProvidersStatusParams = ajv.compile<ProvidersStatusParams>(
|
||||||
|
ProvidersStatusParamsSchema,
|
||||||
|
);
|
||||||
|
export const validateModelsListParams = ajv.compile<ModelsListParams>(
|
||||||
|
ModelsListParamsSchema,
|
||||||
|
);
|
||||||
export const validateSkillsStatusParams = ajv.compile<SkillsStatusParams>(
|
export const validateSkillsStatusParams = ajv.compile<SkillsStatusParams>(
|
||||||
SkillsStatusParamsSchema,
|
SkillsStatusParamsSchema,
|
||||||
);
|
);
|
||||||
@ -173,6 +187,12 @@ export const validateChatAbortParams = ajv.compile<ChatAbortParams>(
|
|||||||
ChatAbortParamsSchema,
|
ChatAbortParamsSchema,
|
||||||
);
|
);
|
||||||
export const validateChatEvent = ajv.compile(ChatEventSchema);
|
export const validateChatEvent = ajv.compile(ChatEventSchema);
|
||||||
|
export const validateWebLoginStartParams = ajv.compile<WebLoginStartParams>(
|
||||||
|
WebLoginStartParamsSchema,
|
||||||
|
);
|
||||||
|
export const validateWebLoginWaitParams = ajv.compile<WebLoginWaitParams>(
|
||||||
|
WebLoginWaitParamsSchema,
|
||||||
|
);
|
||||||
|
|
||||||
export function formatValidationErrors(
|
export function formatValidationErrors(
|
||||||
errors: ErrorObject[] | null | undefined,
|
errors: ErrorObject[] | null | undefined,
|
||||||
@ -208,6 +228,10 @@ export {
|
|||||||
SessionsPatchParamsSchema,
|
SessionsPatchParamsSchema,
|
||||||
ConfigGetParamsSchema,
|
ConfigGetParamsSchema,
|
||||||
ConfigSetParamsSchema,
|
ConfigSetParamsSchema,
|
||||||
|
ProvidersStatusParamsSchema,
|
||||||
|
WebLoginStartParamsSchema,
|
||||||
|
WebLoginWaitParamsSchema,
|
||||||
|
ModelsListParamsSchema,
|
||||||
SkillsStatusParamsSchema,
|
SkillsStatusParamsSchema,
|
||||||
SkillsInstallParamsSchema,
|
SkillsInstallParamsSchema,
|
||||||
SkillsUpdateParamsSchema,
|
SkillsUpdateParamsSchema,
|
||||||
@ -250,6 +274,9 @@ export type {
|
|||||||
NodePairApproveParams,
|
NodePairApproveParams,
|
||||||
ConfigGetParams,
|
ConfigGetParams,
|
||||||
ConfigSetParams,
|
ConfigSetParams,
|
||||||
|
ProvidersStatusParams,
|
||||||
|
WebLoginStartParams,
|
||||||
|
WebLoginWaitParams,
|
||||||
SkillsStatusParams,
|
SkillsStatusParams,
|
||||||
SkillsInstallParams,
|
SkillsInstallParams,
|
||||||
SkillsUpdateParams,
|
SkillsUpdateParams,
|
||||||
|
|||||||
@ -305,6 +305,52 @@ export const ConfigSetParamsSchema = Type.Object(
|
|||||||
{ additionalProperties: false },
|
{ 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(
|
export const SkillsStatusParamsSchema = Type.Object(
|
||||||
{},
|
{},
|
||||||
{ additionalProperties: false },
|
{ additionalProperties: false },
|
||||||
@ -583,6 +629,12 @@ export const ProtocolSchemas: Record<string, TSchema> = {
|
|||||||
SessionsPatchParams: SessionsPatchParamsSchema,
|
SessionsPatchParams: SessionsPatchParamsSchema,
|
||||||
ConfigGetParams: ConfigGetParamsSchema,
|
ConfigGetParams: ConfigGetParamsSchema,
|
||||||
ConfigSetParams: ConfigSetParamsSchema,
|
ConfigSetParams: ConfigSetParamsSchema,
|
||||||
|
ProvidersStatusParams: ProvidersStatusParamsSchema,
|
||||||
|
WebLoginStartParams: WebLoginStartParamsSchema,
|
||||||
|
WebLoginWaitParams: WebLoginWaitParamsSchema,
|
||||||
|
ModelChoice: ModelChoiceSchema,
|
||||||
|
ModelsListParams: ModelsListParamsSchema,
|
||||||
|
ModelsListResult: ModelsListResultSchema,
|
||||||
SkillsStatusParams: SkillsStatusParamsSchema,
|
SkillsStatusParams: SkillsStatusParamsSchema,
|
||||||
SkillsInstallParams: SkillsInstallParamsSchema,
|
SkillsInstallParams: SkillsInstallParamsSchema,
|
||||||
SkillsUpdateParams: SkillsUpdateParamsSchema,
|
SkillsUpdateParams: SkillsUpdateParamsSchema,
|
||||||
@ -629,6 +681,12 @@ export type SessionsListParams = Static<typeof SessionsListParamsSchema>;
|
|||||||
export type SessionsPatchParams = Static<typeof SessionsPatchParamsSchema>;
|
export type SessionsPatchParams = Static<typeof SessionsPatchParamsSchema>;
|
||||||
export type ConfigGetParams = Static<typeof ConfigGetParamsSchema>;
|
export type ConfigGetParams = Static<typeof ConfigGetParamsSchema>;
|
||||||
export type ConfigSetParams = Static<typeof ConfigSetParamsSchema>;
|
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 SkillsStatusParams = Static<typeof SkillsStatusParamsSchema>;
|
||||||
export type SkillsInstallParams = Static<typeof SkillsInstallParamsSchema>;
|
export type SkillsInstallParams = Static<typeof SkillsInstallParamsSchema>;
|
||||||
export type SkillsUpdateParams = Static<typeof SkillsUpdateParamsSchema>;
|
export type SkillsUpdateParams = Static<typeof SkillsUpdateParamsSchema>;
|
||||||
|
|||||||
@ -10,7 +10,10 @@ import { emitAgentEvent } from "../infra/agent-events.js";
|
|||||||
import { GatewayLockError } from "../infra/gateway-lock.js";
|
import { GatewayLockError } from "../infra/gateway-lock.js";
|
||||||
import { emitHeartbeatEvent } from "../infra/heartbeat-events.js";
|
import { emitHeartbeatEvent } from "../infra/heartbeat-events.js";
|
||||||
import { PROTOCOL_VERSION } from "./protocol/index.js";
|
import { PROTOCOL_VERSION } from "./protocol/index.js";
|
||||||
import { startGatewayServer } from "./server.js";
|
import {
|
||||||
|
__resetModelCatalogCacheForTest,
|
||||||
|
startGatewayServer,
|
||||||
|
} from "./server.js";
|
||||||
|
|
||||||
type BridgeClientInfo = {
|
type BridgeClientInfo = {
|
||||||
nodeId: string;
|
nodeId: string;
|
||||||
@ -58,6 +61,36 @@ const bridgeSendEvent = vi.hoisted(() => vi.fn());
|
|||||||
const testTailnetIPv4 = vi.hoisted(() => ({
|
const testTailnetIPv4 = vi.hoisted(() => ({
|
||||||
value: undefined as string | undefined,
|
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", () => ({
|
vi.mock("../infra/bridge/server.js", () => ({
|
||||||
startNodeBridgeServer: vi.fn(async (opts: BridgeStartOpts) => {
|
startNodeBridgeServer: vi.fn(async (opts: BridgeStartOpts) => {
|
||||||
bridgeStartCalls.push(opts);
|
bridgeStartCalls.push(opts);
|
||||||
@ -141,6 +174,11 @@ beforeEach(async () => {
|
|||||||
sessionStoreSaveDelayMs.value = 0;
|
sessionStoreSaveDelayMs.value = 0;
|
||||||
testTailnetIPv4.value = undefined;
|
testTailnetIPv4.value = undefined;
|
||||||
testGatewayBind = undefined;
|
testGatewayBind = undefined;
|
||||||
|
__resetModelCatalogCacheForTest();
|
||||||
|
piAiMock.enabled = false;
|
||||||
|
piAiMock.getModelsCalls.length = 0;
|
||||||
|
piAiMock.providers = ["openai", "anthropic"];
|
||||||
|
piAiMock.modelsByProvider = { openai: [], anthropic: [] };
|
||||||
});
|
});
|
||||||
|
|
||||||
afterEach(async () => {
|
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 () => {
|
test("pushes voicewake.changed to nodes on connect and on updates", async () => {
|
||||||
const homeDir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdis-home-"));
|
const homeDir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdis-home-"));
|
||||||
const prevHome = process.env.HOME;
|
const prevHome = process.env.HOME;
|
||||||
@ -1702,19 +1863,26 @@ describe("gateway server", () => {
|
|||||||
ws,
|
ws,
|
||||||
(o) => o.type === "res" && o.id === "presence1",
|
(o) => o.type === "res" && o.id === "presence1",
|
||||||
);
|
);
|
||||||
|
const providersP = onceMessage(
|
||||||
|
ws,
|
||||||
|
(o) => o.type === "res" && o.id === "providers1",
|
||||||
|
);
|
||||||
|
|
||||||
const sendReq = (id: string, method: string) =>
|
const sendReq = (id: string, method: string) =>
|
||||||
ws.send(JSON.stringify({ type: "req", id, method }));
|
ws.send(JSON.stringify({ type: "req", id, method }));
|
||||||
sendReq("health1", "health");
|
sendReq("health1", "health");
|
||||||
sendReq("status1", "status");
|
sendReq("status1", "status");
|
||||||
sendReq("presence1", "system-presence");
|
sendReq("presence1", "system-presence");
|
||||||
|
sendReq("providers1", "providers.status");
|
||||||
|
|
||||||
const health = await healthP;
|
const health = await healthP;
|
||||||
const status = await statusP;
|
const status = await statusP;
|
||||||
const presence = await presenceP;
|
const presence = await presenceP;
|
||||||
|
const providers = await providersP;
|
||||||
expect(health.ok).toBe(true);
|
expect(health.ok).toBe(true);
|
||||||
expect(status.ok).toBe(true);
|
expect(status.ok).toBe(true);
|
||||||
expect(presence.ok).toBe(true);
|
expect(presence.ok).toBe(true);
|
||||||
|
expect(providers.ok).toBe(true);
|
||||||
expect(Array.isArray(presence.payload)).toBe(true);
|
expect(Array.isArray(presence.payload)).toBe(true);
|
||||||
|
|
||||||
ws.close();
|
ws.close();
|
||||||
|
|||||||
@ -101,11 +101,17 @@ import { runExec } from "../process/exec.js";
|
|||||||
import { monitorWebProvider, webAuthExists } from "../providers/web/index.js";
|
import { monitorWebProvider, webAuthExists } from "../providers/web/index.js";
|
||||||
import { defaultRuntime } from "../runtime.js";
|
import { defaultRuntime } from "../runtime.js";
|
||||||
import { monitorTelegramProvider } from "../telegram/monitor.js";
|
import { monitorTelegramProvider } from "../telegram/monitor.js";
|
||||||
|
import { probeTelegram, type TelegramProbe } from "../telegram/probe.js";
|
||||||
import { sendMessageTelegram } from "../telegram/send.js";
|
import { sendMessageTelegram } from "../telegram/send.js";
|
||||||
import { normalizeE164, resolveUserPath } from "../utils.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 { sendMessageWhatsApp } from "../web/outbound.js";
|
||||||
import { requestReplyHeartbeatNow } from "../web/reply-heartbeat-wake.js";
|
import { requestReplyHeartbeatNow } from "../web/reply-heartbeat-wake.js";
|
||||||
|
import { getWebAuthAgeMs, logoutWeb, readWebSelfId } from "../web/session.js";
|
||||||
import { buildMessageWithAttachments } from "./chat-attachments.js";
|
import { buildMessageWithAttachments } from "./chat-attachments.js";
|
||||||
import { handleControlUiHttpRequest } from "./control-ui.js";
|
import { handleControlUiHttpRequest } from "./control-ui.js";
|
||||||
|
|
||||||
@ -156,6 +162,65 @@ async function startBrowserControlServerIfEnabled(): Promise<void> {
|
|||||||
await mod.startBrowserControlServerFromConfig(defaultRuntime);
|
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 {
|
import {
|
||||||
type ConnectParams,
|
type ConnectParams,
|
||||||
ErrorCodes,
|
ErrorCodes,
|
||||||
@ -181,6 +246,7 @@ import {
|
|||||||
validateCronRunsParams,
|
validateCronRunsParams,
|
||||||
validateCronStatusParams,
|
validateCronStatusParams,
|
||||||
validateCronUpdateParams,
|
validateCronUpdateParams,
|
||||||
|
validateModelsListParams,
|
||||||
validateNodeDescribeParams,
|
validateNodeDescribeParams,
|
||||||
validateNodeInvokeParams,
|
validateNodeInvokeParams,
|
||||||
validateNodeListParams,
|
validateNodeListParams,
|
||||||
@ -189,6 +255,7 @@ import {
|
|||||||
validateNodePairRejectParams,
|
validateNodePairRejectParams,
|
||||||
validateNodePairRequestParams,
|
validateNodePairRequestParams,
|
||||||
validateNodePairVerifyParams,
|
validateNodePairVerifyParams,
|
||||||
|
validateProvidersStatusParams,
|
||||||
validateRequestFrame,
|
validateRequestFrame,
|
||||||
validateSendParams,
|
validateSendParams,
|
||||||
validateSessionsListParams,
|
validateSessionsListParams,
|
||||||
@ -197,6 +264,8 @@ import {
|
|||||||
validateSkillsStatusParams,
|
validateSkillsStatusParams,
|
||||||
validateSkillsUpdateParams,
|
validateSkillsUpdateParams,
|
||||||
validateWakeParams,
|
validateWakeParams,
|
||||||
|
validateWebLoginStartParams,
|
||||||
|
validateWebLoginWaitParams,
|
||||||
} from "./protocol/index.js";
|
} from "./protocol/index.js";
|
||||||
import { DEFAULT_WS_SLOW_MS, getGatewayWsLogStyle } from "./ws-logging.js";
|
import { DEFAULT_WS_SLOW_MS, getGatewayWsLogStyle } from "./ws-logging.js";
|
||||||
|
|
||||||
@ -267,9 +336,11 @@ type SessionsPatchResult = {
|
|||||||
|
|
||||||
const METHODS = [
|
const METHODS = [
|
||||||
"health",
|
"health",
|
||||||
|
"providers.status",
|
||||||
"status",
|
"status",
|
||||||
"config.get",
|
"config.get",
|
||||||
"config.set",
|
"config.set",
|
||||||
|
"models.list",
|
||||||
"skills.status",
|
"skills.status",
|
||||||
"skills.install",
|
"skills.install",
|
||||||
"skills.update",
|
"skills.update",
|
||||||
@ -299,6 +370,10 @@ const METHODS = [
|
|||||||
"system-event",
|
"system-event",
|
||||||
"send",
|
"send",
|
||||||
"agent",
|
"agent",
|
||||||
|
"web.login.start",
|
||||||
|
"web.login.wait",
|
||||||
|
"web.logout",
|
||||||
|
"telegram.logout",
|
||||||
// WebChat WebSocket-native chat methods
|
// WebChat WebSocket-native chat methods
|
||||||
"chat.history",
|
"chat.history",
|
||||||
"chat.abort",
|
"chat.abort",
|
||||||
@ -1113,8 +1188,33 @@ export async function startGatewayServer(
|
|||||||
wss.emit("connection", ws, req);
|
wss.emit("connection", ws, req);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
const providerAbort = new AbortController();
|
let whatsappAbort: AbortController | null = null;
|
||||||
const providerTasks: Array<Promise<unknown>> = [];
|
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>();
|
const clients = new Set<Client>();
|
||||||
let seq = 0;
|
let seq = 0;
|
||||||
// Track per-run sequence to detect out-of-order/lost agent events.
|
// Track per-run sequence to detect out-of-order/lost agent events.
|
||||||
@ -1185,49 +1285,150 @@ export async function startGatewayServer(
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
const startProviders = async () => {
|
const updateWhatsAppStatus = (next: WebProviderStatus) => {
|
||||||
const cfg = loadConfig();
|
whatsappRuntime = next;
|
||||||
const telegramToken =
|
};
|
||||||
process.env.TELEGRAM_BOT_TOKEN ?? cfg.telegram?.botToken ?? "";
|
|
||||||
|
|
||||||
if (await webAuthExists()) {
|
const startWhatsAppProvider = async () => {
|
||||||
defaultRuntime.log("gateway: starting WhatsApp Web provider");
|
if (whatsappTask) return;
|
||||||
providerTasks.push(
|
if (!(await webAuthExists())) {
|
||||||
monitorWebProvider(
|
whatsappRuntime = {
|
||||||
isVerbose(),
|
...whatsappRuntime,
|
||||||
undefined,
|
running: false,
|
||||||
true,
|
connected: false,
|
||||||
undefined,
|
lastError: "not linked",
|
||||||
defaultRuntime,
|
};
|
||||||
providerAbort.signal,
|
|
||||||
).catch((err) => logError(`web provider exited: ${formatError(err)}`)),
|
|
||||||
);
|
|
||||||
} else {
|
|
||||||
defaultRuntime.log(
|
defaultRuntime.log(
|
||||||
"gateway: skipping WhatsApp Web provider (no linked session)",
|
"gateway: skipping WhatsApp Web provider (no linked session)",
|
||||||
);
|
);
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
|
defaultRuntime.log("gateway: starting WhatsApp Web provider");
|
||||||
|
whatsappAbort = new AbortController();
|
||||||
|
whatsappRuntime = {
|
||||||
|
...whatsappRuntime,
|
||||||
|
running: true,
|
||||||
|
connected: false,
|
||||||
|
lastError: null,
|
||||||
|
};
|
||||||
|
const task = monitorWebProvider(
|
||||||
|
isVerbose(),
|
||||||
|
undefined,
|
||||||
|
true,
|
||||||
|
undefined,
|
||||||
|
defaultRuntime,
|
||||||
|
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 () => {
|
||||||
defaultRuntime.log("gateway: starting Telegram provider");
|
if (!whatsappAbort && !whatsappTask) return;
|
||||||
providerTasks.push(
|
whatsappAbort?.abort();
|
||||||
monitorTelegramProvider({
|
try {
|
||||||
token: telegramToken.trim(),
|
await whatsappTask;
|
||||||
runtime: defaultRuntime,
|
} catch {
|
||||||
abortSignal: providerAbort.signal,
|
// ignore
|
||||||
useWebhook: Boolean(cfg.telegram?.webhookUrl),
|
}
|
||||||
webhookUrl: cfg.telegram?.webhookUrl,
|
whatsappAbort = null;
|
||||||
webhookSecret: cfg.telegram?.webhookSecret,
|
whatsappTask = null;
|
||||||
webhookPath: cfg.telegram?.webhookPath,
|
whatsappRuntime = {
|
||||||
}).catch((err) =>
|
...whatsappRuntime,
|
||||||
logError(`telegram provider exited: ${formatError(err)}`),
|
running: false,
|
||||||
),
|
connected: false,
|
||||||
);
|
};
|
||||||
} else {
|
};
|
||||||
|
|
||||||
|
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(
|
defaultRuntime.log(
|
||||||
"gateway: skipping Telegram provider (no TELEGRAM_BOT_TOKEN/config)",
|
"gateway: skipping Telegram provider (no TELEGRAM_BOT_TOKEN/config)",
|
||||||
);
|
);
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
|
defaultRuntime.log("gateway: starting Telegram provider");
|
||||||
|
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: telegramAbort.signal,
|
||||||
|
useWebhook: Boolean(cfg.telegram?.webhookUrl),
|
||||||
|
webhookUrl: cfg.telegram?.webhookUrl,
|
||||||
|
webhookSecret: cfg.telegram?.webhookSecret,
|
||||||
|
webhookPath: cfg.telegram?.webhookPath,
|
||||||
|
})
|
||||||
|
.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 = (
|
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": {
|
case "sessions.list": {
|
||||||
const params = parseParams();
|
const params = parseParams();
|
||||||
if (!validateSessionsListParams(params)) {
|
if (!validateSessionsListParams(params)) {
|
||||||
@ -2407,7 +2622,8 @@ export async function startGatewayServer(
|
|||||||
const remoteAddr = (
|
const remoteAddr = (
|
||||||
socket as WebSocket & { _socket?: { remoteAddress?: string } }
|
socket as WebSocket & { _socket?: { remoteAddress?: string } }
|
||||||
)._socket?.remoteAddress;
|
)._socket?.remoteAddress;
|
||||||
const canvasHostPortForWs = canvasHostServer?.port ?? (canvasHost ? port : undefined);
|
const canvasHostPortForWs =
|
||||||
|
canvasHostServer?.port ?? (canvasHost ? port : undefined);
|
||||||
const canvasHostOverride =
|
const canvasHostOverride =
|
||||||
bridgeHost && bridgeHost !== "0.0.0.0" && bridgeHost !== "::"
|
bridgeHost && bridgeHost !== "0.0.0.0" && bridgeHost !== "::"
|
||||||
? bridgeHost
|
? bridgeHost
|
||||||
@ -2770,6 +2986,84 @@ export async function startGatewayServer(
|
|||||||
}
|
}
|
||||||
break;
|
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": {
|
case "chat.history": {
|
||||||
const params = (req.params ?? {}) as Record<string, unknown>;
|
const params = (req.params ?? {}) as Record<string, unknown>;
|
||||||
if (!validateChatHistoryParams(params)) {
|
if (!validateChatHistoryParams(params)) {
|
||||||
@ -3194,6 +3488,164 @@ export async function startGatewayServer(
|
|||||||
respond(true, status, undefined);
|
respond(true, status, undefined);
|
||||||
break;
|
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": {
|
case "config.get": {
|
||||||
const params = (req.params ?? {}) as Record<string, unknown>;
|
const params = (req.params ?? {}) as Record<string, unknown>;
|
||||||
if (!validateConfigGetParams(params)) {
|
if (!validateConfigGetParams(params)) {
|
||||||
@ -4444,7 +4896,8 @@ export async function startGatewayServer(
|
|||||||
/* ignore */
|
/* ignore */
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
providerAbort.abort();
|
await stopWhatsAppProvider();
|
||||||
|
await stopTelegramProvider();
|
||||||
cron.stop();
|
cron.stop();
|
||||||
broadcast("shutdown", {
|
broadcast("shutdown", {
|
||||||
reason: "gateway stopping",
|
reason: "gateway stopping",
|
||||||
@ -4480,7 +4933,9 @@ export async function startGatewayServer(
|
|||||||
if (stopBrowserControlServerIfStarted) {
|
if (stopBrowserControlServerIfStarted) {
|
||||||
await stopBrowserControlServerIfStarted().catch(() => {});
|
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) => wss.close(() => resolve()));
|
||||||
await new Promise<void>((resolve, reject) =>
|
await new Promise<void>((resolve, reject) =>
|
||||||
httpServer.close((err) => (err ? reject(err) : resolve())),
|
httpServer.close((err) => (err ? reject(err) : resolve())),
|
||||||
|
|||||||
96
src/telegram/probe.ts
Normal file
96
src/telegram/probe.ts
Normal 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,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue
Block a user