Files
VolcanicAgenticCore/hosts/Volcanic.AgenticCore.Telegram/TelegramOptions.cs
EugeneTes d51438e665 feat(telegram): several admins via ADMIN_USER_IDS, each impersonating independently
ADMIN_USER_IDS (Telegram:AdminUserIds) takes a comma separated list; the older
ADMIN_USER_ID / Telegram:AdminUserId still works, also takes a list and is merged.
Empty entries and duplicates are ignored, non-numeric entries fail validation.
Impersonation is now per admin: one admin per target, an impersonating admin
can't be impersonated, and every admin's chat menu lists /impersonate.
2026-10-15 14:02:59 +00:00

160 lines
9.2 KiB
C#

using Volcanic.AgenticCore.Telegram.Speech;
using Volcanic.AgenticCore.Telegram.Triggers;
namespace Volcanic.AgenticCore.Telegram;
/// <summary>The <c>Telegram</c> configuration section.</summary>
public sealed class TelegramOptions
{
public const string SectionName = "Telegram";
public const string DefaultModel = "claude-code/claude-opus-5-5";
/// <summary>Bot token from @BotFather. Also read from <c>TELEGRAM_BOT_TOKEN</c>.</summary>
public string BotToken { get; set; } = "";
/// <summary>Telegram user ids allowed to talk to the bot. Required: the agent runs shell commands.</summary>
public List<long> AllowedUserIds { get; set; } = [];
/// <summary>
/// The Telegram user ids that may run <c>/impersonate &lt;user_id&gt;</c> (see <see cref="Chats.Impersonation"/>); all
/// admins have the same rights, and none turns the command off. Also read from <c>ADMIN_USER_IDS</c> and
/// <c>ADMIN_USER_ID</c> (both take a comma separated list). See <see cref="Admins"/>.
/// </summary>
public List<long> AdminUserIds { get; set; } = [];
/// <summary>The single admin of older configurations (<c>Telegram:AdminUserId</c>); merged into <see cref="Admins"/>.</summary>
public long? AdminUserId { get; set; }
/// <summary><see cref="AdminUserId"/> and <see cref="AdminUserIds"/>, without duplicates, in that order.</summary>
public IReadOnlyList<long> Admins =>
[.. (AdminUserId is { } one ? AdminUserIds.Prepend(one) : AdminUserIds).Distinct()];
private readonly List<string> _invalidAdminIds = [];
/// <summary>Each chat works in <c>&lt;WorkspaceRoot&gt;/&lt;chatId&gt;</c>.</summary>
public string WorkspaceRoot { get; set; } = Path.Combine(
Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), ".volcanic", "telegram-workspaces");
/// <summary><c>provider/model-id</c> every chat uses.</summary>
public string Model { get; set; } = DefaultModel;
/// <summary>Minimum time between two edits of a streaming reply (Telegram rate-limits edits).</summary>
public TimeSpan EditInterval { get; set; } = TimeSpan.FromSeconds(1.5);
/// <summary>How often the "typing…" action is repeated while the agent runs (Telegram shows it for 5 s).</summary>
public TimeSpan TypingInterval { get; set; } = TimeSpan.FromSeconds(4);
/// <summary>
/// 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.
/// </summary>
public TimeSpan InputDebounce { get; set; } = TimeSpan.FromMilliseconds(600);
/// <summary>First delay after a failed poll; doubles per consecutive failure up to <see cref="ReconnectMaxDelay"/>.</summary>
public TimeSpan ReconnectInitialDelay { get; set; } = TimeSpan.FromSeconds(1);
public TimeSpan ReconnectMaxDelay { get; set; } = TimeSpan.FromSeconds(60);
/// <summary>Image documents up to this size are also shown to the model inline (base64 stays under providers' 5 MB cap).</summary>
public long InlineImageMaxBytes { get; set; } = 3_750_000;
/// <summary>Speech to text for voice notes and audio (<c>Telegram:Speech</c>).</summary>
public SpeechOptions Speech { get; set; } = new();
/// <summary>Group watching with Jev topic triggers (<c>Telegram:Triggers</c>); off without an OpenRouter key.</summary>
public TriggerOptions Triggers { get; set; } = new();
/// <summary>IANA zone for chats that haven't set one with <c>/timezone</c>; empty = the machine's zone. Also <c>DEFAULT_TIMEZONE</c>.</summary>
public string? DefaultTimeZone { get; set; }
/// <summary>The configured default zone, or null when unset (then <see cref="TimeZoneInfo.Local"/> applies).</summary>
public TimeZoneInfo? ConfiguredTimeZone() =>
!string.IsNullOrWhiteSpace(DefaultTimeZone) && Chats.ChatTimeZones.TryParse(DefaultTimeZone, out var zone, out _) ? zone : null;
/// <summary>Reads the environment-variable shortcuts the README documents, when the section leaves them empty.</summary>
public void ApplyEnvironment(Func<string, string?> getEnv)
{
if (string.IsNullOrWhiteSpace(BotToken)) BotToken = getEnv("TELEGRAM_BOT_TOKEN") ?? "";
if (AllowedUserIds.Count == 0 && getEnv("TELEGRAM_ALLOWED_USER_IDS") is { Length: > 0 } ids)
{
AllowedUserIds.AddRange(ParseUserIds(ids, out _)); // entries that aren't numbers are skipped, as always
}
// Admins: ADMIN_USER_IDS and the older ADMIN_USER_ID, both lists, merged; only when the section sets none.
if (AdminUserIds.Count == 0 && AdminUserId is null)
{
foreach (var name in (string[])["ADMIN_USER_IDS", "ADMIN_USER_ID"])
{
AdminUserIds.AddRange(ParseUserIds(getEnv(name), out var invalid).Where(id => !AdminUserIds.Contains(id)));
_invalidAdminIds.AddRange(invalid.Select(entry => $"{name}: '{entry}'"));
}
}
// 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();
if (string.IsNullOrWhiteSpace(Speech.TtsModel) && getEnv("TTS_MODEL") is { } ttsModel && !string.IsNullOrWhiteSpace(ttsModel))
Speech.TtsModel = ttsModel.Trim();
if (string.IsNullOrWhiteSpace(Speech.TtsVoice) && getEnv("TTS_VOICE") is { } ttsVoice && !string.IsNullOrWhiteSpace(ttsVoice))
Speech.TtsVoice = ttsVoice.Trim();
if (string.IsNullOrWhiteSpace(Speech.TtsProvider) && getEnv("TTS_PROVIDER") is { } ttsProvider && !string.IsNullOrWhiteSpace(ttsProvider))
Speech.TtsProvider = ttsProvider.Trim();
Speech.ElevenLabs ??= new ElevenLabsOptions();
Speech.ElevenLabs.ApplyEnvironment(getEnv);
Triggers ??= new TriggerOptions();
Triggers.ApplyEnvironment(getEnv);
if (string.IsNullOrWhiteSpace(DefaultTimeZone) && getEnv("DEFAULT_TIMEZONE") is { } tz && !string.IsNullOrWhiteSpace(tz))
DefaultTimeZone = tz.Trim();
}
/// <summary>Configuration problems that must stop the host, empty when valid.</summary>
public IReadOnlyList<string> Validate()
{
var errors = new List<string>();
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 (_invalidAdminIds.Count > 0)
errors.Add("Admin ids must be Telegram user ids separated by commas: " + string.Join(", ", _invalidAdminIds) + ".");
if (Admins.Any(id => id <= 0))
errors.Add("Telegram:AdminUserIds (ADMIN_USER_IDS / ADMIN_USER_ID) must be positive Telegram user ids.");
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.");
(Speech ?? new SpeechOptions()).Validate(errors);
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);
/// <summary>
/// A list of user ids separated by commas, semicolons or spaces: empty entries are skipped, duplicates kept once in
/// first-seen order, and entries that aren't whole numbers go to <paramref name="invalid"/>.
/// </summary>
public static List<long> ParseUserIds(string? text, out List<string> invalid)
{
var ids = new List<long>();
invalid = [];
foreach (var part in (text ?? "").Split([',', ';', ' ', '\t', '\r', '\n'], StringSplitOptions.RemoveEmptyEntries))
{
if (!long.TryParse(part, System.Globalization.NumberStyles.AllowLeadingSign, System.Globalization.CultureInfo.InvariantCulture, out var id))
invalid.Add(part);
else if (!ids.Contains(id))
ids.Add(id);
}
return ids;
}
}