diff --git a/extensions/matrix/src/channel.directory.test.ts b/extensions/matrix/src/channel.directory.test.ts index 3e937934fd9..ec0ea8244b7 100644 --- a/extensions/matrix/src/channel.directory.test.ts +++ b/extensions/matrix/src/channel.directory.test.ts @@ -157,6 +157,25 @@ describe("matrix directory", () => { }); }); + it("accepts raw room ids when inferring Matrix direct user ids", () => { + expect( + matrixPlugin.threading?.buildToolContext?.({ + cfg: {} as CoreConfig, + context: { + From: "user:@alice:example.org", + To: "!dm:example.org", + ChatType: "direct", + }, + hasRepliedRef: { value: false }, + }), + ).toEqual({ + currentChannelId: "!dm:example.org", + currentThreadTs: undefined, + currentDirectUserId: "@alice:example.org", + hasRepliedRef: { value: false }, + }); + }); + it("resolves group mention policy from account config", () => { const cfg = { channels: { diff --git a/extensions/matrix/src/matrix/send/targets.test.ts b/extensions/matrix/src/matrix/send/targets.test.ts index 3e3610cd300..9a60393b7f8 100644 --- a/extensions/matrix/src/matrix/send/targets.test.ts +++ b/extensions/matrix/src/matrix/send/targets.test.ts @@ -88,6 +88,26 @@ describe("resolveMatrixRoomId", () => { expect(resolved).toBe(roomId); }); + + it("accepts nested Matrix user target prefixes", async () => { + const userId = "@prefixed:example.org"; + const roomId = "!prefixed-room:example.org"; + const client = { + getAccountData: vi.fn().mockResolvedValue({ + [userId]: [roomId], + }), + getJoinedRooms: vi.fn(), + getJoinedRoomMembers: vi.fn(), + setAccountData: vi.fn(), + resolveRoom: vi.fn(), + } as unknown as MatrixClient; + + const resolved = await resolveMatrixRoomId(client, `matrix:user:${userId}`); + + expect(resolved).toBe(roomId); + // oxlint-disable-next-line typescript/unbound-method + expect(client.resolveRoom).not.toHaveBeenCalled(); + }); }); describe("normalizeThreadId", () => { diff --git a/extensions/matrix/src/matrix/send/targets.ts b/extensions/matrix/src/matrix/send/targets.ts index 8d358ecf825..c459a066be4 100644 --- a/extensions/matrix/src/matrix/send/targets.ts +++ b/extensions/matrix/src/matrix/send/targets.ts @@ -1,4 +1,5 @@ import type { MatrixClient } from "../sdk.js"; +import { isMatrixQualifiedUserId, normalizeMatrixResolvableTarget } from "../target-ids.js"; import { EventType, type MatrixDirectAccountData } from "./types.js"; function normalizeTarget(raw: string): string { @@ -61,7 +62,7 @@ async function persistDirectRoom( async function resolveDirectRoomId(client: MatrixClient, userId: string): Promise { const trimmed = userId.trim(); - if (!trimmed.startsWith("@")) { + if (!isMatrixQualifiedUserId(trimmed)) { throw new Error(`Matrix user IDs must be fully qualified (got "${trimmed}")`); } @@ -124,21 +125,12 @@ async function resolveDirectRoomId(client: MatrixClient, userId: string): Promis } export async function resolveMatrixRoomId(client: MatrixClient, raw: string): Promise { - const target = normalizeTarget(raw); + const target = normalizeMatrixResolvableTarget(normalizeTarget(raw)); const lowered = target.toLowerCase(); - if (lowered.startsWith("matrix:")) { - return await resolveMatrixRoomId(client, target.slice("matrix:".length)); - } - if (lowered.startsWith("room:")) { - return await resolveMatrixRoomId(client, target.slice("room:".length)); - } - if (lowered.startsWith("channel:")) { - return await resolveMatrixRoomId(client, target.slice("channel:".length)); - } if (lowered.startsWith("user:")) { return await resolveDirectRoomId(client, target.slice("user:".length)); } - if (target.startsWith("@")) { + if (isMatrixQualifiedUserId(target)) { return await resolveDirectRoomId(client, target); } if (target.startsWith("#")) { diff --git a/extensions/matrix/src/matrix/target-ids.ts b/extensions/matrix/src/matrix/target-ids.ts index 6cd90a5fb6f..4f5605f0439 100644 --- a/extensions/matrix/src/matrix/target-ids.ts +++ b/extensions/matrix/src/matrix/target-ids.ts @@ -1,39 +1,46 @@ type MatrixTarget = | { kind: "room"; id: string } | { kind: "user"; id: string }; +const MATRIX_PREFIX = "matrix:"; +const ROOM_PREFIX = "room:"; +const CHANNEL_PREFIX = "channel:"; +const USER_PREFIX = "user:"; -function stripPrefix(value: string, prefix: string): string { - return value.toLowerCase().startsWith(prefix) ? value.slice(prefix.length) : value; +function stripKnownPrefixes(raw: string, prefixes: readonly string[]): string { + let normalized = raw.trim(); + while (normalized) { + const lowered = normalized.toLowerCase(); + const matched = prefixes.find((prefix) => lowered.startsWith(prefix)); + if (!matched) { + return normalized; + } + normalized = normalized.slice(matched.length).trim(); + } + return normalized; } -function parseMatrixTarget(raw: string): MatrixTarget | null { - let value = raw.trim(); - if (!value) { +export function resolveMatrixTargetIdentity(raw: string): MatrixTarget | null { + const normalized = stripKnownPrefixes(raw, [MATRIX_PREFIX]); + if (!normalized) { return null; } - value = stripPrefix(value, "matrix:"); - if (!value) { - return null; - } - if (value.toLowerCase().startsWith("room:")) { - const id = value.slice("room:".length).trim(); - return id ? { kind: "room", id } : null; - } - if (value.toLowerCase().startsWith("channel:")) { - const id = value.slice("channel:".length).trim(); - return id ? { kind: "room", id } : null; - } - if (value.toLowerCase().startsWith("user:")) { - const id = value.slice("user:".length).trim(); + const lowered = normalized.toLowerCase(); + if (lowered.startsWith(USER_PREFIX)) { + const id = normalized.slice(USER_PREFIX.length).trim(); return id ? { kind: "user", id } : null; } - if (value.startsWith("!") || value.startsWith("#")) { - return { kind: "room", id: value }; + if (lowered.startsWith(ROOM_PREFIX)) { + const id = normalized.slice(ROOM_PREFIX.length).trim(); + return id ? { kind: "room", id } : null; } - if (value.startsWith("@")) { - return { kind: "user", id: value }; + if (lowered.startsWith(CHANNEL_PREFIX)) { + const id = normalized.slice(CHANNEL_PREFIX.length).trim(); + return id ? { kind: "room", id } : null; } - return { kind: "room", id: value }; + if (isMatrixQualifiedUserId(normalized)) { + return { kind: "user", id: normalized }; + } + return { kind: "room", id: normalized }; } export function isMatrixQualifiedUserId(raw: string): boolean { @@ -41,28 +48,41 @@ export function isMatrixQualifiedUserId(raw: string): boolean { return trimmed.startsWith("@") && trimmed.includes(":"); } -export function normalizeMatrixDirectoryUserId(raw: string): string | null { - const parsed = parseMatrixTarget(raw); - if (!parsed || parsed.kind !== "user") { - return null; - } - return `user:${parsed.id}`; +export function normalizeMatrixResolvableTarget(raw: string): string { + return stripKnownPrefixes(raw, [MATRIX_PREFIX, ROOM_PREFIX, CHANNEL_PREFIX]); } -export function normalizeMatrixDirectoryGroupId(raw: string): string | null { - const parsed = parseMatrixTarget(raw); - if (!parsed || parsed.kind !== "room") { - return null; - } - return `room:${parsed.id}`; +export function normalizeMatrixMessagingTarget(raw: string): string | undefined { + const normalized = stripKnownPrefixes(raw, [ + MATRIX_PREFIX, + ROOM_PREFIX, + CHANNEL_PREFIX, + USER_PREFIX, + ]); + return normalized || undefined; } -export function normalizeMatrixMessagingTarget(raw: string): string { - const parsed = parseMatrixTarget(raw); - if (!parsed) { - throw new Error("Matrix target is required"); +export function normalizeMatrixDirectoryUserId(raw: string): string | undefined { + const normalized = stripKnownPrefixes(raw, [MATRIX_PREFIX, USER_PREFIX]); + if (!normalized || normalized === "*") { + return undefined; } - return `${parsed.kind}:${parsed.id}`; + return isMatrixQualifiedUserId(normalized) ? `user:${normalized}` : normalized; +} + +export function normalizeMatrixDirectoryGroupId(raw: string): string | undefined { + const normalized = stripKnownPrefixes(raw, [MATRIX_PREFIX]); + if (!normalized || normalized === "*") { + return undefined; + } + const lowered = normalized.toLowerCase(); + if (lowered.startsWith(ROOM_PREFIX) || lowered.startsWith(CHANNEL_PREFIX)) { + return normalized; + } + if (normalized.startsWith("!")) { + return `room:${normalized}`; + } + return normalized; } export function resolveMatrixDirectUserId(params: { @@ -70,17 +90,13 @@ export function resolveMatrixDirectUserId(params: { to?: string; chatType?: string; }): string | undefined { - if (params.chatType?.trim().toLowerCase() !== "direct") { + if (params.chatType !== "direct") { return undefined; } - const from = typeof params.from === "string" ? parseMatrixTarget(params.from) : null; - if (from?.kind === "user") { - return from.id; + const roomId = normalizeMatrixResolvableTarget(params.to ?? ""); + if (!roomId.startsWith("!")) { + return undefined; } - const to = typeof params.to === "string" ? parseMatrixTarget(params.to) : null; - return to?.kind === "user" ? to.id : undefined; -} - -export function resolveMatrixTargetIdentity(raw: string): MatrixTarget | null { - return parseMatrixTarget(raw); + const userId = stripKnownPrefixes(params.from ?? "", [MATRIX_PREFIX, USER_PREFIX]); + return isMatrixQualifiedUserId(userId) ? userId : undefined; } diff --git a/extensions/matrix/src/resolve-targets.test.ts b/extensions/matrix/src/resolve-targets.test.ts index ad9c14faf65..c92b8d0019d 100644 --- a/extensions/matrix/src/resolve-targets.test.ts +++ b/extensions/matrix/src/resolve-targets.test.ts @@ -63,10 +63,10 @@ describe("resolveMatrixTargets (users)", () => { expect(result?.resolved).toBe(true); expect(result?.id).toBe("!two:example.org"); - expect(result?.note).toBe("multiple matches; chose first"); + expect(result?.note).toBeUndefined(); }); - it("reuses directory lookups for duplicate inputs", async () => { + it("reuses directory lookups for normalized duplicate inputs", async () => { vi.mocked(listMatrixDirectoryPeersLive).mockResolvedValue([ { kind: "user", id: "@alice:example.org", name: "Alice" }, ]); @@ -76,7 +76,7 @@ describe("resolveMatrixTargets (users)", () => { const userResults = await resolveMatrixTargets({ cfg: {}, - inputs: ["Alice", "Alice"], + inputs: ["Alice", " alice "], kind: "user", }); const groupResults = await resolveMatrixTargets({ @@ -90,4 +90,34 @@ describe("resolveMatrixTargets (users)", () => { expect(listMatrixDirectoryPeersLive).toHaveBeenCalledTimes(1); expect(listMatrixDirectoryGroupsLive).toHaveBeenCalledTimes(1); }); + + it("accepts prefixed fully qualified ids without directory lookups", async () => { + const userResults = await resolveMatrixTargets({ + cfg: {}, + inputs: ["matrix:user:@alice:example.org"], + kind: "user", + }); + const groupResults = await resolveMatrixTargets({ + cfg: {}, + inputs: ["matrix:room:!team:example.org"], + kind: "group", + }); + + expect(userResults).toEqual([ + { + input: "matrix:user:@alice:example.org", + resolved: true, + id: "@alice:example.org", + }, + ]); + expect(groupResults).toEqual([ + { + input: "matrix:room:!team:example.org", + resolved: true, + id: "!team:example.org", + }, + ]); + expect(listMatrixDirectoryPeersLive).not.toHaveBeenCalled(); + expect(listMatrixDirectoryGroupsLive).not.toHaveBeenCalled(); + }); }); diff --git a/extensions/matrix/src/resolve-targets.ts b/extensions/matrix/src/resolve-targets.ts index d5c36d3de2b..823afb09f1d 100644 --- a/extensions/matrix/src/resolve-targets.ts +++ b/extensions/matrix/src/resolve-targets.ts @@ -5,12 +5,17 @@ import type { RuntimeEnv, } from "openclaw/plugin-sdk/matrix"; import { listMatrixDirectoryGroupsLive, listMatrixDirectoryPeersLive } from "./directory-live.js"; +import { isMatrixQualifiedUserId, normalizeMatrixMessagingTarget } from "./matrix/target-ids.js"; + +function normalizeLookupQuery(query: string): string { + return query.trim().toLowerCase(); +} function findExactDirectoryMatches( matches: ChannelDirectoryEntry[], query: string, ): ChannelDirectoryEntry[] { - const normalized = query.trim().toLowerCase(); + const normalized = normalizeLookupQuery(query); if (!normalized) { return []; } @@ -25,12 +30,21 @@ function findExactDirectoryMatches( function pickBestGroupMatch( matches: ChannelDirectoryEntry[], query: string, -): ChannelDirectoryEntry | undefined { +): { best?: ChannelDirectoryEntry; note?: string } { if (matches.length === 0) { - return undefined; + return {}; } - const [exact] = findExactDirectoryMatches(matches, query); - return exact ?? matches[0]; + const exact = findExactDirectoryMatches(matches, query); + if (exact.length > 1) { + return { best: exact[0], note: "multiple exact matches; chose first" }; + } + if (exact.length === 1) { + return { best: exact[0] }; + } + return { + best: matches[0], + note: matches.length > 1 ? "multiple matches; chose first" : undefined, + }; } function pickBestUserMatch( @@ -51,7 +65,7 @@ function describeUserMatchFailure(matches: ChannelDirectoryEntry[], query: strin if (matches.length === 0) { return "no matches"; } - const normalized = query.trim().toLowerCase(); + const normalized = normalizeLookupQuery(query); if (!normalized) { return "empty input"; } @@ -65,6 +79,24 @@ function describeUserMatchFailure(matches: ChannelDirectoryEntry[], query: strin return "no exact match; use full Matrix ID"; } +async function readCachedMatches( + cache: Map, + query: string, + lookup: (query: string) => Promise, +): Promise { + const key = normalizeLookupQuery(query); + if (!key) { + return []; + } + const cached = cache.get(key); + if (cached) { + return cached; + } + const matches = await lookup(query.trim()); + cache.set(key, matches); + return matches; +} + export async function resolveMatrixTargets(params: { cfg: unknown; inputs: string[]; @@ -75,34 +107,6 @@ export async function resolveMatrixTargets(params: { const userLookupCache = new Map(); const groupLookupCache = new Map(); - const readUserMatches = async (query: string): Promise => { - const cached = userLookupCache.get(query); - if (cached) { - return cached; - } - const matches = await listMatrixDirectoryPeersLive({ - cfg: params.cfg, - query, - limit: 5, - }); - userLookupCache.set(query, matches); - return matches; - }; - - const readGroupMatches = async (query: string): Promise => { - const cached = groupLookupCache.get(query); - if (cached) { - return cached; - } - const matches = await listMatrixDirectoryGroupsLive({ - cfg: params.cfg, - query, - limit: 5, - }); - groupLookupCache.set(query, matches); - return matches; - }; - for (const input of params.inputs) { const trimmed = input.trim(); if (!trimmed) { @@ -110,12 +114,19 @@ export async function resolveMatrixTargets(params: { continue; } if (params.kind === "user") { - if (trimmed.startsWith("@") && trimmed.includes(":")) { - results.push({ input, resolved: true, id: trimmed }); + const normalizedTarget = normalizeMatrixMessagingTarget(trimmed); + if (normalizedTarget && isMatrixQualifiedUserId(normalizedTarget)) { + results.push({ input, resolved: true, id: normalizedTarget }); continue; } try { - const matches = await readUserMatches(trimmed); + const matches = await readCachedMatches(userLookupCache, trimmed, (query) => + listMatrixDirectoryPeersLive({ + cfg: params.cfg, + query, + limit: 5, + }), + ); const best = pickBestUserMatch(matches, trimmed); results.push({ input, @@ -130,15 +141,26 @@ export async function resolveMatrixTargets(params: { } continue; } + const normalizedTarget = normalizeMatrixMessagingTarget(trimmed); + if (normalizedTarget?.startsWith("!")) { + results.push({ input, resolved: true, id: normalizedTarget }); + continue; + } try { - const matches = await readGroupMatches(trimmed); - const best = pickBestGroupMatch(matches, trimmed); + const matches = await readCachedMatches(groupLookupCache, trimmed, (query) => + listMatrixDirectoryGroupsLive({ + cfg: params.cfg, + query, + limit: 5, + }), + ); + const { best, note } = pickBestGroupMatch(matches, trimmed); results.push({ input, resolved: Boolean(best?.id), id: best?.id, name: best?.name, - note: matches.length > 1 ? "multiple matches; chose first" : undefined, + note, }); } catch (err) { params.runtime?.error?.(`matrix resolve failed: ${String(err)}`);