fix(matrix): harden allowlists
This commit is contained in:
@@ -24,7 +24,7 @@ import {
|
||||
type ResolvedMatrixAccount,
|
||||
} from "./matrix/accounts.js";
|
||||
import { resolveMatrixAuth } from "./matrix/client.js";
|
||||
import { normalizeAllowListLower } from "./matrix/monitor/allowlist.js";
|
||||
import { normalizeMatrixAllowList, normalizeMatrixUserId } from "./matrix/monitor/allowlist.js";
|
||||
import { probeMatrix } from "./matrix/probe.js";
|
||||
import { sendMessageMatrix } from "./matrix/send.js";
|
||||
import { matrixOnboardingAdapter } from "./onboarding.js";
|
||||
@@ -144,7 +144,7 @@ export const matrixPlugin: ChannelPlugin<ResolvedMatrixAccount> = {
|
||||
}),
|
||||
resolveAllowFrom: ({ cfg }) =>
|
||||
((cfg as CoreConfig).channels?.matrix?.dm?.allowFrom ?? []).map((entry) => String(entry)),
|
||||
formatAllowFrom: ({ allowFrom }) => normalizeAllowListLower(allowFrom),
|
||||
formatAllowFrom: ({ allowFrom }) => normalizeMatrixAllowList(allowFrom),
|
||||
},
|
||||
security: {
|
||||
resolveDmPolicy: ({ account }) => ({
|
||||
@@ -153,11 +153,7 @@ export const matrixPlugin: ChannelPlugin<ResolvedMatrixAccount> = {
|
||||
policyPath: "channels.matrix.dm.policy",
|
||||
allowFromPath: "channels.matrix.dm.allowFrom",
|
||||
approveHint: formatPairingApproveHint("matrix"),
|
||||
normalizeEntry: (raw) =>
|
||||
raw
|
||||
.replace(/^matrix:/i, "")
|
||||
.trim()
|
||||
.toLowerCase(),
|
||||
normalizeEntry: (raw) => normalizeMatrixUserId(raw),
|
||||
}),
|
||||
collectWarnings: ({ account, cfg }) => {
|
||||
const defaultGroupPolicy = (cfg as CoreConfig).channels?.defaults?.groupPolicy;
|
||||
|
||||
45
extensions/matrix/src/matrix/monitor/allowlist.test.ts
Normal file
45
extensions/matrix/src/matrix/monitor/allowlist.test.ts
Normal file
@@ -0,0 +1,45 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { normalizeMatrixAllowList, resolveMatrixAllowListMatch } from "./allowlist.js";
|
||||
|
||||
describe("resolveMatrixAllowListMatch", () => {
|
||||
it("matches full user IDs and prefixes", () => {
|
||||
const userId = "@Alice:Example.org";
|
||||
const direct = resolveMatrixAllowListMatch({
|
||||
allowList: normalizeMatrixAllowList(["@alice:example.org"]),
|
||||
userId,
|
||||
});
|
||||
expect(direct.allowed).toBe(true);
|
||||
expect(direct.matchSource).toBe("id");
|
||||
|
||||
const prefixedMatrix = resolveMatrixAllowListMatch({
|
||||
allowList: normalizeMatrixAllowList(["matrix:@alice:example.org"]),
|
||||
userId,
|
||||
});
|
||||
expect(prefixedMatrix.allowed).toBe(true);
|
||||
expect(prefixedMatrix.matchSource).toBe("prefixed-id");
|
||||
|
||||
const prefixedUser = resolveMatrixAllowListMatch({
|
||||
allowList: normalizeMatrixAllowList(["user:@alice:example.org"]),
|
||||
userId,
|
||||
});
|
||||
expect(prefixedUser.allowed).toBe(true);
|
||||
expect(prefixedUser.matchSource).toBe("prefixed-user");
|
||||
});
|
||||
|
||||
it("ignores display names and localparts", () => {
|
||||
const match = resolveMatrixAllowListMatch({
|
||||
allowList: normalizeMatrixAllowList(["alice", "Alice"]),
|
||||
userId: "@alice:example.org",
|
||||
});
|
||||
expect(match.allowed).toBe(false);
|
||||
});
|
||||
|
||||
it("matches wildcard", () => {
|
||||
const match = resolveMatrixAllowListMatch({
|
||||
allowList: normalizeMatrixAllowList(["*"]),
|
||||
userId: "@alice:example.org",
|
||||
});
|
||||
expect(match.allowed).toBe(true);
|
||||
expect(match.matchSource).toBe("wildcard");
|
||||
});
|
||||
});
|
||||
@@ -4,22 +4,71 @@ function normalizeAllowList(list?: Array<string | number>) {
|
||||
return (list ?? []).map((entry) => String(entry).trim()).filter(Boolean);
|
||||
}
|
||||
|
||||
export function normalizeAllowListLower(list?: Array<string | number>) {
|
||||
return normalizeAllowList(list).map((entry) => entry.toLowerCase());
|
||||
function normalizeMatrixUser(raw?: string | null): string {
|
||||
const value = (raw ?? "").trim();
|
||||
if (!value) {
|
||||
return "";
|
||||
}
|
||||
if (!value.startsWith("@") || !value.includes(":")) {
|
||||
return value.toLowerCase();
|
||||
}
|
||||
const withoutAt = value.slice(1);
|
||||
const splitIndex = withoutAt.indexOf(":");
|
||||
if (splitIndex === -1) {
|
||||
return value.toLowerCase();
|
||||
}
|
||||
const localpart = withoutAt.slice(0, splitIndex).toLowerCase();
|
||||
const server = withoutAt.slice(splitIndex + 1).toLowerCase();
|
||||
if (!server) {
|
||||
return value.toLowerCase();
|
||||
}
|
||||
return `@${localpart}:${server.toLowerCase()}`;
|
||||
}
|
||||
|
||||
function normalizeMatrixUser(raw?: string | null): string {
|
||||
return (raw ?? "").trim().toLowerCase();
|
||||
export function normalizeMatrixUserId(raw?: string | null): string {
|
||||
const trimmed = (raw ?? "").trim();
|
||||
if (!trimmed) {
|
||||
return "";
|
||||
}
|
||||
const lowered = trimmed.toLowerCase();
|
||||
if (lowered.startsWith("matrix:")) {
|
||||
return normalizeMatrixUser(trimmed.slice("matrix:".length));
|
||||
}
|
||||
if (lowered.startsWith("user:")) {
|
||||
return normalizeMatrixUser(trimmed.slice("user:".length));
|
||||
}
|
||||
return normalizeMatrixUser(trimmed);
|
||||
}
|
||||
|
||||
function normalizeMatrixAllowListEntry(raw: string): string {
|
||||
const trimmed = raw.trim();
|
||||
if (!trimmed) {
|
||||
return "";
|
||||
}
|
||||
if (trimmed === "*") {
|
||||
return trimmed;
|
||||
}
|
||||
const lowered = trimmed.toLowerCase();
|
||||
if (lowered.startsWith("matrix:")) {
|
||||
return `matrix:${normalizeMatrixUser(trimmed.slice("matrix:".length))}`;
|
||||
}
|
||||
if (lowered.startsWith("user:")) {
|
||||
return `user:${normalizeMatrixUser(trimmed.slice("user:".length))}`;
|
||||
}
|
||||
return normalizeMatrixUser(trimmed);
|
||||
}
|
||||
|
||||
export function normalizeMatrixAllowList(list?: Array<string | number>) {
|
||||
return normalizeAllowList(list).map((entry) => normalizeMatrixAllowListEntry(entry));
|
||||
}
|
||||
|
||||
export type MatrixAllowListMatch = AllowlistMatch<
|
||||
"wildcard" | "id" | "prefixed-id" | "prefixed-user" | "name" | "localpart"
|
||||
"wildcard" | "id" | "prefixed-id" | "prefixed-user"
|
||||
>;
|
||||
|
||||
export function resolveMatrixAllowListMatch(params: {
|
||||
allowList: string[];
|
||||
userId?: string;
|
||||
userName?: string;
|
||||
}): MatrixAllowListMatch {
|
||||
const allowList = params.allowList;
|
||||
if (allowList.length === 0) {
|
||||
@@ -29,14 +78,10 @@ export function resolveMatrixAllowListMatch(params: {
|
||||
return { allowed: true, matchKey: "*", matchSource: "wildcard" };
|
||||
}
|
||||
const userId = normalizeMatrixUser(params.userId);
|
||||
const userName = normalizeMatrixUser(params.userName);
|
||||
const localPart = userId.startsWith("@") ? (userId.slice(1).split(":")[0] ?? "") : "";
|
||||
const candidates: Array<{ value?: string; source: MatrixAllowListMatch["matchSource"] }> = [
|
||||
{ value: userId, source: "id" },
|
||||
{ value: userId ? `matrix:${userId}` : "", source: "prefixed-id" },
|
||||
{ value: userId ? `user:${userId}` : "", source: "prefixed-user" },
|
||||
{ value: userName, source: "name" },
|
||||
{ value: localPart, source: "localpart" },
|
||||
];
|
||||
for (const candidate of candidates) {
|
||||
if (!candidate.value) {
|
||||
@@ -53,10 +98,6 @@ export function resolveMatrixAllowListMatch(params: {
|
||||
return { allowed: false };
|
||||
}
|
||||
|
||||
export function resolveMatrixAllowListMatches(params: {
|
||||
allowList: string[];
|
||||
userId?: string;
|
||||
userName?: string;
|
||||
}) {
|
||||
export function resolveMatrixAllowListMatches(params: { allowList: string[]; userId?: string }) {
|
||||
return resolveMatrixAllowListMatch(params).allowed;
|
||||
}
|
||||
|
||||
@@ -23,9 +23,9 @@ import {
|
||||
sendTypingMatrix,
|
||||
} from "../send.js";
|
||||
import {
|
||||
normalizeMatrixAllowList,
|
||||
resolveMatrixAllowListMatch,
|
||||
resolveMatrixAllowListMatches,
|
||||
normalizeAllowListLower,
|
||||
} from "./allowlist.js";
|
||||
import { resolveMatrixLocation, type MatrixLocationPayload } from "./location.js";
|
||||
import { downloadMatrixMedia } from "./media.js";
|
||||
@@ -236,12 +236,9 @@ export function createMatrixRoomMessageHandler(params: MatrixMonitorHandlerParam
|
||||
const storeAllowFrom = await core.channel.pairing
|
||||
.readAllowFromStore("matrix")
|
||||
.catch(() => []);
|
||||
const effectiveAllowFrom = normalizeAllowListLower([...allowFrom, ...storeAllowFrom]);
|
||||
const effectiveAllowFrom = normalizeMatrixAllowList([...allowFrom, ...storeAllowFrom]);
|
||||
const groupAllowFrom = cfg.channels?.matrix?.groupAllowFrom ?? [];
|
||||
const effectiveGroupAllowFrom = normalizeAllowListLower([
|
||||
...groupAllowFrom,
|
||||
...storeAllowFrom,
|
||||
]);
|
||||
const effectiveGroupAllowFrom = normalizeMatrixAllowList(groupAllowFrom);
|
||||
const groupAllowConfigured = effectiveGroupAllowFrom.length > 0;
|
||||
|
||||
if (isDirectMessage) {
|
||||
@@ -252,7 +249,6 @@ export function createMatrixRoomMessageHandler(params: MatrixMonitorHandlerParam
|
||||
const allowMatch = resolveMatrixAllowListMatch({
|
||||
allowList: effectiveAllowFrom,
|
||||
userId: senderId,
|
||||
userName: senderName,
|
||||
});
|
||||
const allowMatchMeta = formatAllowlistMatchMeta(allowMatch);
|
||||
if (!allowMatch.allowed) {
|
||||
@@ -297,9 +293,8 @@ export function createMatrixRoomMessageHandler(params: MatrixMonitorHandlerParam
|
||||
const roomUsers = roomConfig?.users ?? [];
|
||||
if (isRoom && roomUsers.length > 0) {
|
||||
const userMatch = resolveMatrixAllowListMatch({
|
||||
allowList: normalizeAllowListLower(roomUsers),
|
||||
allowList: normalizeMatrixAllowList(roomUsers),
|
||||
userId: senderId,
|
||||
userName: senderName,
|
||||
});
|
||||
if (!userMatch.allowed) {
|
||||
logVerboseMessage(
|
||||
@@ -314,7 +309,6 @@ export function createMatrixRoomMessageHandler(params: MatrixMonitorHandlerParam
|
||||
const groupAllowMatch = resolveMatrixAllowListMatch({
|
||||
allowList: effectiveGroupAllowFrom,
|
||||
userId: senderId,
|
||||
userName: senderName,
|
||||
});
|
||||
if (!groupAllowMatch.allowed) {
|
||||
logVerboseMessage(
|
||||
@@ -387,21 +381,18 @@ export function createMatrixRoomMessageHandler(params: MatrixMonitorHandlerParam
|
||||
const senderAllowedForCommands = resolveMatrixAllowListMatches({
|
||||
allowList: effectiveAllowFrom,
|
||||
userId: senderId,
|
||||
userName: senderName,
|
||||
});
|
||||
const senderAllowedForGroup = groupAllowConfigured
|
||||
? resolveMatrixAllowListMatches({
|
||||
allowList: effectiveGroupAllowFrom,
|
||||
userId: senderId,
|
||||
userName: senderName,
|
||||
})
|
||||
: false;
|
||||
const senderAllowedForRoomUsers =
|
||||
isRoom && roomUsers.length > 0
|
||||
? resolveMatrixAllowListMatches({
|
||||
allowList: normalizeAllowListLower(roomUsers),
|
||||
allowList: normalizeMatrixAllowList(roomUsers),
|
||||
userId: senderId,
|
||||
userName: senderName,
|
||||
})
|
||||
: false;
|
||||
const hasControlCommandInMessage = core.channel.text.hasControlCommand(bodyText, cfg);
|
||||
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
resolveSharedMatrixClient,
|
||||
stopSharedClient,
|
||||
} from "../client.js";
|
||||
import { normalizeMatrixUserId } from "./allowlist.js";
|
||||
import { registerMatrixAutoJoin } from "./auto-join.js";
|
||||
import { createDirectRoomTracker } from "./direct.js";
|
||||
import { registerMatrixMonitorEvents } from "./events.js";
|
||||
@@ -68,68 +69,94 @@ export async function monitorMatrixProvider(opts: MonitorMatrixOpts = {}): Promi
|
||||
.replace(/^(room|channel):/i, "")
|
||||
.trim();
|
||||
const isMatrixUserId = (value: string) => value.startsWith("@") && value.includes(":");
|
||||
const resolveUserAllowlist = async (
|
||||
label: string,
|
||||
list?: Array<string | number>,
|
||||
): Promise<string[]> => {
|
||||
let allowList = list ?? [];
|
||||
if (allowList.length === 0) {
|
||||
return allowList;
|
||||
}
|
||||
const entries = allowList
|
||||
.map((entry) => normalizeUserEntry(String(entry)))
|
||||
.filter((entry) => entry && entry !== "*");
|
||||
if (entries.length === 0) {
|
||||
return allowList;
|
||||
}
|
||||
const mapping: string[] = [];
|
||||
const unresolved: string[] = [];
|
||||
const additions: string[] = [];
|
||||
const pending: string[] = [];
|
||||
for (const entry of entries) {
|
||||
if (isMatrixUserId(entry)) {
|
||||
additions.push(normalizeMatrixUserId(entry));
|
||||
continue;
|
||||
}
|
||||
pending.push(entry);
|
||||
}
|
||||
if (pending.length > 0) {
|
||||
const resolved = await resolveMatrixTargets({
|
||||
cfg,
|
||||
inputs: pending,
|
||||
kind: "user",
|
||||
runtime,
|
||||
});
|
||||
for (const entry of resolved) {
|
||||
if (entry.resolved && entry.id) {
|
||||
const normalizedId = normalizeMatrixUserId(entry.id);
|
||||
additions.push(normalizedId);
|
||||
mapping.push(`${entry.input}→${normalizedId}`);
|
||||
} else {
|
||||
unresolved.push(entry.input);
|
||||
}
|
||||
}
|
||||
}
|
||||
allowList = mergeAllowlist({ existing: allowList, additions });
|
||||
summarizeMapping(label, mapping, unresolved, runtime);
|
||||
if (unresolved.length > 0) {
|
||||
runtime.log?.(
|
||||
`${label} entries must be full Matrix IDs (example: @user:server). Unresolved entries are ignored.`,
|
||||
);
|
||||
}
|
||||
return allowList;
|
||||
};
|
||||
|
||||
const allowlistOnly = cfg.channels?.matrix?.allowlistOnly === true;
|
||||
let allowFrom = cfg.channels?.matrix?.dm?.allowFrom ?? [];
|
||||
let groupAllowFrom = cfg.channels?.matrix?.groupAllowFrom ?? [];
|
||||
let roomsConfig = cfg.channels?.matrix?.groups ?? cfg.channels?.matrix?.rooms;
|
||||
|
||||
if (allowFrom.length > 0) {
|
||||
const entries = allowFrom
|
||||
.map((entry) => normalizeUserEntry(String(entry)))
|
||||
.filter((entry) => entry && entry !== "*");
|
||||
if (entries.length > 0) {
|
||||
const mapping: string[] = [];
|
||||
const unresolved: string[] = [];
|
||||
const additions: string[] = [];
|
||||
const pending: string[] = [];
|
||||
for (const entry of entries) {
|
||||
if (isMatrixUserId(entry)) {
|
||||
additions.push(entry);
|
||||
continue;
|
||||
}
|
||||
pending.push(entry);
|
||||
}
|
||||
if (pending.length > 0) {
|
||||
const resolved = await resolveMatrixTargets({
|
||||
cfg,
|
||||
inputs: pending,
|
||||
kind: "user",
|
||||
runtime,
|
||||
});
|
||||
for (const entry of resolved) {
|
||||
if (entry.resolved && entry.id) {
|
||||
additions.push(entry.id);
|
||||
mapping.push(`${entry.input}→${entry.id}`);
|
||||
} else {
|
||||
unresolved.push(entry.input);
|
||||
}
|
||||
}
|
||||
}
|
||||
allowFrom = mergeAllowlist({ existing: allowFrom, additions });
|
||||
summarizeMapping("matrix users", mapping, unresolved, runtime);
|
||||
}
|
||||
}
|
||||
allowFrom = await resolveUserAllowlist("matrix dm allowlist", allowFrom);
|
||||
groupAllowFrom = await resolveUserAllowlist("matrix group allowlist", groupAllowFrom);
|
||||
|
||||
if (roomsConfig && Object.keys(roomsConfig).length > 0) {
|
||||
const entries = Object.keys(roomsConfig).filter((key) => key !== "*");
|
||||
const mapping: string[] = [];
|
||||
const unresolved: string[] = [];
|
||||
const nextRooms = { ...roomsConfig };
|
||||
const pending: Array<{ input: string; query: string }> = [];
|
||||
for (const entry of entries) {
|
||||
const nextRooms: Record<string, (typeof roomsConfig)[string]> = {};
|
||||
if (roomsConfig["*"]) {
|
||||
nextRooms["*"] = roomsConfig["*"];
|
||||
}
|
||||
const pending: Array<{ input: string; query: string; config: (typeof roomsConfig)[string] }> =
|
||||
[];
|
||||
for (const [entry, roomConfig] of Object.entries(roomsConfig)) {
|
||||
if (entry === "*") {
|
||||
continue;
|
||||
}
|
||||
const trimmed = entry.trim();
|
||||
if (!trimmed) {
|
||||
continue;
|
||||
}
|
||||
const cleaned = normalizeRoomEntry(trimmed);
|
||||
if (cleaned.startsWith("!") && cleaned.includes(":")) {
|
||||
if ((cleaned.startsWith("!") || cleaned.startsWith("#")) && cleaned.includes(":")) {
|
||||
if (!nextRooms[cleaned]) {
|
||||
nextRooms[cleaned] = roomsConfig[entry];
|
||||
nextRooms[cleaned] = roomConfig;
|
||||
}
|
||||
if (cleaned !== entry) {
|
||||
mapping.push(`${entry}→${cleaned}`);
|
||||
}
|
||||
mapping.push(`${entry}→${cleaned}`);
|
||||
continue;
|
||||
}
|
||||
pending.push({ input: entry, query: trimmed });
|
||||
pending.push({ input: entry, query: trimmed, config: roomConfig });
|
||||
}
|
||||
if (pending.length > 0) {
|
||||
const resolved = await resolveMatrixTargets({
|
||||
@@ -145,7 +172,7 @@ export async function monitorMatrixProvider(opts: MonitorMatrixOpts = {}): Promi
|
||||
}
|
||||
if (entry.resolved && entry.id) {
|
||||
if (!nextRooms[entry.id]) {
|
||||
nextRooms[entry.id] = roomsConfig[source.input];
|
||||
nextRooms[entry.id] = source.config;
|
||||
}
|
||||
mapping.push(`${source.input}→${entry.id}`);
|
||||
} else {
|
||||
@@ -155,6 +182,25 @@ export async function monitorMatrixProvider(opts: MonitorMatrixOpts = {}): Promi
|
||||
}
|
||||
roomsConfig = nextRooms;
|
||||
summarizeMapping("matrix rooms", mapping, unresolved, runtime);
|
||||
if (unresolved.length > 0) {
|
||||
runtime.log?.(
|
||||
"matrix rooms must be room IDs or aliases (example: !room:server or #alias:server). Unresolved entries are ignored.",
|
||||
);
|
||||
}
|
||||
}
|
||||
if (roomsConfig && Object.keys(roomsConfig).length > 0) {
|
||||
const nextRooms = { ...roomsConfig };
|
||||
for (const [roomKey, roomConfig] of Object.entries(roomsConfig)) {
|
||||
const users = roomConfig?.users ?? [];
|
||||
if (users.length === 0) {
|
||||
continue;
|
||||
}
|
||||
const resolvedUsers = await resolveUserAllowlist(`matrix room users (${roomKey})`, users);
|
||||
if (resolvedUsers !== users) {
|
||||
nextRooms[roomKey] = { ...roomConfig, users: resolvedUsers };
|
||||
}
|
||||
}
|
||||
roomsConfig = nextRooms;
|
||||
}
|
||||
|
||||
cfg = {
|
||||
@@ -167,6 +213,7 @@ export async function monitorMatrixProvider(opts: MonitorMatrixOpts = {}): Promi
|
||||
...cfg.channels?.matrix?.dm,
|
||||
allowFrom,
|
||||
},
|
||||
...(groupAllowFrom.length > 0 ? { groupAllowFrom } : {}),
|
||||
...(roomsConfig ? { groups: roomsConfig } : {}),
|
||||
},
|
||||
},
|
||||
|
||||
39
extensions/matrix/src/matrix/monitor/rooms.test.ts
Normal file
39
extensions/matrix/src/matrix/monitor/rooms.test.ts
Normal file
@@ -0,0 +1,39 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { resolveMatrixRoomConfig } from "./rooms.js";
|
||||
|
||||
describe("resolveMatrixRoomConfig", () => {
|
||||
it("matches room IDs and aliases, not names", () => {
|
||||
const rooms = {
|
||||
"!room:example.org": { allow: true },
|
||||
"#alias:example.org": { allow: true },
|
||||
"Project Room": { allow: true },
|
||||
};
|
||||
|
||||
const byId = resolveMatrixRoomConfig({
|
||||
rooms,
|
||||
roomId: "!room:example.org",
|
||||
aliases: [],
|
||||
name: "Project Room",
|
||||
});
|
||||
expect(byId.allowed).toBe(true);
|
||||
expect(byId.matchKey).toBe("!room:example.org");
|
||||
|
||||
const byAlias = resolveMatrixRoomConfig({
|
||||
rooms,
|
||||
roomId: "!other:example.org",
|
||||
aliases: ["#alias:example.org"],
|
||||
name: "Other Room",
|
||||
});
|
||||
expect(byAlias.allowed).toBe(true);
|
||||
expect(byAlias.matchKey).toBe("#alias:example.org");
|
||||
|
||||
const byName = resolveMatrixRoomConfig({
|
||||
rooms: { "Project Room": { allow: true } },
|
||||
roomId: "!different:example.org",
|
||||
aliases: [],
|
||||
name: "Project Room",
|
||||
});
|
||||
expect(byName.allowed).toBe(false);
|
||||
expect(byName.config).toBeUndefined();
|
||||
});
|
||||
});
|
||||
@@ -22,7 +22,6 @@ export function resolveMatrixRoomConfig(params: {
|
||||
params.roomId,
|
||||
`room:${params.roomId}`,
|
||||
...params.aliases,
|
||||
params.name ?? "",
|
||||
);
|
||||
const {
|
||||
entry: matched,
|
||||
|
||||
48
extensions/matrix/src/resolve-targets.test.ts
Normal file
48
extensions/matrix/src/resolve-targets.test.ts
Normal file
@@ -0,0 +1,48 @@
|
||||
import type { ChannelDirectoryEntry } from "openclaw/plugin-sdk";
|
||||
import { describe, expect, it, vi, beforeEach } from "vitest";
|
||||
import { listMatrixDirectoryPeersLive } from "./directory-live.js";
|
||||
import { resolveMatrixTargets } from "./resolve-targets.js";
|
||||
|
||||
vi.mock("./directory-live.js", () => ({
|
||||
listMatrixDirectoryPeersLive: vi.fn(),
|
||||
listMatrixDirectoryGroupsLive: vi.fn(),
|
||||
}));
|
||||
|
||||
describe("resolveMatrixTargets (users)", () => {
|
||||
beforeEach(() => {
|
||||
vi.mocked(listMatrixDirectoryPeersLive).mockReset();
|
||||
});
|
||||
|
||||
it("resolves exact unique display name matches", async () => {
|
||||
const matches: ChannelDirectoryEntry[] = [
|
||||
{ kind: "user", id: "@alice:example.org", name: "Alice" },
|
||||
];
|
||||
vi.mocked(listMatrixDirectoryPeersLive).mockResolvedValue(matches);
|
||||
|
||||
const [result] = await resolveMatrixTargets({
|
||||
cfg: {},
|
||||
inputs: ["Alice"],
|
||||
kind: "user",
|
||||
});
|
||||
|
||||
expect(result?.resolved).toBe(true);
|
||||
expect(result?.id).toBe("@alice:example.org");
|
||||
});
|
||||
|
||||
it("does not resolve ambiguous or non-exact matches", async () => {
|
||||
const matches: ChannelDirectoryEntry[] = [
|
||||
{ kind: "user", id: "@alice:example.org", name: "Alice" },
|
||||
{ kind: "user", id: "@alice:evil.example", name: "Alice" },
|
||||
];
|
||||
vi.mocked(listMatrixDirectoryPeersLive).mockResolvedValue(matches);
|
||||
|
||||
const [result] = await resolveMatrixTargets({
|
||||
cfg: {},
|
||||
inputs: ["Alice"],
|
||||
kind: "user",
|
||||
});
|
||||
|
||||
expect(result?.resolved).toBe(false);
|
||||
expect(result?.note).toMatch(/use full Matrix ID/i);
|
||||
});
|
||||
});
|
||||
@@ -28,6 +28,52 @@ function pickBestGroupMatch(
|
||||
return matches[0];
|
||||
}
|
||||
|
||||
function pickBestUserMatch(
|
||||
matches: ChannelDirectoryEntry[],
|
||||
query: string,
|
||||
): ChannelDirectoryEntry | undefined {
|
||||
if (matches.length === 0) {
|
||||
return undefined;
|
||||
}
|
||||
const normalized = query.trim().toLowerCase();
|
||||
if (!normalized) {
|
||||
return undefined;
|
||||
}
|
||||
const exact = matches.filter((match) => {
|
||||
const id = match.id.trim().toLowerCase();
|
||||
const name = match.name?.trim().toLowerCase();
|
||||
const handle = match.handle?.trim().toLowerCase();
|
||||
return normalized === id || normalized === name || normalized === handle;
|
||||
});
|
||||
if (exact.length === 1) {
|
||||
return exact[0];
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function describeUserMatchFailure(matches: ChannelDirectoryEntry[], query: string): string {
|
||||
if (matches.length === 0) {
|
||||
return "no matches";
|
||||
}
|
||||
const normalized = query.trim().toLowerCase();
|
||||
if (!normalized) {
|
||||
return "empty input";
|
||||
}
|
||||
const exact = matches.filter((match) => {
|
||||
const id = match.id.trim().toLowerCase();
|
||||
const name = match.name?.trim().toLowerCase();
|
||||
const handle = match.handle?.trim().toLowerCase();
|
||||
return normalized === id || normalized === name || normalized === handle;
|
||||
});
|
||||
if (exact.length === 0) {
|
||||
return "no exact match; use full Matrix ID";
|
||||
}
|
||||
if (exact.length > 1) {
|
||||
return "multiple exact matches; use full Matrix ID";
|
||||
}
|
||||
return "no exact match; use full Matrix ID";
|
||||
}
|
||||
|
||||
export async function resolveMatrixTargets(params: {
|
||||
cfg: unknown;
|
||||
inputs: string[];
|
||||
@@ -52,13 +98,13 @@ export async function resolveMatrixTargets(params: {
|
||||
query: trimmed,
|
||||
limit: 5,
|
||||
});
|
||||
const best = matches[0];
|
||||
const best = pickBestUserMatch(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: best ? undefined : describeUserMatchFailure(matches, trimmed),
|
||||
});
|
||||
} catch (err) {
|
||||
params.runtime?.error?.(`matrix resolve failed: ${String(err)}`);
|
||||
|
||||
@@ -7,7 +7,7 @@ export type MatrixDmConfig = {
|
||||
enabled?: boolean;
|
||||
/** Direct message access policy (default: pairing). */
|
||||
policy?: DmPolicy;
|
||||
/** Allowlist for DM senders (matrix user IDs, localparts, or "*"). */
|
||||
/** Allowlist for DM senders (matrix user IDs or "*"). */
|
||||
allowFrom?: Array<string | number>;
|
||||
};
|
||||
|
||||
@@ -22,7 +22,7 @@ export type MatrixRoomConfig = {
|
||||
tools?: { allow?: string[]; deny?: string[] };
|
||||
/** If true, reply without mention requirements. */
|
||||
autoReply?: boolean;
|
||||
/** Optional allowlist for room senders (user IDs or localparts). */
|
||||
/** Optional allowlist for room senders (matrix user IDs). */
|
||||
users?: Array<string | number>;
|
||||
/** Optional skill filter for this room. */
|
||||
skills?: string[];
|
||||
@@ -61,7 +61,7 @@ export type MatrixConfig = {
|
||||
allowlistOnly?: boolean;
|
||||
/** Group message policy (default: allowlist). */
|
||||
groupPolicy?: GroupPolicy;
|
||||
/** Allowlist for group senders (user IDs or localparts). */
|
||||
/** Allowlist for group senders (matrix user IDs). */
|
||||
groupAllowFrom?: Array<string | number>;
|
||||
/** Control reply threading when reply tags are present (off|first|all). */
|
||||
replyToMode?: ReplyToMode;
|
||||
@@ -79,9 +79,9 @@ export type MatrixConfig = {
|
||||
autoJoinAllowlist?: Array<string | number>;
|
||||
/** Direct message policy + allowlist overrides. */
|
||||
dm?: MatrixDmConfig;
|
||||
/** Room config allowlist keyed by room ID, alias, or name. */
|
||||
/** Room config allowlist keyed by room ID or alias (names resolved to IDs when possible). */
|
||||
groups?: Record<string, MatrixRoomConfig>;
|
||||
/** Room config allowlist keyed by room ID, alias, or name. Legacy; use groups. */
|
||||
/** Room config allowlist keyed by room ID or alias. Legacy; use groups. */
|
||||
rooms?: Record<string, MatrixRoomConfig>;
|
||||
/** Per-action tool gating (default: true for all). */
|
||||
actions?: MatrixActionConfig;
|
||||
|
||||
Reference in New Issue
Block a user