mirror of https://github.com/openclaw/openclaw.git
Matrix: centralize target normalization
This commit is contained in:
parent
9be74a5cff
commit
9ffe53ef71
|
|
@ -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: {
|
||||
|
|
|
|||
|
|
@ -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", () => {
|
||||
|
|
|
|||
|
|
@ -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("#")) {
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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)}`);
|
||||
|
|
|
|||
Loading…
Reference in New Issue