fix: CLI group settings handling and env var support (#257)

* Fix CLI group settings handling

* Document group settings in config

* fix: move group settings below channel list in README, add GROUP_DEBOUNCE_SEC env var

- README: group settings section was splitting the cross-channel bullet
  list; moved it after the channel table
- onboard.ts: groupDebounceSec had no env var override for non-interactive
  deploys; added <CHANNEL>_GROUP_DEBOUNCE_SEC for all 5 channels
- SKILL.md: documented the new env var

Written by Cameron and Letta Code

"For every complex problem there is an answer that is clear, simple, and wrong." - H.L. Mencken

---------

Co-authored-by: Jason Carreira <jason@visotrust.com>
Co-authored-by: Cameron <cameron@pfiffer.org>
This commit is contained in:
Jason Carreira
2026-02-10 17:01:39 -05:00
committed by GitHub
parent 320c1cd6a0
commit df43091d21
4 changed files with 294 additions and 14 deletions

View File

@@ -211,6 +211,20 @@ Signal ────┘
At least one channel is required. Telegram is the easiest to start with.
### Group Settings (Optional)
Configure group batching and listening mode in `lettabot.yaml`:
```yaml
channels:
slack:
groupDebounceSec: 5
instantGroups: ["C0123456789"]
listeningGroups: ["C0987654321"] # observe only, reply on mention
```
See `SKILL.md` for the full environment variable list and examples.
## Bot Commands
| Command | Description |

View File

@@ -176,6 +176,35 @@ Each channel supports three DM policies:
- **`allowlist`**: Only specified user IDs can message
- **`open`**: Anyone can message (not recommended)
## Group Settings (Optional)
Group settings apply to Telegram, Slack, Discord, WhatsApp, and Signal.
**YAML fields (per channel under `channels.<name>`):**
- `groupDebounceSec`: Debounce seconds for group batching (default: 5)
- `groupPollIntervalMin`: Deprecated (minutes)
- `instantGroups`: Group IDs that bypass batching
- `listeningGroups`: Group IDs where the bot observes and only replies when mentioned
**Environment variables (non-interactive onboarding):**
- `<CHANNEL>_GROUP_DEBOUNCE_SEC` (seconds, e.g. `5`)
- `<CHANNEL>_GROUP_POLL_INTERVAL_MIN` (deprecated, use `_GROUP_DEBOUNCE_SEC` instead)
- `<CHANNEL>_INSTANT_GROUPS` (comma-separated)
- `<CHANNEL>_LISTENING_GROUPS` (comma-separated)
Example:
```yaml
channels:
slack:
enabled: true
botToken: xoxb-...
appToken: xapp-...
groupDebounceSec: 5
instantGroups: ["C0123456789"]
listeningGroups: ["C0987654321"]
```
## Configuration File
After onboarding, config is saved to `~/.lettabot/config.yaml`:
@@ -186,16 +215,17 @@ server:
apiKey: letta_...
agentId: agent-...
telegram:
enabled: true
botToken: 123456:ABC-DEF...
dmPolicy: pairing
channels:
telegram:
enabled: true
botToken: 123456:ABC-DEF...
dmPolicy: pairing
slack:
enabled: true
botToken: xoxb-...
appToken: xapp-...
dmPolicy: pairing
slack:
enabled: true
botToken: xoxb-...
appToken: xapp-...
dmPolicy: pairing
```
Edit this file directly or re-run `lettabot onboard` to reconfigure.

View File

@@ -41,6 +41,88 @@ export function getChannelHint(id: ChannelId): string {
// Setup Functions
// ============================================================================
function parseIdList(input?: string | null): string[] | undefined {
if (!input) return undefined;
const ids = input.split(',').map(s => s.trim()).filter(Boolean);
return ids.length > 0 ? ids : undefined;
}
async function promptGroupSettings(existing?: any): Promise<{
groupDebounceSec?: number;
groupPollIntervalMin?: number;
instantGroups?: string[];
listeningGroups?: string[];
}> {
const hasExisting = existing?.groupDebounceSec !== undefined
|| existing?.groupPollIntervalMin !== undefined
|| (existing?.instantGroups && existing.instantGroups.length > 0)
|| (existing?.listeningGroups && existing.listeningGroups.length > 0);
const configure = await p.confirm({
message: 'Configure group settings?',
initialValue: hasExisting,
});
if (p.isCancel(configure)) {
p.cancel('Cancelled');
process.exit(0);
}
if (!configure) {
return {
groupDebounceSec: existing?.groupDebounceSec,
groupPollIntervalMin: existing?.groupPollIntervalMin,
instantGroups: existing?.instantGroups,
listeningGroups: existing?.listeningGroups,
};
}
const debounceRaw = await p.text({
message: 'Group debounce seconds (blank = default)',
placeholder: '5',
initialValue: existing?.groupDebounceSec !== undefined ? String(existing.groupDebounceSec) : '',
validate: (value) => {
const trimmed = value.trim();
if (!trimmed) return undefined;
const num = Number(trimmed);
if (!Number.isFinite(num) || num < 0) return 'Enter a non-negative number or leave blank';
return undefined;
},
});
if (p.isCancel(debounceRaw)) {
p.cancel('Cancelled');
process.exit(0);
}
const instantRaw = await p.text({
message: 'Instant group IDs (comma-separated, optional)',
placeholder: '123,456',
initialValue: Array.isArray(existing?.instantGroups) ? existing.instantGroups.join(',') : '',
});
if (p.isCancel(instantRaw)) {
p.cancel('Cancelled');
process.exit(0);
}
const listeningRaw = await p.text({
message: 'Listening group IDs (comma-separated, optional)',
placeholder: '123,456',
initialValue: Array.isArray(existing?.listeningGroups) ? existing.listeningGroups.join(',') : '',
});
if (p.isCancel(listeningRaw)) {
p.cancel('Cancelled');
process.exit(0);
}
const debounceValue = debounceRaw?.trim() || '';
return {
groupDebounceSec: debounceValue ? Number(debounceValue) : undefined,
groupPollIntervalMin: existing?.groupPollIntervalMin,
instantGroups: parseIdList(instantRaw),
listeningGroups: parseIdList(listeningRaw),
};
}
export async function setupTelegram(existing?: any): Promise<any> {
p.note(
'1. Message @BotFather on Telegram\n' +
@@ -90,11 +172,14 @@ export async function setupTelegram(existing?: any): Promise<any> {
}
}
const groupSettings = await promptGroupSettings(existing);
return {
enabled: true,
token: token || undefined,
dmPolicy: dmPolicy as 'pairing' | 'allowlist' | 'open',
allowedUsers,
...groupSettings,
};
}
@@ -131,11 +216,13 @@ export async function setupSlack(existing?: any): Promise<any> {
});
if (result) {
const groupSettings = await promptGroupSettings(existing);
return {
enabled: true,
appToken: result.appToken,
botToken: result.botToken,
allowedUsers: result.allowedUsers,
...groupSettings,
};
}
return { enabled: false }; // Wizard cancelled
@@ -179,12 +266,14 @@ export async function setupSlack(existing?: any): Promise<any> {
}
const allowedUsers = await stepAccessControl(existing?.allowedUsers);
const groupSettings = await promptGroupSettings(existing);
return {
enabled: true,
appToken: appToken || undefined,
botToken: botToken || undefined,
allowedUsers,
...groupSettings,
};
}
@@ -256,11 +345,14 @@ export async function setupDiscord(existing?: any): Promise<any> {
}
}
const groupSettings = await promptGroupSettings(existing);
return {
enabled: true,
token: token || undefined,
dmPolicy: dmPolicy as 'pairing' | 'allowlist' | 'open',
allowedUsers,
...groupSettings,
};
}
@@ -313,6 +405,8 @@ export async function setupWhatsApp(existing?: any): Promise<any> {
}
}
const groupSettings = await promptGroupSettings(existing);
p.log.info('Run "lettabot server" to see the QR code and complete pairing.');
return {
@@ -320,6 +414,7 @@ export async function setupWhatsApp(existing?: any): Promise<any> {
selfChat: isSelfChat,
dmPolicy,
allowedUsers,
...groupSettings,
};
}
@@ -399,12 +494,15 @@ export async function setupSignal(existing?: any): Promise<any> {
}
}
const groupSettings = await promptGroupSettings(existing);
return {
enabled: true,
phone: phone || undefined,
selfChat: isSelfChat,
dmPolicy,
allowedUsers,
...groupSettings,
};
}

View File

@@ -15,6 +15,18 @@ import { CHANNELS, getChannelHint, isSignalCliInstalled, setupTelegram, setupSla
// Non-Interactive Helpers
// ============================================================================
function parseCsvList(value?: string): string[] | undefined {
if (!value) return undefined;
const items = value.split(',').map(s => s.trim()).filter(Boolean);
return items.length > 0 ? items : undefined;
}
function parseOptionalInt(value?: string): number | undefined {
if (!value) return undefined;
const parsed = parseInt(value, 10);
return Number.isFinite(parsed) ? parsed : undefined;
}
function readConfigFromEnv(existingConfig: any): any {
return {
baseUrl: process.env.LETTA_BASE_URL || existingConfig.server?.baseUrl || 'https://api.letta.com',
@@ -27,6 +39,14 @@ function readConfigFromEnv(existingConfig: any): any {
botToken: process.env.TELEGRAM_BOT_TOKEN || existingConfig.channels?.telegram?.token,
dmPolicy: process.env.TELEGRAM_DM_POLICY || existingConfig.channels?.telegram?.dmPolicy || 'pairing',
allowedUsers: process.env.TELEGRAM_ALLOWED_USERS?.split(',').map(s => s.trim()) || existingConfig.channels?.telegram?.allowedUsers,
groupDebounceSec: parseOptionalInt(process.env.TELEGRAM_GROUP_DEBOUNCE_SEC)
?? existingConfig.channels?.telegram?.groupDebounceSec,
groupPollIntervalMin: parseOptionalInt(process.env.TELEGRAM_GROUP_POLL_INTERVAL_MIN)
?? existingConfig.channels?.telegram?.groupPollIntervalMin,
instantGroups: parseCsvList(process.env.TELEGRAM_INSTANT_GROUPS)
?? existingConfig.channels?.telegram?.instantGroups,
listeningGroups: parseCsvList(process.env.TELEGRAM_LISTENING_GROUPS)
?? existingConfig.channels?.telegram?.listeningGroups,
},
slack: {
@@ -35,6 +55,14 @@ function readConfigFromEnv(existingConfig: any): any {
appToken: process.env.SLACK_APP_TOKEN || existingConfig.channels?.slack?.appToken,
dmPolicy: process.env.SLACK_DM_POLICY || existingConfig.channels?.slack?.dmPolicy || 'pairing',
allowedUsers: process.env.SLACK_ALLOWED_USERS?.split(',').map(s => s.trim()) || existingConfig.channels?.slack?.allowedUsers,
groupDebounceSec: parseOptionalInt(process.env.SLACK_GROUP_DEBOUNCE_SEC)
?? existingConfig.channels?.slack?.groupDebounceSec,
groupPollIntervalMin: parseOptionalInt(process.env.SLACK_GROUP_POLL_INTERVAL_MIN)
?? existingConfig.channels?.slack?.groupPollIntervalMin,
instantGroups: parseCsvList(process.env.SLACK_INSTANT_GROUPS)
?? existingConfig.channels?.slack?.instantGroups,
listeningGroups: parseCsvList(process.env.SLACK_LISTENING_GROUPS)
?? existingConfig.channels?.slack?.listeningGroups,
},
discord: {
@@ -42,6 +70,14 @@ function readConfigFromEnv(existingConfig: any): any {
botToken: process.env.DISCORD_BOT_TOKEN || existingConfig.channels?.discord?.token,
dmPolicy: process.env.DISCORD_DM_POLICY || existingConfig.channels?.discord?.dmPolicy || 'pairing',
allowedUsers: process.env.DISCORD_ALLOWED_USERS?.split(',').map(s => s.trim()) || existingConfig.channels?.discord?.allowedUsers,
groupDebounceSec: parseOptionalInt(process.env.DISCORD_GROUP_DEBOUNCE_SEC)
?? existingConfig.channels?.discord?.groupDebounceSec,
groupPollIntervalMin: parseOptionalInt(process.env.DISCORD_GROUP_POLL_INTERVAL_MIN)
?? existingConfig.channels?.discord?.groupPollIntervalMin,
instantGroups: parseCsvList(process.env.DISCORD_INSTANT_GROUPS)
?? existingConfig.channels?.discord?.instantGroups,
listeningGroups: parseCsvList(process.env.DISCORD_LISTENING_GROUPS)
?? existingConfig.channels?.discord?.listeningGroups,
},
whatsapp: {
@@ -49,6 +85,14 @@ function readConfigFromEnv(existingConfig: any): any {
selfChat: process.env.WHATSAPP_SELF_CHAT_MODE !== 'false' && (existingConfig.channels?.whatsapp?.selfChat !== false),
dmPolicy: process.env.WHATSAPP_DM_POLICY || existingConfig.channels?.whatsapp?.dmPolicy || 'pairing',
allowedUsers: process.env.WHATSAPP_ALLOWED_USERS?.split(',').map(s => s.trim()) || existingConfig.channels?.whatsapp?.allowedUsers,
groupDebounceSec: parseOptionalInt(process.env.WHATSAPP_GROUP_DEBOUNCE_SEC)
?? existingConfig.channels?.whatsapp?.groupDebounceSec,
groupPollIntervalMin: parseOptionalInt(process.env.WHATSAPP_GROUP_POLL_INTERVAL_MIN)
?? existingConfig.channels?.whatsapp?.groupPollIntervalMin,
instantGroups: parseCsvList(process.env.WHATSAPP_INSTANT_GROUPS)
?? existingConfig.channels?.whatsapp?.instantGroups,
listeningGroups: parseCsvList(process.env.WHATSAPP_LISTENING_GROUPS)
?? existingConfig.channels?.whatsapp?.listeningGroups,
},
signal: {
@@ -57,6 +101,14 @@ function readConfigFromEnv(existingConfig: any): any {
selfChat: process.env.SIGNAL_SELF_CHAT_MODE !== 'false' && (existingConfig.channels?.signal?.selfChat !== false),
dmPolicy: process.env.SIGNAL_DM_POLICY || existingConfig.channels?.signal?.dmPolicy || 'pairing',
allowedUsers: process.env.SIGNAL_ALLOWED_USERS?.split(',').map(s => s.trim()) || existingConfig.channels?.signal?.allowedUsers,
groupDebounceSec: parseOptionalInt(process.env.SIGNAL_GROUP_DEBOUNCE_SEC)
?? existingConfig.channels?.signal?.groupDebounceSec,
groupPollIntervalMin: parseOptionalInt(process.env.SIGNAL_GROUP_POLL_INTERVAL_MIN)
?? existingConfig.channels?.signal?.groupPollIntervalMin,
instantGroups: parseCsvList(process.env.SIGNAL_INSTANT_GROUPS)
?? existingConfig.channels?.signal?.instantGroups,
listeningGroups: parseCsvList(process.env.SIGNAL_LISTENING_GROUPS)
?? existingConfig.channels?.signal?.listeningGroups,
},
};
}
@@ -81,6 +133,10 @@ async function saveConfigFromEnv(config: any, configPath: string): Promise<void>
token: config.telegram.botToken,
dmPolicy: config.telegram.dmPolicy,
allowedUsers: config.telegram.allowedUsers,
groupDebounceSec: config.telegram.groupDebounceSec,
groupPollIntervalMin: config.telegram.groupPollIntervalMin,
instantGroups: config.telegram.instantGroups,
listeningGroups: config.telegram.listeningGroups,
} : { enabled: false },
slack: config.slack.enabled ? {
@@ -88,6 +144,10 @@ async function saveConfigFromEnv(config: any, configPath: string): Promise<void>
botToken: config.slack.botToken,
appToken: config.slack.appToken,
allowedUsers: config.slack.allowedUsers,
groupDebounceSec: config.slack.groupDebounceSec,
groupPollIntervalMin: config.slack.groupPollIntervalMin,
instantGroups: config.slack.instantGroups,
listeningGroups: config.slack.listeningGroups,
} : { enabled: false },
discord: config.discord.enabled ? {
@@ -95,6 +155,10 @@ async function saveConfigFromEnv(config: any, configPath: string): Promise<void>
token: config.discord.botToken,
dmPolicy: config.discord.dmPolicy,
allowedUsers: config.discord.allowedUsers,
groupDebounceSec: config.discord.groupDebounceSec,
groupPollIntervalMin: config.discord.groupPollIntervalMin,
instantGroups: config.discord.instantGroups,
listeningGroups: config.discord.listeningGroups,
} : { enabled: false },
whatsapp: config.whatsapp.enabled ? {
@@ -102,6 +166,10 @@ async function saveConfigFromEnv(config: any, configPath: string): Promise<void>
selfChat: config.whatsapp.selfChat,
dmPolicy: config.whatsapp.dmPolicy,
allowedUsers: config.whatsapp.allowedUsers,
groupDebounceSec: config.whatsapp.groupDebounceSec,
groupPollIntervalMin: config.whatsapp.groupPollIntervalMin,
instantGroups: config.whatsapp.instantGroups,
listeningGroups: config.whatsapp.listeningGroups,
} : { enabled: false },
signal: config.signal.enabled ? {
@@ -110,6 +178,10 @@ async function saveConfigFromEnv(config: any, configPath: string): Promise<void>
selfChat: config.signal.selfChat,
dmPolicy: config.signal.dmPolicy,
allowedUsers: config.signal.allowedUsers,
groupDebounceSec: config.signal.groupDebounceSec,
groupPollIntervalMin: config.signal.groupPollIntervalMin,
instantGroups: config.signal.instantGroups,
listeningGroups: config.signal.listeningGroups,
} : { enabled: false },
},
features: {
@@ -147,11 +219,57 @@ interface OnboardConfig {
providers?: Array<{ id: string; name: string; apiKey: string }>;
// Channels (with access control)
telegram: { enabled: boolean; token?: string; dmPolicy?: 'pairing' | 'allowlist' | 'open'; allowedUsers?: string[] };
slack: { enabled: boolean; appToken?: string; botToken?: string; allowedUsers?: string[] };
whatsapp: { enabled: boolean; selfChat?: boolean; dmPolicy?: 'pairing' | 'allowlist' | 'open'; allowedUsers?: string[] };
signal: { enabled: boolean; phone?: string; selfChat?: boolean; dmPolicy?: 'pairing' | 'allowlist' | 'open'; allowedUsers?: string[] };
discord: { enabled: boolean; token?: string; dmPolicy?: 'pairing' | 'allowlist' | 'open'; allowedUsers?: string[] };
telegram: {
enabled: boolean;
token?: string;
dmPolicy?: 'pairing' | 'allowlist' | 'open';
allowedUsers?: string[];
groupDebounceSec?: number;
groupPollIntervalMin?: number;
instantGroups?: string[];
listeningGroups?: string[];
};
slack: {
enabled: boolean;
appToken?: string;
botToken?: string;
allowedUsers?: string[];
groupDebounceSec?: number;
groupPollIntervalMin?: number;
instantGroups?: string[];
listeningGroups?: string[];
};
whatsapp: {
enabled: boolean;
selfChat?: boolean;
dmPolicy?: 'pairing' | 'allowlist' | 'open';
allowedUsers?: string[];
groupDebounceSec?: number;
groupPollIntervalMin?: number;
instantGroups?: string[];
listeningGroups?: string[];
};
signal: {
enabled: boolean;
phone?: string;
selfChat?: boolean;
dmPolicy?: 'pairing' | 'allowlist' | 'open';
allowedUsers?: string[];
groupDebounceSec?: number;
groupPollIntervalMin?: number;
instantGroups?: string[];
listeningGroups?: string[];
};
discord: {
enabled: boolean;
token?: string;
dmPolicy?: 'pairing' | 'allowlist' | 'open';
allowedUsers?: string[];
groupDebounceSec?: number;
groupPollIntervalMin?: number;
instantGroups?: string[];
listeningGroups?: string[];
};
// Google Workspace (via gog CLI)
google: { enabled: boolean; accounts: Array<{ account: string; services: string[] }> };
@@ -1525,6 +1643,10 @@ export async function onboard(options?: { nonInteractive?: boolean }): Promise<v
token: config.telegram.token,
dmPolicy: config.telegram.dmPolicy,
allowedUsers: config.telegram.allowedUsers,
groupDebounceSec: config.telegram.groupDebounceSec,
groupPollIntervalMin: config.telegram.groupPollIntervalMin,
instantGroups: config.telegram.instantGroups,
listeningGroups: config.telegram.listeningGroups,
}
} : {}),
...(config.slack.enabled ? {
@@ -1533,6 +1655,10 @@ export async function onboard(options?: { nonInteractive?: boolean }): Promise<v
appToken: config.slack.appToken,
botToken: config.slack.botToken,
allowedUsers: config.slack.allowedUsers,
groupDebounceSec: config.slack.groupDebounceSec,
groupPollIntervalMin: config.slack.groupPollIntervalMin,
instantGroups: config.slack.instantGroups,
listeningGroups: config.slack.listeningGroups,
}
} : {}),
...(config.discord.enabled ? {
@@ -1541,6 +1667,10 @@ export async function onboard(options?: { nonInteractive?: boolean }): Promise<v
token: config.discord.token,
dmPolicy: config.discord.dmPolicy,
allowedUsers: config.discord.allowedUsers,
groupDebounceSec: config.discord.groupDebounceSec,
groupPollIntervalMin: config.discord.groupPollIntervalMin,
instantGroups: config.discord.instantGroups,
listeningGroups: config.discord.listeningGroups,
}
} : {}),
...(config.whatsapp.enabled ? {
@@ -1549,6 +1679,10 @@ export async function onboard(options?: { nonInteractive?: boolean }): Promise<v
selfChat: config.whatsapp.selfChat,
dmPolicy: config.whatsapp.dmPolicy,
allowedUsers: config.whatsapp.allowedUsers,
groupDebounceSec: config.whatsapp.groupDebounceSec,
groupPollIntervalMin: config.whatsapp.groupPollIntervalMin,
instantGroups: config.whatsapp.instantGroups,
listeningGroups: config.whatsapp.listeningGroups,
}
} : {}),
...(config.signal.enabled ? {
@@ -1558,6 +1692,10 @@ export async function onboard(options?: { nonInteractive?: boolean }): Promise<v
selfChat: config.signal.selfChat,
dmPolicy: config.signal.dmPolicy,
allowedUsers: config.signal.allowedUsers,
groupDebounceSec: config.signal.groupDebounceSec,
groupPollIntervalMin: config.signal.groupPollIntervalMin,
instantGroups: config.signal.instantGroups,
listeningGroups: config.signal.listeningGroups,
}
} : {}),
},