feat: extract group listing into shared module with --agent support (#418)

This commit is contained in:
Cameron
2026-02-27 14:46:59 -08:00
committed by GitHub
parent 35dad4dedc
commit 0832c8d032
5 changed files with 471 additions and 177 deletions

View File

@@ -195,6 +195,7 @@ Commands:
model set <handle> 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 <ch> Add a channel (telegram, slack, discord, whatsapp, signal)
channels remove <ch> 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;
}

View File

@@ -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<void> {
// Main Command Handler
// ============================================================================
export async function channelManagementCommand(subCommand?: string, channelName?: string): Promise<void> {
export async function channelManagementCommand(subCommand?: string, channelName?: string, extraArgs: string[] = []): Promise<void> {
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;

View File

@@ -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<void> {
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<void> {
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<void> {
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 <name> Platform to list: discord, slack (default: all configured)
--agent <name> 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}`);

View File

@@ -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',
});
});
});

278
src/cli/group-listing.ts Normal file
View File

@@ -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<void> {
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<void> {
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<void> {
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<void> {
const { channel, agent, error } = parseChannelArgs(args);
if (error) {
console.error(error);
process.exit(1);
}
await listGroups(channel, agent);
}