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

@@ -0,0 +1,85 @@
import { listChannelPlugins } from "../../channels/plugins/index.js";
import type { ChannelPlugin } from "../../channels/plugins/types.js";
import type { ClawdbotConfig } from "../../config/config.js";
import {
DELIVERABLE_MESSAGE_CHANNELS,
type DeliverableMessageChannel,
normalizeMessageChannel,
} from "../../utils/message-channel.js";
export type MessageChannelId = DeliverableMessageChannel;
const MESSAGE_CHANNELS = [...DELIVERABLE_MESSAGE_CHANNELS];
function isKnownChannel(value: string): value is MessageChannelId {
return (MESSAGE_CHANNELS as readonly string[]).includes(value);
}
function isAccountEnabled(account: unknown): boolean {
if (!account || typeof account !== "object") return true;
const enabled = (account as { enabled?: boolean }).enabled;
return enabled !== false;
}
async function isPluginConfigured(
plugin: ChannelPlugin,
cfg: ClawdbotConfig,
): Promise<boolean> {
const accountIds = plugin.config.listAccountIds(cfg);
if (accountIds.length === 0) return false;
for (const accountId of accountIds) {
const account = plugin.config.resolveAccount(cfg, accountId);
const enabled = plugin.config.isEnabled
? plugin.config.isEnabled(account, cfg)
: isAccountEnabled(account);
if (!enabled) continue;
if (!plugin.config.isConfigured) return true;
const configured = await plugin.config.isConfigured(account, cfg);
if (configured) return true;
}
return false;
}
export async function listConfiguredMessageChannels(
cfg: ClawdbotConfig,
): Promise<MessageChannelId[]> {
const channels: MessageChannelId[] = [];
for (const plugin of listChannelPlugins()) {
if (!isKnownChannel(plugin.id)) continue;
if (await isPluginConfigured(plugin, cfg)) {
channels.push(plugin.id);
}
}
return channels;
}
export async function resolveMessageChannelSelection(params: {
cfg: ClawdbotConfig;
channel?: string | null;
}): Promise<{ channel: MessageChannelId; configured: MessageChannelId[] }> {
const normalized = normalizeMessageChannel(params.channel);
if (normalized) {
if (!isKnownChannel(normalized)) {
throw new Error(`Unknown channel: ${normalized}`);
}
return {
channel: normalized,
configured: await listConfiguredMessageChannels(params.cfg),
};
}
const configured = await listConfiguredMessageChannels(params.cfg);
if (configured.length === 1) {
return { channel: configured[0], configured };
}
if (configured.length === 0) {
throw new Error("Channel is required (no configured channels detected).");
}
throw new Error(
`Channel is required when multiple channels are configured: ${configured.join(
", ",
)}`,
);
}

View File

