Outbound: move target resolution heuristics behind plugins

This commit is contained in:
Gustavo Madeira Santana
2026-03-18 04:23:35 +00:00
committed by Val Alexander
parent 50a2be72fe
commit 0d438921ea
8 changed files with 260 additions and 27 deletions

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 {