From e93412b5ce13ac09207aa232085358d8febc804e Mon Sep 17 00:00:00 2001 From: Gustavo Madeira Santana Date: Wed, 18 Mar 2026 04:23:35 +0000 Subject: [PATCH] Outbound: move target resolution heuristics behind plugins --- extensions/bluebubbles/src/channel.ts | 20 ++++++- extensions/bluebubbles/src/targets.test.ts | 26 +++++++++ extensions/bluebubbles/src/targets.ts | 57 ++++++++++++++++++++ extensions/imessage/src/channel.ts | 26 +++++++-- extensions/imessage/src/targets.test.ts | 14 +++++ extensions/imessage/src/targets.ts | 28 ++++++++++ src/infra/outbound/target-resolver.test.ts | 53 ++++++++++++++++++ src/infra/outbound/target-resolver.ts | 63 ++++++++++++++-------- 8 files changed, 260 insertions(+), 27 deletions(-) diff --git a/extensions/bluebubbles/src/channel.ts b/extensions/bluebubbles/src/channel.ts index 0400fa5bd67..cdc3a5bc567 100644 --- a/extensions/bluebubbles/src/channel.ts +++ b/extensions/bluebubbles/src/channel.ts @@ -34,6 +34,8 @@ import { blueBubblesSetupAdapter } from "./setup-core.js"; import { blueBubblesSetupWizard } from "./setup-surface.js"; import { extractHandleFromChatGuid, + inferBlueBubblesTargetChatType, + looksLikeBlueBubblesExplicitTargetId, looksLikeBlueBubblesTargetId, normalizeBlueBubblesHandle, normalizeBlueBubblesMessagingTarget, @@ -141,10 +143,26 @@ export const bluebubblesPlugin: ChannelPlugin = { }, messaging: { normalizeTarget: normalizeBlueBubblesMessagingTarget, + inferTargetChatType: ({ to }) => inferBlueBubblesTargetChatType(to), resolveOutboundSessionRoute: (params) => resolveBlueBubblesOutboundSessionRoute(params), targetResolver: { - looksLikeId: looksLikeBlueBubblesTargetId, + looksLikeId: looksLikeBlueBubblesExplicitTargetId, hint: "", + resolveTarget: async ({ normalized }) => { + const to = normalized?.trim(); + if (!to) { + return null; + } + const chatType = inferBlueBubblesTargetChatType(to); + if (!chatType) { + return null; + } + return { + to, + kind: chatType === "direct" ? "user" : "group", + source: "normalized" as const, + }; + }, }, formatTargetDisplay: ({ target, display }) => { const shouldParseDisplay = (value: string): boolean => { diff --git a/extensions/bluebubbles/src/targets.test.ts b/extensions/bluebubbles/src/targets.test.ts index c5b4109eb45..26475c70c3d 100644 --- a/extensions/bluebubbles/src/targets.test.ts +++ b/extensions/bluebubbles/src/targets.test.ts @@ -1,5 +1,7 @@ import { describe, expect, it } from "vitest"; import { + inferBlueBubblesTargetChatType, + looksLikeBlueBubblesExplicitTargetId, isAllowedBlueBubblesSender, looksLikeBlueBubblesTargetId, normalizeBlueBubblesMessagingTarget, @@ -101,6 +103,30 @@ describe("looksLikeBlueBubblesTargetId", () => { }); }); +describe("looksLikeBlueBubblesExplicitTargetId", () => { + it("treats explicit chat targets as immediate ids", () => { + expect(looksLikeBlueBubblesExplicitTargetId("chat_guid:ABC-123")).toBe(true); + expect(looksLikeBlueBubblesExplicitTargetId("imessage:+15551234567")).toBe(true); + }); + + it("prefers directory fallback for bare handles and phone numbers", () => { + expect(looksLikeBlueBubblesExplicitTargetId("+1 (555) 123-4567")).toBe(false); + expect(looksLikeBlueBubblesExplicitTargetId("user@example.com")).toBe(false); + }); +}); + +describe("inferBlueBubblesTargetChatType", () => { + it("infers direct chat for handles and dm chat_guids", () => { + expect(inferBlueBubblesTargetChatType("+15551234567")).toBe("direct"); + expect(inferBlueBubblesTargetChatType("chat_guid:iMessage;-;+15551234567")).toBe("direct"); + }); + + it("infers group chat for explicit group targets", () => { + expect(inferBlueBubblesTargetChatType("chat_id:123")).toBe("group"); + expect(inferBlueBubblesTargetChatType("chat_guid:iMessage;+;chat123")).toBe("group"); + }); +}); + describe("parseBlueBubblesTarget", () => { it("parses chat pattern as chat_identifier", () => { expect(parseBlueBubblesTarget("chat660250192681427962")).toEqual({ diff --git a/extensions/bluebubbles/src/targets.ts b/extensions/bluebubbles/src/targets.ts index ef6a69ae8e4..d445c2c5f0c 100644 --- a/extensions/bluebubbles/src/targets.ts +++ b/extensions/bluebubbles/src/targets.ts @@ -237,6 +237,63 @@ export function looksLikeBlueBubblesTargetId(raw: string, normalized?: string): return false; } +export function looksLikeBlueBubblesExplicitTargetId(raw: string, normalized?: string): boolean { + const trimmed = raw.trim(); + if (!trimmed) { + return false; + } + const candidate = stripBlueBubblesPrefix(trimmed); + if (!candidate) { + return false; + } + const lowered = candidate.toLowerCase(); + if (/^(imessage|sms|auto):/.test(lowered)) { + return true; + } + if ( + /^(chat_id|chatid|chat|chat_guid|chatguid|guid|chat_identifier|chatidentifier|chatident|group):/.test( + lowered, + ) + ) { + return true; + } + if (parseRawChatGuid(candidate) || looksLikeRawChatIdentifier(candidate)) { + return true; + } + if (normalized) { + const normalizedTrimmed = normalized.trim(); + if (!normalizedTrimmed) { + return false; + } + const normalizedLower = normalizedTrimmed.toLowerCase(); + if ( + /^(imessage|sms|auto):/.test(normalizedLower) || + /^(chat_id|chat_guid|chat_identifier):/.test(normalizedLower) + ) { + return true; + } + } + return false; +} + +export function inferBlueBubblesTargetChatType(raw: string): "direct" | "group" | undefined { + try { + const parsed = parseBlueBubblesTarget(raw); + if (parsed.kind === "handle") { + return "direct"; + } + if (parsed.kind === "chat_guid") { + return parsed.chatGuid.includes(";+;") ? "group" : "direct"; + } + if (parsed.kind === "chat_id" || parsed.kind === "chat_identifier") { + return "group"; + } + } catch { + return undefined; + } + return undefined; +} + export function parseBlueBubblesTarget(raw: string): BlueBubblesTarget { const trimmed = stripBlueBubblesPrefix(raw); if (!trimmed) { diff --git a/extensions/imessage/src/channel.ts b/extensions/imessage/src/channel.ts index b287cb79c54..ec13a605406 100644 --- a/extensions/imessage/src/channel.ts +++ b/extensions/imessage/src/channel.ts @@ -5,7 +5,6 @@ import { collectStatusIssuesFromLastError, DEFAULT_ACCOUNT_ID, formatTrimmedAllowFromEntries, - looksLikeIMessageTargetId, normalizeIMessageMessagingTarget, type ChannelPlugin, } from "openclaw/plugin-sdk/imessage"; @@ -25,7 +24,12 @@ import { imessageResolveDmPolicy, imessageSetupWizard, } from "./shared.js"; -import { normalizeIMessageHandle, parseIMessageTarget } from "./targets.js"; +import { + inferIMessageTargetChatType, + looksLikeIMessageExplicitTargetId, + normalizeIMessageHandle, + parseIMessageTarget, +} from "./targets.js"; const loadIMessageChannelRuntime = createLazyRuntimeModule(() => import("./channel.runtime.js")); @@ -139,10 +143,26 @@ export const imessagePlugin: ChannelPlugin = { }, messaging: { normalizeTarget: normalizeIMessageMessagingTarget, + inferTargetChatType: ({ to }) => inferIMessageTargetChatType(to), resolveOutboundSessionRoute: (params) => resolveIMessageOutboundSessionRoute(params), targetResolver: { - looksLikeId: looksLikeIMessageTargetId, + looksLikeId: looksLikeIMessageExplicitTargetId, hint: "", + resolveTarget: async ({ normalized }) => { + const to = normalized?.trim(); + if (!to) { + return null; + } + const chatType = inferIMessageTargetChatType(to); + if (!chatType) { + return null; + } + return { + to, + kind: chatType === "direct" ? "user" : "group", + source: "normalized" as const, + }; + }, }, }, outbound: { diff --git a/extensions/imessage/src/targets.test.ts b/extensions/imessage/src/targets.test.ts index 252c397399d..2a29a7ea167 100644 --- a/extensions/imessage/src/targets.test.ts +++ b/extensions/imessage/src/targets.test.ts @@ -1,7 +1,9 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; import { formatIMessageChatTarget, + inferIMessageTargetChatType, isAllowedIMessageSender, + looksLikeIMessageExplicitTargetId, normalizeIMessageHandle, parseIMessageTarget, } from "./targets.js"; @@ -83,6 +85,18 @@ describe("imessage targets", () => { expect(formatIMessageChatTarget(42)).toBe("chat_id:42"); expect(formatIMessageChatTarget(undefined)).toBe(""); }); + + it("only treats explicit chat targets as immediate ids", () => { + expect(looksLikeIMessageExplicitTargetId("chat_id:42")).toBe(true); + expect(looksLikeIMessageExplicitTargetId("sms:+15552223333")).toBe(true); + expect(looksLikeIMessageExplicitTargetId("+15552223333")).toBe(false); + expect(looksLikeIMessageExplicitTargetId("user@example.com")).toBe(false); + }); + + it("infers direct and group chat types from normalized targets", () => { + expect(inferIMessageTargetChatType("+15552223333")).toBe("direct"); + expect(inferIMessageTargetChatType("chat_id:42")).toBe("group"); + }); }); describe("createIMessageRpcClient", () => { diff --git a/extensions/imessage/src/targets.ts b/extensions/imessage/src/targets.ts index d6cd6a11f38..f20d0c99eb0 100644 --- a/extensions/imessage/src/targets.ts +++ b/extensions/imessage/src/targets.ts @@ -107,6 +107,34 @@ export function parseIMessageTarget(raw: string): IMessageTarget { return { kind: "handle", to: trimmed, service: "auto" }; } +export function looksLikeIMessageExplicitTargetId(raw: string): boolean { + const trimmed = raw.trim(); + if (!trimmed) { + return false; + } + const lower = trimmed.toLowerCase(); + if (/^(imessage:|sms:|auto:)/.test(lower)) { + return true; + } + return ( + CHAT_ID_PREFIXES.some((prefix) => lower.startsWith(prefix)) || + CHAT_GUID_PREFIXES.some((prefix) => lower.startsWith(prefix)) || + CHAT_IDENTIFIER_PREFIXES.some((prefix) => lower.startsWith(prefix)) + ); +} + +export function inferIMessageTargetChatType(raw: string): "direct" | "group" | undefined { + try { + const parsed = parseIMessageTarget(raw); + if (parsed.kind === "handle") { + return "direct"; + } + return "group"; + } catch { + return undefined; + } +} + export function parseIMessageAllowTarget(raw: string): IMessageAllowTarget { const trimmed = raw.trim(); if (!trimmed) { diff --git a/src/infra/outbound/target-resolver.test.ts b/src/infra/outbound/target-resolver.test.ts index 0e877a60c6a..b99f49cdd42 100644 --- a/src/infra/outbound/target-resolver.test.ts +++ b/src/infra/outbound/target-resolver.test.ts @@ -7,6 +7,8 @@ let resetDirectoryCache: TargetResolverModule["resetDirectoryCache"]; let resolveMessagingTarget: TargetResolverModule["resolveMessagingTarget"]; const mocks = vi.hoisted(() => ({ + listPeers: vi.fn(), + listPeersLive: vi.fn(), listGroups: vi.fn(), listGroupsLive: vi.fn(), resolveTarget: vi.fn(), @@ -16,6 +18,8 @@ const mocks = vi.hoisted(() => ({ beforeEach(async () => { vi.resetModules(); + mocks.listPeers.mockReset(); + mocks.listPeersLive.mockReset(); mocks.listGroups.mockReset(); mocks.listGroupsLive.mockReset(); mocks.resolveTarget.mockReset(); @@ -39,6 +43,8 @@ describe("resolveMessagingTarget (directory fallback)", () => { resetDirectoryCache(); mocks.getChannelPlugin.mockReturnValue({ directory: { + listPeers: mocks.listPeers, + listPeersLive: mocks.listPeersLive, listGroups: mocks.listGroups, listGroupsLive: mocks.listGroupsLive, }, @@ -134,4 +140,51 @@ describe("resolveMessagingTarget (directory fallback)", () => { expect(mocks.listGroups).not.toHaveBeenCalled(); expect(mocks.listGroupsLive).not.toHaveBeenCalled(); }); + + it("uses plugin chat-type inference for directory lookups and plugin fallback on miss", async () => { + mocks.getChannelPlugin.mockReturnValue({ + directory: { + listPeers: mocks.listPeers, + listPeersLive: mocks.listPeersLive, + }, + messaging: { + inferTargetChatType: () => "direct", + targetResolver: { + looksLikeId: () => false, + resolveTarget: mocks.resolveTarget, + }, + }, + }); + mocks.listPeers.mockResolvedValue([]); + mocks.listPeersLive.mockResolvedValue([]); + mocks.resolveTarget.mockResolvedValue({ + to: "+15551234567", + kind: "user", + source: "normalized", + }); + + const result = await resolveMessagingTarget({ + cfg, + channel: "imessage", + input: "+15551234567", + }); + + expect(result.ok).toBe(true); + if (result.ok) { + expect(result.target).toEqual({ + to: "+15551234567", + kind: "user", + source: "normalized", + display: undefined, + }); + } + expect(mocks.listPeers).toHaveBeenCalledTimes(1); + expect(mocks.listPeersLive).toHaveBeenCalledTimes(1); + expect(mocks.listGroups).not.toHaveBeenCalled(); + expect(mocks.resolveTarget).toHaveBeenCalledWith( + expect.objectContaining({ + input: "+15551234567", + }), + ); + }); }); diff --git a/src/infra/outbound/target-resolver.ts b/src/infra/outbound/target-resolver.ts index 992206b1566..c458b2faf7c 100644 --- a/src/infra/outbound/target-resolver.ts +++ b/src/infra/outbound/target-resolver.ts @@ -47,6 +47,23 @@ export async function maybeResolveIdLikeTarget(params: { accountId?: string | null; preferredKind?: TargetResolveKind; }): Promise { + const raw = normalizeChannelTargetInput(params.input); + if (!raw) { + return undefined; + } + return await maybeResolvePluginTarget(params, { requireIdLike: true }); +} + +async function maybeResolvePluginTarget( + params: { + cfg: OpenClawConfig; + channel: ChannelId; + input: string; + accountId?: string | null; + preferredKind?: TargetResolveKind; + }, + options?: { requireIdLike?: boolean }, +): Promise { const raw = normalizeChannelTargetInput(params.input); if (!raw) { return undefined; @@ -57,7 +74,7 @@ export async function maybeResolveIdLikeTarget(params: { return undefined; } const normalized = normalizeTargetForProvider(params.channel, raw) ?? raw; - if (resolver.looksLikeId && !resolver.looksLikeId(raw, normalized)) { + if (options?.requireIdLike && resolver.looksLikeId && !resolver.looksLikeId(raw, normalized)) { return undefined; } const resolved = await resolver.resolveTarget({ @@ -196,6 +213,16 @@ function detectTargetKind( if (!trimmed) { return "group"; } + const inferredChatType = getChannelPlugin(channel)?.messaging?.inferTargetChatType?.({ to: raw }); + if (inferredChatType === "direct") { + return "user"; + } + if (inferredChatType === "channel") { + return "channel"; + } + if (inferredChatType === "group") { + return "group"; + } if (trimmed.startsWith("@") || /^<@!?/.test(trimmed) || /^user:/i.test(trimmed)) { return "user"; @@ -204,11 +231,6 @@ function detectTargetKind( return "group"; } - // For some channels (e.g., BlueBubbles/iMessage), bare phone numbers are almost always DM targets. - if ((channel === "bluebubbles" || channel === "imessage") && /^\+?\d{6,}$/.test(trimmed)) { - return "user"; - } - return "group"; } @@ -410,11 +432,6 @@ export async function resolveMessagingTarget(params: { return true; } if (/^\+?\d{6,}$/.test(trimmed)) { - // BlueBubbles/iMessage phone numbers should usually resolve via the directory to a DM chat, - // otherwise the provider may pick an existing group containing that handle. - if (params.channel === "bluebubbles" || params.channel === "imessage") { - return false; - } return true; } if (trimmed.includes("@thread")) { @@ -491,18 +508,18 @@ export async function resolveMessagingTarget(params: { candidates: match.entries, }; } - // For iMessage-style channels, allow sending directly to the normalized handle - // even if the directory doesn't contain an entry yet. - if ( - (params.channel === "bluebubbles" || params.channel === "imessage") && - /^\+?\d{6,}$/.test(query) - ) { - return buildNormalizedResolveResult({ - channel: params.channel, - raw, - normalized, - kind, - }); + const resolvedFallbackTarget = await maybeResolvePluginTarget({ + cfg: params.cfg, + channel: params.channel, + input: raw, + accountId: params.accountId, + preferredKind: params.preferredKind, + }); + if (resolvedFallbackTarget) { + return { + ok: true, + target: resolvedFallbackTarget, + }; } return {