feat(telegram): trigger options and Jev client for OpenRouter systemone

This commit is contained in:
EugeneTes
2026-10-05 16:15:06 +00:00
parent 7520ff56a2
commit af714cc50b
6 changed files with 502 additions and 1 deletions

View File

@@ -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~<Class>"`. 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/<id>.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<string>)`.
- `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<JevQuestion>, ct) → IReadOnlyDictionary<string,double>`.
- 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/<id-without--100>/<mid>`, username `t.me/<user>/<mid>`, else null).
- `GroupRegistry` (`<agentDir>/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
`<root>/<chatId>/.volcanic/triggers/triggers.json`; `List(owner)`, `Find(owner,id)`, `Watching(groupId)`,
`AddAsync`, `UpdateAsync(owner,id,Func<Trigger,Trigger>)`, `RemoveAsync`, `MoveGroupAsync`, `NextSnoozeEnd()`,
`SnoozeDue(now)`, `WorkspaceOf(owner)`. Ids `t<max+1>`.
- `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`.

View File

@@ -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
/// <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; }
@@ -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;
}

View File

@@ -8,7 +8,7 @@ public static class TelegramProcessEnvironment
{
public const string AllowVariable = "PROCESS_ENV_ALLOW";
/// <summary>Adds the section's lists, <c>PROCESS_ENV_ALLOW</c>, the bot token, the speech API key and the Google client secret to <paramref name="target"/>.</summary>
/// <summary>Adds the section's lists, <c>PROCESS_ENV_ALLOW</c>, the bot token, the speech and OpenRouter API keys and the Google client secret to <paramref name="target"/>.</summary>
public static void Configure(ProcessEnvironmentOptions target, TelegramOptions telegram, ProcessEnvironmentSection section, Func<string, string?> 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);
}

View File

