Files
lettabot/src/config/types.ts
Jason Carreira 1fbd6d5a2e Add send-file directive and Discord/CLI file support (#319)
Co-authored-by: Jason Carreira <jason@visotrust.com>
Co-authored-by: Cameron <cameron@pfiffer.org>
Co-authored-by: Charles Packer <packercharles@gmail.com>
Co-authored-by: Sarah Wooders <sarahwooders@gmail.com>
Co-authored-by: Letta <noreply@letta.com>
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-02-23 15:44:34 -08:00

580 lines
22 KiB
TypeScript

/**
* LettaBot Configuration Types
*
* Two modes:
* 1. Docker server: Uses baseUrl (e.g., http://localhost:8283), no API key
* 2. Letta API: Uses apiKey, optional BYOK providers
*/
export type ServerMode = 'api' | 'docker' | 'cloud' | 'selfhosted';
export type CanonicalServerMode = 'api' | 'docker';
export function canonicalizeServerMode(mode?: ServerMode): CanonicalServerMode {
return mode === 'docker' || mode === 'selfhosted' ? 'docker' : 'api';
}
export function isDockerServerMode(mode?: ServerMode): boolean {
return canonicalizeServerMode(mode) === 'docker';
}
export function isApiServerMode(mode?: ServerMode): boolean {
return canonicalizeServerMode(mode) === 'api';
}
export function serverModeLabel(mode?: ServerMode): string {
return canonicalizeServerMode(mode);
}
/**
* Display configuration for tool calls and reasoning in channel output.
*/
export interface DisplayConfig {
/** Show tool invocations in channel output (default: false) */
showToolCalls?: boolean;
/** Show agent reasoning/thinking in channel output (default: false) */
showReasoning?: boolean;
/** Truncate reasoning to N characters (default: 0 = no limit) */
reasoningMaxChars?: number;
}
/**
* Configuration for a single agent in multi-agent mode.
* Each agent has its own name, channels, and features.
*/
export interface AgentConfig {
/** Agent name (used for display, agent creation, and store keying) */
name: string;
/** Use existing agent ID (skip creation) */
id?: string;
/** Display name prefixed to outbound messages (e.g. "💜 Signo") */
displayName?: string;
/** Model for initial agent creation */
model?: string;
/** Channels this agent connects to */
channels: {
telegram?: TelegramConfig;
'telegram-mtproto'?: TelegramMTProtoConfig;
slack?: SlackConfig;
whatsapp?: WhatsAppConfig;
signal?: SignalConfig;
discord?: DiscordConfig;
};
/** Conversation routing */
conversations?: {
mode?: 'shared' | 'per-channel'; // Default: shared (single conversation across all channels)
heartbeat?: string; // "dedicated" | "last-active" | "<channel>" (default: last-active)
};
/** Features for this agent */
features?: {
cron?: boolean;
heartbeat?: {
enabled: boolean;
intervalMin?: number;
skipRecentUserMin?: number; // Skip auto-heartbeats for N minutes after user message (0 disables)
prompt?: string; // Custom heartbeat prompt (replaces default body)
promptFile?: string; // Path to prompt file (re-read each tick for live editing)
target?: string; // Delivery target ("telegram:123", "slack:C123", etc.)
};
memfs?: boolean; // Enable memory filesystem (git-backed context repository) for SDK sessions
maxToolCalls?: number;
sendFileDir?: string; // Restrict <send-file> directive to this directory (default: data/outbound)
sendFileMaxSize?: number; // Max file size in bytes for <send-file> (default: 50MB)
sendFileCleanup?: boolean; // Allow <send-file cleanup="true"> to delete after send (default: false)
display?: DisplayConfig;
};
/** Polling config */
polling?: PollingYamlConfig;
/** Integrations */
integrations?: {
google?: GoogleConfig;
};
}
export interface LettaBotConfig {
// Server connection
server: {
// Canonical values: 'api' or 'docker'
// Legacy aliases accepted for compatibility: 'cloud', 'selfhosted'
mode: ServerMode;
// Only for docker mode
baseUrl?: string;
// Only for api mode
apiKey?: string;
// API server config (port, host, CORS) — canonical location
api?: {
port?: number; // Default: 8080 (or PORT env var)
host?: string; // Default: 127.0.0.1 (secure). Use '0.0.0.0' for Docker/Railway
corsOrigin?: string; // CORS origin. Default: same-origin only
};
};
// Multi-agent configuration
agents?: AgentConfig[];
// Agent configuration
agent: {
id?: string;
name: string;
displayName?: string;
// model is configured on the Letta agent server-side, not in config
// Kept as optional for backward compat (ignored if present in existing configs)
model?: string;
};
// BYOK providers (api mode only)
providers?: ProviderConfig[];
// Channel configurations
channels: {
telegram?: TelegramConfig;
'telegram-mtproto'?: TelegramMTProtoConfig;
slack?: SlackConfig;
whatsapp?: WhatsAppConfig;
signal?: SignalConfig;
discord?: DiscordConfig;
};
// Conversation routing
conversations?: {
mode?: 'shared' | 'per-channel'; // Default: shared (single conversation across all channels)
heartbeat?: string; // "dedicated" | "last-active" | "<channel>" (default: last-active)
};
// Features
features?: {
cron?: boolean;
heartbeat?: {
enabled: boolean;
intervalMin?: number;
skipRecentUserMin?: number; // Skip auto-heartbeats for N minutes after user message (0 disables)
prompt?: string; // Custom heartbeat prompt (replaces default body)
promptFile?: string; // Path to prompt file (re-read each tick for live editing)
target?: string; // Delivery target ("telegram:123", "slack:C123", etc.)
};
inlineImages?: boolean; // Send images directly to the LLM (default: true). Set false to only send file paths.
memfs?: boolean; // Enable memory filesystem (git-backed context repository) for SDK sessions
maxToolCalls?: number; // Abort if agent calls this many tools in one turn (default: 100)
sendFileDir?: string; // Restrict <send-file> directive to this directory (default: data/outbound)
sendFileMaxSize?: number; // Max file size in bytes for <send-file> (default: 50MB)
sendFileCleanup?: boolean; // Allow <send-file cleanup="true"> to delete after send (default: false)
display?: DisplayConfig; // Show tool calls / reasoning in channel output
};
// Polling - system-level background checks (Gmail, etc.)
polling?: PollingYamlConfig;
// Integrations (Google Workspace, etc.)
// NOTE: integrations.google is a legacy path for polling config.
// Prefer the top-level `polling` section instead.
integrations?: {
google?: GoogleConfig;
};
// Transcription (voice messages)
transcription?: TranscriptionConfig;
// Attachment handling
attachments?: {
maxMB?: number;
maxAgeDays?: number;
};
// API server (health checks, CLI messaging)
/** @deprecated Use server.api instead */
api?: {
port?: number; // Default: 8080 (or PORT env var)
host?: string; // Default: 127.0.0.1 (secure). Use '0.0.0.0' for Docker/Railway
corsOrigin?: string; // CORS origin. Default: same-origin only
};
}
export interface TranscriptionConfig {
provider: 'openai' | 'mistral';
apiKey?: string; // Falls back to OPENAI_API_KEY or MISTRAL_API_KEY env var
model?: string; // Defaults to 'whisper-1' (OpenAI) or 'voxtral-mini-latest' (Mistral)
}
export interface PollingYamlConfig {
enabled?: boolean; // Master switch (default: auto-detected from sub-configs)
intervalMs?: number; // Polling interval in milliseconds (default: 60000)
gmail?: {
enabled?: boolean; // Enable Gmail polling
account?: string; // Gmail account to poll (e.g., user@example.com)
accounts?: string[]; // Multiple Gmail accounts to poll
};
}
export interface ProviderConfig {
id: string; // e.g., 'anthropic', 'openai'
name: string; // e.g., 'lc-anthropic'
type: string; // e.g., 'anthropic', 'openai'
apiKey: string;
}
export type GroupMode = 'open' | 'listen' | 'mention-only' | 'disabled';
export interface GroupConfig {
mode?: GroupMode;
/** Only process group messages from these user IDs. Omit to allow all users. */
allowedUsers?: string[];
/** Process messages from other bots instead of dropping them. Default: false. */
receiveBotMessages?: boolean;
/**
* @deprecated Use mode: "mention-only" (true) or "open" (false).
*/
requireMention?: boolean;
}
export interface TelegramConfig {
enabled: boolean;
token?: string;
dmPolicy?: 'pairing' | 'allowlist' | 'open';
allowedUsers?: string[];
groupDebounceSec?: number; // Debounce interval in seconds (default: 5, 0 = immediate)
groupPollIntervalMin?: number; // @deprecated Use groupDebounceSec instead
instantGroups?: string[]; // Group chat IDs that bypass batching
listeningGroups?: string[]; // @deprecated Use groups.<id>.mode = "listen"
mentionPatterns?: string[]; // Regex patterns for mention detection (e.g., ["@mybot"])
groups?: Record<string, GroupConfig>; // Per-group settings, "*" for defaults
}
export interface TelegramMTProtoConfig {
enabled: boolean;
phoneNumber?: string; // E.164 format: +1234567890
apiId?: number; // From my.telegram.org
apiHash?: string; // From my.telegram.org
databaseDirectory?: string; // Default: ./data/telegram-mtproto
dmPolicy?: 'pairing' | 'allowlist' | 'open';
allowedUsers?: number[]; // Telegram user IDs
groupPolicy?: 'mention' | 'reply' | 'both' | 'off';
adminChatId?: number; // Chat ID for pairing request notifications
}
export interface SlackConfig {
enabled: boolean;
appToken?: string;
botToken?: string;
dmPolicy?: 'pairing' | 'allowlist' | 'open';
allowedUsers?: string[];
groupDebounceSec?: number; // Debounce interval in seconds (default: 5, 0 = immediate)
groupPollIntervalMin?: number; // @deprecated Use groupDebounceSec instead
instantGroups?: string[]; // Channel IDs that bypass batching
listeningGroups?: string[]; // @deprecated Use groups.<id>.mode = "listen"
groups?: Record<string, GroupConfig>; // Per-channel settings, "*" for defaults
}
export interface WhatsAppConfig {
enabled: boolean;
sessionPath?: string; // Auth/session directory (default: ./data/whatsapp-session)
selfChat?: boolean;
dmPolicy?: 'pairing' | 'allowlist' | 'open';
allowedUsers?: string[];
groupPolicy?: 'open' | 'disabled' | 'allowlist';
groupAllowFrom?: string[];
mentionPatterns?: string[];
groups?: Record<string, GroupConfig>;
groupDebounceSec?: number; // Debounce interval in seconds (default: 5, 0 = immediate)
groupPollIntervalMin?: number; // @deprecated Use groupDebounceSec instead
instantGroups?: string[]; // Group JIDs that bypass batching
listeningGroups?: string[]; // @deprecated Use groups.<id>.mode = "listen"
}
export interface SignalConfig {
enabled: boolean;
phone?: string;
cliPath?: string; // Path to signal-cli binary (default: "signal-cli")
httpHost?: string; // Daemon HTTP host (default: "127.0.0.1")
httpPort?: number; // Daemon HTTP port (default: 8090)
selfChat?: boolean;
dmPolicy?: 'pairing' | 'allowlist' | 'open';
allowedUsers?: string[];
// Group gating
mentionPatterns?: string[]; // Regex patterns for mention detection (e.g., ["@bot"])
groups?: Record<string, GroupConfig>; // Per-group settings, "*" for defaults
groupDebounceSec?: number; // Debounce interval in seconds (default: 5, 0 = immediate)
groupPollIntervalMin?: number; // @deprecated Use groupDebounceSec instead
instantGroups?: string[]; // Group IDs that bypass batching
listeningGroups?: string[]; // @deprecated Use groups.<id>.mode = "listen"
}
export interface DiscordConfig {
enabled: boolean;
token?: string;
dmPolicy?: 'pairing' | 'allowlist' | 'open';
allowedUsers?: string[];
groupDebounceSec?: number; // Debounce interval in seconds (default: 5, 0 = immediate)
groupPollIntervalMin?: number; // @deprecated Use groupDebounceSec instead
instantGroups?: string[]; // Guild/server IDs or channel IDs that bypass batching
listeningGroups?: string[]; // @deprecated Use groups.<id>.mode = "listen"
groups?: Record<string, GroupConfig>; // Per-guild/channel settings, "*" for defaults
}
/**
* Telegram MTProto (user account) configuration.
* Uses TDLib for user account mode instead of Bot API.
* Cannot be used simultaneously with TelegramConfig (bot mode).
*/
export interface TelegramMTProtoConfig {
enabled: boolean;
phoneNumber?: string; // E.164 format: +1234567890
apiId?: number; // From my.telegram.org
apiHash?: string; // From my.telegram.org
databaseDirectory?: string; // Default: ./data/telegram-mtproto
dmPolicy?: 'pairing' | 'allowlist' | 'open';
allowedUsers?: number[]; // Telegram user IDs
groupPolicy?: 'mention' | 'reply' | 'both' | 'off';
adminChatId?: number; // Chat ID for pairing request notifications
groupDebounceSec?: number; // Debounce interval in seconds (default: 5, 0 = immediate)
instantGroups?: string[]; // Chat IDs that bypass batching
}
export interface GoogleAccountConfig {
account: string;
services?: string[]; // e.g., ['gmail', 'calendar', 'drive', 'contacts', 'docs', 'sheets']
}
export interface GoogleConfig {
enabled: boolean;
account?: string;
accounts?: GoogleAccountConfig[];
services?: string[]; // e.g., ['gmail', 'calendar', 'drive', 'contacts', 'docs', 'sheets']
pollIntervalSec?: number; // Polling interval in seconds (default: 60)
}
// Default config
export const DEFAULT_CONFIG: LettaBotConfig = {
server: {
mode: 'api',
},
agent: {
name: 'LettaBot',
// model is configured on the Letta agent server-side (via onboarding or `lettabot model set`)
},
channels: {},
};
type ChannelWithLegacyGroupFields = {
groups?: Record<string, GroupConfig>;
listeningGroups?: string[];
};
const warnedGroupConfigDeprecations = new Set<string>();
function warnGroupConfigDeprecation(path: string, detail: string): void {
const key = `${path}:${detail}`;
if (warnedGroupConfigDeprecations.has(key)) return;
warnedGroupConfigDeprecations.add(key);
console.warn(`[Config] WARNING: ${path} ${detail}`);
}
function normalizeLegacyGroupFields(
channel: ChannelWithLegacyGroupFields | undefined,
path: string,
): void {
if (!channel) return;
const hadOriginalGroups = !!(
channel.groups &&
typeof channel.groups === 'object' &&
Object.keys(channel.groups).length > 0
);
const groups: Record<string, GroupConfig> = channel.groups && typeof channel.groups === 'object'
? { ...channel.groups }
: {};
const modeDerivedFromRequireMention = new Set<string>();
let sawLegacyRequireMention = false;
for (const [groupId, value] of Object.entries(groups)) {
const group = value && typeof value === 'object' ? { ...value } : {};
const hasLegacyRequireMention = typeof group.requireMention === 'boolean';
if (hasLegacyRequireMention) {
sawLegacyRequireMention = true;
}
if (!group.mode && hasLegacyRequireMention) {
group.mode = group.requireMention ? 'mention-only' : 'open';
modeDerivedFromRequireMention.add(groupId);
}
if ('requireMention' in group) {
delete group.requireMention;
}
groups[groupId] = group;
}
if (sawLegacyRequireMention) {
warnGroupConfigDeprecation(
`${path}.groups.<id>.requireMention`,
'is deprecated. Use groups.<id>.mode: "mention-only" | "open" | "listen".'
);
}
const legacyListeningGroups = Array.isArray(channel.listeningGroups)
? channel.listeningGroups.map((id) => String(id).trim()).filter(Boolean)
: [];
if (legacyListeningGroups.length > 0) {
warnGroupConfigDeprecation(
`${path}.listeningGroups`,
'is deprecated. Use groups.<id>.mode: "listen".'
);
for (const id of legacyListeningGroups) {
const existing = groups[id] ? { ...groups[id] } : {};
if (!existing.mode || modeDerivedFromRequireMention.has(id)) {
existing.mode = 'listen';
} else if (existing.mode !== 'listen') {
warnGroupConfigDeprecation(
`${path}.groups.${id}.mode`,
`is "${existing.mode}" while ${path}.listeningGroups also includes "${id}". Keeping mode "${existing.mode}".`
);
}
groups[id] = existing;
}
// Legacy listeningGroups never restricted other groups.
// Add wildcard open when there was no explicit groups config.
if (!hadOriginalGroups && !groups['*']) {
groups['*'] = { mode: 'open' };
}
}
channel.groups = Object.keys(groups).length > 0 ? groups : undefined;
delete channel.listeningGroups;
}
/**
* Normalize config to multi-agent format.
*
* If the config uses legacy single-agent format (agent: + channels:),
* it's converted to an agents[] array with one entry.
* Channels with `enabled: false` are dropped during normalization.
*/
export function normalizeAgents(config: LettaBotConfig): AgentConfig[] {
const normalizeChannels = (channels?: AgentConfig['channels'], sourcePath = 'channels'): AgentConfig['channels'] => {
const normalized: AgentConfig['channels'] = {};
if (!channels) return normalized;
if (channels.telegram?.enabled !== false && channels.telegram?.token) {
const telegram = { ...channels.telegram };
normalizeLegacyGroupFields(telegram, `${sourcePath}.telegram`);
normalized.telegram = telegram;
}
// telegram-mtproto: check apiId as the key credential
if (channels['telegram-mtproto']?.enabled !== false && channels['telegram-mtproto']?.apiId) {
normalized['telegram-mtproto'] = channels['telegram-mtproto'];
}
if (channels.slack?.enabled !== false && channels.slack?.botToken && channels.slack?.appToken) {
const slack = { ...channels.slack };
normalizeLegacyGroupFields(slack, `${sourcePath}.slack`);
normalized.slack = slack;
}
// WhatsApp has no credential to check (uses QR pairing), so just check enabled
if (channels.whatsapp?.enabled) {
const whatsapp = { ...channels.whatsapp };
normalizeLegacyGroupFields(whatsapp, `${sourcePath}.whatsapp`);
normalized.whatsapp = whatsapp;
}
if (channels.signal?.enabled !== false && channels.signal?.phone) {
const signal = { ...channels.signal };
normalizeLegacyGroupFields(signal, `${sourcePath}.signal`);
normalized.signal = signal;
}
if (channels.discord?.enabled !== false && channels.discord?.token) {
const discord = { ...channels.discord };
normalizeLegacyGroupFields(discord, `${sourcePath}.discord`);
normalized.discord = discord;
}
return normalized;
};
// Multi-agent mode: normalize channels for each configured agent
if (config.agents && config.agents.length > 0) {
return config.agents.map((agent, index) => ({
...agent,
channels: normalizeChannels(agent.channels, `agents[${index}].channels`),
}));
}
// Legacy single-agent mode: normalize to agents[]
const envAgentName = process.env.LETTA_AGENT_NAME || process.env.AGENT_NAME;
const agentName = envAgentName || config.agent?.name || 'LettaBot';
const model = config.agent?.model;
const id = config.agent?.id;
// Filter out disabled/misconfigured channels
const channels = normalizeChannels(config.channels, 'channels');
// Env var fallback for container deploys without lettabot.yaml (e.g. Railway)
// Helper: parse comma-separated env var into string array (or undefined)
const parseList = (envVar?: string): string[] | undefined =>
envVar ? envVar.split(',').map(s => s.trim()).filter(Boolean) : undefined;
if (!channels.telegram && process.env.TELEGRAM_BOT_TOKEN) {
channels.telegram = {
enabled: true,
token: process.env.TELEGRAM_BOT_TOKEN,
dmPolicy: (process.env.TELEGRAM_DM_POLICY as 'pairing' | 'allowlist' | 'open') || 'pairing',
allowedUsers: parseList(process.env.TELEGRAM_ALLOWED_USERS),
};
}
// telegram-mtproto env var fallback (only if telegram bot not configured)
if (!channels.telegram && !channels['telegram-mtproto'] && process.env.TELEGRAM_API_ID && process.env.TELEGRAM_API_HASH && process.env.TELEGRAM_PHONE_NUMBER) {
channels['telegram-mtproto'] = {
enabled: true,
apiId: parseInt(process.env.TELEGRAM_API_ID, 10),
apiHash: process.env.TELEGRAM_API_HASH,
phoneNumber: process.env.TELEGRAM_PHONE_NUMBER,
databaseDirectory: process.env.TELEGRAM_MTPROTO_DB_DIR || './data/telegram-mtproto',
dmPolicy: (process.env.TELEGRAM_DM_POLICY as 'pairing' | 'allowlist' | 'open') || 'pairing',
allowedUsers: parseList(process.env.TELEGRAM_ALLOWED_USERS)?.map(s => parseInt(s, 10)).filter(n => !isNaN(n)),
groupPolicy: (process.env.TELEGRAM_GROUP_POLICY as 'mention' | 'reply' | 'both' | 'off') || 'both',
adminChatId: process.env.TELEGRAM_ADMIN_CHAT_ID ? parseInt(process.env.TELEGRAM_ADMIN_CHAT_ID, 10) : undefined,
};
}
if (!channels.slack && process.env.SLACK_BOT_TOKEN && process.env.SLACK_APP_TOKEN) {
channels.slack = {
enabled: true,
botToken: process.env.SLACK_BOT_TOKEN,
appToken: process.env.SLACK_APP_TOKEN,
dmPolicy: (process.env.SLACK_DM_POLICY as 'pairing' | 'allowlist' | 'open') || 'pairing',
allowedUsers: parseList(process.env.SLACK_ALLOWED_USERS),
};
}
if (!channels.whatsapp && process.env.WHATSAPP_ENABLED === 'true') {
channels.whatsapp = {
enabled: true,
selfChat: process.env.WHATSAPP_SELF_CHAT_MODE !== 'false',
dmPolicy: (process.env.WHATSAPP_DM_POLICY as 'pairing' | 'allowlist' | 'open') || 'pairing',
allowedUsers: parseList(process.env.WHATSAPP_ALLOWED_USERS),
};
}
if (!channels.signal && process.env.SIGNAL_PHONE_NUMBER) {
channels.signal = {
enabled: true,
phone: process.env.SIGNAL_PHONE_NUMBER,
selfChat: process.env.SIGNAL_SELF_CHAT_MODE !== 'false',
dmPolicy: (process.env.SIGNAL_DM_POLICY as 'pairing' | 'allowlist' | 'open') || 'pairing',
allowedUsers: parseList(process.env.SIGNAL_ALLOWED_USERS),
};
}
if (!channels.discord && process.env.DISCORD_BOT_TOKEN) {
channels.discord = {
enabled: true,
token: process.env.DISCORD_BOT_TOKEN,
dmPolicy: (process.env.DISCORD_DM_POLICY as 'pairing' | 'allowlist' | 'open') || 'pairing',
allowedUsers: parseList(process.env.DISCORD_ALLOWED_USERS),
};
}
return [{
name: agentName,
id,
displayName: config.agent?.displayName,
model,
channels,
conversations: config.conversations,
features: config.features,
polling: config.polling,
integrations: config.integrations,
}];
}