feat: unify directory across channels

This commit is contained in:
Peter Steinberger
2026-01-16 22:05:53 +00:00
parent 929b86e302
commit e44f28bd4f
7 changed files with 372 additions and 18 deletions

View File

@@ -10,10 +10,13 @@ read_when:
Directory lookups for channels that support it (contacts/peers, groups, and “me”).
## Common flags
- `--channel <name>`: channel id/alias (default: `whatsapp`)
- `--channel <name>`: channel id/alias (auto when exactly one channel is configured)
- `--account <id>`: account id (default: channel default)
- `--json`: output JSON
## Notes
- For many channels, `directory` lists IDs from your configuration (allowlists / configured groups), not a live provider directory.
## Self (“me”)
```bash
@@ -35,4 +38,3 @@ clawdbot directory groups list --channel zalouser
clawdbot directory groups list --channel zalouser --query "work"
clawdbot directory groups members --channel zalouser --group-id <id>
```

View File

@@ -0,0 +1,148 @@
import { describe, expect, it } from "vitest";
describe("directory (config-backed)", () => {
it("lists Slack peers/groups from config", async () => {
const { slackPlugin } = await import("./slack.js");
const runtime = { log: () => {}, error: () => {}, exit: () => {} } as any;
const cfg = {
channels: {
slack: {
botToken: "xoxb-test",
appToken: "xapp-test",
dm: { allowFrom: ["U123", "user:U999"] },
dms: { U234: {} },
channels: { C111: { users: ["U777"] } },
},
},
} as any;
const peers = await slackPlugin.directory?.listPeers?.({
cfg,
accountId: "default",
query: null,
limit: null,
runtime,
});
expect(peers?.map((e) => e.id).sort()).toEqual([
"user:u123",
"user:u234",
"user:u777",
"user:u999",
]);
const groups = await slackPlugin.directory?.listGroups?.({
cfg,
accountId: "default",
query: null,
limit: null,
runtime,
});
expect(groups?.map((e) => e.id)).toEqual(["channel:c111"]);
});
it("lists Discord peers/groups from config (numeric ids only)", async () => {
const { discordPlugin } = await import("./discord.js");
const runtime = { log: () => {}, error: () => {}, exit: () => {} } as any;
const cfg = {
channels: {
discord: {
token: "discord-test",
dm: { allowFrom: ["<@111>", "nope"] },
dms: { "222": {} },
guilds: {
"123": {
users: ["<@12345>", "not-an-id"],
channels: {
"555": {},
"channel:666": {},
general: {},
},
},
},
},
},
} as any;
const peers = await discordPlugin.directory?.listPeers?.({
cfg,
accountId: "default",
query: null,
limit: null,
runtime,
});
expect(peers?.map((e) => e.id).sort()).toEqual(["user:111", "user:12345", "user:222"]);
const groups = await discordPlugin.directory?.listGroups?.({
cfg,
accountId: "default",
query: null,
limit: null,
runtime,
});
expect(groups?.map((e) => e.id).sort()).toEqual(["channel:555", "channel:666"]);
});
it("lists Telegram peers/groups from config", async () => {
const { telegramPlugin } = await import("./telegram.js");
const runtime = { log: () => {}, error: () => {}, exit: () => {} } as any;
const cfg = {
channels: {
telegram: {
botToken: "telegram-test",
allowFrom: ["123", "alice", "tg:@bob"],
dms: { "456": {} },
groups: { "-1001": {}, "*": {} },
},
},
} as any;
const peers = await telegramPlugin.directory?.listPeers?.({
cfg,
accountId: "default",
query: null,
limit: null,
runtime,
});
expect(peers?.map((e) => e.id).sort()).toEqual(["123", "456", "@alice", "@bob"]);
const groups = await telegramPlugin.directory?.listGroups?.({
cfg,
accountId: "default",
query: null,
limit: null,
runtime,
});
expect(groups?.map((e) => e.id)).toEqual(["-1001"]);
});
it("lists WhatsApp peers/groups from config", async () => {
const { whatsappPlugin } = await import("./whatsapp.js");
const runtime = { log: () => {}, error: () => {}, exit: () => {} } as any;
const cfg = {
channels: {
whatsapp: {
allowFrom: ["+15550000000", "*", "123@g.us"],
groups: { "999@g.us": { requireMention: true }, "*": {} },
},
},
} as any;
const peers = await whatsappPlugin.directory?.listPeers?.({
cfg,
accountId: "default",
query: null,
limit: null,
runtime,
});
expect(peers?.map((e) => e.id)).toEqual(["+15550000000"]);
const groups = await whatsappPlugin.directory?.listGroups?.({
cfg,
accountId: "default",
query: null,
limit: null,
runtime,
});
expect(groups?.map((e) => e.id)).toEqual(["999@g.us"]);
});
});

