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:
@@ -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.
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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)")
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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([]);
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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([
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user