diff --git a/README.md b/README.md index 3390dfc..e6e6cf3 100644 --- a/README.md +++ b/README.md @@ -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 | diff --git a/SKILL.md b/SKILL.md index 693fd0c..62172dc 100644 --- a/SKILL.md +++ b/SKILL.md @@ -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.`):** +- `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):** +- `_GROUP_DEBOUNCE_SEC` (seconds, e.g. `5`) +- `_GROUP_POLL_INTERVAL_MIN` (deprecated, use `_GROUP_DEBOUNCE_SEC` instead) +- `_INSTANT_GROUPS` (comma-separated) +- `_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. diff --git a/src/channels/setup.ts b/src/channels/setup.ts index 1efc815..7f8ab52 100644 --- a/src/channels/setup.ts +++ b/src/channels/setup.ts @@ -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 { p.note( '1. Message @BotFather on Telegram\n' + @@ -90,11 +172,14 @@ export async function setupTelegram(existing?: any): Promise { } } + 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 { }); 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 { } 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 { } } + 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 { } } + 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 { selfChat: isSelfChat, dmPolicy, allowedUsers, + ...groupSettings, }; } @@ -399,12 +494,15 @@ export async function setupSignal(existing?: any): Promise { } } + const groupSettings = await promptGroupSettings(existing); + return { enabled: true, phone: phone || undefined, selfChat: isSelfChat, dmPolicy, allowedUsers, + ...groupSettings, }; } diff --git a/src/onboard.ts b/src/onboard.ts index ca83ae0..2331324 100644 --- a/src/onboard.ts +++ b/src/onboard.ts @@ -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 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 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 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 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 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