feat: unify directory across channels
This commit is contained in:
@@ -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>
|
||||
```
|
||||
|
||||
|
||||
148
src/channels/plugins/directory-config.test.ts
Normal file
148
src/channels/plugins/directory-config.test.ts
Normal 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"]);
|
||||
});
|
||||
});
|
||||
@@ -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),
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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 [];
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user