From e44f28bd4f473715d3799d2b3245fe696bc179f7 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 16 Jan 2026 22:05:53 +0000 Subject: [PATCH] feat: unify directory across channels --- docs/cli/directory.md | 6 +- src/channels/plugins/directory-config.test.ts | 148 ++++++++++++++++++ src/channels/plugins/discord.ts | 71 +++++++++ src/channels/plugins/slack.ts | 54 +++++++ src/channels/plugins/telegram.ts | 42 +++++ src/channels/plugins/whatsapp.ts | 39 +++++ src/cli/directory-cli.ts | 30 ++-- 7 files changed, 372 insertions(+), 18 deletions(-) create mode 100644 src/channels/plugins/directory-config.test.ts diff --git a/docs/cli/directory.md b/docs/cli/directory.md index 7ec247e02..46e8eda88 100644 --- a/docs/cli/directory.md +++ b/docs/cli/directory.md @@ -10,10 +10,13 @@ read_when: Directory lookups for channels that support it (contacts/peers, groups, and “me”). ## Common flags -- `--channel `: channel id/alias (default: `whatsapp`) +- `--channel `: channel id/alias (auto when exactly one channel is configured) - `--account `: 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 ``` - diff --git a/src/channels/plugins/directory-config.test.ts b/src/channels/plugins/directory-config.test.ts new file mode 100644 index 000000000..d9df6caa5 --- /dev/null +++ b/src/channels/plugins/directory-config.test.ts @@ -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"]); + }); +}); diff --git a/src/channels/plugins/discord.ts b/src/channels/plugins/discord.ts index b5240e01f..59c79948e 100644 --- a/src/channels/plugins/discord.ts +++ b/src/channels/plugins/discord.ts @@ -136,6 +136,77 @@ export const discordPlugin: ChannelPlugin = { 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(); + + 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(); + 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), diff --git a/src/channels/plugins/slack.ts b/src/channels/plugins/slack.ts index f55964327..149205767 100644 --- a/src/channels/plugins/slack.ts +++ b/src/channels/plugins/slack.ts @@ -169,6 +169,60 @@ export const slackPlugin: ChannelPlugin = { 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(); + + 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( diff --git a/src/channels/plugins/telegram.ts b/src/channels/plugins/telegram.ts index 3202974db..a2e2fdc0d 100644 --- a/src/channels/plugins/telegram.ts +++ b/src/channels/plugins/telegram.ts @@ -155,6 +155,48 @@ export const telegramPlugin: ChannelPlugin = { 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), diff --git a/src/channels/plugins/whatsapp.ts b/src/channels/plugins/whatsapp.ts index 29c4c1810..85718c74e 100644 --- a/src/channels/plugins/whatsapp.ts +++ b/src/channels/plugins/whatsapp.ts @@ -215,6 +215,45 @@ export const whatsappPlugin: ChannelPlugin = { 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 []; diff --git a/src/cli/directory-cli.ts b/src/cli/directory-cli.ts index 9324285fe..ea927c7b6 100644 --- a/src/cli/directory-cli.ts +++ b/src/cli/directory-cli.ts @@ -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 ", "Channel (default: whatsapp)") + .option("--channel ", "Channel (auto when only one is configured)") .option("--account ", "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 ", "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 ", "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 ", "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, });