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

@@ -13,18 +13,18 @@ type ProviderSchema = {
anyOf?: Array<{ const?: unknown }>;
};
function extractCronProviders(schema: SchemaLike): string[] {
function extractCronChannels(schema: SchemaLike): string[] {
const union = schema.anyOf ?? [];
const payloadWithProvider = union.find((entry) =>
Boolean(entry?.properties && "provider" in entry.properties),
const payloadWithChannel = union.find((entry) =>
Boolean(entry?.properties && "channel" in entry.properties),
);
const providerSchema = payloadWithProvider?.properties
? (payloadWithProvider.properties.provider as ProviderSchema)
const channelSchema = payloadWithChannel?.properties
? (payloadWithChannel.properties.channel as ProviderSchema)
: undefined;
const providers = (providerSchema?.anyOf ?? [])
const channels = (channelSchema?.anyOf ?? [])
.map((entry) => entry?.const)
.filter((value): value is string => typeof value === "string");
return providers;
return channels;
}
const UI_FILES = [
@@ -37,27 +37,27 @@ const SWIFT_FILES = ["apps/macos/Sources/Clawdbot/GatewayConnection.swift"];
describe("cron protocol conformance", () => {
it("ui + swift include all cron providers from gateway schema", async () => {
const providers = extractCronProviders(CronPayloadSchema as SchemaLike);
expect(providers.length).toBeGreaterThan(0);
const channels = extractCronChannels(CronPayloadSchema as SchemaLike);
expect(channels.length).toBeGreaterThan(0);
const cwd = process.cwd();
for (const relPath of UI_FILES) {
const content = await fs.readFile(path.join(cwd, relPath), "utf-8");
for (const provider of providers) {
for (const channel of channels) {
expect(
content.includes(`"${provider}"`),
`${relPath} missing ${provider}`,
content.includes(`"${channel}"`),
`${relPath} missing ${channel}`,
).toBe(true);
}
}
for (const relPath of SWIFT_FILES) {
const content = await fs.readFile(path.join(cwd, relPath), "utf-8");
for (const provider of providers) {
const pattern = new RegExp(`\\bcase\\s+${provider}\\b`);
for (const channel of channels) {
const pattern = new RegExp(`\\bcase\\s+${channel}\\b`);
expect(
pattern.test(content),
`${relPath} missing case ${provider}`,
`${relPath} missing case ${channel}`,
).toBe(true);
}
}

View File

@@ -539,7 +539,9 @@ describe("runCronIsolatedAgentTurn", () => {
process.env.TELEGRAM_BOT_TOKEN = "";
try {
const res = await runCronIsolatedAgentTurn({
cfg: makeCfg(home, storePath, { telegram: { botToken: "t-1" } }),
cfg: makeCfg(home, storePath, {
channels: { telegram: { botToken: "t-1" } },
}),
deps,
job: makeJob({
kind: "agentTurn",
@@ -642,7 +644,7 @@ describe("runCronIsolatedAgentTurn", () => {
kind: "agentTurn",
message: "do it",
deliver: true,
provider: "telegram",
channel: "telegram",
to: "-1001234567890:321",
}),
message: "do it",
@@ -687,7 +689,7 @@ describe("runCronIsolatedAgentTurn", () => {
kind: "agentTurn",
message: "do it",
deliver: true,
provider: "discord",
channel: "discord",
to: "channel:1122",
}),
message: "do it",
@@ -732,7 +734,7 @@ describe("runCronIsolatedAgentTurn", () => {
kind: "agentTurn",
message: "do it",
deliver: true,
provider: "telegram",
channel: "telegram",
to: "123",
}),
message: "do it",
@@ -770,7 +772,9 @@ describe("runCronIsolatedAgentTurn", () => {
});
const res = await runCronIsolatedAgentTurn({
cfg: makeCfg(home, storePath, { whatsapp: { allowFrom: ["+1234"] } }),
cfg: makeCfg(home, storePath, {
channels: { whatsapp: { allowFrom: ["+1234"] } },
}),
deps,
job: makeJob({
kind: "agentTurn",

View File

@@ -37,6 +37,9 @@ import {
normalizeThinkLevel,
supportsXHighThinking,
} from "../auto-reply/thinking.js";
import { normalizeChannelId } from "../channels/plugins/index.js";
import type { ChannelId } from "../channels/plugins/types.js";
import { DEFAULT_CHAT_CHANNEL } from "../channels/registry.js";
import type { CliDeps } from "../cli/deps.js";
import type { ClawdbotConfig } from "../config/config.js";
import {
@@ -50,23 +53,18 @@ import {
} from "../config/sessions.js";
import type { AgentDefaultsConfig } from "../config/types.js";
import { registerAgentRunContext } from "../infra/agent-events.js";
import { resolveMessageChannelSelection } from "../infra/outbound/channel-selection.js";
import { deliverOutboundPayloads } from "../infra/outbound/deliver.js";
import { resolveMessageProviderSelection } from "../infra/outbound/provider-selection.js";
import {
type OutboundProvider,
resolveOutboundTarget,
} from "../infra/outbound/targets.js";
import { normalizeProviderId } from "../providers/plugins/index.js";
import type { ProviderId } from "../providers/plugins/types.js";
import { DEFAULT_CHAT_PROVIDER } from "../providers/registry.js";
import type { OutboundChannel } from "../infra/outbound/targets.js";
import { resolveOutboundTarget } from "../infra/outbound/targets.js";
import {
buildAgentMainSessionKey,
normalizeAgentId,
} from "../routing/session-key.js";
import {
INTERNAL_MESSAGE_PROVIDER,
normalizeMessageProvider,
} from "../utils/message-provider.js";
INTERNAL_MESSAGE_CHANNEL,
normalizeMessageChannel,
} from "../utils/message-channel.js";
import { truncateUtf16Safe } from "../utils.js";
import type { CronJob } from "./types.js";
@@ -126,20 +124,20 @@ async function resolveDeliveryTarget(
cfg: ClawdbotConfig,
agentId: string,
jobPayload: {
provider?: "last" | ProviderId;
channel?: "last" | ChannelId;
to?: string;
},
): Promise<{
provider: string;
channel: Exclude<OutboundChannel, "none">;
to?: string;
accountId?: string;
mode: "explicit" | "implicit";
error?: Error;
}> {
const requestedRaw =
typeof jobPayload.provider === "string" ? jobPayload.provider : "last";
const requestedProvider =
normalizeMessageProvider(requestedRaw) ?? requestedRaw;
typeof jobPayload.channel === "string" ? jobPayload.channel : "last";
const requestedChannelHint =
normalizeMessageChannel(requestedRaw) ?? requestedRaw;
const explicitTo =
typeof jobPayload.to === "string" && jobPayload.to.trim()
? jobPayload.to.trim()
@@ -150,45 +148,45 @@ async function resolveDeliveryTarget(
const storePath = resolveStorePath(sessionCfg?.store, { agentId });
const store = loadSessionStore(storePath);
const main = store[mainSessionKey];
const lastProvider =
main?.lastProvider && main.lastProvider !== INTERNAL_MESSAGE_PROVIDER
? (normalizeProviderId(main.lastProvider) ?? main.lastProvider)
const lastChannel =
main?.lastChannel && main.lastChannel !== INTERNAL_MESSAGE_CHANNEL
? normalizeChannelId(main.lastChannel)
: undefined;
const lastTo = typeof main?.lastTo === "string" ? main.lastTo.trim() : "";
const lastAccountId = main?.lastAccountId;
let provider =
requestedProvider === "last"
? lastProvider
: requestedProvider === INTERNAL_MESSAGE_PROVIDER
let channel: Exclude<OutboundChannel, "none"> | undefined =
requestedChannelHint === "last"
? (lastChannel ?? undefined)
: requestedChannelHint === INTERNAL_MESSAGE_CHANNEL
? undefined
: normalizeProviderId(requestedProvider);
if (!provider) {
: (normalizeChannelId(requestedChannelHint) ?? undefined);
if (!channel) {
try {
const selection = await resolveMessageProviderSelection({ cfg });
provider = selection.provider;
const selection = await resolveMessageChannelSelection({ cfg });
channel = selection.channel;
} catch {
provider = lastProvider ?? DEFAULT_CHAT_PROVIDER;
channel = lastChannel ?? DEFAULT_CHAT_CHANNEL;
}
}
const toCandidate = explicitTo ?? (lastTo || undefined);
const mode: "explicit" | "implicit" = explicitTo ? "explicit" : "implicit";
if (!toCandidate) {
return { provider, to: undefined, accountId: lastAccountId, mode };
return { channel, to: undefined, accountId: lastAccountId, mode };
}
const resolved = resolveOutboundTarget({
provider: provider as Exclude<OutboundProvider, "none">,
channel,
to: toCandidate,
cfg,
accountId: provider === lastProvider ? lastAccountId : undefined,
accountId: channel === lastChannel ? lastAccountId : undefined,
mode,
});
return {
provider,
channel,
to: resolved.ok ? resolved.to : undefined,
accountId: provider === lastProvider ? lastAccountId : undefined,
accountId: channel === lastChannel ? lastAccountId : undefined,
mode,
error: resolved.ok ? undefined : resolved.error,
};
@@ -223,7 +221,7 @@ function resolveCronSession(params: {
model: entry?.model,
contextTokens: entry?.contextTokens,
sendPolicy: entry?.sendPolicy,
lastProvider: entry?.lastProvider,
lastChannel: entry?.lastChannel,
lastTo: entry?.lastTo,
};
return { storePath, store, sessionEntry, systemSent, isNewSession: !fresh };
@@ -396,9 +394,9 @@ export async function runCronIsolatedAgentTurn(params: {
cfgWithAgentDefaults,
agentId,
{
provider:
channel:
params.job.payload.kind === "agentTurn"
? params.job.payload.provider
? (params.job.payload.channel ?? "last")
: "last",
to:
params.job.payload.kind === "agentTurn"
@@ -454,7 +452,7 @@ export async function runCronIsolatedAgentTurn(params: {
sessionKey: agentSessionKey,
verboseLevel: resolvedVerboseLevel,
});
const messageProvider = resolvedDelivery.provider;
const messageChannel = resolvedDelivery.channel;
const fallbackResult = await runWithModelFallback({
cfg: cfgWithAgentDefaults,
provider,
@@ -487,7 +485,7 @@ export async function runCronIsolatedAgentTurn(params: {
return runEmbeddedPiAgent({
sessionId: cronSession.sessionEntry.sessionId,
sessionKey: agentSessionKey,
messageProvider,
messageChannel,
sessionFile,
workspaceDir,
config: cfgWithAgentDefaults,
@@ -577,10 +575,7 @@ export async function runCronIsolatedAgentTurn(params: {
try {
await deliverOutboundPayloads({
cfg: cfgWithAgentDefaults,
provider: resolvedDelivery.provider as Exclude<
OutboundProvider,
"none"
>,
channel: resolvedDelivery.channel,
to: resolvedDelivery.to,
accountId: resolvedDelivery.accountId,
payloads,

View File

@@ -3,7 +3,7 @@ import { describe, expect, it } from "vitest";
import { normalizeCronJobCreate } from "./normalize.js";
describe("normalizeCronJobCreate", () => {
it("maps legacy payload.channel to payload.provider and strips channel", () => {
it("maps legacy payload.provider to payload.channel and strips provider", () => {
const normalized = normalizeCronJobCreate({
name: "legacy",
enabled: true,
@@ -14,14 +14,14 @@ describe("normalizeCronJobCreate", () => {
kind: "agentTurn",
message: "hi",
deliver: true,
channel: " TeLeGrAm ",
provider: " TeLeGrAm ",
to: "7200373102",
},
}) as unknown as Record<string, unknown>;
const payload = normalized.payload as Record<string, unknown>;
expect(payload.provider).toBe("telegram");
expect("channel" in payload).toBe(false);
expect(payload.channel).toBe("telegram");
expect("provider" in payload).toBe(false);
});
it("normalizes agentId and drops null", () => {
@@ -56,7 +56,7 @@ describe("normalizeCronJobCreate", () => {
expect(cleared.agentId).toBeNull();
});
it("canonicalizes payload.provider casing", () => {
it("canonicalizes payload.channel casing", () => {
const normalized = normalizeCronJobCreate({
name: "legacy provider",
enabled: true,
@@ -67,13 +67,13 @@ describe("normalizeCronJobCreate", () => {
kind: "agentTurn",
message: "hi",
deliver: true,
provider: "Telegram",
channel: "Telegram",
to: "7200373102",
},
}) as unknown as Record<string, unknown>;
const payload = normalized.payload as Record<string, unknown>;
expect(payload.provider).toBe("telegram");
expect(payload.channel).toBe("telegram");
});
it("coerces ISO schedule.at to atMs (UTC)", () => {

View File

@@ -57,7 +57,7 @@ function coercePayload(payload: UnknownRecord) {
else if (typeof payload.message === "string") next.kind = "agentTurn";
}
// Back-compat: older configs used `channel` for delivery provider.
// Back-compat: older configs used `provider` for delivery channel.
migrateLegacyCronPayload(next);
return next;
}

View File

@@ -5,32 +5,32 @@ function readString(value: unknown): string | undefined {
return value;
}
function normalizeProvider(value: string): string {
function normalizeChannel(value: string): string {
return value.trim().toLowerCase();
}
export function migrateLegacyCronPayload(payload: UnknownRecord): boolean {
let mutated = false;
const providerValue = readString(payload.provider);
const channelValue = readString(payload.channel);
const providerValue = readString(payload.provider);
const nextProvider =
typeof providerValue === "string" && providerValue.trim().length > 0
? normalizeProvider(providerValue)
: typeof channelValue === "string" && channelValue.trim().length > 0
? normalizeProvider(channelValue)
const nextChannel =
typeof channelValue === "string" && channelValue.trim().length > 0
? normalizeChannel(channelValue)
: typeof providerValue === "string" && providerValue.trim().length > 0
? normalizeChannel(providerValue)
: "";
if (nextProvider) {
if (providerValue !== nextProvider) {
payload.provider = nextProvider;
if (nextChannel) {
if (channelValue !== nextChannel) {
payload.channel = nextChannel;
mutated = true;
}
}
if ("channel" in payload) {
delete payload.channel;
if ("provider" in payload) {
delete payload.provider;
mutated = true;
}

View File

@@ -227,7 +227,7 @@ describe("CronService", () => {
await store.cleanup();
});
it("migrates legacy payload.channel to payload.provider on load", async () => {
it("migrates legacy payload.provider to payload.channel on load", async () => {
const store = await makeStorePath();
const enqueueSystemEvent = vi.fn();
const requestHeartbeatNow = vi.fn();
@@ -245,7 +245,7 @@ describe("CronService", () => {
kind: "agentTurn",
message: "hi",
deliver: true,
channel: " TeLeGrAm ",
provider: " TeLeGrAm ",
to: "7200373102",
},
state: {},
@@ -271,14 +271,14 @@ describe("CronService", () => {
const jobs = await cron.list({ includeDisabled: true });
const job = jobs.find((j) => j.id === rawJob.id);
const payload = job?.payload as unknown as Record<string, unknown>;
expect(payload.provider).toBe("telegram");
expect("channel" in payload).toBe(false);
expect(payload.channel).toBe("telegram");
expect("provider" in payload).toBe(false);
cron.stop();
await store.cleanup();
});
it("canonicalizes payload.provider casing on load", async () => {
it("canonicalizes payload.channel casing on load", async () => {
const store = await makeStorePath();
const enqueueSystemEvent = vi.fn();
const requestHeartbeatNow = vi.fn();
@@ -296,7 +296,7 @@ describe("CronService", () => {
kind: "agentTurn",
message: "hi",
deliver: true,
provider: "Telegram",
channel: "Telegram",
to: "7200373102",
},
state: {},
@@ -322,7 +322,7 @@ describe("CronService", () => {
const jobs = await cron.list({ includeDisabled: true });
const job = jobs.find((j) => j.id === rawJob.id);
const payload = job?.payload as unknown as Record<string, unknown>;
expect(payload.provider).toBe("telegram");
expect(payload.channel).toBe("telegram");
cron.stop();
await store.cleanup();

View File

@@ -1,4 +1,4 @@
import type { ProviderId } from "../providers/plugins/types.js";
import type { ChannelId } from "../channels/plugins/types.js";
export type CronSchedule =
| { kind: "at"; atMs: number }
@@ -8,7 +8,7 @@ export type CronSchedule =
export type CronSessionTarget = "main" | "isolated";
export type CronWakeMode = "next-heartbeat" | "now";
export type CronMessageProvider = ProviderId | "last";
export type CronMessageChannel = ChannelId | "last";
export type CronPayload =
| { kind: "systemEvent"; text: string }
@@ -20,7 +20,7 @@ export type CronPayload =
thinking?: string;
timeoutSeconds?: number;
deliver?: boolean;
provider?: CronMessageProvider;
channel?: CronMessageChannel;
to?: string;
bestEffortDeliver?: boolean;
};