From ee519086f60c3bd5602880df53ca892a52a7fb36 Mon Sep 17 00:00:00 2001 From: Kirill Shchetynin Date: Thu, 19 Feb 2026 23:37:19 -0500 Subject: [PATCH] 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> --- CHANGELOG.md | 1 + extensions/discord/src/channel.ts | 2 + extensions/googlechat/src/channel.ts | 2 + extensions/imessage/src/channel.ts | 2 + extensions/irc/src/channel.ts | 3 + extensions/irc/src/types.ts | 1 + extensions/msteams/src/channel.ts | 1 + extensions/signal/src/channel.ts | 2 + extensions/slack/src/channel.ts | 2 + extensions/telegram/src/channel.ts | 4 + extensions/whatsapp/src/channel.ts | 6 + src/channels/dock.ts | 51 ++++++ src/channels/plugins/types.adapters.ts | 4 + src/config/types.channels.ts | 2 + src/config/types.discord.ts | 2 + src/config/types.googlechat.ts | 2 + src/config/types.imessage.ts | 2 + src/config/types.irc.ts | 2 + src/config/types.msteams.ts | 2 + src/config/types.signal.ts | 2 + src/config/types.slack.ts | 2 + src/config/types.telegram.ts | 2 + src/config/types.whatsapp.ts | 4 + src/config/zod-schema.providers-core.ts | 8 + src/config/zod-schema.providers-whatsapp.ts | 1 + src/infra/outbound/targets.test.ts | 166 +++++++++++++++++++- src/infra/outbound/targets.ts | 17 +- 27 files changed, 289 insertions(+), 6 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index cbd0b2012c1..29071c11b48 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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. diff --git a/extensions/discord/src/channel.ts b/extensions/discord/src/channel.ts index 4db082e32ef..7556f14e154 100644 --- a/extensions/discord/src/channel.ts +++ b/extensions/discord/src/channel.ts @@ -110,6 +110,8 @@ export const discordPlugin: ChannelPlugin = { .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 }) => { diff --git a/extensions/googlechat/src/channel.ts b/extensions/googlechat/src/channel.ts index 79918b94940..8022add55ca 100644 --- a/extensions/googlechat/src/channel.ts +++ b/extensions/googlechat/src/channel.ts @@ -178,6 +178,8 @@ export const googlechatPlugin: ChannelPlugin = { .map((entry) => String(entry)) .filter(Boolean) .map(formatAllowFromEntry), + resolveDefaultTo: ({ cfg, accountId }) => + resolveGoogleChatAccount({ cfg, accountId }).config.defaultTo?.trim() || undefined, }, security: { resolveDmPolicy: ({ cfg, accountId, account }) => { diff --git a/extensions/imessage/src/channel.ts b/extensions/imessage/src/channel.ts index dd57a0b75ba..00696414f23 100644 --- a/extensions/imessage/src/channel.ts +++ b/extensions/imessage/src/channel.ts @@ -78,6 +78,8 @@ export const imessagePlugin: ChannelPlugin = { ), 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 }) => { diff --git a/extensions/irc/src/channel.ts b/extensions/irc/src/channel.ts index 4c39012831a..024f379c3d0 100644 --- a/extensions/irc/src/channel.ts +++ b/extensions/irc/src/channel.ts @@ -112,6 +112,9 @@ export const ircPlugin: ChannelPlugin = { ), 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 }) => { diff --git a/extensions/irc/src/types.ts b/extensions/irc/src/types.ts index ac6a5c9cb7b..2da3d31bafc 100644 --- a/extensions/irc/src/types.ts +++ b/extensions/irc/src/types.ts @@ -43,6 +43,7 @@ export type IrcAccountConfig = { nickserv?: IrcNickServConfig; dmPolicy?: DmPolicy; allowFrom?: Array; + defaultTo?: string; groupPolicy?: GroupPolicy; groupAllowFrom?: Array; groups?: Record; diff --git a/extensions/msteams/src/channel.ts b/extensions/msteams/src/channel.ts index 2958e4c22d0..d7e9b3088e8 100644 --- a/extensions/msteams/src/channel.ts +++ b/extensions/msteams/src/channel.ts @@ -123,6 +123,7 @@ export const msteamsPlugin: ChannelPlugin = { .map((entry) => String(entry).trim()) .filter(Boolean) .map((entry) => entry.toLowerCase()), + resolveDefaultTo: ({ cfg }) => cfg.channels?.msteams?.defaultTo?.trim() || undefined, }, security: { collectWarnings: ({ cfg }) => { diff --git a/extensions/signal/src/channel.ts b/extensions/signal/src/channel.ts index 18c3bcc2393..2d627eeb9a6 100644 --- a/extensions/signal/src/channel.ts +++ b/extensions/signal/src/channel.ts @@ -103,6 +103,8 @@ export const signalPlugin: ChannelPlugin = { .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 }) => { diff --git a/extensions/slack/src/channel.ts b/extensions/slack/src/channel.ts index d8f40efe3d9..891dd6a590c 100644 --- a/extensions/slack/src/channel.ts +++ b/extensions/slack/src/channel.ts @@ -130,6 +130,8 @@ export const slackPlugin: ChannelPlugin = { .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 }) => { diff --git a/extensions/telegram/src/channel.ts b/extensions/telegram/src/channel.ts index 8623aa94761..9cc203fd59c 100644 --- a/extensions/telegram/src/channel.ts +++ b/extensions/telegram/src/channel.ts @@ -119,6 +119,10 @@ export const telegramPlugin: ChannelPlugin 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 }) => { diff --git a/extensions/whatsapp/src/channel.ts b/extensions/whatsapp/src/channel.ts index f0248823cad..d19359630b1 100644 --- a/extensions/whatsapp/src/channel.ts +++ b/extensions/whatsapp/src/channel.ts @@ -118,6 +118,12 @@ export const whatsappPlugin: ChannelPlugin = { .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 }) => { diff --git a/src/channels/dock.ts b/src/channels/dock.ts index 38ec5aab8a2..b881a1008aa 100644 --- a/src/channels/dock.ts +++ b/src/channels/dock.ts @@ -63,6 +63,10 @@ export type ChannelDock = { accountId?: string | null; allowFrom: Array; }) => string[]; + resolveDefaultTo?: (params: { + cfg: OpenClawConfig; + accountId?: string | null; + }) => string | undefined; }; groups?: ChannelGroupAdapter; mentions?: ChannelMentionAdapter; @@ -174,6 +178,10 @@ const DOCKS: Record = { .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 = { .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 = { ); }, formatAllowFrom: ({ allowFrom }) => formatDiscordAllowFrom(allowFrom), + resolveDefaultTo: ({ cfg, accountId }) => + resolveDiscordAccount({ cfg, accountId }).config.defaultTo?.trim() || undefined, }, groups: { resolveRequireMention: resolveDiscordGroupRequireMention, @@ -311,6 +327,20 @@ const DOCKS: Record = { .replace(/^user:/i, "") .toLowerCase(), ), + resolveDefaultTo: ({ cfg, accountId }) => { + const channel = cfg.channels?.irc as + | { accounts?: Record; 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 = { .replace(/^users\//i, "") .toLowerCase(), ), + resolveDefaultTo: ({ cfg, accountId }) => { + const channel = cfg.channels?.googlechat as + | { accounts?: Record; 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 = { ); }, formatAllowFrom: ({ allowFrom }) => formatLower(allowFrom), + resolveDefaultTo: ({ cfg, accountId }) => + resolveSlackAccount({ cfg, accountId }).config.defaultTo?.trim() || undefined, }, groups: { resolveRequireMention: resolveSlackGroupRequireMention, @@ -453,6 +499,8 @@ const DOCKS: Record = { .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 = { ), 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, diff --git a/src/channels/plugins/types.adapters.ts b/src/channels/plugins/types.adapters.ts index 837a00a0609..1315e2c2c11 100644 --- a/src/channels/plugins/types.adapters.ts +++ b/src/channels/plugins/types.adapters.ts @@ -63,6 +63,10 @@ export type ChannelConfigAdapter = { accountId?: string | null; allowFrom: Array; }) => string[]; + resolveDefaultTo?: (params: { + cfg: OpenClawConfig; + accountId?: string | null; + }) => string | undefined; }; export type ChannelGroupAdapter = { diff --git a/src/config/types.channels.ts b/src/config/types.channels.ts index c0fece90ea5..a238756577e 100644 --- a/src/config/types.channels.ts +++ b/src/config/types.channels.ts @@ -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; diff --git a/src/config/types.discord.ts b/src/config/types.discord.ts index b7a42cd0912..a578338ead7 100644 --- a/src/config/types.discord.ts +++ b/src/config/types.discord.ts @@ -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; diff --git a/src/config/types.googlechat.ts b/src/config/types.googlechat.ts index 2ffa7a0ead5..75d7b0224a9 100644 --- a/src/config/types.googlechat.ts +++ b/src/config/types.googlechat.ts @@ -54,6 +54,8 @@ export type GoogleChatAccountConfig = { groupPolicy?: GroupPolicy; /** Optional allowlist for space senders (user ids or emails). */ groupAllowFrom?: Array; + /** 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; /** Service account JSON (inline string or object). */ diff --git a/src/config/types.imessage.ts b/src/config/types.imessage.ts index f3a225bdd84..836f3ae6d7e 100644 --- a/src/config/types.imessage.ts +++ b/src/config/types.imessage.ts @@ -33,6 +33,8 @@ export type IMessageAccountConfig = { dmPolicy?: DmPolicy; /** Optional allowlist for inbound handles or chat_id targets. */ allowFrom?: Array; + /** 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; /** diff --git a/src/config/types.irc.ts b/src/config/types.irc.ts index 833823d7c92..eff575d1918 100644 --- a/src/config/types.irc.ts +++ b/src/config/types.irc.ts @@ -56,6 +56,8 @@ export type IrcAccountConfig = { dmPolicy?: DmPolicy; /** Optional allowlist for inbound DM senders. */ allowFrom?: Array; + /** Default delivery target for CLI --deliver when no explicit --reply-to is provided. */ + defaultTo?: string; /** Optional allowlist for IRC channel senders. */ groupAllowFrom?: Array; /** diff --git a/src/config/types.msteams.ts b/src/config/types.msteams.ts index 0eb5e03fd00..359b9da8be9 100644 --- a/src/config/types.msteams.ts +++ b/src/config/types.msteams.ts @@ -63,6 +63,8 @@ export type MSTeamsConfig = { dmPolicy?: DmPolicy; /** Allowlist for DM senders (AAD object IDs or UPNs). */ allowFrom?: Array; + /** 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; /** diff --git a/src/config/types.signal.ts b/src/config/types.signal.ts index e8e1a696721..8103b409906 100644 --- a/src/config/types.signal.ts +++ b/src/config/types.signal.ts @@ -42,6 +42,8 @@ export type SignalAccountConfig = { /** Direct message access policy (default: pairing). */ dmPolicy?: DmPolicy; allowFrom?: Array; + /** 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; /** diff --git a/src/config/types.slack.ts b/src/config/types.slack.ts index 22210dcf7d1..b3a509ee44b 100644 --- a/src/config/types.slack.ts +++ b/src/config/types.slack.ts @@ -160,6 +160,8 @@ export type SlackAccountConfig = { * Legacy key: channels.slack.dm.allowFrom. */ allowFrom?: Array; + /** Default delivery target for CLI --deliver when no explicit --reply-to is provided. */ + defaultTo?: string; dm?: SlackDmConfig; channels?: Record; /** Heartbeat visibility settings for this channel. */ diff --git a/src/config/types.telegram.ts b/src/config/types.telegram.ts index 81fc41320b0..0de285a2412 100644 --- a/src/config/types.telegram.ts +++ b/src/config/types.telegram.ts @@ -74,6 +74,8 @@ export type TelegramAccountConfig = { groups?: Record; /** DM allowlist (numeric Telegram user IDs). Onboarding can resolve @username to IDs. */ allowFrom?: Array; + /** 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; /** diff --git a/src/config/types.whatsapp.ts b/src/config/types.whatsapp.ts index bfc4711035b..72890d0b31b 100644 --- a/src/config/types.whatsapp.ts +++ b/src/config/types.whatsapp.ts @@ -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). */ diff --git a/src/config/zod-schema.providers-core.ts b/src/config/zod-schema.providers-core.ts index 3bf1fa66ea5..416d559ad3c 100644 --- a/src/config/zod-schema.providers-core.ts +++ b/src/config/zod-schema.providers-core.ts @@ -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(), diff --git a/src/config/zod-schema.providers-whatsapp.ts b/src/config/zod-schema.providers-whatsapp.ts index 4cc5bb5999f..92c6daeffc3 100644 --- a/src/config/zod-schema.providers-whatsapp.ts +++ b/src/config/zod-schema.providers-whatsapp.ts @@ -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(), diff --git a/src/infra/outbound/targets.test.ts b/src/infra/outbound/targets.test.ts index 68a242ad1a7..e4649c3c07d 100644 --- a/src/infra/outbound/targets.test.ts +++ b/src/infra/outbound/targets.test.ts @@ -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", () => { diff --git a/src/infra/outbound/targets.ts b/src/infra/outbound/targets.ts index d6b756fc9bb..6ce063afe75 100644 --- a/src/infra/outbound/targets.ts +++ b/src/infra/outbound/targets.ts @@ -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 {