From 1cd3b309074d6e73ea92e38e65283bbb3973f4c0 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sun, 22 Feb 2026 11:20:33 +0100 Subject: [PATCH] fix: stop hardcoded channel fallback and auto-pick sole configured channel (#23357) (thanks @lbo728) Co-authored-by: lbo728 --- CHANGELOG.md | 1 + src/channels/registry.ts | 2 - src/cli/channel-auth.test.ts | 31 ++++-- src/cli/channel-auth.ts | 33 +++--- src/cli/channels-cli.ts | 4 +- src/cli/program/register.agent.ts | 3 +- src/commands/agent-via-gateway.ts | 3 +- src/commands/agent/delivery.ts | 37 +++++-- .../isolated-agent/delivery-target.test.ts | 36 +++++-- src/cron/isolated-agent/delivery-target.ts | 37 +++++-- src/cron/isolated-agent/run.ts | 18 +++- src/gateway/server-methods/agent.ts | 42 +++++++- src/gateway/server-methods/send.test.ts | 101 +++++++++++++++++- src/gateway/server-methods/send.ts | 26 ++++- ...r.agent.gateway-server-agent-a.e2e.test.ts | 36 ++++--- ...r.agent.gateway-server-agent-b.e2e.test.ts | 18 ++-- src/infra/outbound/agent-delivery.test.ts | 13 +++ src/infra/outbound/agent-delivery.ts | 5 +- 18 files changed, 355 insertions(+), 91 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index aa54e2b0893..b3e15b1a8e2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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. diff --git a/src/channels/registry.ts b/src/channels/registry.ts index 20a015320d5..958dbf174a3 100644 --- a/src/channels/registry.ts +++ b/src/channels/registry.ts @@ -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"; diff --git a/src/cli/channel-auth.test.ts b/src/cli/channel-auth.test.ts index 2510e058869..5f0c2a34b67 100644 --- a/src/cli/channel-auth.test.ts +++ b/src/cli/channel-auth.test.ts @@ -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); diff --git a/src/cli/channel-auth.ts b/src/cli/channel-auth.ts index 8b47cf4364d..4aa6f70576e 100644 --- a/src/cli/channel-auth.ts +++ b/src/cli/channel-auth.ts @@ -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>; 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, diff --git a/src/cli/channels-cli.ts b/src/cli/channels-cli.ts index 463bccac4e4..8a1b8eb3f53 100644 --- a/src/cli/channels-cli.ts +++ b/src/cli/channels-cli.ts @@ -221,7 +221,7 @@ export function registerChannelsCli(program: Command) { channels .command("login") .description("Link a channel account (if supported)") - .option("--channel ", "Channel alias (default: whatsapp)") + .option("--channel ", "Channel alias (auto when only one is configured)") .option("--account ", "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 alias (default: whatsapp)") + .option("--channel ", "Channel alias (auto when only one is configured)") .option("--account ", "Account id (accountId)") .action(async (opts) => { await runChannelsCommandWithDanger(async () => { diff --git a/src/cli/program/register.agent.ts b/src/cli/program/register.agent.ts index 7d114591dd9..4f112403c14 100644 --- a/src/cli/program/register.agent.ts +++ b/src/cli/program/register.agent.ts @@ -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 ", "Persist agent verbose level for the session") .option( "--channel ", - `Delivery channel: ${args.agentChannelOptions} (default: ${DEFAULT_CHAT_CHANNEL})`, + `Delivery channel: ${args.agentChannelOptions} (omit to use the main session channel)`, ) .option("--reply-to ", "Delivery target override (separate from session routing)") .option("--reply-channel ", "Delivery channel override (separate from routing)") diff --git a/src/commands/agent-via-gateway.ts b/src/commands/agent-via-gateway.ts index cc0c05850c3..39e282614bb 100644 --- a/src/commands/agent-via-gateway.ts +++ b/src/commands/agent-via-gateway.ts @@ -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( diff --git a/src/commands/agent/delivery.ts b/src/commands/agent/delivery.ts index d657295d058..24ef360a586 100644 --- a/src/commands/agent/delivery.ts +++ b/src/commands/agent/delivery.ts @@ -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; diff --git a/src/cron/isolated-agent/delivery-target.test.ts b/src/cron/isolated-agent/delivery-target.test.ts index 9f58a10e639..6cc3cd9c4e8 100644 --- a/src/cron/isolated-agent/delivery-target.test.ts +++ b/src/cron/isolated-agent/delivery-target.test.ts @@ -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(); }); }); diff --git a/src/cron/isolated-agent/delivery-target.ts b/src/cron/isolated-agent/delivery-target.ts index b13e4a40c6f..a800b9ca6ed 100644 --- a/src/cron/isolated-agent/delivery-target.ts +++ b/src/cron/isolated-agent/delivery-target.ts @@ -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; + channel?: Exclude; to?: string; accountId?: string; threadId?: string | number; @@ -57,12 +56,20 @@ export async function resolveDeliveryTarget( }); let fallbackChannel: Exclude | 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, }; } diff --git a/src/cron/isolated-agent/run.ts b/src/cron/isolated-agent/run.ts index 4de81a3db62..bb8c2f67833 100644 --- a/src/cron/isolated-agent/run.ts +++ b/src/cron/isolated-agent/run.ts @@ -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) { diff --git a/src/gateway/server-methods/agent.ts b/src/gateway/server-methods/agent.ts index 1336d42cb88..896a1ff0c7f 100644 --- a/src/gateway/server-methods/agent.ts +++ b/src/gateway/server-methods/agent.ts @@ -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 = { diff --git a/src/gateway/server-methods/send.test.ts b/src/gateway/server-methods/send.test.ts index c7001df58fe..7209d3e6176 100644 --- a/src/gateway/server-methods/send.test.ts +++ b/src/gateway/server-methods/send.test.ts @@ -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) { return { respond }; } +async function runPoll(params: Record) { + 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([]); diff --git a/src/gateway/server-methods/send.ts b/src/gateway/server-methods/send.ts index 527eec42483..6e456f771da 100644 --- a/src/gateway/server-methods/send.ts +++ b/src/gateway/server-methods/send.ts @@ -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 => { 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, diff --git a/src/gateway/server.agent.gateway-server-agent-a.e2e.test.ts b/src/gateway/server.agent.gateway-server-agent-a.e2e.test.ts index 59d983e5ded..c6b54e189e1 100644 --- a/src/gateway/server.agent.gateway-server-agent-a.e2e.test.ts +++ b/src/gateway/server.agent.gateway-server-agent-a.e2e.test.ts @@ -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([ diff --git a/src/gateway/server.agent.gateway-server-agent-b.e2e.test.ts b/src/gateway/server.agent.gateway-server-agent-b.e2e.test.ts index fe786188574..9468a7e8cd9 100644 --- a/src/gateway/server.agent.gateway-server-agent-b.e2e.test.ts +++ b/src/gateway/server.agent.gateway-server-agent-b.e2e.test.ts @@ -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 () => { diff --git a/src/infra/outbound/agent-delivery.test.ts b/src/infra/outbound/agent-delivery.test.ts index 8f2cbb23ea3..6a1ae858d7b 100644 --- a/src/infra/outbound/agent-delivery.test.ts +++ b/src/infra/outbound/agent-delivery.test.ts @@ -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: { diff --git a/src/infra/outbound/agent-delivery.ts b/src/infra/outbound/agent-delivery.ts index 08480cbf23b..7c856598d2d 100644 --- a/src/infra/outbound/agent-delivery.ts +++ b/src/infra/outbound/agent-delivery.ts @@ -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