Outbound: move target resolution heuristics behind plugins
This commit is contained in:
parent
0ffcc308f2
commit
e93412b5ce
@ -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 => {
|
||||
|
||||
@ -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({
|
||||
|
||||
@ -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) {
|
||||
|
||||
@ -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: {
|
||||
|
||||
@ -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", () => {
|
||||
|
||||
@ -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) {
|
||||
|
||||
@ -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",
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@ -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 {
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user