forked from admin/VolcanicAgenticCore
feat(telegram): trigger options and Jev client for OpenRouter systemone
This commit is contained in:
111
docs/superpowers/plans/2026-10-05-telegram-group-triggers.md
Normal file
111
docs/superpowers/plans/2026-10-05-telegram-group-triggers.md
Normal 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`.
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
156
hosts/Volcanic.AgenticCore.Telegram/Triggers/JevClient.cs
Normal file
156
hosts/Volcanic.AgenticCore.Telegram/Triggers/JevClient.cs
Normal 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);
|
||||
}
|
||||
@@ -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.");
|
||||
}
|
||||
}
|
||||
143
tests/Volcanic.AgenticCore.Telegram.Tests/JevClientTests.cs
Normal file
143
tests/Volcanic.AgenticCore.Telegram.Tests/JevClientTests.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user