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,17 @@
import { listChannelPlugins } from "../channels/plugins/index.js";
import type { ChannelAgentTool } from "../channels/plugins/types.js";
import type { ClawdbotConfig } from "../config/config.js";
export function listChannelAgentTools(params: {
cfg?: ClawdbotConfig;
}): ChannelAgentTool[] {
// Channel docking: aggregate channel-owned tools (login, etc.).
const tools: ChannelAgentTool[] = [];
for (const plugin of listChannelPlugins()) {
const entry = plugin.agentTools;
if (!entry) continue;
const resolved = typeof entry === "function" ? entry(params) : entry;
if (Array.isArray(resolved)) tools.push(...resolved);
}
return tools;
}

View File

@@ -73,14 +73,14 @@ describe("sessions tools", () => {
kind: "direct",
sessionId: "s-main",
updatedAt: 10,
lastProvider: "whatsapp",
lastChannel: "whatsapp",
},
{
key: "discord:group:dev",
kind: "group",
sessionId: "s-group",
updatedAt: 11,
provider: "discord",
channel: "discord",
displayName: "discord:g-dev",
},
{
@@ -120,7 +120,7 @@ describe("sessions tools", () => {
};
expect(details.sessions).toHaveLength(3);
const main = details.sessions?.find((s) => s.key === "main");
expect(main?.provider).toBe("whatsapp");
expect(main?.channel).toBe("whatsapp");
expect(main?.messages?.length).toBe(1);
expect(main?.messages?.[0]?.role).toBe("assistant");
@@ -233,7 +233,7 @@ describe("sessions tools", () => {
const tool = createClawdbotTools({
agentSessionKey: requesterKey,
agentProvider: "discord",
agentChannel: "discord",
}).find((candidate) => candidate.name === "sessions_send");
expect(tool).toBeDefined();
if (!tool) throw new Error("missing sessions_send tool");
@@ -275,7 +275,7 @@ describe("sessions tools", () => {
for (const call of agentCalls) {
expect(call.params).toMatchObject({
lane: "nested",
provider: "webchat",
channel: "webchat",
});
}
expect(
@@ -321,7 +321,7 @@ describe("sessions tools", () => {
const replyByRunId = new Map<string, string>();
const requesterKey = "discord:group:req";
const targetKey = "discord:group:target";
let sendParams: { to?: string; provider?: string; message?: string } = {};
let sendParams: { to?: string; channel?: string; message?: string } = {};
callGatewayMock.mockImplementation(async (opts: unknown) => {
const request = opts as { method?: string; params?: unknown };
calls.push(request);
@@ -371,11 +371,11 @@ describe("sessions tools", () => {
}
if (request.method === "send") {
const params = request.params as
| { to?: string; provider?: string; message?: string }
| { to?: string; channel?: string; message?: string }
| undefined;
sendParams = {
to: params?.to,
provider: params?.provider,
channel: params?.channel,
message: params?.message,
};
return { messageId: "m-announce" };
@@ -385,7 +385,7 @@ describe("sessions tools", () => {
const tool = createClawdbotTools({
agentSessionKey: requesterKey,
agentProvider: "discord",
agentChannel: "discord",
}).find((candidate) => candidate.name === "sessions_send");
expect(tool).toBeDefined();
if (!tool) throw new Error("missing sessions_send tool");
@@ -407,7 +407,7 @@ describe("sessions tools", () => {
for (const call of agentCalls) {
expect(call.params).toMatchObject({
lane: "nested",
provider: "webchat",
channel: "webchat",
});
}
@@ -423,7 +423,7 @@ describe("sessions tools", () => {
expect(replySteps).toHaveLength(2);
expect(sendParams).toMatchObject({
to: "channel:target",
provider: "discord",
channel: "discord",
message: "announce now",
});
});

View File

@@ -37,12 +37,12 @@ describe("subagents", () => {
};
});
it("sessions_spawn announces back to the requester group provider", async () => {
it("sessions_spawn announces back to the requester group channel", async () => {
resetSubagentRegistryForTests();
callGatewayMock.mockReset();
const calls: Array<{ method?: string; params?: unknown }> = [];
let agentCallCount = 0;
let sendParams: { to?: string; provider?: string; message?: string } = {};
let sendParams: { to?: string; channel?: string; message?: string } = {};
let deletedKey: string | undefined;
let childRunId: string | undefined;
let childSessionKey: string | undefined;
@@ -58,7 +58,7 @@ describe("subagents", () => {
const params = request.params as {
message?: string;
sessionKey?: string;
provider?: string;
channel?: string;
timeout?: number;
};
const message = params?.message ?? "";
@@ -69,7 +69,7 @@ describe("subagents", () => {
childRunId = runId;
childSessionKey = sessionKey;
sessionLastAssistantText.set(sessionKey, "result");
expect(params?.provider).toBe("discord");
expect(params?.channel).toBe("discord");
expect(params?.timeout).toBe(1);
}
return {
@@ -96,11 +96,11 @@ describe("subagents", () => {
}
if (request.method === "send") {
const params = request.params as
| { to?: string; provider?: string; message?: string }
| { to?: string; channel?: string; message?: string }
| undefined;
sendParams = {
to: params?.to,
provider: params?.provider,
channel: params?.channel,
message: params?.message,
};
return { messageId: "m-announce" };
@@ -115,7 +115,7 @@ describe("subagents", () => {
const tool = createClawdbotTools({
agentSessionKey: "discord:group:req",
agentProvider: "discord",
agentChannel: "discord",
}).find((candidate) => candidate.name === "sessions_spawn");
if (!tool) throw new Error("missing sessions_spawn tool");
@@ -153,22 +153,22 @@ describe("subagents", () => {
lane?: string;
deliver?: boolean;
sessionKey?: string;
provider?: string;
channel?: string;
}
| undefined;
expect(first?.lane).toBe("subagent");
expect(first?.deliver).toBe(false);
expect(first?.provider).toBe("discord");
expect(first?.channel).toBe("discord");
expect(first?.sessionKey?.startsWith("agent:main:subagent:")).toBe(true);
expect(childSessionKey?.startsWith("agent:main:subagent:")).toBe(true);
const second = agentCalls[1]?.params as
| { provider?: string; deliver?: boolean; lane?: string }
| { channel?: string; deliver?: boolean; lane?: string }
| undefined;
expect(second?.lane).toBe("nested");
expect(second?.deliver).toBe(false);
expect(second?.provider).toBe("webchat");
expect(second?.channel).toBe("webchat");
expect(sendParams.provider).toBe("discord");
expect(sendParams.channel).toBe("discord");
expect(sendParams.to).toBe("channel:req");
expect(sendParams.message ?? "").toContain("announce now");
expect(sendParams.message ?? "").toContain("Stats:");
@@ -180,7 +180,7 @@ describe("subagents", () => {
callGatewayMock.mockReset();
const calls: Array<{ method?: string; params?: unknown }> = [];
let agentCallCount = 0;
let sendParams: { to?: string; provider?: string; message?: string } = {};
let sendParams: { to?: string; channel?: string; message?: string } = {};
let deletedKey: string | undefined;
let childRunId: string | undefined;
let childSessionKey: string | undefined;
@@ -196,7 +196,7 @@ describe("subagents", () => {
const params = request.params as {
message?: string;
sessionKey?: string;
provider?: string;
channel?: string;
timeout?: number;
};
const message = params?.message ?? "";
@@ -207,7 +207,7 @@ describe("subagents", () => {
childRunId = runId;
childSessionKey = sessionKey;
sessionLastAssistantText.set(sessionKey, "result");
expect(params?.provider).toBe("discord");
expect(params?.channel).toBe("discord");
expect(params?.timeout).toBe(1);
}
return {
@@ -238,11 +238,11 @@ describe("subagents", () => {
}
if (request.method === "send") {
const params = request.params as
| { to?: string; provider?: string; message?: string }
| { to?: string; channel?: string; message?: string }
| undefined;
sendParams = {
to: params?.to,
provider: params?.provider,
channel: params?.channel,
message: params?.message,
};
return { messageId: "m-announce" };
@@ -257,7 +257,7 @@ describe("subagents", () => {
const tool = createClawdbotTools({
agentSessionKey: "discord:group:req",
agentProvider: "discord",
agentChannel: "discord",
}).find((candidate) => candidate.name === "sessions_spawn");
if (!tool) throw new Error("missing sessions_spawn tool");
@@ -282,13 +282,13 @@ describe("subagents", () => {
const agentCalls = calls.filter((call) => call.method === "agent");
expect(agentCalls).toHaveLength(2);
const second = agentCalls[1]?.params as
| { provider?: string; deliver?: boolean; lane?: string }
| { channel?: string; deliver?: boolean; lane?: string }
| undefined;
expect(second?.lane).toBe("nested");
expect(second?.deliver).toBe(false);
expect(second?.provider).toBe("webchat");
expect(second?.channel).toBe("webchat");
expect(sendParams.provider).toBe("discord");
expect(sendParams.channel).toBe("discord");
expect(sendParams.to).toBe("channel:req");
expect(sendParams.message ?? "").toContain("announce now");
expect(sendParams.message ?? "").toContain("Stats:");
@@ -300,7 +300,7 @@ describe("subagents", () => {
callGatewayMock.mockReset();
const calls: Array<{ method?: string; params?: unknown }> = [];
let agentCallCount = 0;
let sendParams: { to?: string; provider?: string; message?: string } = {};
let sendParams: { to?: string; channel?: string; message?: string } = {};
let childRunId: string | undefined;
let childSessionKey: string | undefined;
const waitCalls: Array<{ runId?: string; timeoutMs?: number }> = [];
@@ -314,7 +314,7 @@ describe("subagents", () => {
sessions: [
{
key: "main",
lastProvider: "whatsapp",
lastChannel: "whatsapp",
lastTo: "+123",
},
],
@@ -360,11 +360,11 @@ describe("subagents", () => {
}
if (request.method === "send") {
const params = request.params as
| { to?: string; provider?: string; message?: string }
| { to?: string; channel?: string; message?: string }
| undefined;
sendParams = {
to: params?.to,
provider: params?.provider,
channel: params?.channel,
message: params?.message,
};
return { messageId: "m1" };
@@ -377,7 +377,7 @@ describe("subagents", () => {
const tool = createClawdbotTools({
agentSessionKey: "main",
agentProvider: "whatsapp",
agentChannel: "whatsapp",
}).find((candidate) => candidate.name === "sessions_spawn");
if (!tool) throw new Error("missing sessions_spawn tool");
@@ -407,7 +407,7 @@ describe("subagents", () => {
const childWait = waitCalls.find((call) => call.runId === childRunId);
expect(childWait?.timeoutMs).toBe(1000);
expect(sendParams.provider).toBe("whatsapp");
expect(sendParams.channel).toBe("whatsapp");
expect(sendParams.to).toBe("+123");
expect(sendParams.message ?? "").toContain("hello from sub");
expect(sendParams.message ?? "").toContain("Stats:");
@@ -420,7 +420,7 @@ describe("subagents", () => {
const tool = createClawdbotTools({
agentSessionKey: "main",
agentProvider: "whatsapp",
agentChannel: "whatsapp",
}).find((candidate) => candidate.name === "sessions_spawn");
if (!tool) throw new Error("missing sessions_spawn tool");
@@ -470,7 +470,7 @@ describe("subagents", () => {
const tool = createClawdbotTools({
agentSessionKey: "main",
agentProvider: "whatsapp",
agentChannel: "whatsapp",
}).find((candidate) => candidate.name === "sessions_spawn");
if (!tool) throw new Error("missing sessions_spawn tool");
@@ -522,7 +522,7 @@ describe("subagents", () => {
const tool = createClawdbotTools({
agentSessionKey: "main",
agentProvider: "whatsapp",
agentChannel: "whatsapp",
}).find((candidate) => candidate.name === "sessions_spawn");
if (!tool) throw new Error("missing sessions_spawn tool");
@@ -574,7 +574,7 @@ describe("subagents", () => {
const tool = createClawdbotTools({
agentSessionKey: "main",
agentProvider: "whatsapp",
agentChannel: "whatsapp",
}).find((candidate) => candidate.name === "sessions_spawn");
if (!tool) throw new Error("missing sessions_spawn tool");
@@ -612,7 +612,7 @@ describe("subagents", () => {
const tool = createClawdbotTools({
agentSessionKey: "main",
agentProvider: "whatsapp",
agentChannel: "whatsapp",
}).find((candidate) => candidate.name === "sessions_spawn");
if (!tool) throw new Error("missing sessions_spawn tool");
@@ -710,7 +710,7 @@ describe("subagents", () => {
const tool = createClawdbotTools({
agentSessionKey: "agent:main:main",
agentProvider: "discord",
agentChannel: "discord",
}).find((candidate) => candidate.name === "sessions_spawn");
if (!tool) throw new Error("missing sessions_spawn tool");
@@ -754,7 +754,7 @@ describe("subagents", () => {
const tool = createClawdbotTools({
agentSessionKey: "agent:research:main",
agentProvider: "discord",
agentChannel: "discord",
}).find((candidate) => candidate.name === "sessions_spawn");
if (!tool) throw new Error("missing sessions_spawn tool");
@@ -804,7 +804,7 @@ describe("subagents", () => {
const tool = createClawdbotTools({
agentSessionKey: "main",
agentProvider: "whatsapp",
agentChannel: "whatsapp",
}).find((candidate) => candidate.name === "sessions_spawn");
if (!tool) throw new Error("missing sessions_spawn tool");
@@ -840,7 +840,7 @@ describe("subagents", () => {
const tool = createClawdbotTools({
agentSessionKey: "main",
agentProvider: "whatsapp",
agentChannel: "whatsapp",
}).find((candidate) => candidate.name === "sessions_spawn");
if (!tool) throw new Error("missing sessions_spawn tool");

View File

@@ -1,6 +1,6 @@
import type { ClawdbotConfig } from "../config/config.js";
import { resolvePluginTools } from "../plugins/tools.js";
import type { GatewayMessageProvider } from "../utils/message-provider.js";
import type { GatewayMessageChannel } from "../utils/message-channel.js";
import { resolveSessionAgentId } from "./agent-scope.js";
import { createAgentsListTool } from "./tools/agents-list-tool.js";
import { createBrowserTool } from "./tools/browser-tool.js";
@@ -28,7 +28,7 @@ export function createClawdbotTools(options?: {
allowedControlHosts?: string[];
allowedControlPorts?: number[];
agentSessionKey?: string;
agentProvider?: GatewayMessageProvider;
agentChannel?: GatewayMessageChannel;
agentAccountId?: string;
agentDir?: string;
sandboxRoot?: string;
@@ -93,12 +93,12 @@ export function createClawdbotTools(options?: {
}),
createSessionsSendTool({
agentSessionKey: options?.agentSessionKey,
agentProvider: options?.agentProvider,
agentChannel: options?.agentChannel,
sandboxed: options?.sandboxed,
}),
createSessionsSpawnTool({
agentSessionKey: options?.agentSessionKey,
agentProvider: options?.agentProvider,
agentChannel: options?.agentChannel,
sandboxed: options?.sandboxed,
}),
createSessionStatusTool({
@@ -121,7 +121,7 @@ export function createClawdbotTools(options?: {
config: options?.config,
}),
sessionKey: options?.agentSessionKey,
messageProvider: options?.agentProvider,
messageChannel: options?.agentChannel,
agentAccountId: options?.agentAccountId,
sandboxed: options?.sandboxed,
},

View File

@@ -1,7 +1,7 @@
import {
getProviderPlugin,
normalizeProviderId,
} from "../providers/plugins/index.js";
getChannelPlugin,
normalizeChannelId,
} from "../channels/plugins/index.js";
export type MessagingToolSend = {
tool: string;
@@ -15,8 +15,8 @@ const CORE_MESSAGING_TOOLS = new Set(["sessions_send", "message"]);
// Provider docking: any plugin with `actions` opts into messaging tool handling.
export function isMessagingTool(toolName: string): boolean {
if (CORE_MESSAGING_TOOLS.has(toolName)) return true;
const providerId = normalizeProviderId(toolName);
return Boolean(providerId && getProviderPlugin(providerId)?.actions);
const providerId = normalizeChannelId(toolName);
return Boolean(providerId && getChannelPlugin(providerId)?.actions);
}
export function isMessagingToolSendAction(
@@ -28,9 +28,9 @@ export function isMessagingToolSendAction(
if (toolName === "message") {
return action === "send" || action === "thread-reply";
}
const providerId = normalizeProviderId(toolName);
const providerId = normalizeChannelId(toolName);
if (!providerId) return false;
const plugin = getProviderPlugin(providerId);
const plugin = getChannelPlugin(providerId);
if (!plugin?.actions?.extractToolSend) return false;
return Boolean(plugin.actions.extractToolSend({ args })?.to);
}
@@ -40,8 +40,8 @@ export function normalizeTargetForProvider(
raw?: string,
): string | undefined {
if (!raw) return undefined;
const providerId = normalizeProviderId(provider);
const plugin = providerId ? getProviderPlugin(providerId) : undefined;
const providerId = normalizeChannelId(provider);
const plugin = providerId ? getChannelPlugin(providerId) : undefined;
const normalized =
plugin?.messaging?.normalizeTarget?.(raw) ??
(raw.trim().toLowerCase() || undefined);

View File

@@ -478,17 +478,23 @@ describe("getDmHistoryLimitFromSessionKey", () => {
});
it("returns dmHistoryLimit for telegram provider", () => {
const config = { telegram: { dmHistoryLimit: 15 } } as ClawdbotConfig;
const config = {
channels: { telegram: { dmHistoryLimit: 15 } },
} as ClawdbotConfig;
expect(getDmHistoryLimitFromSessionKey("telegram:dm:123", config)).toBe(15);
});
it("returns dmHistoryLimit for whatsapp provider", () => {
const config = { whatsapp: { dmHistoryLimit: 20 } } as ClawdbotConfig;
const config = {
channels: { whatsapp: { dmHistoryLimit: 20 } },
} as ClawdbotConfig;
expect(getDmHistoryLimitFromSessionKey("whatsapp:dm:123", config)).toBe(20);
});
it("returns dmHistoryLimit for agent-prefixed session keys", () => {
const config = { telegram: { dmHistoryLimit: 10 } } as ClawdbotConfig;
const config = {
channels: { telegram: { dmHistoryLimit: 10 } },
} as ClawdbotConfig;
expect(
getDmHistoryLimitFromSessionKey("agent:main:telegram:dm:123", config),
).toBe(10);
@@ -496,8 +502,10 @@ describe("getDmHistoryLimitFromSessionKey", () => {
it("returns undefined for non-dm session kinds", () => {
const config = {
slack: { dmHistoryLimit: 10 },
telegram: { dmHistoryLimit: 15 },
channels: {
telegram: { dmHistoryLimit: 15 },
slack: { dmHistoryLimit: 10 },
},
} as ClawdbotConfig;
expect(
getDmHistoryLimitFromSessionKey("agent:beta:slack:channel:C1", config),
@@ -508,14 +516,16 @@ describe("getDmHistoryLimitFromSessionKey", () => {
});
it("returns undefined for unknown provider", () => {
const config = { telegram: { dmHistoryLimit: 15 } } as ClawdbotConfig;
const config = {
channels: { telegram: { dmHistoryLimit: 15 } },
} as ClawdbotConfig;
expect(
getDmHistoryLimitFromSessionKey("unknown:dm:123", config),
).toBeUndefined();
});
it("returns undefined when provider config has no dmHistoryLimit", () => {
const config = { telegram: {} } as ClawdbotConfig;
const config = { channels: { telegram: {} } } as ClawdbotConfig;
expect(
getDmHistoryLimitFromSessionKey("telegram:dm:123", config),
).toBeUndefined();
@@ -533,7 +543,9 @@ describe("getDmHistoryLimitFromSessionKey", () => {
] as const;
for (const provider of providers) {
const config = { [provider]: { dmHistoryLimit: 5 } } as ClawdbotConfig;
const config = {
channels: { [provider]: { dmHistoryLimit: 5 } },
} as ClawdbotConfig;
expect(
getDmHistoryLimitFromSessionKey(`${provider}:dm:123`, config),
).toBe(5);
@@ -554,9 +566,11 @@ describe("getDmHistoryLimitFromSessionKey", () => {
for (const provider of providers) {
// Test per-DM override takes precedence
const configWithOverride = {
[provider]: {
dmHistoryLimit: 20,
dms: { user123: { historyLimit: 7 } },
channels: {
[provider]: {
dmHistoryLimit: 20,
dms: { user123: { historyLimit: 7 } },
},
},
} as ClawdbotConfig;
expect(
@@ -586,9 +600,11 @@ describe("getDmHistoryLimitFromSessionKey", () => {
it("returns per-DM override when set", () => {
const config = {
telegram: {
dmHistoryLimit: 15,
dms: { "123": { historyLimit: 5 } },
channels: {
telegram: {
dmHistoryLimit: 15,
dms: { "123": { historyLimit: 5 } },
},
},
} as ClawdbotConfig;
expect(getDmHistoryLimitFromSessionKey("telegram:dm:123", config)).toBe(5);
@@ -596,9 +612,11 @@ describe("getDmHistoryLimitFromSessionKey", () => {
it("falls back to provider default when per-DM not set", () => {
const config = {
telegram: {
dmHistoryLimit: 15,
dms: { "456": { historyLimit: 5 } },
channels: {
telegram: {
dmHistoryLimit: 15,
dms: { "456": { historyLimit: 5 } },
},
},
} as ClawdbotConfig;
expect(getDmHistoryLimitFromSessionKey("telegram:dm:123", config)).toBe(15);
@@ -606,9 +624,11 @@ describe("getDmHistoryLimitFromSessionKey", () => {
it("returns per-DM override for agent-prefixed keys", () => {
const config = {
telegram: {
dmHistoryLimit: 20,
dms: { "789": { historyLimit: 3 } },
channels: {
telegram: {
dmHistoryLimit: 20,
dms: { "789": { historyLimit: 3 } },
},
},
} as ClawdbotConfig;
expect(
@@ -618,9 +638,11 @@ describe("getDmHistoryLimitFromSessionKey", () => {
it("handles userId with colons (e.g., email)", () => {
const config = {
msteams: {
dmHistoryLimit: 10,
dms: { "user@example.com": { historyLimit: 7 } },
channels: {
msteams: {
dmHistoryLimit: 10,
dms: { "user@example.com": { historyLimit: 7 } },
},
},
} as ClawdbotConfig;
expect(
@@ -630,8 +652,10 @@ describe("getDmHistoryLimitFromSessionKey", () => {
it("returns undefined when per-DM historyLimit is not set", () => {
const config = {
telegram: {
dms: { "123": {} },
channels: {
telegram: {
dms: { "123": {} },
},
},
} as ClawdbotConfig;
expect(
@@ -641,9 +665,11 @@ describe("getDmHistoryLimitFromSessionKey", () => {
it("returns 0 when per-DM historyLimit is explicitly 0 (unlimited)", () => {
const config = {
telegram: {
dmHistoryLimit: 15,
dms: { "123": { historyLimit: 0 } },
channels: {
telegram: {
dmHistoryLimit: 15,
dms: { "123": { historyLimit: 0 } },
},
},
} as ClawdbotConfig;
expect(getDmHistoryLimitFromSessionKey("telegram:dm:123", config)).toBe(0);

View File

@@ -33,8 +33,8 @@ import type {
} from "../auto-reply/thinking.js";
import { formatToolAggregate } from "../auto-reply/tool-meta.js";
import { isCacheEnabled, resolveCacheTtlMs } from "../config/cache-utils.js";
import { resolveChannelCapabilities } from "../config/channel-capabilities.js";
import type { ClawdbotConfig } from "../config/config.js";
import { resolveProviderCapabilities } from "../config/provider-capabilities.js";
import { getMachineDisplayName } from "../infra/machine-name.js";
import { registerUnhandledRejectionHandler } from "../infra/unhandled-rejections.js";
import { createSubsystemLogger } from "../logging.js";
@@ -42,7 +42,7 @@ import {
type enqueueCommand,
enqueueCommandInLane,
} from "../process/command-queue.js";
import { normalizeMessageProvider } from "../utils/message-provider.js";
import { normalizeMessageChannel } from "../utils/message-channel.js";
import { isReasoningTagProvider } from "../utils/provider-utils.js";
import { resolveUserPath } from "../utils.js";
import { resolveClawdbotAgentDir } from "./agent-paths.js";
@@ -690,19 +690,19 @@ export function getDmHistoryLimitFromSessionKey(
// Map provider to config key
switch (provider) {
case "telegram":
return getLimit(config.telegram);
return getLimit(config.channels?.telegram);
case "whatsapp":
return getLimit(config.whatsapp);
return getLimit(config.channels?.whatsapp);
case "discord":
return getLimit(config.discord);
return getLimit(config.channels?.discord);
case "slack":
return getLimit(config.slack);
return getLimit(config.channels?.slack);
case "signal":
return getLimit(config.signal);
return getLimit(config.channels?.signal);
case "imessage":
return getLimit(config.imessage);
return getLimit(config.channels?.imessage);
case "msteams":
return getLimit(config.msteams);
return getLimit(config.channels?.msteams);
default:
return undefined;
}
@@ -1125,6 +1125,7 @@ function resolveModel(
export async function compactEmbeddedPiSession(params: {
sessionId: string;
sessionKey?: string;
messageChannel?: string;
messageProvider?: string;
agentAccountId?: string;
sessionFile: string;
@@ -1258,7 +1259,7 @@ export async function compactEmbeddedPiSession(params: {
elevated: params.bashElevated,
},
sandbox,
messageProvider: params.messageProvider,
messageProvider: params.messageChannel ?? params.messageProvider,
agentAccountId: params.agentAccountId,
sessionKey: params.sessionKey ?? params.sessionId,
agentDir,
@@ -1272,13 +1273,13 @@ export async function compactEmbeddedPiSession(params: {
});
logToolSchemasForGoogle({ tools, provider });
const machineName = await getMachineDisplayName();
const runtimeProvider = normalizeMessageProvider(
params.messageProvider,
const runtimeChannel = normalizeMessageChannel(
params.messageChannel ?? params.messageProvider,
);
const runtimeCapabilities = runtimeProvider
? (resolveProviderCapabilities({
const runtimeCapabilities = runtimeChannel
? (resolveChannelCapabilities({
cfg: params.config,
provider: runtimeProvider,
channel: runtimeChannel,
accountId: params.agentAccountId,
}) ?? [])
: undefined;
@@ -1288,7 +1289,7 @@ export async function compactEmbeddedPiSession(params: {
arch: os.arch(),
node: process.version,
model: `${provider}/${modelId}`,
provider: runtimeProvider,
channel: runtimeChannel,
capabilities: runtimeCapabilities,
};
const sandboxInfo = buildEmbeddedSandboxInfo(
@@ -1443,6 +1444,7 @@ export async function compactEmbeddedPiSession(params: {
export async function runEmbeddedPiAgent(params: {
sessionId: string;
sessionKey?: string;
messageChannel?: string;
messageProvider?: string;
agentAccountId?: string;
/** Current channel ID for auto-threading (Slack). */
@@ -1639,7 +1641,7 @@ export async function runEmbeddedPiAgent(params: {
attemptedThinking.add(thinkLevel);
log.debug(
`embedded run start: runId=${params.runId} sessionId=${params.sessionId} provider=${provider} model=${modelId} thinking=${thinkLevel} messageProvider=${params.messageProvider ?? "unknown"}`,
`embedded run start: runId=${params.runId} sessionId=${params.sessionId} provider=${provider} model=${modelId} thinking=${thinkLevel} messageChannel=${params.messageChannel ?? params.messageProvider ?? "unknown"}`,
);
await fs.mkdir(resolvedWorkspace, { recursive: true });
@@ -1698,7 +1700,7 @@ export async function runEmbeddedPiAgent(params: {
elevated: params.bashElevated,
},
sandbox,
messageProvider: params.messageProvider,
messageProvider: params.messageChannel ?? params.messageProvider,
agentAccountId: params.agentAccountId,
sessionKey: params.sessionKey ?? params.sessionId,
agentDir,

View File

@@ -6,13 +6,13 @@ import type { AgentSession } from "@mariozechner/pi-coding-agent";
import { parseReplyDirectives } from "../auto-reply/reply/reply-directives.js";
import type { ReasoningLevel } from "../auto-reply/thinking.js";
import { formatToolAggregate } from "../auto-reply/tool-meta.js";
import {
getChannelPlugin,
normalizeChannelId,
} from "../channels/plugins/index.js";
import { resolveStateDir } from "../config/paths.js";
import { emitAgentEvent } from "../infra/agent-events.js";
import { createSubsystemLogger } from "../logging.js";
import {
getProviderPlugin,
normalizeProviderId,
} from "../providers/plugins/index.js";
import { truncateUtf16Safe } from "../utils.js";
import type { BlockReplyChunking } from "./pi-embedded-block-chunker.js";
import { EmbeddedBlockChunker } from "./pi-embedded-block-chunker.js";
@@ -124,15 +124,15 @@ function extractMessagingToolSend(
if (!toRaw) return undefined;
const providerRaw =
typeof args.provider === "string" ? args.provider.trim() : "";
const providerId = providerRaw ? normalizeProviderId(providerRaw) : null;
const providerId = providerRaw ? normalizeChannelId(providerRaw) : null;
const provider =
providerId ?? (providerRaw ? providerRaw.toLowerCase() : "message");
const to = normalizeTargetForProvider(provider, toRaw);
return to ? { tool: toolName, provider, accountId, to } : undefined;
}
const providerId = normalizeProviderId(toolName);
const providerId = normalizeChannelId(toolName);
if (!providerId) return undefined;
const plugin = getProviderPlugin(providerId);
const plugin = getChannelPlugin(providerId);
const extracted = plugin?.actions?.extractToolSend?.({ args });
if (!extracted?.to) return undefined;
const to = normalizeTargetForProvider(providerId, extracted.to);

View File

@@ -9,7 +9,7 @@ import {
import type { ClawdbotConfig } from "../config/config.js";
import { detectMime } from "../media/mime.js";
import { isSubagentSessionKey } from "../routing/session-key.js";
import { resolveGatewayMessageProvider } from "../utils/message-provider.js";
import { resolveGatewayMessageChannel } from "../utils/message-channel.js";
import {
resolveAgentConfig,
resolveAgentIdFromSessionKey,
@@ -21,9 +21,9 @@ import {
type ExecToolDefaults,
type ProcessToolDefaults,
} from "./bash-tools.js";
import { listChannelAgentTools } from "./channel-tools.js";
import { createClawdbotTools } from "./clawdbot-tools.js";
import type { ModelAuthMode } from "./model-auth.js";
import { listProviderAgentTools } from "./provider-tools.js";
import type { SandboxContext, SandboxToolPolicy } from "./sandbox.js";
import { assertSandboxPath } from "./sandbox-paths.js";
import { cleanSchemaForGemini } from "./schema/clean-for-gemini.js";
@@ -807,8 +807,8 @@ export function createClawdbotCodingTools(options?: {
execTool as unknown as AnyAgentTool,
bashTool,
processTool as unknown as AnyAgentTool,
// Provider docking: include provider-defined agent tools (login, etc.).
...listProviderAgentTools({ cfg: options?.config }),
// Channel docking: include channel-defined agent tools (login, etc.).
...listChannelAgentTools({ cfg: options?.config }),
...createClawdbotTools({
browserControlUrl: sandbox?.browser?.controlUrl,
allowHostBrowserControl: sandbox ? sandbox.browserAllowHostControl : true,
@@ -816,7 +816,7 @@ export function createClawdbotCodingTools(options?: {
allowedControlHosts: sandbox?.browserAllowedControlHosts,
allowedControlPorts: sandbox?.browserAllowedControlPorts,
agentSessionKey: options?.sessionKey,
agentProvider: resolveGatewayMessageProvider(options?.messageProvider),
agentChannel: resolveGatewayMessageChannel(options?.messageProvider),
agentAccountId: options?.agentAccountId,
agentDir: options?.agentDir,
sandboxRoot,

View File

@@ -1,17 +0,0 @@
import type { ClawdbotConfig } from "../config/config.js";
import { listProviderPlugins } from "../providers/plugins/index.js";
import type { ProviderAgentTool } from "../providers/plugins/types.js";
export function listProviderAgentTools(params: {
cfg?: ClawdbotConfig;
}): ProviderAgentTool[] {
// Provider docking: aggregate provider-owned tools (login, etc.).
const tools: ProviderAgentTool[] = [];
for (const plugin of listProviderPlugins()) {
const entry = plugin.agentTools;
if (!entry) continue;
const resolved = typeof entry === "function" ? entry(params) : entry;
if (Array.isArray(resolved)) tools.push(...resolved);
}
return tools;
}

View File

@@ -14,6 +14,7 @@ import {
resolveProfile,
} from "../browser/config.js";
import { DEFAULT_CLAWD_BROWSER_COLOR } from "../browser/constants.js";
import { CHANNEL_IDS } from "../channels/registry.js";
import {
type ClawdbotConfig,
loadConfig,
@@ -23,7 +24,6 @@ import {
canonicalizeMainSessionAlias,
resolveAgentMainSessionKey,
} from "../config/sessions.js";
import { PROVIDER_IDS } from "../providers/registry.js";
import { normalizeAgentId } from "../routing/session-key.js";
import { defaultRuntime } from "../runtime.js";
import { resolveUserPath } from "../utils.js";
@@ -188,7 +188,7 @@ const DEFAULT_TOOL_DENY = [
"nodes",
"cron",
"gateway",
...PROVIDER_IDS,
...CHANNEL_IDS,
];
export const DEFAULT_SANDBOX_BROWSER_IMAGE =
"clawdbot-sandbox-browser:bookworm-slim";

View File

@@ -8,7 +8,7 @@ import {
resolveStorePath,
} from "../config/sessions.js";
import { callGateway } from "../gateway/call.js";
import { INTERNAL_MESSAGE_PROVIDER } from "../utils/message-provider.js";
import { INTERNAL_MESSAGE_CHANNEL } from "../utils/message-channel.js";
import { AGENT_LANE_NESTED } from "./lanes.js";
import { readLatestAssistantReply, runAgentStep } from "./tools/agent-step.js";
import { resolveAnnounceTarget } from "./tools/sessions-announce-target.js";
@@ -139,7 +139,7 @@ async function buildSubagentStatsLine(params: {
export function buildSubagentSystemPrompt(params: {
requesterSessionKey?: string;
requesterProvider?: string;
requesterChannel?: string;
childSessionKey: string;
label?: string;
task?: string;
@@ -182,8 +182,8 @@ export function buildSubagentSystemPrompt(params: {
params.requesterSessionKey
? `- Requester session: ${params.requesterSessionKey}.`
: undefined,
params.requesterProvider
? `- Requester provider: ${params.requesterProvider}.`
params.requesterChannel
? `- Requester channel: ${params.requesterChannel}.`
: undefined,
`- Your session: ${params.childSessionKey}.`,
"",
@@ -195,7 +195,7 @@ export function buildSubagentSystemPrompt(params: {
function buildSubagentAnnouncePrompt(params: {
requesterSessionKey?: string;
requesterProvider?: string;
requesterChannel?: string;
announceChannel: string;
task: string;
subagentReply?: string;
@@ -205,10 +205,10 @@ function buildSubagentAnnouncePrompt(params: {
params.requesterSessionKey
? `Requester session: ${params.requesterSessionKey}.`
: undefined,
params.requesterProvider
? `Requester provider: ${params.requesterProvider}.`
params.requesterChannel
? `Requester channel: ${params.requesterChannel}.`
: undefined,
`Post target provider: ${params.announceChannel}.`,
`Post target channel: ${params.announceChannel}.`,
`Original task: ${params.task}`,
params.subagentReply
? `Sub-agent result: ${params.subagentReply}`
@@ -226,7 +226,7 @@ export async function runSubagentAnnounceFlow(params: {
childSessionKey: string;
childRunId: string;
requesterSessionKey: string;
requesterProvider?: string;
requesterChannel?: string;
requesterDisplayKey: string;
task: string;
timeoutMs: number;
@@ -269,8 +269,8 @@ export async function runSubagentAnnounceFlow(params: {
const announcePrompt = buildSubagentAnnouncePrompt({
requesterSessionKey: params.requesterSessionKey,
requesterProvider: params.requesterProvider,
announceChannel: announceTarget.provider,
requesterChannel: params.requesterChannel,
announceChannel: announceTarget.channel,
task: params.task,
subagentReply: reply,
});
@@ -280,7 +280,7 @@ export async function runSubagentAnnounceFlow(params: {
message: "Sub-agent announce step.",
extraSystemPrompt: announcePrompt,
timeoutMs: params.timeoutMs,
provider: INTERNAL_MESSAGE_PROVIDER,
channel: INTERNAL_MESSAGE_CHANNEL,
lane: AGENT_LANE_NESTED,
});
@@ -305,7 +305,7 @@ export async function runSubagentAnnounceFlow(params: {
params: {
to: announceTarget.to,
message,
provider: announceTarget.provider,
channel: announceTarget.channel,
accountId: announceTarget.accountId,
idempotencyKey: crypto.randomUUID(),
},

View File

@@ -8,7 +8,7 @@ export type SubagentRunRecord = {
runId: string;
childSessionKey: string;
requesterSessionKey: string;
requesterProvider?: string;
requesterChannel?: string;
requesterDisplayKey: string;
task: string;
cleanup: "delete" | "keep";
@@ -105,7 +105,7 @@ function ensureListener() {
childSessionKey: entry.childSessionKey,
childRunId: entry.runId,
requesterSessionKey: entry.requesterSessionKey,
requesterProvider: entry.requesterProvider,
requesterChannel: entry.requesterChannel,
requesterDisplayKey: entry.requesterDisplayKey,
task: entry.task,
timeoutMs: 30_000,
@@ -133,7 +133,7 @@ export function registerSubagentRun(params: {
runId: string;
childSessionKey: string;
requesterSessionKey: string;
requesterProvider?: string;
requesterChannel?: string;
requesterDisplayKey: string;
task: string;
cleanup: "delete" | "keep";
@@ -152,7 +152,7 @@ export function registerSubagentRun(params: {
runId: params.runId,
childSessionKey: params.childSessionKey,
requesterSessionKey: params.requesterSessionKey,
requesterProvider: params.requesterProvider,
requesterChannel: params.requesterChannel,
requesterDisplayKey: params.requesterDisplayKey,
task: params.task,
cleanup: params.cleanup,
@@ -191,7 +191,7 @@ async function waitForSubagentCompletion(runId: string, waitTimeoutMs: number) {
childSessionKey: entry.childSessionKey,
childRunId: entry.runId,
requesterSessionKey: entry.requesterSessionKey,
requesterProvider: entry.requesterProvider,
requesterChannel: entry.requesterChannel,
requesterDisplayKey: entry.requesterDisplayKey,
task: entry.task,
timeoutMs: 30_000,

View File

@@ -153,7 +153,7 @@ describe("buildAgentSystemPrompt", () => {
toolNames: ["message"],
});
expect(prompt).toContain("message: Send messages and provider actions");
expect(prompt).toContain("message: Send messages and channel actions");
expect(prompt).toContain("### message tool");
});
@@ -161,12 +161,12 @@ describe("buildAgentSystemPrompt", () => {
const prompt = buildAgentSystemPrompt({
workspaceDir: "/tmp/clawd",
runtimeInfo: {
provider: "telegram",
channel: "telegram",
capabilities: ["inlineButtons"],
},
});
expect(prompt).toContain("provider=telegram");
expect(prompt).toContain("channel=telegram");
expect(prompt).toContain("capabilities=inlineButtons");
});

View File

@@ -1,9 +1,9 @@
import type { ReasoningLevel, ThinkLevel } from "../auto-reply/thinking.js";
import { SILENT_REPLY_TOKEN } from "../auto-reply/tokens.js";
import { PROVIDER_IDS } from "../providers/registry.js";
import { CHANNEL_IDS } from "../channels/registry.js";
import type { EmbeddedContextFile } from "./pi-embedded-helpers.js";
const MESSAGE_PROVIDER_OPTIONS = PROVIDER_IDS.join("|");
const MESSAGE_CHANNEL_OPTIONS = CHANNEL_IDS.join("|");
export function buildAgentSystemPrompt(params: {
workspaceDir: string;
@@ -26,7 +26,7 @@ export function buildAgentSystemPrompt(params: {
arch?: string;
node?: string;
model?: string;
provider?: string;
channel?: string;
capabilities?: string[];
};
sandboxInfo?: {
@@ -56,12 +56,12 @@ export function buildAgentSystemPrompt(params: {
ls: "List directory contents",
exec: "Run shell commands",
process: "Manage background exec sessions",
// Provider docking: add provider login tools here when a provider needs interactive linking.
// Channel docking: add login tools here when a channel needs interactive linking.
browser: "Control web browser",
canvas: "Present/eval/snapshot the Canvas",
nodes: "List/describe/notify/camera/screen on paired nodes",
cron: "Manage cron jobs and wake events (use for reminders)",
message: "Send messages and provider actions",
message: "Send messages and channel actions",
gateway:
"Restart, apply config, or run updates on the running Clawdbot process",
agents_list: "List agent ids allowed for sessions_spawn",
@@ -166,7 +166,7 @@ export function buildAgentSystemPrompt(params: {
? `Heartbeat prompt: ${heartbeatPrompt}`
: "Heartbeat prompt: (configured)";
const runtimeInfo = params.runtimeInfo;
const runtimeProvider = runtimeInfo?.provider?.trim().toLowerCase();
const runtimeChannel = runtimeInfo?.channel?.trim().toLowerCase();
const runtimeCapabilities = (runtimeInfo?.capabilities ?? [])
.map((cap) => String(cap).trim())
.filter(Boolean);
@@ -322,23 +322,23 @@ export function buildAgentSystemPrompt(params: {
"- [[reply_to_current]] replies to the triggering message.",
"- [[reply_to:<id>]] replies to a specific message id when you have it.",
"Whitespace inside the tag is allowed (e.g. [[ reply_to_current ]] / [[ reply_to: 123 ]]).",
"Tags are stripped before sending; support depends on the current provider config.",
"Tags are stripped before sending; support depends on the current channel config.",
"",
"## Messaging",
"- Reply in current session → automatically routes to the source provider (Signal, Telegram, etc.)",
"- Reply in current session → automatically routes to the source channel (Signal, Telegram, etc.)",
"- Cross-session messaging → use sessions_send(sessionKey, message)",
"- Never use exec/curl for provider messaging; Clawdbot handles all routing internally.",
availableTools.has("message")
? [
"",
"### message tool",
"- Use `message` for proactive sends + provider actions (polls, reactions, etc.).",
"- Use `message` for proactive sends + channel actions (polls, reactions, etc.).",
"- For `action=send`, include `to` and `message`.",
`- If multiple providers are configured, pass \`provider\` (${MESSAGE_PROVIDER_OPTIONS}).`,
`- If multiple channels are configured, pass \`channel\` (${MESSAGE_CHANNEL_OPTIONS}).`,
inlineButtonsEnabled
? "- Inline buttons supported. Use `action=send` with `buttons=[[{text,callback_data}]]` (callback_data routes back as a user message)."
: runtimeProvider
? `- Inline buttons not enabled for ${runtimeProvider}. If you need them, ask to add "inlineButtons" to ${runtimeProvider}.capabilities or ${runtimeProvider}.accounts.<id>.capabilities.`
: runtimeChannel
? `- Inline buttons not enabled for ${runtimeChannel}. If you need them, ask to add "inlineButtons" to ${runtimeChannel}.capabilities or ${runtimeChannel}.accounts.<id>.capabilities.`
: "",
]
.filter(Boolean)
@@ -397,8 +397,8 @@ export function buildAgentSystemPrompt(params: {
: "",
runtimeInfo?.node ? `node=${runtimeInfo.node}` : "",
runtimeInfo?.model ? `model=${runtimeInfo.model}` : "",
runtimeProvider ? `provider=${runtimeProvider}` : "",
runtimeProvider
runtimeChannel ? `channel=${runtimeChannel}` : "",
runtimeChannel
? `capabilities=${
runtimeCapabilities.length > 0
? runtimeCapabilities.join(",")

View File

@@ -1,7 +1,7 @@
import crypto from "node:crypto";
import { callGateway } from "../../gateway/call.js";
import { INTERNAL_MESSAGE_PROVIDER } from "../../utils/message-provider.js";
import { INTERNAL_MESSAGE_CHANNEL } from "../../utils/message-channel.js";
import { AGENT_LANE_NESTED } from "../lanes.js";
import { extractAssistantText, stripToolMessages } from "./sessions-helpers.js";
@@ -25,7 +25,7 @@ export async function runAgentStep(params: {
message: string;
extraSystemPrompt: string;
timeoutMs: number;
provider?: string;
channel?: string;
lane?: string;
}): Promise<string | undefined> {
const stepIdem = crypto.randomUUID();
@@ -36,7 +36,7 @@ export async function runAgentStep(params: {
sessionKey: params.sessionKey,
idempotencyKey: stepIdem,
deliver: false,
provider: params.provider ?? INTERNAL_MESSAGE_PROVIDER,
channel: params.channel ?? INTERNAL_MESSAGE_CHANNEL,
lane: params.lane ?? AGENT_LANE_NESTED,
extraSystemPrompt: params.extraSystemPrompt,
},

View File

@@ -56,7 +56,7 @@ export async function handleDiscordAction(
cfg: ClawdbotConfig,
): Promise<AgentToolResult<unknown>> {
const action = readStringParam(params, "action", { required: true });
const isActionEnabled = createActionGate(cfg.discord?.actions);
const isActionEnabled = createActionGate(cfg.channels?.discord?.actions);
if (messagingActions.has(action)) {
return await handleDiscordMessagingAction(action, params, isActionEnabled);

View File

@@ -2,7 +2,7 @@ import { callGateway } from "../../gateway/call.js";
import {
GATEWAY_CLIENT_MODES,
GATEWAY_CLIENT_NAMES,
} from "../../utils/message-provider.js";
} from "../../utils/message-channel.js";
export const DEFAULT_GATEWAY_URL = "ws://127.0.0.1:18789";

View File

@@ -1,5 +1,12 @@
import { Type } from "@sinclair/typebox";
import {
listChannelMessageActions,
supportsChannelMessageButtons,
} from "../../channels/plugins/message-actions.js";
import {
CHANNEL_MESSAGE_ACTION_NAMES,
type ChannelMessageActionName,
} from "../../channels/plugins/types.js";
import type { ClawdbotConfig } from "../../config/config.js";
import { loadConfig } from "../../config/config.js";
import {
@@ -7,23 +14,15 @@ import {
GATEWAY_CLIENT_MODES,
} from "../../gateway/protocol/client-info.js";
import { runMessageAction } from "../../infra/outbound/message-action-runner.js";
import {
listProviderMessageActions,
supportsProviderMessageButtons,
} from "../../providers/plugins/message-actions.js";
import {
PROVIDER_MESSAGE_ACTION_NAMES,
type ProviderMessageActionName,
} from "../../providers/plugins/types.js";
import { normalizeAccountId } from "../../routing/session-key.js";
import { stringEnum } from "../schema/typebox.js";
import type { AnyAgentTool } from "./common.js";
import { jsonResult, readNumberParam, readStringParam } from "./common.js";
const AllMessageActions = PROVIDER_MESSAGE_ACTION_NAMES;
const AllMessageActions = CHANNEL_MESSAGE_ACTION_NAMES;
const MessageToolCommonSchema = {
provider: Type.Optional(Type.String()),
channel: Type.Optional(Type.String()),
to: Type.Optional(Type.String()),
message: Type.Optional(Type.String()),
media: Type.Optional(Type.String()),
@@ -131,8 +130,8 @@ type MessageToolOptions = {
};
function buildMessageToolSchema(cfg: ClawdbotConfig) {
const actions = listProviderMessageActions(cfg);
const includeButtons = supportsProviderMessageButtons(cfg);
const actions = listChannelMessageActions(cfg);
const includeButtons = supportsChannelMessageButtons(cfg);
return buildMessageToolSchemaFromActions(
actions.length > 0 ? actions : ["send"],
{ includeButtons },
@@ -155,14 +154,14 @@ export function createMessageTool(options?: MessageToolOptions): AnyAgentTool {
label: "Message",
name: "message",
description:
"Send messages and provider actions (polls, reactions, pins, threads, etc.) via configured provider plugins.",
"Send messages and channel actions (polls, reactions, pins, threads, etc.) via configured channel plugins.",
parameters: schema,
execute: async (_toolCallId, args) => {
const params = args as Record<string, unknown>;
const cfg = options?.config ?? loadConfig();
const action = readStringParam(params, "action", {
required: true,
}) as ProviderMessageActionName;
}) as ChannelMessageActionName;
const accountId = readStringParam(params, "accountId") ?? agentAccountId;
const gateway = {

View File

@@ -322,8 +322,8 @@ export function createSessionStatusTool(opts?: {
const queueSettings = resolveQueueSettings({
cfg,
provider:
resolved.entry.provider ?? resolved.entry.lastProvider ?? "unknown",
channel:
resolved.entry.channel ?? resolved.entry.lastChannel ?? "unknown",
sessionEntry: resolved.entry,
});
const queueKey = resolved.key ?? resolved.entry.sessionId;

View File

@@ -17,7 +17,7 @@ describe("resolveAnnounceTarget", () => {
sessionKey: "agent:main:discord:group:dev",
displayKey: "agent:main:discord:group:dev",
});
expect(target).toEqual({ provider: "discord", to: "channel:dev" });
expect(target).toEqual({ channel: "discord", to: "channel:dev" });
expect(callGatewayMock).not.toHaveBeenCalled();
});
@@ -26,7 +26,7 @@ describe("resolveAnnounceTarget", () => {
sessions: [
{
key: "agent:main:whatsapp:group:123@g.us",
lastProvider: "whatsapp",
lastChannel: "whatsapp",
lastTo: "123@g.us",
lastAccountId: "work",
},
@@ -38,7 +38,7 @@ describe("resolveAnnounceTarget", () => {
displayKey: "agent:main:whatsapp:group:123@g.us",
});
expect(target).toEqual({
provider: "whatsapp",
channel: "whatsapp",
to: "123@g.us",
accountId: "work",
});

View File

@@ -1,8 +1,8 @@
import { callGateway } from "../../gateway/call.js";
import {
getProviderPlugin,
normalizeProviderId,
} from "../../providers/plugins/index.js";
getChannelPlugin,
normalizeChannelId,
} from "../../channels/plugins/index.js";
import { callGateway } from "../../gateway/call.js";
import type { AnnounceTarget } from "./sessions-send-helpers.js";
import { resolveAnnounceTargetFromKey } from "./sessions-send-helpers.js";
@@ -15,8 +15,8 @@ export async function resolveAnnounceTarget(params: {
const fallback = parsed ?? parsedDisplay ?? null;
if (fallback) {
const normalized = normalizeProviderId(fallback.provider);
const plugin = normalized ? getProviderPlugin(normalized) : null;
const normalized = normalizeChannelId(fallback.channel);
const plugin = normalized ? getChannelPlugin(normalized) : null;
if (!plugin?.meta?.preferSessionLookupForAnnounceTarget) {
return fallback;
}
@@ -35,14 +35,14 @@ export async function resolveAnnounceTarget(params: {
const match =
sessions.find((entry) => entry?.key === params.sessionKey) ??
sessions.find((entry) => entry?.key === params.displayKey);
const provider =
typeof match?.lastProvider === "string" ? match.lastProvider : undefined;
const channel =
typeof match?.lastChannel === "string" ? match.lastChannel : undefined;
const to = typeof match?.lastTo === "string" ? match.lastTo : undefined;
const accountId =
typeof match?.lastAccountId === "string"
? match.lastAccountId
: undefined;
if (provider && to) return { provider, to, accountId };
if (channel && to) return { channel, to, accountId };
} catch {
// ignore
}

View File

@@ -56,11 +56,11 @@ export function classifySessionKind(params: {
return "other";
}
export function deriveProvider(params: {
export function deriveChannel(params: {
key: string;
kind: SessionKind;
provider?: string | null;
lastProvider?: string | null;
channel?: string | null;
lastChannel?: string | null;
}): string {
if (
params.kind === "cron" ||
@@ -68,10 +68,10 @@ export function deriveProvider(params: {
params.kind === "node"
)
return "internal";
const provider = normalizeKey(params.provider ?? undefined);
if (provider) return provider;
const lastProvider = normalizeKey(params.lastProvider ?? undefined);
if (lastProvider) return lastProvider;
const channel = normalizeKey(params.channel ?? undefined);
if (channel) return channel;
const lastChannel = normalizeKey(params.lastChannel ?? undefined);
if (lastChannel) return lastChannel;
const parts = params.key.split(":").filter(Boolean);
if (parts.length >= 3 && (parts[1] === "group" || parts[1] === "channel")) {
return parts[0];

View File

@@ -13,7 +13,7 @@ import type { AnyAgentTool } from "./common.js";
import { jsonResult, readStringArrayParam } from "./common.js";
import {
classifySessionKind,
deriveProvider,
deriveChannel,
resolveDisplaySessionKey,
resolveInternalSessionKey,
resolveMainSessionAlias,
@@ -24,7 +24,7 @@ import {
type SessionListRow = {
key: string;
kind: SessionKind;
provider: string;
channel: string;
label?: string;
displayName?: string;
updatedAt?: number | null;
@@ -37,7 +37,7 @@ type SessionListRow = {
systemSent?: boolean;
abortedLastRun?: boolean;
sendPolicy?: string;
lastProvider?: string;
lastChannel?: string;
lastTo?: string;
lastAccountId?: string;
transcriptPath?: string;
@@ -178,21 +178,19 @@ export function createSessionsListTool(opts?: {
mainKey,
});
const entryProvider =
typeof entry.provider === "string" ? entry.provider : undefined;
const lastProvider =
typeof entry.lastProvider === "string"
? entry.lastProvider
: undefined;
const entryChannel =
typeof entry.channel === "string" ? entry.channel : undefined;
const lastChannel =
typeof entry.lastChannel === "string" ? entry.lastChannel : undefined;
const lastAccountId =
typeof entry.lastAccountId === "string"
? entry.lastAccountId
: undefined;
const derivedProvider = deriveProvider({
const derivedChannel = deriveChannel({
key,
kind,
provider: entryProvider,
lastProvider,
channel: entryChannel,
lastChannel,
});
const sessionId =
@@ -205,7 +203,7 @@ export function createSessionsListTool(opts?: {
const row: SessionListRow = {
key: displayKey,
kind,
provider: derivedProvider,
channel: derivedChannel,
label: typeof entry.label === "string" ? entry.label : undefined,
displayName:
typeof entry.displayName === "string"
@@ -241,7 +239,7 @@ export function createSessionsListTool(opts?: {
: undefined,
sendPolicy:
typeof entry.sendPolicy === "string" ? entry.sendPolicy : undefined,
lastProvider,
lastChannel,
lastTo: typeof entry.lastTo === "string" ? entry.lastTo : undefined,
lastAccountId,
transcriptPath,

View File

@@ -1,8 +1,8 @@
import type { ClawdbotConfig } from "../../config/config.js";
import {
getProviderPlugin,
normalizeProviderId,
} from "../../providers/plugins/index.js";
getChannelPlugin,
normalizeChannelId,
} from "../../channels/plugins/index.js";
import type { ClawdbotConfig } from "../../config/config.js";
const ANNOUNCE_SKIP_TOKEN = "ANNOUNCE_SKIP";
const REPLY_SKIP_TOKEN = "REPLY_SKIP";
@@ -10,7 +10,7 @@ const DEFAULT_PING_PONG_TURNS = 5;
const MAX_PING_PONG_TURNS = 5;
export type AnnounceTarget = {
provider: string;
channel: string;
to: string;
accountId?: string;
};
@@ -24,29 +24,29 @@ export function resolveAnnounceTargetFromKey(
? rawParts.slice(2)
: rawParts;
if (parts.length < 3) return null;
const [providerRaw, kind, ...rest] = parts;
const [channelRaw, kind, ...rest] = parts;
if (kind !== "group" && kind !== "channel") return null;
const id = rest.join(":").trim();
if (!id) return null;
if (!providerRaw) return null;
const normalizedProvider = normalizeProviderId(providerRaw);
const provider = normalizedProvider ?? providerRaw.toLowerCase();
const kindTarget = normalizedProvider
if (!channelRaw) return null;
const normalizedChannel = normalizeChannelId(channelRaw);
const channel = normalizedChannel ?? channelRaw.toLowerCase();
const kindTarget = normalizedChannel
? kind === "channel"
? `channel:${id}`
: `group:${id}`
: id;
const normalized = normalizedProvider
? getProviderPlugin(normalizedProvider)?.messaging?.normalizeTarget?.(
const normalized = normalizedChannel
? getChannelPlugin(normalizedChannel)?.messaging?.normalizeTarget?.(
kindTarget,
)
: undefined;
return { provider, to: normalized ?? kindTarget };
return { channel, to: normalized ?? kindTarget };
}
export function buildAgentToAgentMessageContext(params: {
requesterSessionKey?: string;
requesterProvider?: string;
requesterChannel?: string;
targetSessionKey: string;
}) {
const lines = [
@@ -54,8 +54,8 @@ export function buildAgentToAgentMessageContext(params: {
params.requesterSessionKey
? `Agent 1 (requester) session: ${params.requesterSessionKey}.`
: undefined,
params.requesterProvider
? `Agent 1 (requester) provider: ${params.requesterProvider}.`
params.requesterChannel
? `Agent 1 (requester) channel: ${params.requesterChannel}.`
: undefined,
`Agent 2 (target) session: ${params.targetSessionKey}.`,
].filter(Boolean);
@@ -64,9 +64,9 @@ export function buildAgentToAgentMessageContext(params: {
export function buildAgentToAgentReplyContext(params: {
requesterSessionKey?: string;
requesterProvider?: string;
requesterChannel?: string;
targetSessionKey: string;
targetProvider?: string;
targetChannel?: string;
currentRole: "requester" | "target";
turn: number;
maxTurns: number;
@@ -82,12 +82,12 @@ export function buildAgentToAgentReplyContext(params: {
params.requesterSessionKey
? `Agent 1 (requester) session: ${params.requesterSessionKey}.`
: undefined,
params.requesterProvider
? `Agent 1 (requester) provider: ${params.requesterProvider}.`
params.requesterChannel
? `Agent 1 (requester) channel: ${params.requesterChannel}.`
: undefined,
`Agent 2 (target) session: ${params.targetSessionKey}.`,
params.targetProvider
? `Agent 2 (target) provider: ${params.targetProvider}.`
params.targetChannel
? `Agent 2 (target) channel: ${params.targetChannel}.`
: undefined,
`If you want to stop the ping-pong, reply exactly "${REPLY_SKIP_TOKEN}".`,
].filter(Boolean);
@@ -96,9 +96,9 @@ export function buildAgentToAgentReplyContext(params: {
export function buildAgentToAgentAnnounceContext(params: {
requesterSessionKey?: string;
requesterProvider?: string;
requesterChannel?: string;
targetSessionKey: string;
targetProvider?: string;
targetChannel?: string;
originalMessage: string;
roundOneReply?: string;
latestReply?: string;
@@ -108,12 +108,12 @@ export function buildAgentToAgentAnnounceContext(params: {
params.requesterSessionKey
? `Agent 1 (requester) session: ${params.requesterSessionKey}.`
: undefined,
params.requesterProvider
? `Agent 1 (requester) provider: ${params.requesterProvider}.`
params.requesterChannel
? `Agent 1 (requester) channel: ${params.requesterChannel}.`
: undefined,
`Agent 2 (target) session: ${params.targetSessionKey}.`,
params.targetProvider
? `Agent 2 (target) provider: ${params.targetProvider}.`
params.targetChannel
? `Agent 2 (target) channel: ${params.targetChannel}.`
: undefined,
`Original request: ${params.originalMessage}`,
params.roundOneReply
@@ -123,7 +123,7 @@ export function buildAgentToAgentAnnounceContext(params: {
? `Latest reply: ${params.latestReply}`
: "Latest reply: (not available).",
`If you want to remain silent, reply exactly "${ANNOUNCE_SKIP_TOKEN}".`,
"Any other reply will be posted to the target provider.",
"Any other reply will be posted to the target channel.",
"After this reply, the agent-to-agent conversation is over.",
].filter(Boolean);
return lines.join("\n");

View File

@@ -28,7 +28,7 @@ describe("sessions_send gating", () => {
it("blocks cross-agent sends when tools.agentToAgent.enabled is false", async () => {
const tool = createSessionsSendTool({
agentSessionKey: "agent:main:main",
agentProvider: "whatsapp",
agentChannel: "whatsapp",
});
const result = await tool.execute("call1", {

View File

@@ -13,9 +13,9 @@ import {
} from "../../routing/session-key.js";
import { SESSION_LABEL_MAX_LENGTH } from "../../sessions/session-label.js";
import {
type GatewayMessageProvider,
INTERNAL_MESSAGE_PROVIDER,
} from "../../utils/message-provider.js";
type GatewayMessageChannel,
INTERNAL_MESSAGE_CHANNEL,
} from "../../utils/message-channel.js";
import { AGENT_LANE_NESTED } from "../lanes.js";
import { readLatestAssistantReply, runAgentStep } from "./agent-step.js";
import type { AnyAgentTool } from "./common.js";
@@ -51,7 +51,7 @@ const SessionsSendToolSchema = Type.Object({
export function createSessionsSendTool(opts?: {
agentSessionKey?: string;
agentProvider?: GatewayMessageProvider;
agentChannel?: GatewayMessageChannel;
sandboxed?: boolean;
}): AnyAgentTool {
return {
@@ -297,7 +297,7 @@ export function createSessionsSendTool(opts?: {
const agentMessageContext = buildAgentToAgentMessageContext({
requesterSessionKey: opts?.agentSessionKey,
requesterProvider: opts?.agentProvider,
requesterChannel: opts?.agentChannel,
targetSessionKey: displayKey,
});
const sendParams = {
@@ -305,12 +305,12 @@ export function createSessionsSendTool(opts?: {
sessionKey: resolvedKey,
idempotencyKey,
deliver: false,
provider: INTERNAL_MESSAGE_PROVIDER,
channel: INTERNAL_MESSAGE_CHANNEL,
lane: AGENT_LANE_NESTED,
extraSystemPrompt: agentMessageContext,
};
const requesterSessionKey = opts?.agentSessionKey;
const requesterProvider = opts?.agentProvider;
const requesterChannel = opts?.agentChannel;
const maxPingPongTurns = resolvePingPongTurns(cfg);
const delivery = { status: "pending", mode: "announce" as const };
@@ -344,7 +344,7 @@ export function createSessionsSendTool(opts?: {
sessionKey: resolvedKey,
displayKey,
});
const targetProvider = announceTarget?.provider ?? "unknown";
const targetChannel = announceTarget?.channel ?? "unknown";
if (
maxPingPongTurns > 0 &&
requesterSessionKey &&
@@ -360,9 +360,9 @@ export function createSessionsSendTool(opts?: {
: "target";
const replyPrompt = buildAgentToAgentReplyContext({
requesterSessionKey,
requesterProvider,
requesterChannel,
targetSessionKey: displayKey,
targetProvider,
targetChannel,
currentRole,
turn,
maxTurns: maxPingPongTurns,
@@ -386,9 +386,9 @@ export function createSessionsSendTool(opts?: {
}
const announcePrompt = buildAgentToAgentAnnounceContext({
requesterSessionKey,
requesterProvider,
requesterChannel,
targetSessionKey: displayKey,
targetProvider,
targetChannel,
originalMessage: message,
roundOneReply: primaryReply,
latestReply,
@@ -412,7 +412,7 @@ export function createSessionsSendTool(opts?: {
params: {
to: announceTarget.to,
message: announceReply.trim(),
provider: announceTarget.provider,
channel: announceTarget.channel,
accountId: announceTarget.accountId,
idempotencyKey: crypto.randomUUID(),
},
@@ -421,7 +421,7 @@ export function createSessionsSendTool(opts?: {
} catch (err) {
log.warn("sessions_send announce delivery failed", {
runId: runContextId,
provider: announceTarget.provider,
channel: announceTarget.channel,
to: announceTarget.to,
error: formatErrorMessage(err),
});

View File

@@ -9,7 +9,7 @@ import {
normalizeAgentId,
parseAgentSessionKey,
} from "../../routing/session-key.js";
import type { GatewayMessageProvider } from "../../utils/message-provider.js";
import type { GatewayMessageChannel } from "../../utils/message-channel.js";
import { resolveAgentConfig } from "../agent-scope.js";
import { AGENT_LANE_SUBAGENT } from "../lanes.js";
import { optionalStringEnum } from "../schema/typebox.js";
@@ -47,7 +47,7 @@ function normalizeModelSelection(value: unknown): string | undefined {
export function createSessionsSpawnTool(opts?: {
agentSessionKey?: string;
agentProvider?: GatewayMessageProvider;
agentChannel?: GatewayMessageChannel;
sandboxed?: boolean;
}): AnyAgentTool {
return {
@@ -174,7 +174,7 @@ export function createSessionsSpawnTool(opts?: {
}
const childSystemPrompt = buildSubagentSystemPrompt({
requesterSessionKey,
requesterProvider: opts?.agentProvider,
requesterChannel: opts?.agentChannel,
childSessionKey,
label: label || undefined,
task,
@@ -188,7 +188,7 @@ export function createSessionsSpawnTool(opts?: {
params: {
message: task,
sessionKey: childSessionKey,
provider: opts?.agentProvider,
channel: opts?.agentChannel,
idempotencyKey: childIdem,
deliver: false,
lane: AGENT_LANE_SUBAGENT,
@@ -221,7 +221,7 @@ export function createSessionsSpawnTool(opts?: {
runId: childRunId,
childSessionKey,
requesterSessionKey: requesterInternalKey,
requesterProvider: opts?.agentProvider,
requesterChannel: opts?.agentChannel,
requesterDisplayKey,
task,
cleanup,

View File

@@ -36,7 +36,7 @@ vi.mock("../../slack/actions.js", () => ({
describe("handleSlackAction", () => {
it("adds reactions", async () => {
const cfg = { slack: { botToken: "tok" } } as ClawdbotConfig;
const cfg = { channels: { slack: { botToken: "tok" } } } as ClawdbotConfig;
await handleSlackAction(
{
action: "react",
@@ -50,7 +50,7 @@ describe("handleSlackAction", () => {
});
it("removes reactions on empty emoji", async () => {
const cfg = { slack: { botToken: "tok" } } as ClawdbotConfig;
const cfg = { channels: { slack: { botToken: "tok" } } } as ClawdbotConfig;
await handleSlackAction(
{
action: "react",
@@ -64,7 +64,7 @@ describe("handleSlackAction", () => {
});
it("removes reactions when remove flag set", async () => {
const cfg = { slack: { botToken: "tok" } } as ClawdbotConfig;
const cfg = { channels: { slack: { botToken: "tok" } } } as ClawdbotConfig;
await handleSlackAction(
{
action: "react",
@@ -79,7 +79,7 @@ describe("handleSlackAction", () => {
});
it("rejects removes without emoji", async () => {
const cfg = { slack: { botToken: "tok" } } as ClawdbotConfig;
const cfg = { channels: { slack: { botToken: "tok" } } } as ClawdbotConfig;
await expect(
handleSlackAction(
{
@@ -96,7 +96,7 @@ describe("handleSlackAction", () => {
it("respects reaction gating", async () => {
const cfg = {
slack: { botToken: "tok", actions: { reactions: false } },
channels: { slack: { botToken: "tok", actions: { reactions: false } } },
} as ClawdbotConfig;
await expect(
handleSlackAction(
@@ -112,7 +112,7 @@ describe("handleSlackAction", () => {
});
it("passes threadTs to sendSlackMessage for thread replies", async () => {
const cfg = { slack: { botToken: "tok" } } as ClawdbotConfig;
const cfg = { channels: { slack: { botToken: "tok" } } } as ClawdbotConfig;
await handleSlackAction(
{
action: "sendMessage",
@@ -133,7 +133,7 @@ describe("handleSlackAction", () => {
});
it("auto-injects threadTs from context when replyToMode=all", async () => {
const cfg = { slack: { botToken: "tok" } } as ClawdbotConfig;
const cfg = { channels: { slack: { botToken: "tok" } } } as ClawdbotConfig;
sendSlackMessage.mockClear();
await handleSlackAction(
{
@@ -159,7 +159,7 @@ describe("handleSlackAction", () => {
});
it("replyToMode=first threads first message then stops", async () => {
const cfg = { slack: { botToken: "tok" } } as ClawdbotConfig;
const cfg = { channels: { slack: { botToken: "tok" } } } as ClawdbotConfig;
sendSlackMessage.mockClear();
const hasRepliedRef = { value: false };
const context = {
@@ -198,7 +198,7 @@ describe("handleSlackAction", () => {
});
it("replyToMode=first marks hasRepliedRef even when threadTs is explicit", async () => {
const cfg = { slack: { botToken: "tok" } } as ClawdbotConfig;
const cfg = { channels: { slack: { botToken: "tok" } } } as ClawdbotConfig;
sendSlackMessage.mockClear();
const hasRepliedRef = { value: false };
const context = {
@@ -244,7 +244,7 @@ describe("handleSlackAction", () => {
});
it("replyToMode=first without hasRepliedRef does not thread", async () => {
const cfg = { slack: { botToken: "tok" } } as ClawdbotConfig;
const cfg = { channels: { slack: { botToken: "tok" } } } as ClawdbotConfig;
sendSlackMessage.mockClear();
await handleSlackAction(
{ action: "sendMessage", to: "channel:C123", content: "No ref" },
@@ -263,7 +263,7 @@ describe("handleSlackAction", () => {
});
it("does not auto-inject threadTs when replyToMode=off", async () => {
const cfg = { slack: { botToken: "tok" } } as ClawdbotConfig;
const cfg = { channels: { slack: { botToken: "tok" } } } as ClawdbotConfig;
sendSlackMessage.mockClear();
await handleSlackAction(
{
@@ -285,7 +285,7 @@ describe("handleSlackAction", () => {
});
it("does not auto-inject threadTs when sending to different channel", async () => {
const cfg = { slack: { botToken: "tok" } } as ClawdbotConfig;
const cfg = { channels: { slack: { botToken: "tok" } } } as ClawdbotConfig;
sendSlackMessage.mockClear();
await handleSlackAction(
{
@@ -311,7 +311,7 @@ describe("handleSlackAction", () => {
});
it("explicit threadTs overrides context threadTs", async () => {
const cfg = { slack: { botToken: "tok" } } as ClawdbotConfig;
const cfg = { channels: { slack: { botToken: "tok" } } } as ClawdbotConfig;
sendSlackMessage.mockClear();
await handleSlackAction(
{
@@ -338,7 +338,7 @@ describe("handleSlackAction", () => {
});
it("handles channel target without prefix when replyToMode=all", async () => {
const cfg = { slack: { botToken: "tok" } } as ClawdbotConfig;
const cfg = { channels: { slack: { botToken: "tok" } } } as ClawdbotConfig;
sendSlackMessage.mockClear();
await handleSlackAction(
{

View File

@@ -93,7 +93,7 @@ export async function handleSlackAction(
const accountId = readStringParam(params, "accountId");
const accountOpts = accountId ? { accountId } : undefined;
const account = resolveSlackAccount({ cfg, accountId });
const actionConfig = account.actions ?? cfg.slack?.actions;
const actionConfig = account.actions ?? cfg.channels?.slack?.actions;
const isActionEnabled = createActionGate(actionConfig);
if (reactionsActions.has(action)) {

View File

@@ -34,7 +34,9 @@ describe("handleTelegramAction", () => {
});
it("adds reactions", async () => {
const cfg = { telegram: { botToken: "tok" } } as ClawdbotConfig;
const cfg = {
channels: { telegram: { botToken: "tok" } },
} as ClawdbotConfig;
await handleTelegramAction(
{
action: "react",
@@ -53,7 +55,9 @@ describe("handleTelegramAction", () => {
});
it("removes reactions on empty emoji", async () => {
const cfg = { telegram: { botToken: "tok" } } as ClawdbotConfig;
const cfg = {
channels: { telegram: { botToken: "tok" } },
} as ClawdbotConfig;
await handleTelegramAction(
{
action: "react",
@@ -72,7 +76,9 @@ describe("handleTelegramAction", () => {
});
it("removes reactions when remove flag set", async () => {
const cfg = { telegram: { botToken: "tok" } } as ClawdbotConfig;
const cfg = {
channels: { telegram: { botToken: "tok" } },
} as ClawdbotConfig;
await handleTelegramAction(
{
action: "react",
@@ -93,7 +99,9 @@ describe("handleTelegramAction", () => {
it("respects reaction gating", async () => {
const cfg = {
telegram: { botToken: "tok", actions: { reactions: false } },
channels: {
telegram: { botToken: "tok", actions: { reactions: false } },
},
} as ClawdbotConfig;
await expect(
handleTelegramAction(
@@ -109,7 +117,9 @@ describe("handleTelegramAction", () => {
});
it("sends a text message", async () => {
const cfg = { telegram: { botToken: "tok" } } as ClawdbotConfig;
const cfg = {
channels: { telegram: { botToken: "tok" } },
} as ClawdbotConfig;
const result = await handleTelegramAction(
{
action: "sendMessage",
@@ -130,7 +140,9 @@ describe("handleTelegramAction", () => {
});
it("sends a message with media", async () => {
const cfg = { telegram: { botToken: "tok" } } as ClawdbotConfig;
const cfg = {
channels: { telegram: { botToken: "tok" } },
} as ClawdbotConfig;
await handleTelegramAction(
{
action: "sendMessage",
@@ -152,7 +164,9 @@ describe("handleTelegramAction", () => {
it("respects sendMessage gating", async () => {
const cfg = {
telegram: { botToken: "tok", actions: { sendMessage: false } },
channels: {
telegram: { botToken: "tok", actions: { sendMessage: false } },
},
} as ClawdbotConfig;
await expect(
handleTelegramAction(
@@ -182,7 +196,9 @@ describe("handleTelegramAction", () => {
});
it("requires inlineButtons capability when buttons are provided", async () => {
const cfg = { telegram: { botToken: "tok" } } as ClawdbotConfig;
const cfg = {
channels: { telegram: { botToken: "tok" } },
} as ClawdbotConfig;
await expect(
handleTelegramAction(
{
@@ -198,7 +214,9 @@ describe("handleTelegramAction", () => {
it("sends messages with inline keyboard buttons when enabled", async () => {
const cfg = {
telegram: { botToken: "tok", capabilities: ["inlineButtons"] },
channels: {
telegram: { botToken: "tok", capabilities: ["inlineButtons"] },
},
} as ClawdbotConfig;
await handleTelegramAction(
{

View File

@@ -1,7 +1,6 @@
import type { AgentToolResult } from "@mariozechner/pi-agent-core";
import { resolveChannelCapabilities } from "../../config/channel-capabilities.js";
import type { ClawdbotConfig } from "../../config/config.js";
import { resolveProviderCapabilities } from "../../config/provider-capabilities.js";
import {
reactMessageTelegram,
sendMessageTelegram,
@@ -26,9 +25,9 @@ function hasInlineButtonsCapability(params: {
accountId?: string | undefined;
}): boolean {
const caps =
resolveProviderCapabilities({
resolveChannelCapabilities({
cfg: params.cfg,
provider: "telegram",
channel: "telegram",
accountId: params.accountId,
}) ?? [];
return caps.some((cap) => cap.toLowerCase() === "inlinebuttons");
@@ -84,7 +83,7 @@ export async function handleTelegramAction(
): Promise<AgentToolResult<unknown>> {
const action = readStringParam(params, "action", { required: true });
const accountId = readStringParam(params, "accountId");
const isActionEnabled = createActionGate(cfg.telegram?.actions);
const isActionEnabled = createActionGate(cfg.channels?.telegram?.actions);
if (action === "react") {
if (!isActionEnabled("reactions")) {
@@ -103,7 +102,7 @@ export async function handleTelegramAction(
const token = resolveTelegramToken(cfg, { accountId }).token;
if (!token) {
throw new Error(
"Telegram bot token missing. Set TELEGRAM_BOT_TOKEN or telegram.botToken.",
"Telegram bot token missing. Set TELEGRAM_BOT_TOKEN or channels.telegram.botToken.",
);
}
await reactMessageTelegram(chatId ?? "", messageId ?? 0, emoji ?? "", {
@@ -130,7 +129,7 @@ export async function handleTelegramAction(
!hasInlineButtonsCapability({ cfg, accountId: accountId ?? undefined })
) {
throw new Error(
'Telegram inline buttons requested but not enabled. Add "inlineButtons" to telegram.capabilities (or telegram.accounts.<id>.capabilities).',
'Telegram inline buttons requested but not enabled. Add "inlineButtons" to channels.telegram.capabilities (or channels.telegram.accounts.<id>.capabilities).',
);
}
// Optional threading parameters for forum topics and reply chains
@@ -143,7 +142,7 @@ export async function handleTelegramAction(
const token = resolveTelegramToken(cfg, { accountId }).token;
if (!token) {
throw new Error(
"Telegram bot token missing. Set TELEGRAM_BOT_TOKEN or telegram.botToken.",
"Telegram bot token missing. Set TELEGRAM_BOT_TOKEN or channels.telegram.botToken.",
);
}
const result = await sendMessageTelegram(to, content, {

View File

@@ -10,7 +10,7 @@ vi.mock("../../web/outbound.js", () => ({
}));
const enabledConfig = {
whatsapp: { actions: { reactions: true } },
channels: { whatsapp: { actions: { reactions: true } } },
} as ClawdbotConfig;
describe("handleWhatsAppAction", () => {
@@ -112,7 +112,7 @@ describe("handleWhatsAppAction", () => {
it("respects reaction gating", async () => {
const cfg = {
whatsapp: { actions: { reactions: false } },
channels: { whatsapp: { actions: { reactions: false } } },
} as ClawdbotConfig;
await expect(
handleWhatsAppAction(

View File

@@ -14,7 +14,7 @@ export async function handleWhatsAppAction(
cfg: ClawdbotConfig,
): Promise<AgentToolResult<unknown>> {
const action = readStringParam(params, "action", { required: true });
const isActionEnabled = createActionGate(cfg.whatsapp?.actions);
const isActionEnabled = createActionGate(cfg.channels?.whatsapp?.actions);
if (action === "react") {
if (!isActionEnabled("reactions")) {

View File

@@ -126,18 +126,20 @@ describe("resolveTextChunkLimit", () => {
});
it("supports provider overrides", () => {
const cfg = { telegram: { textChunkLimit: 1234 } };
const cfg = { channels: { telegram: { textChunkLimit: 1234 } } };
expect(resolveTextChunkLimit(cfg, "whatsapp")).toBe(4000);
expect(resolveTextChunkLimit(cfg, "telegram")).toBe(1234);
});
it("prefers account overrides when provided", () => {
const cfg = {
telegram: {
textChunkLimit: 2000,
accounts: {
default: { textChunkLimit: 1234 },
primary: { textChunkLimit: 777 },
channels: {
telegram: {
textChunkLimit: 2000,
accounts: {
default: { textChunkLimit: 1234 },
primary: { textChunkLimit: 777 },
},
},
},
};
@@ -147,8 +149,10 @@ describe("resolveTextChunkLimit", () => {
it("uses the matching provider override", () => {
const cfg = {
discord: { textChunkLimit: 111 },
slack: { textChunkLimit: 222 },
channels: {
discord: { textChunkLimit: 111 },
slack: { textChunkLimit: 222 },
},
};
expect(resolveTextChunkLimit(cfg, "discord")).toBe(111);
expect(resolveTextChunkLimit(cfg, "slack")).toBe(222);

View File

@@ -2,17 +2,17 @@
// unintentionally breaking on newlines. Using [\s\S] keeps newlines inside
// the chunk so messages are only split when they truly exceed the limit.
import type { ChannelId } from "../channels/plugins/types.js";
import type { ClawdbotConfig } from "../config/config.js";
import {
findFenceSpanAt,
isSafeFenceBreak,
parseFenceSpans,
} from "../markdown/fences.js";
import type { ProviderId } from "../providers/plugins/types.js";
import { normalizeAccountId } from "../routing/session-key.js";
import { INTERNAL_MESSAGE_PROVIDER } from "../utils/message-provider.js";
import { INTERNAL_MESSAGE_CHANNEL } from "../utils/message-channel.js";
export type TextChunkProvider = ProviderId | typeof INTERNAL_MESSAGE_PROVIDER;
export type TextChunkProvider = ChannelId | typeof INTERNAL_MESSAGE_CHANNEL;
const DEFAULT_CHUNK_LIMIT = 4000;
@@ -55,10 +55,12 @@ export function resolveTextChunkLimit(
? opts.fallbackLimit
: DEFAULT_CHUNK_LIMIT;
const providerOverride = (() => {
if (!provider || provider === INTERNAL_MESSAGE_PROVIDER) return undefined;
const providerConfig = (cfg as Record<string, unknown> | undefined)?.[
provider
] as ProviderChunkConfig | undefined;
if (!provider || provider === INTERNAL_MESSAGE_CHANNEL) return undefined;
const channelsConfig = cfg?.channels as Record<string, unknown> | undefined;
const providerConfig = (channelsConfig?.[provider] ??
(cfg as Record<string, unknown> | undefined)?.[provider]) as
| ProviderChunkConfig
| undefined;
return resolveChunkLimitForProvider(providerConfig, accountId);
})();
if (typeof providerOverride === "number" && providerOverride > 0) {

View File

@@ -7,7 +7,7 @@ import type { MsgContext } from "./templating.js";
describe("resolveCommandAuthorization", () => {
it("falls back from empty SenderId to SenderE164", () => {
const cfg = {
whatsapp: { allowFrom: ["+123"] },
channels: { whatsapp: { allowFrom: ["+123"] } },
} as ClawdbotConfig;
const ctx = {

View File

@@ -1,12 +1,12 @@
import type { ChannelDock } from "../channels/dock.js";
import { getChannelDock, listChannelDocks } from "../channels/dock.js";
import type { ChannelId } from "../channels/plugins/types.js";
import { normalizeChannelId } from "../channels/registry.js";
import type { ClawdbotConfig } from "../config/config.js";
import type { ProviderDock } from "../providers/dock.js";
import { getProviderDock, listProviderDocks } from "../providers/dock.js";
import type { ProviderId } from "../providers/plugins/types.js";
import { normalizeProviderId } from "../providers/registry.js";
import type { MsgContext } from "./templating.js";
export type CommandAuthorization = {
providerId?: ProviderId;
providerId?: ChannelId;
ownerList: string[];
senderId?: string;
isAuthorizedSender: boolean;
@@ -17,20 +17,20 @@ export type CommandAuthorization = {
function resolveProviderFromContext(
ctx: MsgContext,
cfg: ClawdbotConfig,
): ProviderId | undefined {
): ChannelId | undefined {
const direct =
normalizeProviderId(ctx.Provider) ??
normalizeProviderId(ctx.Surface) ??
normalizeProviderId(ctx.OriginatingChannel);
normalizeChannelId(ctx.Provider) ??
normalizeChannelId(ctx.Surface) ??
normalizeChannelId(ctx.OriginatingChannel);
if (direct) return direct;
const candidates = [ctx.From, ctx.To]
.filter((value): value is string => Boolean(value?.trim()))
.flatMap((value) => value.split(":").map((part) => part.trim()));
for (const candidate of candidates) {
const normalized = normalizeProviderId(candidate);
const normalized = normalizeChannelId(candidate);
if (normalized) return normalized;
}
const configured = listProviderDocks()
const configured = listChannelDocks()
.map((dock) => {
if (!dock.config?.resolveAllowFrom) return null;
const allowFrom = dock.config.resolveAllowFrom({
@@ -40,13 +40,13 @@ function resolveProviderFromContext(
if (!Array.isArray(allowFrom) || allowFrom.length === 0) return null;
return dock.id;
})
.filter((value): value is ProviderId => Boolean(value));
.filter((value): value is ChannelId => Boolean(value));
if (configured.length === 1) return configured[0];
return undefined;
}
function formatAllowFromList(params: {
dock?: ProviderDock;
dock?: ChannelDock;
cfg: ClawdbotConfig;
accountId?: string | null;
allowFrom: Array<string | number>;
@@ -66,7 +66,7 @@ export function resolveCommandAuthorization(params: {
}): CommandAuthorization {
const { ctx, cfg, commandAuthorized } = params;
const providerId = resolveProviderFromContext(ctx, cfg);
const dock = providerId ? getProviderDock(providerId) : undefined;
const dock = providerId ? getChannelDock(providerId) : undefined;
const from = (ctx.From ?? "").trim();
const to = (ctx.To ?? "").trim();
const allowFromRaw = dock?.config?.resolveAllowFrom

View File

@@ -1,5 +1,5 @@
import { listChannelDocks } from "../channels/dock.js";
import type { ClawdbotConfig } from "../config/types.js";
import { listProviderDocks } from "../providers/dock.js";
export type CommandScope = "text" | "native" | "both";
@@ -276,7 +276,7 @@ let cachedNativeCommandSurfaces: Set<string> | null = null;
const getNativeCommandSurfaces = (): Set<string> => {
if (!cachedNativeCommandSurfaces) {
cachedNativeCommandSurfaces = new Set(
listProviderDocks()
listChannelDocks()
.filter((dock) => dock.capabilities.nativeCommands)
.map((dock) => dock.id),
);

View File

@@ -3,13 +3,13 @@ import { describe, expect, it } from "vitest";
import { formatAgentEnvelope } from "./envelope.js";
describe("formatAgentEnvelope", () => {
it("includes provider, from, ip, host, and timestamp", () => {
it("includes channel, from, ip, host, and timestamp", () => {
const originalTz = process.env.TZ;
process.env.TZ = "UTC";
const ts = Date.UTC(2025, 0, 2, 3, 4); // 2025-01-02T03:04:00Z
const body = formatAgentEnvelope({
provider: "WebChat",
channel: "WebChat",
from: "user1",
host: "mac-mini",
ip: "10.0.0.5",
@@ -30,7 +30,7 @@ describe("formatAgentEnvelope", () => {
const ts = Date.UTC(2025, 0, 2, 3, 4); // 2025-01-02T03:04:00Z
const body = formatAgentEnvelope({
provider: "WebChat",
channel: "WebChat",
timestamp: ts,
body: "hello",
});
@@ -41,7 +41,7 @@ describe("formatAgentEnvelope", () => {
});
it("handles missing optional fields", () => {
const body = formatAgentEnvelope({ provider: "Telegram", body: "hi" });
const body = formatAgentEnvelope({ channel: "Telegram", body: "hi" });
expect(body).toBe("[Telegram] hi");
});
});

View File

@@ -1,5 +1,5 @@
export type AgentEnvelopeParams = {
provider: string;
channel: string;
from?: string;
timestamp?: number | Date;
host?: string;
@@ -24,8 +24,8 @@ function formatTimestamp(ts?: number | Date): string | undefined {
}
export function formatAgentEnvelope(params: AgentEnvelopeParams): string {
const provider = params.provider?.trim() || "Provider";
const parts: string[] = [provider];
const channel = params.channel?.trim() || "Channel";
const parts: string[] = [channel];
if (params.from?.trim()) parts.push(params.from.trim());
if (params.host?.trim()) parts.push(params.host.trim());
if (params.ip?.trim()) parts.push(params.ip.trim());
@@ -36,13 +36,13 @@ export function formatAgentEnvelope(params: AgentEnvelopeParams): string {
}
export function formatThreadStarterEnvelope(params: {
provider: string;
channel: string;
author?: string;
timestamp?: number | Date;
body: string;
}): string {
return formatAgentEnvelope({
provider: params.provider,
channel: params.channel,
from: params.author,
timestamp: params.timestamp,
body: params.body,

View File

@@ -97,7 +97,7 @@ describe("block streaming", () => {
workspace: path.join(home, "clawd"),
},
},
whatsapp: { allowFrom: ["*"] },
channels: { whatsapp: { allowFrom: ["*"] } },
session: { store: path.join(home, "sessions.json") },
},
);
@@ -156,7 +156,7 @@ describe("block streaming", () => {
workspace: path.join(home, "clawd"),
},
},
telegram: { allowFrom: ["*"] },
channels: { telegram: { allowFrom: ["*"] } },
session: { store: path.join(home, "sessions.json") },
},
);
@@ -205,7 +205,7 @@ describe("block streaming", () => {
workspace: path.join(home, "clawd"),
},
},
whatsapp: { allowFrom: ["*"] },
channels: { whatsapp: { allowFrom: ["*"] } },
session: { store: path.join(home, "sessions.json") },
},
);
@@ -263,7 +263,7 @@ describe("block streaming", () => {
workspace: path.join(home, "clawd"),
},
},
telegram: { allowFrom: ["*"] },
channels: { telegram: { allowFrom: ["*"] } },
session: { store: path.join(home, "sessions.json") },
},
);
@@ -305,7 +305,7 @@ describe("block streaming", () => {
workspace: path.join(home, "clawd"),
},
},
telegram: { allowFrom: ["*"], streamMode: "block" },
channels: { telegram: { allowFrom: ["*"], streamMode: "block" } },
session: { store: path.join(home, "sessions.json") },
},
);

View File

@@ -181,7 +181,7 @@ describe("directive behavior", () => {
},
},
},
whatsapp: { allowFrom: ["*"] },
channels: { whatsapp: { allowFrom: ["*"] } },
session: { store: path.join(home, "sessions.json") },
},
);
@@ -210,7 +210,7 @@ describe("directive behavior", () => {
workspace: path.join(home, "clawd"),
},
},
whatsapp: { allowFrom: ["*"] },
channels: { whatsapp: { allowFrom: ["*"] } },
session: { store: path.join(home, "sessions.json") },
},
);
@@ -250,7 +250,7 @@ describe("directive behavior", () => {
drop: "summarize",
},
},
whatsapp: { allowFrom: ["*"] },
channels: { whatsapp: { allowFrom: ["*"] } },
session: { store: path.join(home, "sessions.json") },
},
);
@@ -383,7 +383,7 @@ describe("directive behavior", () => {
workspace: path.join(home, "clawd"),
},
},
whatsapp: { allowFrom: ["*"] },
channels: { whatsapp: { allowFrom: ["*"] } },
session: { store: path.join(home, "sessions.json") },
},
);
@@ -419,7 +419,7 @@ describe("directive behavior", () => {
workspace: path.join(home, "clawd"),
},
},
whatsapp: { allowFrom: ["*"] },
channels: { whatsapp: { allowFrom: ["*"] } },
session: { store: path.join(home, "sessions.json") },
},
);
@@ -459,7 +459,7 @@ describe("directive behavior", () => {
workspace: path.join(home, "clawd"),
},
},
whatsapp: { allowFrom: ["*"] },
channels: { whatsapp: { allowFrom: ["*"] } },
session: { store: path.join(home, "sessions.json") },
},
);
@@ -494,9 +494,7 @@ describe("directive behavior", () => {
workspace: path.join(home, "clawd"),
},
},
whatsapp: {
allowFrom: ["*"],
},
channels: { whatsapp: { allowFrom: ["*"] } },
session: { store: path.join(home, "sessions.json") },
},
);
@@ -541,9 +539,7 @@ describe("directive behavior", () => {
workspace: path.join(home, "clawd"),
},
},
whatsapp: {
allowFrom: ["*"],
},
channels: { whatsapp: { allowFrom: ["*"] } },
session: { store: storePath },
},
);
@@ -589,7 +585,7 @@ describe("directive behavior", () => {
workspace: path.join(home, "clawd"),
},
},
whatsapp: { allowFrom: ["*"] },
channels: { whatsapp: { allowFrom: ["*"] } },
session: { store: storePath },
},
);
@@ -613,7 +609,7 @@ describe("directive behavior", () => {
workspace: path.join(home, "clawd"),
},
},
whatsapp: { allowFrom: ["*"] },
channels: { whatsapp: { allowFrom: ["*"] } },
session: { store: storePath },
},
);
@@ -803,7 +799,7 @@ describe("directive behavior", () => {
allowFrom: { whatsapp: ["+1222"] },
},
},
whatsapp: { allowFrom: ["+1222"] },
channels: { whatsapp: { allowFrom: ["+1222"] } },
session: { store: path.join(home, "sessions.json") },
},
);
@@ -842,7 +838,7 @@ describe("directive behavior", () => {
allowFrom: { whatsapp: ["+1222"] },
},
},
whatsapp: { allowFrom: ["+1222"] },
channels: { whatsapp: { allowFrom: ["+1222"] } },
session: { store: storePath },
},
);
@@ -894,7 +890,7 @@ describe("directive behavior", () => {
allowFrom: { whatsapp: ["+1222"] },
},
},
whatsapp: { allowFrom: ["+1222"] },
channels: { whatsapp: { allowFrom: ["+1222"] } },
session: { store: storePath },
},
);
@@ -937,7 +933,7 @@ describe("directive behavior", () => {
allowFrom: { whatsapp: ["+1222"] },
},
},
whatsapp: { allowFrom: ["+1222"] },
channels: { whatsapp: { allowFrom: ["+1222"] } },
session: { store: storePath },
},
);
@@ -964,7 +960,7 @@ describe("directive behavior", () => {
allowFrom: { whatsapp: ["+1222"] },
},
},
whatsapp: { allowFrom: ["+1222"] },
channels: { whatsapp: { allowFrom: ["+1222"] } },
session: { store: storePath },
},
);
@@ -993,7 +989,7 @@ describe("directive behavior", () => {
allowFrom: { whatsapp: ["+1222"] },
},
},
whatsapp: { allowFrom: ["+1222"] },
channels: { whatsapp: { allowFrom: ["+1222"] } },
session: { store: storePath },
} as const;
@@ -1079,7 +1075,7 @@ describe("directive behavior", () => {
allowFrom: { whatsapp: ["+1222"] },
},
},
whatsapp: { allowFrom: ["+1222"] },
channels: { whatsapp: { allowFrom: ["+1222"] } },
session: { store: path.join(home, "sessions.json") },
},
);
@@ -1126,7 +1122,7 @@ describe("directive behavior", () => {
allowFrom: { whatsapp: ["+1222", "+1333"] },
},
},
whatsapp: { allowFrom: ["+1222", "+1333"] },
channels: { whatsapp: { allowFrom: ["+1222", "+1333"] } },
session: { store: path.join(home, "sessions.json") },
},
);
@@ -1173,7 +1169,7 @@ describe("directive behavior", () => {
allowFrom: { whatsapp: ["+1222", "+1333"] },
},
},
whatsapp: { allowFrom: ["+1222", "+1333"] },
channels: { whatsapp: { allowFrom: ["+1222", "+1333"] } },
session: { store: path.join(home, "sessions.json") },
},
);
@@ -1210,7 +1206,7 @@ describe("directive behavior", () => {
allowFrom: { whatsapp: ["+1222"] },
},
},
whatsapp: { allowFrom: ["+1222"] },
channels: { whatsapp: { allowFrom: ["+1222"] } },
session: { store: path.join(home, "sessions.json") },
},
);
@@ -1247,7 +1243,7 @@ describe("directive behavior", () => {
allowFrom: { whatsapp: ["+1222"] },
},
},
whatsapp: { allowFrom: ["+1222"] },
channels: { whatsapp: { allowFrom: ["+1222"] } },
session: { store: path.join(home, "sessions.json") },
},
);
@@ -1283,7 +1279,7 @@ describe("directive behavior", () => {
allowFrom: { whatsapp: ["+1222"] },
},
},
whatsapp: { allowFrom: ["+1222"] },
channels: { whatsapp: { allowFrom: ["+1222"] } },
session: { store: path.join(home, "sessions.json") },
},
);
@@ -1321,7 +1317,7 @@ describe("directive behavior", () => {
allowFrom: { whatsapp: ["+1222"] },
},
},
whatsapp: { allowFrom: ["+1222"] },
channels: { whatsapp: { allowFrom: ["+1222"] } },
session: { store: storePath },
},
);
@@ -1375,7 +1371,7 @@ describe("directive behavior", () => {
allowFrom: { whatsapp: ["+1222"] },
},
},
whatsapp: { allowFrom: ["+1222"] },
channels: { whatsapp: { allowFrom: ["+1222"] } },
session: { store: path.join(home, "sessions.json") },
},
);
@@ -1401,7 +1397,7 @@ describe("directive behavior", () => {
workspace: path.join(home, "clawd"),
},
},
whatsapp: { allowFrom: ["*"] },
channels: { whatsapp: { allowFrom: ["*"] } },
session: { store: storePath },
},
);
@@ -1434,7 +1430,7 @@ describe("directive behavior", () => {
workspace: path.join(home, "clawd"),
},
},
whatsapp: { allowFrom: ["*"] },
channels: { whatsapp: { allowFrom: ["*"] } },
session: { store: storePath },
},
);
@@ -1469,7 +1465,7 @@ describe("directive behavior", () => {
workspace: path.join(home, "clawd"),
},
},
whatsapp: { allowFrom: ["*"] },
channels: { whatsapp: { allowFrom: ["*"] } },
session: { store: storePath },
},
);
@@ -1484,7 +1480,7 @@ describe("directive behavior", () => {
workspace: path.join(home, "clawd"),
},
},
whatsapp: { allowFrom: ["*"] },
channels: { whatsapp: { allowFrom: ["*"] } },
session: { store: storePath },
},
);
@@ -1545,9 +1541,7 @@ describe("directive behavior", () => {
workspace: path.join(home, "clawd"),
},
},
whatsapp: {
allowFrom: ["*"],
},
channels: { whatsapp: { allowFrom: ["*"] } },
session: { store: storePath },
},
);
@@ -1608,9 +1602,7 @@ describe("directive behavior", () => {
workspace: path.join(home, "clawd"),
},
},
whatsapp: {
allowFrom: ["*"],
},
channels: { whatsapp: { allowFrom: ["*"] } },
session: { store: storePath },
},
);
@@ -1625,9 +1617,7 @@ describe("directive behavior", () => {
workspace: path.join(home, "clawd"),
},
},
whatsapp: {
allowFrom: ["*"],
},
channels: { whatsapp: { allowFrom: ["*"] } },
session: { store: storePath },
},
);
@@ -2283,7 +2273,7 @@ describe("directive behavior", () => {
},
},
tools: { elevated: { allowFrom: { whatsapp: ["*"] } } },
whatsapp: { allowFrom: ["*"] },
channels: { whatsapp: { allowFrom: ["*"] } },
session: { store: storePath },
},
);
@@ -2313,7 +2303,7 @@ describe("directive behavior", () => {
workspace: path.join(home, "clawd"),
},
},
whatsapp: { allowFrom: ["*"] },
channels: { whatsapp: { allowFrom: ["*"] } },
session: { store: storePath },
},
);
@@ -2352,9 +2342,7 @@ describe("directive behavior", () => {
},
},
},
whatsapp: {
allowFrom: ["*"],
},
channels: { whatsapp: { allowFrom: ["*"] } },
session: { store: storePath },
},
);
@@ -2403,9 +2391,7 @@ describe("directive behavior", () => {
workspace: path.join(home, "clawd"),
},
},
whatsapp: {
allowFrom: ["*"],
},
channels: { whatsapp: { allowFrom: ["*"] } },
session: { store: storePath },
},
);
@@ -2448,9 +2434,7 @@ describe("directive behavior", () => {
allowFrom: { whatsapp: ["+1004"] },
},
},
whatsapp: {
allowFrom: ["*"],
},
channels: { whatsapp: { allowFrom: ["*"] } },
session: { store: storePath },
},
);

View File

@@ -60,8 +60,10 @@ function makeCfg(home: string) {
workspace: join(home, "clawd"),
},
},
whatsapp: {
allowFrom: ["*"],
channels: {
whatsapp: {
allowFrom: ["*"],
},
},
session: { store: join(home, "sessions.json") },
};

View File

@@ -50,7 +50,7 @@ function makeCfg(home: string) {
workspace: path.join(home, "clawd"),
},
},
whatsapp: { allowFrom: ["*"] },
channels: { whatsapp: { allowFrom: ["*"] } },
session: { store: path.join(home, "sessions.json") },
};
}

View File

@@ -48,7 +48,7 @@ function makeCfg(home: string, queue?: Record<string, unknown>) {
workspace: path.join(home, "clawd"),
},
},
whatsapp: { allowFrom: ["*"] },
channels: { whatsapp: { allowFrom: ["*"] } },
session: { store: path.join(home, "sessions.json") },
messages: queue ? { queue } : undefined,
};

View File

@@ -67,7 +67,7 @@ describe("RawBody directive parsing", () => {
workspace: path.join(home, "clawd"),
},
},
whatsapp: { allowFrom: ["*"] },
channels: { whatsapp: { allowFrom: ["*"] } },
session: { store: path.join(home, "sessions.json") },
},
);
@@ -103,7 +103,7 @@ describe("RawBody directive parsing", () => {
},
},
},
whatsapp: { allowFrom: ["*"] },
channels: { whatsapp: { allowFrom: ["*"] } },
session: { store: path.join(home, "sessions.json") },
},
);
@@ -136,7 +136,7 @@ describe("RawBody directive parsing", () => {
workspace: path.join(home, "clawd"),
},
},
whatsapp: { allowFrom: ["*"] },
channels: { whatsapp: { allowFrom: ["*"] } },
session: { store: path.join(home, "sessions.json") },
},
);
@@ -173,7 +173,7 @@ describe("RawBody directive parsing", () => {
workspace: path.join(home, "clawd"),
},
},
whatsapp: { allowFrom: ["+1222"] },
channels: { whatsapp: { allowFrom: ["+1222"] } },
session: { store: path.join(home, "sessions.json") },
},
);
@@ -220,7 +220,7 @@ describe("RawBody directive parsing", () => {
workspace: path.join(home, "clawd"),
},
},
whatsapp: { allowFrom: ["*"] },
channels: { whatsapp: { allowFrom: ["*"] } },
session: { store: path.join(home, "sessions.json") },
},
);

View File

@@ -95,8 +95,10 @@ function makeCfg(home: string) {
workspace: join(home, "clawd"),
},
},
whatsapp: {
allowFrom: ["*"],
channels: {
whatsapp: {
allowFrom: ["*"],
},
},
session: { store: join(home, "sessions.json") },
};
@@ -856,8 +858,10 @@ describe("trigger handling", () => {
workspace: join(home, "clawd"),
},
},
whatsapp: {
allowFrom: ["+1000"],
channels: {
whatsapp: {
allowFrom: ["+1000"],
},
},
session: { store: join(home, "sessions.json") },
};
@@ -886,8 +890,10 @@ describe("trigger handling", () => {
workspace: join(home, "clawd"),
},
},
whatsapp: {
allowFrom: ["+1000"],
channels: {
whatsapp: {
allowFrom: ["+1000"],
},
},
session: { store: join(home, "sessions.json") },
};
@@ -923,8 +929,10 @@ describe("trigger handling", () => {
workspace: join(home, "clawd"),
},
},
whatsapp: {
allowFrom: ["+1000"],
channels: {
whatsapp: {
allowFrom: ["+1000"],
},
},
session: { store: join(home, "sessions.json") },
};
@@ -965,8 +973,10 @@ describe("trigger handling", () => {
workspace: join(home, "clawd"),
},
},
whatsapp: {
allowFrom: ["+1000"],
channels: {
whatsapp: {
allowFrom: ["+1000"],
},
},
session: { store: join(home, "sessions.json") },
};
@@ -1017,8 +1027,10 @@ describe("trigger handling", () => {
workspace: join(home, "clawd"),
},
},
whatsapp: {
allowFrom: ["+1000"],
channels: {
whatsapp: {
allowFrom: ["+1000"],
},
},
session: { store: join(home, "sessions.json") },
};
@@ -1060,8 +1072,10 @@ describe("trigger handling", () => {
allowFrom: { whatsapp: ["+1000"] },
},
},
whatsapp: {
allowFrom: ["+1000"],
channels: {
whatsapp: {
allowFrom: ["+1000"],
},
},
session: { store: join(home, "sessions.json") },
};
@@ -1104,8 +1118,10 @@ describe("trigger handling", () => {
allowFrom: { whatsapp: ["+1000"] },
},
},
whatsapp: {
allowFrom: ["+1000"],
channels: {
whatsapp: {
allowFrom: ["+1000"],
},
},
session: { store: join(home, "sessions.json") },
};
@@ -1154,9 +1170,11 @@ describe("trigger handling", () => {
allowFrom: { whatsapp: ["+1000"] },
},
},
whatsapp: {
allowFrom: ["+1000"],
groups: { "*": { requireMention: false } },
channels: {
whatsapp: {
allowFrom: ["+1000"],
groups: { "*": { requireMention: false } },
},
},
session: { store: join(home, "sessions.json") },
};
@@ -1201,9 +1219,11 @@ describe("trigger handling", () => {
allowFrom: { whatsapp: ["+1000"] },
},
},
whatsapp: {
allowFrom: ["+1000"],
groups: { "*": { requireMention: false } },
channels: {
whatsapp: {
allowFrom: ["+1000"],
groups: { "*": { requireMention: false } },
},
},
session: { store: join(home, "sessions.json") },
};
@@ -1245,9 +1265,11 @@ describe("trigger handling", () => {
allowFrom: { whatsapp: ["+1000"] },
},
},
whatsapp: {
allowFrom: ["+1000"],
groups: { "*": { requireMention: true } },
channels: {
whatsapp: {
allowFrom: ["+1000"],
groups: { "*": { requireMention: true } },
},
},
session: { store: join(home, "sessions.json") },
};
@@ -1293,8 +1315,10 @@ describe("trigger handling", () => {
allowFrom: { whatsapp: ["+1000"] },
},
},
whatsapp: {
allowFrom: ["+1000"],
channels: {
whatsapp: {
allowFrom: ["+1000"],
},
},
session: { store: join(home, "sessions.json") },
};
@@ -1343,8 +1367,10 @@ describe("trigger handling", () => {
allowFrom: { whatsapp: ["+1000"] },
},
},
whatsapp: {
allowFrom: ["+1000"],
channels: {
whatsapp: {
allowFrom: ["+1000"],
},
},
session: { store: join(home, "sessions.json") },
};
@@ -1648,9 +1674,11 @@ describe("trigger handling", () => {
workspace: join(home, "clawd"),
},
},
whatsapp: {
allowFrom: ["*"],
groups: { "*": { requireMention: false } },
channels: {
whatsapp: {
allowFrom: ["*"],
groups: { "*": { requireMention: false } },
},
},
messages: {
groupChat: {},
@@ -1694,8 +1722,10 @@ describe("trigger handling", () => {
workspace: join(home, "clawd"),
},
},
whatsapp: {
allowFrom: ["*"],
channels: {
whatsapp: {
allowFrom: ["*"],
},
},
session: {
store: join(tmpdir(), `clawdbot-session-test-${Date.now()}.json`),
@@ -1735,8 +1765,10 @@ describe("trigger handling", () => {
workspace: join(home, "clawd"),
},
},
whatsapp: {
allowFrom: ["*"],
channels: {
whatsapp: {
allowFrom: ["*"],
},
},
session: {
store: join(tmpdir(), `clawdbot-session-test-${Date.now()}.json`),
@@ -1769,8 +1801,10 @@ describe("trigger handling", () => {
workspace: join(home, "clawd"),
},
},
whatsapp: {
allowFrom: ["+1999"],
channels: {
whatsapp: {
allowFrom: ["+1999"],
},
},
session: {
store: join(tmpdir(), `clawdbot-session-test-${Date.now()}.json`),
@@ -1798,8 +1832,10 @@ describe("trigger handling", () => {
workspace: join(home, "clawd"),
},
},
whatsapp: {
allowFrom: ["+1999"],
channels: {
whatsapp: {
allowFrom: ["+1999"],
},
},
session: {
store: join(tmpdir(), `clawdbot-session-test-${Date.now()}.json`),
@@ -1841,8 +1877,10 @@ describe("trigger handling", () => {
workspace: join(home, "clawd"),
},
},
whatsapp: {
allowFrom: ["*"],
channels: {
whatsapp: {
allowFrom: ["*"],
},
},
session: {
store: storePath,
@@ -1956,8 +1994,10 @@ describe("trigger handling", () => {
},
},
},
whatsapp: {
allowFrom: ["*"],
channels: {
whatsapp: {
allowFrom: ["*"],
},
},
session: {
store: join(home, "sessions.json"),

View File

@@ -24,6 +24,11 @@ import {
DEFAULT_AGENT_WORKSPACE_DIR,
ensureAgentWorkspace,
} from "../agents/workspace.js";
import { getChannelDock } from "../channels/dock.js";
import {
CHAT_CHANNEL_ORDER,
normalizeChannelId,
} from "../channels/registry.js";
import {
type AgentElevatedAllowFromConfig,
type ClawdbotConfig,
@@ -35,14 +40,9 @@ import {
} from "../config/sessions.js";
import { logVerbose } from "../globals.js";
import { clearCommandLane, getQueueSize } from "../process/command-queue.js";
import { getProviderDock } from "../providers/dock.js";
import {
CHAT_PROVIDER_ORDER,
normalizeProviderId,
} from "../providers/registry.js";
import { normalizeMainKey } from "../routing/session-key.js";
import { defaultRuntime } from "../runtime.js";
import { INTERNAL_MESSAGE_PROVIDER } from "../utils/message-provider.js";
import { INTERNAL_MESSAGE_CHANNEL } from "../utils/message-channel.js";
import { isReasoningTagProvider } from "../utils/provider-utils.js";
import { resolveCommandAuthorization } from "./command-auth.js";
import { hasControlCommand } from "./command-detection.js";
@@ -141,8 +141,8 @@ function slugAllowToken(value?: string) {
}
const SENDER_PREFIXES = [
...CHAT_PROVIDER_ORDER,
INTERNAL_MESSAGE_PROVIDER,
...CHAT_CHANNEL_ORDER,
INTERNAL_MESSAGE_CHANNEL,
"user",
"group",
"channel",
@@ -287,9 +287,9 @@ function resolveElevatedPermissions(params: {
return { enabled, allowed: false, failures };
}
const normalizedProvider = normalizeProviderId(params.provider);
const normalizedProvider = normalizeChannelId(params.provider);
const dockFallbackAllowFrom = normalizedProvider
? getProviderDock(normalizedProvider)?.elevated?.allowFromFallback?.({
? getChannelDock(normalizedProvider)?.elevated?.allowFromFallback?.({
cfg: params.cfg,
accountId: params.ctx.AccountId,
})
@@ -1017,10 +1017,8 @@ export async function getReplyFromConfig(
}
const isEmptyConfig = Object.keys(cfg).length === 0;
const skipWhenConfigEmpty = command.providerId
? Boolean(
getProviderDock(command.providerId)?.commands?.skipWhenConfigEmpty,
)
const skipWhenConfigEmpty = command.channelId
? Boolean(getChannelDock(command.channelId)?.commands?.skipWhenConfigEmpty)
: false;
if (
skipWhenConfigEmpty &&
@@ -1255,7 +1253,7 @@ export async function getReplyFromConfig(
: queueBodyBase;
const resolvedQueue = resolveQueueSettings({
cfg,
provider: sessionCtx.Provider,
channel: sessionCtx.Provider,
sessionEntry,
inlineMode: perMessageQueueMode,
inlineOptions: perMessageQueueOptions,

View File

@@ -21,6 +21,9 @@ import {
resolveSandboxRuntimeStatus,
} from "../../agents/sandbox.js";
import { hasNonzeroUsage, type NormalizedUsage } from "../../agents/usage.js";
import { getChannelDock } from "../../channels/dock.js";
import type { ChannelThreadingToolContext } from "../../channels/plugins/types.js";
import { normalizeChannelId } from "../../channels/registry.js";
import type { ClawdbotConfig } from "../../config/config.js";
import {
loadSessionStore,
@@ -37,9 +40,6 @@ import {
registerAgentRunContext,
} from "../../infra/agent-events.js";
import { isAudioFileName } from "../../media/mime.js";
import { getProviderDock } from "../../providers/dock.js";
import type { ProviderThreadingToolContext } from "../../providers/plugins/types.js";
import { normalizeProviderId } from "../../providers/registry.js";
import { defaultRuntime } from "../../runtime.js";
import { isReasoningTagProvider } from "../../utils/provider-utils.js";
import {
@@ -96,19 +96,19 @@ function buildThreadingToolContext(params: {
sessionCtx: TemplateContext;
config: ClawdbotConfig | undefined;
hasRepliedRef: { value: boolean } | undefined;
}): ProviderThreadingToolContext {
}): ChannelThreadingToolContext {
const { sessionCtx, config, hasRepliedRef } = params;
if (!config) return {};
const provider = normalizeProviderId(sessionCtx.Provider);
const provider = normalizeChannelId(sessionCtx.Provider);
if (!provider) return {};
const dock = getProviderDock(provider);
const dock = getChannelDock(provider);
if (!dock?.threading?.buildToolContext) return {};
return (
dock.threading.buildToolContext({
cfg: config,
accountId: sessionCtx.AccountId,
context: {
Provider: sessionCtx.Provider,
Channel: sessionCtx.Provider,
To: sessionCtx.To,
ReplyToId: sessionCtx.ReplyToId,
ThreadLabel: sessionCtx.ThreadLabel,

View File

@@ -1,17 +1,17 @@
import { getChannelDock } from "../../channels/dock.js";
import { CHANNEL_IDS, normalizeChannelId } from "../../channels/registry.js";
import type { ClawdbotConfig } from "../../config/config.js";
import type { BlockStreamingCoalesceConfig } from "../../config/types.js";
import { getProviderDock } from "../../providers/dock.js";
import { normalizeProviderId, PROVIDER_IDS } from "../../providers/registry.js";
import { normalizeAccountId } from "../../routing/session-key.js";
import { INTERNAL_MESSAGE_PROVIDER } from "../../utils/message-provider.js";
import { INTERNAL_MESSAGE_CHANNEL } from "../../utils/message-channel.js";
import { resolveTextChunkLimit, type TextChunkProvider } from "../chunk.js";
const DEFAULT_BLOCK_STREAM_MIN = 800;
const DEFAULT_BLOCK_STREAM_MAX = 1200;
const DEFAULT_BLOCK_STREAM_COALESCE_IDLE_MS = 1000;
const BLOCK_CHUNK_PROVIDERS = new Set<TextChunkProvider>([
...PROVIDER_IDS,
INTERNAL_MESSAGE_PROVIDER,
...CHANNEL_IDS,
INTERNAL_MESSAGE_CHANNEL,
]);
function normalizeChunkProvider(
@@ -64,9 +64,9 @@ export function resolveBlockStreamingChunking(
breakPreference: "paragraph" | "newline" | "sentence";
} {
const providerKey = normalizeChunkProvider(provider);
const providerId = providerKey ? normalizeProviderId(providerKey) : null;
const providerId = providerKey ? normalizeChannelId(providerKey) : null;
const providerChunkLimit = providerId
? getProviderDock(providerId)?.outbound?.textChunkLimit
? getChannelDock(providerId)?.outbound?.textChunkLimit
: undefined;
const textLimit = resolveTextChunkLimit(cfg, providerKey, accountId, {
fallbackLimit: providerChunkLimit,
@@ -102,15 +102,15 @@ export function resolveBlockStreamingCoalescing(
},
): BlockStreamingCoalescing | undefined {
const providerKey = normalizeChunkProvider(provider);
const providerId = providerKey ? normalizeProviderId(providerKey) : null;
const providerId = providerKey ? normalizeChannelId(providerKey) : null;
const providerChunkLimit = providerId
? getProviderDock(providerId)?.outbound?.textChunkLimit
? getChannelDock(providerId)?.outbound?.textChunkLimit
: undefined;
const textLimit = resolveTextChunkLimit(cfg, providerKey, accountId, {
fallbackLimit: providerChunkLimit,
});
const providerDefaults = providerId
? getProviderDock(providerId)?.streaming?.blockStreamingCoalesceDefaults
? getChannelDock(providerId)?.streaming?.blockStreamingCoalesceDefaults
: undefined;
const providerCfg = resolveProviderBlockStreamingCoalesce({
cfg,

View File

@@ -83,7 +83,7 @@ describe("handleCommands gating", () => {
it("blocks /config when disabled", async () => {
const cfg = {
commands: { config: false, debug: false, text: true },
whatsapp: { allowFrom: ["*"] },
channels: { whatsapp: { allowFrom: ["*"] } },
} as ClawdbotConfig;
const params = buildParams("/config show", cfg);
const result = await handleCommands(params);
@@ -94,7 +94,7 @@ describe("handleCommands gating", () => {
it("blocks /debug when disabled", async () => {
const cfg = {
commands: { config: false, debug: false, text: true },
whatsapp: { allowFrom: ["*"] },
channels: { whatsapp: { allowFrom: ["*"] } },
} as ClawdbotConfig;
const params = buildParams("/debug show", cfg);
const result = await handleCommands(params);
@@ -133,7 +133,7 @@ describe("handleCommands identity", () => {
it("returns sender details for /whoami", async () => {
const cfg = {
commands: { text: true },
whatsapp: { allowFrom: ["*"] },
channels: { whatsapp: { allowFrom: ["*"] } },
} as ClawdbotConfig;
const params = buildParams("/whoami", cfg, {
SenderId: "12345",
@@ -142,7 +142,7 @@ describe("handleCommands identity", () => {
});
const result = await handleCommands(params);
expect(result.shouldContinue).toBe(false);
expect(result.reply?.text).toContain("Provider: whatsapp");
expect(result.reply?.text).toContain("Channel: whatsapp");
expect(result.reply?.text).toContain("User id: 12345");
expect(result.reply?.text).toContain("Username: @TestUser");
expect(result.reply?.text).toContain("AllowFrom: 12345");

View File

@@ -19,6 +19,7 @@ import {
isEmbeddedPiRunActive,
waitForEmbeddedPiRunEnd,
} from "../../agents/pi-embedded.js";
import type { ChannelId } from "../../channels/plugins/types.js";
import type { ClawdbotConfig } from "../../config/config.js";
import {
readConfigFileSnapshot,
@@ -54,7 +55,6 @@ import {
triggerClawdbotRestart,
} from "../../infra/restart.js";
import { enqueueSystemEvent } from "../../infra/system-events.js";
import type { ProviderId } from "../../providers/plugins/types.js";
import { parseAgentSessionKey } from "../../routing/session-key.js";
import { resolveSendPolicy } from "../../sessions/send-policy.js";
import { resolveCommandAuthorization } from "../command-auth.js";
@@ -108,8 +108,8 @@ function resolveSessionEntryForKey(
export type CommandContext = {
surface: string;
provider: string;
providerId?: ProviderId;
channel: string;
channelId?: ChannelId;
ownerList: string[];
isAuthorizedSender: boolean;
senderId?: string;
@@ -189,7 +189,7 @@ export async function buildStatusReply(params: {
}
const queueSettings = resolveQueueSettings({
cfg,
provider: command.provider,
channel: command.channel,
sessionEntry,
});
const queueKey = sessionKey ?? sessionEntry?.sessionId;
@@ -347,7 +347,7 @@ export function buildCommandContext(params: {
commandAuthorized: params.commandAuthorized,
});
const surface = (ctx.Surface ?? ctx.Provider ?? "").trim().toLowerCase();
const provider = (ctx.Provider ?? surface).trim().toLowerCase();
const channel = (ctx.Provider ?? surface).trim().toLowerCase();
const abortKey =
sessionKey ?? (auth.from || undefined) ?? (auth.to || undefined);
const rawBodyNormalized = triggerBodyNormalized;
@@ -359,8 +359,8 @@ export function buildCommandContext(params: {
return {
surface,
provider,
providerId: auth.providerId,
channel,
channelId: auth.providerId,
ownerList: auth.ownerList,
isAuthorizedSender: auth.isAuthorizedSender,
senderId: auth.senderId,
@@ -677,7 +677,7 @@ export async function handleCommands(params: {
}
const senderId = ctx.SenderId ?? "";
const senderUsername = ctx.SenderUsername ?? "";
const lines = ["🧭 Identity", `Provider: ${command.provider}`];
const lines = ["🧭 Identity", `Channel: ${command.channel}`];
if (senderId) lines.push(`User id: ${senderId}`);
if (senderUsername) {
const handle = senderUsername.startsWith("@")
@@ -980,7 +980,7 @@ export async function handleCommands(params: {
const result = await compactEmbeddedPiSession({
sessionId,
sessionKey,
messageProvider: command.provider,
messageChannel: command.channel,
sessionFile: resolveSessionFilePath(sessionId, sessionEntry),
workspaceDir,
config: cfg,
@@ -1056,7 +1056,7 @@ export async function handleCommands(params: {
cfg,
entry: sessionEntry,
sessionKey,
provider: sessionEntry?.provider ?? command.provider,
channel: sessionEntry?.channel ?? command.channel,
chatType: sessionEntry?.chatType,
});
if (sendPolicy === "deny") {

View File

@@ -1150,7 +1150,7 @@ export async function handleDirectiveOnly(params: {
) {
const settings = resolveQueueSettings({
cfg: params.cfg,
provider,
channel: provider,
sessionEntry,
});
const debounceLabel =

View File

@@ -7,12 +7,14 @@ import { resolveGroupRequireMention } from "./groups.js";
describe("resolveGroupRequireMention", () => {
it("respects Discord guild/channel requireMention settings", () => {
const cfg: ClawdbotConfig = {
discord: {
guilds: {
"145": {
requireMention: false,
channels: {
general: { allow: true },
channels: {
discord: {
guilds: {
"145": {
requireMention: false,
channels: {
general: { allow: true },
},
},
},
},
@@ -25,7 +27,7 @@ describe("resolveGroupRequireMention", () => {
GroupSpace: "145",
};
const groupResolution: GroupKeyResolution = {
provider: "discord",
channel: "discord",
id: "123",
chatType: "group",
};
@@ -37,9 +39,11 @@ describe("resolveGroupRequireMention", () => {
it("respects Slack channel requireMention settings", () => {
const cfg: ClawdbotConfig = {
slack: {
channels: {
C123: { requireMention: false },
channels: {
slack: {
channels: {
C123: { requireMention: false },
},
},
},
};
@@ -49,7 +53,7 @@ describe("resolveGroupRequireMention", () => {
GroupSubject: "#general",
};
const groupResolution: GroupKeyResolution = {
provider: "slack",
channel: "slack",
id: "C123",
chatType: "group",
};

View File

@@ -1,14 +1,14 @@
import { getChannelDock } from "../../channels/dock.js";
import {
getChatChannelMeta,
normalizeChannelId,
} from "../../channels/registry.js";
import type { ClawdbotConfig } from "../../config/config.js";
import type {
GroupKeyResolution,
SessionEntry,
} from "../../config/sessions.js";
import { getProviderDock } from "../../providers/dock.js";
import {
getChatProviderMeta,
normalizeProviderId,
} from "../../providers/registry.js";
import { isInternalMessageProvider } from "../../utils/message-provider.js";
import { isInternalMessageChannel } from "../../utils/message-channel.js";
import { normalizeGroupActivation } from "../group-activation.js";
import type { TemplateContext } from "../templating.js";
@@ -18,14 +18,14 @@ export function resolveGroupRequireMention(params: {
groupResolution?: GroupKeyResolution;
}): boolean {
const { cfg, ctx, groupResolution } = params;
const rawProvider = groupResolution?.provider ?? ctx.Provider?.trim();
const provider = normalizeProviderId(rawProvider);
if (!provider) return true;
const rawChannel = groupResolution?.channel ?? ctx.Provider?.trim();
const channel = normalizeChannelId(rawChannel);
if (!channel) return true;
const groupId = groupResolution?.id ?? ctx.From?.replace(/^group:/, "");
const groupRoom = ctx.GroupRoom?.trim() ?? ctx.GroupSubject?.trim();
const groupSpace = ctx.GroupSpace?.trim();
const requireMention = getProviderDock(
provider,
const requireMention = getChannelDock(
channel,
)?.groups?.resolveRequireMention?.({
cfg,
groupId,
@@ -57,11 +57,11 @@ export function buildGroupIntro(params: {
const members = params.sessionCtx.GroupMembers?.trim();
const rawProvider = params.sessionCtx.Provider?.trim();
const providerKey = rawProvider?.toLowerCase() ?? "";
const providerId = normalizeProviderId(rawProvider);
const providerId = normalizeChannelId(rawProvider);
const providerLabel = (() => {
if (!providerKey) return "chat";
if (isInternalMessageProvider(providerKey)) return "WebChat";
if (providerId) return getChatProviderMeta(providerId).label;
if (isInternalMessageChannel(providerKey)) return "WebChat";
if (providerId) return getChatChannelMeta(providerId).label;
return `${providerKey.at(0)?.toUpperCase() ?? ""}${providerKey.slice(1)}`;
})();
const subjectLine = subject
@@ -76,7 +76,7 @@ export function buildGroupIntro(params: {
const groupRoom = params.sessionCtx.GroupRoom?.trim() ?? subject;
const groupSpace = params.sessionCtx.GroupSpace?.trim();
const providerIdsLine = providerId
? getProviderDock(providerId)?.groups?.resolveGroupIntroHint?.({
? getChannelDock(providerId)?.groups?.resolveGroupIntroHint?.({
cfg: params.cfg,
groupId,
groupRoom,

View File

@@ -1,7 +1,7 @@
import { resolveAgentConfig } from "../../agents/agent-scope.js";
import { getChannelDock } from "../../channels/dock.js";
import { normalizeChannelId } from "../../channels/registry.js";
import type { ClawdbotConfig } from "../../config/config.js";
import { getProviderDock } from "../../providers/dock.js";
import { normalizeProviderId } from "../../providers/registry.js";
import type { MsgContext } from "../templating.js";
function escapeRegExp(text: string): string {
@@ -114,9 +114,9 @@ export function stripMentions(
agentId?: string,
): string {
let result = text;
const providerId = ctx.Provider ? normalizeProviderId(ctx.Provider) : null;
const providerId = ctx.Provider ? normalizeChannelId(ctx.Provider) : null;
const providerMentions = providerId
? getProviderDock(providerId)?.mentions
? getChannelDock(providerId)?.mentions
: undefined;
const patterns = normalizeMentionPatterns([
...resolveMentionPatterns(cfg, agentId),

View File

@@ -577,28 +577,28 @@ export function scheduleFollowupDrain(
}
})();
}
function defaultQueueModeForProvider(_provider?: string): QueueMode {
function defaultQueueModeForChannel(_channel?: string): QueueMode {
return "collect";
}
export function resolveQueueSettings(params: {
cfg: ClawdbotConfig;
provider?: string;
channel?: string;
sessionEntry?: SessionEntry;
inlineMode?: QueueMode;
inlineOptions?: Partial<QueueSettings>;
}): QueueSettings {
const providerKey = params.provider?.trim().toLowerCase();
const channelKey = params.channel?.trim().toLowerCase();
const queueCfg = params.cfg.messages?.queue;
const providerModeRaw =
providerKey && queueCfg?.byProvider
? (queueCfg.byProvider as Record<string, string | undefined>)[providerKey]
channelKey && queueCfg?.byChannel
? (queueCfg.byChannel as Record<string, string | undefined>)[channelKey]
: undefined;
const resolvedMode =
params.inlineMode ??
normalizeQueueMode(params.sessionEntry?.queueMode) ??
normalizeQueueMode(providerModeRaw) ??
normalizeQueueMode(queueCfg?.mode) ??
defaultQueueModeForProvider(providerKey);
defaultQueueModeForChannel(channelKey);
const debounceRaw =
params.inlineOptions?.debounceMs ??
params.sessionEntry?.queueDebounceMs ??

View File

@@ -24,9 +24,11 @@ describe("resolveReplyToMode", () => {
it("uses configured value when present", () => {
const cfg = {
telegram: { replyToMode: "all" },
discord: { replyToMode: "first" },
slack: { replyToMode: "all" },
channels: {
telegram: { replyToMode: "all" },
discord: { replyToMode: "first" },
slack: { replyToMode: "all" },
},
} as ClawdbotConfig;
expect(resolveReplyToMode(cfg, "telegram")).toBe("all");
expect(resolveReplyToMode(cfg, "discord")).toBe("first");

View File

@@ -1,7 +1,7 @@
import { getChannelDock } from "../../channels/dock.js";
import { normalizeChannelId } from "../../channels/registry.js";
import type { ClawdbotConfig } from "../../config/config.js";
import type { ReplyToMode } from "../../config/types.js";
import { getProviderDock } from "../../providers/dock.js";
import { normalizeProviderId } from "../../providers/registry.js";
import type { OriginatingChannelType } from "../templating.js";
import type { ReplyPayload } from "../types.js";
@@ -10,9 +10,9 @@ export function resolveReplyToMode(
channel?: OriginatingChannelType,
accountId?: string | null,
): ReplyToMode {
const provider = normalizeProviderId(channel);
const provider = normalizeChannelId(channel);
if (!provider) return "all";
const resolved = getProviderDock(provider)?.threading?.resolveReplyToMode?.({
const resolved = getChannelDock(provider)?.threading?.resolveReplyToMode?.({
cfg,
accountId,
});
@@ -43,9 +43,9 @@ export function createReplyToModeFilterForChannel(
mode: ReplyToMode,
channel?: OriginatingChannelType,
) {
const provider = normalizeProviderId(channel);
const provider = normalizeChannelId(channel);
const allowTagsWhenOff = provider
? Boolean(getProviderDock(provider)?.threading?.allowTagsWhenOff)
? Boolean(getChannelDock(provider)?.threading?.allowTagsWhenOff)
: false;
return createReplyToModeFilter(mode, {
allowTagsWhenOff,

View File

@@ -226,8 +226,10 @@ describe("routeReply", () => {
it("routes MS Teams via proactive sender", async () => {
mocks.sendMessageMSTeams.mockClear();
const cfg = {
msteams: {
enabled: true,
channels: {
msteams: {
enabled: true,
},
},
} as unknown as ClawdbotConfig;
await routeReply({

View File

@@ -9,9 +9,9 @@
import { resolveSessionAgentId } from "../../agents/agent-scope.js";
import { resolveEffectiveMessagesConfig } from "../../agents/identity.js";
import { normalizeChannelId } from "../../channels/registry.js";
import type { ClawdbotConfig } from "../../config/config.js";
import { normalizeProviderId } from "../../providers/registry.js";
import { INTERNAL_MESSAGE_PROVIDER } from "../../utils/message-provider.js";
import { INTERNAL_MESSAGE_CHANNEL } from "../../utils/message-channel.js";
import type { OriginatingChannelType } from "../templating.js";
import type { ReplyPayload } from "../types.js";
import { normalizeReplyPayload } from "./normalize-reply.js";
@@ -88,15 +88,15 @@ export async function routeReply(
return { ok: true };
}
if (channel === INTERNAL_MESSAGE_PROVIDER) {
if (channel === INTERNAL_MESSAGE_CHANNEL) {
return {
ok: false,
error: "Webchat routing not supported for queued replies",
};
}
const provider = normalizeProviderId(channel) ?? null;
if (!provider) {
const channelId = normalizeChannelId(channel) ?? null;
if (!channelId) {
return { ok: false, error: `Unknown channel: ${String(channel)}` };
}
if (abortSignal?.aborted) {
@@ -111,7 +111,7 @@ export async function routeReply(
);
const results = await deliverOutboundPayloads({
cfg,
provider,
channel: channelId,
to,
accountId: accountId ?? undefined,
payloads: [normalized],
@@ -138,10 +138,7 @@ export async function routeReply(
*/
export function isRoutableChannel(
channel: OriginatingChannelType | undefined,
): channel is Exclude<
OriginatingChannelType,
typeof INTERNAL_MESSAGE_PROVIDER
> {
if (!channel || channel === INTERNAL_MESSAGE_PROVIDER) return false;
return normalizeProviderId(channel) !== null;
): channel is Exclude<OriginatingChannelType, typeof INTERNAL_MESSAGE_CHANNEL> {
if (!channel || channel === INTERNAL_MESSAGE_CHANNEL) return false;
return normalizeChannelId(channel) !== null;
}

View File

@@ -3,8 +3,8 @@ import crypto from "node:crypto";
import { buildWorkspaceSkillSnapshot } from "../../agents/skills.js";
import type { ClawdbotConfig } from "../../config/config.js";
import { type SessionEntry, saveSessionStore } from "../../config/sessions.js";
import { buildProviderSummary } from "../../infra/provider-summary.js";
import { drainSystemEventEntries } from "../../infra/system-events.js";
import { buildChannelSummary } from "../../infra/channel-summary.js";
export async function prependSystemEvents(params: {
cfg: ClawdbotConfig;
@@ -48,7 +48,7 @@ export async function prependSystemEvents(params: {
.filter((v): v is string => Boolean(v)),
);
if (params.isMainSession && params.isNewSession) {
const summary = await buildProviderSummary(params.cfg);
const summary = await buildChannelSummary(params.cfg);
if (summary.length > 0) systemLines.unshift(...summary);
}
if (systemLines.length === 0) return params.prefixedBodyBase;

View File

@@ -7,6 +7,8 @@ import {
SessionManager,
} from "@mariozechner/pi-coding-agent";
import { resolveSessionAgentId } from "../../agents/agent-scope.js";
import { getChannelDock } from "../../channels/dock.js";
import { normalizeChannelId } from "../../channels/registry.js";
import type { ClawdbotConfig } from "../../config/config.js";
import {
buildGroupDisplayName,
@@ -23,8 +25,6 @@ import {
type SessionScope,
saveSessionStore,
} from "../../config/sessions.js";
import { getProviderDock } from "../../providers/dock.js";
import { normalizeProviderId } from "../../providers/registry.js";
import { normalizeMainKey } from "../../routing/session-key.js";
import { resolveCommandAuthorization } from "../command-auth.js";
import type { MsgContext, TemplateContext } from "../templating.js";
@@ -228,20 +228,20 @@ export async function initSessionState(params: {
queueDrop: baseEntry?.queueDrop,
displayName: baseEntry?.displayName,
chatType: baseEntry?.chatType,
provider: baseEntry?.provider,
channel: baseEntry?.channel,
subject: baseEntry?.subject,
room: baseEntry?.room,
space: baseEntry?.space,
};
if (groupResolution?.provider) {
const provider = groupResolution.provider;
if (groupResolution?.channel) {
const channel = groupResolution.channel;
const subject = ctx.GroupSubject?.trim();
const space = ctx.GroupSpace?.trim();
const explicitRoom = ctx.GroupRoom?.trim();
const normalizedProvider = normalizeProviderId(provider);
const normalizedChannel = normalizeChannelId(channel);
const isRoomProvider = Boolean(
normalizedProvider &&
getProviderDock(normalizedProvider)?.capabilities.chatTypes.includes(
normalizedChannel &&
getChannelDock(normalizedChannel)?.capabilities.chatTypes.includes(
"channel",
),
);
@@ -252,12 +252,12 @@ export async function initSessionState(params: {
: undefined);
const nextSubject = nextRoom ? undefined : subject;
sessionEntry.chatType = groupResolution.chatType ?? "group";
sessionEntry.provider = provider;
sessionEntry.channel = channel;
if (nextSubject) sessionEntry.subject = nextSubject;
if (nextRoom) sessionEntry.room = nextRoom;
if (space) sessionEntry.space = space;
sessionEntry.displayName = buildGroupDisplayName({
provider: sessionEntry.provider,
provider: sessionEntry.channel,
subject: sessionEntry.subject,
room: sessionEntry.room,
space: sessionEntry.space,

View File

@@ -1,8 +1,8 @@
import type { ProviderId } from "../providers/plugins/types.js";
import type { InternalMessageProvider } from "../utils/message-provider.js";
import type { ChannelId } from "../channels/plugins/types.js";
import type { InternalMessageChannel } from "../utils/message-channel.js";
/** Valid provider channels for message routing. */
export type OriginatingChannelType = ProviderId | InternalMessageProvider;
export type OriginatingChannelType = ChannelId | InternalMessageChannel;
export type MsgContext = {
Body?: string;

View File

@@ -1,15 +1,15 @@
import { describe, expect, it } from "vitest";
import * as mod from "./provider-web.js";
import * as mod from "./channel-web.js";
describe("provider-web barrel", () => {
describe("channel-web barrel", () => {
it("exports the expected web helpers", () => {
expect(mod.createWaSocket).toBeTypeOf("function");
expect(mod.loginWeb).toBeTypeOf("function");
expect(mod.monitorWebProvider).toBeTypeOf("function");
expect(mod.monitorWebChannel).toBeTypeOf("function");
expect(mod.sendMessageWhatsApp).toBeTypeOf("function");
expect(mod.monitorWebInbox).toBeTypeOf("function");
expect(mod.pickProvider).toBeTypeOf("function");
expect(mod.pickWebChannel).toBeTypeOf("function");
expect(mod.WA_WEB_AUTH_DIR).toBeTruthy();
});
});

View File

@@ -1,14 +1,14 @@
// Barrel exports for the web provider pieces. Splitting the original 900+ line
// module keeps responsibilities small and testable without changing the public API.
// Barrel exports for the web channel pieces. Splitting the original 900+ line
// module keeps responsibilities small and testable.
export {
DEFAULT_WEB_MEDIA_BYTES,
HEARTBEAT_PROMPT,
HEARTBEAT_TOKEN,
monitorWebProvider,
monitorWebChannel,
resolveHeartbeatRecipients,
runWebHeartbeatOnce,
type WebChannelStatus,
type WebMonitorTuning,
type WebProviderStatus,
} from "./web/auto-reply.js";
export {
extractMediaPlaceholder,
@@ -26,7 +26,7 @@ export {
getStatusCode,
logoutWeb,
logWebSelfId,
pickProvider,
pickWebChannel,
WA_WEB_AUTH_DIR,
waitForWaConnection,
webAuthExists,

View File

@@ -15,25 +15,25 @@ import {
resolveWhatsAppGroupRequireMention,
} from "./plugins/group-mentions.js";
import type {
ProviderCapabilities,
ProviderCommandAdapter,
ProviderElevatedAdapter,
ProviderGroupAdapter,
ProviderId,
ProviderMentionAdapter,
ProviderThreadingAdapter,
ChannelCapabilities,
ChannelCommandAdapter,
ChannelElevatedAdapter,
ChannelGroupAdapter,
ChannelId,
ChannelMentionAdapter,
ChannelThreadingAdapter,
} from "./plugins/types.js";
import { CHAT_PROVIDER_ORDER } from "./registry.js";
import { CHAT_CHANNEL_ORDER } from "./registry.js";
export type ProviderDock = {
id: ProviderId;
capabilities: ProviderCapabilities;
commands?: ProviderCommandAdapter;
export type ChannelDock = {
id: ChannelId;
capabilities: ChannelCapabilities;
commands?: ChannelCommandAdapter;
outbound?: {
textChunkLimit?: number;
};
streaming?: ProviderDockStreaming;
elevated?: ProviderElevatedAdapter;
streaming?: ChannelDockStreaming;
elevated?: ChannelElevatedAdapter;
config?: {
resolveAllowFrom?: (params: {
cfg: ClawdbotConfig;
@@ -45,12 +45,12 @@ export type ProviderDock = {
allowFrom: Array<string | number>;
}) => string[];
};
groups?: ProviderGroupAdapter;
mentions?: ProviderMentionAdapter;
threading?: ProviderThreadingAdapter;
groups?: ChannelGroupAdapter;
mentions?: ChannelMentionAdapter;
threading?: ChannelThreadingAdapter;
};
type ProviderDockStreaming = {
type ChannelDockStreaming = {
blockStreamingCoalesceDefaults?: {
minChars?: number;
idleMs?: number;
@@ -66,17 +66,17 @@ const formatLower = (allowFrom: Array<string | number>) =>
const escapeRegExp = (value: string) =>
value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
// Provider docks: lightweight provider metadata/behavior for shared code paths.
// Channel docks: lightweight channel metadata/behavior for shared code paths.
//
// Rules:
// - keep this module *light* (no monitors, probes, puppeteer/web login, etc)
// - OK: config readers, allowFrom formatting, mention stripping patterns, threading defaults
// - shared code should import from here (and from `src/providers/registry.ts`), not from the plugins registry
// - shared code should import from here (and from `src/channels/registry.ts`), not from the plugins registry
//
// Adding a provider:
// Adding a channel:
// - add a new entry to `DOCKS`
// - keep it cheap; push heavy logic into `src/providers/plugins/<id>.ts` or provider modules
const DOCKS: Record<ProviderId, ProviderDock> = {
// - keep it cheap; push heavy logic into `src/channels/plugins/<id>.ts` or channel modules
const DOCKS: Record<ChannelId, ChannelDock> = {
telegram: {
id: "telegram",
capabilities: {
@@ -101,7 +101,8 @@ const DOCKS: Record<ProviderId, ProviderDock> = {
resolveRequireMention: resolveTelegramGroupRequireMention,
},
threading: {
resolveReplyToMode: ({ cfg }) => cfg.telegram?.replyToMode ?? "first",
resolveReplyToMode: ({ cfg }) =>
cfg.channels?.telegram?.replyToMode ?? "first",
buildToolContext: ({ context, hasRepliedRef }) => ({
currentChannelId: context.To?.trim() || undefined,
currentThreadTs: context.ReplyToId,
@@ -170,7 +171,7 @@ const DOCKS: Record<ProviderId, ProviderDock> = {
blockStreamingCoalesceDefaults: { minChars: 1500, idleMs: 1000 },
},
elevated: {
allowFromFallback: ({ cfg }) => cfg.discord?.dm?.allowFrom,
allowFromFallback: ({ cfg }) => cfg.channels?.discord?.dm?.allowFrom,
},
config: {
resolveAllowFrom: ({ cfg, accountId }) =>
@@ -186,7 +187,8 @@ const DOCKS: Record<ProviderId, ProviderDock> = {
stripPatterns: () => ["<@!?\\d+>"],
},
threading: {
resolveReplyToMode: ({ cfg }) => cfg.discord?.replyToMode ?? "off",
resolveReplyToMode: ({ cfg }) =>
cfg.channels?.discord?.replyToMode ?? "off",
buildToolContext: ({ context, hasRepliedRef }) => ({
currentChannelId: context.To?.trim() || undefined,
currentThreadTs: context.ReplyToId,
@@ -308,7 +310,7 @@ const DOCKS: Record<ProviderId, ProviderDock> = {
},
outbound: { textChunkLimit: 4000 },
config: {
resolveAllowFrom: ({ cfg }) => cfg.msteams?.allowFrom ?? [],
resolveAllowFrom: ({ cfg }) => cfg.channels?.msteams?.allowFrom ?? [],
formatAllowFrom: ({ allowFrom }) => formatLower(allowFrom),
},
threading: {
@@ -321,10 +323,10 @@ const DOCKS: Record<ProviderId, ProviderDock> = {
},
};
export function listProviderDocks(): ProviderDock[] {
return CHAT_PROVIDER_ORDER.map((id) => DOCKS[id]);
export function listChannelDocks(): ChannelDock[] {
return CHAT_CHANNEL_ORDER.map((id) => DOCKS[id]);
}
export function getProviderDock(id: ProviderId): ProviderDock | undefined {
export function getChannelDock(id: ChannelId): ChannelDock | undefined {
return DOCKS[id];
}

View File

@@ -7,8 +7,8 @@ import {
import { handleDiscordAction } from "../../../agents/tools/discord-actions.js";
import { listEnabledDiscordAccounts } from "../../../discord/accounts.js";
import type {
ProviderMessageActionAdapter,
ProviderMessageActionName,
ChannelMessageActionAdapter,
ChannelMessageActionName,
} from "../types.js";
const providerId = "discord";
@@ -21,14 +21,14 @@ function readParentIdParam(
return readStringParam(params, "parentId");
}
export const discordMessageActions: ProviderMessageActionAdapter = {
export const discordMessageActions: ChannelMessageActionAdapter = {
listActions: ({ cfg }) => {
const accounts = listEnabledDiscordAccounts(cfg).filter(
(account) => account.tokenSource !== "none",
);
if (accounts.length === 0) return [];
const gate = createActionGate(cfg.discord?.actions);
const actions = new Set<ProviderMessageActionName>(["send"]);
const gate = createActionGate(cfg.channels?.discord?.actions);
const actions = new Set<ChannelMessageActionName>(["send"]);
if (gate("polls")) actions.add("poll");
if (gate("reactions")) {
actions.add("react");

View File

@@ -6,19 +6,19 @@ import { handleTelegramAction } from "../../../agents/tools/telegram-actions.js"
import type { ClawdbotConfig } from "../../../config/config.js";
import { listEnabledTelegramAccounts } from "../../../telegram/accounts.js";
import type {
ProviderMessageActionAdapter,
ProviderMessageActionName,
ChannelMessageActionAdapter,
ChannelMessageActionName,
} from "../types.js";
const providerId = "telegram";
function hasTelegramInlineButtons(cfg: ClawdbotConfig): boolean {
const caps = new Set<string>();
for (const entry of cfg.telegram?.capabilities ?? []) {
for (const entry of cfg.channels?.telegram?.capabilities ?? []) {
const trimmed = String(entry).trim();
if (trimmed) caps.add(trimmed.toLowerCase());
}
const accounts = cfg.telegram?.accounts;
const accounts = cfg.channels?.telegram?.accounts;
if (accounts && typeof accounts === "object") {
for (const account of Object.values(accounts)) {
const accountCaps = (account as { capabilities?: unknown })?.capabilities;
@@ -32,14 +32,14 @@ function hasTelegramInlineButtons(cfg: ClawdbotConfig): boolean {
return caps.has("inlinebuttons");
}
export const telegramMessageActions: ProviderMessageActionAdapter = {
export const telegramMessageActions: ChannelMessageActionAdapter = {
listActions: ({ cfg }) => {
const accounts = listEnabledTelegramAccounts(cfg).filter(
(account) => account.tokenSource !== "none",
);
if (accounts.length === 0) return [];
const gate = createActionGate(cfg.telegram?.actions);
const actions = new Set<ProviderMessageActionName>(["send"]);
const gate = createActionGate(cfg.channels?.telegram?.actions);
const actions = new Set<ChannelMessageActionName>(["send"]);
if (gate("reactions")) actions.add("react");
return Array.from(actions);
},

View File

@@ -1,7 +1,7 @@
import { Type } from "@sinclair/typebox";
import type { ProviderAgentTool } from "../types.js";
import type { ChannelAgentTool } from "../types.js";
export function createWhatsAppLoginTool(): ProviderAgentTool {
export function createWhatsAppLoginTool(): ChannelAgentTool {
return {
label: "WhatsApp Login",
name: "whatsapp_login",

View File

@@ -1,7 +1,7 @@
import type { ClawdbotConfig } from "../../config/config.js";
import { DEFAULT_ACCOUNT_ID } from "../../routing/session-key.js";
type ProviderSection = {
type ChannelSection = {
accounts?: Record<string, Record<string, unknown>>;
enabled?: boolean;
};
@@ -14,9 +14,8 @@ export function setAccountEnabledInConfigSection(params: {
allowTopLevel?: boolean;
}): ClawdbotConfig {
const accountKey = params.accountId || DEFAULT_ACCOUNT_ID;
const base = (params.cfg as Record<string, unknown>)[params.sectionKey] as
| ProviderSection
| undefined;
const channels = params.cfg.channels as Record<string, unknown> | undefined;
const base = channels?.[params.sectionKey] as ChannelSection | undefined;
const hasAccounts = Boolean(base?.accounts);
if (
params.allowTopLevel &&
@@ -25,9 +24,12 @@ export function setAccountEnabledInConfigSection(params: {
) {
return {
...params.cfg,
[params.sectionKey]: {
...base,
enabled: params.enabled,
channels: {
...params.cfg.channels,
[params.sectionKey]: {
...base,
enabled: params.enabled,
},
},
} as ClawdbotConfig;
}
@@ -39,13 +41,16 @@ export function setAccountEnabledInConfigSection(params: {
const existing = baseAccounts[accountKey] ?? {};
return {
...params.cfg,
[params.sectionKey]: {
...base,
accounts: {
...baseAccounts,
[accountKey]: {
...existing,
enabled: params.enabled,
channels: {
...params.cfg.channels,
[params.sectionKey]: {
...base,
accounts: {
...baseAccounts,
[accountKey]: {
...existing,
enabled: params.enabled,
},
},
},
},
@@ -59,9 +64,8 @@ export function deleteAccountFromConfigSection(params: {
clearBaseFields?: string[];
}): ClawdbotConfig {
const accountKey = params.accountId || DEFAULT_ACCOUNT_ID;
const base = (params.cfg as Record<string, unknown>)[params.sectionKey] as
| ProviderSection
| undefined;
const channels = params.cfg.channels as Record<string, unknown> | undefined;
const base = channels?.[params.sectionKey] as ChannelSection | undefined;
if (!base) return params.cfg;
const baseAccounts =
@@ -74,9 +78,12 @@ export function deleteAccountFromConfigSection(params: {
delete accounts[accountKey];
return {
...params.cfg,
[params.sectionKey]: {
...base,
accounts: Object.keys(accounts).length ? accounts : undefined,
channels: {
...params.cfg.channels,
[params.sectionKey]: {
...base,
accounts: Object.keys(accounts).length ? accounts : undefined,
},
},
} as ClawdbotConfig;
}
@@ -89,14 +96,26 @@ export function deleteAccountFromConfigSection(params: {
}
return {
...params.cfg,
[params.sectionKey]: {
...baseRecord,
accounts: Object.keys(baseAccounts).length ? baseAccounts : undefined,
channels: {
...params.cfg.channels,
[params.sectionKey]: {
...baseRecord,
accounts: Object.keys(baseAccounts).length ? baseAccounts : undefined,
},
},
} as ClawdbotConfig;
}
const clone = { ...params.cfg } as Record<string, unknown>;
delete clone[params.sectionKey];
return clone as ClawdbotConfig;
const nextChannels = { ...(params.cfg.channels ?? {}) } as Record<
string,
unknown
>;
delete nextChannels[params.sectionKey];
const nextCfg = { ...params.cfg } as ClawdbotConfig;
if (Object.keys(nextChannels).length > 0) {
nextCfg.channels = nextChannels as ClawdbotConfig["channels"];
} else {
delete nextCfg.channels;
}
return nextCfg;
}

View File

@@ -15,7 +15,7 @@ import {
DEFAULT_ACCOUNT_ID,
normalizeAccountId,
} from "../../routing/session-key.js";
import { getChatProviderMeta } from "../registry.js";
import { getChatChannelMeta } from "../registry.js";
import { discordMessageActions } from "./actions/discord.js";
import {
deleteAccountFromConfigSection,
@@ -27,15 +27,15 @@ import { normalizeDiscordMessagingTarget } from "./normalize-target.js";
import { discordOnboardingAdapter } from "./onboarding/discord.js";
import { PAIRING_APPROVED_MESSAGE } from "./pairing-message.js";
import {
applyAccountNameToProviderSection,
applyAccountNameToChannelSection,
migrateBaseNameToDefaultAccount,
} from "./setup-helpers.js";
import { collectDiscordStatusIssues } from "./status-issues/discord.js";
import type { ProviderPlugin } from "./types.js";
import type { ChannelPlugin } from "./types.js";
const meta = getChatProviderMeta("discord");
const meta = getChatChannelMeta("discord");
export const discordPlugin: ProviderPlugin<ResolvedDiscordAccount> = {
export const discordPlugin: ChannelPlugin<ResolvedDiscordAccount> = {
id: "discord",
meta: {
...meta,
@@ -59,7 +59,7 @@ export const discordPlugin: ProviderPlugin<ResolvedDiscordAccount> = {
streaming: {
blockStreamingCoalesceDefaults: { minChars: 1500, idleMs: 1000 },
},
reload: { configPrefixes: ["discord"] },
reload: { configPrefixes: ["channels.discord"] },
config: {
listAccountIds: (cfg) => listDiscordAccountIds(cfg),
resolveAccount: (cfg, accountId) =>
@@ -103,11 +103,11 @@ export const discordPlugin: ProviderPlugin<ResolvedDiscordAccount> = {
const resolvedAccountId =
accountId ?? account.accountId ?? DEFAULT_ACCOUNT_ID;
const useAccountPath = Boolean(
cfg.discord?.accounts?.[resolvedAccountId],
cfg.channels?.discord?.accounts?.[resolvedAccountId],
);
const allowFromPath = useAccountPath
? `discord.accounts.${resolvedAccountId}.dm.`
: "discord.dm.";
? `channels.discord.accounts.${resolvedAccountId}.dm.`
: "channels.discord.dm.";
return {
policy: account.config.dm?.policy ?? "pairing",
allowFrom: account.config.dm?.allowFrom ?? [],
@@ -125,11 +125,11 @@ export const discordPlugin: ProviderPlugin<ResolvedDiscordAccount> = {
Object.keys(account.config.guilds ?? {}).length > 0;
if (channelAllowlistConfigured) {
return [
`- Discord guilds: groupPolicy="open" allows any channel not explicitly denied to trigger (mention-gated). Set discord.groupPolicy="allowlist" and configure discord.guilds.<id>.channels.`,
`- Discord guilds: groupPolicy="open" allows any channel not explicitly denied to trigger (mention-gated). Set channels.discord.groupPolicy="allowlist" and configure channels.discord.guilds.<id>.channels.`,
];
}
return [
`- Discord guilds: groupPolicy="open" with no guild/channel allowlist; any channel can trigger (mention-gated). Set discord.groupPolicy="allowlist" and configure discord.guilds.<id>.channels.`,
`- Discord guilds: groupPolicy="open" with no guild/channel allowlist; any channel can trigger (mention-gated). Set channels.discord.groupPolicy="allowlist" and configure channels.discord.guilds.<id>.channels.`,
];
},
},
@@ -140,7 +140,8 @@ export const discordPlugin: ProviderPlugin<ResolvedDiscordAccount> = {
stripPatterns: () => ["<@!?\\d+>"],
},
threading: {
resolveReplyToMode: ({ cfg }) => cfg.discord?.replyToMode ?? "off",
resolveReplyToMode: ({ cfg }) =>
cfg.channels?.discord?.replyToMode ?? "off",
},
messaging: {
normalizeTarget: normalizeDiscordMessagingTarget,
@@ -149,9 +150,9 @@ export const discordPlugin: ProviderPlugin<ResolvedDiscordAccount> = {
setup: {
resolveAccountId: ({ accountId }) => normalizeAccountId(accountId),
applyAccountName: ({ cfg, accountId, name }) =>
applyAccountNameToProviderSection({
applyAccountNameToChannelSection({
cfg,
providerKey: "discord",
channelKey: "discord",
accountId,
name,
}),
@@ -165,9 +166,9 @@ export const discordPlugin: ProviderPlugin<ResolvedDiscordAccount> = {
return null;
},
applyAccountConfig: ({ cfg, accountId, input }) => {
const namedConfig = applyAccountNameToProviderSection({
const namedConfig = applyAccountNameToChannelSection({
cfg,
providerKey: "discord",
channelKey: "discord",
accountId,
name: input.name,
});
@@ -175,30 +176,40 @@ export const discordPlugin: ProviderPlugin<ResolvedDiscordAccount> = {
accountId !== DEFAULT_ACCOUNT_ID
? migrateBaseNameToDefaultAccount({
cfg: namedConfig,
providerKey: "discord",
channelKey: "discord",
})
: namedConfig;
if (accountId === DEFAULT_ACCOUNT_ID) {
return {
...next,
discord: {
...next.discord,
enabled: true,
...(input.useEnv ? {} : input.token ? { token: input.token } : {}),
channels: {
...next.channels,
discord: {
...next.channels?.discord,
enabled: true,
...(input.useEnv
? {}
: input.token
? { token: input.token }
: {}),
},
},
};
}
return {
...next,
discord: {
...next.discord,
enabled: true,
accounts: {
...next.discord?.accounts,
[accountId]: {
...next.discord?.accounts?.[accountId],
enabled: true,
...(input.token ? { token: input.token } : {}),
channels: {
...next.channels,
discord: {
...next.channels?.discord,
enabled: true,
accounts: {
...next.channels?.discord?.accounts,
[accountId]: {
...next.channels?.discord?.accounts?.[accountId],
enabled: true,
...(input.token ? { token: input.token } : {}),
},
},
},
},
@@ -229,7 +240,7 @@ export const discordPlugin: ProviderPlugin<ResolvedDiscordAccount> = {
replyTo: replyToId ?? undefined,
accountId: accountId ?? undefined,
});
return { provider: "discord", ...result };
return { channel: "discord", ...result };
},
sendMedia: async ({ to, text, mediaUrl, accountId, deps, replyToId }) => {
const send = deps?.sendDiscord ?? sendMessageDiscord;
@@ -239,7 +250,7 @@ export const discordPlugin: ProviderPlugin<ResolvedDiscordAccount> = {
replyTo: replyToId ?? undefined,
accountId: accountId ?? undefined,
});
return { provider: "discord", ...result };
return { channel: "discord", ...result };
},
sendPoll: async ({ to, poll, accountId }) =>
await sendPollDiscord(to, poll, {
@@ -255,7 +266,7 @@ export const discordPlugin: ProviderPlugin<ResolvedDiscordAccount> = {
lastError: null,
},
collectStatusIssues: collectDiscordStatusIssues,
buildProviderSummary: ({ snapshot }) => ({
buildChannelSummary: ({ snapshot }) => ({
configured: snapshot.configured ?? false,
tokenSource: snapshot.tokenSource ?? "none",
running: snapshot.running ?? false,

View File

@@ -1,5 +1,6 @@
import type { ClawdbotConfig } from "../../config/config.js";
import { resolveProviderGroupRequireMention } from "../../config/group-policy.js";
import { resolveChannelGroupRequireMention } from "../../config/group-policy.js";
import type { DiscordConfig } from "../../config/types.js";
import { resolveSlackAccount } from "../../slack/accounts.js";
type GroupMentionParams = {
@@ -54,8 +55,8 @@ function resolveTelegramRequireMention(params: {
}): boolean | undefined {
const { cfg, chatId, topicId } = params;
if (!chatId) return undefined;
const groupConfig = cfg.telegram?.groups?.[chatId];
const groupDefault = cfg.telegram?.groups?.["*"];
const groupConfig = cfg.channels?.telegram?.groups?.[chatId];
const groupDefault = cfg.channels?.telegram?.groups?.["*"];
const topicConfig =
topicId && groupConfig?.topics ? groupConfig.topics[topicId] : undefined;
const defaultTopicConfig =
@@ -76,7 +77,7 @@ function resolveTelegramRequireMention(params: {
}
function resolveDiscordGuildEntry(
guilds: NonNullable<ClawdbotConfig["discord"]>["guilds"],
guilds: DiscordConfig["guilds"],
groupSpace?: string | null,
) {
if (!guilds || Object.keys(guilds).length === 0) return null;
@@ -103,9 +104,9 @@ export function resolveTelegramGroupRequireMention(
topicId,
});
if (typeof requireMention === "boolean") return requireMention;
return resolveProviderGroupRequireMention({
return resolveChannelGroupRequireMention({
cfg: params.cfg,
provider: "telegram",
channel: "telegram",
groupId: chatId ?? params.groupId,
accountId: params.accountId,
});
@@ -114,9 +115,9 @@ export function resolveTelegramGroupRequireMention(
export function resolveWhatsAppGroupRequireMention(
params: GroupMentionParams,
): boolean {
return resolveProviderGroupRequireMention({
return resolveChannelGroupRequireMention({
cfg: params.cfg,
provider: "whatsapp",
channel: "whatsapp",
groupId: params.groupId,
accountId: params.accountId,
});
@@ -125,9 +126,9 @@ export function resolveWhatsAppGroupRequireMention(
export function resolveIMessageGroupRequireMention(
params: GroupMentionParams,
): boolean {
return resolveProviderGroupRequireMention({
return resolveChannelGroupRequireMention({
cfg: params.cfg,
provider: "imessage",
channel: "imessage",
groupId: params.groupId,
accountId: params.accountId,
});
@@ -137,7 +138,7 @@ export function resolveDiscordGroupRequireMention(
params: GroupMentionParams,
): boolean {
const guildEntry = resolveDiscordGuildEntry(
params.cfg.discord?.guilds,
params.cfg.channels?.discord?.guilds,
params.groupSpace,
);
const channelEntries = guildEntry?.channels;

View File

@@ -0,0 +1,22 @@
import type { ClawdbotConfig } from "../../config/config.js";
import { DEFAULT_ACCOUNT_ID } from "../../routing/session-key.js";
import type { ChannelPlugin } from "./types.js";
// Channel docking helper: use this when selecting the default account for a plugin.
export function resolveChannelDefaultAccountId<ResolvedAccount>(params: {
plugin: ChannelPlugin<ResolvedAccount>;
cfg: ClawdbotConfig;
accountIds?: string[];
}): string {
const accountIds =
params.accountIds ?? params.plugin.config.listAccountIds(params.cfg);
return (
params.plugin.config.defaultAccountId?.(params.cfg) ??
accountIds[0] ??
DEFAULT_ACCOUNT_ID
);
}
export function formatPairingApproveHint(channelId: string): string {
return `Approve via: clawdbot pairing list ${channelId} / clawdbot pairing approve ${channelId} <code>`;
}

View File

@@ -11,25 +11,25 @@ import {
DEFAULT_ACCOUNT_ID,
normalizeAccountId,
} from "../../routing/session-key.js";
import { getChatProviderMeta } from "../registry.js";
import { getChatChannelMeta } from "../registry.js";
import {
deleteAccountFromConfigSection,
setAccountEnabledInConfigSection,
} from "./config-helpers.js";
import { resolveIMessageGroupRequireMention } from "./group-mentions.js";
import { formatPairingApproveHint } from "./helpers.js";
import { resolveProviderMediaMaxBytes } from "./media-limits.js";
import { resolveChannelMediaMaxBytes } from "./media-limits.js";
import { imessageOnboardingAdapter } from "./onboarding/imessage.js";
import { PAIRING_APPROVED_MESSAGE } from "./pairing-message.js";
import {
applyAccountNameToProviderSection,
applyAccountNameToChannelSection,
migrateBaseNameToDefaultAccount,
} from "./setup-helpers.js";
import type { ProviderPlugin } from "./types.js";
import type { ChannelPlugin } from "./types.js";
const meta = getChatProviderMeta("imessage");
const meta = getChatChannelMeta("imessage");
export const imessagePlugin: ProviderPlugin<ResolvedIMessageAccount> = {
export const imessagePlugin: ChannelPlugin<ResolvedIMessageAccount> = {
id: "imessage",
meta: {
...meta,
@@ -46,7 +46,7 @@ export const imessagePlugin: ProviderPlugin<ResolvedIMessageAccount> = {
chatTypes: ["direct", "group"],
media: true,
},
reload: { configPrefixes: ["imessage"] },
reload: { configPrefixes: ["channels.imessage"] },
config: {
listAccountIds: (cfg) => listIMessageAccountIds(cfg),
resolveAccount: (cfg, accountId) =>
@@ -86,11 +86,11 @@ export const imessagePlugin: ProviderPlugin<ResolvedIMessageAccount> = {
const resolvedAccountId =
accountId ?? account.accountId ?? DEFAULT_ACCOUNT_ID;
const useAccountPath = Boolean(
cfg.imessage?.accounts?.[resolvedAccountId],
cfg.channels?.imessage?.accounts?.[resolvedAccountId],
);
const basePath = useAccountPath
? `imessage.accounts.${resolvedAccountId}.`
: "imessage.";
? `channels.imessage.accounts.${resolvedAccountId}.`
: "channels.imessage.";
return {
policy: account.config.dmPolicy ?? "pairing",
allowFrom: account.config.allowFrom ?? [],
@@ -103,7 +103,7 @@ export const imessagePlugin: ProviderPlugin<ResolvedIMessageAccount> = {
const groupPolicy = account.config.groupPolicy ?? "allowlist";
if (groupPolicy !== "open") return [];
return [
`- iMessage groups: groupPolicy="open" allows any member to trigger the bot. Set imessage.groupPolicy="allowlist" + imessage.groupAllowFrom to restrict senders.`,
`- iMessage groups: groupPolicy="open" allows any member to trigger the bot. Set channels.imessage.groupPolicy="allowlist" + channels.imessage.groupAllowFrom to restrict senders.`,
];
},
},
@@ -113,16 +113,16 @@ export const imessagePlugin: ProviderPlugin<ResolvedIMessageAccount> = {
setup: {
resolveAccountId: ({ accountId }) => normalizeAccountId(accountId),
applyAccountName: ({ cfg, accountId, name }) =>
applyAccountNameToProviderSection({
applyAccountNameToChannelSection({
cfg,
providerKey: "imessage",
channelKey: "imessage",
accountId,
name,
}),
applyAccountConfig: ({ cfg, accountId, input }) => {
const namedConfig = applyAccountNameToProviderSection({
const namedConfig = applyAccountNameToChannelSection({
cfg,
providerKey: "imessage",
channelKey: "imessage",
accountId,
name: input.name,
});
@@ -130,31 +130,16 @@ export const imessagePlugin: ProviderPlugin<ResolvedIMessageAccount> = {
accountId !== DEFAULT_ACCOUNT_ID
? migrateBaseNameToDefaultAccount({
cfg: namedConfig,
providerKey: "imessage",
channelKey: "imessage",
})
: namedConfig;
if (accountId === DEFAULT_ACCOUNT_ID) {
return {
...next,
imessage: {
...next.imessage,
enabled: true,
...(input.cliPath ? { cliPath: input.cliPath } : {}),
...(input.dbPath ? { dbPath: input.dbPath } : {}),
...(input.service ? { service: input.service } : {}),
...(input.region ? { region: input.region } : {}),
},
};
}
return {
...next,
imessage: {
...next.imessage,
enabled: true,
accounts: {
...next.imessage?.accounts,
[accountId]: {
...next.imessage?.accounts?.[accountId],
channels: {
...next.channels,
imessage: {
...next.channels?.imessage,
enabled: true,
...(input.cliPath ? { cliPath: input.cliPath } : {}),
...(input.dbPath ? { dbPath: input.dbPath } : {}),
@@ -162,6 +147,27 @@ export const imessagePlugin: ProviderPlugin<ResolvedIMessageAccount> = {
...(input.region ? { region: input.region } : {}),
},
},
};
}
return {
...next,
channels: {
...next.channels,
imessage: {
...next.channels?.imessage,
enabled: true,
accounts: {
...next.channels?.imessage?.accounts,
[accountId]: {
...next.channels?.imessage?.accounts?.[accountId],
enabled: true,
...(input.cliPath ? { cliPath: input.cliPath } : {}),
...(input.dbPath ? { dbPath: input.dbPath } : {}),
...(input.service ? { service: input.service } : {}),
...(input.region ? { region: input.region } : {}),
},
},
},
},
};
},
@@ -184,26 +190,26 @@ export const imessagePlugin: ProviderPlugin<ResolvedIMessageAccount> = {
},
sendText: async ({ cfg, to, text, accountId, deps }) => {
const send = deps?.sendIMessage ?? sendMessageIMessage;
const maxBytes = resolveProviderMediaMaxBytes({
const maxBytes = resolveChannelMediaMaxBytes({
cfg,
resolveProviderLimitMb: ({ cfg, accountId }) =>
cfg.imessage?.accounts?.[accountId]?.mediaMaxMb ??
cfg.imessage?.mediaMaxMb,
resolveChannelLimitMb: ({ cfg, accountId }) =>
cfg.channels?.imessage?.accounts?.[accountId]?.mediaMaxMb ??
cfg.channels?.imessage?.mediaMaxMb,
accountId,
});
const result = await send(to, text, {
maxBytes,
accountId: accountId ?? undefined,
});
return { provider: "imessage", ...result };
return { channel: "imessage", ...result };
},
sendMedia: async ({ cfg, to, text, mediaUrl, accountId, deps }) => {
const send = deps?.sendIMessage ?? sendMessageIMessage;
const maxBytes = resolveProviderMediaMaxBytes({
const maxBytes = resolveChannelMediaMaxBytes({
cfg,
resolveProviderLimitMb: ({ cfg, accountId }) =>
cfg.imessage?.accounts?.[accountId]?.mediaMaxMb ??
cfg.imessage?.mediaMaxMb,
resolveChannelLimitMb: ({ cfg, accountId }) =>
cfg.channels?.imessage?.accounts?.[accountId]?.mediaMaxMb ??
cfg.channels?.imessage?.mediaMaxMb,
accountId,
});
const result = await send(to, text, {
@@ -211,7 +217,7 @@ export const imessagePlugin: ProviderPlugin<ResolvedIMessageAccount> = {
maxBytes,
accountId: accountId ?? undefined,
});
return { provider: "imessage", ...result };
return { channel: "imessage", ...result };
},
},
status: {
@@ -231,14 +237,14 @@ export const imessagePlugin: ProviderPlugin<ResolvedIMessageAccount> = {
if (!lastError) return [];
return [
{
provider: "imessage",
channel: "imessage",
accountId: account.accountId,
kind: "runtime",
message: `Provider error: ${lastError}`,
message: `Channel error: ${lastError}`,
},
];
}),
buildProviderSummary: ({ snapshot }) => ({
buildChannelSummary: ({ snapshot }) => ({
configured: snapshot.configured ?? false,
running: snapshot.running ?? false,
lastStartAt: snapshot.lastStartAt ?? null,

View File

@@ -0,0 +1,14 @@
import { describe, expect, it } from "vitest";
import { CHANNEL_IDS } from "../registry.js";
import { listChannelPlugins } from "./index.js";
describe("channel plugin registry", () => {
it("stays in sync with channel ids", () => {
const pluginIds = listChannelPlugins()
.map((plugin) => plugin.id)
.slice()
.sort();
const channelIds = [...CHANNEL_IDS].slice().sort();
expect(pluginIds).toEqual(channelIds);
});
});

View File

@@ -0,0 +1,67 @@
import {
CHAT_CHANNEL_ORDER,
type ChatChannelId,
normalizeChatChannelId,
} from "../registry.js";
import { discordPlugin } from "./discord.js";
import { imessagePlugin } from "./imessage.js";
import { msteamsPlugin } from "./msteams.js";
import { signalPlugin } from "./signal.js";
import { slackPlugin } from "./slack.js";
import { telegramPlugin } from "./telegram.js";
import type { ChannelId, ChannelPlugin } from "./types.js";
import { whatsappPlugin } from "./whatsapp.js";
// Channel plugins registry (runtime).
//
// This module is intentionally "heavy" (plugins may import channel monitors, web login, etc).
// Shared code paths (reply flow, command auth, sandbox explain) should depend on `src/channels/dock.ts`
// instead, and only call `getChannelPlugin()` at execution boundaries.
//
// Adding a channel:
// - add `<id>Plugin` import + entry in `resolveChannels()`
// - add an entry to `src/channels/dock.ts` for shared behavior (capabilities, allowFrom, threading, …)
// - add ids/aliases in `src/channels/registry.ts`
function resolveChannels(): ChannelPlugin[] {
return [
telegramPlugin,
whatsappPlugin,
discordPlugin,
slackPlugin,
signalPlugin,
imessagePlugin,
msteamsPlugin,
];
}
export function listChannelPlugins(): ChannelPlugin[] {
return resolveChannels().sort((a, b) => {
const indexA = CHAT_CHANNEL_ORDER.indexOf(a.id as ChatChannelId);
const indexB = CHAT_CHANNEL_ORDER.indexOf(b.id as ChatChannelId);
const orderA = a.meta.order ?? (indexA === -1 ? 999 : indexA);
const orderB = b.meta.order ?? (indexB === -1 ? 999 : indexB);
if (orderA !== orderB) return orderA - orderB;
return a.id.localeCompare(b.id);
});
}
export function getChannelPlugin(id: ChannelId): ChannelPlugin | undefined {
return resolveChannels().find((plugin) => plugin.id === id);
}
export function normalizeChannelId(raw?: string | null): ChannelId | null {
// Channel docking: keep input normalization centralized in src/channels/registry.ts
// so CLI/API/protocol can rely on stable aliases without plugin init side effects.
return normalizeChatChannelId(raw);
}
export {
discordPlugin,
imessagePlugin,
msteamsPlugin,
signalPlugin,
slackPlugin,
telegramPlugin,
whatsappPlugin,
};
export type { ChannelId, ChannelPlugin } from "./types.js";

View File

@@ -1,12 +1,12 @@
import type { ProviderId, ProviderPlugin } from "./types.js";
import type { ChannelId, ChannelPlugin } from "./types.js";
type PluginLoader = () => Promise<ProviderPlugin>;
type PluginLoader = () => Promise<ChannelPlugin>;
// Provider docking: load *one* plugin on-demand.
// Channel docking: load *one* plugin on-demand.
//
// This avoids importing `src/providers/plugins/index.ts` (intentionally heavy)
// This avoids importing `src/channels/plugins/index.ts` (intentionally heavy)
// from shared flows like outbound delivery / followup routing.
const LOADERS: Record<ProviderId, PluginLoader> = {
const LOADERS: Record<ChannelId, PluginLoader> = {
telegram: async () => (await import("./telegram.js")).telegramPlugin,
whatsapp: async () => (await import("./whatsapp.js")).whatsappPlugin,
discord: async () => (await import("./discord.js")).discordPlugin,
@@ -16,11 +16,11 @@ const LOADERS: Record<ProviderId, PluginLoader> = {
msteams: async () => (await import("./msteams.js")).msteamsPlugin,
};
const cache = new Map<ProviderId, ProviderPlugin>();
const cache = new Map<ChannelId, ChannelPlugin>();
export async function loadProviderPlugin(
id: ProviderId,
): Promise<ProviderPlugin | undefined> {
export async function loadChannelPlugin(
id: ChannelId,
): Promise<ChannelPlugin | undefined> {
const cached = cache.get(id);
if (cached) return cached;
const loader = LOADERS[id];

View File

@@ -3,22 +3,22 @@ import { normalizeAccountId } from "../../routing/session-key.js";
const MB = 1024 * 1024;
export function resolveProviderMediaMaxBytes(params: {
export function resolveChannelMediaMaxBytes(params: {
cfg: ClawdbotConfig;
// Provider-specific config lives under different keys; keep this helper generic
// so shared plugin helpers don't need provider-id branching.
resolveProviderLimitMb: (params: {
// Channel-specific config lives under different keys; keep this helper generic
// so shared plugin helpers don't need channel-id branching.
resolveChannelLimitMb: (params: {
cfg: ClawdbotConfig;
accountId: string;
}) => number | undefined;
accountId?: string | null;
}): number | undefined {
const accountId = normalizeAccountId(params.accountId);
const providerLimit = params.resolveProviderLimitMb({
const channelLimit = params.resolveChannelLimitMb({
cfg: params.cfg,
accountId,
});
if (providerLimit) return providerLimit * MB;
if (channelLimit) return channelLimit * MB;
if (params.cfg.agents?.defaults?.mediaMaxMb) {
return params.cfg.agents.defaults.mediaMaxMb * MB;
}

View File

@@ -1,4 +1,4 @@
export const PROVIDER_MESSAGE_ACTION_NAMES = [
export const CHANNEL_MESSAGE_ACTION_NAMES = [
"send",
"poll",
"react",
@@ -39,5 +39,5 @@ export const PROVIDER_MESSAGE_ACTION_NAMES = [
"ban",
] as const;
export type ProviderMessageActionName =
(typeof PROVIDER_MESSAGE_ACTION_NAMES)[number];
export type ChannelMessageActionName =
(typeof CHANNEL_MESSAGE_ACTION_NAMES)[number];

View File

@@ -1,17 +1,17 @@
import type { AgentToolResult } from "@mariozechner/pi-agent-core";
import type { ClawdbotConfig } from "../../config/config.js";
import { getProviderPlugin, listProviderPlugins } from "./index.js";
import { getChannelPlugin, listChannelPlugins } from "./index.js";
import type {
ProviderMessageActionContext,
ProviderMessageActionName,
ChannelMessageActionContext,
ChannelMessageActionName,
} from "./types.js";
export function listProviderMessageActions(
export function listChannelMessageActions(
cfg: ClawdbotConfig,
): ProviderMessageActionName[] {
const actions = new Set<ProviderMessageActionName>(["send"]);
for (const plugin of listProviderPlugins()) {
): ChannelMessageActionName[] {
const actions = new Set<ChannelMessageActionName>(["send"]);
for (const plugin of listChannelPlugins()) {
const list = plugin.actions?.listActions?.({ cfg });
if (!list) continue;
for (const action of list) actions.add(action);
@@ -19,17 +19,17 @@ export function listProviderMessageActions(
return Array.from(actions);
}
export function supportsProviderMessageButtons(cfg: ClawdbotConfig): boolean {
for (const plugin of listProviderPlugins()) {
export function supportsChannelMessageButtons(cfg: ClawdbotConfig): boolean {
for (const plugin of listChannelPlugins()) {
if (plugin.actions?.supportsButtons?.({ cfg })) return true;
}
return false;
}
export async function dispatchProviderMessageAction(
ctx: ProviderMessageActionContext,
export async function dispatchChannelMessageAction(
ctx: ChannelMessageActionContext,
): Promise<AgentToolResult<unknown> | null> {
const plugin = getProviderPlugin(ctx.provider);
const plugin = getChannelPlugin(ctx.channel);
if (!plugin?.actions?.handleAction) return null;
if (
plugin.actions.supportsAction &&

View File

@@ -1,11 +1,12 @@
import { chunkMarkdownText } from "../../auto-reply/chunk.js";
import type { ClawdbotConfig } from "../../config/config.js";
import { createMSTeamsPollStoreFs } from "../../msteams/polls.js";
import { sendMessageMSTeams, sendPollMSTeams } from "../../msteams/send.js";
import { resolveMSTeamsCredentials } from "../../msteams/token.js";
import { DEFAULT_ACCOUNT_ID } from "../../routing/session-key.js";
import { msteamsOnboardingAdapter } from "./onboarding/msteams.js";
import { PAIRING_APPROVED_MESSAGE } from "./pairing-message.js";
import type { ProviderMessageActionName, ProviderPlugin } from "./types.js";
import type { ChannelMessageActionName, ChannelPlugin } from "./types.js";
type ResolvedMSTeamsAccount = {
accountId: string;
@@ -22,7 +23,7 @@ const meta = {
blurb: "bot via Microsoft Teams.",
} as const;
export const msteamsPlugin: ProviderPlugin<ResolvedMSTeamsAccount> = {
export const msteamsPlugin: ChannelPlugin<ResolvedMSTeamsAccount> = {
id: "msteams",
meta: {
...meta,
@@ -45,35 +46,44 @@ export const msteamsPlugin: ProviderPlugin<ResolvedMSTeamsAccount> = {
threads: true,
media: true,
},
reload: { configPrefixes: ["msteams"] },
reload: { configPrefixes: ["channels.msteams"] },
config: {
listAccountIds: () => [DEFAULT_ACCOUNT_ID],
resolveAccount: (cfg) => ({
accountId: DEFAULT_ACCOUNT_ID,
enabled: cfg.msteams?.enabled !== false,
configured: Boolean(resolveMSTeamsCredentials(cfg.msteams)),
enabled: cfg.channels?.msteams?.enabled !== false,
configured: Boolean(resolveMSTeamsCredentials(cfg.channels?.msteams)),
}),
defaultAccountId: () => DEFAULT_ACCOUNT_ID,
setAccountEnabled: ({ cfg, enabled }) => ({
...cfg,
msteams: {
...cfg.msteams,
enabled,
channels: {
...cfg.channels,
msteams: {
...cfg.channels?.msteams,
enabled,
},
},
}),
deleteAccount: ({ cfg }) => {
const next = { ...cfg } as Record<string, unknown>;
delete next.msteams;
return next as typeof cfg;
const next = { ...cfg } as ClawdbotConfig;
const nextChannels = { ...(cfg.channels ?? {}) };
delete nextChannels.msteams;
if (Object.keys(nextChannels).length > 0) {
next.channels = nextChannels;
} else {
delete next.channels;
}
return next;
},
isConfigured: (_account, cfg) =>
Boolean(resolveMSTeamsCredentials(cfg.msteams)),
Boolean(resolveMSTeamsCredentials(cfg.channels?.msteams)),
describeAccount: (account) => ({
accountId: account.accountId,
enabled: account.enabled,
configured: account.configured,
}),
resolveAllowFrom: ({ cfg }) => cfg.msteams?.allowFrom ?? [],
resolveAllowFrom: ({ cfg }) => cfg.channels?.msteams?.allowFrom ?? [],
formatAllowFrom: ({ allowFrom }) =>
allowFrom
.map((entry) => String(entry).trim())
@@ -82,10 +92,10 @@ export const msteamsPlugin: ProviderPlugin<ResolvedMSTeamsAccount> = {
},
security: {
collectWarnings: ({ cfg }) => {
const groupPolicy = cfg.msteams?.groupPolicy ?? "allowlist";
const groupPolicy = cfg.channels?.msteams?.groupPolicy ?? "allowlist";
if (groupPolicy !== "open") return [];
return [
`- MS Teams groups: groupPolicy="open" allows any member to trigger (mention-gated). Set msteams.groupPolicy="allowlist" + msteams.groupAllowFrom to restrict senders.`,
`- MS Teams groups: groupPolicy="open" allows any member to trigger (mention-gated). Set channels.msteams.groupPolicy="allowlist" + channels.msteams.groupAllowFrom to restrict senders.`,
];
},
},
@@ -93,19 +103,22 @@ export const msteamsPlugin: ProviderPlugin<ResolvedMSTeamsAccount> = {
resolveAccountId: () => DEFAULT_ACCOUNT_ID,
applyAccountConfig: ({ cfg }) => ({
...cfg,
msteams: {
...cfg.msteams,
enabled: true,
channels: {
...cfg.channels,
msteams: {
...cfg.channels?.msteams,
enabled: true,
},
},
}),
},
actions: {
listActions: ({ cfg }) => {
const enabled =
cfg.msteams?.enabled !== false &&
Boolean(resolveMSTeamsCredentials(cfg.msteams));
cfg.channels?.msteams?.enabled !== false &&
Boolean(resolveMSTeamsCredentials(cfg.channels?.msteams));
if (!enabled) return [];
return ["poll"] satisfies ProviderMessageActionName[];
return ["poll"] satisfies ChannelMessageActionName[];
},
},
outbound: {
@@ -130,7 +143,7 @@ export const msteamsPlugin: ProviderPlugin<ResolvedMSTeamsAccount> = {
deps?.sendMSTeams ??
((to, text) => sendMessageMSTeams({ cfg, to, text }));
const result = await send(to, text);
return { provider: "msteams", ...result };
return { channel: "msteams", ...result };
},
sendMedia: async ({ cfg, to, text, mediaUrl, deps }) => {
const send =
@@ -138,7 +151,7 @@ export const msteamsPlugin: ProviderPlugin<ResolvedMSTeamsAccount> = {
((to, text, opts) =>
sendMessageMSTeams({ cfg, to, text, mediaUrl: opts?.mediaUrl }));
const result = await send(to, text, { mediaUrl });
return { provider: "msteams", ...result };
return { channel: "msteams", ...result };
},
sendPoll: async ({ cfg, to, poll }) => {
const maxSelections = poll.maxSelections ?? 1;
@@ -172,7 +185,7 @@ export const msteamsPlugin: ProviderPlugin<ResolvedMSTeamsAccount> = {
lastError: null,
port: null,
},
buildProviderSummary: ({ snapshot }) => ({
buildChannelSummary: ({ snapshot }) => ({
configured: snapshot.configured ?? false,
running: snapshot.running ?? false,
lastStartAt: snapshot.lastStartAt ?? null,
@@ -196,7 +209,7 @@ export const msteamsPlugin: ProviderPlugin<ResolvedMSTeamsAccount> = {
gateway: {
startAccount: async (ctx) => {
const { monitorMSTeamsProvider } = await import("../../msteams/index.js");
const port = ctx.cfg.msteams?.webhook?.port ?? 3978;
const port = ctx.cfg.channels?.msteams?.webhook?.port ?? 3978;
ctx.setStatus({ accountId: ctx.accountId, port });
ctx.log?.info(`starting provider (port ${port})`);
return monitorMSTeamsProvider({

View File

@@ -2,23 +2,23 @@ import type { ClawdbotConfig } from "../../config/config.js";
import type { DmPolicy } from "../../config/types.js";
import type { RuntimeEnv } from "../../runtime.js";
import type { WizardPrompter } from "../../wizard/prompts.js";
import type { ChatProviderId } from "../registry.js";
import type { ChatChannelId } from "../registry.js";
export type SetupProvidersOptions = {
export type SetupChannelsOptions = {
allowDisable?: boolean;
allowSignalInstall?: boolean;
onSelection?: (selection: ChatProviderId[]) => void;
accountIds?: Partial<Record<ChatProviderId, string>>;
onAccountId?: (provider: ChatProviderId, accountId: string) => void;
onSelection?: (selection: ChatChannelId[]) => void;
accountIds?: Partial<Record<ChatChannelId, string>>;
onAccountId?: (channel: ChatChannelId, accountId: string) => void;
promptAccountIds?: boolean;
whatsappAccountId?: string;
promptWhatsAppAccountId?: boolean;
onWhatsAppAccountId?: (accountId: string) => void;
forceAllowFromProviders?: ChatProviderId[];
forceAllowFromChannels?: ChatChannelId[];
skipDmPolicyPrompt?: boolean;
skipConfirm?: boolean;
quickstartDefaults?: boolean;
initialSelection?: ChatProviderId[];
initialSelection?: ChatChannelId[];
};
export type PromptAccountIdParams = {
@@ -34,56 +34,56 @@ export type PromptAccountId = (
params: PromptAccountIdParams,
) => Promise<string>;
export type ProviderOnboardingStatus = {
provider: ChatProviderId;
export type ChannelOnboardingStatus = {
channel: ChatChannelId;
configured: boolean;
statusLines: string[];
selectionHint?: string;
quickstartScore?: number;
};
export type ProviderOnboardingStatusContext = {
export type ChannelOnboardingStatusContext = {
cfg: ClawdbotConfig;
options?: SetupProvidersOptions;
accountOverrides: Partial<Record<ChatProviderId, string>>;
options?: SetupChannelsOptions;
accountOverrides: Partial<Record<ChatChannelId, string>>;
};
export type ProviderOnboardingConfigureContext = {
export type ChannelOnboardingConfigureContext = {
cfg: ClawdbotConfig;
runtime: RuntimeEnv;
prompter: WizardPrompter;
options?: SetupProvidersOptions;
accountOverrides: Partial<Record<ChatProviderId, string>>;
options?: SetupChannelsOptions;
accountOverrides: Partial<Record<ChatChannelId, string>>;
shouldPromptAccountIds: boolean;
forceAllowFrom: boolean;
};
export type ProviderOnboardingResult = {
export type ChannelOnboardingResult = {
cfg: ClawdbotConfig;
accountId?: string;
};
export type ProviderOnboardingDmPolicy = {
export type ChannelOnboardingDmPolicy = {
label: string;
provider: ChatProviderId;
channel: ChatChannelId;
policyKey: string;
allowFromKey: string;
getCurrent: (cfg: ClawdbotConfig) => DmPolicy;
setPolicy: (cfg: ClawdbotConfig, policy: DmPolicy) => ClawdbotConfig;
};
export type ProviderOnboardingAdapter = {
provider: ChatProviderId;
export type ChannelOnboardingAdapter = {
channel: ChatChannelId;
getStatus: (
ctx: ProviderOnboardingStatusContext,
) => Promise<ProviderOnboardingStatus>;
ctx: ChannelOnboardingStatusContext,
) => Promise<ChannelOnboardingStatus>;
configure: (
ctx: ProviderOnboardingConfigureContext,
) => Promise<ProviderOnboardingResult>;
dmPolicy?: ProviderOnboardingDmPolicy;
ctx: ChannelOnboardingConfigureContext,
) => Promise<ChannelOnboardingResult>;
dmPolicy?: ChannelOnboardingDmPolicy;
onAccountRecorded?: (
accountId: string,
options?: SetupProvidersOptions,
options?: SetupChannelsOptions,
) => void;
disable?: (cfg: ClawdbotConfig) => ClawdbotConfig;
};

View File

@@ -12,27 +12,30 @@ import {
import { formatDocsLink } from "../../../terminal/links.js";
import type { WizardPrompter } from "../../../wizard/prompts.js";
import type {
ProviderOnboardingAdapter,
ProviderOnboardingDmPolicy,
ChannelOnboardingAdapter,
ChannelOnboardingDmPolicy,
} from "../onboarding-types.js";
import { addWildcardAllowFrom, promptAccountId } from "./helpers.js";
const provider = "discord" as const;
const channel = "discord" as const;
function setDiscordDmPolicy(cfg: ClawdbotConfig, dmPolicy: DmPolicy) {
const allowFrom =
dmPolicy === "open"
? addWildcardAllowFrom(cfg.discord?.dm?.allowFrom)
? addWildcardAllowFrom(cfg.channels?.discord?.dm?.allowFrom)
: undefined;
return {
...cfg,
discord: {
...cfg.discord,
dm: {
...cfg.discord?.dm,
enabled: cfg.discord?.dm?.enabled ?? true,
policy: dmPolicy,
...(allowFrom ? { allowFrom } : {}),
channels: {
...cfg.channels,
discord: {
...cfg.channels?.discord,
dm: {
...cfg.channels?.discord?.dm,
enabled: cfg.channels?.discord?.dm?.enabled ?? true,
policy: dmPolicy,
...(allowFrom ? { allowFrom } : {}),
},
},
},
};
@@ -51,23 +54,23 @@ async function noteDiscordTokenHelp(prompter: WizardPrompter): Promise<void> {
);
}
const dmPolicy: ProviderOnboardingDmPolicy = {
const dmPolicy: ChannelOnboardingDmPolicy = {
label: "Discord",
provider,
policyKey: "discord.dm.policy",
allowFromKey: "discord.dm.allowFrom",
getCurrent: (cfg) => cfg.discord?.dm?.policy ?? "pairing",
channel,
policyKey: "channels.discord.dm.policy",
allowFromKey: "channels.discord.dm.allowFrom",
getCurrent: (cfg) => cfg.channels?.discord?.dm?.policy ?? "pairing",
setPolicy: (cfg, policy) => setDiscordDmPolicy(cfg, policy),
};
export const discordOnboardingAdapter: ProviderOnboardingAdapter = {
provider,
export const discordOnboardingAdapter: ChannelOnboardingAdapter = {
channel,
getStatus: async ({ cfg }) => {
const configured = listDiscordAccountIds(cfg).some((accountId) =>
Boolean(resolveDiscordAccount({ cfg, accountId }).token),
);
return {
provider,
channel,
configured,
statusLines: [`Discord: ${configured ? "configured" : "needs token"}`],
selectionHint: configured ? "configured" : "needs token",
@@ -119,9 +122,9 @@ export const discordOnboardingAdapter: ProviderOnboardingAdapter = {
if (keepEnv) {
next = {
...next,
discord: {
...next.discord,
enabled: true,
channels: {
...next.channels,
discord: { ...next.channels?.discord, enabled: true },
},
};
} else {
@@ -158,25 +161,28 @@ export const discordOnboardingAdapter: ProviderOnboardingAdapter = {
if (discordAccountId === DEFAULT_ACCOUNT_ID) {
next = {
...next,
discord: {
...next.discord,
enabled: true,
token,
channels: {
...next.channels,
discord: { ...next.channels?.discord, enabled: true, token },
},
};
} else {
next = {
...next,
discord: {
...next.discord,
enabled: true,
accounts: {
...next.discord?.accounts,
[discordAccountId]: {
...next.discord?.accounts?.[discordAccountId],
enabled:
next.discord?.accounts?.[discordAccountId]?.enabled ?? true,
token,
channels: {
...next.channels,
discord: {
...next.channels?.discord,
enabled: true,
accounts: {
...next.channels?.discord?.accounts,
[discordAccountId]: {
...next.channels?.discord?.accounts?.[discordAccountId],
enabled:
next.channels?.discord?.accounts?.[discordAccountId]
?.enabled ?? true,
token,
},
},
},
},
@@ -189,6 +195,9 @@ export const discordOnboardingAdapter: ProviderOnboardingAdapter = {
dmPolicy,
disable: (cfg) => ({
...cfg,
discord: { ...cfg.discord, enabled: false },
channels: {
...cfg.channels,
discord: { ...cfg.channels?.discord, enabled: false },
},
}),
};

View File

@@ -12,39 +12,42 @@ import {
} from "../../../routing/session-key.js";
import { formatDocsLink } from "../../../terminal/links.js";
import type {
ProviderOnboardingAdapter,
ProviderOnboardingDmPolicy,
ChannelOnboardingAdapter,
ChannelOnboardingDmPolicy,
} from "../onboarding-types.js";
import { addWildcardAllowFrom, promptAccountId } from "./helpers.js";
const provider = "imessage" as const;
const channel = "imessage" as const;
function setIMessageDmPolicy(cfg: ClawdbotConfig, dmPolicy: DmPolicy) {
const allowFrom =
dmPolicy === "open"
? addWildcardAllowFrom(cfg.imessage?.allowFrom)
? addWildcardAllowFrom(cfg.channels?.imessage?.allowFrom)
: undefined;
return {
...cfg,
imessage: {
...cfg.imessage,
dmPolicy,
...(allowFrom ? { allowFrom } : {}),
channels: {
...cfg.channels,
imessage: {
...cfg.channels?.imessage,
dmPolicy,
...(allowFrom ? { allowFrom } : {}),
},
},
};
}
const dmPolicy: ProviderOnboardingDmPolicy = {
const dmPolicy: ChannelOnboardingDmPolicy = {
label: "iMessage",
provider,
policyKey: "imessage.dmPolicy",
allowFromKey: "imessage.allowFrom",
getCurrent: (cfg) => cfg.imessage?.dmPolicy ?? "pairing",
channel,
policyKey: "channels.imessage.dmPolicy",
allowFromKey: "channels.imessage.allowFrom",
getCurrent: (cfg) => cfg.channels?.imessage?.dmPolicy ?? "pairing",
setPolicy: (cfg, policy) => setIMessageDmPolicy(cfg, policy),
};
export const imessageOnboardingAdapter: ProviderOnboardingAdapter = {
provider,
export const imessageOnboardingAdapter: ChannelOnboardingAdapter = {
channel,
getStatus: async ({ cfg }) => {
const configured = listIMessageAccountIds(cfg).some((accountId) => {
const account = resolveIMessageAccount({ cfg, accountId });
@@ -56,10 +59,10 @@ export const imessageOnboardingAdapter: ProviderOnboardingAdapter = {
account.config.region,
);
});
const imessageCliPath = cfg.imessage?.cliPath ?? "imsg";
const imessageCliPath = cfg.channels?.imessage?.cliPath ?? "imsg";
const imessageCliDetected = await detectBinary(imessageCliPath);
return {
provider,
channel,
configured,
statusLines: [
`iMessage: ${configured ? "configured" : "needs setup"}`,
@@ -117,25 +120,32 @@ export const imessageOnboardingAdapter: ProviderOnboardingAdapter = {
if (imessageAccountId === DEFAULT_ACCOUNT_ID) {
next = {
...next,
imessage: {
...next.imessage,
enabled: true,
cliPath: resolvedCliPath,
channels: {
...next.channels,
imessage: {
...next.channels?.imessage,
enabled: true,
cliPath: resolvedCliPath,
},
},
};
} else {
next = {
...next,
imessage: {
...next.imessage,
enabled: true,
accounts: {
...next.imessage?.accounts,
[imessageAccountId]: {
...next.imessage?.accounts?.[imessageAccountId],
enabled:
next.imessage?.accounts?.[imessageAccountId]?.enabled ?? true,
cliPath: resolvedCliPath,
channels: {
...next.channels,
imessage: {
...next.channels?.imessage,
enabled: true,
accounts: {
...next.channels?.imessage?.accounts,
[imessageAccountId]: {
...next.channels?.imessage?.accounts?.[imessageAccountId],
enabled:
next.channels?.imessage?.accounts?.[imessageAccountId]
?.enabled ?? true,
cliPath: resolvedCliPath,
},
},
},
},
@@ -159,6 +169,9 @@ export const imessageOnboardingAdapter: ProviderOnboardingAdapter = {
dmPolicy,
disable: (cfg) => ({
...cfg,
imessage: { ...cfg.imessage, enabled: false },
channels: {
...cfg.channels,
imessage: { ...cfg.channels?.imessage, enabled: false },
},
}),
};

View File

@@ -5,26 +5,29 @@ import { DEFAULT_ACCOUNT_ID } from "../../../routing/session-key.js";
import { formatDocsLink } from "../../../terminal/links.js";
import type { WizardPrompter } from "../../../wizard/prompts.js";
import type {
ProviderOnboardingAdapter,
ProviderOnboardingDmPolicy,
ChannelOnboardingAdapter,
ChannelOnboardingDmPolicy,
} from "../onboarding-types.js";
import { addWildcardAllowFrom } from "./helpers.js";
const provider = "msteams" as const;
const channel = "msteams" as const;
function setMSTeamsDmPolicy(cfg: ClawdbotConfig, dmPolicy: DmPolicy) {
const allowFrom =
dmPolicy === "open"
? addWildcardAllowFrom(cfg.msteams?.allowFrom)?.map((entry) =>
? addWildcardAllowFrom(cfg.channels?.msteams?.allowFrom)?.map((entry) =>
String(entry),
)
: undefined;
return {
...cfg,
msteams: {
...cfg.msteams,
dmPolicy,
...(allowFrom ? { allowFrom } : {}),
channels: {
...cfg.channels,
msteams: {
...cfg.channels?.msteams,
dmPolicy,
...(allowFrom ? { allowFrom } : {}),
},
},
};
}
@@ -44,21 +47,23 @@ async function noteMSTeamsCredentialHelp(
);
}
const dmPolicy: ProviderOnboardingDmPolicy = {
const dmPolicy: ChannelOnboardingDmPolicy = {
label: "MS Teams",
provider,
policyKey: "msteams.dmPolicy",
allowFromKey: "msteams.allowFrom",
getCurrent: (cfg) => cfg.msteams?.dmPolicy ?? "pairing",
channel,
policyKey: "channels.msteams.dmPolicy",
allowFromKey: "channels.msteams.allowFrom",
getCurrent: (cfg) => cfg.channels?.msteams?.dmPolicy ?? "pairing",
setPolicy: (cfg, policy) => setMSTeamsDmPolicy(cfg, policy),
};
export const msteamsOnboardingAdapter: ProviderOnboardingAdapter = {
provider,
export const msteamsOnboardingAdapter: ChannelOnboardingAdapter = {
channel,
getStatus: async ({ cfg }) => {
const configured = Boolean(resolveMSTeamsCredentials(cfg.msteams));
const configured = Boolean(
resolveMSTeamsCredentials(cfg.channels?.msteams),
);
return {
provider,
channel,
configured,
statusLines: [
`MS Teams: ${configured ? "configured" : "needs app credentials"}`,
@@ -68,11 +73,11 @@ export const msteamsOnboardingAdapter: ProviderOnboardingAdapter = {
};
},
configure: async ({ cfg, prompter }) => {
const resolved = resolveMSTeamsCredentials(cfg.msteams);
const resolved = resolveMSTeamsCredentials(cfg.channels?.msteams);
const hasConfigCreds = Boolean(
cfg.msteams?.appId?.trim() &&
cfg.msteams?.appPassword?.trim() &&
cfg.msteams?.tenantId?.trim(),
cfg.channels?.msteams?.appId?.trim() &&
cfg.channels?.msteams?.appPassword?.trim() &&
cfg.channels?.msteams?.tenantId?.trim(),
);
const canUseEnv = Boolean(
!hasConfigCreds &&
@@ -99,9 +104,9 @@ export const msteamsOnboardingAdapter: ProviderOnboardingAdapter = {
if (keepEnv) {
next = {
...next,
msteams: {
...next.msteams,
enabled: true,
channels: {
...next.channels,
msteams: { ...next.channels?.msteams, enabled: true },
},
};
} else {
@@ -173,12 +178,15 @@ export const msteamsOnboardingAdapter: ProviderOnboardingAdapter = {
if (appId && appPassword && tenantId) {
next = {
...next,
msteams: {
...next.msteams,
enabled: true,
appId,
appPassword,
tenantId,
channels: {
...next.channels,
msteams: {
...next.channels?.msteams,
enabled: true,
appId,
appPassword,
tenantId,
},
},
};
}
@@ -188,6 +196,9 @@ export const msteamsOnboardingAdapter: ProviderOnboardingAdapter = {
dmPolicy,
disable: (cfg) => ({
...cfg,
msteams: { ...cfg.msteams, enabled: false },
channels: {
...cfg.channels,
msteams: { ...cfg.channels?.msteams, enabled: false },
},
}),
};

View File

@@ -13,47 +13,50 @@ import {
} from "../../../signal/accounts.js";
import { formatDocsLink } from "../../../terminal/links.js";
import type {
ProviderOnboardingAdapter,
ProviderOnboardingDmPolicy,
ChannelOnboardingAdapter,
ChannelOnboardingDmPolicy,
} from "../onboarding-types.js";
import { addWildcardAllowFrom, promptAccountId } from "./helpers.js";
const provider = "signal" as const;
const channel = "signal" as const;
function setSignalDmPolicy(cfg: ClawdbotConfig, dmPolicy: DmPolicy) {
const allowFrom =
dmPolicy === "open"
? addWildcardAllowFrom(cfg.signal?.allowFrom)
? addWildcardAllowFrom(cfg.channels?.signal?.allowFrom)
: undefined;
return {
...cfg,
signal: {
...cfg.signal,
dmPolicy,
...(allowFrom ? { allowFrom } : {}),
channels: {
...cfg.channels,
signal: {
...cfg.channels?.signal,
dmPolicy,
...(allowFrom ? { allowFrom } : {}),
},
},
};
}
const dmPolicy: ProviderOnboardingDmPolicy = {
const dmPolicy: ChannelOnboardingDmPolicy = {
label: "Signal",
provider,
policyKey: "signal.dmPolicy",
allowFromKey: "signal.allowFrom",
getCurrent: (cfg) => cfg.signal?.dmPolicy ?? "pairing",
channel,
policyKey: "channels.signal.dmPolicy",
allowFromKey: "channels.signal.allowFrom",
getCurrent: (cfg) => cfg.channels?.signal?.dmPolicy ?? "pairing",
setPolicy: (cfg, policy) => setSignalDmPolicy(cfg, policy),
};
export const signalOnboardingAdapter: ProviderOnboardingAdapter = {
provider,
export const signalOnboardingAdapter: ChannelOnboardingAdapter = {
channel,
getStatus: async ({ cfg }) => {
const configured = listSignalAccountIds(cfg).some(
(accountId) => resolveSignalAccount({ cfg, accountId }).configured,
);
const signalCliPath = cfg.signal?.cliPath ?? "signal-cli";
const signalCliPath = cfg.channels?.signal?.cliPath ?? "signal-cli";
const signalCliDetected = await detectBinary(signalCliPath);
return {
provider,
channel,
configured,
statusLines: [
`Signal: ${configured ? "configured" : "needs setup"}`,
@@ -131,7 +134,7 @@ export const signalOnboardingAdapter: ProviderOnboardingAdapter = {
if (!cliDetected) {
await prompter.note(
"signal-cli not found. Install it, then rerun this step or set signal.cliPath.",
"signal-cli not found. Install it, then rerun this step or set channels.signal.cliPath.",
"Signal",
);
}
@@ -158,27 +161,34 @@ export const signalOnboardingAdapter: ProviderOnboardingAdapter = {
if (signalAccountId === DEFAULT_ACCOUNT_ID) {
next = {
...next,
signal: {
...next.signal,
enabled: true,
account,
cliPath: resolvedCliPath ?? "signal-cli",
channels: {
...next.channels,
signal: {
...next.channels?.signal,
enabled: true,
account,
cliPath: resolvedCliPath ?? "signal-cli",
},
},
};
} else {
next = {
...next,
signal: {
...next.signal,
enabled: true,
accounts: {
...next.signal?.accounts,
[signalAccountId]: {
...next.signal?.accounts?.[signalAccountId],
enabled:
next.signal?.accounts?.[signalAccountId]?.enabled ?? true,
account,
cliPath: resolvedCliPath ?? "signal-cli",
channels: {
...next.channels,
signal: {
...next.channels?.signal,
enabled: true,
accounts: {
...next.channels?.signal?.accounts,
[signalAccountId]: {
...next.channels?.signal?.accounts?.[signalAccountId],
enabled:
next.channels?.signal?.accounts?.[signalAccountId]
?.enabled ?? true,
account,
cliPath: resolvedCliPath ?? "signal-cli",
},
},
},
},
@@ -190,7 +200,7 @@ export const signalOnboardingAdapter: ProviderOnboardingAdapter = {
[
'Link device with: signal-cli link -n "Clawdbot"',
"Scan QR in Signal → Linked Devices",
"Then run: clawdbot gateway call providers.status --params '{\"probe\":true}'",
"Then run: clawdbot gateway call channels.status --params '{\"probe\":true}'",
`Docs: ${formatDocsLink("/signal", "signal")}`,
].join("\n"),
"Signal next steps",
@@ -201,6 +211,9 @@ export const signalOnboardingAdapter: ProviderOnboardingAdapter = {
dmPolicy,
disable: (cfg) => ({
...cfg,
signal: { ...cfg.signal, enabled: false },
channels: {
...cfg.channels,
signal: { ...cfg.channels?.signal, enabled: false },
},
}),
};

View File

@@ -12,27 +12,30 @@ import {
import { formatDocsLink } from "../../../terminal/links.js";
import type { WizardPrompter } from "../../../wizard/prompts.js";
import type {
ProviderOnboardingAdapter,
ProviderOnboardingDmPolicy,
ChannelOnboardingAdapter,
ChannelOnboardingDmPolicy,
} from "../onboarding-types.js";
import { addWildcardAllowFrom, promptAccountId } from "./helpers.js";
const provider = "slack" as const;
const channel = "slack" as const;
function setSlackDmPolicy(cfg: ClawdbotConfig, dmPolicy: DmPolicy) {
const allowFrom =
dmPolicy === "open"
? addWildcardAllowFrom(cfg.slack?.dm?.allowFrom)
? addWildcardAllowFrom(cfg.channels?.slack?.dm?.allowFrom)
: undefined;
return {
...cfg,
slack: {
...cfg.slack,
dm: {
...cfg.slack?.dm,
enabled: cfg.slack?.dm?.enabled ?? true,
policy: dmPolicy,
...(allowFrom ? { allowFrom } : {}),
channels: {
...cfg.channels,
slack: {
...cfg.channels?.slack,
dm: {
...cfg.channels?.slack?.dm,
enabled: cfg.channels?.slack?.dm?.enabled ?? true,
policy: dmPolicy,
...(allowFrom ? { allowFrom } : {}),
},
},
},
};
@@ -129,24 +132,24 @@ async function noteSlackTokenHelp(
);
}
const dmPolicy: ProviderOnboardingDmPolicy = {
const dmPolicy: ChannelOnboardingDmPolicy = {
label: "Slack",
provider,
policyKey: "slack.dm.policy",
allowFromKey: "slack.dm.allowFrom",
getCurrent: (cfg) => cfg.slack?.dm?.policy ?? "pairing",
channel,
policyKey: "channels.slack.dm.policy",
allowFromKey: "channels.slack.dm.allowFrom",
getCurrent: (cfg) => cfg.channels?.slack?.dm?.policy ?? "pairing",
setPolicy: (cfg, policy) => setSlackDmPolicy(cfg, policy),
};
export const slackOnboardingAdapter: ProviderOnboardingAdapter = {
provider,
export const slackOnboardingAdapter: ChannelOnboardingAdapter = {
channel,
getStatus: async ({ cfg }) => {
const configured = listSlackAccountIds(cfg).some((accountId) => {
const account = resolveSlackAccount({ cfg, accountId });
return Boolean(account.botToken && account.appToken);
});
return {
provider,
channel,
configured,
statusLines: [`Slack: ${configured ? "configured" : "needs tokens"}`],
selectionHint: configured ? "configured" : "needs tokens",
@@ -214,9 +217,9 @@ export const slackOnboardingAdapter: ProviderOnboardingAdapter = {
if (keepEnv) {
next = {
...next,
slack: {
...next.slack,
enabled: true,
channels: {
...next.channels,
slack: { ...next.channels?.slack, enabled: true },
},
};
} else {
@@ -271,27 +274,34 @@ export const slackOnboardingAdapter: ProviderOnboardingAdapter = {
if (slackAccountId === DEFAULT_ACCOUNT_ID) {
next = {
...next,
slack: {
...next.slack,
enabled: true,
botToken,
appToken,
channels: {
...next.channels,
slack: {
...next.channels?.slack,
enabled: true,
botToken,
appToken,
},
},
};
} else {
next = {
...next,
slack: {
...next.slack,
enabled: true,
accounts: {
...next.slack?.accounts,
[slackAccountId]: {
...next.slack?.accounts?.[slackAccountId],
enabled:
next.slack?.accounts?.[slackAccountId]?.enabled ?? true,
botToken,
appToken,
channels: {
...next.channels,
slack: {
...next.channels?.slack,
enabled: true,
accounts: {
...next.channels?.slack?.accounts,
[slackAccountId]: {
...next.channels?.slack?.accounts?.[slackAccountId],
enabled:
next.channels?.slack?.accounts?.[slackAccountId]?.enabled ??
true,
botToken,
appToken,
},
},
},
},
@@ -304,6 +314,9 @@ export const slackOnboardingAdapter: ProviderOnboardingAdapter = {
dmPolicy,
disable: (cfg) => ({
...cfg,
slack: { ...cfg.slack, enabled: false },
channels: {
...cfg.channels,
slack: { ...cfg.channels?.slack, enabled: false },
},
}),
};

View File

@@ -12,24 +12,27 @@ import {
import { formatDocsLink } from "../../../terminal/links.js";
import type { WizardPrompter } from "../../../wizard/prompts.js";
import type {
ProviderOnboardingAdapter,
ProviderOnboardingDmPolicy,
ChannelOnboardingAdapter,
ChannelOnboardingDmPolicy,
} from "../onboarding-types.js";
import { addWildcardAllowFrom, promptAccountId } from "./helpers.js";
const provider = "telegram" as const;
const channel = "telegram" as const;
function setTelegramDmPolicy(cfg: ClawdbotConfig, dmPolicy: DmPolicy) {
const allowFrom =
dmPolicy === "open"
? addWildcardAllowFrom(cfg.telegram?.allowFrom)
? addWildcardAllowFrom(cfg.channels?.telegram?.allowFrom)
: undefined;
return {
...cfg,
telegram: {
...cfg.telegram,
dmPolicy,
...(allowFrom ? { allowFrom } : {}),
channels: {
...cfg.channels,
telegram: {
...cfg.channels?.telegram,
dmPolicy,
...(allowFrom ? { allowFrom } : {}),
},
},
};
}
@@ -79,50 +82,57 @@ async function promptTelegramAllowFrom(params: {
if (accountId === DEFAULT_ACCOUNT_ID) {
return {
...cfg,
telegram: {
...cfg.telegram,
enabled: true,
dmPolicy: "allowlist",
allowFrom: unique,
channels: {
...cfg.channels,
telegram: {
...cfg.channels?.telegram,
enabled: true,
dmPolicy: "allowlist",
allowFrom: unique,
},
},
};
}
return {
...cfg,
telegram: {
...cfg.telegram,
enabled: true,
accounts: {
...cfg.telegram?.accounts,
[accountId]: {
...cfg.telegram?.accounts?.[accountId],
enabled: cfg.telegram?.accounts?.[accountId]?.enabled ?? true,
dmPolicy: "allowlist",
allowFrom: unique,
channels: {
...cfg.channels,
telegram: {
...cfg.channels?.telegram,
enabled: true,
accounts: {
...cfg.channels?.telegram?.accounts,
[accountId]: {
...cfg.channels?.telegram?.accounts?.[accountId],
enabled:
cfg.channels?.telegram?.accounts?.[accountId]?.enabled ?? true,
dmPolicy: "allowlist",
allowFrom: unique,
},
},
},
},
};
}
const dmPolicy: ProviderOnboardingDmPolicy = {
const dmPolicy: ChannelOnboardingDmPolicy = {
label: "Telegram",
provider,
policyKey: "telegram.dmPolicy",
allowFromKey: "telegram.allowFrom",
getCurrent: (cfg) => cfg.telegram?.dmPolicy ?? "pairing",
channel,
policyKey: "channels.telegram.dmPolicy",
allowFromKey: "channels.telegram.allowFrom",
getCurrent: (cfg) => cfg.channels?.telegram?.dmPolicy ?? "pairing",
setPolicy: (cfg, policy) => setTelegramDmPolicy(cfg, policy),
};
export const telegramOnboardingAdapter: ProviderOnboardingAdapter = {
provider,
export const telegramOnboardingAdapter: ChannelOnboardingAdapter = {
channel,
getStatus: async ({ cfg }) => {
const configured = listTelegramAccountIds(cfg).some((accountId) =>
Boolean(resolveTelegramAccount({ cfg, accountId }).token),
);
return {
provider,
channel,
configured,
statusLines: [`Telegram: ${configured ? "configured" : "needs token"}`],
selectionHint: configured
@@ -179,9 +189,12 @@ export const telegramOnboardingAdapter: ProviderOnboardingAdapter = {
if (keepEnv) {
next = {
...next,
telegram: {
...next.telegram,
enabled: true,
channels: {
...next.channels,
telegram: {
...next.channels?.telegram,
enabled: true,
},
},
};
} else {
@@ -218,25 +231,32 @@ export const telegramOnboardingAdapter: ProviderOnboardingAdapter = {
if (telegramAccountId === DEFAULT_ACCOUNT_ID) {
next = {
...next,
telegram: {
...next.telegram,
enabled: true,
botToken: token,
channels: {
...next.channels,
telegram: {
...next.channels?.telegram,
enabled: true,
botToken: token,
},
},
};
} else {
next = {
...next,
telegram: {
...next.telegram,
enabled: true,
accounts: {
...next.telegram?.accounts,
[telegramAccountId]: {
...next.telegram?.accounts?.[telegramAccountId],
enabled:
next.telegram?.accounts?.[telegramAccountId]?.enabled ?? true,
botToken: token,
channels: {
...next.channels,
telegram: {
...next.channels?.telegram,
enabled: true,
accounts: {
...next.channels?.telegram?.accounts,
[telegramAccountId]: {
...next.channels?.telegram?.accounts?.[telegramAccountId],
enabled:
next.channels?.telegram?.accounts?.[telegramAccountId]
?.enabled ?? true,
botToken: token,
},
},
},
},
@@ -257,6 +277,9 @@ export const telegramOnboardingAdapter: ProviderOnboardingAdapter = {
dmPolicy,
disable: (cfg) => ({
...cfg,
telegram: { ...cfg.telegram, enabled: false },
channels: {
...cfg.channels,
telegram: { ...cfg.channels?.telegram, enabled: false },
},
}),
};

View File

@@ -1,9 +1,9 @@
import fs from "node:fs/promises";
import path from "node:path";
import { loginWeb } from "../../../channel-web.js";
import type { ClawdbotConfig } from "../../../config/config.js";
import { mergeWhatsAppConfig } from "../../../config/merge-config.js";
import type { DmPolicy } from "../../../config/types.js";
import { loginWeb } from "../../../provider-web.js";
import {
DEFAULT_ACCOUNT_ID,
normalizeAccountId,
@@ -17,10 +17,10 @@ import {
resolveWhatsAppAuthDir,
} from "../../../web/accounts.js";
import type { WizardPrompter } from "../../../wizard/prompts.js";
import type { ProviderOnboardingAdapter } from "../onboarding-types.js";
import type { ChannelOnboardingAdapter } from "../onboarding-types.js";
import { promptAccountId } from "./helpers.js";
const provider = "whatsapp" as const;
const channel = "whatsapp" as const;
function setWhatsAppDmPolicy(
cfg: ClawdbotConfig,
@@ -84,8 +84,8 @@ async function promptWhatsAppAllowFrom(
prompter: WizardPrompter,
options?: { forceAllowlist?: boolean },
): Promise<ClawdbotConfig> {
const existingPolicy = cfg.whatsapp?.dmPolicy ?? "pairing";
const existingAllowFrom = cfg.whatsapp?.allowFrom ?? [];
const existingPolicy = cfg.channels?.whatsapp?.dmPolicy ?? "pairing";
const existingAllowFrom = cfg.channels?.whatsapp?.allowFrom ?? [];
const existingLabel =
existingAllowFrom.length > 0 ? existingAllowFrom.join(", ") : "unset";
const existingResponsePrefix = cfg.messages?.responsePrefix;
@@ -138,7 +138,7 @@ async function promptWhatsAppAllowFrom(
await prompter.note(
[
"WhatsApp direct chats are gated by `whatsapp.dmPolicy` + `whatsapp.allowFrom`.",
"WhatsApp direct chats are gated by `channels.whatsapp.dmPolicy` + `channels.whatsapp.allowFrom`.",
"- pairing (default): unknown senders get a pairing code; owner approves",
"- allowlist: unknown senders are blocked",
'- open: public inbound DMs (requires allowFrom to include "*")',
@@ -284,8 +284,8 @@ async function promptWhatsAppAllowFrom(
return next;
}
export const whatsappOnboardingAdapter: ProviderOnboardingAdapter = {
provider,
export const whatsappOnboardingAdapter: ChannelOnboardingAdapter = {
channel,
getStatus: async ({ cfg, accountOverrides }) => {
const overrideId = accountOverrides.whatsapp?.trim();
const defaultAccountId = resolveDefaultWhatsAppAccountId(cfg);
@@ -296,7 +296,7 @@ export const whatsappOnboardingAdapter: ProviderOnboardingAdapter = {
const accountLabel =
accountId === DEFAULT_ACCOUNT_ID ? "default" : accountId;
return {
provider,
channel,
configured: linked,
statusLines: [
`WhatsApp (${accountLabel}): ${linked ? "linked" : "not linked"}`,
@@ -335,13 +335,18 @@ export const whatsappOnboardingAdapter: ProviderOnboardingAdapter = {
if (accountId !== DEFAULT_ACCOUNT_ID) {
next = {
...next,
whatsapp: {
...next.whatsapp,
accounts: {
...next.whatsapp?.accounts,
[accountId]: {
...next.whatsapp?.accounts?.[accountId],
enabled: next.whatsapp?.accounts?.[accountId]?.enabled ?? true,
channels: {
...next.channels,
whatsapp: {
...next.channels?.whatsapp,
accounts: {
...next.channels?.whatsapp?.accounts,
[accountId]: {
...next.channels?.whatsapp?.accounts?.[accountId],
enabled:
next.channels?.whatsapp?.accounts?.[accountId]?.enabled ??
true,
},
},
},
},
@@ -382,7 +387,7 @@ export const whatsappOnboardingAdapter: ProviderOnboardingAdapter = {
}
} else if (!linked) {
await prompter.note(
"Run `clawdbot providers login` later to link WhatsApp.",
"Run `clawdbot channels login` later to link WhatsApp.",
"WhatsApp",
);
}

View File

@@ -1,7 +1,7 @@
import { sendMessageDiscord, sendPollDiscord } from "../../../discord/send.js";
import type { ProviderOutboundAdapter } from "../types.js";
import type { ChannelOutboundAdapter } from "../types.js";
export const discordOutbound: ProviderOutboundAdapter = {
export const discordOutbound: ChannelOutboundAdapter = {
deliveryMode: "direct",
chunker: null,
textChunkLimit: 2000,
@@ -25,7 +25,7 @@ export const discordOutbound: ProviderOutboundAdapter = {
replyTo: replyToId ?? undefined,
accountId: accountId ?? undefined,
});
return { provider: "discord", ...result };
return { channel: "discord", ...result };
},
sendMedia: async ({ to, text, mediaUrl, accountId, deps, replyToId }) => {
const send = deps?.sendDiscord ?? sendMessageDiscord;
@@ -35,7 +35,7 @@ export const discordOutbound: ProviderOutboundAdapter = {
replyTo: replyToId ?? undefined,
accountId: accountId ?? undefined,
});
return { provider: "discord", ...result };
return { channel: "discord", ...result };
},
sendPoll: async ({ to, poll, accountId }) =>
await sendPollDiscord(to, poll, {

View File

@@ -1,9 +1,9 @@
import { chunkText } from "../../../auto-reply/chunk.js";
import { sendMessageIMessage } from "../../../imessage/send.js";
import { resolveProviderMediaMaxBytes } from "../media-limits.js";
import type { ProviderOutboundAdapter } from "../types.js";
import { resolveChannelMediaMaxBytes } from "../media-limits.js";
import type { ChannelOutboundAdapter } from "../types.js";
export const imessageOutbound: ProviderOutboundAdapter = {
export const imessageOutbound: ChannelOutboundAdapter = {
deliveryMode: "direct",
chunker: chunkText,
textChunkLimit: 4000,
@@ -21,26 +21,26 @@ export const imessageOutbound: ProviderOutboundAdapter = {
},
sendText: async ({ cfg, to, text, accountId, deps }) => {
const send = deps?.sendIMessage ?? sendMessageIMessage;
const maxBytes = resolveProviderMediaMaxBytes({
const maxBytes = resolveChannelMediaMaxBytes({
cfg,
resolveProviderLimitMb: ({ cfg, accountId }) =>
cfg.imessage?.accounts?.[accountId]?.mediaMaxMb ??
cfg.imessage?.mediaMaxMb,
resolveChannelLimitMb: ({ cfg, accountId }) =>
cfg.channels?.imessage?.accounts?.[accountId]?.mediaMaxMb ??
cfg.channels?.imessage?.mediaMaxMb,
accountId,
});
const result = await send(to, text, {
maxBytes,
accountId: accountId ?? undefined,
});
return { provider: "imessage", ...result };
return { channel: "imessage", ...result };
},
sendMedia: async ({ cfg, to, text, mediaUrl, accountId, deps }) => {
const send = deps?.sendIMessage ?? sendMessageIMessage;
const maxBytes = resolveProviderMediaMaxBytes({
const maxBytes = resolveChannelMediaMaxBytes({
cfg,
resolveProviderLimitMb: ({ cfg, accountId }) =>
cfg.imessage?.accounts?.[accountId]?.mediaMaxMb ??
cfg.imessage?.mediaMaxMb,
resolveChannelLimitMb: ({ cfg, accountId }) =>
cfg.channels?.imessage?.accounts?.[accountId]?.mediaMaxMb ??
cfg.channels?.imessage?.mediaMaxMb,
accountId,
});
const result = await send(to, text, {
@@ -48,6 +48,6 @@ export const imessageOutbound: ProviderOutboundAdapter = {
maxBytes,
accountId: accountId ?? undefined,
});
return { provider: "imessage", ...result };
return { channel: "imessage", ...result };
},
};

View File

@@ -1,13 +1,13 @@
import type { ProviderId, ProviderOutboundAdapter } from "../types.js";
import type { ChannelId, ChannelOutboundAdapter } from "../types.js";
type OutboundLoader = () => Promise<ProviderOutboundAdapter>;
type OutboundLoader = () => Promise<ChannelOutboundAdapter>;
// Provider docking: outbound sends should stay cheap to import.
// Channel docking: outbound sends should stay cheap to import.
//
// The full provider plugins (src/providers/plugins/*.ts) pull in status,
// The full channel plugins (src/channels/plugins/*.ts) pull in status,
// onboarding, gateway monitors, etc. Outbound delivery only needs chunking +
// send primitives, so we keep a dedicated, lightweight loader here.
const LOADERS: Record<ProviderId, OutboundLoader> = {
const LOADERS: Record<ChannelId, OutboundLoader> = {
telegram: async () => (await import("./telegram.js")).telegramOutbound,
whatsapp: async () => (await import("./whatsapp.js")).whatsappOutbound,
discord: async () => (await import("./discord.js")).discordOutbound,
@@ -17,11 +17,11 @@ const LOADERS: Record<ProviderId, OutboundLoader> = {
msteams: async () => (await import("./msteams.js")).msteamsOutbound,
};
const cache = new Map<ProviderId, ProviderOutboundAdapter>();
const cache = new Map<ChannelId, ChannelOutboundAdapter>();
export async function loadProviderOutboundAdapter(
id: ProviderId,
): Promise<ProviderOutboundAdapter | undefined> {
export async function loadChannelOutboundAdapter(
id: ChannelId,
): Promise<ChannelOutboundAdapter | undefined> {
const cached = cache.get(id);
if (cached) return cached;
const loader = LOADERS[id];

View File

@@ -1,9 +1,9 @@
import { chunkMarkdownText } from "../../../auto-reply/chunk.js";
import { createMSTeamsPollStoreFs } from "../../../msteams/polls.js";
import { sendMessageMSTeams, sendPollMSTeams } from "../../../msteams/send.js";
import type { ProviderOutboundAdapter } from "../types.js";
import type { ChannelOutboundAdapter } from "../types.js";
export const msteamsOutbound: ProviderOutboundAdapter = {
export const msteamsOutbound: ChannelOutboundAdapter = {
deliveryMode: "direct",
chunker: chunkMarkdownText,
textChunkLimit: 4000,
@@ -25,7 +25,7 @@ export const msteamsOutbound: ProviderOutboundAdapter = {
deps?.sendMSTeams ??
((to, text) => sendMessageMSTeams({ cfg, to, text }));
const result = await send(to, text);
return { provider: "msteams", ...result };
return { channel: "msteams", ...result };
},
sendMedia: async ({ cfg, to, text, mediaUrl, deps }) => {
const send =
@@ -33,7 +33,7 @@ export const msteamsOutbound: ProviderOutboundAdapter = {
((to, text, opts) =>
sendMessageMSTeams({ cfg, to, text, mediaUrl: opts?.mediaUrl }));
const result = await send(to, text, { mediaUrl });
return { provider: "msteams", ...result };
return { channel: "msteams", ...result };
},
sendPoll: async ({ cfg, to, poll }) => {
const maxSelections = poll.maxSelections ?? 1;

View File

@@ -1,9 +1,9 @@
import { chunkText } from "../../../auto-reply/chunk.js";
import { sendMessageSignal } from "../../../signal/send.js";
import { resolveProviderMediaMaxBytes } from "../media-limits.js";
import type { ProviderOutboundAdapter } from "../types.js";
import { resolveChannelMediaMaxBytes } from "../media-limits.js";
import type { ChannelOutboundAdapter } from "../types.js";
export const signalOutbound: ProviderOutboundAdapter = {
export const signalOutbound: ChannelOutboundAdapter = {
deliveryMode: "direct",
chunker: chunkText,
textChunkLimit: 4000,
@@ -21,24 +21,26 @@ export const signalOutbound: ProviderOutboundAdapter = {
},
sendText: async ({ cfg, to, text, accountId, deps }) => {
const send = deps?.sendSignal ?? sendMessageSignal;
const maxBytes = resolveProviderMediaMaxBytes({
const maxBytes = resolveChannelMediaMaxBytes({
cfg,
resolveProviderLimitMb: ({ cfg, accountId }) =>
cfg.signal?.accounts?.[accountId]?.mediaMaxMb ?? cfg.signal?.mediaMaxMb,
resolveChannelLimitMb: ({ cfg, accountId }) =>
cfg.channels?.signal?.accounts?.[accountId]?.mediaMaxMb ??
cfg.channels?.signal?.mediaMaxMb,
accountId,
});
const result = await send(to, text, {
maxBytes,
accountId: accountId ?? undefined,
});
return { provider: "signal", ...result };
return { channel: "signal", ...result };
},
sendMedia: async ({ cfg, to, text, mediaUrl, accountId, deps }) => {
const send = deps?.sendSignal ?? sendMessageSignal;
const maxBytes = resolveProviderMediaMaxBytes({
const maxBytes = resolveChannelMediaMaxBytes({
cfg,
resolveProviderLimitMb: ({ cfg, accountId }) =>
cfg.signal?.accounts?.[accountId]?.mediaMaxMb ?? cfg.signal?.mediaMaxMb,
resolveChannelLimitMb: ({ cfg, accountId }) =>
cfg.channels?.signal?.accounts?.[accountId]?.mediaMaxMb ??
cfg.channels?.signal?.mediaMaxMb,
accountId,
});
const result = await send(to, text, {
@@ -46,6 +48,6 @@ export const signalOutbound: ProviderOutboundAdapter = {
maxBytes,
accountId: accountId ?? undefined,
});
return { provider: "signal", ...result };
return { channel: "signal", ...result };
},
};

Some files were not shown because too many files have changed in this diff Show More