116 lines
6.8 KiB
C#
116 lines
6.8 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 id that may run <c>/impersonate <user_id></c> (see <see cref="Chats.Impersonation"/>); null
|
|
/// turns the command off. Also read from <c>ADMIN_USER_ID</c>.
|
|
/// </summary>
|
|
public long? AdminUserId { get; set; }
|
|
|
|
/// <summary>Each chat works in <c><WorkspaceRoot>/<chatId></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)
|
|
{
|
|
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();
|
|
}
|
|
|
|
/// <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 (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);
|
|
}
|