refactor!: rename chat providers to channels

This commit is contained in:
Peter Steinberger
2026-01-13 06:16:43 +00:00
parent 0cd632ba84
commit 90342a4f3a
393 changed files with 8004 additions and 6737 deletions

View File

@@ -12,7 +12,7 @@ import {
GATEWAY_CLIENT_NAMES,
type GatewayClientMode,
type GatewayClientName,
} from "../utils/message-provider.js";
} from "../utils/message-channel.js";
import { GatewayClient } from "./client.js";
import { PROTOCOL_VERSION } from "./protocol/index.js";

View File

@@ -7,7 +7,7 @@ import {
GATEWAY_CLIENT_NAMES,
type GatewayClientMode,
type GatewayClientName,
} from "../utils/message-provider.js";
} from "../utils/message-channel.js";
import {
type ConnectParams,
type EventFrame,

View File

@@ -1,5 +1,5 @@
import { describe, expect, it } from "vitest";
import { listProviderPlugins } from "../providers/plugins/index.js";
import { listChannelPlugins } from "../channels/plugins/index.js";
import {
buildGatewayReloadPlan,
diffConfigPaths,
@@ -37,11 +37,11 @@ describe("buildGatewayReloadPlan", () => {
});
it("restarts providers when provider config prefixes change", () => {
const changedPaths = ["web.enabled", "telegram.botToken"];
const changedPaths = ["web.enabled", "channels.telegram.botToken"];
const plan = buildGatewayReloadPlan(changedPaths);
expect(plan.restartGateway).toBe(false);
const expected = new Set(
listProviderPlugins()
listChannelPlugins()
.filter((plugin) =>
(plugin.reload?.configPrefixes ?? []).some((prefix) =>
changedPaths.some(
@@ -52,7 +52,7 @@ describe("buildGatewayReloadPlan", () => {
.map((plugin) => plugin.id),
);
expect(expected.size).toBeGreaterThan(0);
expect(plan.restartProviders).toEqual(expected);
expect(plan.restartChannels).toEqual(expected);
});
it("treats gateway.remote as no-op", () => {

View File

@@ -1,21 +1,20 @@
import chokidar from "chokidar";
import {
type ChannelId,
listChannelPlugins,
} from "../channels/plugins/index.js";
import type {
ClawdbotConfig,
ConfigFileSnapshot,
GatewayReloadMode,
} from "../config/config.js";
import {
listProviderPlugins,
type ProviderId,
} from "../providers/plugins/index.js";
export type GatewayReloadSettings = {
mode: GatewayReloadMode;
debounceMs: number;
};
export type ProviderKind = ProviderId;
export type ChannelKind = ChannelId;
export type GatewayReloadPlan = {
changedPaths: string[];
@@ -27,7 +26,7 @@ export type GatewayReloadPlan = {
restartBrowserControl: boolean;
restartCron: boolean;
restartHeartbeat: boolean;
restartProviders: Set<ProviderKind>;
restartChannels: Set<ChannelKind>;
noopPaths: string[];
};
@@ -43,7 +42,7 @@ type ReloadAction =
| "restart-browser-control"
| "restart-cron"
| "restart-heartbeat"
| `restart-provider:${ProviderId}`;
| `restart-channel:${ChannelId}`;
const DEFAULT_RELOAD_SETTINGS: GatewayReloadSettings = {
mode: "hybrid",
@@ -96,14 +95,14 @@ let cachedReloadRules: ReloadRule[] | null = null;
function listReloadRules(): ReloadRule[] {
if (cachedReloadRules) return cachedReloadRules;
// Provider docking: plugins contribute hot reload/no-op prefixes here.
const providerReloadRules: ReloadRule[] = listProviderPlugins().flatMap(
// Channel docking: plugins contribute hot reload/no-op prefixes here.
const channelReloadRules: ReloadRule[] = listChannelPlugins().flatMap(
(plugin) => [
...(plugin.reload?.configPrefixes ?? []).map(
(prefix): ReloadRule => ({
prefix,
kind: "hot",
actions: [`restart-provider:${plugin.id}` as ReloadAction],
actions: [`restart-channel:${plugin.id}` as ReloadAction],
}),
),
...(plugin.reload?.noopPrefixes ?? []).map(
@@ -116,7 +115,7 @@ function listReloadRules(): ReloadRule[] {
);
const rules = [
...BASE_RELOAD_RULES,
...providerReloadRules,
...channelReloadRules,
...BASE_RELOAD_RULES_TAIL,
];
cachedReloadRules = rules;
@@ -203,14 +202,14 @@ export function buildGatewayReloadPlan(
restartBrowserControl: false,
restartCron: false,
restartHeartbeat: false,
restartProviders: new Set(),
restartChannels: new Set(),
noopPaths: [],
};
const applyAction = (action: ReloadAction) => {
if (action.startsWith("restart-provider:")) {
const provider = action.slice("restart-provider:".length) as ProviderId;
plan.restartProviders.add(provider);
if (action.startsWith("restart-channel:")) {
const channel = action.slice("restart-channel:".length) as ChannelId;
plan.restartChannels.add(channel);
return;
}
switch (action) {

View File

@@ -29,7 +29,7 @@ import type { ClawdbotConfig, ModelProviderConfig } from "../config/types.js";
import {
GATEWAY_CLIENT_MODES,
GATEWAY_CLIENT_NAMES,
} from "../utils/message-provider.js";
} from "../utils/message-channel.js";
import { resolveUserPath } from "../utils.js";
import { GatewayClient } from "./client.js";
import { renderCatNoncePngBase64 } from "./live-image-probe.js";

View File

@@ -8,7 +8,7 @@ import { describe, expect, it } from "vitest";
import {
GATEWAY_CLIENT_MODES,
GATEWAY_CLIENT_NAMES,
} from "../utils/message-provider.js";
} from "../utils/message-channel.js";
import { GatewayClient } from "./client.js";
import { startGatewayServer } from "./server.js";

View File

@@ -11,7 +11,7 @@ import { rawDataToString } from "../infra/ws.js";
import {
GATEWAY_CLIENT_MODES,
GATEWAY_CLIENT_NAMES,
} from "../utils/message-provider.js";
} from "../utils/message-channel.js";
import { PROTOCOL_VERSION } from "./protocol/index.js";
async function getFreePort(): Promise<number> {

View File

@@ -6,7 +6,7 @@ import {
type HookMappingConfig,
type HooksConfig,
} from "../config/config.js";
import type { HookMessageProvider } from "./hooks.js";
import type { HookMessageChannel } from "./hooks.js";
export type HookMappingResolved = {
id: string;
@@ -19,7 +19,7 @@ export type HookMappingResolved = {
messageTemplate?: string;
textTemplate?: string;
deliver?: boolean;
provider?: HookMessageProvider;
channel?: HookMessageChannel;
to?: string;
model?: string;
thinking?: string;
@@ -52,7 +52,7 @@ export type HookAction =
wakeMode: "now" | "next-heartbeat";
sessionKey?: string;
deliver?: boolean;
provider?: HookMessageProvider;
channel?: HookMessageChannel;
to?: string;
model?: string;
thinking?: string;
@@ -90,7 +90,7 @@ type HookTransformResult = Partial<{
name: string;
sessionKey: string;
deliver: boolean;
provider: HookMessageProvider;
channel: HookMessageChannel;
to: string;
model: string;
thinking: string;
@@ -179,7 +179,7 @@ function normalizeHookMapping(
messageTemplate: mapping.messageTemplate,
textTemplate: mapping.textTemplate,
deliver: mapping.deliver,
provider: mapping.provider,
channel: mapping.channel,
to: mapping.to,
model: mapping.model,
thinking: mapping.thinking,
@@ -225,7 +225,7 @@ function buildActionFromMapping(
wakeMode: mapping.wakeMode ?? "now",
sessionKey: renderOptional(mapping.sessionKey, ctx),
deliver: mapping.deliver,
provider: mapping.provider,
channel: mapping.channel,
to: renderOptional(mapping.to, ctx),
model: renderOptional(mapping.model, ctx),
thinking: renderOptional(mapping.thinking, ctx),
@@ -276,7 +276,7 @@ function mergeAction(
typeof override.deliver === "boolean"
? override.deliver
: baseAgent?.deliver,
provider: override.provider ?? baseAgent?.provider,
channel: override.channel ?? baseAgent?.channel,
to: override.to ?? baseAgent?.to,
model: override.model ?? baseAgent?.model,
thinking: override.thinking ?? baseAgent?.thinking,

View File

@@ -56,7 +56,7 @@ describe("gateway hooks helpers", () => {
expect(normalizeWakePayload({ text: " ", mode: "now" }).ok).toBe(false);
});
test("normalizeAgentPayload defaults + validates provider", () => {
test("normalizeAgentPayload defaults + validates channel", () => {
const ok = normalizeAgentPayload(
{ message: "hello" },
{ idFactory: () => "fixed" },
@@ -64,7 +64,7 @@ describe("gateway hooks helpers", () => {
expect(ok.ok).toBe(true);
if (ok.ok) {
expect(ok.value.sessionKey).toBe("hook:fixed");
expect(ok.value.provider).toBe("last");
expect(ok.value.channel).toBe("last");
expect(ok.value.name).toBe("Hook");
expect(ok.value.deliver).toBe(true);
}
@@ -79,24 +79,24 @@ describe("gateway hooks helpers", () => {
}
const imsg = normalizeAgentPayload(
{ message: "yo", provider: "imsg" },
{ message: "yo", channel: "imsg" },
{ idFactory: () => "x" },
);
expect(imsg.ok).toBe(true);
if (imsg.ok) {
expect(imsg.value.provider).toBe("imessage");
expect(imsg.value.channel).toBe("imessage");
}
const teams = normalizeAgentPayload(
{ message: "yo", provider: "teams" },
{ message: "yo", channel: "teams" },
{ idFactory: () => "x" },
);
expect(teams.ok).toBe(true);
if (teams.ok) {
expect(teams.value.provider).toBe("msteams");
expect(teams.value.channel).toBe("msteams");
}
const bad = normalizeAgentPayload({ message: "yo", provider: "sms" });
const bad = normalizeAgentPayload({ message: "yo", channel: "sms" });
expect(bad.ok).toBe(false);
});
});

View File

@@ -1,11 +1,9 @@
import { randomUUID } from "node:crypto";
import type { IncomingMessage } from "node:http";
import { listChannelPlugins } from "../channels/plugins/index.js";
import type { ChannelId } from "../channels/plugins/types.js";
import type { ClawdbotConfig } from "../config/config.js";
import {
listProviderPlugins,
type ProviderId,
} from "../providers/plugins/index.js";
import { normalizeMessageProvider } from "../utils/message-provider.js";
import { normalizeMessageChannel } from "../utils/message-channel.js";
import {
type HookMappingResolved,
resolveHookMappings,
@@ -142,29 +140,29 @@ export type HookAgentPayload = {
wakeMode: "now" | "next-heartbeat";
sessionKey: string;
deliver: boolean;
provider: HookMessageProvider;
channel: HookMessageChannel;
to?: string;
model?: string;
thinking?: string;
timeoutSeconds?: number;
};
const HOOK_PROVIDER_VALUES = [
const HOOK_CHANNEL_VALUES = [
"last",
...listProviderPlugins().map((plugin) => plugin.id),
...listChannelPlugins().map((plugin) => plugin.id),
];
export type HookMessageProvider = ProviderId | "last";
export type HookMessageChannel = ChannelId | "last";
const hookProviderSet = new Set<string>(HOOK_PROVIDER_VALUES);
export const HOOK_PROVIDER_ERROR = `provider must be ${HOOK_PROVIDER_VALUES.join("|")}`;
const hookChannelSet = new Set<string>(HOOK_CHANNEL_VALUES);
export const HOOK_CHANNEL_ERROR = `channel must be ${HOOK_CHANNEL_VALUES.join("|")}`;
export function resolveHookProvider(raw: unknown): HookMessageProvider | null {
export function resolveHookChannel(raw: unknown): HookMessageChannel | null {
if (raw === undefined) return "last";
if (typeof raw !== "string") return null;
const normalized = normalizeMessageProvider(raw);
if (!normalized || !hookProviderSet.has(normalized)) return null;
return normalized as HookMessageProvider;
const normalized = normalizeMessageChannel(raw);
if (!normalized || !hookChannelSet.has(normalized)) return null;
return normalized as HookMessageChannel;
}
export function resolveHookDeliver(raw: unknown): boolean {
@@ -194,8 +192,8 @@ export function normalizeAgentPayload(
typeof sessionKeyRaw === "string" && sessionKeyRaw.trim()
? sessionKeyRaw.trim()
: `hook:${idFactory()}`;
const provider = resolveHookProvider(payload.provider);
if (!provider) return { ok: false, error: HOOK_PROVIDER_ERROR };
const channel = resolveHookChannel(payload.channel);
if (!channel) return { ok: false, error: HOOK_CHANNEL_ERROR };
const toRaw = payload.to;
const to =
typeof toRaw === "string" && toRaw.trim() ? toRaw.trim() : undefined;
@@ -228,7 +226,7 @@ export function normalizeAgentPayload(
wakeMode,
sessionKey,
deliver,
provider,
channel,
to,
model,
thinking,

View File

@@ -234,7 +234,7 @@ export async function handleOpenAiHttpRequest(
sessionKey,
runId,
deliver: false,
messageProvider: "webchat",
messageChannel: "webchat",
bestEffortDeliver: false,
},
defaultRuntime,
@@ -352,7 +352,7 @@ export async function handleOpenAiHttpRequest(
sessionKey,
runId,
deliver: false,
messageProvider: "webchat",
messageChannel: "webchat",
bestEffortDeliver: false,
},
defaultRuntime,

View File

@@ -4,7 +4,7 @@ import type { SystemPresence } from "../infra/system-presence.js";
import {
GATEWAY_CLIENT_MODES,
GATEWAY_CLIENT_NAMES,
} from "../utils/message-provider.js";
} from "../utils/message-channel.js";
import { GatewayClient } from "./client.js";
export type GatewayProbeAuth = {

View File

@@ -11,6 +11,12 @@ import {
AgentsListResultSchema,
type AgentWaitParams,
AgentWaitParamsSchema,
type ChannelsLogoutParams,
ChannelsLogoutParamsSchema,
type ChannelsStatusParams,
ChannelsStatusParamsSchema,
type ChannelsStatusResult,
ChannelsStatusResultSchema,
type ChatAbortParams,
ChatAbortParamsSchema,
type ChatEvent,
@@ -86,12 +92,6 @@ import {
type PresenceEntry,
PresenceEntrySchema,
ProtocolSchemas,
type ProvidersLogoutParams,
ProvidersLogoutParamsSchema,
type ProvidersStatusParams,
ProvidersStatusParamsSchema,
type ProvidersStatusResult,
ProvidersStatusResultSchema,
type RequestFrame,
RequestFrameSchema,
type ResponseFrame,
@@ -248,11 +248,11 @@ export const validateWizardStatusParams = ajv.compile<WizardStatusParams>(
);
export const validateTalkModeParams =
ajv.compile<TalkModeParams>(TalkModeParamsSchema);
export const validateProvidersStatusParams = ajv.compile<ProvidersStatusParams>(
ProvidersStatusParamsSchema,
export const validateChannelsStatusParams = ajv.compile<ChannelsStatusParams>(
ChannelsStatusParamsSchema,
);
export const validateProvidersLogoutParams = ajv.compile<ProvidersLogoutParams>(
ProvidersLogoutParamsSchema,
export const validateChannelsLogoutParams = ajv.compile<ChannelsLogoutParams>(
ChannelsLogoutParamsSchema,
);
export const validateModelsListParams = ajv.compile<ModelsListParams>(
ModelsListParamsSchema,
@@ -350,9 +350,9 @@ export {
WizardNextResultSchema,
WizardStartResultSchema,
WizardStatusResultSchema,
ProvidersStatusParamsSchema,
ProvidersStatusResultSchema,
ProvidersLogoutParamsSchema,
ChannelsStatusParamsSchema,
ChannelsStatusResultSchema,
ChannelsLogoutParamsSchema,
WebLoginStartParamsSchema,
WebLoginWaitParamsSchema,
AgentSummarySchema,
@@ -417,9 +417,9 @@ export type {
WizardStartResult,
WizardStatusResult,
TalkModeParams,
ProvidersStatusParams,
ProvidersStatusResult,
ProvidersLogoutParams,
ChannelsStatusParams,
ChannelsStatusResult,
ChannelsLogoutParams,
WebLoginStartParams,
WebLoginWaitParams,
AgentSummary,

View File

@@ -206,7 +206,7 @@ export const SendParamsSchema = Type.Object(
message: NonEmptyString,
mediaUrl: Type.Optional(Type.String()),
gifPlayback: Type.Optional(Type.Boolean()),
provider: Type.Optional(Type.String()),
channel: Type.Optional(Type.String()),
accountId: Type.Optional(Type.String()),
idempotencyKey: NonEmptyString,
},
@@ -220,7 +220,7 @@ export const PollParamsSchema = Type.Object(
options: Type.Array(NonEmptyString, { minItems: 2, maxItems: 12 }),
maxSelections: Type.Optional(Type.Integer({ minimum: 1, maximum: 12 })),
durationHours: Type.Optional(Type.Integer({ minimum: 1 })),
provider: Type.Optional(Type.String()),
channel: Type.Optional(Type.String()),
accountId: Type.Optional(Type.String()),
idempotencyKey: NonEmptyString,
},
@@ -235,7 +235,7 @@ export const AgentParamsSchema = Type.Object(
thinking: Type.Optional(Type.String()),
deliver: Type.Optional(Type.Boolean()),
attachments: Type.Optional(Type.Array(Type.Unknown())),
provider: Type.Optional(Type.String()),
channel: Type.Optional(Type.String()),
timeout: Type.Optional(Type.Integer({ minimum: 0 })),
lane: Type.Optional(Type.String()),
extraSystemPrompt: Type.Optional(Type.String()),
@@ -588,7 +588,7 @@ export const TalkModeParamsSchema = Type.Object(
{ additionalProperties: false },
);
export const ProvidersStatusParamsSchema = Type.Object(
export const ChannelsStatusParamsSchema = Type.Object(
{
probe: Type.Optional(Type.Boolean()),
timeoutMs: Type.Optional(Type.Integer({ minimum: 0 })),
@@ -596,9 +596,9 @@ export const ProvidersStatusParamsSchema = Type.Object(
{ additionalProperties: false },
);
// Provider docking: providers.status is intentionally schema-light so new
// providers can ship without protocol updates.
export const ProviderAccountSnapshotSchema = Type.Object(
// Channel docking: channels.status is intentionally schema-light so new
// channels can ship without protocol updates.
export const ChannelAccountSnapshotSchema = Type.Object(
{
accountId: NonEmptyString,
name: Type.Optional(Type.String()),
@@ -635,24 +635,24 @@ export const ProviderAccountSnapshotSchema = Type.Object(
{ additionalProperties: true },
);
export const ProvidersStatusResultSchema = Type.Object(
export const ChannelsStatusResultSchema = Type.Object(
{
ts: Type.Integer({ minimum: 0 }),
providerOrder: Type.Array(NonEmptyString),
providerLabels: Type.Record(NonEmptyString, NonEmptyString),
providers: Type.Record(NonEmptyString, Type.Unknown()),
providerAccounts: Type.Record(
channelOrder: Type.Array(NonEmptyString),
channelLabels: Type.Record(NonEmptyString, NonEmptyString),
channels: Type.Record(NonEmptyString, Type.Unknown()),
channelAccounts: Type.Record(
NonEmptyString,
Type.Array(ProviderAccountSnapshotSchema),
Type.Array(ChannelAccountSnapshotSchema),
),
providerDefaultAccountId: Type.Record(NonEmptyString, NonEmptyString),
channelDefaultAccountId: Type.Record(NonEmptyString, NonEmptyString),
},
{ additionalProperties: false },
);
export const ProvidersLogoutParamsSchema = Type.Object(
export const ChannelsLogoutParamsSchema = Type.Object(
{
provider: NonEmptyString,
channel: NonEmptyString,
accountId: Type.Optional(Type.String()),
},
{ additionalProperties: false },
@@ -788,7 +788,7 @@ export const CronPayloadSchema = Type.Union([
thinking: Type.Optional(Type.String()),
timeoutSeconds: Type.Optional(Type.Integer({ minimum: 1 })),
deliver: Type.Optional(Type.Boolean()),
provider: Type.Optional(
channel: Type.Optional(
Type.Union([Type.Literal("last"), NonEmptyString]),
),
to: Type.Optional(Type.String()),
@@ -1078,9 +1078,9 @@ export const ProtocolSchemas: Record<string, TSchema> = {
WizardStartResult: WizardStartResultSchema,
WizardStatusResult: WizardStatusResultSchema,
TalkModeParams: TalkModeParamsSchema,
ProvidersStatusParams: ProvidersStatusParamsSchema,
ProvidersStatusResult: ProvidersStatusResultSchema,
ProvidersLogoutParams: ProvidersLogoutParamsSchema,
ChannelsStatusParams: ChannelsStatusParamsSchema,
ChannelsStatusResult: ChannelsStatusResultSchema,
ChannelsLogoutParams: ChannelsLogoutParamsSchema,
WebLoginStartParams: WebLoginStartParamsSchema,
WebLoginWaitParams: WebLoginWaitParamsSchema,
AgentSummary: AgentSummarySchema,
@@ -1157,9 +1157,9 @@ export type WizardNextResult = Static<typeof WizardNextResultSchema>;
export type WizardStartResult = Static<typeof WizardStartResultSchema>;
export type WizardStatusResult = Static<typeof WizardStatusResultSchema>;
export type TalkModeParams = Static<typeof TalkModeParamsSchema>;
export type ProvidersStatusParams = Static<typeof ProvidersStatusParamsSchema>;
export type ProvidersStatusResult = Static<typeof ProvidersStatusResultSchema>;
export type ProvidersLogoutParams = Static<typeof ProvidersLogoutParamsSchema>;
export type ChannelsStatusParams = Static<typeof ChannelsStatusParamsSchema>;
export type ChannelsStatusResult = Static<typeof ChannelsStatusResultSchema>;
export type ChannelsLogoutParams = Static<typeof ChannelsLogoutParamsSchema>;
export type WebLoginStartParams = Static<typeof WebLoginStartParamsSchema>;
export type WebLoginWaitParams = Static<typeof WebLoginWaitParamsSchema>;
export type AgentSummary = Static<typeof AgentSummarySchema>;

View File

@@ -13,6 +13,7 @@ import {
waitForEmbeddedPiRunEnd,
} from "../agents/pi-embedded.js";
import { resolveAgentTimeoutMs } from "../agents/timeout.js";
import { normalizeChannelId } from "../channels/plugins/index.js";
import type { CliDeps } from "../cli/deps.js";
import { agentCommand } from "../commands/agent.js";
import type { HealthSummary } from "../commands/health.js";
@@ -39,7 +40,6 @@ import {
} from "../infra/voicewake.js";
import { loadClawdbotPlugins } from "../plugins/loader.js";
import { clearCommandLane } from "../process/command-queue.js";
import { normalizeProviderId } from "../providers/plugins/index.js";
import { normalizeMainKey } from "../routing/session-key.js";
import { defaultRuntime } from "../runtime.js";
import {
@@ -471,11 +471,11 @@ export function createBridgeHandlers(ctx: BridgeHandlersContext) {
label: entry?.label,
displayName: entry?.displayName,
chatType: entry?.chatType,
provider: entry?.provider,
channel: entry?.channel,
subject: entry?.subject,
room: entry?.room,
space: entry?.space,
lastProvider: entry?.lastProvider,
lastChannel: entry?.lastChannel,
lastTo: entry?.lastTo,
skillsSnapshot: entry?.skillsSnapshot,
};
@@ -965,7 +965,7 @@ export function createBridgeHandlers(ctx: BridgeHandlersContext) {
thinking: p.thinking,
deliver: p.deliver,
timeout: Math.ceil(timeoutMs / 1000).toString(),
messageProvider: `node(${nodeId})`,
messageChannel: `node(${nodeId})`,
abortSignal: abortController.signal,
},
defaultRuntime,
@@ -1074,7 +1074,7 @@ export function createBridgeHandlers(ctx: BridgeHandlersContext) {
reasoningLevel: entry?.reasoningLevel,
systemSent: entry?.systemSent,
sendPolicy: entry?.sendPolicy,
lastProvider: entry?.lastProvider,
lastChannel: entry?.lastChannel,
lastTo: entry?.lastTo,
};
if (storePath) {
@@ -1095,7 +1095,7 @@ export function createBridgeHandlers(ctx: BridgeHandlersContext) {
sessionKey,
thinking: "low",
deliver: false,
messageProvider: "node",
messageChannel: "node",
},
defaultRuntime,
ctx.deps,
@@ -1130,12 +1130,12 @@ export function createBridgeHandlers(ctx: BridgeHandlersContext) {
const channelRaw =
typeof link?.channel === "string" ? link.channel.trim() : "";
const provider = normalizeProviderId(channelRaw) ?? undefined;
const channel = normalizeChannelId(channelRaw) ?? undefined;
const to =
typeof link?.to === "string" && link.to.trim()
? link.to.trim()
: undefined;
const deliver = Boolean(link?.deliver) && Boolean(provider);
const deliver = Boolean(link?.deliver) && Boolean(channel);
const sessionKeyRaw = (link?.sessionKey ?? "").trim();
const sessionKey =
@@ -1152,7 +1152,7 @@ export function createBridgeHandlers(ctx: BridgeHandlersContext) {
reasoningLevel: entry?.reasoningLevel,
systemSent: entry?.systemSent,
sendPolicy: entry?.sendPolicy,
lastProvider: entry?.lastProvider,
lastChannel: entry?.lastChannel,
lastTo: entry?.lastTo,
};
if (storePath) {
@@ -1167,12 +1167,12 @@ export function createBridgeHandlers(ctx: BridgeHandlersContext) {
thinking: link?.thinking ?? undefined,
deliver,
to,
provider,
channel,
timeout:
typeof link?.timeoutSeconds === "number"
? link.timeoutSeconds.toString()
: undefined,
messageProvider: "node",
messageChannel: "node",
},
defaultRuntime,
ctx.deps,

View File

@@ -1,32 +1,32 @@
import { resolveChannelDefaultAccountId } from "../channels/plugins/helpers.js";
import {
type ChannelId,
getChannelPlugin,
listChannelPlugins,
} from "../channels/plugins/index.js";
import type { ChannelAccountSnapshot } from "../channels/plugins/types.js";
import type { ClawdbotConfig } from "../config/config.js";
import { formatErrorMessage } from "../infra/errors.js";
import type { createSubsystemLogger } from "../logging.js";
import { resolveProviderDefaultAccountId } from "../providers/plugins/helpers.js";
import {
getProviderPlugin,
listProviderPlugins,
type ProviderId,
} from "../providers/plugins/index.js";
import type { ProviderAccountSnapshot } from "../providers/plugins/types.js";
import { DEFAULT_ACCOUNT_ID } from "../routing/session-key.js";
import type { RuntimeEnv } from "../runtime.js";
export type ProviderRuntimeSnapshot = {
providers: Partial<Record<ProviderId, ProviderAccountSnapshot>>;
providerAccounts: Partial<
Record<ProviderId, Record<string, ProviderAccountSnapshot>>
export type ChannelRuntimeSnapshot = {
channels: Partial<Record<ChannelId, ChannelAccountSnapshot>>;
channelAccounts: Partial<
Record<ChannelId, Record<string, ChannelAccountSnapshot>>
>;
};
type SubsystemLogger = ReturnType<typeof createSubsystemLogger>;
type ProviderRuntimeStore = {
type ChannelRuntimeStore = {
aborts: Map<string, AbortController>;
tasks: Map<string, Promise<unknown>>;
runtimes: Map<string, ProviderAccountSnapshot>;
runtimes: Map<string, ChannelAccountSnapshot>;
};
function createRuntimeStore(): ProviderRuntimeStore {
function createRuntimeStore(): ChannelRuntimeStore {
return {
aborts: new Map(),
tasks: new Map(),
@@ -40,83 +40,80 @@ function isAccountEnabled(account: unknown): boolean {
return enabled !== false;
}
function resolveDefaultRuntime(
providerId: ProviderId,
): ProviderAccountSnapshot {
const plugin = getProviderPlugin(providerId);
function resolveDefaultRuntime(channelId: ChannelId): ChannelAccountSnapshot {
const plugin = getChannelPlugin(channelId);
return plugin?.status?.defaultRuntime ?? { accountId: DEFAULT_ACCOUNT_ID };
}
function cloneDefaultRuntime(
providerId: ProviderId,
channelId: ChannelId,
accountId: string,
): ProviderAccountSnapshot {
return { ...resolveDefaultRuntime(providerId), accountId };
): ChannelAccountSnapshot {
return { ...resolveDefaultRuntime(channelId), accountId };
}
type ProviderManagerOptions = {
type ChannelManagerOptions = {
loadConfig: () => ClawdbotConfig;
providerLogs: Record<ProviderId, SubsystemLogger>;
providerRuntimeEnvs: Record<ProviderId, RuntimeEnv>;
channelLogs: Record<ChannelId, SubsystemLogger>;
channelRuntimeEnvs: Record<ChannelId, RuntimeEnv>;
};
export type ProviderManager = {
getRuntimeSnapshot: () => ProviderRuntimeSnapshot;
startProviders: () => Promise<void>;
startProvider: (provider: ProviderId, accountId?: string) => Promise<void>;
stopProvider: (provider: ProviderId, accountId?: string) => Promise<void>;
markProviderLoggedOut: (
providerId: ProviderId,
export type ChannelManager = {
getRuntimeSnapshot: () => ChannelRuntimeSnapshot;
startChannels: () => Promise<void>;
startChannel: (channel: ChannelId, accountId?: string) => Promise<void>;
stopChannel: (channel: ChannelId, accountId?: string) => Promise<void>;
markChannelLoggedOut: (
channelId: ChannelId,
cleared: boolean,
accountId?: string,
) => void;
};
// Provider docking: lifecycle hooks (`plugin.gateway`) flow through this manager.
export function createProviderManager(
opts: ProviderManagerOptions,
): ProviderManager {
const { loadConfig, providerLogs, providerRuntimeEnvs } = opts;
// Channel docking: lifecycle hooks (`plugin.gateway`) flow through this manager.
export function createChannelManager(
opts: ChannelManagerOptions,
): ChannelManager {
const { loadConfig, channelLogs, channelRuntimeEnvs } = opts;
const providerStores = new Map<ProviderId, ProviderRuntimeStore>();
const channelStores = new Map<ChannelId, ChannelRuntimeStore>();
const getStore = (providerId: ProviderId): ProviderRuntimeStore => {
const existing = providerStores.get(providerId);
const getStore = (channelId: ChannelId): ChannelRuntimeStore => {
const existing = channelStores.get(channelId);
if (existing) return existing;
const next = createRuntimeStore();
providerStores.set(providerId, next);
channelStores.set(channelId, next);
return next;
};
const getRuntime = (
providerId: ProviderId,
channelId: ChannelId,
accountId: string,
): ProviderAccountSnapshot => {
const store = getStore(providerId);
): ChannelAccountSnapshot => {
const store = getStore(channelId);
return (
store.runtimes.get(accountId) ??
cloneDefaultRuntime(providerId, accountId)
store.runtimes.get(accountId) ?? cloneDefaultRuntime(channelId, accountId)
);
};
const setRuntime = (
providerId: ProviderId,
channelId: ChannelId,
accountId: string,
patch: ProviderAccountSnapshot,
): ProviderAccountSnapshot => {
const store = getStore(providerId);
const current = getRuntime(providerId, accountId);
patch: ChannelAccountSnapshot,
): ChannelAccountSnapshot => {
const store = getStore(channelId);
const current = getRuntime(channelId, accountId);
const next = { ...current, ...patch, accountId };
store.runtimes.set(accountId, next);
return next;
};
const startProvider = async (providerId: ProviderId, accountId?: string) => {
const plugin = getProviderPlugin(providerId);
const startChannel = async (channelId: ChannelId, accountId?: string) => {
const plugin = getChannelPlugin(channelId);
const startAccount = plugin?.gateway?.startAccount;
if (!startAccount) return;
const cfg = loadConfig();
const store = getStore(providerId);
const store = getStore(channelId);
const accountIds = accountId
? [accountId]
: plugin.config.listAccountIds(cfg);
@@ -130,7 +127,7 @@ export function createProviderManager(
? plugin.config.isEnabled(account, cfg)
: isAccountEnabled(account);
if (!enabled) {
setRuntime(providerId, id, {
setRuntime(channelId, id, {
accountId: id,
running: false,
lastError:
@@ -144,7 +141,7 @@ export function createProviderManager(
configured = await plugin.config.isConfigured(account, cfg);
}
if (!configured) {
setRuntime(providerId, id, {
setRuntime(channelId, id, {
accountId: id,
running: false,
lastError:
@@ -156,34 +153,34 @@ export function createProviderManager(
const abort = new AbortController();
store.aborts.set(id, abort);
setRuntime(providerId, id, {
setRuntime(channelId, id, {
accountId: id,
running: true,
lastStartAt: Date.now(),
lastError: null,
});
const log = providerLogs[providerId];
const log = channelLogs[channelId];
const task = startAccount({
cfg,
accountId: id,
account,
runtime: providerRuntimeEnvs[providerId],
runtime: channelRuntimeEnvs[channelId],
abortSignal: abort.signal,
log,
getStatus: () => getRuntime(providerId, id),
setStatus: (next) => setRuntime(providerId, id, next),
getStatus: () => getRuntime(channelId, id),
setStatus: (next) => setRuntime(channelId, id, next),
});
const tracked = Promise.resolve(task)
.catch((err) => {
const message = formatErrorMessage(err);
setRuntime(providerId, id, { accountId: id, lastError: message });
log.error?.(`[${id}] provider exited: ${message}`);
setRuntime(channelId, id, { accountId: id, lastError: message });
log.error?.(`[${id}] channel exited: ${message}`);
})
.finally(() => {
store.aborts.delete(id);
store.tasks.delete(id);
setRuntime(providerId, id, {
setRuntime(channelId, id, {
accountId: id,
running: false,
lastStopAt: Date.now(),
@@ -194,10 +191,10 @@ export function createProviderManager(
);
};
const stopProvider = async (providerId: ProviderId, accountId?: string) => {
const plugin = getProviderPlugin(providerId);
const stopChannel = async (channelId: ChannelId, accountId?: string) => {
const plugin = getChannelPlugin(channelId);
const cfg = loadConfig();
const store = getStore(providerId);
const store = getStore(channelId);
const knownIds = new Set<string>([
...store.aborts.keys(),
...store.tasks.keys(),
@@ -220,11 +217,11 @@ export function createProviderManager(
cfg,
accountId: id,
account,
runtime: providerRuntimeEnvs[providerId],
runtime: channelRuntimeEnvs[channelId],
abortSignal: abort?.signal ?? new AbortController().signal,
log: providerLogs[providerId],
getStatus: () => getRuntime(providerId, id),
setStatus: (next) => setRuntime(providerId, id, next),
log: channelLogs[channelId],
getStatus: () => getRuntime(channelId, id),
setStatus: (next) => setRuntime(channelId, id, next),
});
}
try {
@@ -234,7 +231,7 @@ export function createProviderManager(
}
store.aborts.delete(id);
store.tasks.delete(id);
setRuntime(providerId, id, {
setRuntime(channelId, id, {
accountId: id,
running: false,
lastStopAt: Date.now(),
@@ -243,28 +240,28 @@ export function createProviderManager(
);
};
const startProviders = async () => {
for (const plugin of listProviderPlugins()) {
await startProvider(plugin.id);
const startChannels = async () => {
for (const plugin of listChannelPlugins()) {
await startChannel(plugin.id);
}
};
const markProviderLoggedOut = (
providerId: ProviderId,
const markChannelLoggedOut = (
channelId: ChannelId,
cleared: boolean,
accountId?: string,
) => {
const plugin = getProviderPlugin(providerId);
const plugin = getChannelPlugin(channelId);
if (!plugin) return;
const cfg = loadConfig();
const resolvedId =
accountId ??
resolveProviderDefaultAccountId({
resolveChannelDefaultAccountId({
plugin,
cfg,
});
const current = getRuntime(providerId, resolvedId);
const next: ProviderAccountSnapshot = {
const current = getRuntime(channelId, resolvedId);
const next: ChannelAccountSnapshot = {
accountId: resolvedId,
running: false,
lastError: cleared ? "logged out" : current.lastError,
@@ -272,22 +269,22 @@ export function createProviderManager(
if (typeof current.connected === "boolean") {
next.connected = false;
}
setRuntime(providerId, resolvedId, next);
setRuntime(channelId, resolvedId, next);
};
const getRuntimeSnapshot = (): ProviderRuntimeSnapshot => {
const getRuntimeSnapshot = (): ChannelRuntimeSnapshot => {
const cfg = loadConfig();
const providers: ProviderRuntimeSnapshot["providers"] = {};
const providerAccounts: ProviderRuntimeSnapshot["providerAccounts"] = {};
for (const plugin of listProviderPlugins()) {
const channels: ChannelRuntimeSnapshot["channels"] = {};
const channelAccounts: ChannelRuntimeSnapshot["channelAccounts"] = {};
for (const plugin of listChannelPlugins()) {
const store = getStore(plugin.id);
const accountIds = plugin.config.listAccountIds(cfg);
const defaultAccountId = resolveProviderDefaultAccountId({
const defaultAccountId = resolveChannelDefaultAccountId({
plugin,
cfg,
accountIds,
});
const accounts: Record<string, ProviderAccountSnapshot> = {};
const accounts: Record<string, ChannelAccountSnapshot> = {};
for (const id of accountIds) {
const account = plugin.config.resolveAccount(cfg, id);
const enabled = plugin.config.isEnabled
@@ -313,17 +310,17 @@ export function createProviderManager(
const defaultAccount =
accounts[defaultAccountId] ??
cloneDefaultRuntime(plugin.id, defaultAccountId);
providers[plugin.id] = defaultAccount;
providerAccounts[plugin.id] = accounts;
channels[plugin.id] = defaultAccount;
channelAccounts[plugin.id] = accounts;
}
return { providers, providerAccounts };
return { channels, channelAccounts };
};
return {
getRuntimeSnapshot,
startProviders,
startProvider,
stopProvider,
markProviderLoggedOut,
startChannels,
startChannel,
stopChannel,
markChannelLoggedOut,
};
}

View File

@@ -11,15 +11,15 @@ import type { createSubsystemLogger } from "../logging.js";
import { handleControlUiHttpRequest } from "./control-ui.js";
import {
extractHookToken,
HOOK_PROVIDER_ERROR,
type HookMessageProvider,
HOOK_CHANNEL_ERROR,
type HookMessageChannel,
type HooksConfigResolved,
normalizeAgentPayload,
normalizeHookHeaders,
normalizeWakePayload,
readJsonBody,
resolveHookChannel,
resolveHookDeliver,
resolveHookProvider,
} from "./hooks.js";
import { applyHookMappings } from "./hooks-mapping.js";
import { handleOpenAiHttpRequest } from "./openai-http.js";
@@ -37,7 +37,7 @@ type HookDispatchers = {
wakeMode: "now" | "next-heartbeat";
sessionKey: string;
deliver: boolean;
provider: HookMessageProvider;
channel: HookMessageChannel;
to?: string;
model?: string;
thinking?: string;
@@ -168,9 +168,9 @@ export function createHooksRequestHandler(
sendJson(res, 200, { ok: true, mode: mapped.action.mode });
return true;
}
const provider = resolveHookProvider(mapped.action.provider);
if (!provider) {
sendJson(res, 400, { ok: false, error: HOOK_PROVIDER_ERROR });
const channel = resolveHookChannel(mapped.action.channel);
if (!channel) {
sendJson(res, 400, { ok: false, error: HOOK_CHANNEL_ERROR });
return true;
}
const runId = dispatchAgentHook({
@@ -179,7 +179,7 @@ export function createHooksRequestHandler(
wakeMode: mapped.action.wakeMode,
sessionKey: mapped.action.sessionKey ?? "",
deliver: resolveHookDeliver(mapped.action.deliver),
provider,
channel,
to: mapped.action.to,
model: mapped.action.model,
thinking: mapped.action.thinking,

View File

@@ -1,6 +1,7 @@
import { ErrorCodes, errorShape } from "./protocol/index.js";
import { agentHandlers } from "./server-methods/agent.js";
import { agentsHandlers } from "./server-methods/agents.js";
import { channelsHandlers } from "./server-methods/channels.js";
import { chatHandlers } from "./server-methods/chat.js";
import { configHandlers } from "./server-methods/config.js";
import { connectHandlers } from "./server-methods/connect.js";
@@ -9,7 +10,6 @@ import { healthHandlers } from "./server-methods/health.js";
import { logsHandlers } from "./server-methods/logs.js";
import { modelsHandlers } from "./server-methods/models.js";
import { nodeHandlers } from "./server-methods/nodes.js";
import { providersHandlers } from "./server-methods/providers.js";
import { sendHandlers } from "./server-methods/send.js";
import { sessionsHandlers } from "./server-methods/sessions.js";
import { skillsHandlers } from "./server-methods/skills.js";
@@ -30,7 +30,7 @@ export const coreGatewayHandlers: GatewayRequestHandlers = {
...logsHandlers,
...voicewakeHandlers,
...healthHandlers,
...providersHandlers,
...channelsHandlers,
...chatHandlers,
...cronHandlers,
...webHandlers,

View File

@@ -1,5 +1,5 @@
import { randomUUID } from "node:crypto";
import { DEFAULT_CHAT_CHANNEL } from "../../channels/registry.js";
import { agentCommand } from "../../commands/agent.js";
import { loadConfig } from "../../config/config.js";
import {
@@ -10,15 +10,14 @@ import {
} from "../../config/sessions.js";
import { registerAgentRunContext } from "../../infra/agent-events.js";
import { resolveOutboundTarget } from "../../infra/outbound/targets.js";
import { DEFAULT_CHAT_PROVIDER } from "../../providers/registry.js";
import { defaultRuntime } from "../../runtime.js";
import { resolveSendPolicy } from "../../sessions/send-policy.js";
import {
INTERNAL_MESSAGE_PROVIDER,
isDeliverableMessageProvider,
isGatewayMessageProvider,
normalizeMessageProvider,
} from "../../utils/message-provider.js";
INTERNAL_MESSAGE_CHANNEL,
isDeliverableMessageChannel,
isGatewayMessageChannel,
normalizeMessageChannel,
} from "../../utils/message-channel.js";
import { parseMessageWithAttachments } from "../chat-attachments.js";
import {
type AgentWaitParams,
@@ -60,7 +59,7 @@ export const agentHandlers: GatewayRequestHandlers = {
fileName?: string;
content?: unknown;
}>;
provider?: string;
channel?: string;
lane?: string;
extraSystemPrompt?: string;
idempotencyKey: string;
@@ -115,21 +114,21 @@ export const agentHandlers: GatewayRequestHandlers = {
return;
}
}
const rawProvider =
typeof request.provider === "string" ? request.provider.trim() : "";
if (rawProvider) {
const normalized = normalizeMessageProvider(rawProvider);
const rawChannel =
typeof request.channel === "string" ? request.channel.trim() : "";
if (rawChannel) {
const normalized = normalizeMessageChannel(rawChannel);
if (
normalized &&
normalized !== "last" &&
!isGatewayMessageProvider(normalized)
!isGatewayMessageChannel(normalized)
) {
respond(
false,
undefined,
errorShape(
ErrorCodes.INVALID_REQUEST,
`invalid agent params: unknown provider: ${normalized}`,
`invalid agent params: unknown channel: ${normalized}`,
),
);
return;
@@ -162,7 +161,7 @@ export const agentHandlers: GatewayRequestHandlers = {
systemSent: entry?.systemSent,
sendPolicy: entry?.sendPolicy,
skillsSnapshot: entry?.skillsSnapshot,
lastProvider: entry?.lastProvider,
lastChannel: entry?.lastChannel,
lastTo: entry?.lastTo,
modelOverride: entry?.modelOverride,
providerOverride: entry?.providerOverride,
@@ -174,7 +173,7 @@ export const agentHandlers: GatewayRequestHandlers = {
cfg,
entry,
sessionKey: requestedSessionKey,
provider: entry?.provider,
channel: entry?.channel,
chatType: entry?.chatType,
});
if (sendPolicy === "deny") {
@@ -213,10 +212,9 @@ export const agentHandlers: GatewayRequestHandlers = {
const runId = idem;
const requestedProvider =
normalizeMessageProvider(request.provider) ?? "last";
const requestedChannel = normalizeMessageChannel(request.channel) ?? "last";
const lastProvider = sessionEntry?.lastProvider;
const lastChannel = sessionEntry?.lastChannel;
const lastTo =
typeof sessionEntry?.lastTo === "string"
? sessionEntry.lastTo.trim()
@@ -224,24 +222,22 @@ export const agentHandlers: GatewayRequestHandlers = {
const wantsDelivery = request.deliver === true;
const resolvedProvider = (() => {
if (requestedProvider === "last") {
const resolvedChannel = (() => {
if (requestedChannel === "last") {
// WebChat is not a deliverable surface. Treat it as "unset" for routing,
// so VoiceWake and CLI callers don't get stuck with deliver=false.
if (lastProvider && lastProvider !== INTERNAL_MESSAGE_PROVIDER) {
return lastProvider;
if (lastChannel && lastChannel !== INTERNAL_MESSAGE_CHANNEL) {
return lastChannel;
}
return wantsDelivery
? DEFAULT_CHAT_PROVIDER
: INTERNAL_MESSAGE_PROVIDER;
return wantsDelivery ? DEFAULT_CHAT_CHANNEL : INTERNAL_MESSAGE_CHANNEL;
}
if (isGatewayMessageProvider(requestedProvider)) return requestedProvider;
if (isGatewayMessageChannel(requestedChannel)) return requestedChannel;
if (lastProvider && lastProvider !== INTERNAL_MESSAGE_PROVIDER) {
return lastProvider;
if (lastChannel && lastChannel !== INTERNAL_MESSAGE_CHANNEL) {
return lastChannel;
}
return wantsDelivery ? DEFAULT_CHAT_PROVIDER : INTERNAL_MESSAGE_PROVIDER;
return wantsDelivery ? DEFAULT_CHAT_CHANNEL : INTERNAL_MESSAGE_CHANNEL;
})();
const explicitTo =
@@ -250,18 +246,18 @@ export const agentHandlers: GatewayRequestHandlers = {
: undefined;
const deliveryTargetMode = explicitTo
? "explicit"
: isDeliverableMessageProvider(resolvedProvider)
: isDeliverableMessageChannel(resolvedChannel)
? "implicit"
: undefined;
let resolvedTo =
explicitTo ||
(isDeliverableMessageProvider(resolvedProvider)
(isDeliverableMessageChannel(resolvedChannel)
? lastTo || undefined
: undefined);
if (!resolvedTo && isDeliverableMessageProvider(resolvedProvider)) {
if (!resolvedTo && isDeliverableMessageChannel(resolvedChannel)) {
const cfg = cfgForAgent ?? loadConfig();
const fallback = resolveOutboundTarget({
provider: resolvedProvider,
channel: resolvedChannel,
cfg,
accountId: sessionEntry?.lastAccountId ?? undefined,
mode: "implicit",
@@ -272,8 +268,7 @@ export const agentHandlers: GatewayRequestHandlers = {
}
const deliver =
request.deliver === true &&
resolvedProvider !== INTERNAL_MESSAGE_PROVIDER;
request.deliver === true && resolvedChannel !== INTERNAL_MESSAGE_CHANNEL;
const accepted = {
runId,
@@ -298,10 +293,10 @@ export const agentHandlers: GatewayRequestHandlers = {
thinking: request.thinking,
deliver,
deliveryTargetMode,
provider: resolvedProvider,
channel: resolvedChannel,
timeout: request.timeout?.toString(),
bestEffortDeliver,
messageProvider: resolvedProvider,
messageChannel: resolvedChannel,
runId,
lane: request.lane,
extraSystemPrompt: request.extraSystemPrompt,

View File

@@ -1,44 +1,44 @@
import { resolveChannelDefaultAccountId } from "../../channels/plugins/helpers.js";
import {
type ChannelId,
getChannelPlugin,
listChannelPlugins,
normalizeChannelId,
} from "../../channels/plugins/index.js";
import { buildChannelAccountSnapshot } from "../../channels/plugins/status.js";
import type {
ChannelAccountSnapshot,
ChannelPlugin,
} from "../../channels/plugins/types.js";
import type { ClawdbotConfig } from "../../config/config.js";
import { loadConfig, readConfigFileSnapshot } from "../../config/config.js";
import { getProviderActivity } from "../../infra/provider-activity.js";
import { resolveProviderDefaultAccountId } from "../../providers/plugins/helpers.js";
import {
getProviderPlugin,
listProviderPlugins,
normalizeProviderId,
type ProviderId,
} from "../../providers/plugins/index.js";
import { buildProviderAccountSnapshot } from "../../providers/plugins/status.js";
import type {
ProviderAccountSnapshot,
ProviderPlugin,
} from "../../providers/plugins/types.js";
import { getChannelActivity } from "../../infra/channel-activity.js";
import { DEFAULT_ACCOUNT_ID } from "../../routing/session-key.js";
import { defaultRuntime } from "../../runtime.js";
import {
ErrorCodes,
errorShape,
formatValidationErrors,
validateProvidersLogoutParams,
validateProvidersStatusParams,
validateChannelsLogoutParams,
validateChannelsStatusParams,
} from "../protocol/index.js";
import { formatForLog } from "../ws-log.js";
import type { GatewayRequestContext, GatewayRequestHandlers } from "./types.js";
type ProviderLogoutPayload = {
provider: ProviderId;
type ChannelLogoutPayload = {
channel: ChannelId;
accountId: string;
cleared: boolean;
[key: string]: unknown;
};
export async function logoutProviderAccount(params: {
providerId: ProviderId;
export async function logoutChannelAccount(params: {
channelId: ChannelId;
accountId?: string | null;
cfg: ClawdbotConfig;
context: GatewayRequestContext;
plugin: ProviderPlugin;
}): Promise<ProviderLogoutPayload> {
plugin: ChannelPlugin;
}): Promise<ChannelLogoutPayload> {
const resolvedAccountId =
params.accountId?.trim() ||
params.plugin.config.defaultAccountId?.(params.cfg) ||
@@ -48,7 +48,7 @@ export async function logoutProviderAccount(params: {
params.cfg,
resolvedAccountId,
);
await params.context.stopProvider(params.providerId, resolvedAccountId);
await params.context.stopChannel(params.channelId, resolvedAccountId);
const result = await params.plugin.gateway?.logoutAccount?.({
cfg: params.cfg,
accountId: resolvedAccountId,
@@ -56,35 +56,35 @@ export async function logoutProviderAccount(params: {
runtime: defaultRuntime,
});
if (!result) {
throw new Error(`Provider ${params.providerId} does not support logout`);
throw new Error(`Channel ${params.channelId} does not support logout`);
}
const cleared = Boolean(result.cleared);
const loggedOut =
typeof result.loggedOut === "boolean" ? result.loggedOut : cleared;
if (loggedOut) {
params.context.markProviderLoggedOut(
params.providerId,
params.context.markChannelLoggedOut(
params.channelId,
true,
resolvedAccountId,
);
}
return {
provider: params.providerId,
channel: params.channelId,
accountId: resolvedAccountId,
...result,
cleared,
};
}
export const providersHandlers: GatewayRequestHandlers = {
"providers.status": async ({ params, respond, context }) => {
if (!validateProvidersStatusParams(params)) {
export const channelsHandlers: GatewayRequestHandlers = {
"channels.status": async ({ params, respond, context }) => {
if (!validateChannelsStatusParams(params)) {
respond(
false,
undefined,
errorShape(
ErrorCodes.INVALID_REQUEST,
`invalid providers.status params: ${formatValidationErrors(validateProvidersStatusParams.errors)}`,
`invalid channels.status params: ${formatValidationErrors(validateChannelsStatusParams.errors)}`,
),
);
return;
@@ -95,18 +95,18 @@ export const providersHandlers: GatewayRequestHandlers = {
typeof timeoutMsRaw === "number" ? Math.max(1000, timeoutMsRaw) : 10_000;
const cfg = loadConfig();
const runtime = context.getRuntimeSnapshot();
const plugins = listProviderPlugins();
const pluginMap = new Map<ProviderId, ProviderPlugin>(
const plugins = listChannelPlugins();
const pluginMap = new Map<ChannelId, ChannelPlugin>(
plugins.map((plugin) => [plugin.id, plugin]),
);
const resolveRuntimeSnapshot = (
providerId: ProviderId,
channelId: ChannelId,
accountId: string,
defaultAccountId: string,
): ProviderAccountSnapshot | undefined => {
const accounts = runtime.providerAccounts[providerId];
const defaultRuntime = runtime.providers[providerId];
): ChannelAccountSnapshot | undefined => {
const accounts = runtime.channelAccounts[channelId];
const defaultRuntime = runtime.channels[channelId];
const raw =
accounts?.[accountId] ??
(accountId === defaultAccountId ? defaultRuntime : undefined);
@@ -114,30 +114,30 @@ export const providersHandlers: GatewayRequestHandlers = {
return raw;
};
const isAccountEnabled = (plugin: ProviderPlugin, account: unknown) =>
const isAccountEnabled = (plugin: ChannelPlugin, account: unknown) =>
plugin.config.isEnabled
? plugin.config.isEnabled(account, cfg)
: !account ||
typeof account !== "object" ||
(account as { enabled?: boolean }).enabled !== false;
const buildProviderAccounts = async (providerId: ProviderId) => {
const plugin = pluginMap.get(providerId);
const buildChannelAccounts = async (channelId: ChannelId) => {
const plugin = pluginMap.get(channelId);
if (!plugin) {
return {
accounts: [] as ProviderAccountSnapshot[],
accounts: [] as ChannelAccountSnapshot[],
defaultAccountId: DEFAULT_ACCOUNT_ID,
defaultAccount: undefined as ProviderAccountSnapshot | undefined,
defaultAccount: undefined as ChannelAccountSnapshot | undefined,
resolvedAccounts: {} as Record<string, unknown>,
};
}
const accountIds = plugin.config.listAccountIds(cfg);
const defaultAccountId = resolveProviderDefaultAccountId({
const defaultAccountId = resolveChannelDefaultAccountId({
plugin,
cfg,
accountIds,
});
const accounts: ProviderAccountSnapshot[] = [];
const accounts: ChannelAccountSnapshot[] = [];
const resolvedAccounts: Record<string, unknown> = {};
for (const accountId of accountIds) {
const account = plugin.config.resolveAccount(cfg, accountId);
@@ -175,11 +175,11 @@ export const providersHandlers: GatewayRequestHandlers = {
}
}
const runtimeSnapshot = resolveRuntimeSnapshot(
providerId,
channelId,
accountId,
defaultAccountId,
);
const snapshot = await buildProviderAccountSnapshot({
const snapshot = await buildChannelAccountSnapshot({
plugin,
cfg,
accountId,
@@ -188,8 +188,8 @@ export const providersHandlers: GatewayRequestHandlers = {
audit: auditResult,
});
if (lastProbeAt) snapshot.lastProbeAt = lastProbeAt;
const activity = getProviderActivity({
provider: providerId as never,
const activity = getChannelActivity({
channel: channelId as never,
accountId,
});
if (snapshot.lastInboundAt == null) {
@@ -208,28 +208,28 @@ export const providersHandlers: GatewayRequestHandlers = {
const payload: Record<string, unknown> = {
ts: Date.now(),
providerOrder: plugins.map((plugin) => plugin.id),
providerLabels: Object.fromEntries(
channelOrder: plugins.map((plugin) => plugin.id),
channelLabels: Object.fromEntries(
plugins.map((plugin) => [plugin.id, plugin.meta.label]),
),
providers: {} as Record<string, unknown>,
providerAccounts: {} as Record<string, unknown>,
providerDefaultAccountId: {} as Record<string, unknown>,
channels: {} as Record<string, unknown>,
channelAccounts: {} as Record<string, unknown>,
channelDefaultAccountId: {} as Record<string, unknown>,
};
const providersMap = payload.providers as Record<string, unknown>;
const accountsMap = payload.providerAccounts as Record<string, unknown>;
const defaultAccountIdMap = payload.providerDefaultAccountId as Record<
const channelsMap = payload.channels as Record<string, unknown>;
const accountsMap = payload.channelAccounts as Record<string, unknown>;
const defaultAccountIdMap = payload.channelDefaultAccountId as Record<
string,
unknown
>;
for (const plugin of plugins) {
const { accounts, defaultAccountId, defaultAccount, resolvedAccounts } =
await buildProviderAccounts(plugin.id);
await buildChannelAccounts(plugin.id);
const fallbackAccount =
resolvedAccounts[defaultAccountId] ??
plugin.config.resolveAccount(cfg, defaultAccountId);
const summary = plugin.status?.buildProviderSummary
? await plugin.status.buildProviderSummary({
const summary = plugin.status?.buildChannelSummary
? await plugin.status.buildChannelSummary({
account: fallbackAccount,
cfg,
defaultAccountId,
@@ -237,52 +237,52 @@ export const providersHandlers: GatewayRequestHandlers = {
defaultAccount ??
({
accountId: defaultAccountId,
} as ProviderAccountSnapshot),
} as ChannelAccountSnapshot),
})
: {
configured: defaultAccount?.configured ?? false,
};
providersMap[plugin.id] = summary;
channelsMap[plugin.id] = summary;
accountsMap[plugin.id] = accounts;
defaultAccountIdMap[plugin.id] = defaultAccountId;
}
respond(true, payload, undefined);
},
"providers.logout": async ({ params, respond, context }) => {
if (!validateProvidersLogoutParams(params)) {
"channels.logout": async ({ params, respond, context }) => {
if (!validateChannelsLogoutParams(params)) {
respond(
false,
undefined,
errorShape(
ErrorCodes.INVALID_REQUEST,
`invalid providers.logout params: ${formatValidationErrors(validateProvidersLogoutParams.errors)}`,
`invalid channels.logout params: ${formatValidationErrors(validateChannelsLogoutParams.errors)}`,
),
);
return;
}
const rawProvider = (params as { provider?: unknown }).provider;
const providerId =
typeof rawProvider === "string" ? normalizeProviderId(rawProvider) : null;
if (!providerId) {
const rawChannel = (params as { channel?: unknown }).channel;
const channelId =
typeof rawChannel === "string" ? normalizeChannelId(rawChannel) : null;
if (!channelId) {
respond(
false,
undefined,
errorShape(
ErrorCodes.INVALID_REQUEST,
"invalid providers.logout provider",
"invalid channels.logout channel",
),
);
return;
}
const plugin = getProviderPlugin(providerId);
const plugin = getChannelPlugin(channelId);
if (!plugin?.gateway?.logoutAccount) {
respond(
false,
undefined,
errorShape(
ErrorCodes.INVALID_REQUEST,
`provider ${providerId} does not support logout`,
`channel ${channelId} does not support logout`,
),
);
return;
@@ -303,8 +303,8 @@ export const providersHandlers: GatewayRequestHandlers = {
return;
}
try {
const payload = await logoutProviderAccount({
providerId,
const payload = await logoutChannelAccount({
channelId,
accountId,
cfg: snapshot.config ?? {},
context,

View File

@@ -7,7 +7,7 @@ import { mergeSessionEntry, saveSessionStore } from "../../config/sessions.js";
import { registerAgentRunContext } from "../../infra/agent-events.js";
import { defaultRuntime } from "../../runtime.js";
import { resolveSendPolicy } from "../../sessions/send-policy.js";
import { INTERNAL_MESSAGE_PROVIDER } from "../../utils/message-provider.js";
import { INTERNAL_MESSAGE_CHANNEL } from "../../utils/message-channel.js";
import {
abortChatRunById,
abortChatRunsForSessionKey,
@@ -242,7 +242,7 @@ export const chatHandlers: GatewayRequestHandlers = {
cfg,
entry,
sessionKey: p.sessionKey,
provider: entry?.provider,
channel: entry?.channel,
chatType: entry?.chatType,
});
if (sendPolicy === "deny") {
@@ -331,7 +331,7 @@ export const chatHandlers: GatewayRequestHandlers = {
thinking: p.thinking,
deliver: p.deliver,
timeout: Math.ceil(timeoutMs / 1000).toString(),
messageProvider: INTERNAL_MESSAGE_PROVIDER,
messageChannel: INTERNAL_MESSAGE_CHANNEL,
abortSignal: abortController.signal,
},
defaultRuntime,

View File

@@ -1,14 +1,14 @@
import {
getChannelPlugin,
normalizeChannelId,
} from "../../channels/plugins/index.js";
import type { ChannelId } from "../../channels/plugins/types.js";
import { DEFAULT_CHAT_CHANNEL } from "../../channels/registry.js";
import { loadConfig } from "../../config/config.js";
import { deliverOutboundPayloads } from "../../infra/outbound/deliver.js";
import type { OutboundProvider } from "../../infra/outbound/targets.js";
import type { OutboundChannel } from "../../infra/outbound/targets.js";
import { resolveOutboundTarget } from "../../infra/outbound/targets.js";
import { normalizePollInput } from "../../polls.js";
import {
getProviderPlugin,
normalizeProviderId,
} from "../../providers/plugins/index.js";
import type { ProviderId } from "../../providers/plugins/types.js";
import { DEFAULT_CHAT_PROVIDER } from "../../providers/registry.js";
import {
ErrorCodes,
errorShape,
@@ -38,7 +38,7 @@ export const sendHandlers: GatewayRequestHandlers = {
message: string;
mediaUrl?: string;
gifPlayback?: boolean;
provider?: string;
channel?: string;
accountId?: string;
idempotencyKey: string;
};
@@ -52,44 +52,44 @@ export const sendHandlers: GatewayRequestHandlers = {
}
const to = request.to.trim();
const message = request.message.trim();
const providerInput =
typeof request.provider === "string" ? request.provider : undefined;
const normalizedProvider = providerInput
? normalizeProviderId(providerInput)
const channelInput =
typeof request.channel === "string" ? request.channel : undefined;
const normalizedChannel = channelInput
? normalizeChannelId(channelInput)
: null;
if (providerInput && !normalizedProvider) {
if (channelInput && !normalizedChannel) {
respond(
false,
undefined,
errorShape(
ErrorCodes.INVALID_REQUEST,
`unsupported provider: ${providerInput}`,
`unsupported channel: ${channelInput}`,
),
);
return;
}
const provider = normalizedProvider ?? DEFAULT_CHAT_PROVIDER;
const channel = normalizedChannel ?? DEFAULT_CHAT_CHANNEL;
const accountId =
typeof request.accountId === "string" && request.accountId.trim().length
? request.accountId.trim()
: undefined;
try {
const outboundProvider = provider as Exclude<OutboundProvider, "none">;
const plugin = getProviderPlugin(provider as ProviderId);
const outboundChannel = channel as Exclude<OutboundChannel, "none">;
const plugin = getChannelPlugin(channel as ChannelId);
if (!plugin) {
respond(
false,
undefined,
errorShape(
ErrorCodes.INVALID_REQUEST,
`unsupported provider: ${provider}`,
`unsupported channel: ${channel}`,
),
);
return;
}
const cfg = loadConfig();
const resolved = resolveOutboundTarget({
provider: outboundProvider,
channel: outboundChannel,
to,
cfg,
accountId,
@@ -105,7 +105,7 @@ export const sendHandlers: GatewayRequestHandlers = {
}
const results = await deliverOutboundPayloads({
cfg,
provider: outboundProvider,
channel: outboundChannel,
to: resolved.to,
accountId,
payloads: [{ text: message, mediaUrl: request.mediaUrl }],
@@ -118,7 +118,7 @@ export const sendHandlers: GatewayRequestHandlers = {
const payload: Record<string, unknown> = {
runId: idem,
messageId: result.messageId,
provider,
channel,
};
if ("chatId" in result) payload.chatId = result.chatId;
if ("channelId" in result) payload.channelId = result.channelId;
@@ -131,7 +131,7 @@ export const sendHandlers: GatewayRequestHandlers = {
ok: true,
payload,
});
respond(true, payload, undefined, { provider });
respond(true, payload, undefined, { channel });
} catch (err) {
const error = errorShape(ErrorCodes.UNAVAILABLE, String(err));
context.dedupe.set(`send:${idem}`, {
@@ -139,10 +139,7 @@ export const sendHandlers: GatewayRequestHandlers = {
ok: false,
error,
});
respond(false, undefined, error, {
provider,
error: formatForLog(err),
});
respond(false, undefined, error, { channel, error: formatForLog(err) });
}
},
poll: async ({ params, respond, context }) => {
@@ -164,7 +161,7 @@ export const sendHandlers: GatewayRequestHandlers = {
options: string[];
maxSelections?: number;
durationHours?: number;
provider?: string;
channel?: string;
accountId?: string;
idempotencyKey: string;
};
@@ -177,23 +174,23 @@ export const sendHandlers: GatewayRequestHandlers = {
return;
}
const to = request.to.trim();
const providerInput =
typeof request.provider === "string" ? request.provider : undefined;
const normalizedProvider = providerInput
? normalizeProviderId(providerInput)
const channelInput =
typeof request.channel === "string" ? request.channel : undefined;
const normalizedChannel = channelInput
? normalizeChannelId(channelInput)
: null;
if (providerInput && !normalizedProvider) {
if (channelInput && !normalizedChannel) {
respond(
false,
undefined,
errorShape(
ErrorCodes.INVALID_REQUEST,
`unsupported poll provider: ${providerInput}`,
`unsupported poll channel: ${channelInput}`,
),
);
return;
}
const provider = normalizedProvider ?? DEFAULT_CHAT_PROVIDER;
const channel = normalizedChannel ?? DEFAULT_CHAT_CHANNEL;
const poll = {
question: request.question,
options: request.options,
@@ -205,7 +202,7 @@ export const sendHandlers: GatewayRequestHandlers = {
? request.accountId.trim()
: undefined;
try {
const plugin = getProviderPlugin(provider as ProviderId);
const plugin = getChannelPlugin(channel as ChannelId);
const outbound = plugin?.outbound;
if (!outbound?.sendPoll) {
respond(
@@ -213,14 +210,14 @@ export const sendHandlers: GatewayRequestHandlers = {
undefined,
errorShape(
ErrorCodes.INVALID_REQUEST,
`unsupported poll provider: ${provider}`,
`unsupported poll channel: ${channel}`,
),
);
return;
}
const cfg = loadConfig();
const resolved = resolveOutboundTarget({
provider: provider as Exclude<OutboundProvider, "none">,
channel: channel as Exclude<OutboundChannel, "none">,
to,
cfg,
accountId,
@@ -246,7 +243,7 @@ export const sendHandlers: GatewayRequestHandlers = {
const payload: Record<string, unknown> = {
runId: idem,
messageId: result.messageId,
provider,
channel,
};
if (result.toJid) payload.toJid = result.toJid;
if (result.channelId) payload.channelId = result.channelId;
@@ -257,7 +254,7 @@ export const sendHandlers: GatewayRequestHandlers = {
ok: true,
payload,
});
respond(true, payload, undefined, { provider });
respond(true, payload, undefined, { channel });
} catch (err) {
const error = errorShape(ErrorCodes.UNAVAILABLE, String(err));
context.dedupe.set(`poll:${idem}`, {
@@ -266,7 +263,7 @@ export const sendHandlers: GatewayRequestHandlers = {
error,
});
respond(false, undefined, error, {
provider,
channel,
error: formatForLog(err),
});
}

View File

@@ -186,7 +186,7 @@ export const sessionsHandlers: GatewayRequestHandlers = {
contextTokens: entry?.contextTokens,
sendPolicy: entry?.sendPolicy,
label: entry?.label,
lastProvider: entry?.lastProvider,
lastChannel: entry?.lastChannel,
lastTo: entry?.lastTo,
skillsSnapshot: entry?.skillsSnapshot,
};

View File

@@ -10,7 +10,7 @@ import type {
ErrorShape,
RequestFrame,
} from "../protocol/index.js";
import type { ProviderRuntimeSnapshot } from "../server-providers.js";
import type { ChannelRuntimeSnapshot } from "../server-channels.js";
import type { DedupeEntry } from "../server-shared.js";
export type GatewayClient = {
@@ -68,17 +68,17 @@ export type GatewayRequestContext = {
wizardSessions: Map<string, WizardSession>;
findRunningWizard: () => string | null;
purgeWizardSession: (id: string) => void;
getRuntimeSnapshot: () => ProviderRuntimeSnapshot;
startProvider: (
provider: import("../../providers/plugins/types.js").ProviderId,
getRuntimeSnapshot: () => ChannelRuntimeSnapshot;
startChannel: (
channel: import("../../channels/plugins/types.js").ChannelId,
accountId?: string,
) => Promise<void>;
stopProvider: (
provider: import("../../providers/plugins/types.js").ProviderId,
stopChannel: (
channel: import("../../channels/plugins/types.js").ChannelId,
accountId?: string,
) => Promise<void>;
markProviderLoggedOut: (
providerId: import("../../providers/plugins/types.js").ProviderId,
markChannelLoggedOut: (
channelId: import("../../channels/plugins/types.js").ChannelId,
cleared: boolean,
accountId?: string,
) => void;

View File

@@ -1,4 +1,4 @@
import { listProviderPlugins } from "../../providers/plugins/index.js";
import { listChannelPlugins } from "../../channels/plugins/index.js";
import {
ErrorCodes,
errorShape,
@@ -12,7 +12,7 @@ import type { GatewayRequestHandlers } from "./types.js";
const WEB_LOGIN_METHODS = new Set(["web.login.start", "web.login.wait"]);
const resolveWebLoginProvider = () =>
listProviderPlugins().find((plugin) =>
listChannelPlugins().find((plugin) =>
(plugin.gatewayMethods ?? []).some((method) =>
WEB_LOGIN_METHODS.has(method),
),
@@ -48,7 +48,7 @@ export const webHandlers: GatewayRequestHandlers = {
);
return;
}
await context.stopProvider(provider.id, accountId);
await context.stopChannel(provider.id, accountId);
if (!provider.gateway?.loginWithQrStart) {
respond(
false,
@@ -126,7 +126,7 @@ export const webHandlers: GatewayRequestHandlers = {
accountId,
});
if (result.connected) {
await context.startProvider(provider.id, accountId);
await context.startChannel(provider.id, accountId);
}
respond(true, result, undefined);
} catch (err) {

View File

@@ -10,7 +10,7 @@ import {
import {
GATEWAY_CLIENT_MODES,
GATEWAY_CLIENT_NAMES,
} from "../utils/message-provider.js";
} from "../utils/message-channel.js";
import {
agentCommand,
connectOk,
@@ -28,9 +28,9 @@ installGatewayTestHooks();
const BASE_IMAGE_PNG =
"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+X3mIAAAAASUVORK5CYII=";
function expectProviders(call: Record<string, unknown>, provider: string) {
expect(call.provider).toBe(provider);
expect(call.messageProvider).toBe(provider);
function expectChannels(call: Record<string, unknown>, channel: string) {
expect(call.channel).toBe(channel);
expect(call.messageChannel).toBe(channel);
}
describe("gateway server agent", () => {
@@ -45,7 +45,7 @@ describe("gateway server agent", () => {
main: {
sessionId: "sess-main-stale",
updatedAt: Date.now(),
lastProvider: "whatsapp",
lastChannel: "whatsapp",
lastTo: "+1555",
},
},
@@ -61,7 +61,7 @@ describe("gateway server agent", () => {
const res = await rpcReq(ws, "agent", {
message: "hi",
sessionKey: "main",
provider: "last",
channel: "last",
deliver: true,
idempotencyKey: "idem-agent-last-stale",
});
@@ -69,7 +69,7 @@ describe("gateway server agent", () => {
const spy = vi.mocked(agentCommand);
const call = spy.mock.calls.at(-1)?.[0] as Record<string, unknown>;
expectProviders(call, "whatsapp");
expectChannels(call, "whatsapp");
expect(call.to).toBe("+1555");
expect(call.deliveryTargetMode).toBe("implicit");
expect(call.sessionId).toBe("sess-main-stale");
@@ -111,7 +111,7 @@ describe("gateway server agent", () => {
const call = spy.mock.calls.at(-1)?.[0] as Record<string, unknown>;
expect(call.sessionKey).toBe("agent:main:subagent:abc");
expect(call.sessionId).toBe("sess-sub");
expectProviders(call, "webchat");
expectChannels(call, "webchat");
expect(call.deliver).toBe(false);
expect(call.to).toBeUndefined();
@@ -157,7 +157,7 @@ describe("gateway server agent", () => {
const spy = vi.mocked(agentCommand);
const call = spy.mock.calls.at(-1)?.[0] as Record<string, unknown>;
expect(call.sessionKey).toBe("main");
expectProviders(call, "webchat");
expectChannels(call, "webchat");
expect(call.message).toBe("what is in the image?");
const images = call.images as Array<Record<string, unknown>>;
@@ -171,7 +171,7 @@ describe("gateway server agent", () => {
await server.close();
});
test("agent falls back to whatsapp when delivery requested and no last provider exists", async () => {
test("agent falls back to whatsapp when delivery requested and no last channel exists", async () => {
testState.allowFrom = ["+1555"];
const dir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-gw-"));
testState.sessionStorePath = path.join(dir, "sessions.json");
@@ -203,7 +203,7 @@ describe("gateway server agent", () => {
const spy = vi.mocked(agentCommand);
const call = spy.mock.calls.at(-1)?.[0] as Record<string, unknown>;
expectProviders(call, "whatsapp");
expectChannels(call, "whatsapp");
expect(call.to).toBe("+1555");
expect(call.deliver).toBe(true);
expect(call.sessionId).toBe("sess-main-missing-provider");
@@ -223,7 +223,7 @@ describe("gateway server agent", () => {
main: {
sessionId: "sess-main-whatsapp",
updatedAt: Date.now(),
lastProvider: "whatsapp",
lastChannel: "whatsapp",
lastTo: "+1555",
},
},
@@ -239,7 +239,7 @@ describe("gateway server agent", () => {
const res = await rpcReq(ws, "agent", {
message: "hi",
sessionKey: "main",
provider: "last",
channel: "last",
deliver: true,
idempotencyKey: "idem-agent-last-whatsapp",
});
@@ -247,8 +247,8 @@ describe("gateway server agent", () => {
const spy = vi.mocked(agentCommand);
const call = spy.mock.calls.at(-1)?.[0] as Record<string, unknown>;
expectProviders(call, "whatsapp");
expect(call.messageProvider).toBe("whatsapp");
expectChannels(call, "whatsapp");
expect(call.messageChannel).toBe("whatsapp");
expect(call.to).toBe("+1555");
expect(call.deliver).toBe(true);
expect(call.bestEffortDeliver).toBe(true);
@@ -268,7 +268,7 @@ describe("gateway server agent", () => {
main: {
sessionId: "sess-main",
updatedAt: Date.now(),
lastProvider: "telegram",
lastChannel: "telegram",
lastTo: "123",
},
},
@@ -284,7 +284,7 @@ describe("gateway server agent", () => {
const res = await rpcReq(ws, "agent", {
message: "hi",
sessionKey: "main",
provider: "last",
channel: "last",
deliver: true,
idempotencyKey: "idem-agent-last",
});
@@ -292,7 +292,7 @@ describe("gateway server agent", () => {
const spy = vi.mocked(agentCommand);
const call = spy.mock.calls.at(-1)?.[0] as Record<string, unknown>;
expectProviders(call, "telegram");
expectChannels(call, "telegram");
expect(call.to).toBe("123");
expect(call.deliver).toBe(true);
expect(call.bestEffortDeliver).toBe(true);
@@ -312,7 +312,7 @@ describe("gateway server agent", () => {
main: {
sessionId: "sess-discord",
updatedAt: Date.now(),
lastProvider: "discord",
lastChannel: "discord",
lastTo: "channel:discord-123",
},
},
@@ -328,7 +328,7 @@ describe("gateway server agent", () => {
const res = await rpcReq(ws, "agent", {
message: "hi",
sessionKey: "main",
provider: "last",
channel: "last",
deliver: true,
idempotencyKey: "idem-agent-last-discord",
});
@@ -336,7 +336,7 @@ describe("gateway server agent", () => {
const spy = vi.mocked(agentCommand);
const call = spy.mock.calls.at(-1)?.[0] as Record<string, unknown>;
expectProviders(call, "discord");
expectChannels(call, "discord");
expect(call.to).toBe("channel:discord-123");
expect(call.deliver).toBe(true);
expect(call.bestEffortDeliver).toBe(true);
@@ -356,7 +356,7 @@ describe("gateway server agent", () => {
main: {
sessionId: "sess-slack",
updatedAt: Date.now(),
lastProvider: "slack",
lastChannel: "slack",
lastTo: "channel:slack-123",
},
},
@@ -372,7 +372,7 @@ describe("gateway server agent", () => {
const res = await rpcReq(ws, "agent", {
message: "hi",
sessionKey: "main",
provider: "last",
channel: "last",
deliver: true,
idempotencyKey: "idem-agent-last-slack",
});
@@ -380,7 +380,7 @@ describe("gateway server agent", () => {
const spy = vi.mocked(agentCommand);
const call = spy.mock.calls.at(-1)?.[0] as Record<string, unknown>;
expectProviders(call, "slack");
expectChannels(call, "slack");
expect(call.to).toBe("channel:slack-123");
expect(call.deliver).toBe(true);
expect(call.bestEffortDeliver).toBe(true);
@@ -400,7 +400,7 @@ describe("gateway server agent", () => {
main: {
sessionId: "sess-signal",
updatedAt: Date.now(),
lastProvider: "signal",
lastChannel: "signal",
lastTo: "+15551234567",
},
},
@@ -416,7 +416,7 @@ describe("gateway server agent", () => {
const res = await rpcReq(ws, "agent", {
message: "hi",
sessionKey: "main",
provider: "last",
channel: "last",
deliver: true,
idempotencyKey: "idem-agent-last-signal",
});
@@ -424,7 +424,7 @@ describe("gateway server agent", () => {
const spy = vi.mocked(agentCommand);
const call = spy.mock.calls.at(-1)?.[0] as Record<string, unknown>;
expectProviders(call, "signal");
expectChannels(call, "signal");
expect(call.to).toBe("+15551234567");
expect(call.deliver).toBe(true);
expect(call.bestEffortDeliver).toBe(true);
@@ -444,7 +444,7 @@ describe("gateway server agent", () => {
main: {
sessionId: "sess-teams",
updatedAt: Date.now(),
lastProvider: "msteams",
lastChannel: "msteams",
lastTo: "conversation:teams-123",
},
},
@@ -460,7 +460,7 @@ describe("gateway server agent", () => {
const res = await rpcReq(ws, "agent", {
message: "hi",
sessionKey: "main",
provider: "last",
channel: "last",
deliver: true,
idempotencyKey: "idem-agent-last-msteams",
});
@@ -468,7 +468,7 @@ describe("gateway server agent", () => {
const spy = vi.mocked(agentCommand);
const call = spy.mock.calls.at(-1)?.[0] as Record<string, unknown>;
expectProviders(call, "msteams");
expectChannels(call, "msteams");
expect(call.to).toBe("conversation:teams-123");
expect(call.deliver).toBe(true);
expect(call.bestEffortDeliver).toBe(true);
@@ -478,7 +478,7 @@ describe("gateway server agent", () => {
await server.close();
});
test("agent accepts provider aliases (imsg/teams)", async () => {
test("agent accepts channel aliases (imsg/teams)", async () => {
const dir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-gw-"));
testState.sessionStorePath = path.join(dir, "sessions.json");
await fs.writeFile(
@@ -488,7 +488,7 @@ describe("gateway server agent", () => {
main: {
sessionId: "sess-alias",
updatedAt: Date.now(),
lastProvider: "imessage",
lastChannel: "imessage",
lastTo: "chat_id:123",
},
},
@@ -504,7 +504,7 @@ describe("gateway server agent", () => {
const resIMessage = await rpcReq(ws, "agent", {
message: "hi",
sessionKey: "main",
provider: "imsg",
channel: "imsg",
deliver: true,
idempotencyKey: "idem-agent-imsg",
});
@@ -513,7 +513,7 @@ describe("gateway server agent", () => {
const resTeams = await rpcReq(ws, "agent", {
message: "hi",
sessionKey: "main",
provider: "teams",
channel: "teams",
to: "conversation:teams-abc",
deliver: false,
idempotencyKey: "idem-agent-teams",
@@ -525,26 +525,26 @@ describe("gateway server agent", () => {
string,
unknown
>;
expectProviders(lastIMessageCall, "imessage");
expectChannels(lastIMessageCall, "imessage");
expect(lastIMessageCall.to).toBe("chat_id:123");
const lastTeamsCall = spy.mock.calls.at(-1)?.[0] as Record<string, unknown>;
expectProviders(lastTeamsCall, "msteams");
expectChannels(lastTeamsCall, "msteams");
expect(lastTeamsCall.to).toBe("conversation:teams-abc");
ws.close();
await server.close();
});
test("agent rejects unknown provider", async () => {
test("agent rejects unknown channel", async () => {
const { server, ws } = await startServerWithClient();
await connectOk(ws);
const res = await rpcReq(ws, "agent", {
message: "hi",
sessionKey: "main",
provider: "sms",
idempotencyKey: "idem-agent-bad-provider",
channel: "sms",
idempotencyKey: "idem-agent-bad-channel",
});
expect(res.ok).toBe(false);
expect(res.error?.code).toBe("INVALID_REQUEST");
@@ -564,7 +564,7 @@ describe("gateway server agent", () => {
main: {
sessionId: "sess-main-webchat",
updatedAt: Date.now(),
lastProvider: "webchat",
lastChannel: "webchat",
lastTo: "+1555",
},
},
@@ -580,7 +580,7 @@ describe("gateway server agent", () => {
const res = await rpcReq(ws, "agent", {
message: "hi",
sessionKey: "main",
provider: "last",
channel: "last",
deliver: true,
idempotencyKey: "idem-agent-webchat",
});
@@ -588,7 +588,7 @@ describe("gateway server agent", () => {
const spy = vi.mocked(agentCommand);
const call = spy.mock.calls.at(-1)?.[0] as Record<string, unknown>;
expectProviders(call, "whatsapp");
expectChannels(call, "whatsapp");
expect(call.to).toBe("+1555");
expect(call.deliver).toBe(true);
expect(call.bestEffortDeliver).toBe(true);
@@ -608,7 +608,7 @@ describe("gateway server agent", () => {
main: {
sessionId: "sess-main-webchat-internal",
updatedAt: Date.now(),
lastProvider: "webchat",
lastChannel: "webchat",
lastTo: "+1555",
},
},
@@ -624,7 +624,7 @@ describe("gateway server agent", () => {
const res = await rpcReq(ws, "agent", {
message: "hi",
sessionKey: "main",
provider: "last",
channel: "last",
deliver: false,
idempotencyKey: "idem-agent-webchat-internal",
});
@@ -632,7 +632,7 @@ describe("gateway server agent", () => {
const spy = vi.mocked(agentCommand);
const call = spy.mock.calls.at(-1)?.[0] as Record<string, unknown>;
expectProviders(call, "webchat");
expectChannels(call, "webchat");
expect(call.to).toBeUndefined();
expect(call.deliver).toBe(false);
expect(call.bestEffortDeliver).toBe(true);

View File

@@ -10,15 +10,15 @@ const loadConfigHelpers = async () => await import("../config/config.js");
installGatewayTestHooks();
describe("gateway server providers", () => {
test("providers.status returns snapshot without probe", async () => {
describe("gateway server channels", () => {
test("channels.status returns snapshot without probe", async () => {
const prevToken = process.env.TELEGRAM_BOT_TOKEN;
delete process.env.TELEGRAM_BOT_TOKEN;
const { server, ws } = await startServerWithClient();
await connectOk(ws);
const res = await rpcReq<{
providers?: Record<
channels?: Record<
string,
| {
configured?: boolean;
@@ -28,11 +28,11 @@ describe("gateway server providers", () => {
}
| { linked?: boolean }
>;
}>(ws, "providers.status", { probe: false, timeoutMs: 2000 });
}>(ws, "channels.status", { probe: false, timeoutMs: 2000 });
expect(res.ok).toBe(true);
const telegram = res.payload?.providers?.telegram;
const signal = res.payload?.providers?.signal;
expect(res.payload?.providers?.whatsapp).toBeTruthy();
const telegram = res.payload?.channels?.telegram;
const signal = res.payload?.channels?.signal;
expect(res.payload?.channels?.whatsapp).toBeTruthy();
expect(telegram?.configured).toBe(false);
expect(telegram?.tokenSource).toBe("none");
expect(telegram?.probe).toBeUndefined();
@@ -50,32 +50,34 @@ describe("gateway server providers", () => {
}
});
test("providers.logout reports no session when missing", async () => {
test("channels.logout reports no session when missing", async () => {
const { server, ws } = await startServerWithClient();
await connectOk(ws);
const res = await rpcReq<{ cleared?: boolean; provider?: string }>(
const res = await rpcReq<{ cleared?: boolean; channel?: string }>(
ws,
"providers.logout",
{ provider: "whatsapp" },
"channels.logout",
{ channel: "whatsapp" },
);
expect(res.ok).toBe(true);
expect(res.payload?.provider).toBe("whatsapp");
expect(res.payload?.channel).toBe("whatsapp");
expect(res.payload?.cleared).toBe(false);
ws.close();
await server.close();
});
test("providers.logout clears telegram bot token from config", async () => {
test("channels.logout clears telegram bot token from config", async () => {
const prevToken = process.env.TELEGRAM_BOT_TOKEN;
delete process.env.TELEGRAM_BOT_TOKEN;
const { readConfigFileSnapshot, writeConfigFile } =
await loadConfigHelpers();
await writeConfigFile({
telegram: {
botToken: "123:abc",
groups: { "*": { requireMention: false } },
channels: {
telegram: {
botToken: "123:abc",
groups: { "*": { requireMention: false } },
},
},
});
@@ -85,17 +87,19 @@ describe("gateway server providers", () => {
const res = await rpcReq<{
cleared?: boolean;
envToken?: boolean;
provider?: string;
}>(ws, "providers.logout", { provider: "telegram" });
channel?: string;
}>(ws, "channels.logout", { channel: "telegram" });
expect(res.ok).toBe(true);
expect(res.payload?.provider).toBe("telegram");
expect(res.payload?.channel).toBe("telegram");
expect(res.payload?.cleared).toBe(true);
expect(res.payload?.envToken).toBe(false);
const snap = await readConfigFileSnapshot();
expect(snap.valid).toBe(true);
expect(snap.config?.telegram?.botToken).toBeUndefined();
expect(snap.config?.telegram?.groups?.["*"]?.requireMention).toBe(false);
expect(snap.config?.channels?.telegram?.botToken).toBeUndefined();
expect(snap.config?.channels?.telegram?.groups?.["*"]?.requireMention).toBe(
false,
);
ws.close();
await server.close();

View File

@@ -6,7 +6,7 @@ import { emitAgentEvent } from "../infra/agent-events.js";
import {
GATEWAY_CLIENT_MODES,
GATEWAY_CLIENT_NAMES,
} from "../utils/message-provider.js";
} from "../utils/message-channel.js";
import {
agentCommand,
connectOk,
@@ -107,7 +107,7 @@ describe("gateway server chat", () => {
rules: [
{
action: "deny",
match: { provider: "discord", chatType: "group" },
match: { channel: "discord", chatType: "group" },
},
],
},
@@ -121,7 +121,7 @@ describe("gateway server chat", () => {
sessionId: "sess-discord",
updatedAt: Date.now(),
chatType: "group",
provider: "discord",
channel: "discord",
},
},
null,
@@ -534,7 +534,7 @@ describe("gateway server chat", () => {
main: {
sessionId: "sess-main",
updatedAt: Date.now(),
lastProvider: "whatsapp",
lastChannel: "whatsapp",
lastTo: "+1555",
},
},
@@ -557,9 +557,9 @@ describe("gateway server chat", () => {
const stored = JSON.parse(
await fs.readFile(testState.sessionStorePath, "utf-8"),
) as {
main?: { lastProvider?: string; lastTo?: string };
main?: { lastChannel?: string; lastTo?: string };
};
expect(stored.main?.lastProvider).toBe("whatsapp");
expect(stored.main?.lastChannel).toBe("whatsapp");
expect(stored.main?.lastTo).toBe("+1555");
ws.close();

View File

@@ -6,7 +6,7 @@ import { emitHeartbeatEvent } from "../infra/heartbeat-events.js";
import {
GATEWAY_CLIENT_MODES,
GATEWAY_CLIENT_NAMES,
} from "../utils/message-provider.js";
} from "../utils/message-channel.js";
import {
connectOk,
getFreePort,
@@ -38,9 +38,9 @@ describe("gateway server health/presence", () => {
ws,
(o) => o.type === "res" && o.id === "presence1",
);
const providersP = onceMessage(
const channelsP = onceMessage(
ws,
(o) => o.type === "res" && o.id === "providers1",
(o) => o.type === "res" && o.id === "channels1",
);
const sendReq = (id: string, method: string) =>
@@ -48,16 +48,16 @@ describe("gateway server health/presence", () => {
sendReq("health1", "health");
sendReq("status1", "status");
sendReq("presence1", "system-presence");
sendReq("providers1", "providers.status");
sendReq("channels1", "channels.status");
const health = await healthP;
const status = await statusP;
const presence = await presenceP;
const providers = await providersP;
const channels = await channelsP;
expect(health.ok).toBe(true);
expect(status.ok).toBe(true);
expect(presence.ok).toBe(true);
expect(providers.ok).toBe(true);
expect(channels.ok).toBe(true);
expect(Array.isArray(presence.payload)).toBe(true);
ws.close();

View File

@@ -120,7 +120,7 @@ describe("gateway server hooks", () => {
await server.close();
});
test("hooks agent rejects invalid provider", async () => {
test("hooks agent rejects invalid channel", async () => {
testState.hooksConfig = { enabled: true, token: "hook-secret" };
const port = await getFreePort();
const server = await startGatewayServer(port);
@@ -130,7 +130,7 @@ describe("gateway server hooks", () => {
"Content-Type": "application/json",
Authorization: "Bearer hook-secret",
},
body: JSON.stringify({ message: "Nope", provider: "sms" }),
body: JSON.stringify({ message: "Nope", channel: "sms" }),
});
expect(res.status).toBe(400);
expect(peekSystemEvents(resolveMainKey()).length).toBe(0);

View File

@@ -6,7 +6,7 @@ import { emitAgentEvent } from "../infra/agent-events.js";
import {
GATEWAY_CLIENT_MODES,
GATEWAY_CLIENT_NAMES,
} from "../utils/message-provider.js";
} from "../utils/message-channel.js";
import {
agentCommand,
bridgeInvoke,
@@ -814,7 +814,7 @@ describe("gateway server node/bridge", () => {
main: {
sessionId: "sess-main",
updatedAt: Date.now(),
lastProvider: "whatsapp",
lastChannel: "whatsapp",
lastTo: "+1555",
},
},
@@ -842,7 +842,7 @@ describe("gateway server node/bridge", () => {
expect(call.sessionId).toBe("sess-main");
expect(call.sessionKey).toBe("main");
expect(call.deliver).toBe(false);
expect(call.messageProvider).toBe("node");
expect(call.messageChannel).toBe("node");
const stored = JSON.parse(
await fs.readFile(testState.sessionStorePath, "utf-8"),

View File

@@ -94,13 +94,13 @@ const hoisted = vi.hoisted(() => {
msteams: {},
},
})),
startProviders: vi.fn(async () => {}),
startProvider: vi.fn(async () => {}),
stopProvider: vi.fn(async () => {}),
markProviderLoggedOut: vi.fn(),
startChannels: vi.fn(async () => {}),
startChannel: vi.fn(async () => {}),
stopChannel: vi.fn(async () => {}),
markChannelLoggedOut: vi.fn(),
};
const createProviderManager = vi.fn(() => providerManager);
const createChannelManager = vi.fn(() => providerManager);
const reloaderStop = vi.fn(async () => {});
let onHotReload:
@@ -129,7 +129,7 @@ const hoisted = vi.hoisted(() => {
startGmailWatcher,
stopGmailWatcher,
providerManager,
createProviderManager,
createChannelManager,
startGatewayConfigReloader,
reloaderStop,
getOnHotReload: () => onHotReload,
@@ -155,8 +155,8 @@ vi.mock("../hooks/gmail-watcher.js", () => ({
stopGmailWatcher: hoisted.stopGmailWatcher,
}));
vi.mock("./server-providers.js", () => ({
createProviderManager: hoisted.createProviderManager,
vi.mock("./server-channels.js", () => ({
createChannelManager: hoisted.createChannelManager,
}));
vi.mock("./config-reload.js", () => ({
@@ -206,10 +206,12 @@ describe("gateway hot reload", () => {
agents: { defaults: { heartbeat: { every: "1m" }, maxConcurrent: 2 } },
browser: { enabled: true, controlUrl: "http://127.0.0.1:18791" },
web: { enabled: true },
telegram: { botToken: "token" },
discord: { token: "token" },
signal: { account: "+15550000000" },
imessage: { enabled: true },
channels: {
telegram: { botToken: "token" },
discord: { token: "token" },
signal: { account: "+15550000000" },
imessage: { enabled: true },
},
};
await onHotReload?.(
@@ -220,10 +222,10 @@ describe("gateway hot reload", () => {
"agents.defaults.heartbeat.every",
"browser.enabled",
"web.enabled",
"telegram.botToken",
"discord.token",
"signal.account",
"imessage.enabled",
"channels.telegram.botToken",
"channels.discord.token",
"channels.signal.account",
"channels.imessage.enabled",
],
restartGateway: false,
restartReasons: [],
@@ -233,7 +235,7 @@ describe("gateway hot reload", () => {
restartBrowserControl: true,
restartCron: true,
restartHeartbeat: true,
restartProviders: new Set([
restartChannels: new Set([
"whatsapp",
"telegram",
"discord",
@@ -258,34 +260,30 @@ describe("gateway hot reload", () => {
expect(hoisted.cronInstances[0].stop).toHaveBeenCalledTimes(1);
expect(hoisted.cronInstances[1].start).toHaveBeenCalledTimes(1);
expect(hoisted.providerManager.stopProvider).toHaveBeenCalledTimes(5);
expect(hoisted.providerManager.startProvider).toHaveBeenCalledTimes(5);
expect(hoisted.providerManager.stopProvider).toHaveBeenCalledWith(
expect(hoisted.providerManager.stopChannel).toHaveBeenCalledTimes(5);
expect(hoisted.providerManager.startChannel).toHaveBeenCalledTimes(5);
expect(hoisted.providerManager.stopChannel).toHaveBeenCalledWith(
"whatsapp",
);
expect(hoisted.providerManager.startProvider).toHaveBeenCalledWith(
expect(hoisted.providerManager.startChannel).toHaveBeenCalledWith(
"whatsapp",
);
expect(hoisted.providerManager.stopProvider).toHaveBeenCalledWith(
expect(hoisted.providerManager.stopChannel).toHaveBeenCalledWith(
"telegram",
);
expect(hoisted.providerManager.startProvider).toHaveBeenCalledWith(
expect(hoisted.providerManager.startChannel).toHaveBeenCalledWith(
"telegram",
);
expect(hoisted.providerManager.stopProvider).toHaveBeenCalledWith(
expect(hoisted.providerManager.stopChannel).toHaveBeenCalledWith("discord");
expect(hoisted.providerManager.startChannel).toHaveBeenCalledWith(
"discord",
);
expect(hoisted.providerManager.startProvider).toHaveBeenCalledWith(
"discord",
);
expect(hoisted.providerManager.stopProvider).toHaveBeenCalledWith("signal");
expect(hoisted.providerManager.startProvider).toHaveBeenCalledWith(
"signal",
);
expect(hoisted.providerManager.stopProvider).toHaveBeenCalledWith(
expect(hoisted.providerManager.stopChannel).toHaveBeenCalledWith("signal");
expect(hoisted.providerManager.startChannel).toHaveBeenCalledWith("signal");
expect(hoisted.providerManager.stopChannel).toHaveBeenCalledWith(
"imessage",
);
expect(hoisted.providerManager.startProvider).toHaveBeenCalledWith(
expect(hoisted.providerManager.startChannel).toHaveBeenCalledWith(
"imessage",
);
@@ -313,7 +311,7 @@ describe("gateway hot reload", () => {
restartBrowserControl: false,
restartCron: false,
restartHeartbeat: false,
restartProviders: new Set(),
restartChannels: new Set(),
noopPaths: [],
},
{},

View File

@@ -27,6 +27,11 @@ import {
createCanvasHostHandler,
startCanvasHost,
} from "../canvas-host/server.js";
import {
type ChannelId,
listChannelPlugins,
normalizeChannelId,
} from "../channels/plugins/index.js";
import { createDefaultDeps } from "../cli/deps.js";
import { agentCommand } from "../commands/agent.js";
import { getHealthSnapshot, type HealthSummary } from "../commands/health.js";
@@ -114,17 +119,12 @@ import {
startPluginServices,
} from "../plugins/services.js";
import { setCommandLaneConcurrency } from "../process/command-queue.js";
import {
listProviderPlugins,
normalizeProviderId,
type ProviderId,
} from "../providers/plugins/index.js";
import { normalizeAgentId } from "../routing/session-key.js";
import { defaultRuntime, type RuntimeEnv } from "../runtime.js";
import {
isGatewayCliClient,
isWebchatClient,
} from "../utils/message-provider.js";
} from "../utils/message-channel.js";
import { runOnboardingWizard } from "../wizard/onboarding.js";
import type { WizardSession } from "../wizard/session.js";
import {
@@ -138,12 +138,12 @@ import {
type ChatAbortControllerEntry,
} from "./chat-abort.js";
import {
type ChannelKind,
type GatewayReloadPlan,
type ProviderKind,
startGatewayConfigReloader,
} from "./config-reload.js";
import { normalizeControlUiBasePath } from "./control-ui.js";
import { type HookMessageProvider, resolveHooksConfig } from "./hooks.js";
import { type HookMessageChannel, resolveHooksConfig } from "./hooks.js";
import {
isLoopbackAddress,
isLoopbackHost,
@@ -168,6 +168,7 @@ import {
createBridgeSubscriptionManager,
} from "./server-bridge-subscriptions.js";
import { startBrowserControlServerIfEnabled } from "./server-browser.js";
import { createChannelManager } from "./server-channels.js";
import { createAgentEventHandler, createChatRunState } from "./server-chat.js";
import {
DEDUPE_MAX,
@@ -189,7 +190,6 @@ import {
createHooksRequestHandler,
} from "./server-http.js";
import { coreGatewayHandlers, handleGatewayRequest } from "./server-methods.js";
import { createProviderManager } from "./server-providers.js";
import type { DedupeEntry } from "./server-shared.js";
import { formatError } from "./server-utils.js";
import { loadSessionEntry } from "./session-utils.js";
@@ -202,7 +202,7 @@ const logCanvas = log.child("canvas");
const logBridge = log.child("bridge");
const logDiscovery = log.child("discovery");
const logTailscale = log.child("tailscale");
const logProviders = log.child("providers");
const logChannels = log.child("channels");
const logBrowser = log.child("browser");
const logHealth = log.child("health");
const logCron = log.child("cron");
@@ -210,18 +210,18 @@ const logReload = log.child("reload");
const logHooks = log.child("hooks");
const logWsControl = log.child("ws");
const canvasRuntime = runtimeForLogger(logCanvas);
const providerLogs = Object.fromEntries(
listProviderPlugins().map((plugin) => [
const channelLogs = Object.fromEntries(
listChannelPlugins().map((plugin) => [
plugin.id,
logProviders.child(plugin.id),
logChannels.child(plugin.id),
]),
) as Record<ProviderId, ReturnType<typeof createSubsystemLogger>>;
const providerRuntimeEnvs = Object.fromEntries(
Object.entries(providerLogs).map(([id, logger]) => [
) as Record<ChannelId, ReturnType<typeof createSubsystemLogger>>;
const channelRuntimeEnvs = Object.fromEntries(
Object.entries(channelLogs).map(([id, logger]) => [
id,
runtimeForLogger(logger),
]),
) as Record<ProviderId, RuntimeEnv>;
) as Record<ChannelId, RuntimeEnv>;
type GatewayModelChoice = ModelCatalogEntry;
@@ -246,8 +246,8 @@ type Client = {
const BASE_METHODS = [
"health",
"logs.tail",
"providers.status",
"providers.logout",
"channels.status",
"channels.logout",
"status",
"usage.status",
"config.get",
@@ -302,11 +302,11 @@ const BASE_METHODS = [
"chat.send",
];
const PROVIDER_METHODS = listProviderPlugins().flatMap(
const CHANNEL_METHODS = listChannelPlugins().flatMap(
(plugin) => plugin.gatewayMethods ?? [],
);
const METHODS = Array.from(new Set([...BASE_METHODS, ...PROVIDER_METHODS]));
const METHODS = Array.from(new Set([...BASE_METHODS, ...CHANNEL_METHODS]));
const EVENTS = [
"agent",
@@ -576,7 +576,7 @@ export async function startGatewayServer(
wakeMode: "now" | "next-heartbeat";
sessionKey: string;
deliver: boolean;
provider: HookMessageProvider;
channel: HookMessageChannel;
to?: string;
model?: string;
thinking?: string;
@@ -604,7 +604,7 @@ export async function startGatewayServer(
thinking: value.thinking,
timeoutSeconds: value.timeoutSeconds,
deliver: value.deliver,
provider: value.provider,
channel: value.channel,
to: value.to,
},
state: { nextRunAtMs: now },
@@ -864,18 +864,18 @@ export async function startGatewayServer(
let { cron, storePath: cronStorePath } = buildCronService(cfgAtStart);
const providerManager = createProviderManager({
const channelManager = createChannelManager({
loadConfig,
providerLogs,
providerRuntimeEnvs,
channelLogs,
channelRuntimeEnvs,
});
const {
getRuntimeSnapshot,
startProviders,
startProvider,
stopProvider,
markProviderLoggedOut,
} = providerManager;
startChannels,
startChannel,
stopChannel,
markChannelLoggedOut,
} = channelManager;
const broadcast = (
event: string,
@@ -1791,9 +1791,9 @@ export async function startGatewayServer(
findRunningWizard,
purgeWizardSession,
getRuntimeSnapshot,
startProvider,
stopProvider,
markProviderLoggedOut,
startChannel,
stopChannel,
markChannelLoggedOut,
wizardRunner,
broadcastVoiceWakeChanged,
},
@@ -1933,16 +1933,21 @@ export async function startGatewayServer(
}
}
// Launch configured providers so gateway replies via the surface the message came from.
// Tests can opt out via CLAWDBOT_SKIP_PROVIDERS.
if (process.env.CLAWDBOT_SKIP_PROVIDERS !== "1") {
// Launch configured channels so gateway replies via the surface the message came from.
// Tests can opt out via CLAWDBOT_SKIP_CHANNELS (or legacy CLAWDBOT_SKIP_PROVIDERS).
const skipChannels =
process.env.CLAWDBOT_SKIP_CHANNELS === "1" ||
process.env.CLAWDBOT_SKIP_PROVIDERS === "1";
if (!skipChannels) {
try {
await startProviders();
await startChannels();
} catch (err) {
logProviders.error(`provider startup failed: ${String(err)}`);
logChannels.error(`channel startup failed: ${String(err)}`);
}
} else {
logProviders.info("skipping provider start (CLAWDBOT_SKIP_PROVIDERS=1)");
logChannels.info(
"skipping channel start (CLAWDBOT_SKIP_CHANNELS=1 or CLAWDBOT_SKIP_PROVIDERS=1)",
);
}
try {
@@ -1970,19 +1975,19 @@ export async function startGatewayServer(
}
const { cfg, entry } = loadSessionEntry(sessionKey);
const lastProvider = entry?.lastProvider;
const lastChannel = entry?.lastChannel;
const lastTo = entry?.lastTo?.trim();
const parsedTarget = resolveAnnounceTargetFromKey(sessionKey);
const providerRaw = lastProvider ?? parsedTarget?.provider;
const provider = providerRaw ? normalizeProviderId(providerRaw) : null;
const channelRaw = lastChannel ?? parsedTarget?.channel;
const channel = channelRaw ? normalizeChannelId(channelRaw) : null;
const to = lastTo || parsedTarget?.to;
if (!provider || !to) {
if (!channel || !to) {
enqueueSystemEvent(message, { sessionKey });
return;
}
const resolved = resolveOutboundTarget({
provider,
channel,
to,
cfg,
accountId: parsedTarget?.accountId ?? entry?.lastAccountId,
@@ -1999,10 +2004,10 @@ export async function startGatewayServer(
message,
sessionKey,
to: resolved.to,
provider,
channel,
deliver: true,
bestEffortDeliver: true,
messageProvider: provider,
messageChannel: channel,
},
defaultRuntime,
deps,
@@ -2082,19 +2087,22 @@ export async function startGatewayServer(
}
}
if (plan.restartProviders.size > 0) {
if (process.env.CLAWDBOT_SKIP_PROVIDERS === "1") {
logProviders.info(
"skipping provider reload (CLAWDBOT_SKIP_PROVIDERS=1)",
if (plan.restartChannels.size > 0) {
if (
process.env.CLAWDBOT_SKIP_CHANNELS === "1" ||
process.env.CLAWDBOT_SKIP_PROVIDERS === "1"
) {
logChannels.info(
"skipping channel reload (CLAWDBOT_SKIP_CHANNELS=1 or CLAWDBOT_SKIP_PROVIDERS=1)",
);
} else {
const restartProvider = async (name: ProviderKind) => {
logProviders.info(`restarting ${name} provider`);
await stopProvider(name);
await startProvider(name);
const restartChannel = async (name: ChannelKind) => {
logChannels.info(`restarting ${name} channel`);
await stopChannel(name);
await startChannel(name);
};
for (const provider of plan.restartProviders) {
await restartProvider(provider);
for (const channel of plan.restartChannels) {
await restartChannel(channel);
}
}
}
@@ -2189,8 +2197,8 @@ export async function startGatewayServer(
/* ignore */
}
}
for (const plugin of listProviderPlugins()) {
await stopProvider(plugin.id);
for (const plugin of listChannelPlugins()) {
await stopChannel(plugin.id);
}
if (pluginServices) {
await pluginServices.stop().catch(() => {});

View File

@@ -20,7 +20,7 @@ describe("gateway session utils", () => {
test("parseGroupKey handles group prefixes", () => {
expect(parseGroupKey("group:abc")).toEqual({ id: "abc" });
expect(parseGroupKey("discord:group:dev")).toEqual({
provider: "discord",
channel: "discord",
kind: "group",
id: "dev",
});

View File

@@ -37,7 +37,7 @@ export type GatewaySessionRow = {
kind: "direct" | "group" | "global" | "unknown";
label?: string;
displayName?: string;
provider?: string;
channel?: string;
subject?: string;
room?: string;
space?: string;
@@ -58,7 +58,7 @@ export type GatewaySessionRow = {
modelProvider?: string;
model?: string;
contextTokens?: number;
lastProvider?: SessionEntry["lastProvider"];
lastChannel?: SessionEntry["lastChannel"];
lastTo?: string;
lastAccountId?: string;
};
@@ -201,7 +201,7 @@ export function classifySessionKey(
export function parseGroupKey(
key: string,
): { provider?: string; kind?: "group" | "channel"; id?: string } | null {
): { channel?: string; kind?: "group" | "channel"; id?: string } | null {
const agentParsed = parseAgentSessionKey(key);
const rawKey = agentParsed?.rest ?? key;
if (rawKey.startsWith("group:")) {
@@ -210,10 +210,10 @@ export function parseGroupKey(
}
const parts = rawKey.split(":").filter(Boolean);
if (parts.length >= 3) {
const [provider, kind, ...rest] = parts;
const [channel, kind, ...rest] = parts;
if (kind === "group" || kind === "channel") {
const id = rest.join(":");
return { provider, kind, id };
return { channel, kind, id };
}
}
return null;
@@ -533,16 +533,16 @@ export function listSessionsFromStore(params: {
const output = entry?.outputTokens ?? 0;
const total = entry?.totalTokens ?? input + output;
const parsed = parseGroupKey(key);
const provider = entry?.provider ?? parsed?.provider;
const channel = entry?.channel ?? parsed?.channel;
const subject = entry?.subject;
const room = entry?.room;
const space = entry?.space;
const id = parsed?.id;
const displayName =
entry?.displayName ??
(provider
(channel
? buildGroupDisplayName({
provider,
provider: channel,
subject,
room,
space,
@@ -555,7 +555,7 @@ export function listSessionsFromStore(params: {
kind: classifySessionKey(key, entry),
label: entry?.label,
displayName,
provider,
channel,
subject,
room,
space,
@@ -576,7 +576,7 @@ export function listSessionsFromStore(params: {
modelProvider: entry?.modelProvider,
model: entry?.model,
contextTokens: entry?.contextTokens,
lastProvider: entry?.lastProvider,
lastChannel: entry?.lastChannel,
lastTo: entry?.lastTo,
lastAccountId: entry?.lastAccountId,
} satisfies GatewaySessionRow;

View File

@@ -12,7 +12,7 @@ import { resetLogger, setLoggerOverride } from "../logging.js";
import {
GATEWAY_CLIENT_MODES,
GATEWAY_CLIENT_NAMES,
} from "../utils/message-provider.js";
} from "../utils/message-channel.js";
import { PROTOCOL_VERSION } from "./protocol/index.js";
import type { GatewayServerOptions } from "./server.js";
@@ -261,8 +261,10 @@ vi.mock("../config/config.js", async () => {
return { defaults };
})(),
bindings: testState.bindingsConfig,
whatsapp: {
allowFrom: testState.allowFrom,
channels: {
whatsapp: {
allowFrom: testState.allowFrom,
},
},
session: {
mainKey: "main",