Outbound: move target resolution heuristics behind plugins

This commit is contained in:
Gustavo Madeira Santana 2026-03-18 04:23:35 +00:00
parent 0ffcc308f2
commit e93412b5ce
No known key found for this signature in database
8 changed files with 260 additions and 27 deletions

View File

@ -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<ResolvedBlueBubblesAccount> = {
},
messaging: {
normalizeTarget: normalizeBlueBubblesMessagingTarget,
inferTargetChatType: ({ to }) => inferBlueBubblesTargetChatType(to),
resolveOutboundSessionRoute: (params) => resolveBlueBubblesOutboundSessionRoute(params),
targetResolver: {
looksLikeId: looksLikeBlueBubblesTargetId,
looksLikeId: looksLikeBlueBubblesExplicitTargetId,
hint: "<handle|chat_guid:GUID|chat_id:ID|chat_identifier:ID>",
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 => {

View File

@ -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<digits> pattern as chat_identifier", () => {
expect(parseBlueBubblesTarget("chat660250192681427962")).toEqual({

View File

@ -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) {

View File

@ -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<ResolvedIMessageAccount> = {
},
messaging: {
normalizeTarget: normalizeIMessageMessagingTarget,
inferTargetChatType: ({ to }) => inferIMessageTargetChatType(to),
resolveOutboundSessionRoute: (params) => resolveIMessageOutboundSessionRoute(params),
targetResolver: {
looksLikeId: looksLikeIMessageTargetId,
looksLikeId: looksLikeIMessageExplicitTargetId,
hint: "<handle|chat_id:ID>",
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: {

View File

@ -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", () => {

View File

@ -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) {

View File

@ -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",
}),
);
});
});

View File

@ -47,6 +47,23 @@ export async function maybeResolveIdLikeTarget(params: {
accountId?: string | null;
preferredKind?: TargetResolveKind;
}): Promise<ResolvedMessagingTarget | undefined> {
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<ResolvedMessagingTarget | undefined> {
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 {