Matrix: centralize target normalization

This commit is contained in:
Gustavo Madeira Santana 2026-03-09 03:45:19 -04:00
parent 9be74a5cff
commit 9ffe53ef71
No known key found for this signature in database
6 changed files with 205 additions and 106 deletions

View File

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

View File

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

View File

@ -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<string> {
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<string> {
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("#")) {

View File

@ -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;
}

View File

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

View File

@ -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<string, ChannelDirectoryEntry[]>,
query: string,
lookup: (query: string) => Promise<ChannelDirectoryEntry[]>,
): Promise<ChannelDirectoryEntry[]> {
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<string, ChannelDirectoryEntry[]>();
const groupLookupCache = new Map<string, ChannelDirectoryEntry[]>();
const readUserMatches = async (query: string): Promise<ChannelDirectoryEntry[]> => {
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<ChannelDirectoryEntry[]> => {
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)}`);