From af714cc50b9ff7e7393111ecf03ee26874c0098f Mon Sep 17 00:00:00 2001 From: EugeneTes Date: Mon, 5 Oct 2026 16:15:06 +0000 Subject: [PATCH] feat(telegram): trigger options and Jev client for OpenRouter systemone --- .../2026-10-05-telegram-group-triggers.md | 111 +++++++++++++ .../TelegramOptions.cs | 7 + .../TelegramProcessEnvironment.cs | 3 +- .../Triggers/JevClient.cs | 156 ++++++++++++++++++ .../Triggers/TriggerOptions.cs | 83 ++++++++++ .../JevClientTests.cs | 143 ++++++++++++++++ 6 files changed, 502 insertions(+), 1 deletion(-) create mode 100644 docs/superpowers/plans/2026-10-05-telegram-group-triggers.md create mode 100644 hosts/Volcanic.AgenticCore.Telegram/Triggers/JevClient.cs create mode 100644 hosts/Volcanic.AgenticCore.Telegram/Triggers/TriggerOptions.cs create mode 100644 tests/Volcanic.AgenticCore.Telegram.Tests/JevClientTests.cs diff --git a/docs/superpowers/plans/2026-10-05-telegram-group-triggers.md b/docs/superpowers/plans/2026-10-05-telegram-group-triggers.md new file mode 100644 index 0000000..1e9f8ca --- /dev/null +++ b/docs/superpowers/plans/2026-10-05-telegram-group-triggers.md @@ -0,0 +1,111 @@ +# Telegram groups and topic triggers Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** The Telegram bot watches groups silently and, through per-owner topic triggers classified by Jev (OpenRouter +`/systemone`), prompts the owner's private session when a group message is relevant; triggers can be snoozed. + +**Architecture:** Everything in `hosts/Volcanic.AgenticCore.Telegram`: a `Groups/` slice (known groups, the group +message feed) and a `Triggers/` slice (options, Jev client, store, snooze queue, engine, debounce dispatcher, delivery, +tool, `/triggers`). The router splits group messages off before the allow-list. Spec: +`docs/superpowers/specs/2026-10-05-telegram-group-triggers-design.md`. + +**Tech Stack:** .NET 10, Telegram.Bot 22.10, xunit 2, `FakeTimeProvider`, the host test harness (`ChatHost`, +`FakeBotClient`, `FauxProvider`). + +Conventions for every task: `TreatWarningsAsErrors` is on for the host (use `ConfigureAwait(false)`, culture-invariant +formatting, `ArgumentNullException.ThrowIfNull` on public entry points). Run tests with +`dotnet test tests/Volcanic.AgenticCore.Telegram.Tests --filter "FullyQualifiedName~"`. Commit after each task +with a Conventional Commit scoped `telegram`. + +--- + +## File structure + +| File | Responsibility | +|---|---| +| `Triggers/TriggerOptions.cs` | `Telegram:Triggers` settings, env, validation, `Describe()` | +| `Triggers/JevClient.cs` | `IJevClient`, `JevQuestion`, `JevException`, HTTP client with retries | +| `Groups/GroupMessage.cs` | `GroupMessage`, `GroupReply` records, `From(Message)` builder, link | +| `Groups/GroupRegistry.cs` | `KnownGroup`, `groups.json` persistence, vouch / inactive / migrate | +| `Triggers/TriggerStore.cs` | `Trigger`, `WatchingTrigger`, per-owner `triggers.json`, group index | +| `Triggers/SnoozeQueue.cs` | per-trigger `snoozed/.jsonl`: append (capped), take-all, count, delete | +| `Triggers/JevStates.cs` | Jev `state` JSON for one message / a batch, chunking, batch question wrapping | +| `Triggers/TriggerPrompts.cs` | owner prompts (live, snooze), trigger list lines, durations | +| `Triggers/TriggerDispatcher.cs` | 10 s fixed-window buffering per (owner, trigger) | +| `Triggers/ITriggerDelivery.cs` + `TelegramTriggerDelivery.cs` | follow-up prompt into the owner's session | +| `Triggers/TriggerEngine.cs` | classify + log each message, snooze queueing, snooze timer and flush | +| `Groups/GroupFeed.cs` | router entry for group messages and `my_chat_member`; concurrency gate | +| `Groups/GroupMembership.cs` | `IGroupMembership` (`getChatMember`) | +| `Triggers/TriggerTool.cs` + `TriggerExtension.cs` | the `trigger` tool per private chat | +| `Triggers/TriggerServices.cs` | the bundle the registry, commands and tool take | +| `Triggers/TriggersSetup.cs` | DI registration, menu entry, help line, hosted service | +| modified | `TelegramOptions`, `TelegramProcessEnvironment`, `PollingService`, `UpdateRouter`, `ChatRegistry`, `ChatCommands`, `BotSetupService`, `Program.cs` | +| tests | `TriggerOptionsTests`, `JevClientTests`, `GroupRegistryTests`, `TriggerStoreTests`, `TriggerEngineTests`, `TriggerToolTests`, `GroupChatTests`; `FakeBotClient` answers `getChatMember`; `ChatHost` takes `triggers:` | + +## Tasks + +### Task 1: Options and secrets +- `TriggerOptions` (defaults from the spec table; `internal TimeSpan RetryDelay = 1 s`, `MaxRetryAfter = 30 s`, + `ChunkChars = 40_000`, `DigestChars = 30_000`), `IsEnabled`, `ResolvedModel`, `Describe()`, `Validate(List)`. +- `TelegramOptions.Triggers`; `ApplyEnvironment` reads `OPENROUTER_API_KEY`, `TRIGGER_MODEL`; `Validate` calls it. +- `TelegramProcessEnvironment`: the key goes to `SecretValues`. +- Tests: env fills only empty settings; validation errors for bad URL / threshold / non-positive values; secret added. + +### Task 2: Jev client +- `IJevClient.AskAsync(JsonNode state, IReadOnlyList, ct) → IReadOnlyDictionary`. +- POST `{BaseUrl}/systemone`, body `{state, model, questions:{id:{type:"noul", instructions, criteria?}}}`; criteria only + when both set. 1 + `MaxRetries` attempts on network error, timeout, 408, 429, 5xx; backoff `RetryDelay·2^(n-1)`; + `Retry-After` honoured up to `MaxRetryAfter`. `JevException.Attempts`. Missing answer = failure. Key redacted. +- Tests with a scripted `HttpMessageHandler`: request shape, parsing, 3 retries then failure (4 calls), 400 not retried, + missing answer, key never in the message. + +### Task 3: Group registry and messages +- `GroupMessage.From(Message, DateTimeOffset)`: sender `First Last (@user)` or `SenderChat.Title`; text or caption; + reply sender/text; `Link` (supergroup `t.me/c//`, username `t.me//`, else null). +- `GroupRegistry` (`/telegram/groups.json`): `Find`, `All`, `VouchAsync(chat, by)`, `RefreshAsync(chat)`, + `SetInactiveAsync(id)`, `MigrateAsync(from, to)`; corrupt file set aside like `ChatSettingsStore`. +- Tests: persistence round trip, vouch reactivates, refresh title, migrate, links. + +### Task 4: Trigger store and snooze queue +- `Trigger` record (spec fields), `TriggerStore(workspaceRoot, maxPerChat, logger, time)`: loads every + `//.volcanic/triggers/triggers.json`; `List(owner)`, `Find(owner,id)`, `Watching(groupId)`, + `AddAsync`, `UpdateAsync(owner,id,Func)`, `RemoveAsync`, `MoveGroupAsync`, `NextSnoozeEnd()`, + `SnoozeDue(now)`, `WorkspaceOf(owner)`. Ids `t`. +- `SnoozeQueue(maxPerTrigger)`: `AppendAsync → (count, dropped)`, `TakeAllAsync`, `Count`, `Delete`. +- Tests: ids, limits, reload from disk, index after move/remove, queue cap drops oldest, take-all empties. + +### Task 5: Jev states, prompts, dispatcher, delivery +- `JevStates.Single`, `JevStates.Batches(group, messages, zone, maxChars)`, `JevStates.Question(id, trigger, batch)`. +- `TriggerPrompts.Live`, `TriggerPrompts.Snoozed`, `TriggerPrompts.Line`, `TriggerPrompts.TryParseDuration`, + `TriggerPrompts.TryParseUntil`. +- `TriggerDispatcher.Add(owner, trigger, message, score)`; window opens on first hit, `Debounce` later one prompt. +- `ITriggerDelivery { DeliverAsync(owner, prompt, ct); TimeZoneFor(owner) }`, `TelegramTriggerDelivery` (header + + `FollowUp`, errors reported in the chat). +- Tests: prompt text, chunking, durations, window not reset by later hits. + +### Task 6: Engine +- `TriggerEngine.ProcessAsync(GroupMessage)`: the log line per message; snoozed triggers queue; one Jev call with + question ids `q0..qn` mapped back to (owner, trigger); matches to the dispatcher. +- Snooze: `SnoozeAsync(owner, id?, until)`, `UnsnoozeAsync(owner, id?)`, timer on `TimeProvider` (≤ 1 h), `StartAsync` + flushes overdue, `FlushAsync(owner, trigger)` (take-all, chunks, wrapped question, best score, digest file). +- Tests with a fake Jev and fake delivery: match/no match, two owners same group, failure logged, snooze queue then + expiry → one prompt, no match → silence, unsnooze, overdue at start, digest file, disabled dropped. + +### Task 7: Feed, router, polling, membership +- `GroupFeed.OnMessageAsync` / `OnMyChatMemberAsync`; `UpdateRouter` routes group messages and `MyChatMember` first + (feature off: debug log, drop). `PollingService.AllowedUpdates` += `MyChatMember`. +- `IGroupMembership` via `bot.GetChatMember`. `FakeBotClient` answers `GetChatMemberRequest` (non-members set). +- Tests (`GroupChatTests`): stranger's group message is classified, nothing sent to the group, unknown group ignored, + allowed poster vouches, `my_chat_member` vouch/leave, migration, feature off. + +### Task 8: Tool, extension, commands, wiring +- `TriggerTool` actions groups/create/list/update/delete/snooze/unsnooze; group resolution; membership and limits. +- `TriggerExtension` registered by `ChatRegistry` when `TriggerServices` present; `/triggers` in `ChatCommands`, menu + and help; `BotSetupService` privacy warning; `Program.cs` registration and startup log line; hosted service starts + the engine. +- Tests: tool actions, end-to-end in `ChatHost` (group message → owner session prompt through the faux provider). + +### Task 9: Docs +- `docs/telegram-bot.md` "Groups and triggers", `docs/docker.md` env vars, `CLAUDE.md` host summary, spec status. +- Full `dotnet build` and `dotnet test`. diff --git a/hosts/Volcanic.AgenticCore.Telegram/TelegramOptions.cs b/hosts/Volcanic.AgenticCore.Telegram/TelegramOptions.cs index 9bfb8db..90ed387 100644 --- a/hosts/Volcanic.AgenticCore.Telegram/TelegramOptions.cs +++ b/hosts/Volcanic.AgenticCore.Telegram/TelegramOptions.cs @@ -1,4 +1,5 @@ using Volcanic.AgenticCore.Telegram.Speech; +using Volcanic.AgenticCore.Telegram.Triggers; namespace Volcanic.AgenticCore.Telegram; @@ -50,6 +51,9 @@ public sealed class TelegramOptions /// 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; } @@ -75,6 +79,8 @@ public sealed class TelegramOptions 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(); } @@ -101,6 +107,7 @@ public sealed class TelegramOptions 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; } diff --git a/hosts/Volcanic.AgenticCore.Telegram/TelegramProcessEnvironment.cs b/hosts/Volcanic.AgenticCore.Telegram/TelegramProcessEnvironment.cs index 39cad52..17051a6 100644 --- a/hosts/Volcanic.AgenticCore.Telegram/TelegramProcessEnvironment.cs +++ b/hosts/Volcanic.AgenticCore.Telegram/TelegramProcessEnvironment.cs @@ -8,7 +8,7 @@ public static class TelegramProcessEnvironment { public const string AllowVariable = "PROCESS_ENV_ALLOW"; - /// Adds the section's lists, PROCESS_ENV_ALLOW, the bot token, the speech API key and the Google client secret to . + /// Adds the section's lists, PROCESS_ENV_ALLOW, the bot token, the speech and OpenRouter API keys and the Google client secret to . public static void Configure(ProcessEnvironmentOptions target, TelegramOptions telegram, ProcessEnvironmentSection section, Func getEnv, GoogleCalendarOptions? calendar = null) { @@ -23,6 +23,7 @@ public static class TelegramProcessEnvironment target.HardenHostProcess = section.HardenHostProcess; if (!string.IsNullOrWhiteSpace(telegram.BotToken)) target.SecretValues.Add(telegram.BotToken); if (!string.IsNullOrWhiteSpace(telegram.Speech?.ApiKey)) target.SecretValues.Add(telegram.Speech.ApiKey); + if (!string.IsNullOrWhiteSpace(telegram.Triggers?.ApiKey)) target.SecretValues.Add(telegram.Triggers.ApiKey); if (!string.IsNullOrWhiteSpace(calendar?.ClientSecret)) target.SecretValues.Add(calendar.ClientSecret); } diff --git a/hosts/Volcanic.AgenticCore.Telegram/Triggers/JevClient.cs b/hosts/Volcanic.AgenticCore.Telegram/Triggers/JevClient.cs new file mode 100644 index 0000000..1312908 --- /dev/null +++ b/hosts/Volcanic.AgenticCore.Telegram/Triggers/JevClient.cs @@ -0,0 +1,156 @@ +using System.Globalization; +using System.Net.Http.Headers; +using System.Text; +using System.Text.Json; +using System.Text.Json.Nodes; + +namespace Volcanic.AgenticCore.Telegram.Triggers; + +/// One yes/no ("noul") question. is ours and never reaches the model. +public sealed record JevQuestion(string Id, string Instructions, string? CriteriaTrue = null, string? CriteriaFalse = null); + +/// A Jev call that failed for good (after tries). +public sealed class JevException(string message, Exception? inner = null) : Exception(message, inner) +{ + public bool IsTransient { get; init; } + public int Attempts { get; internal set; } = 1; +} + +/// TypeSafe's System One API: typed decisions over a state. +public interface IJevClient +{ + /// Asks every question about in one call; returns each question's yes-probability by id. + Task> AskAsync(JsonNode state, IReadOnlyList questions, CancellationToken cancellationToken); +} + +/// +/// POST {BaseUrl}/systemone (TypeSafe's request shape, served by OpenRouter). Network errors, timeouts, 408, 429 +/// and 5xx are retried times with doubling backoff; a 429's Retry-After +/// is honoured up to . Error messages never contain the key. +/// +public sealed class JevClient(HttpClient http, TriggerOptions options) : IJevClient +{ + private const string Prefix = "Jev: "; + private const int MaxExcerpt = 200; + + public async Task> AskAsync(JsonNode state, IReadOnlyList questions, CancellationToken cancellationToken) + { + ArgumentNullException.ThrowIfNull(state); + ArgumentNullException.ThrowIfNull(questions); + if (questions.Count == 0) return new Dictionary(); // the API rejects an empty question set + var body = RequestBody(state, questions, options.ResolvedModel); + var attempts = 1 + Math.Max(0, options.MaxRetries); + for (var attempt = 1; ; attempt++) + { + TimeSpan? retryAfter = null; + try + { + return await AskOnceAsync(body, questions, after => retryAfter = after, cancellationToken).ConfigureAwait(false); + } + catch (JevException ex) when (ex.IsTransient && attempt < attempts) + { + var delay = retryAfter ?? options.RetryDelay * (1 << (attempt - 1)); + if (delay > TimeSpan.Zero) await Task.Delay(delay, cancellationToken).ConfigureAwait(false); + } + catch (JevException ex) + { + ex.Attempts = attempt; + throw; + } + } + } + + /// The request JSON; criteria are sent only when both sides are set. + internal static string RequestBody(JsonNode state, IReadOnlyList questions, string model) + { + var map = new JsonObject(); + foreach (var q in questions) + { + var question = new JsonObject { ["type"] = "noul", ["instructions"] = q.Instructions }; + if (!string.IsNullOrWhiteSpace(q.CriteriaTrue) && !string.IsNullOrWhiteSpace(q.CriteriaFalse)) + question["criteria"] = new JsonObject { ["true"] = q.CriteriaTrue, ["false"] = q.CriteriaFalse }; + map[q.Id] = question; + } + return new JsonObject { ["state"] = state.DeepClone(), ["model"] = model, ["questions"] = map }.ToJsonString(); + } + + // A request message can be sent only once, so every attempt builds its own. + private async Task> AskOnceAsync(string json, IReadOnlyList questions, + Action retryAfter, CancellationToken cancellationToken) + { + using var request = new HttpRequestMessage(HttpMethod.Post, options.BaseUrl.TrimEnd('/') + "/systemone") + { + Content = new StringContent(json, Encoding.UTF8, "application/json"), + }; + request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", options.ApiKey); + HttpResponseMessage response; + string body; + try + { + response = await http.SendAsync(request, cancellationToken).ConfigureAwait(false); + } + catch (OperationCanceledException ex) when (!cancellationToken.IsCancellationRequested) // HttpClient.Timeout + { + throw new JevException(Prefix + "the request timed out.", ex) { IsTransient = true }; + } + catch (HttpRequestException ex) + { + throw new JevException(Prefix + Hide(ex.Message), ex) { IsTransient = true }; + } + using var _ = response; + try + { + body = await response.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false); + } + catch (OperationCanceledException ex) when (!cancellationToken.IsCancellationRequested) + { + throw new JevException(Prefix + "the request timed out.", ex) { IsTransient = true }; + } + if (!response.IsSuccessStatusCode) + { + var status = (int)response.StatusCode; + if (status == 429 && RetryAfter(response) is { } after) retryAfter(after); + var hidden = Hide(body); // hide the key before cutting, so half a key can't leak + var excerpt = hidden.Length > MaxExcerpt ? hidden[..MaxExcerpt] : hidden; + throw new JevException($"{Prefix}HTTP {status.ToString(CultureInfo.InvariantCulture)} {excerpt}".TrimEnd()) + { + IsTransient = status is 408 or 429 or >= 500, + }; + } + return Parse(body, questions); + } + + private static Dictionary Parse(string body, IReadOnlyList questions) + { + JsonNode? root; + try + { + root = JsonNode.Parse(body); + } + catch (JsonException ex) + { + throw new JevException(Prefix + "the response is not JSON.", ex); + } + var answers = root?["answers"] as JsonObject; + var result = new Dictionary(StringComparer.Ordinal); + foreach (var q in questions) + { + if (answers?[q.Id]?["noul"] is JsonValue value && value.TryGetValue(out var noul)) + result[q.Id] = Math.Clamp(noul, 0, 1); + else + throw new JevException($"{Prefix}the response has no answer for {q.Id}."); + } + return result; + } + + private static TimeSpan? RetryAfter(HttpResponseMessage response) + { + var header = response.Headers.RetryAfter; + var delay = header?.Delta ?? (header?.Date is { } date ? date - DateTimeOffset.UtcNow : null); + if (delay is not { } d) return null; + return d < TimeSpan.Zero ? TimeSpan.Zero : d > TriggerOptions.MaxRetryAfter ? TriggerOptions.MaxRetryAfter : d; + } + + private string Hide(string text) => + string.IsNullOrEmpty(options.ApiKey) ? text : text.Replace(options.ApiKey, "[REDACTED]", StringComparison.Ordinal); +} diff --git a/hosts/Volcanic.AgenticCore.Telegram/Triggers/TriggerOptions.cs b/hosts/Volcanic.AgenticCore.Telegram/Triggers/TriggerOptions.cs new file mode 100644 index 0000000..4da8646 --- /dev/null +++ b/hosts/Volcanic.AgenticCore.Telegram/Triggers/TriggerOptions.cs @@ -0,0 +1,83 @@ +namespace Volcanic.AgenticCore.Telegram.Triggers; + +/// +/// The Telegram:Triggers section: group watching with topic triggers classified by TypeSafe's Jev through +/// OpenRouter. On exactly when is set. +/// +public sealed class TriggerOptions +{ + public const string DefaultModel = "typesafe/jev-1.13"; + public const string DefaultBaseUrl = "https://openrouter.ai/api/v1"; + + /// A snooze batch is split into Jev calls of at most this many characters of state JSON (Jev reads 32k tokens). + public const int ChunkChars = 40_000; + + /// A snooze digest longer than this goes to a file in the owner's workspace instead of the prompt. + public const int DigestChars = 30_000; + + /// A 429's Retry-After is honoured up to this. + public static readonly TimeSpan MaxRetryAfter = TimeSpan.FromSeconds(30); + + /// Falls back to OPENROUTER_API_KEY. Empty turns triggers (and group watching) off. + public string ApiKey { get; set; } = ""; + + /// Falls back to TRIGGER_MODEL, then . + public string Model { get; set; } = ""; + + /// OpenRouter's API root; requests go to {BaseUrl}/systemone. + public string BaseUrl { get; set; } = DefaultBaseUrl; + + /// The threshold of a trigger created without one. + public double DefaultThreshold { get; set; } = 0.7; + + /// Matches of one trigger within this window (opened by the first) reach the owner as one prompt. + public TimeSpan Debounce { get; set; } = TimeSpan.FromSeconds(10); + + /// Retries after the first Jev attempt (network errors, timeouts, 408, 429, 5xx). + public int MaxRetries { get; set; } = 3; + + /// Jev calls in flight at once. + public int MaxConcurrency { get; set; } = 4; + + public int MaxTriggersPerChat { get; set; } = 20; + + public TimeSpan MaxSnooze { get; set; } = TimeSpan.FromDays(30); + + /// Messages kept per snoozed trigger; past it the oldest are dropped. + public int MaxQueuedPerTrigger { get; set; } = 5000; + + /// The wait before the first retry, doubled for each next one. Internal so tests can zero it. + internal TimeSpan RetryDelay { get; set; } = TimeSpan.FromSeconds(1); + + public bool IsEnabled => !string.IsNullOrWhiteSpace(ApiKey); + + public string ResolvedModel => string.IsNullOrWhiteSpace(Model) ? DefaultModel : Model.Trim(); + + /// The startup log line (never the key). + public string Describe() => IsEnabled + ? $"Group triggers: on, {ResolvedModel} via {BaseUrl.TrimEnd('/')}/systemone" + : "Group triggers: off (no OPENROUTER_API_KEY); group messages are ignored"; + + /// The environment fills only empty settings; an empty variable counts as unset. + public void ApplyEnvironment(Func getEnv) + { + ArgumentNullException.ThrowIfNull(getEnv); + if (string.IsNullOrWhiteSpace(ApiKey) && getEnv("OPENROUTER_API_KEY") is { } key && !string.IsNullOrWhiteSpace(key)) ApiKey = key.Trim(); + if (string.IsNullOrWhiteSpace(Model) && getEnv("TRIGGER_MODEL") is { } model && !string.IsNullOrWhiteSpace(model)) Model = model.Trim(); + } + + public void Validate(List errors) + { + ArgumentNullException.ThrowIfNull(errors); + // On Linux "/v1" parses as an absolute file:// URI; the scheme check rejects it. + if (!Uri.TryCreate(BaseUrl, UriKind.Absolute, out var uri) || uri.Scheme is not ("http" or "https")) + errors.Add("Telegram:Triggers:BaseUrl must be an absolute http(s) URL."); + if (DefaultThreshold is <= 0 or >= 1) errors.Add("Telegram:Triggers:DefaultThreshold must be between 0 and 1."); + if (Debounce < TimeSpan.Zero) errors.Add("Telegram:Triggers:Debounce must not be negative."); + if (MaxRetries < 0) errors.Add("Telegram:Triggers:MaxRetries must not be negative."); + if (MaxConcurrency <= 0) errors.Add("Telegram:Triggers:MaxConcurrency must be positive."); + if (MaxTriggersPerChat <= 0) errors.Add("Telegram:Triggers:MaxTriggersPerChat must be positive."); + if (MaxSnooze <= TimeSpan.Zero) errors.Add("Telegram:Triggers:MaxSnooze must be positive."); + if (MaxQueuedPerTrigger <= 0) errors.Add("Telegram:Triggers:MaxQueuedPerTrigger must be positive."); + } +} diff --git a/tests/Volcanic.AgenticCore.Telegram.Tests/JevClientTests.cs b/tests/Volcanic.AgenticCore.Telegram.Tests/JevClientTests.cs new file mode 100644 index 0000000..3a6ada6 --- /dev/null +++ b/tests/Volcanic.AgenticCore.Telegram.Tests/JevClientTests.cs @@ -0,0 +1,143 @@ +using System.Net; +using System.Text; +using System.Text.Json.Nodes; +using Volcanic.AgenticCore.Features.Processes; +using Volcanic.AgenticCore.Telegram.Triggers; + +namespace Volcanic.AgenticCore.Telegram.Tests; + +public sealed class JevClientTests +{ + private const string Key = "sk-or-test-secret-123"; + + private sealed class ScriptedHandler(params Func[] responses) : HttpMessageHandler + { + public List<(HttpRequestMessage Request, string Body)> Calls { get; } = []; + + protected override async Task SendAsync(HttpRequestMessage request, CancellationToken cancellationToken) + { + Calls.Add((request, await request.Content!.ReadAsStringAsync(cancellationToken))); + return responses[Math.Min(Calls.Count - 1, responses.Length - 1)](); + } + } + + private static HttpResponseMessage Json(HttpStatusCode status, string body) => new(status) { Content = new StringContent(body, Encoding.UTF8, "application/json") }; + + private static Func Ok(string answers) => () => Json(HttpStatusCode.OK, $"{{\"model\":\"typesafe/jev-1.13-20260917\",\"answers\":{answers}}}"); + + private static (JevClient Client, ScriptedHandler Handler) Create(params Func[] responses) + { + var handler = new ScriptedHandler(responses); + var options = new TriggerOptions { ApiKey = Key, BaseUrl = "https://or.example/api/v1/", RetryDelay = TimeSpan.Zero }; + return (new JevClient(new HttpClient(handler), options), handler); + } + + private static readonly JevQuestion Deploys = new("q0", "Is this about deploys?", "Mentions deploys", "Small talk"); + private static readonly JevQuestion Lunch = new("q1", "Is this about lunch?"); + + [Fact] + public async Task Posts_state_model_and_noul_questions_and_reads_the_answers() + { + var (client, handler) = Create(Ok("{\"q0\":{\"type\":\"noul\",\"noul\":0.93},\"q1\":{\"type\":\"noul\",\"noul\":0.01}}")); + + var answers = await client.AskAsync(new JsonObject { ["text"] = "prod is down" }, [Deploys, Lunch], CancellationToken.None); + + Assert.Equal(0.93, answers["q0"]); + Assert.Equal(0.01, answers["q1"]); + var (request, body) = Assert.Single(handler.Calls); + Assert.Equal("https://or.example/api/v1/systemone", request.RequestUri!.ToString()); + Assert.Equal(("Bearer", Key), (request.Headers.Authorization!.Scheme, request.Headers.Authorization.Parameter)); + var json = JsonNode.Parse(body)!; + Assert.Equal("prod is down", (string?)json["state"]!["text"]); + Assert.Equal(TriggerOptions.DefaultModel, (string?)json["model"]); + Assert.Equal("noul", (string?)json["questions"]!["q0"]!["type"]); + Assert.Equal("Mentions deploys", (string?)json["questions"]!["q0"]!["criteria"]!["true"]); + Assert.Equal("Small talk", (string?)json["questions"]!["q0"]!["criteria"]!["false"]); + Assert.Null(json["questions"]!["q1"]!["criteria"]); // only when both sides are set + } + + [Fact] + public async Task No_questions_makes_no_call() + { + var (client, handler) = Create(Ok("{}")); + Assert.Empty(await client.AskAsync(JsonValue.Create("x"), [], CancellationToken.None)); + Assert.Empty(handler.Calls); + } + + [Fact] + public async Task Transient_failures_are_retried_three_times_then_fail_with_four_attempts() + { + var (client, handler) = Create(() => Json(HttpStatusCode.ServiceUnavailable, "{\"error\":\"busy\"}")); + + var ex = await Assert.ThrowsAsync(() => client.AskAsync(JsonValue.Create("x"), [Lunch], CancellationToken.None)); + + Assert.Equal(4, handler.Calls.Count); + Assert.Equal(4, ex.Attempts); + Assert.Contains("HTTP 503", ex.Message, StringComparison.Ordinal); + } + + [Fact] + public async Task A_retry_that_succeeds_returns_the_answers() + { + var (client, handler) = Create( + () => throw new HttpRequestException("connection reset"), + () => { var r = Json(HttpStatusCode.TooManyRequests, "slow down"); r.Headers.RetryAfter = new System.Net.Http.Headers.RetryConditionHeaderValue(TimeSpan.Zero); return r; }, + Ok("{\"q1\":{\"type\":\"noul\",\"noul\":0.5}}")); + + var answers = await client.AskAsync(JsonValue.Create("x"), [Lunch], CancellationToken.None); + + Assert.Equal(0.5, answers["q1"]); + Assert.Equal(3, handler.Calls.Count); + } + + [Fact] + public async Task A_client_error_is_not_retried_and_never_shows_the_key() + { + var (client, handler) = Create(() => Json(HttpStatusCode.BadRequest, $"{{\"error\":\"bad key {Key}\"}}")); + + var ex = await Assert.ThrowsAsync(() => client.AskAsync(JsonValue.Create("x"), [Lunch], CancellationToken.None)); + + Assert.Single(handler.Calls); + Assert.Equal(1, ex.Attempts); + Assert.DoesNotContain(Key, ex.Message, StringComparison.Ordinal); + Assert.Contains("[REDACTED]", ex.Message, StringComparison.Ordinal); + } + + [Fact] + public async Task A_missing_answer_is_a_failure() + { + var (client, _) = Create(Ok("{\"q0\":{\"type\":\"noul\",\"noul\":0.9}}")); + var ex = await Assert.ThrowsAsync(() => client.AskAsync(JsonValue.Create("x"), [Deploys, Lunch], CancellationToken.None)); + Assert.Contains("q1", ex.Message, StringComparison.Ordinal); + } + + [Fact] + public void Options_read_the_environment_only_when_empty_and_validate() + { + var env = new Dictionary { ["OPENROUTER_API_KEY"] = " key ", ["TRIGGER_MODEL"] = "typesafe/jev-2" }; + var options = new TelegramOptions { BotToken = "t", AllowedUserIds = [1] }; + options.ApplyEnvironment(name => env.GetValueOrDefault(name)); + Assert.True(options.Triggers.IsEnabled); + Assert.Equal("key", options.Triggers.ApiKey); + Assert.Equal("typesafe/jev-2", options.Triggers.ResolvedModel); + + var configured = new TelegramOptions { Triggers = new TriggerOptions { ApiKey = "mine" } }; + configured.ApplyEnvironment(name => env.GetValueOrDefault(name)); + Assert.Equal("mine", configured.Triggers.ApiKey); + + var bad = new TelegramOptions { BotToken = "t", AllowedUserIds = [1], Triggers = new TriggerOptions { BaseUrl = "/v1", DefaultThreshold = 1.5, MaxConcurrency = 0 } }; + var errors = bad.Validate(); + Assert.Contains(errors, e => e.Contains("Triggers:BaseUrl", StringComparison.Ordinal)); + Assert.Contains(errors, e => e.Contains("DefaultThreshold", StringComparison.Ordinal)); + Assert.Contains(errors, e => e.Contains("MaxConcurrency", StringComparison.Ordinal)); + Assert.Empty(new TelegramOptions { BotToken = "t", AllowedUserIds = [1] }.Validate()); + } + + [Fact] + public void The_key_is_a_secret_value_for_agent_processes() + { + var target = new ProcessEnvironmentOptions(); + TelegramProcessEnvironment.Configure(target, new TelegramOptions { Triggers = new TriggerOptions { ApiKey = Key } }, new ProcessEnvironmentSection(), _ => null); + Assert.Contains(Key, target.SecretValues); + } +}