View File

@@ -136,6 +136,77 @@ export const discordPlugin: ChannelPlugin<ResolvedDiscordAccount> = {
messaging: {
normalizeTarget: normalizeDiscordMessagingTarget,
},
directory: {
self: async () => null,
listPeers: async ({ cfg, accountId, query, limit }) => {
const account = resolveDiscordAccount({ cfg, 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);
}
for (const id of Object.keys(account.config.dms ?? {})) {
const trimmed = id.trim();
if (trimmed) ids.add(trimmed);
}
for (const guild of Object.values(account.config.guilds ?? {})) {
for (const entry of guild.users ?? []) {
const raw = String(entry).trim();
if (raw) ids.add(raw);
}
for (const channel of Object.values(guild.channels ?? {})) {
for (const user of channel.users ?? []) {
const raw = String(user).trim();
if (raw) ids.add(raw);
}
}
}
const peers = Array.from(ids)
.map((raw) => raw.trim())
.filter(Boolean)
.map((raw) => {
const mention = raw.match(/^<@!?(\d+)>$/);
const cleaned = (mention?.[1] ?? raw).replace(/^(discord|user):/i, "").trim();
if (!/^\d+$/.test(cleaned)) return null;
return `user:${cleaned}`;
})
.filter((id): id is string => Boolean(id))
.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 ({ cfg, accountId, query, limit }) => {
const account = resolveDiscordAccount({ cfg, accountId });
const q = query?.trim().toLowerCase() || "";
const ids = new Set<string>();
for (const guild of Object.values(account.config.guilds ?? {})) {
for (const channelId of Object.keys(guild.channels ?? {})) {
const trimmed = channelId.trim();
if (trimmed) ids.add(trimmed);
}
}
const groups = Array.from(ids)
.map((raw) => raw.trim())
.filter(Boolean)
.map((raw) => {
const mention = raw.match(/^<#(\d+)>$/);
const cleaned = (mention?.[1] ?? raw).replace(/^(discord|channel|group):/i, "").trim();
if (!/^\d+$/.test(cleaned)) return null;
return `channel:${cleaned}`;
})
.filter((id): id is string => Boolean(id))
.filter((id) => (q ? id.toLowerCase().includes(q) : true))
.slice(0, limit && limit > 0 ? limit : undefined)
.map((id) => ({ kind: "group", id }) as const);
return groups;
},
},
actions: discordMessageActions,
setup: {
resolveAccountId: ({ accountId }) => normalizeAccountId(accountId),

View File

@@ -169,6 +169,60 @@ export const slackPlugin: ChannelPlugin<ResolvedSlackAccount> = {
messaging: {
normalizeTarget: normalizeSlackMessagingTarget,
},
directory: {
self: async () => null,
listPeers: async ({ cfg, accountId, query, limit }) => {
const account = resolveSlackAccount({ cfg, accountId });
const q = query?.trim().toLowerCase() || "";
const ids = new Set<string>();
for (const entry of account.dm?.allowFrom ?? []) {
const raw = String(entry).trim();
if (!raw || raw === "*") continue;
ids.add(raw);
}
for (const id of Object.keys(account.config.dms ?? {})) {
const trimmed = id.trim();
if (trimmed) ids.add(trimmed);
}
for (const channel of Object.values(account.config.channels ?? {})) {
for (const user of channel.users ?? []) {
const raw = String(user).trim();
if (raw) ids.add(raw);
}
}
const peers = Array.from(ids)
.map((raw) => raw.trim())
.filter(Boolean)
.map((raw) => {
const mention = raw.match(/^<@([A-Z0-9]+)>$/i);
const normalizedUserId = (mention?.[1] ?? raw).replace(/^(slack|user):/i, "").trim();
if (!normalizedUserId) return null;
const target = `user:${normalizedUserId}`;
return normalizeSlackMessagingTarget(target) ?? target.toLowerCase();
})
.filter((id): id is string => Boolean(id))
.filter((id) => id.startsWith("user:"))
.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 ({ cfg, accountId, query, limit }) => {
const account = resolveSlackAccount({ cfg, accountId });
const q = query?.trim().toLowerCase() || "";
const groups = Object.keys(account.config.channels ?? {})
.map((raw) => raw.trim())
.filter(Boolean)
.map((raw) => normalizeSlackMessagingTarget(raw) ?? raw.toLowerCase())
.filter((id) => id.startsWith("channel:"))
.filter((id) => (q ? id.toLowerCase().includes(q) : true))
.slice(0, limit && limit > 0 ? limit : undefined)
.map((id) => ({ kind: "group", id }) as const);
return groups;
},
},
actions: {
listActions: ({ cfg }) => {
const accounts = listEnabledSlackAccounts(cfg).filter(

View File

@@ -155,6 +155,48 @@ export const telegramPlugin: ChannelPlugin<ResolvedTelegramAccount> = {
messaging: {
normalizeTarget: normalizeTelegramMessagingTarget,
},
directory: {
self: async () => null,
listPeers: async ({ cfg, accountId, query, limit }) => {
const account = resolveTelegramAccount({ cfg, accountId });
const q = query?.trim().toLowerCase() || "";
const raw = [
...(account.config.allowFrom ?? []).map((entry) => String(entry)),
...Object.keys(account.config.dms ?? {}),
];
const peers = Array.from(
new Set(
raw
.map((entry) => entry.trim())
.filter(Boolean)
.map((entry) => entry.replace(/^(telegram|tg):/i, "")),
),
)
.map((entry) => {
const trimmed = entry.trim();
if (!trimmed) return null;
if (/^-?\d+$/.test(trimmed)) return trimmed;
const withAt = trimmed.startsWith("@") ? trimmed : `@${trimmed}`;
return withAt;
})
.filter((id): id is string => Boolean(id))
.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 ({ cfg, accountId, query, limit }) => {
const account = resolveTelegramAccount({ cfg, accountId });
const q = query?.trim().toLowerCase() || "";
const groups = Object.keys(account.config.groups ?? {})
.map((id) => id.trim())
.filter((id) => Boolean(id) && id !== "*")
.filter((id) => (q ? id.toLowerCase().includes(q) : true))
.slice(0, limit && limit > 0 ? limit : undefined)
.map((id) => ({ kind: "group", id }) as const);
return groups;
},
},
actions: telegramMessageActions,
setup: {
resolveAccountId: ({ accountId }) => normalizeAccountId(accountId),

View File

@@ -215,6 +215,45 @@ export const whatsappPlugin: ChannelPlugin<ResolvedWhatsAppAccount> = {
messaging: {
normalizeTarget: normalizeWhatsAppMessagingTarget,
},
directory: {
self: async ({ cfg, accountId }) => {
const account = resolveWhatsAppAccount({ cfg, accountId });
const { e164, jid } = readWebSelfId(account.authDir);
const id = e164 ?? jid;
if (!id) return null;
return {
kind: "user",
id,
name: account.name,
raw: { e164, jid },
};
},
listPeers: async ({ cfg, accountId, query, limit }) => {
const account = resolveWhatsAppAccount({ cfg, accountId });
const q = query?.trim().toLowerCase() || "";
const peers = (account.allowFrom ?? [])
.map((entry) => String(entry).trim())
.filter((entry) => Boolean(entry) && entry !== "*")
.map((entry) => normalizeWhatsAppTarget(entry) ?? "")
.filter(Boolean)
.filter((id) => !isWhatsAppGroupJid(id))
.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 ({ cfg, accountId, query, limit }) => {
const account = resolveWhatsAppAccount({ cfg, accountId });
const q = query?.trim().toLowerCase() || "";
const groups = Object.keys(account.groups ?? {})
.map((id) => id.trim())
.filter((id) => Boolean(id) && id !== "*")
.filter((id) => (q ? id.toLowerCase().includes(q) : true))
.slice(0, limit && limit > 0 ? limit : undefined)
.map((id) => ({ kind: "group", id }) as const);
return groups;
},
},
actions: {
listActions: ({ cfg }) => {
if (!cfg.channels?.whatsapp) return [];

View File

@@ -1,10 +1,10 @@
import type { Command } from "commander";
import { resolveChannelDefaultAccountId } from "../channels/plugins/helpers.js";
import { getChannelPlugin, normalizeChannelId } from "../channels/plugins/index.js";
import { DEFAULT_CHAT_CHANNEL } from "../channels/registry.js";
import { getChannelPlugin } from "../channels/plugins/index.js";
import { loadConfig } from "../config/config.js";
import { danger } from "../globals.js";
import { resolveMessageChannelSelection } from "../infra/outbound/channel-selection.js";
import { defaultRuntime } from "../runtime.js";
import { formatDocsLink } from "../terminal/links.js";
import { theme } from "../terminal/theme.js";
@@ -45,21 +45,19 @@ export function registerDirectoryCli(program: Command) {
const withChannel = (cmd: Command) =>
cmd
.option("--channel <name>", "Channel (default: whatsapp)")
.option("--channel <name>", "Channel (auto when only one is configured)")
.option("--account <id>", "Account id (accountId)")
.option("--json", "Output JSON", false);
const resolve = (opts: { channel?: string; account?: string }) => {
const resolve = async (opts: { channel?: string; account?: string }) => {
const cfg = loadConfig();
const channelInput = opts.channel ?? DEFAULT_CHAT_CHANNEL;
const channelId = normalizeChannelId(channelInput);
if (!channelId) {
throw new Error(`Unsupported channel: ${channelInput}`);
}
const selection = await resolveMessageChannelSelection({
cfg,
channel: opts.channel ?? null,
});
const channelId = selection.channel;
const plugin = getChannelPlugin(channelId);
if (!plugin?.directory) {
throw new Error(`Channel ${channelId} does not support directory`);
}
if (!plugin) throw new Error(`Unsupported channel: ${String(channelId)}`);
const accountId = opts.account?.trim() || resolveChannelDefaultAccountId({ plugin, cfg });
return { cfg, channelId, accountId, plugin };
};
@@ -67,7 +65,7 @@ export function registerDirectoryCli(program: Command) {
withChannel(directory.command("self").description("Show the current account user")).action(
async (opts) => {
try {
const { cfg, channelId, accountId, plugin } = resolve({
const { cfg, channelId, accountId, plugin } = await resolve({
channel: opts.channel as string | undefined,
account: opts.account as string | undefined,
});
@@ -96,7 +94,7 @@ export function registerDirectoryCli(program: Command) {
.option("--limit <n>", "Limit results")
.action(async (opts) => {
try {
const { cfg, channelId, accountId, plugin } = resolve({
const { cfg, channelId, accountId, plugin } = await resolve({
channel: opts.channel as string | undefined,
account: opts.account as string | undefined,
});
@@ -128,7 +126,7 @@ export function registerDirectoryCli(program: Command) {
.option("--limit <n>", "Limit results")
.action(async (opts) => {
try {
const { cfg, channelId, accountId, plugin } = resolve({
const { cfg, channelId, accountId, plugin } = await resolve({
channel: opts.channel as string | undefined,
account: opts.account as string | undefined,
});
@@ -163,7 +161,7 @@ export function registerDirectoryCli(program: Command) {
.option("--limit <n>", "Limit results")
.action(async (opts) => {
try {
const { cfg, channelId, accountId, plugin } = resolve({
const { cfg, channelId, accountId, plugin } = await resolve({
channel: opts.channel as string | undefined,
account: opts.account as string | undefined,
});