fix: stop hardcoded channel fallback and auto-pick sole configured channel (#23357) (thanks @lbo728)

Co-authored-by: lbo728 <extreme0728@gmail.com>
This commit is contained in:
Peter Steinberger
2026-02-22 11:20:33 +01:00
parent e33d7fcd13
commit 1cd3b30907
18 changed files with 355 additions and 91 deletions

View File

@@ -21,6 +21,7 @@ Docs: https://docs.openclaw.ai
- Gateway/Restart: fix restart-loop edge cases by keeping `openclaw.mjs -> dist/entry.js` bootstrap detection explicit, reacquiring the gateway lock for in-process restart fallback paths, and tightening restart-loop regression coverage. (#23416) Thanks @jeffwnli.
- Signal/Monitor: treat user-initiated abort shutdowns as clean exits when auto-started `signal-cli` is terminated, while still surfacing unexpected daemon exits as startup/runtime failures. (#23379) Thanks @frankekn.
- Channels/Dedupe: centralize plugin dedupe primitives in plugin SDK (memory + persistent), move Feishu inbound dedupe to a namespace-scoped persistent store, and reuse shared dedupe cache logic for Zalo webhook replay + Tlon processed-message tracking to reduce duplicate handling during reconnect/replay paths. (#23377) Thanks @SidQin-cyber.
- Channels/Delivery: remove hardcoded WhatsApp delivery fallbacks; require explicit/session channel context or auto-pick the sole configured channel when unambiguous. (#23357) Thanks @lbo728.
- ACP/Gateway: wait for gateway hello before opening ACP requests, and fail fast on pre-hello connect failures to avoid startup hangs and early `gateway not connected` request races. (#23390) Thanks @janckerchen.
- Security/Audit: add `openclaw security audit` detection for open group policies that expose runtime/filesystem tools without sandbox/workspace guards (`security.exposure.open_groups_with_runtime_or_fs`).
- Security/Exec env: block request-scoped `HOME` and `ZDOTDIR` overrides in host exec env sanitizers (Node + macOS), preventing shell startup-file execution before allowlist-evaluated command bodies. This ships in the next npm release. Thanks @tdjackey for reporting.

View File

@@ -19,8 +19,6 @@ export type ChatChannelId = (typeof CHAT_CHANNEL_ORDER)[number];
export const CHANNEL_IDS = [...CHAT_CHANNEL_ORDER] as const;
export const DEFAULT_CHAT_CHANNEL: ChatChannelId = "whatsapp";
export type ChatChannelMeta = ChannelMeta;
const WEBSITE_URL = "https://openclaw.ai";

View File

@@ -1,5 +1,4 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import { DEFAULT_CHAT_CHANNEL } from "../channels/registry.js";
import { runChannelLogin, runChannelLogout } from "./channel-auth.js";
const mocks = vi.hoisted(() => ({
@@ -7,6 +6,7 @@ const mocks = vi.hoisted(() => ({
getChannelPlugin: vi.fn(),
normalizeChannelId: vi.fn(),
loadConfig: vi.fn(),
resolveMessageChannelSelection: vi.fn(),
setVerbose: vi.fn(),
login: vi.fn(),
logoutAccount: vi.fn(),
@@ -26,6 +26,10 @@ vi.mock("../config/config.js", () => ({
loadConfig: mocks.loadConfig,
}));
vi.mock("../infra/outbound/channel-selection.js", () => ({
resolveMessageChannelSelection: mocks.resolveMessageChannelSelection,
}));
vi.mock("../globals.js", () => ({
setVerbose: mocks.setVerbose,
}));
@@ -43,6 +47,10 @@ describe("channel-auth", () => {
mocks.normalizeChannelId.mockReturnValue("whatsapp");
mocks.getChannelPlugin.mockReturnValue(plugin);
mocks.loadConfig.mockReturnValue({ channels: {} });
mocks.resolveMessageChannelSelection.mockResolvedValue({
channel: "whatsapp",
configured: ["whatsapp"],
});
mocks.resolveChannelDefaultAccountId.mockReturnValue("default-account");
mocks.resolveAccount.mockReturnValue({ id: "resolved-account" });
mocks.login.mockResolvedValue(undefined);
@@ -65,22 +73,27 @@ describe("channel-auth", () => {
);
});
it("runs login with default channel/account when opts are empty", async () => {
it("auto-picks the single configured channel when opts are empty", async () => {
await runChannelLogin({}, runtime);
expect(mocks.normalizeChannelId).toHaveBeenCalledWith(DEFAULT_CHAT_CHANNEL);
expect(mocks.resolveChannelDefaultAccountId).toHaveBeenCalledWith({
plugin,
cfg: { channels: {} },
});
expect(mocks.resolveMessageChannelSelection).toHaveBeenCalledWith({ cfg: { channels: {} } });
expect(mocks.normalizeChannelId).toHaveBeenCalledWith("whatsapp");
expect(mocks.login).toHaveBeenCalledWith(
expect.objectContaining({
accountId: "default-account",
channelInput: DEFAULT_CHAT_CHANNEL,
channelInput: "whatsapp",
}),
);
});
it("propagates channel ambiguity when channel is omitted", async () => {
mocks.resolveMessageChannelSelection.mockRejectedValueOnce(
new Error("Channel is required when multiple channels are configured: telegram, slack"),
);
await expect(runChannelLogin({}, runtime)).rejects.toThrow("Channel is required");
expect(mocks.login).not.toHaveBeenCalled();
});
it("throws for unsupported channel aliases", async () => {
mocks.normalizeChannelId.mockReturnValueOnce(undefined);

View File

@@ -1,8 +1,8 @@
import { resolveChannelDefaultAccountId } from "../channels/plugins/helpers.js";
import { getChannelPlugin, normalizeChannelId } from "../channels/plugins/index.js";
import { DEFAULT_CHAT_CHANNEL } from "../channels/registry.js";
import { loadConfig } from "../config/config.js";
import { loadConfig, type OpenClawConfig } from "../config/config.js";
import { setVerbose } from "../globals.js";
import { resolveMessageChannelSelection } from "../infra/outbound/channel-selection.js";
import { defaultRuntime, type RuntimeEnv } from "../runtime.js";
type ChannelAuthOptions = {
@@ -14,11 +14,15 @@ type ChannelAuthOptions = {
type ChannelPlugin = NonNullable<ReturnType<typeof getChannelPlugin>>;
type ChannelAuthMode = "login" | "logout";
function resolveChannelPluginForMode(
async function resolveChannelPluginForMode(
opts: ChannelAuthOptions,
mode: ChannelAuthMode,
): { channelInput: string; channelId: string; plugin: ChannelPlugin } {
const channelInput = opts.channel ?? DEFAULT_CHAT_CHANNEL;
cfg: OpenClawConfig,
): Promise<{ channelInput: string; channelId: string; plugin: ChannelPlugin }> {
const explicitChannel = opts.channel?.trim();
const channelInput = explicitChannel
? explicitChannel
: (await resolveMessageChannelSelection({ cfg })).channel;
const channelId = normalizeChannelId(channelInput);
if (!channelId) {
throw new Error(`Unsupported channel: ${channelInput}`);
@@ -32,24 +36,28 @@ function resolveChannelPluginForMode(
return { channelInput, channelId, plugin: plugin as ChannelPlugin };
}
function resolveAccountContext(plugin: ChannelPlugin, opts: ChannelAuthOptions) {
const cfg = loadConfig();
function resolveAccountContext(
plugin: ChannelPlugin,
opts: ChannelAuthOptions,
cfg: OpenClawConfig,
) {
const accountId = opts.account?.trim() || resolveChannelDefaultAccountId({ plugin, cfg });
return { cfg, accountId };
return { accountId };
}
export async function runChannelLogin(
opts: ChannelAuthOptions,
runtime: RuntimeEnv = defaultRuntime,
) {
const { channelInput, plugin } = resolveChannelPluginForMode(opts, "login");
const cfg = loadConfig();
const { channelInput, plugin } = await resolveChannelPluginForMode(opts, "login", cfg);
const login = plugin.auth?.login;
if (!login) {
throw new Error(`Channel ${channelInput} does not support login`);
}
// Auth-only flow: do not mutate channel config here.
setVerbose(Boolean(opts.verbose));
const { cfg, accountId } = resolveAccountContext(plugin, opts);
const { accountId } = resolveAccountContext(plugin, opts, cfg);
await login({
cfg,
accountId,
@@ -63,13 +71,14 @@ export async function runChannelLogout(
opts: ChannelAuthOptions,
runtime: RuntimeEnv = defaultRuntime,
) {
const { channelInput, plugin } = resolveChannelPluginForMode(opts, "logout");
const cfg = loadConfig();
const { channelInput, plugin } = await resolveChannelPluginForMode(opts, "logout", cfg);
const logoutAccount = plugin.gateway?.logoutAccount;
if (!logoutAccount) {
throw new Error(`Channel ${channelInput} does not support logout`);
}
// Auth-only flow: resolve account + clear session state only.
const { cfg, accountId } = resolveAccountContext(plugin, opts);
const { accountId } = resolveAccountContext(plugin, opts, cfg);
const account = plugin.config.resolveAccount(cfg, accountId);
await logoutAccount({
cfg,

View File

@@ -221,7 +221,7 @@ export function registerChannelsCli(program: Command) {
channels
.command("login")
.description("Link a channel account (if supported)")
.option("--channel <channel>", "Channel alias (default: whatsapp)")
.option("--channel <channel>", "Channel alias (auto when only one is configured)")
.option("--account <id>", "Account id (accountId)")
.option("--verbose", "Verbose connection logs", false)
.action(async (opts) => {
@@ -240,7 +240,7 @@ export function registerChannelsCli(program: Command) {
channels
.command("logout")
.description("Log out of a channel session (if supported)")
.option("--channel <channel>", "Channel alias (default: whatsapp)")
.option("--channel <channel>", "Channel alias (auto when only one is configured)")
.option("--account <id>", "Account id (accountId)")
.action(async (opts) => {
await runChannelsCommandWithDanger(async () => {

View File

@@ -1,5 +1,4 @@
import type { Command } from "commander";
import { DEFAULT_CHAT_CHANNEL } from "../../channels/registry.js";
import { agentCliCommand } from "../../commands/agent-via-gateway.js";
import {
agentsAddCommand,
@@ -29,7 +28,7 @@ export function registerAgentCommands(program: Command, args: { agentChannelOpti
.option("--verbose <on|off>", "Persist agent verbose level for the session")
.option(
"--channel <channel>",
`Delivery channel: ${args.agentChannelOptions} (default: ${DEFAULT_CHAT_CHANNEL})`,
`Delivery channel: ${args.agentChannelOptions} (omit to use the main session channel)`,
)
.option("--reply-to <target>", "Delivery target override (separate from session routing)")
.option("--reply-channel <channel>", "Delivery channel override (separate from routing)")

View File

@@ -1,5 +1,4 @@
import { listAgentIds } from "../agents/agent-scope.js";
import { DEFAULT_CHAT_CHANNEL } from "../channels/registry.js";
import { formatCliCommand } from "../cli/command-format.js";
import type { CliDeps } from "../cli/deps.js";
import { withProgress } from "../cli/progress.js";
@@ -118,7 +117,7 @@ export async function agentViaGatewayCommand(opts: AgentCliOpts, runtime: Runtim
sessionId: opts.sessionId,
}).sessionKey;
const channel = normalizeMessageChannel(opts.channel) ?? DEFAULT_CHAT_CHANNEL;
const channel = normalizeMessageChannel(opts.channel);
const idempotencyKey = opts.runId?.trim() || randomIdempotencyKey();
const response = await withProgress(

View File

@@ -8,6 +8,7 @@ import {
resolveAgentDeliveryPlan,
resolveAgentOutboundTarget,
} from "../../infra/outbound/agent-delivery.js";
import { resolveMessageChannelSelection } from "../../infra/outbound/channel-selection.js";
import { deliverOutboundPayloads } from "../../infra/outbound/deliver.js";
import { buildOutboundResultEnvelope } from "../../infra/outbound/envelope.js";
import {
@@ -78,7 +79,23 @@ export async function deliverAgentCommandResult(params: {
accountId: opts.replyAccountId ?? opts.accountId,
wantsDelivery: deliver,
});
const deliveryChannel = deliveryPlan.resolvedChannel;
let deliveryChannel = deliveryPlan.resolvedChannel;
const explicitChannelHint = (opts.replyChannel ?? opts.channel)?.trim();
if (deliver && isInternalMessageChannel(deliveryChannel) && !explicitChannelHint) {
try {
const selection = await resolveMessageChannelSelection({ cfg });
deliveryChannel = selection.channel;
} catch {
// Keep the internal channel marker; error handling below reports the failure.
}
}
const effectiveDeliveryPlan =
deliveryChannel === deliveryPlan.resolvedChannel
? deliveryPlan
: {
...deliveryPlan,
resolvedChannel: deliveryChannel,
};
// Channel docking: delivery channels are resolved via plugin registry.
const deliveryPlugin = !isInternalMessageChannel(deliveryChannel)
? getChannelPlugin(normalizeChannelId(deliveryChannel) ?? deliveryChannel)
@@ -89,20 +106,20 @@ export async function deliverAgentCommandResult(params: {
const targetMode =
opts.deliveryTargetMode ??
deliveryPlan.deliveryTargetMode ??
effectiveDeliveryPlan.deliveryTargetMode ??
(opts.to ? "explicit" : "implicit");
const resolvedAccountId = deliveryPlan.resolvedAccountId;
const resolvedAccountId = effectiveDeliveryPlan.resolvedAccountId;
const resolved =
deliver && isDeliveryChannelKnown && deliveryChannel
? resolveAgentOutboundTarget({
cfg,
plan: deliveryPlan,
plan: effectiveDeliveryPlan,
targetMode,
validateExplicitTarget: true,
})
: {
resolvedTarget: null,
resolvedTo: deliveryPlan.resolvedTo,
resolvedTo: effectiveDeliveryPlan.resolvedTo,
targetMode,
};
const resolvedTarget = resolved.resolvedTarget;
@@ -121,7 +138,15 @@ export async function deliverAgentCommandResult(params: {
};
if (deliver) {
if (!isDeliveryChannelKnown) {
if (isInternalMessageChannel(deliveryChannel)) {
const err = new Error(
"delivery channel is required: pass --channel/--reply-channel or use a main session with a previous channel",
);
if (!bestEffortDeliver) {
throw err;
}
logDeliveryError(err);
} else if (!isDeliveryChannelKnown) {
const err = new Error(`Unknown channel: ${deliveryChannel}`);
if (!bestEffortDeliver) {
throw err;

View File

@@ -1,5 +1,4 @@
import { describe, expect, it, vi } from "vitest";
import { DEFAULT_CHAT_CHANNEL } from "../../channels/registry.js";
import type { OpenClawConfig } from "../../config/config.js";
vi.mock("../../config/sessions.js", () => ({
@@ -223,16 +222,30 @@ describe("resolveDeliveryTarget", () => {
expect(result.threadId).toBe("thread-2");
});
it("falls back to default channel when selection probe fails", async () => {
it("uses single configured channel when neither explicit nor session channel exists", async () => {
setMainSessionEntry(undefined);
vi.mocked(resolveMessageChannelSelection).mockRejectedValueOnce(new Error("no selection"));
const result = await resolveForAgent({
cfg: makeCfg({ bindings: [] }),
target: { channel: "last", to: undefined },
});
expect(result.channel).toBe(DEFAULT_CHAT_CHANNEL);
expect(result.channel).toBe("telegram");
expect(result.error).toBeUndefined();
});
it("returns an error when channel selection is ambiguous", async () => {
setMainSessionEntry(undefined);
vi.mocked(resolveMessageChannelSelection).mockRejectedValueOnce(
new Error("Channel is required when multiple channels are configured: telegram, slack"),
);
const result = await resolveForAgent({
cfg: makeCfg({ bindings: [] }),
target: { channel: "last", to: undefined },
});
expect(result.channel).toBeUndefined();
expect(result.to).toBeUndefined();
expect(result.error?.message).toContain("Channel is required");
});
it("uses sessionKey thread entry before main session entry", async () => {
@@ -261,11 +274,12 @@ describe("resolveDeliveryTarget", () => {
expect(result.to).toBe("thread-chat");
});
it("uses channel selection result when no previous session target exists", async () => {
setMainSessionEntry(undefined);
vi.mocked(resolveMessageChannelSelection).mockResolvedValueOnce({
channel: "telegram",
configured: ["telegram"],
it("uses main session channel when channel=last and session route exists", async () => {
setMainSessionEntry({
sessionId: "sess-4",
updatedAt: 1000,
lastChannel: "telegram",
lastTo: "987654",
});
const result = await resolveForAgent({
@@ -274,7 +288,7 @@ describe("resolveDeliveryTarget", () => {
});
expect(result.channel).toBe("telegram");
expect(result.to).toBeUndefined();
expect(result.mode).toBe("implicit");
expect(result.to).toBe("987654");
expect(result.error).toBeUndefined();
});
});

View File

@@ -1,5 +1,4 @@
import type { ChannelId } from "../../channels/plugins/types.js";
import { DEFAULT_CHAT_CHANNEL } from "../../channels/registry.js";
import type { OpenClawConfig } from "../../config/config.js";
import {
loadSessionStore,
@@ -27,7 +26,7 @@ export async function resolveDeliveryTarget(
sessionKey?: string;
},
): Promise<{
channel: Exclude<OutboundChannel, "none">;
channel?: Exclude<OutboundChannel, "none">;
to?: string;
accountId?: string;
threadId?: string | number;
@@ -57,12 +56,20 @@ export async function resolveDeliveryTarget(
});
let fallbackChannel: Exclude<OutboundChannel, "none"> | undefined;
let channelResolutionError: Error | undefined;
if (!preliminary.channel) {
try {
const selection = await resolveMessageChannelSelection({ cfg });
fallbackChannel = selection.channel;
} catch {
fallbackChannel = preliminary.lastChannel ?? DEFAULT_CHAT_CHANNEL;
if (preliminary.lastChannel) {
fallbackChannel = preliminary.lastChannel;
} else {
try {
const selection = await resolveMessageChannelSelection({ cfg });
fallbackChannel = selection.channel;
} catch (err) {
const detail = err instanceof Error ? err.message : String(err);
channelResolutionError = new Error(
`${detail} Set delivery.channel explicitly or use a main session with a previous channel.`,
);
}
}
}
@@ -77,7 +84,7 @@ export async function resolveDeliveryTarget(
})
: preliminary;
const channel = resolved.channel ?? fallbackChannel ?? DEFAULT_CHAT_CHANNEL;
const channel = resolved.channel ?? fallbackChannel;
const mode = resolved.mode as "explicit" | "implicit";
let toCandidate = resolved.to;
@@ -105,6 +112,17 @@ export async function resolveDeliveryTarget(
? resolved.threadId
: undefined;
if (!channel) {
return {
channel: undefined,
to: undefined,
accountId,
threadId,
mode,
error: channelResolutionError,
};
}
if (!toCandidate) {
return {
channel,
@@ -112,6 +130,7 @@ export async function resolveDeliveryTarget(
accountId,
threadId,
mode,
error: channelResolutionError,
};
}
@@ -150,6 +169,6 @@ export async function resolveDeliveryTarget(
accountId,
threadId,
mode,
error: docked.ok ? undefined : docked.error,
error: docked.ok ? channelResolutionError : docked.error,
};
}

View File

@@ -75,9 +75,9 @@ import {
function matchesMessagingToolDeliveryTarget(
target: MessagingToolSend,
delivery: { channel: string; to?: string; accountId?: string },
delivery: { channel?: string; to?: string; accountId?: string },
): boolean {
if (!delivery.to || !target.to) {
if (!delivery.channel || !delivery.to || !target.to) {
return false;
}
const channel = delivery.channel.trim().toLowerCase();
@@ -611,6 +611,20 @@ export async function runCronIsolatedAgentTurn(params: {
logWarn(`[cron:${params.job.id}] ${resolvedDelivery.error.message}`);
return withRunSession({ status: "ok", summary, outputText, ...telemetry });
}
if (!resolvedDelivery.channel) {
const message = "cron delivery channel is missing";
if (!deliveryBestEffort) {
return withRunSession({
status: "error",
error: message,
summary,
outputText,
...telemetry,
});
}
logWarn(`[cron:${params.job.id}] ${message}`);
return withRunSession({ status: "ok", summary, outputText, ...telemetry });
}
if (!resolvedDelivery.to) {
const message = "cron delivery target is missing";
if (!deliveryBestEffort) {

View File

@@ -15,6 +15,7 @@ import {
resolveAgentDeliveryPlan,
resolveAgentOutboundTarget,
} from "../../infra/outbound/agent-delivery.js";
import { resolveMessageChannelSelection } from "../../infra/outbound/channel-selection.js";
import { classifySessionKeyShape, normalizeAgentId } from "../../routing/session-key.js";
import { defaultRuntime } from "../../runtime.js";
import { normalizeInputProvenance, type InputProvenance } from "../../sessions/input-provenance.js";
@@ -490,17 +491,36 @@ export const agentHandlers: GatewayRequestHandlers = {
wantsDelivery,
});
const resolvedChannel = deliveryPlan.resolvedChannel;
const deliveryTargetMode = deliveryPlan.deliveryTargetMode;
const resolvedAccountId = deliveryPlan.resolvedAccountId;
let resolvedChannel = deliveryPlan.resolvedChannel;
let deliveryTargetMode = deliveryPlan.deliveryTargetMode;
let resolvedAccountId = deliveryPlan.resolvedAccountId;
let resolvedTo = deliveryPlan.resolvedTo;
let effectivePlan = deliveryPlan;
if (wantsDelivery && resolvedChannel === INTERNAL_MESSAGE_CHANNEL) {
const cfgResolved = cfgForAgent ?? cfg;
try {
const selection = await resolveMessageChannelSelection({ cfg: cfgResolved });
resolvedChannel = selection.channel;
deliveryTargetMode = deliveryTargetMode ?? "implicit";
effectivePlan = {
...deliveryPlan,
resolvedChannel,
deliveryTargetMode,
resolvedAccountId,
};
} catch (err) {
respond(false, undefined, errorShape(ErrorCodes.INVALID_REQUEST, String(err)));
return;
}
}
if (!resolvedTo && isDeliverableMessageChannel(resolvedChannel)) {
const cfgResolved = cfgForAgent ?? cfg;
const fallback = resolveAgentOutboundTarget({
cfg: cfgResolved,
plan: deliveryPlan,
targetMode: "implicit",
plan: effectivePlan,
targetMode: deliveryTargetMode ?? "implicit",
validateExplicitTarget: false,
});
if (fallback.resolvedTarget?.ok) {
@@ -508,6 +528,18 @@ export const agentHandlers: GatewayRequestHandlers = {
}
}
if (wantsDelivery && resolvedChannel === INTERNAL_MESSAGE_CHANNEL) {
respond(
false,
undefined,
errorShape(
ErrorCodes.INVALID_REQUEST,
"delivery channel is required: pass --channel/--reply-channel or use a main session with a previous channel",
),
);
return;
}
const deliver = request.deliver === true && resolvedChannel !== INTERNAL_MESSAGE_CHANNEL;
const accepted = {

View File

@@ -8,6 +8,8 @@ const mocks = vi.hoisted(() => ({
appendAssistantMessageToSessionTranscript: vi.fn(async () => ({ ok: true, sessionFile: "x" })),
recordSessionMetaFromInbound: vi.fn(async () => ({ ok: true })),
resolveOutboundTarget: vi.fn(() => ({ ok: true, to: "resolved" })),
resolveMessageChannelSelection: vi.fn(),
sendPoll: vi.fn(async () => ({ messageId: "poll-1" })),
}));
vi.mock("../../config/config.js", async () => {
@@ -20,7 +22,7 @@ vi.mock("../../config/config.js", async () => {
});
vi.mock("../../channels/plugins/index.js", () => ({
getChannelPlugin: () => ({ outbound: {} }),
getChannelPlugin: () => ({ outbound: { sendPoll: mocks.sendPoll } }),
normalizeChannelId: (value: string) => (value === "webchat" ? null : value),
}));
@@ -28,6 +30,10 @@ vi.mock("../../infra/outbound/targets.js", () => ({
resolveOutboundTarget: mocks.resolveOutboundTarget,
}));
vi.mock("../../infra/outbound/channel-selection.js", () => ({
resolveMessageChannelSelection: mocks.resolveMessageChannelSelection,
}));
vi.mock("../../infra/outbound/deliver.js", () => ({
deliverOutboundPayloads: mocks.deliverOutboundPayloads,
}));
@@ -61,6 +67,19 @@ async function runSend(params: Record<string, unknown>) {
return { respond };
}
async function runPoll(params: Record<string, unknown>) {
const respond = vi.fn();
await sendHandlers.poll({
params: params as never,
respond,
context: makeContext(),
req: { type: "req", id: "1", method: "poll" },
client: null,
isWebchatConnect: () => false,
});
return { respond };
}
function mockDeliverySuccess(messageId: string) {
mocks.deliverOutboundPayloads.mockResolvedValue([{ messageId, channel: "slack" }]);
}
@@ -69,6 +88,11 @@ describe("gateway send mirroring", () => {
beforeEach(() => {
vi.clearAllMocks();
mocks.resolveOutboundTarget.mockReturnValue({ ok: true, to: "resolved" });
mocks.resolveMessageChannelSelection.mockResolvedValue({
channel: "slack",
configured: ["slack"],
});
mocks.sendPoll.mockResolvedValue({ messageId: "poll-1" });
});
it("accepts media-only sends without message", async () => {
@@ -137,6 +161,81 @@ describe("gateway send mirroring", () => {
);
});
it("auto-picks the single configured channel for send", async () => {
mockDeliverySuccess("m-single-send");
const { respond } = await runSend({
to: "x",
message: "hi",
idempotencyKey: "idem-missing-channel",
});
expect(mocks.resolveMessageChannelSelection).toHaveBeenCalled();
expect(mocks.deliverOutboundPayloads).toHaveBeenCalled();
expect(respond).toHaveBeenCalledWith(
true,
expect.objectContaining({ messageId: "m-single-send" }),
undefined,
expect.objectContaining({ channel: "slack" }),
);
});
it("returns invalid request when send channel selection is ambiguous", async () => {
mocks.resolveMessageChannelSelection.mockRejectedValueOnce(
new Error("Channel is required when multiple channels are configured: telegram, slack"),
);
const { respond } = await runSend({
to: "x",
message: "hi",
idempotencyKey: "idem-missing-channel-ambiguous",
});
expect(mocks.deliverOutboundPayloads).not.toHaveBeenCalled();
expect(respond).toHaveBeenCalledWith(
false,
undefined,
expect.objectContaining({
message: expect.stringContaining("Channel is required"),
}),
);
});
it("auto-picks the single configured channel for poll", async () => {
const { respond } = await runPoll({
to: "x",
question: "Q?",
options: ["A", "B"],
idempotencyKey: "idem-poll-missing-channel",
});
expect(mocks.resolveMessageChannelSelection).toHaveBeenCalled();
expect(respond).toHaveBeenCalledWith(true, expect.any(Object), undefined, {
channel: "slack",
});
});
it("returns invalid request when poll channel selection is ambiguous", async () => {
mocks.resolveMessageChannelSelection.mockRejectedValueOnce(
new Error("Channel is required when multiple channels are configured: telegram, slack"),
);
const { respond } = await runPoll({
to: "x",
question: "Q?",
options: ["A", "B"],
idempotencyKey: "idem-poll-missing-channel-ambiguous",
});
expect(respond).toHaveBeenCalledWith(
false,
undefined,
expect.objectContaining({
message: expect.stringContaining("Channel is required"),
}),
);
});
it("does not mirror when delivery returns no results", async () => {
mocks.deliverOutboundPayloads.mockResolvedValue([]);

View File

@@ -1,8 +1,8 @@
import { resolveSessionAgentId } from "../../agents/agent-scope.js";
import { getChannelPlugin, normalizeChannelId } from "../../channels/plugins/index.js";
import { DEFAULT_CHAT_CHANNEL } from "../../channels/registry.js";
import { createOutboundSendDeps } from "../../cli/deps.js";
import { loadConfig } from "../../config/config.js";
import { resolveMessageChannelSelection } from "../../infra/outbound/channel-selection.js";
import { deliverOutboundPayloads } from "../../infra/outbound/deliver.js";
import {
ensureOutboundSessionEntry,
@@ -126,7 +126,16 @@ export const sendHandlers: GatewayRequestHandlers = {
);
return;
}
const channel = normalizedChannel ?? DEFAULT_CHAT_CHANNEL;
const cfg = loadConfig();
let channel = normalizedChannel;
if (!channel) {
try {
channel = (await resolveMessageChannelSelection({ cfg })).channel;
} catch (err) {
respond(false, undefined, errorShape(ErrorCodes.INVALID_REQUEST, String(err)));
return;
}
}
const accountId =
typeof request.accountId === "string" && request.accountId.trim().length
? request.accountId.trim()
@@ -148,7 +157,6 @@ export const sendHandlers: GatewayRequestHandlers = {
const work = (async (): Promise<InflightResult> => {
try {
const cfg = loadConfig();
const resolved = resolveOutboundTarget({
channel: outboundChannel,
to,
@@ -324,7 +332,16 @@ export const sendHandlers: GatewayRequestHandlers = {
);
return;
}
const channel = normalizedChannel ?? DEFAULT_CHAT_CHANNEL;
const cfg = loadConfig();
let channel = normalizedChannel;
if (!channel) {
try {
channel = (await resolveMessageChannelSelection({ cfg })).channel;
} catch (err) {
respond(false, undefined, errorShape(ErrorCodes.INVALID_REQUEST, String(err)));
return;
}
}
if (typeof request.durationSeconds === "number" && channel !== "telegram") {
respond(
false,
@@ -370,7 +387,6 @@ export const sendHandlers: GatewayRequestHandlers = {
);
return;
}
const cfg = loadConfig();
const resolved = resolveOutboundTarget({
channel: channel,
to,

View File

@@ -435,19 +435,31 @@ describe("gateway server agent", () => {
expect(images[0]?.data).toBe(BASE_IMAGE_PNG);
});
test("agent falls back to whatsapp when delivery requested and no last channel exists", async () => {
const call = await runMainAgentDeliveryWithSession({
entry: {
sessionId: "sess-main-missing-provider",
},
request: {
test("agent errors when delivery requested and no last channel exists", async () => {
setRegistry(defaultRegistry);
testState.allowFrom = ["+1555"];
try {
await setTestSessionStore({
entries: {
main: {
sessionId: "sess-main-missing-provider",
updatedAt: Date.now(),
},
},
});
const res = await rpcReq(ws, "agent", {
message: "hi",
sessionKey: "main",
deliver: true,
idempotencyKey: "idem-agent-missing-provider",
},
});
expectChannels(call, "whatsapp");
expect(call.to).toBe("+1555");
expect(call.deliver).toBe(true);
expect(call.sessionId).toBe("sess-main-missing-provider");
});
expect(res.ok).toBe(false);
expect(res.error?.code).toBe("INVALID_REQUEST");
expect(res.error?.message).toContain("Channel is required");
expect(vi.mocked(agentCommand)).not.toHaveBeenCalled();
} finally {
testState.allowFrom = undefined;
}
});
test.each([

View File

@@ -154,7 +154,7 @@ describe("gateway server agent", () => {
setRegistry(emptyRegistry);
});
test("agent falls back when last-channel plugin is unavailable", async () => {
test("agent errors when deliver=true and last-channel plugin is unavailable", async () => {
const registry = createRegistry([
{
pluginId: "msteams",
@@ -175,9 +175,10 @@ describe("gateway server agent", () => {
deliver: true,
idempotencyKey: "idem-agent-last-msteams",
});
expect(res.ok).toBe(true);
expectAgentRoutingCall({ channel: "whatsapp", deliver: true });
expect(res.ok).toBe(false);
expect(res.error?.code).toBe("INVALID_REQUEST");
expect(res.error?.message).toContain("Channel is required");
expect(vi.mocked(agentCommand)).not.toHaveBeenCalled();
});
test("agent accepts channel aliases (imsg/teams)", async () => {
@@ -233,7 +234,7 @@ describe("gateway server agent", () => {
expect(res.error?.code).toBe("INVALID_REQUEST");
});
test("agent ignores webchat last-channel for routing", async () => {
test("agent errors when deliver=true and last channel is webchat", async () => {
testState.allowFrom = ["+1555"];
await writeMainSessionEntry({
sessionId: "sess-main-webchat",
@@ -247,9 +248,10 @@ describe("gateway server agent", () => {
deliver: true,
idempotencyKey: "idem-agent-webchat",
});
expect(res.ok).toBe(true);
expectAgentRoutingCall({ channel: "whatsapp", deliver: true });
expect(res.ok).toBe(false);
expect(res.error?.code).toBe("INVALID_REQUEST");
expect(res.error?.message).toMatch(/Channel is required|runtime not initialized/);
expect(vi.mocked(agentCommand)).not.toHaveBeenCalled();
});
test("agent uses webchat for internal runs when last provider is webchat", async () => {

View File

@@ -59,6 +59,19 @@ describe("agent delivery helpers", () => {
expect(resolved.resolvedTo).toBe("+1999");
});
it("does not inject a default deliverable channel when session has none", () => {
const plan = resolveAgentDeliveryPlan({
sessionEntry: undefined,
requestedChannel: "last",
explicitTo: undefined,
accountId: undefined,
wantsDelivery: true,
});
expect(plan.resolvedChannel).toBe("webchat");
expect(plan.deliveryTargetMode).toBeUndefined();
});
it("skips outbound target resolution when explicit target validation is disabled", () => {
const plan = resolveAgentDeliveryPlan({
sessionEntry: {

View File

@@ -1,5 +1,4 @@
import type { ChannelOutboundTargetMode } from "../../channels/plugins/types.js";
import { DEFAULT_CHAT_CHANNEL } from "../../channels/registry.js";
import type { OpenClawConfig } from "../../config/config.js";
import type { SessionEntry } from "../../config/sessions.js";
import { normalizeAccountId } from "../../utils/account-id.js";
@@ -59,7 +58,7 @@ export function resolveAgentDeliveryPlan(params: {
if (baseDelivery.channel && baseDelivery.channel !== INTERNAL_MESSAGE_CHANNEL) {
return baseDelivery.channel;
}
return params.wantsDelivery ? DEFAULT_CHAT_CHANNEL : INTERNAL_MESSAGE_CHANNEL;
return INTERNAL_MESSAGE_CHANNEL;
}
if (isGatewayMessageChannel(requestedChannel)) {
@@ -69,7 +68,7 @@ export function resolveAgentDeliveryPlan(params: {
if (baseDelivery.channel && baseDelivery.channel !== INTERNAL_MESSAGE_CHANNEL) {
return baseDelivery.channel;
}
return params.wantsDelivery ? DEFAULT_CHAT_CHANNEL : INTERNAL_MESSAGE_CHANNEL;
return INTERNAL_MESSAGE_CHANNEL;
})();
const deliveryTargetMode = explicitTo