@@ -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;
/// <summary>One yes/no ("noul") question. <see cref="Id"/> is ours and never reaches the model.</summary>
public sealed record JevQuestion(string Id, string Instructions, string? CriteriaTrue = null, string? CriteriaFalse = null);
/// <summary>A Jev call that failed for good (after <see cref="Attempts"/> tries).</summary>
public sealed class JevException(string message, Exception? inner = null) : Exception(message, inner)
{
public bool IsTransient { get; init; }
public int Attempts { get; internal set; } = 1;
}
/// <summary>TypeSafe's System One API: typed decisions over a state.</summary>
public interface IJevClient
{
/// <summary>Asks every question about <paramref name="state"/> in one call; returns each question's yes-probability by id.</summary>
Task<IReadOnlyDictionary<string, double>> AskAsync(JsonNode state, IReadOnlyList<JevQuestion> questions, CancellationToken cancellationToken);
}
/// <summary>
/// <c>POST {BaseUrl}/systemone</c> (TypeSafe's request shape, served by OpenRouter). Network errors, timeouts, 408, 429
/// and 5xx are retried <see cref="TriggerOptions.MaxRetries"/> times with doubling backoff; a 429's <c>Retry-After</c>
/// is honoured up to <see cref="TriggerOptions.MaxRetryAfter"/>. Error messages never contain the key.
/// </summary>
public sealed class JevClient(HttpClient http, TriggerOptions options) : IJevClient
{
private const string Prefix = "Jev: ";
private const int MaxExcerpt = 200;
public async Task<IReadOnlyDictionary<string, double>> AskAsync(JsonNode state, IReadOnlyList<JevQuestion> questions, CancellationToken cancellationToken)
{
ArgumentNullException.ThrowIfNull(state);
ArgumentNullException.ThrowIfNull(questions);
if (questions.Count == 0) return new Dictionary<string, double>(); // 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;
}
}
}
/// <summary>The request JSON; criteria are sent only when both sides are set.</summary>
internal static string RequestBody(JsonNode state, IReadOnlyList<JevQuestion> 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<IReadOnlyDictionary<string, double>> AskOnceAsync(string json, IReadOnlyList<JevQuestion> questions,
Action<TimeSpan> 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<string, double> Parse(string body, IReadOnlyList<JevQuestion> 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<string, double>(StringComparer.Ordinal);
foreach (var q in questions)
{
if (answers?[q.Id]?["noul"] is JsonValue value && value.TryGetValue<double>(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);
}

View File

@@ -0,0 +1,83 @@
namespace Volcanic.AgenticCore.Telegram.Triggers;
/// <summary>
/// The <c>Telegram:Triggers</c> section: group watching with topic triggers classified by TypeSafe's Jev through
/// OpenRouter. On exactly when <see cref="ApiKey"/> is set.
/// </summary>
public sealed class TriggerOptions
{
public const string DefaultModel = "typesafe/jev-1.13";
public const string DefaultBaseUrl = "https://openrouter.ai/api/v1";
/// <summary>A snooze batch is split into Jev calls of at most this many characters of state JSON (Jev reads 32k tokens).</summary>
public const int ChunkChars = 40_000;
/// <summary>A snooze digest longer than this goes to a file in the owner's workspace instead of the prompt.</summary>
public const int DigestChars = 30_000;
/// <summary>A 429's <c>Retry-After</c> is honoured up to this.</summary>
public static readonly TimeSpan MaxRetryAfter = TimeSpan.FromSeconds(30);
/// <summary>Falls back to <c>OPENROUTER_API_KEY</c>. Empty turns triggers (and group watching) off.</summary>
public string ApiKey { get; set; } = "";
/// <summary>Falls back to <c>TRIGGER_MODEL</c>, then <see cref="DefaultModel"/>.</summary>
public string Model { get; set; } = "";
/// <summary>OpenRouter's API root; requests go to <c>{BaseUrl}/systemone</c>.</summary>
public string BaseUrl { get; set; } = DefaultBaseUrl;
/// <summary>The threshold of a trigger created without one.</summary>
public double DefaultThreshold { get; set; } = 0.7;
/// <summary>Matches of one trigger within this window (opened by the first) reach the owner as one prompt.</summary>
public TimeSpan Debounce { get; set; } = TimeSpan.FromSeconds(10);
/// <summary>Retries after the first Jev attempt (network errors, timeouts, 408, 429, 5xx).</summary>
public int MaxRetries { get; set; } = 3;
/// <summary>Jev calls in flight at once.</summary>
public int MaxConcurrency { get; set; } = 4;
public int MaxTriggersPerChat { get; set; } = 20;
public TimeSpan MaxSnooze { get; set; } = TimeSpan.FromDays(30);
/// <summary>Messages kept per snoozed trigger; past it the oldest are dropped.</summary>
public int MaxQueuedPerTrigger { get; set; } = 5000;
/// <summary>The wait before the first retry, doubled for each next one. Internal so tests can zero it.</summary>
internal TimeSpan RetryDelay { get; set; } = TimeSpan.FromSeconds(1);
public bool IsEnabled => !string.IsNullOrWhiteSpace(ApiKey);
public string ResolvedModel => string.IsNullOrWhiteSpace(Model) ? DefaultModel : Model.Trim();
/// <summary>The startup log line (never the key).</summary>
public string Describe() => IsEnabled
? $"Group triggers: on, {ResolvedModel} via {BaseUrl.TrimEnd('/')}/systemone"
: "Group triggers: off (no OPENROUTER_API_KEY); group messages are ignored";
/// <summary>The environment fills only empty settings; an empty variable counts as unset.</summary>
public void ApplyEnvironment(Func<string, string?> 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<string> 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.");
}
}

View File

@@ -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<HttpResponseMessage>[] responses) : HttpMessageHandler
{
public List<(HttpRequestMessage Request, string Body)> Calls { get; } = [];
protected override async Task<HttpResponseMessage> 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<HttpResponseMessage> Ok(string answers) => () => Json(HttpStatusCode.OK, $"{{\"model\":\"typesafe/jev-1.13-20260917\",\"answers\":{answers}}}");
private static (JevClient Client, ScriptedHandler Handler) Create(params Func<HttpResponseMessage>[] 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<JevException>(() => 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<JevException>(() => 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<JevException>(() => 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<string, string> { ["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);
}
}