Feature/default messenger delivery target (openclaw#16985) thanks @KirillShchetinin

Verified:
- pnpm build
- pnpm check
- pnpm test:macmini

Co-authored-by: KirillShchetinin <13061871+KirillShchetinin@users.noreply.github.com>
Co-authored-by: Tak Hoffman <781889+Takhoffman@users.noreply.github.com>
This commit is contained in:
Kirill Shchetynin
2026-02-19 23:37:19 -05:00
committed by GitHub
parent 59e58bf81c
commit ee519086f6
27 changed files with 289 additions and 6 deletions

View File

@@ -6,6 +6,7 @@ Docs: https://docs.openclaw.ai
### Changes
- Channels/CLI: add per-account/channel `defaultTo` outbound routing fallback so `openclaw agent --deliver` can send without explicit `--reply-to` when a default target is configured. (#16985) Thanks @KirillShchetinin.
- iOS/Gateway: stabilize background wake and reconnect behavior with background reconnect suppression/lease windows, BGAppRefresh wake fallback, location wake hook throttling, and APNs wake retry+nudge instrumentation. (#21226) thanks @mbelinky.
- Auto-reply/UI: add model fallback lifecycle visibility in verbose logs, /status active-model context with fallback reason, and cohesive WebUI fallback indicators. (#20704) Thanks @joshavant.

View File

@@ -110,6 +110,8 @@ export const discordPlugin: ChannelPlugin<ResolvedDiscordAccount> = {
.map((entry) => String(entry).trim())
.filter(Boolean)
.map((entry) => entry.toLowerCase()),
resolveDefaultTo: ({ cfg, accountId }) =>
resolveDiscordAccount({ cfg, accountId }).config.defaultTo?.trim() || undefined,
},
security: {
resolveDmPolicy: ({ cfg, accountId, account }) => {

View File

@@ -178,6 +178,8 @@ export const googlechatPlugin: ChannelPlugin<ResolvedGoogleChatAccount> = {
.map((entry) => String(entry))
.filter(Boolean)
.map(formatAllowFromEntry),
resolveDefaultTo: ({ cfg, accountId }) =>
resolveGoogleChatAccount({ cfg, accountId }).config.defaultTo?.trim() || undefined,
},
security: {
resolveDmPolicy: ({ cfg, accountId, account }) => {

View File

@@ -78,6 +78,8 @@ export const imessagePlugin: ChannelPlugin<ResolvedIMessageAccount> = {
),
formatAllowFrom: ({ allowFrom }) =>
allowFrom.map((entry) => String(entry).trim()).filter(Boolean),
resolveDefaultTo: ({ cfg, accountId }) =>
resolveIMessageAccount({ cfg, accountId }).config.defaultTo?.trim() || undefined,
},
security: {
resolveDmPolicy: ({ cfg, accountId, account }) => {

View File

@@ -112,6 +112,9 @@ export const ircPlugin: ChannelPlugin<ResolvedIrcAccount, IrcProbe> = {
),
formatAllowFrom: ({ allowFrom }) =>
allowFrom.map((entry) => normalizeIrcAllowEntry(String(entry))).filter(Boolean),
resolveDefaultTo: ({ cfg, accountId }) =>
resolveIrcAccount({ cfg: cfg as CoreConfig, accountId }).config.defaultTo?.trim() ||
undefined,
},
security: {
resolveDmPolicy: ({ cfg, accountId, account }) => {

View File

@@ -43,6 +43,7 @@ export type IrcAccountConfig = {
nickserv?: IrcNickServConfig;
dmPolicy?: DmPolicy;
allowFrom?: Array<string | number>;
defaultTo?: string;
groupPolicy?: GroupPolicy;
groupAllowFrom?: Array<string | number>;
groups?: Record<string, IrcChannelConfig>;

View File

@@ -123,6 +123,7 @@ export const msteamsPlugin: ChannelPlugin<ResolvedMSTeamsAccount> = {
.map((entry) => String(entry).trim())
.filter(Boolean)
.map((entry) => entry.toLowerCase()),
resolveDefaultTo: ({ cfg }) => cfg.channels?.msteams?.defaultTo?.trim() || undefined,
},
security: {
collectWarnings: ({ cfg }) => {

View File

@@ -103,6 +103,8 @@ export const signalPlugin: ChannelPlugin<ResolvedSignalAccount> = {
.filter(Boolean)
.map((entry) => (entry === "*" ? "*" : normalizeE164(entry.replace(/^signal:/i, ""))))
.filter(Boolean),
resolveDefaultTo: ({ cfg, accountId }) =>
resolveSignalAccount({ cfg, accountId }).config.defaultTo?.trim() || undefined,
},
security: {
resolveDmPolicy: ({ cfg, accountId, account }) => {

View File

@@ -130,6 +130,8 @@ export const slackPlugin: ChannelPlugin<ResolvedSlackAccount> = {
.map((entry) => String(entry).trim())
.filter(Boolean)
.map((entry) => entry.toLowerCase()),
resolveDefaultTo: ({ cfg, accountId }) =>
resolveSlackAccount({ cfg, accountId }).config.defaultTo?.trim() || undefined,
},
security: {
resolveDmPolicy: ({ cfg, accountId, account }) => {

View File

@@ -119,6 +119,10 @@ export const telegramPlugin: ChannelPlugin<ResolvedTelegramAccount, TelegramProb
.filter(Boolean)
.map((entry) => entry.replace(/^(telegram|tg):/i, ""))
.map((entry) => entry.toLowerCase()),
resolveDefaultTo: ({ cfg, accountId }) => {
const val = resolveTelegramAccount({ cfg, accountId }).config.defaultTo;
return val != null ? String(val) : undefined;
},
},
security: {
resolveDmPolicy: ({ cfg, accountId, account }) => {

View File

@@ -118,6 +118,12 @@ export const whatsappPlugin: ChannelPlugin<ResolvedWhatsAppAccount> = {
.filter((entry): entry is string => Boolean(entry))
.map((entry) => (entry === "*" ? entry : normalizeWhatsAppTarget(entry)))
.filter((entry): entry is string => Boolean(entry)),
resolveDefaultTo: ({ cfg, accountId }) => {
const root = cfg.channels?.whatsapp;
const normalized = normalizeAccountId(accountId);
const account = root?.accounts?.[normalized];
return (account?.defaultTo ?? root?.defaultTo)?.trim() || undefined;
},
},
security: {
resolveDmPolicy: ({ cfg, accountId, account }) => {

View File

@@ -63,6 +63,10 @@ export type ChannelDock = {
accountId?: string | null;
allowFrom: Array<string | number>;
}) => string[];
resolveDefaultTo?: (params: {
cfg: OpenClawConfig;
accountId?: string | null;
}) => string | undefined;
};
groups?: ChannelGroupAdapter;
mentions?: ChannelMentionAdapter;
@@ -174,6 +178,10 @@ const DOCKS: Record<ChatChannelId, ChannelDock> = {
.filter(Boolean)
.map((entry) => entry.replace(/^(telegram|tg):/i, ""))
.map((entry) => entry.toLowerCase()),
resolveDefaultTo: ({ cfg, accountId }) => {
const val = resolveTelegramAccount({ cfg, accountId }).config.defaultTo;
return val != null ? String(val) : undefined;
},
},
groups: {
resolveRequireMention: resolveTelegramGroupRequireMention,
@@ -213,6 +221,12 @@ const DOCKS: Record<ChatChannelId, ChannelDock> = {
.filter((entry): entry is string => Boolean(entry))
.map((entry) => (entry === "*" ? entry : normalizeWhatsAppTarget(entry)))
.filter((entry): entry is string => Boolean(entry)),
resolveDefaultTo: ({ cfg, accountId }) => {
const root = cfg.channels?.whatsapp;
const normalized = normalizeAccountId(accountId);
const account = root?.accounts?.[normalized];
return (account?.defaultTo ?? root?.defaultTo)?.trim() || undefined;
},
},
groups: {
resolveRequireMention: resolveWhatsAppGroupRequireMention,
@@ -267,6 +281,8 @@ const DOCKS: Record<ChatChannelId, ChannelDock> = {
);
},
formatAllowFrom: ({ allowFrom }) => formatDiscordAllowFrom(allowFrom),
resolveDefaultTo: ({ cfg, accountId }) =>
resolveDiscordAccount({ cfg, accountId }).config.defaultTo?.trim() || undefined,
},
groups: {
resolveRequireMention: resolveDiscordGroupRequireMention,
@@ -311,6 +327,20 @@ const DOCKS: Record<ChatChannelId, ChannelDock> = {
.replace(/^user:/i, "")
.toLowerCase(),
),
resolveDefaultTo: ({ cfg, accountId }) => {
const channel = cfg.channels?.irc as
| { accounts?: Record<string, { defaultTo?: string }>; defaultTo?: string }
| undefined;
const normalized = normalizeAccountId(accountId);
const account =
channel?.accounts?.[normalized] ??
channel?.accounts?.[
Object.keys(channel?.accounts ?? {}).find(
(key) => key.toLowerCase() === normalized.toLowerCase(),
) ?? ""
];
return (account?.defaultTo ?? channel?.defaultTo)?.trim() || undefined;
},
},
groups: {
resolveRequireMention: ({ cfg, accountId, groupId }) => {
@@ -378,6 +408,20 @@ const DOCKS: Record<ChatChannelId, ChannelDock> = {
.replace(/^users\//i, "")
.toLowerCase(),
),
resolveDefaultTo: ({ cfg, accountId }) => {
const channel = cfg.channels?.googlechat as
| { accounts?: Record<string, { defaultTo?: string }>; defaultTo?: string }
| undefined;
const normalized = normalizeAccountId(accountId);
const account =
channel?.accounts?.[normalized] ??
channel?.accounts?.[
Object.keys(channel?.accounts ?? {}).find(
(key) => key.toLowerCase() === normalized.toLowerCase(),
) ?? ""
];
return (account?.defaultTo ?? channel?.defaultTo)?.trim() || undefined;
},
},
groups: {
resolveRequireMention: resolveGoogleChatGroupRequireMention,
@@ -416,6 +460,8 @@ const DOCKS: Record<ChatChannelId, ChannelDock> = {
);
},
formatAllowFrom: ({ allowFrom }) => formatLower(allowFrom),
resolveDefaultTo: ({ cfg, accountId }) =>
resolveSlackAccount({ cfg, accountId }).config.defaultTo?.trim() || undefined,
},
groups: {
resolveRequireMention: resolveSlackGroupRequireMention,
@@ -453,6 +499,8 @@ const DOCKS: Record<ChatChannelId, ChannelDock> = {
.filter(Boolean)
.map((entry) => (entry === "*" ? "*" : normalizeE164(entry.replace(/^signal:/i, ""))))
.filter(Boolean),
resolveDefaultTo: ({ cfg, accountId }) =>
resolveSignalAccount({ cfg, accountId }).config.defaultTo?.trim() || undefined,
},
threading: {
buildToolContext: ({ context, hasRepliedRef }) =>
@@ -474,6 +522,8 @@ const DOCKS: Record<ChatChannelId, ChannelDock> = {
),
formatAllowFrom: ({ allowFrom }) =>
allowFrom.map((entry) => String(entry).trim()).filter(Boolean),
resolveDefaultTo: ({ cfg, accountId }) =>
resolveIMessageAccount({ cfg, accountId }).config.defaultTo?.trim() || undefined,
},
groups: {
resolveRequireMention: resolveIMessageGroupRequireMention,
@@ -502,6 +552,7 @@ function buildDockFromPlugin(plugin: ChannelPlugin): ChannelDock {
? {
resolveAllowFrom: plugin.config.resolveAllowFrom,
formatAllowFrom: plugin.config.formatAllowFrom,
resolveDefaultTo: plugin.config.resolveDefaultTo,
}
: undefined,
groups: plugin.groups,

View File

@@ -63,6 +63,10 @@ export type ChannelConfigAdapter<ResolvedAccount> = {
accountId?: string | null;
allowFrom: Array<string | number>;
}) => string[];
resolveDefaultTo?: (params: {
cfg: OpenClawConfig;
accountId?: string | null;
}) => string | undefined;
};
export type ChannelGroupAdapter = {

View File

@@ -31,6 +31,8 @@ export type ChannelDefaultsConfig = {
export type ExtensionChannelConfig = {
enabled?: boolean;
allowFrom?: string | string[];
/** Default delivery target for CLI --deliver when no explicit --reply-to is provided. */
defaultTo?: string;
dmPolicy?: string;
groupPolicy?: GroupPolicy;
accounts?: Record<string, unknown>;

View File

@@ -183,6 +183,8 @@ export type DiscordAccountConfig = {
* Legacy key: channels.discord.dm.allowFrom.
*/
allowFrom?: string[];
/** Default delivery target for CLI --deliver when no explicit --reply-to is provided. */
defaultTo?: string;
dm?: DiscordDmConfig;
/** New per-guild config keyed by guild id or slug. */
guilds?: Record<string, DiscordGuildEntry>;

View File

@@ -54,6 +54,8 @@ export type GoogleChatAccountConfig = {
groupPolicy?: GroupPolicy;
/** Optional allowlist for space senders (user ids or emails). */
groupAllowFrom?: Array<string | number>;
/** Default delivery target for CLI --deliver when no explicit --reply-to is provided. */
defaultTo?: string;
/** Per-space configuration keyed by space id or name. */
groups?: Record<string, GoogleChatGroupConfig>;
/** Service account JSON (inline string or object). */

View File

@@ -33,6 +33,8 @@ export type IMessageAccountConfig = {
dmPolicy?: DmPolicy;
/** Optional allowlist for inbound handles or chat_id targets. */
allowFrom?: Array<string | number>;
/** Default delivery target for CLI --deliver when no explicit --reply-to is provided. */
defaultTo?: string;
/** Optional allowlist for group senders or chat_id targets. */
groupAllowFrom?: Array<string | number>;
/**

View File

@@ -56,6 +56,8 @@ export type IrcAccountConfig = {
dmPolicy?: DmPolicy;
/** Optional allowlist for inbound DM senders. */
allowFrom?: Array<string | number>;
/** Default delivery target for CLI --deliver when no explicit --reply-to is provided. */
defaultTo?: string;
/** Optional allowlist for IRC channel senders. */
groupAllowFrom?: Array<string | number>;
/**

View File

@@ -63,6 +63,8 @@ export type MSTeamsConfig = {
dmPolicy?: DmPolicy;
/** Allowlist for DM senders (AAD object IDs or UPNs). */
allowFrom?: Array<string>;
/** Default delivery target for CLI --deliver when no explicit --reply-to is provided. */
defaultTo?: string;
/** Optional allowlist for group/channel senders (AAD object IDs or UPNs). */
groupAllowFrom?: Array<string>;
/**

View File

@@ -42,6 +42,8 @@ export type SignalAccountConfig = {
/** Direct message access policy (default: pairing). */
dmPolicy?: DmPolicy;
allowFrom?: Array<string | number>;
/** Default delivery target for CLI --deliver when no explicit --reply-to is provided. */
defaultTo?: string;
/** Optional allowlist for Signal group senders (E.164). */
groupAllowFrom?: Array<string | number>;
/**

View File

@@ -160,6 +160,8 @@ export type SlackAccountConfig = {
* Legacy key: channels.slack.dm.allowFrom.
*/
allowFrom?: Array<string | number>;
/** Default delivery target for CLI --deliver when no explicit --reply-to is provided. */
defaultTo?: string;
dm?: SlackDmConfig;
channels?: Record<string, SlackChannelConfig>;
/** Heartbeat visibility settings for this channel. */

View File

@@ -74,6 +74,8 @@ export type TelegramAccountConfig = {
groups?: Record<string, TelegramGroupConfig>;
/** DM allowlist (numeric Telegram user IDs). Onboarding can resolve @username to IDs. */
allowFrom?: Array<string | number>;
/** Default delivery target for CLI `--deliver` when no explicit `--reply-to` is provided. */
defaultTo?: string | number;
/** Optional allowlist for Telegram group senders (numeric Telegram user IDs). */
groupAllowFrom?: Array<string | number>;
/**

View File

@@ -67,6 +67,8 @@ export type WhatsAppConfig = {
selfChatMode?: boolean;
/** Optional allowlist for WhatsApp direct chats (E.164). */
allowFrom?: string[];
/** Default delivery target for CLI `--deliver` when no explicit `--reply-to` is provided (E.164 or group JID). */
defaultTo?: string;
/** Optional allowlist for WhatsApp group senders (E.164). */
groupAllowFrom?: string[];
/**
@@ -127,6 +129,8 @@ export type WhatsAppAccountConfig = {
/** Same-phone setup for this account (bot uses your personal WhatsApp number). */
selfChatMode?: boolean;
allowFrom?: string[];
/** Default delivery target for CLI `--deliver` when no explicit `--reply-to` is provided (E.164 or group JID). */
defaultTo?: string;
groupAllowFrom?: string[];
groupPolicy?: GroupPolicy;
/** Max group messages to keep as history context (0 disables). */

View File

@@ -113,6 +113,7 @@ export const TelegramAccountSchemaBase = z
replyToMode: ReplyToModeSchema.optional(),
groups: z.record(z.string(), TelegramGroupSchema.optional()).optional(),
allowFrom: z.array(z.union([z.string(), z.number()])).optional(),
defaultTo: z.union([z.string(), z.number()]).optional(),
groupAllowFrom: z.array(z.union([z.string(), z.number()])).optional(),
groupPolicy: GroupPolicySchema.optional().default("allowlist"),
historyLimit: z.number().int().min(0).optional(),
@@ -321,6 +322,7 @@ export const DiscordAccountSchema = z
// inheritance in multi-account setups (shallow merge works; nested dm object doesn't).
dmPolicy: DmPolicySchema.optional(),
allowFrom: DiscordIdListSchema.optional(),
defaultTo: z.string().optional(),
dm: DiscordDmSchema.optional(),
guilds: z.record(z.string(), DiscordGuildSchema.optional()).optional(),
heartbeat: ChannelHeartbeatVisibilitySchema,
@@ -448,6 +450,7 @@ export const GoogleChatAccountSchema = z
groupPolicy: GroupPolicySchema.optional().default("allowlist"),
groupAllowFrom: z.array(z.union([z.string(), z.number()])).optional(),
groups: z.record(z.string(), GoogleChatGroupSchema.optional()).optional(),
defaultTo: z.string().optional(),
serviceAccount: z.union([z.string(), z.record(z.string(), z.unknown())]).optional(),
serviceAccountFile: z.string().optional(),
audienceType: z.enum(["app-url", "project-number"]).optional(),
@@ -581,6 +584,7 @@ export const SlackAccountSchema = z
// inheritance in multi-account setups (shallow merge works; nested dm object doesn't).
dmPolicy: DmPolicySchema.optional(),
allowFrom: z.array(z.union([z.string(), z.number()])).optional(),
defaultTo: z.string().optional(),
dm: SlackDmSchema.optional(),
channels: z.record(z.string(), SlackChannelSchema.optional()).optional(),
heartbeat: ChannelHeartbeatVisibilitySchema,
@@ -663,6 +667,7 @@ export const SignalAccountSchemaBase = z
sendReadReceipts: z.boolean().optional(),
dmPolicy: DmPolicySchema.optional().default("pairing"),
allowFrom: z.array(z.union([z.string(), z.number()])).optional(),
defaultTo: z.string().optional(),
groupAllowFrom: z.array(z.union([z.string(), z.number()])).optional(),
groupPolicy: GroupPolicySchema.optional().default("allowlist"),
historyLimit: z.number().int().min(0).optional(),
@@ -751,6 +756,7 @@ export const IrcAccountSchemaBase = z
channels: z.array(z.string()).optional(),
dmPolicy: DmPolicySchema.optional().default("pairing"),
allowFrom: z.array(z.union([z.string(), z.number()])).optional(),
defaultTo: z.string().optional(),
groupAllowFrom: z.array(z.union([z.string(), z.number()])).optional(),
groupPolicy: GroupPolicySchema.optional().default("allowlist"),
groups: z.record(z.string(), IrcGroupSchema.optional()).optional(),
@@ -814,6 +820,7 @@ export const IMessageAccountSchemaBase = z
region: z.string().optional(),
dmPolicy: DmPolicySchema.optional().default("pairing"),
allowFrom: z.array(z.union([z.string(), z.number()])).optional(),
defaultTo: z.string().optional(),
groupAllowFrom: z.array(z.union([z.string(), z.number()])).optional(),
groupPolicy: GroupPolicySchema.optional().default("allowlist"),
historyLimit: z.number().int().min(0).optional(),
@@ -991,6 +998,7 @@ export const MSTeamsConfigSchema = z
.optional(),
dmPolicy: DmPolicySchema.optional().default("pairing"),
allowFrom: z.array(z.string()).optional(),
defaultTo: z.string().optional(),
groupAllowFrom: z.array(z.string()).optional(),
groupPolicy: GroupPolicySchema.optional().default("allowlist"),
textChunkLimit: z.number().int().positive().optional(),

View File

@@ -41,6 +41,7 @@ const WhatsAppSharedSchema = z.object({
dmPolicy: DmPolicySchema.optional().default("pairing"),
selfChatMode: z.boolean().optional(),
allowFrom: z.array(z.string()).optional(),
defaultTo: z.string().optional(),
groupAllowFrom: z.array(z.string()).optional(),
groupPolicy: GroupPolicySchema.optional().default("allowlist"),
historyLimit: z.number().int().min(0).optional(),

View File

@@ -1,5 +1,167 @@
import { describe, expect, it } from "vitest";
import { resolveSessionDeliveryTarget } from "./targets.js";
import { beforeEach, describe, expect, it } from "vitest";
import { telegramPlugin } from "../../../extensions/telegram/src/channel.js";
import { whatsappPlugin } from "../../../extensions/whatsapp/src/channel.js";
import type { OpenClawConfig } from "../../config/config.js";
import { setActivePluginRegistry } from "../../plugins/runtime.js";
import { createTestRegistry } from "../../test-utils/channel-plugins.js";
import { resolveOutboundTarget, resolveSessionDeliveryTarget } from "./targets.js";
describe("resolveOutboundTarget", () => {
beforeEach(() => {
setActivePluginRegistry(
createTestRegistry([
{ pluginId: "whatsapp", plugin: whatsappPlugin, source: "test" },
{ pluginId: "telegram", plugin: telegramPlugin, source: "test" },
]),
);
});
it("rejects whatsapp with empty target even when allowFrom configured", () => {
const cfg: OpenClawConfig = {
channels: { whatsapp: { allowFrom: ["+1555"] } },
};
const res = resolveOutboundTarget({
channel: "whatsapp",
to: "",
cfg,
mode: "explicit",
});
expect(res.ok).toBe(false);
if (!res.ok) {
expect(res.error.message).toContain("WhatsApp");
}
});
it.each([
{
name: "normalizes whatsapp target when provided",
input: { channel: "whatsapp" as const, to: " (555) 123-4567 " },
expected: { ok: true as const, to: "+5551234567" },
},
{
name: "keeps whatsapp group targets",
input: { channel: "whatsapp" as const, to: "120363401234567890@g.us" },
expected: { ok: true as const, to: "120363401234567890@g.us" },
},
{
name: "normalizes prefixed/uppercase whatsapp group targets",
input: {
channel: "whatsapp" as const,
to: " WhatsApp:120363401234567890@G.US ",
},
expected: { ok: true as const, to: "120363401234567890@g.us" },
},
{
name: "rejects whatsapp with empty target and allowFrom (no silent fallback)",
input: { channel: "whatsapp" as const, to: "", allowFrom: ["+1555"] },
expectedErrorIncludes: "WhatsApp",
},
{
name: "rejects whatsapp with empty target and prefixed allowFrom (no silent fallback)",
input: {
channel: "whatsapp" as const,
to: "",
allowFrom: ["whatsapp:(555) 123-4567"],
},
expectedErrorIncludes: "WhatsApp",
},
{
name: "rejects invalid whatsapp target",
input: { channel: "whatsapp" as const, to: "wat" },
expectedErrorIncludes: "WhatsApp",
},
{
name: "rejects whatsapp without to when allowFrom missing",
input: { channel: "whatsapp" as const, to: " " },
expectedErrorIncludes: "WhatsApp",
},
{
name: "rejects whatsapp allowFrom fallback when invalid",
input: { channel: "whatsapp" as const, to: "", allowFrom: ["wat"] },
expectedErrorIncludes: "WhatsApp",
},
])("$name", ({ input, expected, expectedErrorIncludes }) => {
const res = resolveOutboundTarget(input);
if (expected) {
expect(res).toEqual(expected);
return;
}
expect(res.ok).toBe(false);
if (!res.ok) {
expect(res.error.message).toContain(expectedErrorIncludes);
}
});
it("rejects telegram with missing target", () => {
const res = resolveOutboundTarget({ channel: "telegram", to: " " });
expect(res.ok).toBe(false);
if (!res.ok) {
expect(res.error.message).toContain("Telegram");
}
});
it("rejects webchat delivery", () => {
const res = resolveOutboundTarget({ channel: "webchat", to: "x" });
expect(res.ok).toBe(false);
if (!res.ok) {
expect(res.error.message).toContain("WebChat");
}
});
describe("defaultTo config fallback", () => {
it("uses whatsapp defaultTo when no explicit target is provided", () => {
const cfg: OpenClawConfig = {
channels: { whatsapp: { defaultTo: "+15551234567", allowFrom: ["*"] } },
};
const res = resolveOutboundTarget({
channel: "whatsapp",
to: undefined,
cfg,
mode: "implicit",
});
expect(res).toEqual({ ok: true, to: "+15551234567" });
});
it("uses telegram defaultTo when no explicit target is provided", () => {
const cfg: OpenClawConfig = {
channels: { telegram: { defaultTo: "123456789" } },
};
const res = resolveOutboundTarget({
channel: "telegram",
to: "",
cfg,
mode: "implicit",
});
expect(res).toEqual({ ok: true, to: "123456789" });
});
it("explicit --reply-to overrides defaultTo", () => {
const cfg: OpenClawConfig = {
channels: { whatsapp: { defaultTo: "+15551234567", allowFrom: ["*"] } },
};
const res = resolveOutboundTarget({
channel: "whatsapp",
to: "+15559999999",
cfg,
mode: "explicit",
});
expect(res).toEqual({ ok: true, to: "+15559999999" });
});
it("still errors when no defaultTo and no explicit target", () => {
const cfg: OpenClawConfig = {
channels: { whatsapp: { allowFrom: ["+1555"] } },
};
const res = resolveOutboundTarget({
channel: "whatsapp",
to: "",
cfg,
mode: "implicit",
});
expect(res.ok).toBe(false);
});
});
});
describe("resolveSessionDeliveryTarget", () => {
it("derives implicit delivery from the last route", () => {

View File

@@ -169,20 +169,29 @@ export function resolveOutboundTarget(params: {
})
: undefined);
// Fall back to per-channel defaultTo when no explicit target is provided.
const effectiveTo =
params.to?.trim() ||
(params.cfg && plugin.config.resolveDefaultTo
? plugin.config.resolveDefaultTo({
cfg: params.cfg,
accountId: params.accountId ?? undefined,
})
: undefined);
const resolveTarget = plugin.outbound?.resolveTarget;
if (resolveTarget) {
return resolveTarget({
cfg: params.cfg,
to: params.to,
to: effectiveTo,
allowFrom,
accountId: params.accountId ?? undefined,
mode: params.mode ?? "explicit",
});
}
const trimmed = params.to?.trim();
if (trimmed) {
return { ok: true, to: trimmed };
if (effectiveTo) {
return { ok: true, to: effectiveTo };
}
const hint = plugin.messaging?.targetResolver?.hint;
return {