refactor!: rename chat providers to channels
This commit is contained in:
85
src/infra/outbound/channel-selection.ts
Normal file
85
src/infra/outbound/channel-selection.ts
Normal 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(
|
||||
", ",
|
||||
)}`,
|
||||
);
|
||||
}
|
||||
@@ -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" },
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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}`;
|
||||
}
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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(
|
||||
", ",
|
||||
)}`,
|
||||
);
|
||||
}
|
||||
@@ -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");
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user