@@ -12,14 +12,14 @@ describe("deliverOutboundPayloads", () => {
.fn()
.mockResolvedValue({ messageId: "m1", chatId: "c1" });
const cfg: ClawdbotConfig = {
telegram: { botToken: "tok-1", textChunkLimit: 2 },
channels: { telegram: { botToken: "tok-1", textChunkLimit: 2 } },
};
const prevTelegramToken = process.env.TELEGRAM_BOT_TOKEN;
process.env.TELEGRAM_BOT_TOKEN = "";
try {
const results = await deliverOutboundPayloads({
cfg,
provider: "telegram",
channel: "telegram",
to: "123",
payloads: [{ text: "abcd" }],
deps: { sendTelegram },
@@ -32,7 +32,7 @@ describe("deliverOutboundPayloads", () => {
);
}
expect(results).toHaveLength(2);
expect(results[0]).toMatchObject({ provider: "telegram", chatId: "c1" });
expect(results[0]).toMatchObject({ channel: "telegram", chatId: "c1" });
} finally {
if (prevTelegramToken === undefined) {
delete process.env.TELEGRAM_BOT_TOKEN;
@@ -47,12 +47,12 @@ describe("deliverOutboundPayloads", () => {
.fn()
.mockResolvedValue({ messageId: "m1", chatId: "c1" });
const cfg: ClawdbotConfig = {
telegram: { botToken: "tok-1", textChunkLimit: 2 },
channels: { telegram: { botToken: "tok-1", textChunkLimit: 2 } },
};
await deliverOutboundPayloads({
cfg,
provider: "telegram",
channel: "telegram",
to: "123",
accountId: "default",
payloads: [{ text: "hi" }],
@@ -70,11 +70,11 @@ describe("deliverOutboundPayloads", () => {
const sendSignal = vi
.fn()
.mockResolvedValue({ messageId: "s1", timestamp: 123 });
const cfg: ClawdbotConfig = { signal: { mediaMaxMb: 2 } };
const cfg: ClawdbotConfig = { channels: { signal: { mediaMaxMb: 2 } } };
const results = await deliverOutboundPayloads({
cfg,
provider: "signal",
channel: "signal",
to: "+1555",
payloads: [{ text: "hi", mediaUrl: "https://x.test/a.jpg" }],
deps: { sendSignal },
@@ -88,7 +88,7 @@ describe("deliverOutboundPayloads", () => {
maxBytes: 2 * 1024 * 1024,
}),
);
expect(results[0]).toMatchObject({ provider: "signal", messageId: "s1" });
expect(results[0]).toMatchObject({ channel: "signal", messageId: "s1" });
});
it("chunks WhatsApp text and returns all results", async () => {
@@ -97,12 +97,12 @@ describe("deliverOutboundPayloads", () => {
.mockResolvedValueOnce({ messageId: "w1", toJid: "jid" })
.mockResolvedValueOnce({ messageId: "w2", toJid: "jid" });
const cfg: ClawdbotConfig = {
whatsapp: { textChunkLimit: 2 },
channels: { whatsapp: { textChunkLimit: 2 } },
};
const results = await deliverOutboundPayloads({
cfg,
provider: "whatsapp",
channel: "whatsapp",
to: "+1555",
payloads: [{ text: "abcd" }],
deps: { sendWhatsApp },
@@ -120,7 +120,7 @@ describe("deliverOutboundPayloads", () => {
await deliverOutboundPayloads({
cfg,
provider: "imessage",
channel: "imessage",
to: "chat_id:42",
payloads: [{ text: "hello" }],
deps: { sendIMessage },
@@ -156,7 +156,7 @@ describe("deliverOutboundPayloads", () => {
const results = await deliverOutboundPayloads({
cfg,
provider: "whatsapp",
channel: "whatsapp",
to: "+1555",
payloads: [{ text: "a" }, { text: "b" }],
deps: { sendWhatsApp },
@@ -167,7 +167,7 @@ describe("deliverOutboundPayloads", () => {
expect(sendWhatsApp).toHaveBeenCalledTimes(2);
expect(onError).toHaveBeenCalledTimes(1);
expect(results).toEqual([
{ provider: "whatsapp", messageId: "w2", toJid: "jid" },
{ channel: "whatsapp", messageId: "w2", toJid: "jid" },
]);
});
});

View File

@@ -1,17 +1,17 @@
import { resolveTextChunkLimit } from "../../auto-reply/chunk.js";
import type { ReplyPayload } from "../../auto-reply/types.js";
import { loadChannelOutboundAdapter } from "../../channels/plugins/outbound/load.js";
import type { ChannelOutboundAdapter } from "../../channels/plugins/types.js";
import type { ClawdbotConfig } from "../../config/config.js";
import type { sendMessageDiscord } from "../../discord/send.js";
import type { sendMessageIMessage } from "../../imessage/send.js";
import { loadProviderOutboundAdapter } from "../../providers/plugins/outbound/load.js";
import type { ProviderOutboundAdapter } from "../../providers/plugins/types.js";
import type { sendMessageSignal } from "../../signal/send.js";
import type { sendMessageSlack } from "../../slack/send.js";
import type { sendMessageTelegram } from "../../telegram/send.js";
import type { sendMessageWhatsApp } from "../../web/outbound.js";
import type { NormalizedOutboundPayload } from "./payloads.js";
import { normalizeOutboundPayloads } from "./payloads.js";
import type { OutboundProvider } from "./targets.js";
import type { OutboundChannel } from "./targets.js";
export type { NormalizedOutboundPayload } from "./payloads.js";
export { normalizeOutboundPayloads } from "./payloads.js";
@@ -31,7 +31,7 @@ export type OutboundSendDeps = {
};
export type OutboundDeliveryResult = {
provider: Exclude<OutboundProvider, "none">;
channel: Exclude<OutboundChannel, "none">;
messageId: string;
chatId?: string;
channelId?: string;
@@ -39,13 +39,13 @@ export type OutboundDeliveryResult = {
timestamp?: number;
toJid?: string;
pollId?: string;
// Provider docking: stash provider-specific fields here to avoid core type churn.
// Channel docking: stash channel-specific fields here to avoid core type churn.
meta?: Record<string, unknown>;
};
type Chunker = (text: string, limit: number) => string[];
type ProviderHandler = {
type ChannelHandler = {
chunker: Chunker | null;
textChunkLimit?: number;
sendText: (text: string) => Promise<OutboundDeliveryResult>;
@@ -61,25 +61,25 @@ function throwIfAborted(abortSignal?: AbortSignal): void {
}
}
// Provider docking: outbound delivery delegates to plugin.outbound adapters.
async function createProviderHandler(params: {
// Channel docking: outbound delivery delegates to plugin.outbound adapters.
async function createChannelHandler(params: {
cfg: ClawdbotConfig;
provider: Exclude<OutboundProvider, "none">;
channel: Exclude<OutboundChannel, "none">;
to: string;
accountId?: string;
replyToId?: string | null;
threadId?: number | null;
deps?: OutboundSendDeps;
gifPlayback?: boolean;
}): Promise<ProviderHandler> {
const outbound = await loadProviderOutboundAdapter(params.provider);
}): Promise<ChannelHandler> {
const outbound = await loadChannelOutboundAdapter(params.channel);
if (!outbound?.sendText || !outbound?.sendMedia) {
throw new Error(`Outbound not configured for provider: ${params.provider}`);
throw new Error(`Outbound not configured for channel: ${params.channel}`);
}
const handler = createPluginHandler({
outbound,
cfg: params.cfg,
provider: params.provider,
channel: params.channel,
to: params.to,
accountId: params.accountId,
replyToId: params.replyToId,
@@ -88,22 +88,22 @@ async function createProviderHandler(params: {
gifPlayback: params.gifPlayback,
});
if (!handler) {
throw new Error(`Outbound not configured for provider: ${params.provider}`);
throw new Error(`Outbound not configured for channel: ${params.channel}`);
}
return handler;
}
function createPluginHandler(params: {
outbound?: ProviderOutboundAdapter;
outbound?: ChannelOutboundAdapter;
cfg: ClawdbotConfig;
provider: Exclude<OutboundProvider, "none">;
channel: Exclude<OutboundChannel, "none">;
to: string;
accountId?: string;
replyToId?: string | null;
threadId?: number | null;
deps?: OutboundSendDeps;
gifPlayback?: boolean;
}): ProviderHandler | null {
}): ChannelHandler | null {
const outbound = params.outbound;
if (!outbound?.sendText || !outbound?.sendMedia) return null;
const sendText = outbound.sendText;
@@ -140,7 +140,7 @@ function createPluginHandler(params: {
export async function deliverOutboundPayloads(params: {
cfg: ClawdbotConfig;
provider: Exclude<OutboundProvider, "none">;
channel: Exclude<OutboundChannel, "none">;
to: string;
accountId?: string;
payloads: ReplyPayload[];
@@ -153,14 +153,14 @@ export async function deliverOutboundPayloads(params: {
onError?: (err: unknown, payload: NormalizedOutboundPayload) => void;
onPayload?: (payload: NormalizedOutboundPayload) => void;
}): Promise<OutboundDeliveryResult[]> {
const { cfg, provider, to, payloads } = params;
const { cfg, channel, to, payloads } = params;
const accountId = params.accountId;
const deps = params.deps;
const abortSignal = params.abortSignal;
const results: OutboundDeliveryResult[] = [];
const handler = await createProviderHandler({
const handler = await createChannelHandler({
cfg,
provider,
channel,
to,
deps,
accountId,
@@ -169,7 +169,7 @@ export async function deliverOutboundPayloads(params: {
gifPlayback: params.gifPlayback,
});
const textLimit = handler.chunker
? resolveTextChunkLimit(cfg, provider, accountId, {
? resolveTextChunkLimit(cfg, channel, accountId, {
fallbackLimit: handler.textChunkLimit,
})
: undefined;

View File

@@ -19,7 +19,7 @@ describe("formatOutboundDeliverySummary", () => {
it("adds chat or channel details", () => {
expect(
formatOutboundDeliverySummary("telegram", {
provider: "telegram",
channel: "telegram",
messageId: "m1",
chatId: "c1",
}),
@@ -27,7 +27,7 @@ describe("formatOutboundDeliverySummary", () => {
expect(
formatOutboundDeliverySummary("discord", {
provider: "discord",
channel: "discord",
messageId: "d1",
channelId: "chan",
}),
@@ -39,13 +39,13 @@ describe("buildOutboundDeliveryJson", () => {
it("builds direct delivery payloads", () => {
expect(
buildOutboundDeliveryJson({
provider: "telegram",
channel: "telegram",
to: "123",
result: { provider: "telegram", messageId: "m1", chatId: "c1" },
result: { channel: "telegram", messageId: "m1", chatId: "c1" },
mediaUrl: "https://example.com/a.png",
}),
).toEqual({
provider: "telegram",
channel: "telegram",
via: "direct",
to: "123",
messageId: "m1",
@@ -57,12 +57,12 @@ describe("buildOutboundDeliveryJson", () => {
it("supports whatsapp metadata when present", () => {
expect(
buildOutboundDeliveryJson({
provider: "whatsapp",
channel: "whatsapp",
to: "+1",
result: { provider: "whatsapp", messageId: "w1", toJid: "jid" },
result: { channel: "whatsapp", messageId: "w1", toJid: "jid" },
}),
).toEqual({
provider: "whatsapp",
channel: "whatsapp",
via: "direct",
to: "+1",
messageId: "w1",
@@ -74,12 +74,12 @@ describe("buildOutboundDeliveryJson", () => {
it("keeps timestamp for signal", () => {
expect(
buildOutboundDeliveryJson({
provider: "signal",
channel: "signal",
to: "+1",
result: { provider: "signal", messageId: "s1", timestamp: 123 },
result: { channel: "signal", messageId: "s1", timestamp: 123 },
}),
).toEqual({
provider: "signal",
channel: "signal",
via: "direct",
to: "+1",
messageId: "s1",
@@ -90,17 +90,17 @@ describe("buildOutboundDeliveryJson", () => {
});
describe("formatGatewaySummary", () => {
it("formats gateway summaries with provider", () => {
expect(
formatGatewaySummary({ provider: "whatsapp", messageId: "m1" }),
).toBe("✅ Sent via gateway (whatsapp). Message ID: m1");
it("formats gateway summaries with channel", () => {
expect(formatGatewaySummary({ channel: "whatsapp", messageId: "m1" })).toBe(
"✅ Sent via gateway (whatsapp). Message ID: m1",
);
});
it("supports custom actions", () => {
expect(
formatGatewaySummary({
action: "Poll sent",
provider: "discord",
channel: "discord",
messageId: "p1",
}),
).toBe("✅ Poll sent via gateway (discord). Message ID: p1");

View File

@@ -1,9 +1,9 @@
import { getProviderPlugin } from "../../providers/plugins/index.js";
import type { ProviderId } from "../../providers/plugins/types.js";
import { getChannelPlugin } from "../../channels/plugins/index.js";
import type { ChannelId } from "../../channels/plugins/types.js";
import type { OutboundDeliveryResult } from "./deliver.js";
export type OutboundDeliveryJson = {
provider: string;
channel: string;
via: "direct" | "gateway";
to: string;
messageId: string;
@@ -26,18 +26,18 @@ type OutboundDeliveryMeta = {
meta?: Record<string, unknown>;
};
const resolveProviderLabel = (provider: string) =>
getProviderPlugin(provider as ProviderId)?.meta.label ?? provider;
const resolveChannelLabel = (channel: string) =>
getChannelPlugin(channel as ChannelId)?.meta.label ?? channel;
export function formatOutboundDeliverySummary(
provider: string,
channel: string,
result?: OutboundDeliveryResult,
): string {
if (!result) {
return `✅ Sent via ${resolveProviderLabel(provider)}. Message ID: unknown`;
return `✅ Sent via ${resolveChannelLabel(channel)}. Message ID: unknown`;
}
const label = resolveProviderLabel(result.provider);
const label = resolveChannelLabel(result.channel);
const base = `✅ Sent via ${label}. Message ID: ${result.messageId}`;
if ("chatId" in result) return `${base} (chat ${result.chatId})`;
@@ -48,16 +48,16 @@ export function formatOutboundDeliverySummary(
}
export function buildOutboundDeliveryJson(params: {
provider: string;
channel: string;
to: string;
result?: OutboundDeliveryMeta | OutboundDeliveryResult;
via?: "direct" | "gateway";
mediaUrl?: string | null;
}): OutboundDeliveryJson {
const { provider, to, result } = params;
const { channel, to, result } = params;
const messageId = result?.messageId ?? "unknown";
const payload: OutboundDeliveryJson = {
provider,
channel,
via: params.via ?? "direct",
to,
messageId,
@@ -92,11 +92,11 @@ export function buildOutboundDeliveryJson(params: {
export function formatGatewaySummary(params: {
action?: string;
provider?: string;
channel?: string;
messageId?: string | null;
}): string {
const action = params.action ?? "Sent";
const providerSuffix = params.provider ? ` (${params.provider})` : "";
const channelSuffix = params.channel ? ` (${params.channel})` : "";
const messageId = params.messageId ?? "unknown";
return `${action} via gateway${providerSuffix}. Message ID: ${messageId}`;
return `${action} via gateway${channelSuffix}. Message ID: ${messageId}`;
}

View File

@@ -6,21 +6,21 @@ import {
readStringParam,
} from "../../agents/tools/common.js";
import { parseReplyDirectives } from "../../auto-reply/reply/reply-directives.js";
import type { ClawdbotConfig } from "../../config/config.js";
import { dispatchProviderMessageAction } from "../../providers/plugins/message-actions.js";
import { dispatchChannelMessageAction } from "../../channels/plugins/message-actions.js";
import type {
ProviderId,
ProviderMessageActionName,
ProviderThreadingToolContext,
} from "../../providers/plugins/types.js";
ChannelId,
ChannelMessageActionName,
ChannelThreadingToolContext,
} from "../../channels/plugins/types.js";
import type { ClawdbotConfig } from "../../config/config.js";
import type {
GatewayClientMode,
GatewayClientName,
} from "../../utils/message-provider.js";
} from "../../utils/message-channel.js";
import { resolveMessageChannelSelection } from "./channel-selection.js";
import type { OutboundSendDeps } from "./deliver.js";
import type { MessagePollResult, MessageSendResult } from "./message.js";
import { sendMessage, sendPoll } from "./message.js";
import { resolveMessageProviderSelection } from "./provider-selection.js";
export type MessageActionRunnerGateway = {
url?: string;
@@ -33,10 +33,10 @@ export type MessageActionRunnerGateway = {
export type RunMessageActionParams = {
cfg: ClawdbotConfig;
action: ProviderMessageActionName;
action: ChannelMessageActionName;
params: Record<string, unknown>;
defaultAccountId?: string;
toolContext?: ProviderThreadingToolContext;
toolContext?: ChannelThreadingToolContext;
gateway?: MessageActionRunnerGateway;
deps?: OutboundSendDeps;
dryRun?: boolean;
@@ -45,7 +45,7 @@ export type RunMessageActionParams = {
export type MessageActionRunResult =
| {
kind: "send";
provider: ProviderId;
channel: ChannelId;
action: "send";
to: string;
handledBy: "plugin" | "core";
@@ -56,7 +56,7 @@ export type MessageActionRunResult =
}
| {
kind: "poll";
provider: ProviderId;
channel: ChannelId;
action: "poll";
to: string;
handledBy: "plugin" | "core";
@@ -67,8 +67,8 @@ export type MessageActionRunResult =
}
| {
kind: "action";
provider: ProviderId;
action: Exclude<ProviderMessageActionName, "send" | "poll">;
channel: ChannelId;
action: Exclude<ChannelMessageActionName, "send" | "poll">;
handledBy: "plugin" | "dry-run";
payload: unknown;
toolResult?: AgentToolResult<unknown>;
@@ -126,7 +126,7 @@ function parseButtonsParam(params: Record<string, unknown>): void {
}
}
const CONTEXT_GUARDED_ACTIONS = new Set<ProviderMessageActionName>([
const CONTEXT_GUARDED_ACTIONS = new Set<ChannelMessageActionName>([
"send",
"poll",
"thread-create",
@@ -135,7 +135,7 @@ const CONTEXT_GUARDED_ACTIONS = new Set<ProviderMessageActionName>([
]);
function resolveContextGuardTarget(
action: ProviderMessageActionName,
action: ChannelMessageActionName,
params: Record<string, unknown>,
): string | undefined {
if (!CONTEXT_GUARDED_ACTIONS.has(action)) return undefined;
@@ -150,10 +150,10 @@ function resolveContextGuardTarget(
}
function enforceContextIsolation(params: {
provider: ProviderId;
action: ProviderMessageActionName;
channel: ChannelId;
action: ChannelMessageActionName;
params: Record<string, unknown>;
toolContext?: ProviderThreadingToolContext;
toolContext?: ChannelThreadingToolContext;
}): void {
const currentTarget = params.toolContext?.currentChannelId?.trim();
if (!currentTarget) return;
@@ -163,29 +163,29 @@ function enforceContextIsolation(params: {
if (!target) return;
const normalizedTarget =
normalizeTargetForProvider(params.provider, target) ?? target.toLowerCase();
normalizeTargetForProvider(params.channel, target) ?? target.toLowerCase();
const normalizedCurrent =
normalizeTargetForProvider(params.provider, currentTarget) ??
normalizeTargetForProvider(params.channel, currentTarget) ??
currentTarget.toLowerCase();
if (!normalizedTarget || !normalizedCurrent) return;
if (normalizedTarget === normalizedCurrent) return;
throw new Error(
`Cross-context messaging denied: action=${params.action} target="${target}" while bound to "${currentTarget}" (provider=${params.provider}).`,
`Cross-context messaging denied: action=${params.action} target="${target}" while bound to "${currentTarget}" (channel=${params.channel}).`,
);
}
async function resolveProvider(
async function resolveChannel(
cfg: ClawdbotConfig,
params: Record<string, unknown>,
) {
const providerHint = readStringParam(params, "provider");
const selection = await resolveMessageProviderSelection({
const channelHint = readStringParam(params, "channel");
const selection = await resolveMessageChannelSelection({
cfg,
provider: providerHint,
channel: channelHint,
});
return selection.provider;
return selection.channel;
}
export async function runMessageAction(
@@ -196,13 +196,13 @@ export async function runMessageAction(
parseButtonsParam(params);
const action = input.action;
const provider = await resolveProvider(cfg, params);
const channel = await resolveChannel(cfg, params);
const accountId =
readStringParam(params, "accountId") ?? input.defaultAccountId;
const dryRun = Boolean(input.dryRun ?? readBooleanParam(params, "dryRun"));
enforceContextIsolation({
provider,
channel,
action,
params,
toolContext: input.toolContext,
@@ -239,8 +239,8 @@ export async function runMessageAction(
const bestEffort = readBooleanParam(params, "bestEffort");
if (!dryRun) {
const handled = await dispatchProviderMessageAction({
provider,
const handled = await dispatchChannelMessageAction({
channel,
action,
cfg,
params,
@@ -252,7 +252,7 @@ export async function runMessageAction(
if (handled) {
return {
kind: "send",
provider,
channel,
action,
to,
handledBy: "plugin",
@@ -268,7 +268,7 @@ export async function runMessageAction(
to,
content: message,
mediaUrl: mediaUrl || undefined,
provider: provider || undefined,
channel: channel || undefined,
accountId: accountId ?? undefined,
gifPlayback,
dryRun,
@@ -279,7 +279,7 @@ export async function runMessageAction(
return {
kind: "send",
provider,
channel,
action,
to,
handledBy: "core",
@@ -306,8 +306,8 @@ export async function runMessageAction(
const maxSelections = allowMultiselect ? Math.max(2, options.length) : 1;
if (!dryRun) {
const handled = await dispatchProviderMessageAction({
provider,
const handled = await dispatchChannelMessageAction({
channel,
action,
cfg,
params,
@@ -319,7 +319,7 @@ export async function runMessageAction(
if (handled) {
return {
kind: "poll",
provider,
channel,
action,
to,
handledBy: "plugin",
@@ -337,14 +337,14 @@ export async function runMessageAction(
options,
maxSelections,
durationHours: durationHours ?? undefined,
provider,
channel,
dryRun,
gateway,
});
return {
kind: "poll",
provider,
channel,
action,
to,
handledBy: "core",
@@ -357,16 +357,16 @@ export async function runMessageAction(
if (dryRun) {
return {
kind: "action",
provider,
channel,
action,
handledBy: "dry-run",
payload: { ok: true, dryRun: true, provider, action },
payload: { ok: true, dryRun: true, channel, action },
dryRun: true,
};
}
const handled = await dispatchProviderMessageAction({
provider,
const handled = await dispatchChannelMessageAction({
channel,
action,
cfg,
params,
@@ -377,12 +377,12 @@ export async function runMessageAction(
});
if (!handled) {
throw new Error(
`Message action ${action} not supported for provider ${provider}.`,
`Message action ${action} not supported for channel ${channel}.`,
);
}
return {
kind: "action",
provider,
channel,
action,
handledBy: "plugin",
payload: extractToolPayload(handled),

View File

@@ -8,7 +8,7 @@ vi.mock("../../gateway/call.js", () => ({
randomIdempotencyKey: () => "idem-1",
}));
describe("sendMessage provider normalization", () => {
describe("sendMessage channel normalization", () => {
beforeEach(() => {
callGatewayMock.mockReset();
});
@@ -22,7 +22,7 @@ describe("sendMessage provider normalization", () => {
cfg: {},
to: "conversation:19:abc@thread.tacv2",
content: "hi",
provider: "teams",
channel: "teams",
deps: { sendMSTeams },
});
@@ -30,7 +30,7 @@ describe("sendMessage provider normalization", () => {
"conversation:19:abc@thread.tacv2",
"hi",
);
expect(result.provider).toBe("msteams");
expect(result.channel).toBe("msteams");
});
it("normalizes iMessage alias", async () => {
@@ -39,7 +39,7 @@ describe("sendMessage provider normalization", () => {
cfg: {},
to: "someone@example.com",
content: "hi",
provider: "imsg",
channel: "imsg",
deps: { sendIMessage },
});
@@ -48,11 +48,11 @@ describe("sendMessage provider normalization", () => {
"hi",
expect.any(Object),
);
expect(result.provider).toBe("imessage");
expect(result.channel).toBe("imessage");
});
});
describe("sendPoll provider normalization", () => {
describe("sendPoll channel normalization", () => {
beforeEach(() => {
callGatewayMock.mockReset();
});
@@ -65,13 +65,13 @@ describe("sendPoll provider normalization", () => {
to: "conversation:19:abc@thread.tacv2",
question: "Lunch?",
options: ["Pizza", "Sushi"],
provider: "Teams",
channel: "Teams",
});
const call = callGatewayMock.mock.calls[0]?.[0] as {
params?: Record<string, unknown>;
};
expect(call?.params?.provider).toBe("msteams");
expect(result.provider).toBe("msteams");
expect(call?.params?.channel).toBe("msteams");
expect(result.channel).toBe("msteams");
});
});

View File

@@ -1,26 +1,26 @@
import {
getChannelPlugin,
normalizeChannelId,
} from "../../channels/plugins/index.js";
import type { ChannelId } from "../../channels/plugins/types.js";
import type { ClawdbotConfig } from "../../config/config.js";
import { loadConfig } from "../../config/config.js";
import { callGateway, randomIdempotencyKey } from "../../gateway/call.js";
import type { PollInput } from "../../polls.js";
import { normalizePollInput } from "../../polls.js";
import {
getProviderPlugin,
normalizeProviderId,
} from "../../providers/plugins/index.js";
import type { ProviderId } from "../../providers/plugins/types.js";
import {
GATEWAY_CLIENT_MODES,
GATEWAY_CLIENT_NAMES,
type GatewayClientMode,
type GatewayClientName,
} from "../../utils/message-provider.js";
} from "../../utils/message-channel.js";
import { resolveMessageChannelSelection } from "./channel-selection.js";
import {
deliverOutboundPayloads,
type OutboundDeliveryResult,
type OutboundSendDeps,
} from "./deliver.js";
import { resolveMessageProviderSelection } from "./provider-selection.js";
import type { OutboundProvider } from "./targets.js";
import type { OutboundChannel } from "./targets.js";
import { resolveOutboundTarget } from "./targets.js";
export type MessageGatewayOptions = {
@@ -35,7 +35,7 @@ export type MessageGatewayOptions = {
type MessageSendParams = {
to: string;
content: string;
provider?: string;
channel?: string;
mediaUrl?: string;
gifPlayback?: boolean;
accountId?: string;
@@ -48,7 +48,7 @@ type MessageSendParams = {
};
export type MessageSendResult = {
provider: string;
channel: string;
to: string;
via: "direct" | "gateway";
mediaUrl: string | null;
@@ -62,7 +62,7 @@ type MessagePollParams = {
options: string[];
maxSelections?: number;
durationHours?: number;
provider?: string;
channel?: string;
dryRun?: boolean;
cfg?: ClawdbotConfig;
gateway?: MessageGatewayOptions;
@@ -70,7 +70,7 @@ type MessagePollParams = {
};
export type MessagePollResult = {
provider: string;
channel: string;
to: string;
question: string;
options: string[];
@@ -105,21 +105,21 @@ export async function sendMessage(
params: MessageSendParams,
): Promise<MessageSendResult> {
const cfg = params.cfg ?? loadConfig();
const provider = params.provider?.trim()
? normalizeProviderId(params.provider)
: (await resolveMessageProviderSelection({ cfg })).provider;
if (!provider) {
throw new Error(`Unknown provider: ${params.provider}`);
const channel = params.channel?.trim()
? normalizeChannelId(params.channel)
: (await resolveMessageChannelSelection({ cfg })).channel;
if (!channel) {
throw new Error(`Unknown channel: ${params.channel}`);
}
const plugin = getProviderPlugin(provider as ProviderId);
const plugin = getChannelPlugin(channel as ChannelId);
if (!plugin) {
throw new Error(`Unknown provider: ${provider}`);
throw new Error(`Unknown channel: ${channel}`);
}
const deliveryMode = plugin.outbound?.deliveryMode ?? "direct";
if (params.dryRun) {
return {
provider,
channel,
to: params.to,
via: deliveryMode === "gateway" ? "gateway" : "direct",
mediaUrl: params.mediaUrl ?? null,
@@ -128,9 +128,9 @@ export async function sendMessage(
}
if (deliveryMode !== "gateway") {
const outboundProvider = provider as Exclude<OutboundProvider, "none">;
const outboundChannel = channel as Exclude<OutboundChannel, "none">;
const resolvedTarget = resolveOutboundTarget({
provider: outboundProvider,
channel: outboundChannel,
to: params.to,
cfg,
accountId: params.accountId,
@@ -140,7 +140,7 @@ export async function sendMessage(
const results = await deliverOutboundPayloads({
cfg,
provider: outboundProvider,
channel: outboundChannel,
to: resolvedTarget.to,
accountId: params.accountId,
payloads: [{ text: params.content, mediaUrl: params.mediaUrl }],
@@ -150,7 +150,7 @@ export async function sendMessage(
});
return {
provider,
channel,
to: params.to,
via: "direct",
mediaUrl: params.mediaUrl ?? null,
@@ -169,7 +169,7 @@ export async function sendMessage(
mediaUrl: params.mediaUrl,
gifPlayback: params.gifPlayback,
accountId: params.accountId,
provider,
channel,
idempotencyKey: params.idempotencyKey ?? randomIdempotencyKey(),
},
timeoutMs: gateway.timeoutMs,
@@ -179,7 +179,7 @@ export async function sendMessage(
});
return {
provider,
channel,
to: params.to,
via: "gateway",
mediaUrl: params.mediaUrl ?? null,
@@ -191,11 +191,11 @@ export async function sendPoll(
params: MessagePollParams,
): Promise<MessagePollResult> {
const cfg = params.cfg ?? loadConfig();
const provider = params.provider?.trim()
? normalizeProviderId(params.provider)
: (await resolveMessageProviderSelection({ cfg })).provider;
if (!provider) {
throw new Error(`Unknown provider: ${params.provider}`);
const channel = params.channel?.trim()
? normalizeChannelId(params.channel)
: (await resolveMessageChannelSelection({ cfg })).channel;
if (!channel) {
throw new Error(`Unknown channel: ${params.channel}`);
}
const pollInput: PollInput = {
@@ -204,10 +204,10 @@ export async function sendPoll(
maxSelections: params.maxSelections,
durationHours: params.durationHours,
};
const plugin = getProviderPlugin(provider as ProviderId);
const plugin = getChannelPlugin(channel as ChannelId);
const outbound = plugin?.outbound;
if (!outbound?.sendPoll) {
throw new Error(`Unsupported poll provider: ${provider}`);
throw new Error(`Unsupported poll channel: ${channel}`);
}
const normalized = outbound.pollMaxOptions
? normalizePollInput(pollInput, { maxOptions: outbound.pollMaxOptions })
@@ -215,7 +215,7 @@ export async function sendPoll(
if (params.dryRun) {
return {
provider,
channel,
to: params.to,
question: normalized.question,
options: normalized.options,
@@ -243,7 +243,7 @@ export async function sendPoll(
options: normalized.options,
maxSelections: normalized.maxSelections,
durationHours: normalized.durationHours,
provider,
channel,
idempotencyKey: params.idempotencyKey ?? randomIdempotencyKey(),
},
timeoutMs: gateway.timeoutMs,
@@ -253,7 +253,7 @@ export async function sendPoll(
});
return {
provider,
channel,
to: params.to,
question: normalized.question,
options: normalized.options,

View File

@@ -1,85 +0,0 @@
import type { ClawdbotConfig } from "../../config/config.js";
import { listProviderPlugins } from "../../providers/plugins/index.js";
import type { ProviderPlugin } from "../../providers/plugins/types.js";
import {
DELIVERABLE_MESSAGE_PROVIDERS,
type DeliverableMessageProvider,
normalizeMessageProvider,
} from "../../utils/message-provider.js";
export type MessageProviderId = DeliverableMessageProvider;
const MESSAGE_PROVIDERS = [...DELIVERABLE_MESSAGE_PROVIDERS];
function isKnownProvider(value: string): value is MessageProviderId {
return (MESSAGE_PROVIDERS as readonly string[]).includes(value);
}
function isAccountEnabled(account: unknown): boolean {
if (!account || typeof account !== "object") return true;
const enabled = (account as { enabled?: boolean }).enabled;
return enabled !== false;
}
async function isPluginConfigured(
plugin: ProviderPlugin,
cfg: ClawdbotConfig,
): Promise<boolean> {
const accountIds = plugin.config.listAccountIds(cfg);
if (accountIds.length === 0) return false;
for (const accountId of accountIds) {
const account = plugin.config.resolveAccount(cfg, accountId);
const enabled = plugin.config.isEnabled
? plugin.config.isEnabled(account, cfg)
: isAccountEnabled(account);
if (!enabled) continue;
if (!plugin.config.isConfigured) return true;
const configured = await plugin.config.isConfigured(account, cfg);
if (configured) return true;
}
return false;
}
export async function listConfiguredMessageProviders(
cfg: ClawdbotConfig,
): Promise<MessageProviderId[]> {
const providers: MessageProviderId[] = [];
for (const plugin of listProviderPlugins()) {
if (!isKnownProvider(plugin.id)) continue;
if (await isPluginConfigured(plugin, cfg)) {
providers.push(plugin.id);
}
}
return providers;
}
export async function resolveMessageProviderSelection(params: {
cfg: ClawdbotConfig;
provider?: string | null;
}): Promise<{ provider: MessageProviderId; configured: MessageProviderId[] }> {
const normalized = normalizeMessageProvider(params.provider);
if (normalized) {
if (!isKnownProvider(normalized)) {
throw new Error(`Unknown provider: ${normalized}`);
}
return {
provider: normalized,
configured: await listConfiguredMessageProviders(params.cfg),
};
}
const configured = await listConfiguredMessageProviders(params.cfg);
if (configured.length === 1) {
return { provider: configured[0], configured };
}
if (configured.length === 0) {
throw new Error("Provider is required (no configured providers detected).");
}
throw new Error(
`Provider is required when multiple providers are configured: ${configured.join(
", ",
)}`,
);
}

View File

@@ -5,9 +5,11 @@ import { resolveOutboundTarget } from "./targets.js";
describe("resolveOutboundTarget", () => {
it("falls back to whatsapp allowFrom via config", () => {
const cfg: ClawdbotConfig = { whatsapp: { allowFrom: ["+1555"] } };
const cfg: ClawdbotConfig = {
channels: { whatsapp: { allowFrom: ["+1555"] } },
};
const res = resolveOutboundTarget({
provider: "whatsapp",
channel: "whatsapp",
to: "",
cfg,
mode: "explicit",
@@ -18,31 +20,31 @@ describe("resolveOutboundTarget", () => {
it.each([
{
name: "normalizes whatsapp target when provided",
input: { provider: "whatsapp" as const, to: " (555) 123-4567 " },
input: { channel: "whatsapp" as const, to: " (555) 123-4567 " },
expected: { ok: true as const, to: "+5551234567" },
},
{
name: "keeps whatsapp group targets",
input: { provider: "whatsapp" as const, to: "120363401234567890@g.us" },
input: { channel: "whatsapp" as const, to: "120363401234567890@g.us" },
expected: { ok: true as const, to: "120363401234567890@g.us" },
},
{
name: "normalizes prefixed/uppercase whatsapp group targets",
input: {
provider: "whatsapp" as const,
channel: "whatsapp" as const,
to: " WhatsApp:Group:120363401234567890@G.US ",
},
expected: { ok: true as const, to: "120363401234567890@g.us" },
},
{
name: "falls back to whatsapp allowFrom",
input: { provider: "whatsapp" as const, to: "", allowFrom: ["+1555"] },
input: { channel: "whatsapp" as const, to: "", allowFrom: ["+1555"] },
expected: { ok: true as const, to: "+1555" },
},
{
name: "normalizes whatsapp allowFrom fallback targets",
input: {
provider: "whatsapp" as const,
channel: "whatsapp" as const,
to: "",
allowFrom: ["whatsapp:(555) 123-4567"],
},
@@ -50,17 +52,17 @@ describe("resolveOutboundTarget", () => {
},
{
name: "rejects invalid whatsapp target",
input: { provider: "whatsapp" as const, to: "wat" },
input: { channel: "whatsapp" as const, to: "wat" },
expectedErrorIncludes: "WhatsApp",
},
{
name: "rejects whatsapp without to when allowFrom missing",
input: { provider: "whatsapp" as const, to: " " },
input: { channel: "whatsapp" as const, to: " " },
expectedErrorIncludes: "WhatsApp",
},
{
name: "rejects whatsapp allowFrom fallback when invalid",
input: { provider: "whatsapp" as const, to: "", allowFrom: ["wat"] },
input: { channel: "whatsapp" as const, to: "", allowFrom: ["wat"] },
expectedErrorIncludes: "WhatsApp",
},
])("$name", ({ input, expected, expectedErrorIncludes }) => {
@@ -76,7 +78,7 @@ describe("resolveOutboundTarget", () => {
});
it("rejects telegram with missing target", () => {
const res = resolveOutboundTarget({ provider: "telegram", to: " " });
const res = resolveOutboundTarget({ channel: "telegram", to: " " });
expect(res.ok).toBe(false);
if (!res.ok) {
expect(res.error.message).toContain("Telegram");
@@ -84,7 +86,7 @@ describe("resolveOutboundTarget", () => {
});
it("rejects webchat delivery", () => {
const res = resolveOutboundTarget({ provider: "webchat", to: "x" });
const res = resolveOutboundTarget({ channel: "webchat", to: "x" });
expect(res.ok).toBe(false);
if (!res.ok) {
expect(res.error.message).toContain("WebChat");

View File

@@ -1,25 +1,25 @@
import {
getChannelPlugin,
normalizeChannelId,
} from "../../channels/plugins/index.js";
import type {
ChannelId,
ChannelOutboundTargetMode,
} from "../../channels/plugins/types.js";
import type { ClawdbotConfig } from "../../config/config.js";
import type { SessionEntry } from "../../config/sessions.js";
import {
getProviderPlugin,
normalizeProviderId,
} from "../../providers/plugins/index.js";
import type {
ProviderId,
ProviderOutboundTargetMode,
} from "../../providers/plugins/types.js";
import type {
DeliverableMessageProvider,
GatewayMessageProvider,
} from "../../utils/message-provider.js";
import { INTERNAL_MESSAGE_PROVIDER } from "../../utils/message-provider.js";
DeliverableMessageChannel,
GatewayMessageChannel,
} from "../../utils/message-channel.js";
import { INTERNAL_MESSAGE_CHANNEL } from "../../utils/message-channel.js";
export type OutboundProvider = DeliverableMessageProvider | "none";
export type OutboundChannel = DeliverableMessageChannel | "none";
export type HeartbeatTarget = OutboundProvider | "last";
export type HeartbeatTarget = OutboundChannel | "last";
export type OutboundTarget = {
provider: OutboundProvider;
channel: OutboundChannel;
to?: string;
reason?: string;
};
@@ -28,16 +28,16 @@ export type OutboundTargetResolution =
| { ok: true; to: string }
| { ok: false; error: Error };
// Provider docking: prefer plugin.outbound.resolveTarget + allowFrom to normalize destinations.
// Channel docking: prefer plugin.outbound.resolveTarget + allowFrom to normalize destinations.
export function resolveOutboundTarget(params: {
provider: GatewayMessageProvider;
channel: GatewayMessageChannel;
to?: string;
allowFrom?: string[];
cfg?: ClawdbotConfig;
accountId?: string | null;
mode?: ProviderOutboundTargetMode;
mode?: ChannelOutboundTargetMode;
}): OutboundTargetResolution {
if (params.provider === INTERNAL_MESSAGE_PROVIDER) {
if (params.channel === INTERNAL_MESSAGE_CHANNEL) {
return {
ok: false,
error: new Error(
@@ -46,11 +46,11 @@ export function resolveOutboundTarget(params: {
};
}
const plugin = getProviderPlugin(params.provider as ProviderId);
const plugin = getChannelPlugin(params.channel as ChannelId);
if (!plugin) {
return {
ok: false,
error: new Error(`Unsupported provider: ${params.provider}`),
error: new Error(`Unsupported channel: ${params.channel}`),
};
}
@@ -94,12 +94,12 @@ export function resolveHeartbeatDeliveryTarget(params: {
if (rawTarget === "none" || rawTarget === "last") {
target = rawTarget;
} else if (typeof rawTarget === "string") {
const normalized = normalizeProviderId(rawTarget);
const normalized = normalizeChannelId(rawTarget);
if (normalized) target = normalized;
}
if (target === "none") {
return { provider: "none", reason: "target-none" };
return { channel: "none", reason: "target-none" };
}
const explicitTo =
@@ -108,40 +108,39 @@ export function resolveHeartbeatDeliveryTarget(params: {
? cfg.agents.defaults.heartbeat.to.trim()
: undefined;
const lastProvider =
entry?.lastProvider && entry.lastProvider !== INTERNAL_MESSAGE_PROVIDER
? normalizeProviderId(entry.lastProvider)
const lastChannel =
entry?.lastChannel && entry.lastChannel !== INTERNAL_MESSAGE_CHANNEL
? normalizeChannelId(entry.lastChannel)
: undefined;
const lastTo = typeof entry?.lastTo === "string" ? entry.lastTo.trim() : "";
const provider = target === "last" ? lastProvider : target;
const channel = target === "last" ? lastChannel : target;
const to =
explicitTo ||
(provider && lastProvider === provider ? lastTo : undefined) ||
(channel && lastChannel === channel ? lastTo : undefined) ||
(target === "last" ? lastTo : undefined);
if (!provider || !to) {
return { provider: "none", reason: "no-target" };
if (!channel || !to) {
return { channel: "none", reason: "no-target" };
}
const accountId =
provider === lastProvider ? entry?.lastAccountId : undefined;
const accountId = channel === lastChannel ? entry?.lastAccountId : undefined;
const resolved = resolveOutboundTarget({
provider,
channel,
to,
cfg,
accountId,
mode: "heartbeat",
});
if (!resolved.ok) {
return { provider: "none", reason: "no-target" };
return { channel: "none", reason: "no-target" };
}
let reason: string | undefined;
const plugin = getProviderPlugin(provider as ProviderId);
const plugin = getChannelPlugin(channel as ChannelId);
if (plugin?.config.resolveAllowFrom) {
const explicit = resolveOutboundTarget({
provider,
channel,
to,
cfg,
accountId,
@@ -153,6 +152,6 @@ export function resolveHeartbeatDeliveryTarget(params: {
}
return reason
? { provider, to: resolved.to, reason }
: { provider, to: resolved.to };
? { channel, to: resolved.to, reason }
: { channel, to: resolved.to };
}