using Volcanic.AgenticCore.Telegram.Speech; using Volcanic.AgenticCore.Telegram.Triggers; namespace Volcanic.AgenticCore.Telegram; /// The Telegram configuration section. public sealed class TelegramOptions { public const string SectionName = "Telegram"; public const string DefaultModel = "claude-code/claude-opus-5-5"; /// Bot token from @BotFather. Also read from TELEGRAM_BOT_TOKEN. public string BotToken { get; set; } = ""; /// Telegram user ids allowed to talk to the bot. Required: the agent runs shell commands. public List AllowedUserIds { get; set; } = []; /// /// The Telegram user id that may run /impersonate <user_id> (see ); null /// turns the command off. Also read from ADMIN_USER_ID. /// public long? AdminUserId { get; set; } /// Each chat works in <WorkspaceRoot>/<chatId>. public string WorkspaceRoot { get; set; } = Path.Combine( Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), ".volcanic", "telegram-workspaces"); /// provider/model-id every chat uses. public string Model { get; set; } = DefaultModel; /// Minimum time between two edits of a streaming reply (Telegram rate-limits edits). public TimeSpan EditInterval { get; set; } = TimeSpan.FromSeconds(1.5); /// How often the "typing…" action is repeated while the agent runs (Telegram shows it for 5 s). public TimeSpan TypingInterval { get; set; } = TimeSpan.FromSeconds(4); /// /// How long a chat's inbox waits after a message for more before sending the batch to the agent as one request (a /// forward and its comment, an album). Zero sends on the next timer tick. /// public TimeSpan InputDebounce { get; set; } = TimeSpan.FromMilliseconds(600); /// First delay after a failed poll; doubles per consecutive failure up to . public TimeSpan ReconnectInitialDelay { get; set; } = TimeSpan.FromSeconds(1); public TimeSpan ReconnectMaxDelay { get; set; } = TimeSpan.FromSeconds(60); /// Image documents up to this size are also shown to the model inline (base64 stays under providers' 5 MB cap). public long InlineImageMaxBytes { get; set; } = 3_750_000; /// Speech to text for voice notes and audio (Telegram:Speech). public SpeechOptions Speech { get; set; } = new(); /// Group watching with Jev topic triggers (Telegram:Triggers); off without an OpenRouter key. public TriggerOptions Triggers { get; set; } = new(); /// IANA zone for chats that haven't set one with /timezone; empty = the machine's zone. Also DEFAULT_TIMEZONE. public string? DefaultTimeZone { get; set; } /// The configured default zone, or null when unset (then applies). public TimeZoneInfo? ConfiguredTimeZone() => !string.IsNullOrWhiteSpace(DefaultTimeZone) && Chats.ChatTimeZones.TryParse(DefaultTimeZone, out var zone, out _) ? zone : null; /// Reads the environment-variable shortcuts the README documents, when the section leaves them empty. public void ApplyEnvironment(Func getEnv) { if (string.IsNullOrWhiteSpace(BotToken)) BotToken = getEnv("TELEGRAM_BOT_TOKEN") ?? ""; if (AllowedUserIds.Count == 0 && getEnv("TELEGRAM_ALLOWED_USER_IDS") is { Length: > 0 } ids) { foreach (var part in ids.Split([',', ';', ' '], StringSplitOptions.RemoveEmptyEntries)) if (long.TryParse(part, out var id)) AllowedUserIds.Add(id); } if (AdminUserId is null && long.TryParse(getEnv("ADMIN_USER_ID")?.Trim(), out var admin)) AdminUserId = admin; // Speech: the environment fills only empty settings, and an empty variable counts as unset. Speech ??= new SpeechOptions(); if (string.IsNullOrWhiteSpace(Speech.ApiKey) && getEnv("OPENAI_API_KEY") is { } key && !string.IsNullOrWhiteSpace(key)) Speech.ApiKey = key.Trim(); if (string.IsNullOrWhiteSpace(Speech.Model) && getEnv("STT_MODEL") is { } model && !string.IsNullOrWhiteSpace(model)) Speech.Model = model.Trim(); Triggers ??= new TriggerOptions(); Triggers.ApplyEnvironment(getEnv); if (string.IsNullOrWhiteSpace(DefaultTimeZone) && getEnv("DEFAULT_TIMEZONE") is { } tz && !string.IsNullOrWhiteSpace(tz)) DefaultTimeZone = tz.Trim(); } /// Configuration problems that must stop the host, empty when valid. public IReadOnlyList Validate() { var errors = new List(); if (string.IsNullOrWhiteSpace(BotToken)) errors.Add("Telegram bot token is missing: set TELEGRAM_BOT_TOKEN or Telegram:BotToken."); if (AllowedUserIds.Count == 0) errors.Add("No allowed users: set TELEGRAM_ALLOWED_USER_IDS (comma separated) or Telegram:AllowedUserIds. " + "The bot runs shell commands, so it refuses to start open to everyone."); if (AdminUserId is <= 0) errors.Add("Telegram:AdminUserId (ADMIN_USER_ID) must be a positive Telegram user id."); if (string.IsNullOrWhiteSpace(WorkspaceRoot)) errors.Add("Telegram:WorkspaceRoot is empty."); if (string.IsNullOrWhiteSpace(Model) || !Model.Contains('/')) errors.Add("Telegram:Model must be 'provider/model-id'."); if (EditInterval <= TimeSpan.Zero || TypingInterval <= TimeSpan.Zero) errors.Add("Telegram intervals must be positive."); if (ReconnectInitialDelay <= TimeSpan.Zero || ReconnectMaxDelay < ReconnectInitialDelay) errors.Add("Telegram reconnect delays must be positive with max >= initial."); if (InputDebounce < TimeSpan.Zero) errors.Add("Telegram:InputDebounce must not be negative."); if (InlineImageMaxBytes <= 0) errors.Add("Telegram:InlineImageMaxBytes must be positive."); // On Linux "/v1" parses as an absolute file:// URI; the scheme check rejects it. if (!Uri.TryCreate(Speech?.BaseUrl, UriKind.Absolute, out var sttUri) || sttUri.Scheme is not ("http" or "https")) errors.Add("Telegram:Speech:BaseUrl must be an absolute http(s) URL."); if (!string.IsNullOrWhiteSpace(DefaultTimeZone) && !Chats.ChatTimeZones.TryParse(DefaultTimeZone, out _, out var zoneError)) errors.Add("Telegram:DefaultTimeZone: " + zoneError); (Triggers ?? new TriggerOptions()).Validate(errors); return errors; } public bool IsAllowed(long? userId) => userId is { } id && AllowedUserIds.Contains(id); }