feat: directory for plugin channels

This commit is contained in:
Peter Steinberger
2026-01-16 22:22:23 +00:00
parent e44f28bd4f
commit 59f6ea9b21
6 changed files with 270 additions and 0 deletions

View File

@@ -0,0 +1,44 @@
import { describe, expect, it } from "vitest";
import type { CoreConfig } from "./types.js";
import { matrixPlugin } from "./channel.js";
describe("matrix directory", () => {
it("lists peers and groups from config", async () => {
const cfg = {
channels: {
matrix: {
dm: { allowFrom: ["matrix:@alice:example.org", "bob"] },
rooms: {
"!room1:example.org": { users: ["@carol:example.org"] },
"#alias:example.org": { users: [] },
},
},
},
} as unknown as CoreConfig;
expect(matrixPlugin.directory).toBeTruthy();
expect(matrixPlugin.directory?.listPeers).toBeTruthy();
expect(matrixPlugin.directory?.listGroups).toBeTruthy();
await expect(
matrixPlugin.directory!.listPeers({ cfg, accountId: undefined, query: undefined, limit: undefined }),
).resolves.toEqual(
expect.arrayContaining([
{ kind: "user", id: "user:@alice:example.org" },
{ kind: "user", id: "bob", name: "incomplete id; expected @user:server" },
{ kind: "user", id: "user:@carol:example.org" },
]),
);
await expect(
matrixPlugin.directory!.listGroups({ cfg, accountId: undefined, query: undefined, limit: undefined }),
).resolves.toEqual(
expect.arrayContaining([
{ kind: "group", id: "room:!room1:example.org" },
{ kind: "group", id: "#alias:example.org" },
]),
);
});
});

View File

