From 0832c8d0327d6eb01bd2c2e695c19c711af1d1b2 Mon Sep 17 00:00:00 2001 From: Cameron Date: Fri, 27 Feb 2026 14:46:59 -0800 Subject: [PATCH] feat: extract group listing into shared module with --agent support (#418) --- src/cli.ts | 3 +- src/cli/channel-management.ts | 9 +- src/cli/channels.ts | 185 ++-------------------- src/cli/group-listing.test.ts | 173 +++++++++++++++++++++ src/cli/group-listing.ts | 278 ++++++++++++++++++++++++++++++++++ 5 files changed, 471 insertions(+), 177 deletions(-) create mode 100644 src/cli/group-listing.test.ts create mode 100644 src/cli/group-listing.ts diff --git a/src/cli.ts b/src/cli.ts index fd97f89..8377f52 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -195,6 +195,7 @@ Commands: model set Set model by handle (e.g., anthropic/claude-sonnet-4-5-20250929) channels Manage channels (interactive menu) channels list Show channel status + channels list-groups List group/channel IDs for Slack/Discord channels add Add a channel (telegram, slack, discord, whatsapp, signal) channels remove Remove a channel logout Logout from Letta Platform (revoke OAuth tokens) @@ -300,7 +301,7 @@ async function main() { case 'channels': case 'channel': { const { channelManagementCommand } = await import('./cli/channel-management.js'); - await channelManagementCommand(subCommand, args[2]); + await channelManagementCommand(subCommand, args[2], args.slice(3)); break; } diff --git a/src/cli/channel-management.ts b/src/cli/channel-management.ts index 14ad73c..13b8517 100644 --- a/src/cli/channel-management.ts +++ b/src/cli/channel-management.ts @@ -13,6 +13,7 @@ import { getSetupFunction, type ChannelId } from '../channels/setup.js'; +import { listGroupsFromArgs } from './group-listing.js'; // ============================================================================ // Status Helpers @@ -258,12 +259,18 @@ export async function removeChannel(channelId?: string): Promise { // Main Command Handler // ============================================================================ -export async function channelManagementCommand(subCommand?: string, channelName?: string): Promise { +export async function channelManagementCommand(subCommand?: string, channelName?: string, extraArgs: string[] = []): Promise { switch (subCommand) { case 'list': case 'ls': await listChannels(); break; + case 'list-groups': + case 'groups': { + const args = channelName ? [channelName, ...extraArgs] : extraArgs; + await listGroupsFromArgs(args); + break; + } case 'add': await addChannel(channelName); break; diff --git a/src/cli/channels.ts b/src/cli/channels.ts index c5831bd..be05ad8 100644 --- a/src/cli/channels.ts +++ b/src/cli/channels.ts @@ -3,187 +3,18 @@ * lettabot-channels - Discover channels across platforms * * Usage: - * lettabot-channels list [--channel discord|slack] + * lettabot-channels list [--channel discord|slack] [--agent name] * * The agent can use this CLI via Bash to discover channel IDs * for sending messages with lettabot-message. */ -// Config loaded from lettabot.yaml +// Config loaded from lettabot.yaml (sets env vars for token fallback) import { loadAppConfigOrExit, applyConfigToEnv } from '../config/index.js'; const config = loadAppConfigOrExit(); applyConfigToEnv(config); -// Types -interface DiscordGuild { - id: string; - name: string; -} - -interface DiscordChannel { - id: string; - name: string; - type: number; -} - -interface SlackChannel { - id: string; - name: string; - is_member: boolean; -} - -// Discord channel types that are text-based -const DISCORD_TEXT_CHANNEL_TYPES = new Set([ - 0, // GUILD_TEXT - 2, // GUILD_VOICE - 5, // GUILD_ANNOUNCEMENT - 13, // GUILD_STAGE_VOICE - 15, // GUILD_FORUM -]); - -async function listDiscord(): Promise { - const token = process.env.DISCORD_BOT_TOKEN; - if (!token) { - console.error('Discord: DISCORD_BOT_TOKEN not set, skipping.'); - return; - } - - const headers = { Authorization: `Bot ${token}` }; - - // Fetch guilds the bot is in - const guildsRes = await fetch('https://discord.com/api/v10/users/@me/guilds', { headers }); - if (!guildsRes.ok) { - const error = await guildsRes.text(); - console.error(`Discord: Failed to fetch guilds: ${error}`); - return; - } - - const guilds = (await guildsRes.json()) as DiscordGuild[]; - if (guilds.length === 0) { - console.log('Discord:\n (bot is not in any servers)'); - return; - } - - console.log('Discord:'); - - for (const guild of guilds) { - const channelsRes = await fetch(`https://discord.com/api/v10/guilds/${guild.id}/channels`, { headers }); - if (!channelsRes.ok) { - console.log(` Server: ${guild.name}`); - console.log(` (failed to fetch channels)`); - continue; - } - - const channels = (await channelsRes.json()) as DiscordChannel[]; - const textChannels = channels - .filter((c) => DISCORD_TEXT_CHANNEL_TYPES.has(c.type)) - .sort((a, b) => a.name.localeCompare(b.name)); - - console.log(` Server: ${guild.name}`); - if (textChannels.length === 0) { - console.log(` (no text channels)`); - } else { - const maxNameLen = Math.max(...textChannels.map((c) => c.name.length)); - for (const ch of textChannels) { - const padded = ch.name.padEnd(maxNameLen); - console.log(` #${padded} (id: ${ch.id})`); - } - } - } -} - -async function listSlack(): Promise { - const token = process.env.SLACK_BOT_TOKEN; - if (!token) { - console.error('Slack: SLACK_BOT_TOKEN not set, skipping.'); - return; - } - - const params = new URLSearchParams({ - types: 'public_channel,private_channel', - limit: '1000', - }); - - const res = await fetch(`https://slack.com/api/conversations.list?${params}`, { - headers: { Authorization: `Bearer ${token}` }, - }); - - const data = (await res.json()) as { ok: boolean; channels?: SlackChannel[]; error?: string }; - if (!data.ok) { - console.error(`Slack: API error: ${data.error}`); - return; - } - - const channels = (data.channels || []).sort((a, b) => a.name.localeCompare(b.name)); - - console.log('Slack:'); - if (channels.length === 0) { - console.log(' (no channels found)'); - } else { - const maxNameLen = Math.max(...channels.map((c) => c.name.length)); - for (const ch of channels) { - const padded = ch.name.padEnd(maxNameLen); - console.log(` #${padded} (id: ${ch.id})`); - } - } -} - -function printUnsupported(platform: string): void { - console.log(`${platform}: Channel listing not supported (platform does not expose a bot-visible channel list).`); -} - -async function listCommand(args: string[]): Promise { - let channel = ''; - - for (let i = 0; i < args.length; i++) { - const arg = args[i]; - const next = args[i + 1]; - if ((arg === '--channel' || arg === '-c') && next) { - channel = next.toLowerCase(); - i++; - } - } - - if (channel) { - switch (channel) { - case 'discord': - await listDiscord(); - break; - case 'slack': - await listSlack(); - break; - case 'telegram': - printUnsupported('Telegram'); - break; - case 'whatsapp': - printUnsupported('WhatsApp'); - break; - case 'signal': - printUnsupported('Signal'); - break; - default: - console.error(`Unknown channel: ${channel}. Supported for listing: discord, slack`); - process.exit(1); - } - } else { - // List all configured platforms - const hasDiscord = !!process.env.DISCORD_BOT_TOKEN; - const hasSlack = !!process.env.SLACK_BOT_TOKEN; - - if (!hasDiscord && !hasSlack) { - console.log('No supported platforms configured. Set DISCORD_BOT_TOKEN or SLACK_BOT_TOKEN.'); - return; - } - - if (hasDiscord) { - await listDiscord(); - } - if (hasSlack) { - if (hasDiscord) console.log(''); - await listSlack(); - } - } -} +import { listGroupsFromArgs } from './group-listing.js'; function showHelp(): void { console.log(` @@ -194,6 +25,7 @@ Commands: List options: --channel, -c Platform to list: discord, slack (default: all configured) + --agent Agent name from lettabot.yaml (reads tokens from that agent's config) Examples: # List channels for all configured platforms @@ -205,7 +37,10 @@ Examples: # List Slack channels only lettabot-channels list --channel slack -Environment variables: + # List channels for a specific agent (multi-agent setup) + lettabot-channels list --agent MyAgent + +Environment variables (used as fallback when --agent is not specified): DISCORD_BOT_TOKEN Required for Discord channel listing SLACK_BOT_TOKEN Required for Slack channel listing @@ -219,7 +54,7 @@ const command = args[0]; switch (command) { case 'list': - listCommand(args.slice(1)); + listGroupsFromArgs(args.slice(1)); break; case 'help': @@ -232,7 +67,7 @@ switch (command) { if (command) { // Allow `lettabot-channels --channel discord` without 'list' if (command.startsWith('-')) { - listCommand(args); + listGroupsFromArgs(args); break; } console.error(`Unknown command: ${command}`); diff --git a/src/cli/group-listing.test.ts b/src/cli/group-listing.test.ts new file mode 100644 index 0000000..9ce9d3e --- /dev/null +++ b/src/cli/group-listing.test.ts @@ -0,0 +1,173 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { parseChannelArgs, resolveAgentConfig, resolveListingTokens } from './group-listing.js'; + +// ── parseChannelArgs ───────────────────────────────────────────────────────── + +describe('parseChannelArgs', () => { + it('returns empty result for no args', () => { + expect(parseChannelArgs([])).toEqual({}); + }); + + it('parses --channel flag', () => { + expect(parseChannelArgs(['--channel', 'discord'])).toEqual({ channel: 'discord' }); + }); + + it('parses -c shorthand', () => { + expect(parseChannelArgs(['-c', 'slack'])).toEqual({ channel: 'slack' }); + }); + + it('lowercases the channel value', () => { + expect(parseChannelArgs(['--channel', 'Discord'])).toEqual({ channel: 'discord' }); + }); + + it('parses --agent flag', () => { + expect(parseChannelArgs(['--agent', 'MyAgent'])).toEqual({ agent: 'MyAgent' }); + }); + + it('parses --channel and --agent together', () => { + expect(parseChannelArgs(['--channel', 'discord', '--agent', 'MyAgent'])).toEqual({ + channel: 'discord', + agent: 'MyAgent', + }); + }); + + it('accepts a bare positional as channel shorthand', () => { + expect(parseChannelArgs(['discord'])).toEqual({ channel: 'discord' }); + }); + + it('returns error for --channel with no value', () => { + expect(parseChannelArgs(['--channel'])).toEqual({ error: 'Missing value for --channel' }); + }); + + it('returns error for --channel when next token is another flag', () => { + expect(parseChannelArgs(['--channel', '--agent', 'MyAgent'])).toEqual({ + error: 'Missing value for --channel', + }); + }); + + it('returns error for -c with no value', () => { + expect(parseChannelArgs(['-c'])).toEqual({ error: 'Missing value for --channel' }); + }); + + it('returns error for --agent with no value', () => { + expect(parseChannelArgs(['--agent'])).toEqual({ error: 'Missing value for --agent' }); + }); + + it('returns error for --agent when next token is another flag', () => { + expect(parseChannelArgs(['--agent', '--channel', 'slack'])).toEqual({ + error: 'Missing value for --agent', + }); + }); + + it('returns error for unexpected extra positional argument', () => { + const result = parseChannelArgs(['discord', 'slack']); + expect(result.error).toMatch(/unexpected argument/i); + }); +}); + +// ── resolveAgentConfig ─────────────────────────────────────────────────────── + +describe('resolveAgentConfig', () => { + beforeEach(() => { + vi.resetModules(); + }); + + it('returns undefined when no agentName provided', async () => { + const { resolveAgentConfig: resolve } = await import('./group-listing.js'); + expect(resolve(undefined)).toBeUndefined(); + expect(resolve('')).toBeUndefined(); + }); + + it('finds agent by exact name', async () => { + vi.doMock('../config/index.js', () => ({ + loadAppConfigOrExit: () => ({}), + normalizeAgents: () => [ + { name: 'Muninn', channels: { discord: { token: 'tok' } } }, + { name: 'Other', channels: {} }, + ], + })); + const { resolveAgentConfig: resolve } = await import('./group-listing.js'); + const result = resolve('Muninn'); + expect(result).toBeDefined(); + expect(result!.name).toBe('Muninn'); + }); + + it('finds agent case-insensitively', async () => { + vi.doMock('../config/index.js', () => ({ + loadAppConfigOrExit: () => ({}), + normalizeAgents: () => [{ name: 'Muninn', channels: {} }], + })); + const { resolveAgentConfig: resolve } = await import('./group-listing.js'); + const result = resolve('muninn'); + expect(result).toBeDefined(); + expect(result!.name).toBe('Muninn'); + }); + + it('exits with error when agent not found', async () => { + vi.doMock('../config/index.js', () => ({ + loadAppConfigOrExit: () => ({}), + normalizeAgents: () => [{ name: 'Other', channels: {} }], + })); + const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); + const exitSpy = vi.spyOn(process, 'exit').mockImplementation((() => {}) as never); + + const { resolveAgentConfig: resolve } = await import('./group-listing.js'); + resolve('NonExistent'); + + expect(consoleSpy).toHaveBeenCalledWith(expect.stringContaining('NonExistent')); + expect(exitSpy).toHaveBeenCalledWith(1); + + consoleSpy.mockRestore(); + exitSpy.mockRestore(); + }); +}); + +// ── resolveListingTokens ───────────────────────────────────────────────────── + +describe('resolveListingTokens', () => { + beforeEach(() => { + vi.unstubAllEnvs(); + }); + + it('uses env fallback when no agent is selected', () => { + vi.stubEnv('DISCORD_BOT_TOKEN', 'env-discord'); + vi.stubEnv('SLACK_BOT_TOKEN', 'env-slack'); + + const result = resolveListingTokens(undefined, undefined); + expect(result).toEqual({ + discordToken: 'env-discord', + slackToken: 'env-slack', + }); + }); + + it('does not fall back to env when an agent is selected', () => { + vi.stubEnv('DISCORD_BOT_TOKEN', 'env-discord'); + vi.stubEnv('SLACK_BOT_TOKEN', 'env-slack'); + + const result = resolveListingTokens({ name: 'A', channels: {} } as any, 'A'); + expect(result).toEqual({ + discordToken: undefined, + slackToken: undefined, + }); + }); + + it('uses selected agent tokens when present', () => { + vi.stubEnv('DISCORD_BOT_TOKEN', 'env-discord'); + vi.stubEnv('SLACK_BOT_TOKEN', 'env-slack'); + + const result = resolveListingTokens( + { + name: 'A', + channels: { + discord: { enabled: true, token: 'agent-discord' }, + slack: { enabled: true, botToken: 'agent-slack' }, + }, + } as any, + 'A', + ); + expect(result).toEqual({ + discordToken: 'agent-discord', + slackToken: 'agent-slack', + }); + }); +}); diff --git a/src/cli/group-listing.ts b/src/cli/group-listing.ts new file mode 100644 index 0000000..453cb08 --- /dev/null +++ b/src/cli/group-listing.ts @@ -0,0 +1,278 @@ +/** + * Group Listing Helpers + * + * Shared module for listing group/channel IDs across platforms. + * Used by both the `lettabot channels list-groups` CLI subcommand + * and the standalone `lettabot-channels` binary. + */ + +import { loadAppConfigOrExit, normalizeAgents, type AgentConfig } from '../config/index.js'; + +// ── Types ──────────────────────────────────────────────────────────────────── + +interface DiscordGuild { + id: string; + name: string; +} + +interface DiscordChannel { + id: string; + name: string; + type: number; +} + +interface SlackChannel { + id: string; + name: string; + is_member: boolean; +} + +// Discord channel types that are text-based +const DISCORD_TEXT_CHANNEL_TYPES = new Set([ + 0, // GUILD_TEXT + 2, // GUILD_VOICE + 5, // GUILD_ANNOUNCEMENT + 13, // GUILD_STAGE_VOICE + 15, // GUILD_FORUM +]); + +// ── Platform Listing ───────────────────────────────────────────────────────── + +async function listDiscord(token?: string): Promise { + const discordToken = token || process.env.DISCORD_BOT_TOKEN; + if (!discordToken) { + console.error('Discord: DISCORD_BOT_TOKEN not set, skipping.'); + return; + } + + const headers = { Authorization: `Bot ${discordToken}` }; + const guildsRes = await fetch('https://discord.com/api/v10/users/@me/guilds', { headers }); + if (!guildsRes.ok) { + const error = await guildsRes.text(); + console.error(`Discord: Failed to fetch guilds: ${error}`); + return; + } + + const guilds = (await guildsRes.json()) as DiscordGuild[]; + if (guilds.length === 0) { + console.log('Discord:\n (bot is not in any servers)'); + return; + } + + console.log('Discord:'); + for (const guild of guilds) { + const channelsRes = await fetch(`https://discord.com/api/v10/guilds/${guild.id}/channels`, { headers }); + if (!channelsRes.ok) { + console.log(` Server: ${guild.name} (id: ${guild.id})`); + console.log(' (failed to fetch channels)'); + continue; + } + + const channels = (await channelsRes.json()) as DiscordChannel[]; + const textChannels = channels + .filter((c) => DISCORD_TEXT_CHANNEL_TYPES.has(c.type)) + .sort((a, b) => a.name.localeCompare(b.name)); + + console.log(` Server: ${guild.name} (id: ${guild.id})`); + if (textChannels.length === 0) { + console.log(' (no text channels)'); + } else { + const maxNameLen = Math.max(...textChannels.map((c) => c.name.length)); + for (const ch of textChannels) { + const padded = ch.name.padEnd(maxNameLen); + console.log(` #${padded} (id: ${ch.id})`); + } + } + } +} + +async function listSlack(token?: string): Promise { + const slackToken = token || process.env.SLACK_BOT_TOKEN; + if (!slackToken) { + console.error('Slack: SLACK_BOT_TOKEN not set, skipping.'); + return; + } + + const allChannels: SlackChannel[] = []; + let cursor = ''; + + // Cursor-based pagination for workspaces with >1000 channels + while (true) { + const params = new URLSearchParams({ + types: 'public_channel,private_channel', + exclude_archived: 'true', + limit: '1000', + }); + if (cursor) params.set('cursor', cursor); + + const res = await fetch(`https://slack.com/api/conversations.list?${params}`, { + headers: { Authorization: `Bearer ${slackToken}` }, + }); + + const data = (await res.json()) as { + ok: boolean; + channels?: SlackChannel[]; + error?: string; + response_metadata?: { next_cursor?: string }; + }; + if (!data.ok) { + console.error(`Slack: API error: ${data.error}`); + return; + } + + allChannels.push(...(data.channels || [])); + cursor = data.response_metadata?.next_cursor || ''; + if (!cursor) break; + } + + const channels = allChannels.sort((a, b) => a.name.localeCompare(b.name)); + + console.log('Slack:'); + if (channels.length === 0) { + console.log(' (no channels found)'); + } else { + const maxNameLen = Math.max(...channels.map((c) => c.name.length)); + for (const ch of channels) { + const padded = ch.name.padEnd(maxNameLen); + console.log(` #${padded} (id: ${ch.id})`); + } + } +} + +function printUnsupported(platform: string): void { + console.log(`${platform}: Channel listing not supported (platform does not expose a bot-visible channel list).`); +} + +// ── Agent Config Resolution ────────────────────────────────────────────────── + +export function resolveAgentConfig(agentName?: string): AgentConfig | undefined { + if (!agentName) return undefined; + + const config = loadAppConfigOrExit(); + const agents = normalizeAgents(config); + + const exact = agents.find(a => a.name === agentName); + if (exact) return exact; + + const lower = agentName.toLowerCase(); + const found = agents.find(a => a.name.toLowerCase() === lower); + if (found) return found; + + console.error(`Agent "${agentName}" not found in config`); + process.exit(1); +} + +export function resolveListingTokens( + agentConfig: AgentConfig | undefined, + agentName?: string, +): { discordToken?: string; slackToken?: string } { + // When an agent is explicitly selected, only use that agent's configured tokens. + // Do not fall back to global env vars (prevents cross-agent token leakage). + if (agentName) { + return { + discordToken: agentConfig?.channels?.discord?.token, + slackToken: agentConfig?.channels?.slack?.botToken, + }; + } + + return { + discordToken: agentConfig?.channels?.discord?.token || process.env.DISCORD_BOT_TOKEN, + slackToken: agentConfig?.channels?.slack?.botToken || process.env.SLACK_BOT_TOKEN, + }; +} + +// ── Arg Parsing ────────────────────────────────────────────────────────────── + +export function parseChannelArgs(args: string[]): { channel?: string; agent?: string; error?: string } { + let channel: string | undefined; + let agent: string | undefined; + + for (let i = 0; i < args.length; i++) { + const arg = args[i]; + const next = args[i + 1]; + + if (arg === '--channel' || arg === '-c') { + if (!next || next.startsWith('-')) return { error: 'Missing value for --channel' }; + channel = next.toLowerCase(); + i++; + continue; + } + if (arg === '--agent') { + if (!next || next.startsWith('-')) return { error: 'Missing value for --agent' }; + agent = next; + i++; + continue; + } + if (!arg.startsWith('-')) { + if (!channel) { + channel = arg.toLowerCase(); + } else { + return { error: `Unexpected argument: ${arg}` }; + } + continue; + } + } + + return { channel, agent }; +} + +// ── Main Entry Points ──────────────────────────────────────────────────────── + +export async function listGroups(channel?: string, agentName?: string): Promise { + const agentConfig = resolveAgentConfig(agentName); + + const { discordToken, slackToken } = resolveListingTokens(agentConfig, agentName); + + if (channel) { + switch (channel) { + case 'discord': + await listDiscord(discordToken); + break; + case 'slack': + await listSlack(slackToken); + break; + case 'telegram': + printUnsupported('Telegram'); + break; + case 'whatsapp': + printUnsupported('WhatsApp'); + break; + case 'signal': + printUnsupported('Signal'); + break; + default: + console.error(`Unknown channel: ${channel}. Supported for listing: discord, slack`); + process.exit(1); + } + return; + } + + const hasDiscord = !!discordToken; + const hasSlack = !!slackToken; + + if (!hasDiscord && !hasSlack) { + if (agentName) { + console.log(`No Discord or Slack channels configured for agent "${agentName}".`); + } else { + console.log('No supported platforms configured. Set DISCORD_BOT_TOKEN or SLACK_BOT_TOKEN.'); + } + return; + } + + if (hasDiscord) { + await listDiscord(discordToken); + } + if (hasSlack) { + if (hasDiscord) console.log(''); + await listSlack(slackToken); + } +} + +export async function listGroupsFromArgs(args: string[]): Promise { + const { channel, agent, error } = parseChannelArgs(args); + if (error) { + console.error(error); + process.exit(1); + } + await listGroups(channel, agent); +}