From 59f6ea9b217eb6abe0f5a375b214d77aa6281627 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 16 Jan 2026 22:22:23 +0000 Subject: [PATCH] feat: directory for plugin channels --- .../matrix/src/channel.directory.test.ts | 44 +++++++++++++ extensions/matrix/src/channel.ts | 61 ++++++++++++++++++ .../msteams/src/channel.directory.test.ts | 46 +++++++++++++ extensions/msteams/src/channel.ts | 64 +++++++++++++++++++ extensions/zalo/src/channel.directory.test.ts | 35 ++++++++++ extensions/zalo/src/channel.ts | 20 ++++++ 6 files changed, 270 insertions(+) create mode 100644 extensions/matrix/src/channel.directory.test.ts create mode 100644 extensions/msteams/src/channel.directory.test.ts create mode 100644 extensions/zalo/src/channel.directory.test.ts diff --git a/extensions/matrix/src/channel.directory.test.ts b/extensions/matrix/src/channel.directory.test.ts new file mode 100644 index 000000000..7b45e408f --- /dev/null +++ b/extensions/matrix/src/channel.directory.test.ts @@ -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" }, + ]), + ); + }); +}); diff --git a/extensions/matrix/src/channel.ts b/extensions/matrix/src/channel.ts index d828da88b..067820b3a 100644 --- a/extensions/matrix/src/channel.ts +++ b/extensions/matrix/src/channel.ts @@ -162,6 +162,67 @@ export const matrixPlugin: ChannelPlugin = { 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(); + + 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), diff --git a/extensions/msteams/src/channel.directory.test.ts b/extensions/msteams/src/channel.directory.test.ts new file mode 100644 index 000000000..f1bc50238 --- /dev/null +++ b/extensions/msteams/src/channel.directory.test.ts @@ -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" }, + ]), + ); + }); +}); diff --git a/extensions/msteams/src/channel.ts b/extensions/msteams/src/channel.ts index 5b23c10f4..46e294d5d 100644 --- a/extensions/msteams/src/channel.ts +++ b/extensions/msteams/src/channel.ts @@ -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 = { id: "msteams", meta: { @@ -113,6 +128,55 @@ export const msteamsPlugin: ChannelPlugin = { }, }), }, + messaging: { + normalizeTarget: normalizeMSTeamsMessagingTarget, + }, + directory: { + self: async () => null, + listPeers: async ({ cfg, query, limit }) => { + const q = query?.trim().toLowerCase() || ""; + const ids = new Set(); + 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(); + 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 = diff --git a/extensions/zalo/src/channel.directory.test.ts b/extensions/zalo/src/channel.directory.test.ts new file mode 100644 index 000000000..0ce14ca9f --- /dev/null +++ b/extensions/zalo/src/channel.directory.test.ts @@ -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( + [], + ); + }); +}); diff --git a/extensions/zalo/src/channel.ts b/extensions/zalo/src/channel.ts index d03812653..1d24cbfa5 100644 --- a/extensions/zalo/src/channel.ts +++ b/extensions/zalo/src/channel.ts @@ -148,6 +148,26 @@ export const zaloPlugin: ChannelPlugin = { 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 }) =>