@@ -162,6 +162,67 @@ export const matrixPlugin: ChannelPlugin<ResolvedMatrixAccount> = {
messaging: {
normalizeTarget: normalizeMatrixMessagingTarget,
},
directory: {
self: async () => null,
listPeers: async ({ cfg, accountId, query, limit }) => {
const account = resolveMatrixAccount({ cfg: cfg as CoreConfig, accountId });
const q = query?.trim().toLowerCase() || "";
const ids = new Set<string>();
for (const entry of account.config.dm?.allowFrom ?? []) {
const raw = String(entry).trim();
if (!raw || raw === "*") continue;
ids.add(raw.replace(/^matrix:/i, ""));
}
for (const room of Object.values(account.config.rooms ?? {})) {
for (const entry of room.users ?? []) {
const raw = String(entry).trim();
if (!raw || raw === "*") continue;
ids.add(raw.replace(/^matrix:/i, ""));
}
}
return Array.from(ids)
.map((raw) => raw.trim())
.filter(Boolean)
.map((raw) => {
const lowered = raw.toLowerCase();
const cleaned = lowered.startsWith("user:") ? raw.slice("user:".length).trim() : raw;
if (cleaned.startsWith("@")) return `user:${cleaned}`;
return cleaned;
})
.filter((id) => (q ? id.toLowerCase().includes(q) : true))
.slice(0, limit && limit > 0 ? limit : undefined)
.map((id) => {
const raw = id.startsWith("user:") ? id.slice("user:".length) : id;
const incomplete = !raw.startsWith("@") || !raw.includes(":");
return {
kind: "user",
id,
...(incomplete ? { name: "incomplete id; expected @user:server" } : {}),
};
});
},
listGroups: async ({ cfg, accountId, query, limit }) => {
const account = resolveMatrixAccount({ cfg: cfg as CoreConfig, accountId });
const q = query?.trim().toLowerCase() || "";
const ids = Object.keys(account.config.rooms ?? {})
.map((raw) => raw.trim())
.filter((raw) => Boolean(raw) && raw !== "*")
.map((raw) => raw.replace(/^matrix:/i, ""))
.map((raw) => {
const lowered = raw.toLowerCase();
if (lowered.startsWith("room:") || lowered.startsWith("channel:")) return raw;
if (raw.startsWith("!")) return `room:${raw}`;
return raw;
})
.filter((id) => (q ? id.toLowerCase().includes(q) : true))
.slice(0, limit && limit > 0 ? limit : undefined)
.map((id) => ({ kind: "group", id }) as const);
return ids;
},
},
actions: matrixMessageActions,
setup: {
resolveAccountId: ({ accountId }) => normalizeAccountId(accountId),

View File

@@ -0,0 +1,46 @@
import { describe, expect, it } from "vitest";
import type { ClawdbotConfig } from "../../../src/config/config.js";
import { msteamsPlugin } from "./channel.js";
describe("msteams directory", () => {
it("lists peers and groups from config", async () => {
const cfg = {
channels: {
msteams: {
allowFrom: ["alice", "user:Bob"],
dms: { carol: {}, bob: {} },
teams: {
team1: {
channels: {
"conversation:chan1": {},
chan2: {},
},
},
},
},
},
} as unknown as ClawdbotConfig;
expect(msteamsPlugin.directory).toBeTruthy();
expect(msteamsPlugin.directory?.listPeers).toBeTruthy();
expect(msteamsPlugin.directory?.listGroups).toBeTruthy();
await expect(msteamsPlugin.directory!.listPeers({ cfg, query: undefined, limit: undefined })).resolves.toEqual(
expect.arrayContaining([
{ kind: "user", id: "user:alice" },
{ kind: "user", id: "user:Bob" },
{ kind: "user", id: "user:carol" },
{ kind: "user", id: "user:bob" },
]),
);
await expect(msteamsPlugin.directory!.listGroups({ cfg, query: undefined, limit: undefined })).resolves.toEqual(
expect.arrayContaining([
{ kind: "group", id: "conversation:chan1" },
{ kind: "group", id: "conversation:chan2" },
]),
);
});
});

View File

@@ -25,6 +25,21 @@ const meta = {
order: 60,
} as const;
function normalizeMSTeamsMessagingTarget(raw: string): string | undefined {
let trimmed = raw.trim();
if (!trimmed) return undefined;
if (/^(msteams|teams):/i.test(trimmed)) {
trimmed = trimmed.replace(/^(msteams|teams):/i, "");
}
if (/^conversation:/i.test(trimmed)) {
return `conversation:${trimmed.slice("conversation:".length).trim()}`;
}
if (/^user:/i.test(trimmed)) {
return `user:${trimmed.slice("user:".length).trim()}`;
}
return trimmed;
}
export const msteamsPlugin: ChannelPlugin<ResolvedMSTeamsAccount> = {
id: "msteams",
meta: {
@@ -113,6 +128,55 @@ export const msteamsPlugin: ChannelPlugin<ResolvedMSTeamsAccount> = {
},
}),
},
messaging: {
normalizeTarget: normalizeMSTeamsMessagingTarget,
},
directory: {
self: async () => null,
listPeers: async ({ cfg, query, limit }) => {
const q = query?.trim().toLowerCase() || "";
const ids = new Set<string>();
for (const entry of cfg.channels?.msteams?.allowFrom ?? []) {
const trimmed = String(entry).trim();
if (trimmed && trimmed !== "*") ids.add(trimmed);
}
for (const userId of Object.keys(cfg.channels?.msteams?.dms ?? {})) {
const trimmed = userId.trim();
if (trimmed) ids.add(trimmed);
}
return Array.from(ids)
.map((raw) => raw.trim())
.filter(Boolean)
.map((raw) => normalizeMSTeamsMessagingTarget(raw) ?? raw)
.map((raw) => {
const lowered = raw.toLowerCase();
if (lowered.startsWith("user:")) return raw;
if (lowered.startsWith("conversation:")) return raw;
return `user:${raw}`;
})
.filter((id) => (q ? id.toLowerCase().includes(q) : true))
.slice(0, limit && limit > 0 ? limit : undefined)
.map((id) => ({ kind: "user", id }) as const);
},
listGroups: async ({ cfg, query, limit }) => {
const q = query?.trim().toLowerCase() || "";
const ids = new Set<string>();
for (const team of Object.values(cfg.channels?.msteams?.teams ?? {})) {
for (const channelId of Object.keys(team.channels ?? {})) {
const trimmed = channelId.trim();
if (trimmed && trimmed !== "*") ids.add(trimmed);
}
}
return Array.from(ids)
.map((raw) => raw.trim())
.filter(Boolean)
.map((raw) => raw.replace(/^conversation:/i, "").trim())
.map((id) => `conversation:${id}`)
.filter((id) => (q ? id.toLowerCase().includes(q) : true))
.slice(0, limit && limit > 0 ? limit : undefined)
.map((id) => ({ kind: "group", id }) as const);
},
},
actions: {
listActions: ({ cfg }) => {
const enabled =

View File

@@ -0,0 +1,35 @@
import { describe, expect, it } from "vitest";
import type { CoreConfig } from "./types.js";
import { zaloPlugin } from "./channel.js";
describe("zalo directory", () => {
it("lists peers from allowFrom", async () => {
const cfg = {
channels: {
zalo: {
allowFrom: ["zalo:123", "zl:234", "345"],
},
},
} as unknown as CoreConfig;
expect(zaloPlugin.directory).toBeTruthy();
expect(zaloPlugin.directory?.listPeers).toBeTruthy();
expect(zaloPlugin.directory?.listGroups).toBeTruthy();
await expect(
zaloPlugin.directory!.listPeers({ cfg, accountId: undefined, query: undefined, limit: undefined }),
).resolves.toEqual(
expect.arrayContaining([
{ kind: "user", id: "123" },
{ kind: "user", id: "234" },
{ kind: "user", id: "345" },
]),
);
await expect(zaloPlugin.directory!.listGroups({ cfg, accountId: undefined, query: undefined, limit: undefined })).resolves.toEqual(
[],
);
});
});

View File

@@ -148,6 +148,26 @@ export const zaloPlugin: ChannelPlugin<ResolvedZaloAccount> = {
messaging: {
normalizeTarget: normalizeZaloMessagingTarget,
},
directory: {
self: async () => null,
listPeers: async ({ cfg, accountId, query, limit }) => {
const account = resolveZaloAccount({ cfg: cfg as CoreConfig, accountId });
const q = query?.trim().toLowerCase() || "";
const peers = Array.from(
new Set(
(account.config.allowFrom ?? [])
.map((entry) => String(entry).trim())
.filter((entry) => Boolean(entry) && entry !== "*")
.map((entry) => entry.replace(/^(zalo|zl):/i, "")),
),
)
.filter((id) => (q ? id.toLowerCase().includes(q) : true))
.slice(0, limit && limit > 0 ? limit : undefined)
.map((id) => ({ kind: "user", id }) as const);
return peers;
},
listGroups: async () => [],
},
setup: {
resolveAccountId: ({ accountId }) => normalizeAccountId(accountId),
applyAccountName: ({ cfg, accountId, name }) =>