forked from admin/VolcanicAgenticCore
Merge branch 'feat/telegram-message-context'
This commit is contained in:
@@ -94,7 +94,10 @@ and `ClaudeCode` + `ClaudeCode.Bridge`. The Claude Code provider is the most int
|
||||
|
||||
Long polling (`Polling/`, with `ReconnectPolicy` backoff). Each private chat gets its own `AgentSession`
|
||||
(`Chats/ChatRegistry`), with workspace `<WorkspaceRoot>/<chatId>` and session scope `telegram-<chatId>`. Access is
|
||||
limited by `AllowedUserIds`. The first allowed id is the "owner": `OwnerLoginChannel` sends Claude Code re-login
|
||||
limited by `AllowedUserIds`. Incoming messages go through `Inbound/`: the router takes a `ChatInbox` lease per
|
||||
message in update order, `InboundComposer` builds its part (reply/forward headers from `MessageContext`, text and
|
||||
photo) in the background, and the inbox flushes a batch `InputDebounce` (600 ms) after the last message as one prompt
|
||||
with a local-time header. The first allowed id is the "owner": `OwnerLoginChannel` sends Claude Code re-login
|
||||
prompts to that chat. It resolves lazily to break the DI cycle ChatRegistry → session factory → extension → ChatRegistry.
|
||||
Answers go out complete, not streamed (`Rendering/`). Tests drive the host through `Support/FakeBotClient` and `ChatHost`.
|
||||
|
||||
|
||||
2326
docs/superpowers/plans/2026-09-26-telegram-message-context.md
Normal file
2326
docs/superpowers/plans/2026-09-26-telegram-message-context.md
Normal file
File diff suppressed because it is too large
Load Diff
@@ -1,11 +1,12 @@
|
||||
# Telegram message context, grouping and presence — design
|
||||
|
||||
Date: 2026-09-26
|
||||
Status: draft
|
||||
Status: implemented
|
||||
Depends on: `2026-09-26-telegram-attachments-design.md`, which plugs its content steps (files, speech) into
|
||||
`InboundComposer` and uses the renderer's `RunInOrderAsync` for notices and the `🎤` echo.
|
||||
Also `2026-09-26-scheduler-design.md` §7 (`ChatSettingsStore`, the chat's time zone for the timestamp header; if this spec
|
||||
lands first, the header uses `TimeZoneInfo.Local` until the store exists).
|
||||
lands first, the header uses the session's zone (`AgentSession.TimeZone`, which falls back to `TimeZoneInfo.Local`)
|
||||
through `ChatRegistry.TimeZoneFor` until the store exists).
|
||||
|
||||
## Problem
|
||||
|
||||
@@ -36,7 +37,7 @@ spec ports that behaviour. Header wording is kept identical so both bots read th
|
||||
| Topic | Decision |
|
||||
|---|---|
|
||||
| Header format | One English line per header in square brackets, before the body: reply header first, then forward header, then the message text. Same wording as the reference. The batch's local timestamp line (next row) comes before all of them. |
|
||||
| Local timestamp | Every prompt the host sends to the agent starts with one timestamp line in the chat's zone: `[Sat 2026-09-26 14:03 Europe/Berlin]` (invariant culture, 24 h, IANA id). **One per flushed batch**, not per part, before the first part's reply/forward headers. The time is the first part's `Message.Date` (when the user sent it, not when the batch flushed or a transcript finished), converted to the zone. It's cache-safe: it's in the appended user message, not in the system prompt prefix. Steering messages get it too, since they're prompts. |
|
||||
| Local timestamp | Every prompt the host sends to the agent starts with one timestamp line in the chat's zone (except a `/command` the bot doesn't handle itself, which goes to the session as typed; see *What bypasses the inbox*): `[Sat 2026-09-26 14:03 Europe/Berlin]` (invariant culture, 24 h, IANA id). **One per flushed batch**, not per part, before the first part's reply/forward headers. The time is the first part's `Message.Date` (when the user sent it, not when the batch flushed or a transcript finished), converted to the zone. It's cache-safe: it's in the appended user message, not in the system prompt prefix. Steering messages get it too, since they're prompts. |
|
||||
| Where the timestamp is built | **Host-side** (Telegram), not in the core. The core doesn't know when a user message was sent, and a headless caller may not want a header; the zone it uses is the same one the core session carries (`ChatSettingsStore.TimeZoneFor(chatId)`, which also feeds `AgentSessionOptions.TimeZone`, scheduler spec §7–§8), so prompt date, header and `bash`'s `TZ` always agree. Scheduled prompts and watcher matches get the same header from `TelegramJobDelivery` (scheduler spec §6). |
|
||||
| Reply content | `quote.text` if present (reply with quote), else the replied message's `text` → `caption` → media descriptor. Capped at **500** Unicode scalars (first 499 + `…`). Quote entities are dropped. |
|
||||
| Who was replied to | `ReplyToMessage.From.Id == bot.BotId` → "your message". Any other `From` → "your own earlier message" (private chats only, so it's the user). No `From` → "the earlier message". The bot id comes from `ITelegramBotClient.BotId` (parsed from the token), so no `getMe` is needed. |
|
||||
@@ -48,7 +49,7 @@ spec ports that behaviour. Header wording is kept identical so both bots read th
|
||||
| Race handling | The reference's pending counter, as leases. A lease is taken **synchronously in update order** while routing. The window timer is only armed when no lease is open. Parts merge in lease order, which is Telegram order, not completion order. This improves on the reference, which merges in completion order. |
|
||||
| Hung preparation | A lease open longer than **2 min** (`ChatInbox` constructor default, not user config) stops holding the batch. The batch flushes without it, and the late part becomes a prompt of its own. |
|
||||
| Slow work off the poll loop | Downloads (and the sibling's transcription) run in a background task per message, holding the lease. `RouteAsync` returns right after taking the lease, so `/stop` still gets through. Today the photo download is awaited inline. |
|
||||
| What bypasses the inbox | Bot commands, callback queries, and plain text that answers a pending `TelegramUi` input. Unchanged rules, checked before the inbox, and only for messages with no attachment: a message with an attachment is never a command or a pending answer, and its caption is prompt text (attachments spec). `/new` and `/resume` discard the buffer (open leases still finish, but their parts are dropped). |
|
||||
| What bypasses the inbox | Bot commands, callback queries, and plain text that answers a pending `TelegramUi` input. Unchanged rules, checked before the inbox, and only for messages with no attachment that weren't forwarded: a message with an attachment is never a command or a pending answer, and its caption is prompt text (attachments spec); a forwarded message is content, so a forwarded `/new …` goes to the agent with its `[Forwarded from …]` label. `/new`, `/stop` and a `/resume` that names exactly one conversation discard the buffer first, before any await, so a buffered message can't flush into the old conversation (open leases still finish, but their parts are dropped). A `/resume` whose switch an extension cancels has already dropped the buffer. A `/command` the bot doesn't handle itself goes to the session directly, without headers or the window, so extension commands (`/claude-code-doctor`, `/claude-code-login`) still parse. `AgentSession.PromptAsync` only recognises a command at the start of the text. Such a command doesn't wait for the batch in the inbox, so it can overtake messages sent just before it; that is accepted, since extension commands don't depend on them. |
|
||||
| Delivery | A flush calls `ChatContext.PromptInBackground(text, images)`, which is unchanged. While a run is active the merged prompt steers it (`StreamingBehavior.Steer`), as today. |
|
||||
| 👀 reaction | At flush time, if `session.IsStreaming`, every message in the batch gets a 👀 reaction (`setMessageReaction`), best-effort. An album has at most 10 items. It is not sent when the batch starts a new run. |
|
||||
| 🟢 Online | Once per process, after `BotSetupService`'s first successful `getMe` + `setMyCommands`, it sends `🟢 Online.` to the owner (`AllowedUserIds[0]`). Best-effort: a failure (for example 403 because the owner never started the bot) is logged and not retried. |
|
||||
@@ -147,6 +148,9 @@ Unsupported kinds never reach the composer: the router checks the message kind b
|
||||
than text, photo, document, video, audio or voice gets `I can read text, photos, files and voice messages.` (the
|
||||
sibling spec's text, replacing today's "I can read text and photos.").
|
||||
|
||||
Until the attachments spec lands, only text (or a caption) and photos are supported; anything else keeps today's reply
|
||||
`I can read text and photos.` (`InboundComposer.UnsupportedReply`) and takes no lease.
|
||||
|
||||
The sibling spec relies on this contract: **any slow work for a message happens inside `ComposeAsync`, while the
|
||||
router holds that message's lease.** A preparer never talks to `ChatInbox` itself.
|
||||
|
||||
@@ -154,11 +158,11 @@ router holds that message's lease.** A preparer never talks to `ChatInbox` itsel
|
||||
|
||||
```csharp
|
||||
public sealed class ChatInbox(Func<IReadOnlyList<InboundPart>, Task> onFlush, TimeSpan window,
|
||||
TimeProvider time, TimeSpan? maxHold = null) : IAsyncDisposable
|
||||
TimeProvider time, TimeSpan? maxHold = null, ILogger? logger = null) : IAsyncDisposable
|
||||
{
|
||||
public InboundLease Begin(int messageId); // reserves the next slot; cancels an armed timer
|
||||
public void Discard(); // /new, /resume: drop buffered and in-flight parts (generation bump)
|
||||
public Task DrainAsync(); // completes when nothing is buffered, no lease is open and the last flush was handed over
|
||||
public void Discard(); // /new, /stop, /resume: drop buffered and in-flight parts (generation bump)
|
||||
internal Task DrainAsync(); // test helper: completes when nothing is buffered, no lease is open and the last flush was handed over
|
||||
}
|
||||
|
||||
public sealed class InboundLease : IDisposable
|
||||
@@ -178,7 +182,9 @@ Rules (one lock, timer from `TimeProvider.CreateTimer`):
|
||||
4. When a slot has been open for `maxHold` (default 2 min, tracked by a second timer), it is detached. The batch is
|
||||
handled as if that slot were empty. Its later `Complete` is treated as a new `Begin` + `Complete` at the end.
|
||||
5. `Discard` bumps a generation counter and clears the slots. Leases from an older generation complete into nothing.
|
||||
6. `DisposeAsync` (from `ChatContext.DisposeAsync`) discards and logs how many parts were dropped.
|
||||
6. `DisposeAsync` (from `ChatContext.DisposeAsync`) discards and logs how many parts were dropped. The optional
|
||||
logger also records a hung lease being detached and a failing `onFlush`, which is caught so it can't stop later
|
||||
batches.
|
||||
|
||||
Race walk-through (comment, then a forwarded voice that takes 3 s to transcribe):
|
||||
|
||||
@@ -211,17 +217,19 @@ _ = Task.Run(async () =>
|
||||
```
|
||||
|
||||
The pending-input rule adds one condition: a message with `ForwardOrigin` never answers a pending question. A
|
||||
forwarded message is content, not an answer. `ChatCommands` calls `chat.Inbox.Discard()` on `/new` and a successful
|
||||
`/resume`.
|
||||
forwarded message is content, not an answer, and never a command either. `ChatCommands` calls `chat.Inbox.Discard()`
|
||||
on `/new`, `/stop` and a `/resume` that names exactly one conversation, before its first await.
|
||||
|
||||
### `ChatContext` / `ChatRegistry` changes
|
||||
|
||||
- `ChatContext` gets an `Inbox` property. `ChatRegistry.CreateAsync` builds the inbox with
|
||||
`options.InputDebounce`, an injected `TimeProvider` (default `TimeProvider.System`, registered in DI), and an
|
||||
`options.InputDebounce`, a `TimeProvider` (an optional last `ChatRegistry` constructor parameter, default
|
||||
`TimeProvider.System`; `Program.cs` registers `TimeProvider.System` with `TryAddSingleton`), and an
|
||||
`onFlush` that:
|
||||
1. renders the parts to `(text, images)`, with the timestamp header in the chat's current zone
|
||||
(`ChatSettingsStore.TimeZoneFor(chatId)`, read at flush time, so a `/timezone` change applies to the next
|
||||
batch). An image-only batch still gets the header line as its text;
|
||||
(`ChatRegistry.TimeZoneFor(chatId)`, which returns the chat session's `TimeZone` until the scheduler spec's
|
||||
`ChatSettingsStore` exists and then delegates to `ChatSettingsStore.TimeZoneFor(chatId)`; read at flush time, so
|
||||
a `/timezone` change applies to the next batch). An image-only batch still gets the header line as its text;
|
||||
2. if `session.IsStreaming`, calls `sender.ReactAsync(chatId, id, "👀")` for each part's message id, fire-and-forget;
|
||||
3. calls `chat.PromptInBackground(text, images)`.
|
||||
- The check in step 2 and the steer in step 3 can race with the end of a run. In that case the message starts a new
|
||||
@@ -301,6 +309,7 @@ signature, and a bot id taken from `BotId` when `From.IsBot` is false.
|
||||
- `Discard` with an open lease: the late completion is dropped;
|
||||
- double `Dispose` is a no-op;
|
||||
- `maxHold` detaches a hung lease, and its late part flushes as its own batch;
|
||||
- a throwing `onFlush` doesn't stop the next batch, and `DrainAsync` still completes;
|
||||
- album header collapsing in the renderer.
|
||||
|
||||
**`MessageContextChatTests`** (through `ChatHost` + `FauxProvider`, asserting on `Faux.Requests[..].Messages.Last()`):
|
||||
@@ -312,7 +321,8 @@ signature, and a bot id taken from `BotId` when `From.IsBot` is false.
|
||||
- a three-photo album with one caption is one request with three images and one forward label;
|
||||
- a message sent during a scripted long run (a `FauxTurn` tool that waits on a gate) gets a 👀
|
||||
`SetMessageReactionRequest` and appears as a steering message. A message sent while idle gets no reaction;
|
||||
- `/new` during the window drops the buffered text;
|
||||
- `/new`, `/stop` and `/resume <id>` during the window drop the buffered text;
|
||||
- a forwarded `/new …` reaches the model with its forward label and starts no new conversation;
|
||||
- a forwarded text while `TelegramUi` waits for input goes to the agent, not to the question;
|
||||
- `/stop` routed while a photo download is blocked (an `OnSend` gate on `GetFileRequest`) is handled right away,
|
||||
which proves the poll loop isn't blocked.
|
||||
|
||||
@@ -31,6 +31,7 @@ Environment variables, or the `Telegram` section of `appsettings.json` (`Telegra
|
||||
| `Telegram:WorkspaceRoot` | `~/.volcanic/telegram-workspaces` | Chat `<id>` works in `<root>/<id>`; put `AGENTS.md`, `PERSONA.md`, `MEMORY.md` there |
|
||||
| `Telegram:Model` | `claude-code/claude-opus-5-5` | Any `provider/model` the Claude Code provider serves |
|
||||
| `Telegram:EditInterval` | `00:00:01.5` | Minimum time between edits of the "Working..." status message |
|
||||
| `Telegram:InputDebounce` | `00:00:00.6` | How long the bot waits after a message for more before sending them to the agent as one request. `0` disables the wait; messages still being read together (e.g. a photo still downloading and a text after it) are still sent as one request. Must not be negative |
|
||||
| `Telegram:ReconnectInitialDelay` / `ReconnectMaxDelay` | `1 s` / `60 s` | Backoff after lost connection |
|
||||
| `PROCESS_ENV_ALLOW` | — | Comma-separated names or globs let through to `bash`, scheduled scripts and MCP servers despite the deny-list (e.g. `GH_TOKEN` for `gh`). Added to `ProcessEnvironment:Allow` |
|
||||
| `ProcessEnvironment:Deny` / `ProcessEnvironment:Allow` | built-in deny-list | Added to the defaults (`*_API_KEY`, `*_TOKEN`, `*_SECRET`, `*_PASSWORD`, their `Section__Key` forms and the named provider credentials), never replacing them. Allowing a name that is denied by name stops startup |
|
||||
@@ -53,7 +54,24 @@ Write normally. While the agent works, a status message stays at the bottom of t
|
||||
It lists the last five tool calls (`▶` running, `✓` done, `✗` failed) and is deleted when the run finishes.
|
||||
Answers are not streamed: each one is sent complete when the model finishes it (split into several messages past
|
||||
Telegram's 4096 character limit).
|
||||
A message sent while the agent works steers the current run. Photos (with or without a caption) are passed to the model.
|
||||
A message sent while the agent works steers the current run, and the bot reacts to it with 👀 so you can see it was
|
||||
added. Photos (with or without a caption) are passed to the model.
|
||||
|
||||
Along with your text, the agent sees:
|
||||
|
||||
- **Replies and quotes.** Reply to one of the bot's messages (or one of yours), or quote part of it, and the agent gets
|
||||
a line such as `[Reply to your message: "Which plant do you mean?"]` before your text.
|
||||
- **Forwards**, labelled with their source: `[Forwarded from Daily News (Anna K.)]`.
|
||||
- **The local time** you sent the message, in the chat's time zone (for now, the zone of the machine running the bot):
|
||||
`[Sat 2026-09-26 14:03 Europe/Berlin]`, once per request.
|
||||
|
||||
Messages sent within about 0.6 s of each other (`Telegram:InputDebounce`), such as a forward with your comment or
|
||||
an album, reach the agent as one request. Every message waits that long before it's sent. Commands that an extension
|
||||
handles (for example `/claude-code-doctor`) are sent at once, as typed, with no time header. `/new`, `/stop` and
|
||||
`/resume` drop a message that is still waiting. A forwarded message is never taken as a command.
|
||||
|
||||
After a start the bot sends `🟢 Online.` to the owner (the first id in `AllowedUserIds`), once. If the owner never
|
||||
started a chat with the bot, Telegram refuses the message; the bot logs that and carries on.
|
||||
|
||||
| Command | |
|
||||
|---|---|
|
||||
@@ -74,9 +92,9 @@ gets the re-login link and code prompt in their own private chat, regardless of
|
||||
it. That user must already have started a chat with the bot — Telegram bots cannot message a user first — or
|
||||
the login goes unanswered and the triggering request fails ("no code was entered"). The link, the steps and the
|
||||
code prompt arrive as one message. While that prompt is pending, the owner's next plain text message in that chat
|
||||
is taken as the code and is not sent to the agent; photos and `/commands` still behave normally. Don't ask the bot
|
||||
something else in the owner chat until the code is in: a second free-text question in that chat (for example from
|
||||
an extension) replaces the pending prompt and cancels the login. See
|
||||
is taken as the code and is not sent to the agent; photos and `/commands` still behave normally. A forwarded message
|
||||
never counts as the code. Don't ask the bot something else in the owner chat until the code is in: a second free-text
|
||||
question in that chat (for example from an extension) replaces the pending prompt and cancels the login. See
|
||||
[claude-code-provider.md](claude-code-provider.md#session-expiry-and-re-login).
|
||||
|
||||
## Security
|
||||
|
||||
@@ -2,16 +2,34 @@ using Microsoft.Extensions.Hosting;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Telegram.Bot;
|
||||
using Volcanic.AgenticCore.Telegram.Chats;
|
||||
using Volcanic.AgenticCore.Telegram.Rendering;
|
||||
|
||||
namespace Volcanic.AgenticCore.Telegram;
|
||||
|
||||
/// <summary>
|
||||
/// Best-effort start-up calls: log who the bot is and publish the command menu. Retried in the background until the
|
||||
/// first success, so a host started without network still comes up.
|
||||
/// Best-effort start-up calls: log who the bot is and publish the command menu, retried in the background until the
|
||||
/// first success so a host started without network still comes up. Then, once per process, tell the owner
|
||||
/// (<c>AllowedUserIds[0]</c>) the bot is back. That message is never retried: a 403 (the owner never started the bot)
|
||||
/// is logged by <see cref="TelegramSender"/> and that's all.
|
||||
/// </summary>
|
||||
public sealed class BotSetupService(ITelegramBotClient bot, TelegramOptions options, ILogger<BotSetupService> logger) : BackgroundService
|
||||
public sealed class BotSetupService(ITelegramBotClient bot, TelegramOptions options, TelegramSender sender, ILogger<BotSetupService> logger) : BackgroundService
|
||||
{
|
||||
public const string OnlineText = "🟢 Online.";
|
||||
|
||||
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
|
||||
{
|
||||
if (!await SetUpAsync(stoppingToken).ConfigureAwait(false) || options.AllowedUserIds.Count == 0) return;
|
||||
try
|
||||
{
|
||||
await sender.SendTextAsync(options.AllowedUserIds[0], OnlineText, cancellationToken: stoppingToken).ConfigureAwait(false);
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>True once <c>getMe</c> and <c>setMyCommands</c> succeeded; false when stopped first.</summary>
|
||||
private async Task<bool> SetUpAsync(CancellationToken stoppingToken)
|
||||
{
|
||||
var delay = options.ReconnectInitialDelay;
|
||||
while (!stoppingToken.IsCancellationRequested)
|
||||
@@ -21,11 +39,11 @@ public sealed class BotSetupService(ITelegramBotClient bot, TelegramOptions opti
|
||||
var me = await bot.GetMe(stoppingToken).ConfigureAwait(false);
|
||||
await bot.SetMyCommands(ChatCommands.Menu, cancellationToken: stoppingToken).ConfigureAwait(false);
|
||||
logger.LogInformation("Bot @{Username} ({Id}) is ready", me.Username, me.Id);
|
||||
return;
|
||||
return true;
|
||||
}
|
||||
catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested)
|
||||
{
|
||||
return;
|
||||
return false;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
@@ -37,9 +55,10 @@ public sealed class BotSetupService(ITelegramBotClient bot, TelegramOptions opti
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
return;
|
||||
return false;
|
||||
}
|
||||
delay = TimeSpan.FromTicks(Math.Min(delay.Ticks * 2, options.ReconnectMaxDelay.Ticks));
|
||||
}
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -46,6 +46,7 @@ public sealed class ChatCommands(TelegramSender sender)
|
||||
return true;
|
||||
|
||||
case "stop":
|
||||
chat.Inbox.Discard(); // what is still waiting in the window would start the run the user just stopped
|
||||
if (!session.IsStreaming && !session.IsCompacting)
|
||||
{
|
||||
await ReplyAsync(chat, "Nothing is running.", cancellationToken).ConfigureAwait(false);
|
||||
@@ -55,6 +56,7 @@ public sealed class ChatCommands(TelegramSender sender)
|
||||
return true; // the renderer reports "Stopped."
|
||||
|
||||
case "new":
|
||||
chat.Inbox.Discard(); // drop what the user typed before /new, before a buffered message can flush
|
||||
if (session.IsStreaming) await session.AbortAsync().ConfigureAwait(false);
|
||||
var created = await session.NewSessionAsync(cancellationToken: cancellationToken).ConfigureAwait(false);
|
||||
await ReplyAsync(chat, created.Cancelled ? "An extension cancelled the new session." : "🆕 New conversation.", cancellationToken).ConfigureAwait(false);
|
||||
@@ -109,6 +111,8 @@ public sealed class ChatCommands(TelegramSender sender)
|
||||
|
||||
While I am working, a new message steers the current run.
|
||||
|
||||
Reply to one of my messages, or quote part of it, to point at what you mean.
|
||||
|
||||
/new – start a new conversation
|
||||
/stop – stop the current run
|
||||
/status – model, session and context usage
|
||||
@@ -153,6 +157,7 @@ public sealed class ChatCommands(TelegramSender sender)
|
||||
var sessions = await chat.Session.ListSessionsAsync(cancellationToken).ConfigureAwait(false);
|
||||
var matches = sessions.Where(s => s.SessionId.StartsWith(id, StringComparison.OrdinalIgnoreCase)).ToList();
|
||||
if (matches.Count != 1) return matches.Count == 0 ? $"No conversation `{id}`." : $"`{id}` matches several conversations; use more characters.";
|
||||
chat.Inbox.Discard(); // what was typed before /resume belongs to neither conversation; drop it before it can flush
|
||||
if (chat.Session.IsStreaming) await chat.Session.AbortAsync().ConfigureAwait(false);
|
||||
var result = await chat.Session.SwitchSessionAsync(matches[0].SessionId, cancellationToken).ConfigureAwait(false);
|
||||
return result.Cancelled ? "An extension cancelled the switch." : $"Resumed `{matches[0].SessionId}` ({matches[0].MessageCount} messages).";
|
||||
|
||||
@@ -6,12 +6,13 @@ using Volcanic.AgenticCore.Abstractions.Messages;
|
||||
using Volcanic.AgenticCore.Abstractions.Ui;
|
||||
using Volcanic.AgenticCore.Features.Agent;
|
||||
using Volcanic.AgenticCore.Hosting;
|
||||
using Volcanic.AgenticCore.Telegram.Inbound;
|
||||
using Volcanic.AgenticCore.Telegram.Rendering;
|
||||
|
||||
namespace Volcanic.AgenticCore.Telegram.Chats;
|
||||
|
||||
/// <summary>Everything that belongs to one Telegram chat: its agent session, renderer and dialog surface.</summary>
|
||||
public sealed class ChatContext(long chatId, AgentSession session, TelegramUi ui, ChatRenderer renderer, IDisposable subscription, ILogger logger)
|
||||
public sealed class ChatContext(long chatId, AgentSession session, TelegramUi ui, ChatRenderer renderer, IDisposable subscription, ChatInbox inbox, ILogger logger)
|
||||
: IAsyncDisposable
|
||||
{
|
||||
public long ChatId => chatId;
|
||||
@@ -19,6 +20,9 @@ public sealed class ChatContext(long chatId, AgentSession session, TelegramUi ui
|
||||
public TelegramUi Ui => ui;
|
||||
public ChatRenderer Renderer => renderer;
|
||||
|
||||
/// <summary>Groups this chat's incoming messages into prompts (see <see cref="ChatInbox"/>).</summary>
|
||||
public ChatInbox Inbox => inbox;
|
||||
|
||||
/// <summary>The most recent background prompt; completes when that run has settled.</summary>
|
||||
public Task LastPrompt { get; private set; } = Task.CompletedTask;
|
||||
|
||||
@@ -44,6 +48,7 @@ public sealed class ChatContext(long chatId, AgentSession session, TelegramUi ui
|
||||
|
||||
public async ValueTask DisposeAsync()
|
||||
{
|
||||
await inbox.DisposeAsync().ConfigureAwait(false);
|
||||
subscription.Dispose();
|
||||
await session.DisposeAsync().ConfigureAwait(false);
|
||||
await renderer.DisposeAsync().ConfigureAwait(false);
|
||||
@@ -54,11 +59,16 @@ public sealed class ChatContext(long chatId, AgentSession session, TelegramUi ui
|
||||
/// One <see cref="ChatContext"/> per chat, created on first use. Each chat works in <c><WorkspaceRoot>/<chatId></c>,
|
||||
/// stores its sessions under scope <c>telegram-<chatId></c> and resumes the most recent one after a restart.
|
||||
/// </summary>
|
||||
public sealed class ChatRegistry(IAgentSessionFactory factory, TelegramSender sender, TelegramOptions options, ILoggerFactory loggerFactory)
|
||||
: IAsyncDisposable
|
||||
public sealed class ChatRegistry(
|
||||
IAgentSessionFactory factory,
|
||||
TelegramSender sender,
|
||||
TelegramOptions options,
|
||||
ILoggerFactory loggerFactory,
|
||||
TimeProvider? time = null) : IAsyncDisposable
|
||||
{
|
||||
private readonly ConcurrentDictionary<long, Lazy<Task<ChatContext>>> _chats = new();
|
||||
private readonly ILogger _logger = loggerFactory.CreateLogger<ChatRegistry>();
|
||||
private readonly TimeProvider _time = time ?? TimeProvider.System;
|
||||
|
||||
public static string ScopeFor(long chatId) => $"telegram-{chatId}";
|
||||
|
||||
@@ -113,7 +123,30 @@ public sealed class ChatRegistry(IAgentSessionFactory factory, TelegramSender se
|
||||
var subscription = session.Subscribe(renderer.Post);
|
||||
_logger.LogInformation("Chat {ChatId}: {Action} session {SessionId} in {Cwd}", chatId, last is null ? "new" : "resumed", session.SessionId, cwd);
|
||||
foreach (var error in session.ExtensionErrors) _logger.LogWarning("Chat {ChatId}: extension error {Source}: {Error}", chatId, error.Source, error.Error);
|
||||
return new ChatContext(chatId, session, ui, renderer, subscription, logger);
|
||||
ChatContext? context = null;
|
||||
var inbox = new ChatInbox(parts => FlushAsync(context!, parts), options.InputDebounce, _time,
|
||||
logger: loggerFactory.CreateLogger<ChatInbox>());
|
||||
context = new ChatContext(chatId, session, ui, renderer, subscription, inbox, logger);
|
||||
return context; // no lease can be taken before this returns, so the flush always sees the context
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The zone for a chat's timestamp headers: the chat session's zone, else the machine's. This is the one place that
|
||||
/// switches to the per-chat setting (<c>ChatSettingsStore.TimeZoneFor</c>, scheduler spec) when that lands.
|
||||
/// Read at flush time, so a zone change applies to the next batch.
|
||||
/// </summary>
|
||||
public TimeZoneInfo TimeZoneFor(long chatId) => Find(chatId)?.Session.TimeZone ?? TimeZoneInfo.Local;
|
||||
|
||||
/// <summary>A flushed batch becomes one prompt; while a run is active it steers it and each message gets a 👀 reaction.</summary>
|
||||
internal Task FlushAsync(ChatContext chat, IReadOnlyList<InboundPart> parts)
|
||||
{
|
||||
var (text, images) = InboundBatch.Render(parts, TimeZoneFor(chat.ChatId));
|
||||
// Tell the user their message joined the running request (best-effort, fire-and-forget). If the run ends between
|
||||
// this check and the steer, the message starts a new run and still shows 👀: harmless.
|
||||
if (chat.Session.IsStreaming)
|
||||
foreach (var part in parts) _ = sender.ReactAsync(chat.ChatId, part.MessageId, "👀");
|
||||
_ = chat.PromptInBackground(text, images.Count > 0 ? images : null);
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
public async ValueTask DisposeAsync()
|
||||
|
||||
@@ -1,8 +1,7 @@
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Telegram.Bot;
|
||||
using Telegram.Bot.Types;
|
||||
using Telegram.Bot.Types.Enums;
|
||||
using ImageContent = Volcanic.AgenticCore.Abstractions.Messages.ImageContent;
|
||||
using Volcanic.AgenticCore.Telegram.Inbound;
|
||||
using Volcanic.AgenticCore.Telegram.Polling;
|
||||
using Volcanic.AgenticCore.Telegram.Rendering;
|
||||
|
||||
@@ -10,13 +9,16 @@ namespace Volcanic.AgenticCore.Telegram.Chats;
|
||||
|
||||
/// <summary>
|
||||
/// Routes an update to its chat: only allow-listed users in private chats get through; button presses and answers go to
|
||||
/// the chat's pending dialog, bot commands run directly, everything else becomes a prompt that runs in the background.
|
||||
/// the chat's pending dialog, bot commands run directly, everything else goes through the chat's <see cref="ChatInbox"/>:
|
||||
/// the router takes a lease in update order, composes the message in the background, and the inbox turns the batch into
|
||||
/// a prompt that runs in the background.
|
||||
/// </summary>
|
||||
public sealed class UpdateRouter(
|
||||
TelegramOptions options,
|
||||
ChatRegistry chats,
|
||||
ChatCommands commands,
|
||||
TelegramSender sender,
|
||||
InboundComposer composer,
|
||||
ILogger<UpdateRouter> logger) : IUpdateRouter
|
||||
{
|
||||
public async Task RouteAsync(Update update, CancellationToken cancellationToken)
|
||||
@@ -58,39 +60,48 @@ public sealed class UpdateRouter(
|
||||
return;
|
||||
}
|
||||
|
||||
var text = message.Text ?? message.Caption ?? "";
|
||||
if (text.Length == 0 && message.Photo is not { Length: > 0 })
|
||||
if (!InboundComposer.IsSupported(message))
|
||||
{
|
||||
await sender.SendTextAsync(message.Chat.Id, "I can read text and photos.", cancellationToken: cancellationToken).ConfigureAwait(false);
|
||||
await sender.SendTextAsync(message.Chat.Id, InboundComposer.UnsupportedReply, cancellationToken: cancellationToken).ConfigureAwait(false);
|
||||
return;
|
||||
}
|
||||
|
||||
var chat = await chats.GetAsync(message.Chat.Id, cancellationToken).ConfigureAwait(false);
|
||||
var isCommand = ChatCommands.Parse(text) is not null;
|
||||
// Only plain text answers a pending question: a photo (with or without a caption) is a prompt of its own.
|
||||
if (!isCommand && text.Length > 0 && message.Photo is null && chat.Ui.IsWaitingForInput && chat.Ui.TryResolveInput(text)) return;
|
||||
if (isCommand && await commands.TryHandleAsync(chat, text, cancellationToken).ConfigureAwait(false)) return;
|
||||
|
||||
var images = await DownloadPhotoAsync(chat, message, cancellationToken).ConfigureAwait(false);
|
||||
if (text.Length == 0 && images is null) return; // photo download failed and already reported
|
||||
_ = chat.PromptInBackground(text, images);
|
||||
}
|
||||
|
||||
private async Task<IReadOnlyList<ImageContent>?> DownloadPhotoAsync(ChatContext chat, Message message, CancellationToken cancellationToken)
|
||||
{
|
||||
if (message.Photo is not { Length: > 0 } sizes) return null;
|
||||
try
|
||||
var text = message.Text ?? message.Caption ?? "";
|
||||
// Commands and pending answers are checked before the inbox, and only for messages without an attachment that
|
||||
// weren't forwarded: a forwarded message is content, never a command or an answer.
|
||||
if (!InboundComposer.HasAttachment(message) && message.ForwardOrigin is null)
|
||||
{
|
||||
var largest = sizes.MaxBy(p => p.FileSize ?? (long)p.Width * p.Height)!;
|
||||
using var stream = new MemoryStream();
|
||||
await sender.Bot.GetInfoAndDownloadFile(largest.FileId, stream, cancellationToken).ConfigureAwait(false);
|
||||
return [new ImageContent(Convert.ToBase64String(stream.ToArray()), "image/jpeg")];
|
||||
if (ChatCommands.Parse(text) is not null)
|
||||
{
|
||||
if (await commands.TryHandleAsync(chat, text, cancellationToken).ConfigureAwait(false)) return;
|
||||
// Not one of ours: straight to the session, without headers or the window, so extension commands still parse.
|
||||
_ = chat.PromptInBackground(text);
|
||||
return;
|
||||
}
|
||||
if (chat.Ui.IsWaitingForInput && chat.Ui.TryResolveInput(text)) return;
|
||||
}
|
||||
catch (Exception ex) when (ex is not OperationCanceledException)
|
||||
|
||||
// Take the lease now, in update order; compose (downloads) in the background so polling isn't blocked.
|
||||
var lease = chat.Inbox.Begin(message.Id);
|
||||
_ = Task.Run(async () =>
|
||||
{
|
||||
logger.LogWarning(ex, "Downloading a photo in chat {ChatId} failed", chat.ChatId);
|
||||
chat.Report("I could not download that photo.");
|
||||
return null;
|
||||
}
|
||||
using (lease)
|
||||
{
|
||||
try
|
||||
{
|
||||
lease.Complete(await composer.ComposeAsync(chat, message, cancellationToken).ConfigureAwait(false));
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
// shutting down: the lease completes empty
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
logger.LogWarning(ex, "Reading message {MessageId} in chat {ChatId} failed", message.Id, chat.ChatId);
|
||||
chat.Report("I could not read that message.");
|
||||
}
|
||||
}
|
||||
}, CancellationToken.None);
|
||||
}
|
||||
}
|
||||
|
||||
240
hosts/Volcanic.AgenticCore.Telegram/Inbound/ChatInbox.cs
Normal file
240
hosts/Volcanic.AgenticCore.Telegram/Inbound/ChatInbox.cs
Normal file
@@ -0,0 +1,240 @@
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
|
||||
namespace Volcanic.AgenticCore.Telegram.Inbound;
|
||||
|
||||
/// <summary>
|
||||
/// Groups one chat's messages into batches. The router takes a lease per message, synchronously and in update order, and
|
||||
/// completes it with the message's <see cref="InboundPart"/> once composed. A batch is flushed <c>window</c> after the
|
||||
/// last completion, and only while no lease is open. Parts keep lease order (Telegram order), not completion order.
|
||||
/// A lease open longer than <c>maxHold</c> stops holding the batch; its late part becomes a batch of its own.
|
||||
/// </summary>
|
||||
public sealed class ChatInbox : IAsyncDisposable
|
||||
{
|
||||
/// <summary>How long an open lease may hold a batch before it is detached.</summary>
|
||||
public static readonly TimeSpan DefaultMaxHold = TimeSpan.FromMinutes(2);
|
||||
|
||||
internal sealed class Slot
|
||||
{
|
||||
public InboundPart? Part;
|
||||
public bool Open = true;
|
||||
public bool Detached;
|
||||
public ITimer? Hold;
|
||||
}
|
||||
|
||||
private readonly Func<IReadOnlyList<InboundPart>, Task> _onFlush;
|
||||
private readonly TimeSpan _window;
|
||||
private readonly TimeSpan _maxHold;
|
||||
private readonly TimeProvider _time;
|
||||
private readonly ILogger _logger;
|
||||
private readonly Lock _lock = new();
|
||||
private readonly List<Slot> _slots = [];
|
||||
private ITimer? _timer;
|
||||
private long _timerVersion;
|
||||
private long _generation;
|
||||
private int _flushing;
|
||||
private bool _disposed;
|
||||
|
||||
/// <param name="onFlush">Receives each batch, in slot order. A failure is logged and doesn't stop later batches.</param>
|
||||
/// <param name="window">Quiet time after the last completion before a batch is flushed.</param>
|
||||
/// <param name="time">Clock and timers.</param>
|
||||
/// <param name="maxHold">How long an open lease may hold the batch; <see cref="DefaultMaxHold"/> when null.</param>
|
||||
/// <param name="logger">Records dropped parts on dispose, detached leases and failing flushes.</param>
|
||||
public ChatInbox(Func<IReadOnlyList<InboundPart>, Task> onFlush, TimeSpan window, TimeProvider time, TimeSpan? maxHold = null, ILogger? logger = null)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(onFlush);
|
||||
ArgumentNullException.ThrowIfNull(time);
|
||||
ArgumentOutOfRangeException.ThrowIfLessThan(window, TimeSpan.Zero);
|
||||
_onFlush = onFlush;
|
||||
_window = window;
|
||||
_time = time;
|
||||
_maxHold = maxHold ?? DefaultMaxHold;
|
||||
_logger = logger ?? NullLogger.Instance;
|
||||
}
|
||||
|
||||
/// <summary>Slots holding a part (test view).</summary>
|
||||
internal int Buffered { get { lock (_lock) return _slots.Count(s => s.Part is not null); } }
|
||||
|
||||
/// <summary>Slots still open (test view).</summary>
|
||||
internal int OpenLeases { get { lock (_lock) return _slots.Count(s => s.Open); } }
|
||||
|
||||
/// <summary>Reserve the next slot. Cancels an armed flush until this lease completes.</summary>
|
||||
public InboundLease Begin(int messageId)
|
||||
{
|
||||
lock (_lock)
|
||||
{
|
||||
var slot = new Slot();
|
||||
var lease = new InboundLease(this, slot, _generation, messageId);
|
||||
if (_disposed)
|
||||
{
|
||||
slot.Open = false; // completes into nothing
|
||||
return lease;
|
||||
}
|
||||
CancelTimer();
|
||||
_slots.Add(slot);
|
||||
slot.Hold = _time.CreateTimer(_ => Detach(lease), null, _maxHold, Timeout.InfiniteTimeSpan);
|
||||
return lease;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Drop buffered parts and in-flight leases (<c>/new</c>, <c>/stop</c>, <c>/resume</c>). Leases already open complete into nothing.</summary>
|
||||
public void Discard()
|
||||
{
|
||||
lock (_lock) DropLocked();
|
||||
}
|
||||
|
||||
/// <summary>Completes when nothing is buffered, no lease is open and the last flush was handed over (test helper; polls).</summary>
|
||||
internal async Task DrainAsync()
|
||||
{
|
||||
while (true)
|
||||
{
|
||||
lock (_lock)
|
||||
{
|
||||
if (_slots.Count == 0 && _flushing == 0) return;
|
||||
}
|
||||
await Task.Delay(10).ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Discards everything buffered and logs how many parts were dropped.</summary>
|
||||
public ValueTask DisposeAsync()
|
||||
{
|
||||
int dropped;
|
||||
lock (_lock)
|
||||
{
|
||||
if (_disposed) return ValueTask.CompletedTask;
|
||||
_disposed = true;
|
||||
dropped = DropLocked();
|
||||
}
|
||||
if (dropped > 0) _logger.LogInformation("Dropped {Count} buffered message(s) on shutdown", dropped);
|
||||
return ValueTask.CompletedTask;
|
||||
}
|
||||
|
||||
internal void Complete(InboundLease lease, InboundPart? part)
|
||||
{
|
||||
lock (_lock)
|
||||
{
|
||||
var slot = lease.Slot;
|
||||
slot.Hold?.Dispose();
|
||||
if (_disposed || lease.Generation != _generation) return; // discarded
|
||||
if (slot.Detached)
|
||||
{
|
||||
if (part is null) return;
|
||||
_slots.Add(new Slot { Part = part, Open = false }); // a late part: a new Begin + Complete at the end
|
||||
}
|
||||
else
|
||||
{
|
||||
if (!slot.Open) return;
|
||||
slot.Open = false;
|
||||
slot.Part = part;
|
||||
}
|
||||
SettleLocked();
|
||||
}
|
||||
}
|
||||
|
||||
private void Detach(InboundLease lease)
|
||||
{
|
||||
lock (_lock)
|
||||
{
|
||||
var slot = lease.Slot;
|
||||
if (_disposed || lease.Generation != _generation || !slot.Open) return;
|
||||
slot.Open = false;
|
||||
slot.Detached = true;
|
||||
_slots.Remove(slot);
|
||||
_logger.LogWarning("Message {MessageId} took longer than {MaxHold} to prepare; sending the others without it", lease.MessageId, _maxHold);
|
||||
SettleLocked();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Arms the flush timer when no slot is open and one holds a part; clears the slots when all are empty.</summary>
|
||||
private void SettleLocked()
|
||||
{
|
||||
if (_slots.Any(s => s.Open)) return;
|
||||
if (_slots.All(s => s.Part is null))
|
||||
{
|
||||
_slots.Clear();
|
||||
return;
|
||||
}
|
||||
CancelTimer();
|
||||
var version = _timerVersion;
|
||||
_timer = _time.CreateTimer(_ => OnTimer(version), null, _window, Timeout.InfiniteTimeSpan);
|
||||
}
|
||||
|
||||
/// <summary>Bumps the version so a callback from a cancelled arm can't flush.</summary>
|
||||
private void CancelTimer()
|
||||
{
|
||||
_timerVersion++;
|
||||
_timer?.Dispose();
|
||||
_timer = null;
|
||||
}
|
||||
|
||||
private void OnTimer(long version)
|
||||
{
|
||||
List<InboundPart> parts;
|
||||
lock (_lock)
|
||||
{
|
||||
if (_disposed || version != _timerVersion || _slots.Any(s => s.Open)) return;
|
||||
CancelTimer();
|
||||
parts = _slots.Where(s => s.Part is not null).Select(s => s.Part!).ToList();
|
||||
_slots.Clear();
|
||||
if (parts.Count == 0) return;
|
||||
_flushing++;
|
||||
}
|
||||
_ = DeliverAsync(parts);
|
||||
}
|
||||
|
||||
private async Task DeliverAsync(List<InboundPart> parts)
|
||||
{
|
||||
try
|
||||
{
|
||||
await _onFlush(parts).ConfigureAwait(false);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Handing over {Count} message(s) failed", parts.Count);
|
||||
}
|
||||
finally
|
||||
{
|
||||
lock (_lock) _flushing--;
|
||||
}
|
||||
}
|
||||
|
||||
private int DropLocked()
|
||||
{
|
||||
_generation++;
|
||||
CancelTimer();
|
||||
var dropped = _slots.Count(s => s.Part is not null);
|
||||
foreach (var slot in _slots) slot.Hold?.Dispose();
|
||||
_slots.Clear();
|
||||
return dropped;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>One message's reserved place in a <see cref="ChatInbox"/> batch.</summary>
|
||||
public sealed class InboundLease : IDisposable
|
||||
{
|
||||
private readonly ChatInbox _inbox;
|
||||
private int _done;
|
||||
|
||||
internal InboundLease(ChatInbox inbox, ChatInbox.Slot slot, long generation, int messageId)
|
||||
{
|
||||
_inbox = inbox;
|
||||
Slot = slot;
|
||||
Generation = generation;
|
||||
MessageId = messageId;
|
||||
}
|
||||
|
||||
/// <summary>The Telegram message this lease was taken for.</summary>
|
||||
public int MessageId { get; }
|
||||
internal ChatInbox.Slot Slot { get; }
|
||||
internal long Generation { get; }
|
||||
|
||||
/// <summary>Fill the slot; null means the message produced nothing. Only the first call counts.</summary>
|
||||
public void Complete(InboundPart? part)
|
||||
{
|
||||
if (Interlocked.Exchange(ref _done, 1) == 0) _inbox.Complete(this, part);
|
||||
}
|
||||
|
||||
/// <summary><c>Complete(null)</c> if not completed yet. Safe to call twice.</summary>
|
||||
public void Dispose() => Complete(null);
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Telegram.Bot;
|
||||
using Telegram.Bot.Types;
|
||||
using Volcanic.AgenticCore.Telegram.Chats;
|
||||
using Volcanic.AgenticCore.Telegram.Rendering;
|
||||
using ImageContent = Volcanic.AgenticCore.Abstractions.Messages.ImageContent;
|
||||
|
||||
namespace Volcanic.AgenticCore.Telegram.Inbound;
|
||||
|
||||
/// <summary>What the content steps have gathered for one message so far.</summary>
|
||||
internal sealed class InboundDraft
|
||||
{
|
||||
public List<string> Body { get; } = [];
|
||||
public List<ImageContent> Images { get; } = [];
|
||||
/// <summary>A step refused the message (and sent its own notice): nothing is prompted.</summary>
|
||||
public bool Refused { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Turns one Telegram message into an <see cref="InboundPart"/>: reply and forward headers, then the content steps in a
|
||||
/// fixed order. Any slow work for a message happens here, while the router holds that message's
|
||||
/// <see cref="InboundLease"/>. A step never talks to the <see cref="ChatInbox"/> itself.
|
||||
/// </summary>
|
||||
public sealed class InboundComposer(TelegramSender sender, ILogger<InboundComposer> logger)
|
||||
{
|
||||
/// <summary>The reply to a message kind the bot can't read (no lease is taken).</summary>
|
||||
public const string UnsupportedReply = "I can read text and photos.";
|
||||
|
||||
/// <summary>Kinds the content steps can read. Checked by the router before it takes a lease.</summary>
|
||||
public static bool IsSupported(Message message)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(message);
|
||||
return message.Photo is { Length: > 0 } || (message.Text ?? message.Caption) is { Length: > 0 };
|
||||
}
|
||||
|
||||
/// <summary>A message with an attachment is never a command or a pending answer; its caption is prompt text.</summary>
|
||||
public static bool HasAttachment(Message message)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(message);
|
||||
return message.Photo is { Length: > 0 } || message.Document is not null || message.Video is not null
|
||||
|| message.Audio is not null || message.Voice is not null || message.VideoNote is not null
|
||||
|| message.Animation is not null || message.Sticker is not null;
|
||||
}
|
||||
|
||||
/// <summary>The message's part, or null when it has nothing usable or a step refused it.</summary>
|
||||
public async Task<InboundPart?> ComposeAsync(ChatContext chat, Message message, CancellationToken cancellationToken)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(chat);
|
||||
ArgumentNullException.ThrowIfNull(message);
|
||||
var draft = new InboundDraft();
|
||||
|
||||
// Content steps, in order. Each may add body lines and images, send a notice, or refuse the message.
|
||||
await AddTextAndPhotoAsync(chat, message, draft, cancellationToken).ConfigureAwait(false); // step 1
|
||||
// Step 2 (documents, videos, audio saved to the workspace) and step 3 (voice/audio transcription) are added here
|
||||
// by the attachments spec.
|
||||
|
||||
if (draft.Refused || (draft.Body.Count == 0 && draft.Images.Count == 0)) return null;
|
||||
return new InboundPart(
|
||||
message.Id,
|
||||
new DateTimeOffset(DateTime.SpecifyKind(message.Date, DateTimeKind.Utc)),
|
||||
message.MediaGroupId,
|
||||
MessageContext.ReplyHeader(message, sender.Bot.BotId),
|
||||
message.ForwardOrigin is { } origin ? MessageContext.ForwardLabel(origin) : null,
|
||||
draft.Body,
|
||||
draft.Images);
|
||||
}
|
||||
|
||||
/// <summary>Step 1: the text or caption, and the largest photo size.</summary>
|
||||
private async Task AddTextAndPhotoAsync(ChatContext chat, Message message, InboundDraft draft, CancellationToken cancellationToken)
|
||||
{
|
||||
if ((message.Text ?? message.Caption) is { Length: > 0 } text) draft.Body.Add(text);
|
||||
if (message.Photo is not { Length: > 0 } sizes) return;
|
||||
try
|
||||
{
|
||||
var largest = sizes.MaxBy(p => p.FileSize ?? (long)p.Width * p.Height)!;
|
||||
using var stream = new MemoryStream();
|
||||
await sender.Bot.GetInfoAndDownloadFile(largest.FileId, stream, cancellationToken).ConfigureAwait(false);
|
||||
draft.Images.Add(new ImageContent(Convert.ToBase64String(stream.ToArray()), "image/jpeg"));
|
||||
}
|
||||
catch (Exception ex) when (ex is not OperationCanceledException)
|
||||
{
|
||||
logger.LogWarning(ex, "Downloading a photo in chat {ChatId} failed", chat.ChatId);
|
||||
chat.Report("I could not download that photo.");
|
||||
}
|
||||
}
|
||||
}
|
||||
69
hosts/Volcanic.AgenticCore.Telegram/Inbound/InboundPart.cs
Normal file
69
hosts/Volcanic.AgenticCore.Telegram/Inbound/InboundPart.cs
Normal file
@@ -0,0 +1,69 @@
|
||||
using ImageContent = Volcanic.AgenticCore.Abstractions.Messages.ImageContent;
|
||||
|
||||
namespace Volcanic.AgenticCore.Telegram.Inbound;
|
||||
|
||||
/// <summary>
|
||||
/// What one Telegram message contributes to a prompt. Parts stay structured until the batch is flushed, so an album's
|
||||
/// repeated headers can be collapsed.
|
||||
/// </summary>
|
||||
/// <param name="MessageId">The Telegram message id, used to react to the message.</param>
|
||||
/// <param name="SentAt">The message's <c>Date</c>, for the batch's timestamp header.</param>
|
||||
/// <param name="MediaGroupId">The album id, when the message is one item of an album.</param>
|
||||
/// <param name="ReplyHeader">From <see cref="MessageContext.ReplyHeader"/>, brackets included.</param>
|
||||
/// <param name="ForwardLabel">From <see cref="MessageContext.ForwardLabel"/>, without the brackets.</param>
|
||||
/// <param name="Body">Text or caption, then lines added by content steps (<c>[File received: …]</c>, <c>[Voice message] …</c>).</param>
|
||||
/// <param name="Images">Images the message carries (its largest photo).</param>
|
||||
public sealed record InboundPart(
|
||||
int MessageId,
|
||||
DateTimeOffset SentAt,
|
||||
string? MediaGroupId,
|
||||
string? ReplyHeader,
|
||||
string? ForwardLabel,
|
||||
IReadOnlyList<string> Body,
|
||||
IReadOnlyList<ImageContent> Images);
|
||||
|
||||
/// <summary>Turns a flushed batch of parts into one prompt.</summary>
|
||||
public static class InboundBatch
|
||||
{
|
||||
/// <summary>
|
||||
/// One timestamp line (the first part's <see cref="InboundPart.SentAt"/> in <paramref name="zone"/>), then each part:
|
||||
/// its header lines, then its body lines separated by a blank line. Parts are separated by a blank line. Consecutive
|
||||
/// parts of one media group show the reply header and the forward label once. An image-only batch is just the
|
||||
/// timestamp line.
|
||||
/// </summary>
|
||||
public static (string Text, IReadOnlyList<ImageContent> Images) Render(IReadOnlyList<InboundPart> parts, TimeZoneInfo zone)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(parts);
|
||||
ArgumentNullException.ThrowIfNull(zone);
|
||||
ArgumentOutOfRangeException.ThrowIfZero(parts.Count);
|
||||
var blocks = new List<string>();
|
||||
var images = new List<ImageContent>();
|
||||
string? group = null;
|
||||
bool replyShown = false, forwardShown = false;
|
||||
foreach (var part in parts)
|
||||
{
|
||||
if (part.MediaGroupId is null || part.MediaGroupId != group)
|
||||
{
|
||||
group = part.MediaGroupId;
|
||||
replyShown = forwardShown = false;
|
||||
}
|
||||
var lines = new List<string>();
|
||||
if (part.ReplyHeader is { } reply && !replyShown)
|
||||
{
|
||||
lines.Add(reply);
|
||||
replyShown = group is not null;
|
||||
}
|
||||
if (part.ForwardLabel is { } forward && !forwardShown)
|
||||
{
|
||||
lines.Add($"[Forwarded from {forward}]");
|
||||
forwardShown = group is not null;
|
||||
}
|
||||
var body = part.Body.Where(line => line.Length > 0).ToList();
|
||||
if (body.Count > 0) lines.Add(string.Join("\n\n", body));
|
||||
if (lines.Count > 0) blocks.Add(string.Join('\n', lines));
|
||||
images.AddRange(part.Images);
|
||||
}
|
||||
var header = MessageContext.TimestampHeader(parts[0].SentAt, zone);
|
||||
return (blocks.Count == 0 ? header : header + "\n" + string.Join("\n\n", blocks), images);
|
||||
}
|
||||
}
|
||||
106
hosts/Volcanic.AgenticCore.Telegram/Inbound/MessageContext.cs
Normal file
106
hosts/Volcanic.AgenticCore.Telegram/Inbound/MessageContext.cs
Normal file
@@ -0,0 +1,106 @@
|
||||
using System.Globalization;
|
||||
using System.Text;
|
||||
using Telegram.Bot.Types;
|
||||
using Volcanic.AgenticCore.Hosting;
|
||||
|
||||
namespace Volcanic.AgenticCore.Telegram.Inbound;
|
||||
|
||||
/// <summary>
|
||||
/// The header lines the agent reads before a message: what it replies to or quotes, where a forward came from, and the
|
||||
/// local time it was sent. Wording matches the TypeScript reference bot, so both read the same to the model.
|
||||
/// </summary>
|
||||
public static class MessageContext
|
||||
{
|
||||
/// <summary>Quoted or replied text is cut to this many Unicode scalars (the last one being <c>…</c>).</summary>
|
||||
public const int MaxQuoted = 500;
|
||||
|
||||
/// <summary>
|
||||
/// <c>[Reply to …: "…"]</c> or <c>[Reply quoting …: "…"]</c> for a reply, a quote or an external reply; null when the
|
||||
/// message replies to nothing. Never throws: an unknown shape is described as <c>(message)</c>.
|
||||
/// </summary>
|
||||
public static string? ReplyHeader(Message message, long botId)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(message);
|
||||
var quote = message.Quote?.Text is { Length: > 0 } q ? Truncate(q) : null;
|
||||
if (message.ExternalReply is { } external)
|
||||
{
|
||||
var origin = external.Origin is { } o ? ForwardLabel(o) : "unknown";
|
||||
return quote is not null
|
||||
? $"[Reply quoting a message from {origin}: \"{quote}\"]"
|
||||
: $"[Reply to a message from {origin}: \"{Truncate(Describe(external))}\"]";
|
||||
}
|
||||
if (message.ReplyToMessage is not { } replied) return null;
|
||||
var who = replied.From is null ? "the earlier message"
|
||||
: replied.From.Id == botId ? "your message"
|
||||
: "your own earlier message"; // private chats only, so any other sender is the user
|
||||
return quote is not null
|
||||
? $"[Reply quoting {who}: \"{quote}\"]"
|
||||
: $"[Reply to {who}: \"{Truncate(Describe(replied))}\"]";
|
||||
}
|
||||
|
||||
/// <summary>Who a forward (or an external reply) came from, without the brackets.</summary>
|
||||
public static string ForwardLabel(MessageOrigin origin) => origin switch
|
||||
{
|
||||
MessageOriginUser { SenderUser: { } user } => UserLabel(user),
|
||||
MessageOriginUser => "unknown user",
|
||||
MessageOriginHiddenUser hidden => hidden.SenderUserName is { Length: > 0 } n ? n : "hidden user",
|
||||
MessageOriginChat chat => WithSignature(Named(chat.SenderChat?.Title, chat.SenderChat?.Username, "chat"), chat.AuthorSignature),
|
||||
MessageOriginChannel channel => WithSignature(Named(channel.Chat?.Title, channel.Chat?.Username, "channel"), channel.AuthorSignature),
|
||||
_ => "unknown",
|
||||
};
|
||||
|
||||
/// <summary>The replied message's text, else its caption, else a media descriptor such as <c>(photo)</c>.</summary>
|
||||
public static string Describe(Message replied)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(replied);
|
||||
return replied.Text is { Length: > 0 } text ? text
|
||||
: replied.Caption is { Length: > 0 } caption ? caption
|
||||
: Media(replied.Photo, replied.Video, replied.Voice, replied.VideoNote, replied.Audio, replied.Document, replied.Sticker);
|
||||
}
|
||||
|
||||
internal static string Describe(ExternalReplyInfo external) =>
|
||||
Media(external.Photo, external.Video, external.Voice, external.VideoNote, external.Audio, external.Document, external.Sticker);
|
||||
|
||||
/// <summary>Cut to <paramref name="max"/> Unicode scalars (the last one <c>…</c>), never splitting a surrogate pair.</summary>
|
||||
public static string Truncate(string text, int max = MaxQuoted)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(text);
|
||||
ArgumentOutOfRangeException.ThrowIfLessThan(max, 1);
|
||||
var runes = text.EnumerateRunes().Take(max + 1).ToList();
|
||||
if (runes.Count <= max) return text;
|
||||
var sb = new StringBuilder();
|
||||
foreach (var rune in runes.Take(max - 1)) sb.Append(rune.ToString());
|
||||
return sb.Append('…').ToString();
|
||||
}
|
||||
|
||||
/// <summary><c>[Sat 2026-09-26 14:03 Europe/Berlin]</c>: <paramref name="at"/> in <paramref name="zone"/>, invariant culture.</summary>
|
||||
public static string TimestampHeader(DateTimeOffset at, TimeZoneInfo zone)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(zone);
|
||||
var local = TimeZoneInfo.ConvertTime(at, zone);
|
||||
return string.Create(CultureInfo.InvariantCulture, $"[{local:ddd yyyy-MM-dd HH:mm} {TimeZoneIds.ToIana(zone) ?? zone.Id}]");
|
||||
}
|
||||
|
||||
private static string UserLabel(User user)
|
||||
{
|
||||
if (user.Username is { Length: > 0 } name) return "@" + name;
|
||||
var full = string.Join(' ', new[] { user.FirstName, user.LastName }.Where(s => !string.IsNullOrWhiteSpace(s)));
|
||||
return full.Length > 0 ? full : "unknown user";
|
||||
}
|
||||
|
||||
private static string Named(string? title, string? username, string fallback) =>
|
||||
title is { Length: > 0 } ? title : username is { Length: > 0 } ? "@" + username : fallback;
|
||||
|
||||
private static string WithSignature(string label, string? signature) =>
|
||||
signature is { Length: > 0 } ? $"{label} ({signature})" : label;
|
||||
|
||||
private static string Media(PhotoSize[]? photo, Video? video, Voice? voice, VideoNote? videoNote, Audio? audio, Document? document, Sticker? sticker) =>
|
||||
photo is { Length: > 0 } ? "(photo)"
|
||||
: video is not null ? "(video)"
|
||||
: voice is not null ? "(voice message)"
|
||||
: videoNote is not null ? "(video message)"
|
||||
: audio is not null ? audio.FileName is { Length: > 0 } a ? $"(audio: {a})" : "(audio)"
|
||||
: document is not null ? document.FileName is { Length: > 0 } d ? $"(document: {d})" : "(document)"
|
||||
: sticker is not null ? sticker.Emoji is { Length: > 0 } e ? $"(sticker {e})" : "(sticker)"
|
||||
: "(message)";
|
||||
}
|
||||
@@ -4,6 +4,7 @@
|
||||
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.DependencyInjection.Extensions;
|
||||
using Microsoft.Extensions.Hosting;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Telegram.Bot;
|
||||
@@ -12,6 +13,7 @@ using Volcanic.AgenticCore.Features.Processes;
|
||||
using Volcanic.AgenticCore.Hosting;
|
||||
using Volcanic.AgenticCore.Telegram;
|
||||
using Volcanic.AgenticCore.Telegram.Chats;
|
||||
using Volcanic.AgenticCore.Telegram.Inbound;
|
||||
using Volcanic.AgenticCore.Telegram.Polling;
|
||||
using Volcanic.AgenticCore.Telegram.Rendering;
|
||||
|
||||
@@ -47,8 +49,10 @@ builder.Services.AddAgenticCore(o =>
|
||||
builder.Services.AddSingleton<ITelegramBotClient>(_ => new TelegramBotClient(options.BotToken));
|
||||
builder.Services.AddSingleton(new ReconnectPolicy(options.ReconnectInitialDelay, options.ReconnectMaxDelay));
|
||||
builder.Services.AddSingleton<TelegramSender>();
|
||||
builder.Services.TryAddSingleton(TimeProvider.System);
|
||||
builder.Services.AddSingleton<ChatRegistry>();
|
||||
builder.Services.AddSingleton<ChatCommands>();
|
||||
builder.Services.AddSingleton<InboundComposer>();
|
||||
builder.Services.AddSingleton<IUpdateRouter, UpdateRouter>();
|
||||
builder.Services.AddSingleton<PollingService>();
|
||||
builder.Services.AddHostedService(sp => sp.GetRequiredService<PollingService>());
|
||||
|
||||
@@ -114,6 +114,20 @@ public sealed class TelegramSender(ITelegramBotClient bot, ILogger<TelegramSende
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>React to a message with <paramref name="emoji"/>. Best-effort: a failure is logged at Debug and swallowed.</summary>
|
||||
public async Task ReactAsync(long chatId, int messageId, string emoji, CancellationToken cancellationToken = default)
|
||||
{
|
||||
try
|
||||
{
|
||||
await bot.SetMessageReaction(chatId, messageId, [new ReactionTypeEmoji { Emoji = emoji }], cancellationToken: cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
}
|
||||
catch (Exception ex) when (ex is not OperationCanceledException)
|
||||
{
|
||||
logger.LogDebug(ex, "Reacting to message {MessageId} in chat {ChatId} failed", messageId, chatId);
|
||||
}
|
||||
}
|
||||
|
||||
public async Task AnswerCallbackAsync(string callbackQueryId, string? text = null, CancellationToken cancellationToken = default)
|
||||
{
|
||||
try
|
||||
|
||||
@@ -25,6 +25,12 @@ public sealed class TelegramOptions
|
||||
/// <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);
|
||||
|
||||
@@ -55,6 +61,7 @@ public sealed class TelegramOptions
|
||||
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.");
|
||||
return errors;
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Telegram.Bot.Exceptions;
|
||||
using Telegram.Bot.Requests;
|
||||
using Telegram.Bot.Requests.Abstractions;
|
||||
using Volcanic.AgenticCore.Telegram.Rendering;
|
||||
using Volcanic.AgenticCore.Telegram.Tests.Support;
|
||||
|
||||
namespace Volcanic.AgenticCore.Telegram.Tests;
|
||||
|
||||
public sealed class BotSetupServiceTests
|
||||
{
|
||||
private static (BotSetupService Service, FakeBotClient Bot) Create(Action<IRequest>? onSend)
|
||||
{
|
||||
var bot = new FakeBotClient { OnSend = onSend };
|
||||
var options = new TelegramOptions
|
||||
{
|
||||
BotToken = "t",
|
||||
AllowedUserIds = [Updates.AllowedUser, 2002],
|
||||
ReconnectInitialDelay = TimeSpan.FromMilliseconds(5),
|
||||
ReconnectMaxDelay = TimeSpan.FromMilliseconds(20),
|
||||
};
|
||||
var sender = new TelegramSender(bot, NullLogger<TelegramSender>.Instance);
|
||||
return (new BotSetupService(bot, options, sender, NullLogger<BotSetupService>.Instance), bot);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Online_goes_once_to_the_owner_after_setup_succeeds_on_the_second_try()
|
||||
{
|
||||
var getMeCalls = 0;
|
||||
var (service, bot) = Create(r =>
|
||||
{
|
||||
if (r is GetMeRequest && Interlocked.Increment(ref getMeCalls) == 1) throw FakeBotClient.NetworkDown();
|
||||
});
|
||||
|
||||
await service.StartAsync(CancellationToken.None);
|
||||
await service.ExecuteTask!.WaitAsync(TimeSpan.FromSeconds(5));
|
||||
await service.StopAsync(CancellationToken.None);
|
||||
|
||||
Assert.Equal(2, bot.Requests.OfType<GetMeRequest>().Count());
|
||||
var online = Assert.Single(bot.Sent);
|
||||
Assert.Equal(Updates.AllowedUser, online.ChatId.Identifier);
|
||||
Assert.Equal("🟢 Online.", online.Text);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task A_forbidden_online_message_is_not_retried_and_does_not_stop_the_service()
|
||||
{
|
||||
var (service, bot) = Create(r =>
|
||||
{
|
||||
if (r is SendMessageRequest) throw new ApiRequestException("Forbidden: bot can't initiate conversation with a user", 403);
|
||||
});
|
||||
|
||||
await service.StartAsync(CancellationToken.None);
|
||||
await service.ExecuteTask!.WaitAsync(TimeSpan.FromSeconds(5));
|
||||
|
||||
Assert.True(service.ExecuteTask.IsCompletedSuccessfully);
|
||||
Assert.Single(bot.Requests.OfType<SendMessageRequest>());
|
||||
Assert.Single(bot.Requests.OfType<GetMeRequest>());
|
||||
await service.StopAsync(CancellationToken.None);
|
||||
}
|
||||
}
|
||||
289
tests/Volcanic.AgenticCore.Telegram.Tests/ChatInboxTests.cs
Normal file
289
tests/Volcanic.AgenticCore.Telegram.Tests/ChatInboxTests.cs
Normal file
@@ -0,0 +1,289 @@
|
||||
using Microsoft.Extensions.Time.Testing;
|
||||
using Volcanic.AgenticCore.Abstractions.Messages;
|
||||
using Volcanic.AgenticCore.Telegram.Inbound;
|
||||
|
||||
namespace Volcanic.AgenticCore.Telegram.Tests;
|
||||
|
||||
public sealed class ChatInboxTests
|
||||
{
|
||||
private static readonly TimeZoneInfo Berlin = TimeZoneInfo.FindSystemTimeZoneById("Europe/Berlin");
|
||||
private static readonly DateTimeOffset At = new(2026, 9, 26, 12, 3, 0, TimeSpan.Zero);
|
||||
private const string Header = "[Sat 2026-09-26 14:03 Europe/Berlin]";
|
||||
|
||||
private static InboundPart P(int id, string? group = null, string? reply = null, string? forward = null, string[]? body = null, int images = 0) =>
|
||||
new(id, At.AddSeconds(id - 1), group, reply, forward, body ?? [],
|
||||
Enumerable.Range(0, images).Select(_ => new ImageContent("", "image/jpeg")).ToList());
|
||||
|
||||
private static readonly TimeSpan Window = TimeSpan.FromMilliseconds(600);
|
||||
private static readonly TimeSpan Ms = TimeSpan.FromMilliseconds(1);
|
||||
private readonly FakeTimeProvider _time = new(new DateTimeOffset(2026, 9, 26, 12, 0, 0, TimeSpan.Zero));
|
||||
private readonly List<IReadOnlyList<InboundPart>> _flushes = [];
|
||||
|
||||
private ChatInbox Inbox(TimeSpan? maxHold = null) => new(parts =>
|
||||
{
|
||||
lock (_flushes) _flushes.Add(parts);
|
||||
return Task.CompletedTask;
|
||||
}, Window, _time, maxHold);
|
||||
|
||||
private static InboundPart Text(int id, string text = "x") => new(id, DateTimeOffset.UnixEpoch, null, null, null, [text], []);
|
||||
|
||||
/// <summary>Each flush as "1,2" (message ids in order).</summary>
|
||||
private List<string> Batches { get { lock (_flushes) return _flushes.Select(f => string.Join(",", f.Select(p => p.MessageId))).ToList(); } }
|
||||
|
||||
[Fact]
|
||||
public async Task One_part_flushes_after_the_window_not_before()
|
||||
{
|
||||
await using var inbox = Inbox();
|
||||
inbox.Begin(1).Complete(Text(1));
|
||||
|
||||
_time.Advance(Window - Ms);
|
||||
Assert.Empty(Batches);
|
||||
_time.Advance(Ms);
|
||||
Assert.Equal(["1"], Batches);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Two_parts_inside_the_window_make_one_flush_and_each_resets_the_timer()
|
||||
{
|
||||
await using var inbox = Inbox();
|
||||
inbox.Begin(1).Complete(Text(1));
|
||||
_time.Advance(TimeSpan.FromMilliseconds(400));
|
||||
inbox.Begin(2).Complete(Text(2));
|
||||
_time.Advance(TimeSpan.FromMilliseconds(400)); // 800 ms after the first, 400 after the second
|
||||
|
||||
Assert.Empty(Batches);
|
||||
_time.Advance(TimeSpan.FromMilliseconds(200));
|
||||
Assert.Equal(["1,2"], Batches);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Begin_cancels_an_armed_timer()
|
||||
{
|
||||
await using var inbox = Inbox();
|
||||
inbox.Begin(1).Complete(Text(1));
|
||||
_time.Advance(TimeSpan.FromMilliseconds(500));
|
||||
var second = inbox.Begin(2);
|
||||
|
||||
_time.Advance(Window * 2);
|
||||
Assert.Empty(Batches);
|
||||
second.Complete(Text(2));
|
||||
_time.Advance(Window);
|
||||
Assert.Equal(["1,2"], Batches);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Complete_while_another_lease_is_open_does_not_arm()
|
||||
{
|
||||
await using var inbox = Inbox();
|
||||
var first = inbox.Begin(1);
|
||||
inbox.Begin(2).Complete(Text(2));
|
||||
|
||||
_time.Advance(Window * 3);
|
||||
Assert.Empty(Batches);
|
||||
first.Complete(Text(1));
|
||||
_time.Advance(Window);
|
||||
Assert.Equal(["1,2"], Batches);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Two_leases_need_two_completions()
|
||||
{
|
||||
await using var inbox = Inbox();
|
||||
var a = inbox.Begin(1);
|
||||
var b = inbox.Begin(2);
|
||||
a.Complete(Text(1));
|
||||
|
||||
_time.Advance(Window * 3);
|
||||
Assert.Empty(Batches);
|
||||
Assert.Equal(1, inbox.OpenLeases);
|
||||
b.Complete(Text(2));
|
||||
_time.Advance(Window);
|
||||
Assert.Equal(["1,2"], Batches);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task A_null_part_never_flushes()
|
||||
{
|
||||
await using var inbox = Inbox();
|
||||
inbox.Begin(1).Complete(null);
|
||||
using (inbox.Begin(2)) { } // disposed without Complete: same as Complete(null)
|
||||
|
||||
_time.Advance(Window * 3);
|
||||
Assert.Empty(Batches);
|
||||
Assert.Equal(0, inbox.Buffered);
|
||||
Assert.Equal(0, inbox.OpenLeases);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Comment_then_slow_voice_race_gives_one_flush()
|
||||
{
|
||||
await using var inbox = Inbox();
|
||||
inbox.Begin(1).Complete(Text(1, "look at this")); // t=0.000 comment
|
||||
_time.Advance(TimeSpan.FromMilliseconds(50));
|
||||
var voice = inbox.Begin(2); // t=0.050 voice, transcription takes 3 s
|
||||
_time.Advance(TimeSpan.FromSeconds(3));
|
||||
|
||||
Assert.Empty(Batches);
|
||||
voice.Complete(Text(2, "[Voice message] hello")); // t=3.050
|
||||
_time.Advance(Window); // t=3.650
|
||||
Assert.Equal(["1,2"], Batches);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Voice_then_comment_race_keeps_slot_order()
|
||||
{
|
||||
await using var inbox = Inbox();
|
||||
var voice = inbox.Begin(1);
|
||||
_time.Advance(TimeSpan.FromMilliseconds(50));
|
||||
inbox.Begin(2).Complete(Text(2, "what does it say?"));
|
||||
_time.Advance(TimeSpan.FromSeconds(3));
|
||||
|
||||
voice.Complete(Text(1, "[Voice message] hello"));
|
||||
_time.Advance(Window);
|
||||
Assert.Equal(["1,2"], Batches); // Telegram order, not completion order
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Discard_with_an_open_lease_drops_the_late_completion()
|
||||
{
|
||||
await using var inbox = Inbox();
|
||||
inbox.Begin(1).Complete(Text(1));
|
||||
var late = inbox.Begin(2);
|
||||
|
||||
inbox.Discard();
|
||||
late.Complete(Text(2));
|
||||
_time.Advance(Window * 2);
|
||||
Assert.Empty(Batches);
|
||||
Assert.Equal(0, inbox.Buffered);
|
||||
|
||||
inbox.Begin(3).Complete(Text(3));
|
||||
_time.Advance(Window);
|
||||
Assert.Equal(["3"], Batches);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Double_dispose_of_a_lease_is_a_no_op()
|
||||
{
|
||||
await using var inbox = Inbox();
|
||||
var lease = inbox.Begin(1);
|
||||
lease.Complete(Text(1));
|
||||
lease.Dispose();
|
||||
lease.Dispose();
|
||||
var empty = inbox.Begin(2);
|
||||
empty.Dispose();
|
||||
empty.Dispose();
|
||||
|
||||
_time.Advance(Window);
|
||||
Assert.Equal(["1"], Batches);
|
||||
Assert.Equal(0, inbox.OpenLeases);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Max_hold_detaches_a_hung_lease_and_its_late_part_flushes_alone()
|
||||
{
|
||||
await using var inbox = Inbox(maxHold: TimeSpan.FromMinutes(2));
|
||||
inbox.Begin(1).Complete(Text(1));
|
||||
var hung = inbox.Begin(2);
|
||||
|
||||
_time.Advance(TimeSpan.FromMinutes(2)); // the hold expires: the batch no longer waits for #2
|
||||
Assert.Empty(Batches);
|
||||
_time.Advance(Window);
|
||||
Assert.Equal(["1"], Batches);
|
||||
|
||||
hung.Complete(Text(2));
|
||||
_time.Advance(Window);
|
||||
Assert.Equal(["1", "2"], Batches);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Drain_completes_once_the_batch_is_handed_over()
|
||||
{
|
||||
await using var inbox = Inbox();
|
||||
inbox.Begin(1).Complete(Text(1));
|
||||
var drain = inbox.DrainAsync();
|
||||
|
||||
Assert.False(drain.IsCompleted); // the part is still buffered
|
||||
_time.Advance(Window);
|
||||
await drain.WaitAsync(TimeSpan.FromSeconds(5));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task A_failing_flush_does_not_stop_later_batches()
|
||||
{
|
||||
var calls = 0;
|
||||
await using var inbox = new ChatInbox(parts =>
|
||||
{
|
||||
if (Interlocked.Increment(ref calls) == 1) throw new InvalidOperationException("boom");
|
||||
lock (_flushes) _flushes.Add(parts);
|
||||
return Task.CompletedTask;
|
||||
}, Window, _time);
|
||||
|
||||
inbox.Begin(1).Complete(Text(1));
|
||||
_time.Advance(Window);
|
||||
await inbox.DrainAsync().WaitAsync(TimeSpan.FromSeconds(5)); // the flushing counter is back to zero
|
||||
Assert.Equal(1, calls);
|
||||
|
||||
inbox.Begin(2).Complete(Text(2));
|
||||
_time.Advance(Window);
|
||||
await inbox.DrainAsync().WaitAsync(TimeSpan.FromSeconds(5));
|
||||
Assert.Equal(["2"], Batches);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Dispose_drops_buffered_parts()
|
||||
{
|
||||
var inbox = Inbox();
|
||||
inbox.Begin(1).Complete(Text(1));
|
||||
|
||||
await inbox.DisposeAsync();
|
||||
_time.Advance(Window);
|
||||
Assert.Empty(Batches);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Render_starts_with_one_timestamp_header_from_the_first_part()
|
||||
{
|
||||
var (text, images) = InboundBatch.Render([P(1, body: ["look at this"]), P(2, body: ["and this"])], Berlin);
|
||||
|
||||
Assert.Equal($"{Header}\nlook at this\n\nand this", text);
|
||||
Assert.Empty(images);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Render_puts_headers_before_the_body_and_keeps_body_lines_apart()
|
||||
{
|
||||
var part = P(1, reply: "[Reply to your message: \"x\"]", forward: "Daily News (Anna K.)", body: ["post", "[Voice message] hi"]);
|
||||
|
||||
var (text, _) = InboundBatch.Render([part], Berlin);
|
||||
|
||||
Assert.Equal($"{Header}\n[Reply to your message: \"x\"]\n[Forwarded from Daily News (Anna K.)]\npost\n\n[Voice message] hi", text);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Album_headers_collapse_to_the_first_part_that_has_them()
|
||||
{
|
||||
var (text, images) = InboundBatch.Render(
|
||||
[P(1, "g", forward: "Daily News", body: ["three views"], images: 1), P(2, "g", forward: "Daily News", images: 1), P(3, "g", forward: "Daily News", images: 1)],
|
||||
Berlin);
|
||||
|
||||
Assert.Equal($"{Header}\n[Forwarded from Daily News]\nthree views", text);
|
||||
Assert.Equal(3, images.Count);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Parts_outside_a_media_group_keep_their_own_headers()
|
||||
{
|
||||
var (text, _) = InboundBatch.Render([P(1, forward: "A", body: ["x"]), P(2, forward: "A", body: ["y"])], Berlin);
|
||||
|
||||
Assert.Equal($"{Header}\n[Forwarded from A]\nx\n\n[Forwarded from A]\ny", text);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void An_image_only_batch_is_just_the_timestamp_header()
|
||||
{
|
||||
var (text, images) = InboundBatch.Render([P(1, images: 1)], Berlin);
|
||||
|
||||
Assert.Equal(Header, text);
|
||||
Assert.Single(images);
|
||||
}
|
||||
}
|
||||
@@ -22,7 +22,7 @@ public sealed class ChatTests
|
||||
Assert.Equal(ParseMode.Html, reply.ParseMode);
|
||||
Assert.Equal("Hello <b>there</b>, how can I help?", host.Bot.CurrentTexts.Values.Single());
|
||||
Assert.Empty(host.Bot.Edits); // not streamed
|
||||
Assert.Equal("hi", host.Faux.Requests.Single().Messages.Last().Text);
|
||||
Assert.Matches(@"^\[\w{3} \d{4}-\d{2}-\d{2} \d{2}:\d{2} [^\]]+\]\nhi$", host.Faux.Requests.Single().Messages.Last().Text); // the timestamp line comes first
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -173,7 +173,7 @@ public sealed class ChatTests
|
||||
await host.SettleAsync();
|
||||
|
||||
Assert.DoesNotContain(host.Texts, t => t.Contains("failed", StringComparison.OrdinalIgnoreCase));
|
||||
Assert.Contains(host.Faux.Requests.Last().Messages, m => m.Text == "actually, also say hi");
|
||||
Assert.Contains(host.Faux.Requests.Last().Messages, m => m.Text.EndsWith("]\nactually, also say hi", StringComparison.Ordinal));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -204,6 +204,16 @@ public sealed class ChatTests
|
||||
Assert.Empty(host.Faux.Requests);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Help_mentions_replies()
|
||||
{
|
||||
await using var host = new ChatHost();
|
||||
|
||||
await host.SendAsync("/help");
|
||||
|
||||
Assert.Contains(host.Texts, t => t.Contains("Reply to one of my messages", StringComparison.Ordinal));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Unknown_commands_go_to_the_session()
|
||||
{
|
||||
|
||||
@@ -69,6 +69,20 @@ public sealed class FormattingAndOptionsTests
|
||||
Assert.Contains(errors, e => e.Contains("allowed users", StringComparison.OrdinalIgnoreCase));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Input_debounce_defaults_to_600_ms_and_must_not_be_negative()
|
||||
{
|
||||
var options = new TelegramOptions { BotToken = "t", AllowedUserIds = [1] };
|
||||
Assert.Equal(TimeSpan.FromMilliseconds(600), options.InputDebounce);
|
||||
Assert.Empty(options.Validate());
|
||||
|
||||
options.InputDebounce = TimeSpan.Zero;
|
||||
Assert.Empty(options.Validate());
|
||||
|
||||
options.InputDebounce = TimeSpan.FromMilliseconds(-1);
|
||||
Assert.Contains(options.Validate(), e => e.Contains("InputDebounce", StringComparison.Ordinal));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Environment_shortcuts_fill_the_token_and_users()
|
||||
{
|
||||
|
||||
@@ -0,0 +1,88 @@
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Telegram.Bot.Requests;
|
||||
using Volcanic.AgenticCore.Telegram.Inbound;
|
||||
using Volcanic.AgenticCore.Telegram.Rendering;
|
||||
using Volcanic.AgenticCore.Telegram.Tests.Support;
|
||||
|
||||
namespace Volcanic.AgenticCore.Telegram.Tests;
|
||||
|
||||
public sealed class InboundComposerTests
|
||||
{
|
||||
private static InboundComposer Composer(ChatHost host) =>
|
||||
new(new TelegramSender(host.Bot, NullLogger<TelegramSender>.Instance), NullLogger<InboundComposer>.Instance);
|
||||
|
||||
[Fact]
|
||||
public async Task Text_becomes_the_body_with_reply_and_forward_headers()
|
||||
{
|
||||
await using var host = new ChatHost();
|
||||
var chat = await host.ChatAsync();
|
||||
var update = Updates.Forward("Geothermal plant opens", Updates.Channel("Daily News", "Anna K."));
|
||||
update.Message!.ReplyToMessage = Updates.BotMessage("Which plant do you mean?");
|
||||
|
||||
var part = await Composer(host).ComposeAsync(chat, update.Message, CancellationToken.None);
|
||||
|
||||
Assert.NotNull(part);
|
||||
Assert.Equal(update.Message.Id, part!.MessageId);
|
||||
Assert.Equal(new DateTimeOffset(DateTime.SpecifyKind(update.Message.Date, DateTimeKind.Utc)), part.SentAt);
|
||||
Assert.Equal("[Reply to your message: \"Which plant do you mean?\"]", part.ReplyHeader);
|
||||
Assert.Equal("Daily News (Anna K.)", part.ForwardLabel);
|
||||
Assert.Equal(["Geothermal plant opens"], part.Body);
|
||||
Assert.Empty(part.Images);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Caption_and_the_largest_photo_become_body_and_image()
|
||||
{
|
||||
await using var host = new ChatHost();
|
||||
var chat = await host.ChatAsync();
|
||||
var update = Updates.AlbumPhoto("g1", caption: "three views");
|
||||
update.Message!.Photo = [
|
||||
new() { FileId = "small", FileUniqueId = "s", Width = 10, Height = 10 },
|
||||
new() { FileId = "large", FileUniqueId = "l", Width = 100, Height = 100 },
|
||||
];
|
||||
|
||||
var part = await Composer(host).ComposeAsync(chat, update.Message, CancellationToken.None);
|
||||
|
||||
Assert.Equal(["three views"], part!.Body);
|
||||
Assert.Equal("image/jpeg", Assert.Single(part.Images).MimeType);
|
||||
Assert.Equal("g1", part.MediaGroupId);
|
||||
Assert.Equal("large", Assert.Single(host.Bot.Requests.OfType<GetFileRequest>()).FileId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task A_failed_photo_download_is_reported_and_a_bare_photo_composes_to_null()
|
||||
{
|
||||
await using var host = new ChatHost();
|
||||
var chat = await host.ChatAsync();
|
||||
host.Bot.OnSend = r => { if (r is GetFileRequest) throw FakeBotClient.NetworkDown(); };
|
||||
|
||||
var part = await Composer(host).ComposeAsync(chat, Updates.Photo().Message!, CancellationToken.None);
|
||||
await chat.Renderer.DrainAsync().WaitAsync(TimeSpan.FromSeconds(5));
|
||||
|
||||
Assert.Null(part);
|
||||
Assert.Contains(host.Texts, t => t.Contains("I could not download that photo.", StringComparison.Ordinal));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task A_captioned_photo_that_fails_to_download_keeps_its_caption()
|
||||
{
|
||||
await using var host = new ChatHost();
|
||||
var chat = await host.ChatAsync();
|
||||
host.Bot.OnSend = r => { if (r is GetFileRequest) throw FakeBotClient.NetworkDown(); };
|
||||
|
||||
var part = await Composer(host).ComposeAsync(chat, Updates.Photo("what is this?").Message!, CancellationToken.None);
|
||||
|
||||
Assert.Equal(["what is this?"], part!.Body);
|
||||
Assert.Empty(part.Images);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Only_text_captions_and_photos_are_supported_in_this_wave()
|
||||
{
|
||||
Assert.True(InboundComposer.IsSupported(Updates.Text("hi").Message!));
|
||||
Assert.True(InboundComposer.IsSupported(Updates.Photo().Message!));
|
||||
Assert.False(InboundComposer.IsSupported(Updates.StickerMessage().Message!));
|
||||
Assert.True(InboundComposer.HasAttachment(Updates.Photo("caption").Message!));
|
||||
Assert.False(InboundComposer.HasAttachment(Updates.Text("/status").Message!));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,295 @@
|
||||
using System.Text.RegularExpressions;
|
||||
using Microsoft.Extensions.Time.Testing;
|
||||
using Telegram.Bot.Requests;
|
||||
using Telegram.Bot.Types;
|
||||
using Volcanic.AgenticCore.Abstractions.Messages;
|
||||
using Volcanic.AgenticCore.Telegram.Inbound;
|
||||
using Volcanic.AgenticCore.Telegram.Tests.Support;
|
||||
using Volcanic.AgenticCore.Testing;
|
||||
|
||||
namespace Volcanic.AgenticCore.Telegram.Tests;
|
||||
|
||||
/// <summary>Reply/forward headers, the timestamp line and grouping, end to end through the router and the faux model.</summary>
|
||||
public sealed class MessageContextChatTests
|
||||
{
|
||||
private static readonly TimeZoneInfo Tokyo = TimeZoneInfo.FindSystemTimeZoneById("Asia/Tokyo");
|
||||
private static readonly Regex TimestampLine = new(@"^\[(Mon|Tue|Wed|Thu|Fri|Sat|Sun) \d{4}-\d{2}-\d{2} \d{2}:\d{2} [^\]]+\]$");
|
||||
|
||||
private static DateTimeOffset SentAt(Update update) => new(DateTime.SpecifyKind(update.Message!.Date, DateTimeKind.Utc));
|
||||
|
||||
private static string Prompt(ChatHost host) => host.Faux.Requests.Single().Messages.Last().Text;
|
||||
|
||||
/// <summary>Wait until <paramref name="parts"/> parts are composed, then let the (fake) window elapse.</summary>
|
||||
internal static async Task FlushAsync(ChatHost host, FakeTimeProvider time, int parts)
|
||||
{
|
||||
var chat = await host.ChatAsync();
|
||||
await Wait.UntilAsync(() => chat.Inbox.Buffered == parts && chat.Inbox.OpenLeases == 0, $"{parts} composed part(s)");
|
||||
time.Advance(host.Options.InputDebounce);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task A_reply_to_a_bot_message_reaches_the_model_with_the_header()
|
||||
{
|
||||
await using var host = new ChatHost(core: o => o.DefaultTimeZone = Tokyo);
|
||||
host.Faux.Enqueue(FauxTurn.Say("That one."));
|
||||
var reply = Updates.Reply("what about this?", Updates.BotMessage("Which plant do you mean?"));
|
||||
|
||||
await host.RouteAsync(reply);
|
||||
await host.SettleAsync();
|
||||
|
||||
Assert.Equal(
|
||||
$"{MessageContext.TimestampHeader(SentAt(reply), Tokyo)}\n[Reply to your message: \"Which plant do you mean?\"]\nwhat about this?",
|
||||
Prompt(host));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task The_first_line_is_one_timestamp_header_in_the_chat_zone()
|
||||
{
|
||||
var time = new FakeTimeProvider(DateTimeOffset.UtcNow);
|
||||
await using var host = new ChatHost(core: o => o.DefaultTimeZone = Tokyo, time: time);
|
||||
host.Faux.Enqueue(FauxTurn.Say("Yes."));
|
||||
var comment = Updates.Text("look at this");
|
||||
var forward = Updates.Forward("Geothermal plant opens", Updates.Channel("Daily News"));
|
||||
|
||||
await host.RouteAsync(comment);
|
||||
await host.RouteAsync(forward);
|
||||
await FlushAsync(host, time, parts: 2);
|
||||
await host.SettleAsync();
|
||||
|
||||
// The zone comes from the ChatRegistry.TimeZoneFor seam (the session's zone until the scheduler's ChatSettingsStore).
|
||||
Assert.Equal("Asia/Tokyo", Volcanic.AgenticCore.Hosting.TimeZoneIds.ToIana(host.Chats.TimeZoneFor(Updates.AllowedUser)));
|
||||
var lines = Prompt(host).Split('\n');
|
||||
Assert.Equal(MessageContext.TimestampHeader(SentAt(comment), Tokyo), lines[0]);
|
||||
Assert.EndsWith(" Asia/Tokyo]", lines[0]);
|
||||
Assert.Single(lines, l => TimestampLine.IsMatch(l)); // one per batch, not per part
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Plain_text_waits_for_the_window_and_a_forward_inside_it_joins_the_request()
|
||||
{
|
||||
var time = new FakeTimeProvider(DateTimeOffset.UtcNow);
|
||||
await using var host = new ChatHost(time: time);
|
||||
host.Faux.Enqueue(FauxTurn.Say("No."));
|
||||
|
||||
await host.SendAsync("is this true?");
|
||||
var chat = await host.ChatAsync();
|
||||
await Wait.UntilAsync(() => chat.Inbox.Buffered == 1 && chat.Inbox.OpenLeases == 0, "the text part");
|
||||
time.Advance(host.Options.InputDebounce - TimeSpan.FromMilliseconds(1));
|
||||
Assert.Equal(1, chat.Inbox.Buffered); // still waiting
|
||||
Assert.Empty(host.Faux.Requests);
|
||||
|
||||
await host.RouteAsync(Updates.Forward("The moon is made of cheese.", Updates.UserOrigin("ada", "Ada", "Lovelace")));
|
||||
await FlushAsync(host, time, parts: 2);
|
||||
await host.SettleAsync();
|
||||
|
||||
Assert.EndsWith("\nis this true?\n\n[Forwarded from @ada]\nThe moon is made of cheese.", Prompt(host));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Comment_and_forward_sent_back_to_back_are_one_request_in_order()
|
||||
{
|
||||
var time = new FakeTimeProvider(DateTimeOffset.UtcNow);
|
||||
await using var host = new ChatHost(time: time);
|
||||
host.Faux.Enqueue(FauxTurn.Say("Yes, that one."));
|
||||
var comment = Updates.Text("look at this, is it the plant you mentioned?");
|
||||
var forward = Updates.Forward("Geothermal plant opens", Updates.Channel("Daily News", "Anna K."));
|
||||
|
||||
await host.RouteAsync(comment);
|
||||
await host.RouteAsync(forward);
|
||||
await FlushAsync(host, time, parts: 2);
|
||||
await host.SettleAsync();
|
||||
|
||||
Assert.Equal(
|
||||
$"{MessageContext.TimestampHeader(SentAt(comment), TimeZoneInfo.Local)}\nlook at this, is it the plant you mentioned?\n\n[Forwarded from Daily News (Anna K.)]\nGeothermal plant opens",
|
||||
Prompt(host));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task A_three_photo_album_with_one_caption_is_one_request_with_three_images_and_one_forward_label()
|
||||
{
|
||||
var time = new FakeTimeProvider(DateTimeOffset.UtcNow);
|
||||
await using var host = new ChatHost(time: time);
|
||||
host.Faux.Enqueue(FauxTurn.Say("Nice views."));
|
||||
var channel = Updates.Channel("Daily News");
|
||||
|
||||
await host.RouteAsync(Updates.AlbumPhoto("g1", "three views", channel));
|
||||
await host.RouteAsync(Updates.AlbumPhoto("g1", forwardedFrom: channel));
|
||||
await host.RouteAsync(Updates.AlbumPhoto("g1", forwardedFrom: channel));
|
||||
await FlushAsync(host, time, parts: 3);
|
||||
await host.SettleAsync();
|
||||
|
||||
var message = Assert.IsType<UserMessage>(host.Faux.Requests.Single().Messages.Last());
|
||||
Assert.Equal(3, message.Content.OfType<ImageContent>().Count());
|
||||
Assert.Single(Regex.Matches(message.Text, Regex.Escape("[Forwarded from Daily News]")));
|
||||
Assert.Contains("three views", message.Text);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Stop_is_handled_while_a_photo_download_is_blocked()
|
||||
{
|
||||
await using var host = new ChatHost();
|
||||
var download = new TaskCompletionSource();
|
||||
host.Bot.OnSend = r => { if (r is GetFileRequest) download.Task.Wait(TimeSpan.FromSeconds(10)); };
|
||||
host.Faux.Enqueue(FauxTurn.Say("too late").WithDelay(TimeSpan.FromSeconds(30)), FauxTurn.Say("A photo."));
|
||||
|
||||
await host.SendAsync("take your time");
|
||||
var chat = await host.ChatAsync();
|
||||
await Wait.UntilAsync(() => chat.Session.IsStreaming, "the run to start");
|
||||
|
||||
await host.RouteAsync(Updates.Photo()).WaitAsync(TimeSpan.FromSeconds(2)); // returns while the download hangs
|
||||
await Wait.UntilAsync(() => host.Bot.Requests.OfType<GetFileRequest>().Any(), "the download to start");
|
||||
await host.SendAsync("/stop").WaitAsync(TimeSpan.FromSeconds(2));
|
||||
await Wait.UntilAsync(() => !chat.Session.IsStreaming, "the run to stop");
|
||||
Assert.False(download.Task.IsCompleted);
|
||||
|
||||
download.SetResult();
|
||||
await host.SettleAsync();
|
||||
Assert.Contains("⏹ Stopped.", host.Texts);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task A_message_sent_during_a_run_gets_eyes_and_steers_it()
|
||||
{
|
||||
var started = new TaskCompletionSource();
|
||||
var gate = new TaskCompletionSource();
|
||||
await using var host = new ChatHost(core: o => o.AddExtension(new GateToolExtension(started, gate)));
|
||||
host.Faux.Enqueue(FauxTurn.Tool(GateToolExtension.ToolName, new { }), FauxTurn.Say("Done."));
|
||||
|
||||
await host.SendAsync("start the long job");
|
||||
await started.Task.WaitAsync(TimeSpan.FromSeconds(5));
|
||||
var steer = Updates.Text("also check the logs");
|
||||
await host.RouteAsync(steer);
|
||||
await Wait.UntilAsync(() => host.Bot.Reactions.Count == 1, "the 👀 reaction");
|
||||
gate.SetResult();
|
||||
await host.SettleAsync();
|
||||
|
||||
var reaction = Assert.Single(host.Bot.Reactions); // the first message started the run: no reaction
|
||||
Assert.Equal(steer.Message!.Id, reaction.MessageId);
|
||||
Assert.Equal("👀", Assert.IsType<ReactionTypeEmoji>(Assert.Single(reaction.Reaction!)).Emoji);
|
||||
var messages = host.Faux.Requests[1].Messages.ToList(); // the model call after the tool
|
||||
var toolResult = messages.FindLastIndex(m => m is ToolResultMessage);
|
||||
var steered = messages.FindIndex(m => m is UserMessage && m.Text.EndsWith("]\nalso check the logs", StringComparison.Ordinal));
|
||||
Assert.True(steered > toolResult, "the message is delivered as steering after the tool result");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task A_message_sent_while_idle_gets_no_reaction()
|
||||
{
|
||||
await using var host = new ChatHost();
|
||||
host.Faux.Enqueue(FauxTurn.Say("Hi."));
|
||||
|
||||
await host.SendAsync("hello");
|
||||
await host.SettleAsync();
|
||||
|
||||
Assert.Empty(host.Bot.Reactions);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task New_during_the_window_drops_the_buffered_text()
|
||||
{
|
||||
var time = new FakeTimeProvider(DateTimeOffset.UtcNow);
|
||||
await using var host = new ChatHost(time: time);
|
||||
|
||||
await host.SendAsync("forget me");
|
||||
var chat = await host.ChatAsync();
|
||||
await Wait.UntilAsync(() => chat.Inbox.Buffered == 1 && chat.Inbox.OpenLeases == 0, "the buffered text");
|
||||
await host.SendAsync("/new");
|
||||
Assert.Equal(0, chat.Inbox.Buffered);
|
||||
|
||||
time.Advance(host.Options.InputDebounce * 2);
|
||||
await host.SettleAsync();
|
||||
Assert.Empty(host.Faux.Requests);
|
||||
Assert.Contains("🆕 New conversation.", host.Texts);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Resume_during_the_window_drops_the_buffered_text()
|
||||
{
|
||||
var time = new FakeTimeProvider(DateTimeOffset.UtcNow);
|
||||
await using var host = new ChatHost(time: time);
|
||||
host.Faux.Enqueue(FauxTurn.Say("Hi."));
|
||||
await host.SendAsync("hello");
|
||||
await FlushAsync(host, time, parts: 1);
|
||||
await host.SettleAsync();
|
||||
var chat = await host.ChatAsync();
|
||||
var earlier = chat.Session.SessionId;
|
||||
await host.SendAsync("/new");
|
||||
|
||||
await host.SendAsync("forget me");
|
||||
await Wait.UntilAsync(() => chat.Inbox.Buffered == 1 && chat.Inbox.OpenLeases == 0, "the buffered text");
|
||||
await host.SendAsync($"/resume {earlier}");
|
||||
Assert.Equal(0, chat.Inbox.Buffered);
|
||||
|
||||
time.Advance(host.Options.InputDebounce * 2);
|
||||
await host.SettleAsync();
|
||||
Assert.Single(host.Faux.Requests);
|
||||
Assert.Equal(earlier, chat.Session.SessionId);
|
||||
Assert.Contains(host.Texts, t => t.StartsWith("Resumed", StringComparison.Ordinal) && t.Contains(earlier, StringComparison.Ordinal));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Stop_during_the_window_drops_the_buffered_text()
|
||||
{
|
||||
var time = new FakeTimeProvider(DateTimeOffset.UtcNow);
|
||||
await using var host = new ChatHost(time: time);
|
||||
|
||||
await host.SendAsync("never mind");
|
||||
var chat = await host.ChatAsync();
|
||||
await Wait.UntilAsync(() => chat.Inbox.Buffered == 1 && chat.Inbox.OpenLeases == 0, "the buffered text");
|
||||
await host.SendAsync("/stop");
|
||||
Assert.Equal(0, chat.Inbox.Buffered);
|
||||
|
||||
time.Advance(host.Options.InputDebounce * 2);
|
||||
await host.SettleAsync();
|
||||
Assert.Empty(host.Faux.Requests);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task A_forwarded_command_is_content_not_a_command()
|
||||
{
|
||||
await using var host = new ChatHost();
|
||||
host.Faux.Enqueue(FauxTurn.Say("Noted."));
|
||||
await host.SendAsync("/status"); // creates the chat
|
||||
var chat = await host.ChatAsync();
|
||||
var session = chat.Session.SessionId;
|
||||
|
||||
await host.RouteAsync(Updates.Forward("/new beginnings", Updates.Channel("Daily News")));
|
||||
await host.SettleAsync();
|
||||
|
||||
Assert.Equal(session, chat.Session.SessionId);
|
||||
Assert.DoesNotContain("🆕 New conversation.", host.Texts);
|
||||
Assert.EndsWith("\n[Forwarded from Daily News]\n/new beginnings", Prompt(host), StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task A_forwarded_text_while_input_is_pending_goes_to_the_agent()
|
||||
{
|
||||
await using var host = new ChatHost();
|
||||
host.Faux.Enqueue(FauxTurn.Say("Interesting."));
|
||||
await host.SendAsync("/status");
|
||||
var chat = await host.ChatAsync();
|
||||
var answer = chat.Ui.InputAsync("Your name?", "name", null, CancellationToken.None);
|
||||
await Wait.UntilAsync(() => chat.Ui.IsWaitingForInput, "the question");
|
||||
|
||||
await host.RouteAsync(Updates.Forward("Ada Lovelace", Updates.UserOrigin("ada", "Ada", "Lovelace")));
|
||||
await host.SettleAsync();
|
||||
|
||||
Assert.True(chat.Ui.IsWaitingForInput);
|
||||
Assert.False(answer.IsCompleted);
|
||||
Assert.EndsWith("\n[Forwarded from @ada]\nAda Lovelace", Prompt(host));
|
||||
await host.SendAsync("Ada");
|
||||
Assert.Equal("Ada", await answer.WaitAsync(TimeSpan.FromSeconds(5)));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Unsupported_messages_keep_the_old_reply_and_take_no_lease()
|
||||
{
|
||||
await using var host = new ChatHost();
|
||||
|
||||
await host.RouteAsync(Updates.StickerMessage());
|
||||
|
||||
Assert.Equal("I can read text and photos.", Assert.Single(host.Bot.Sent).Text);
|
||||
Assert.Null(host.Chats.Find(Updates.AllowedUser));
|
||||
Assert.Empty(host.Faux.Requests);
|
||||
}
|
||||
}
|
||||
237
tests/Volcanic.AgenticCore.Telegram.Tests/MessageContextTests.cs
Normal file
237
tests/Volcanic.AgenticCore.Telegram.Tests/MessageContextTests.cs
Normal file
@@ -0,0 +1,237 @@
|
||||
using System.Globalization;
|
||||
using Telegram.Bot.Types;
|
||||
using Telegram.Bot.Types.Enums;
|
||||
using Volcanic.AgenticCore.Telegram.Inbound;
|
||||
|
||||
namespace Volcanic.AgenticCore.Telegram.Tests;
|
||||
|
||||
public sealed class MessageContextTests
|
||||
{
|
||||
private const long BotId = 42;
|
||||
private static readonly User Bot = new() { Id = BotId, IsBot = true, FirstName = "Bot" };
|
||||
private static readonly User Me = new() { Id = 1001, FirstName = "Ada" };
|
||||
|
||||
internal static Message Msg(string? text = null, User? from = null) => new()
|
||||
{
|
||||
Id = 7,
|
||||
Chat = new Chat { Id = 1001, Type = ChatType.Private },
|
||||
Date = DateTime.UtcNow,
|
||||
Text = text,
|
||||
From = from,
|
||||
};
|
||||
|
||||
private static Message ReplyTo(Message replied, string? quote = null)
|
||||
{
|
||||
var m = Msg("and?", Me);
|
||||
m.ReplyToMessage = replied;
|
||||
if (quote is not null) m.Quote = new TextQuote { Text = quote, Position = 0 };
|
||||
return m;
|
||||
}
|
||||
|
||||
internal static MessageOriginChannel Channel(string? title, string? username = null, string? signature = null) => new()
|
||||
{
|
||||
Chat = new Chat { Id = -100, Type = ChatType.Channel, Title = title, Username = username },
|
||||
MessageId = 1,
|
||||
AuthorSignature = signature,
|
||||
Date = DateTime.UtcNow,
|
||||
};
|
||||
|
||||
[Fact]
|
||||
public void No_reply_means_no_header() => Assert.Null(MessageContext.ReplyHeader(Msg("hi", Me), BotId));
|
||||
|
||||
[Fact]
|
||||
public void Reply_to_the_bot_says_your_message() =>
|
||||
Assert.Equal("[Reply to your message: \"Which plant do you mean?\"]",
|
||||
MessageContext.ReplyHeader(ReplyTo(Msg("Which plant do you mean?", Bot)), BotId));
|
||||
|
||||
[Fact]
|
||||
public void Reply_to_self_says_your_own_earlier_message() =>
|
||||
Assert.Equal("[Reply to your own earlier message: \"buy milk\"]",
|
||||
MessageContext.ReplyHeader(ReplyTo(Msg("buy milk", Me)), BotId));
|
||||
|
||||
[Fact]
|
||||
public void Reply_without_from_says_the_earlier_message() =>
|
||||
Assert.Equal("[Reply to the earlier message: \"old news\"]",
|
||||
MessageContext.ReplyHeader(ReplyTo(Msg("old news")), BotId));
|
||||
|
||||
[Fact]
|
||||
public void Quote_of_the_bot_uses_the_quote_text() =>
|
||||
Assert.Equal("[Reply quoting your message: \"The key part.\"]",
|
||||
MessageContext.ReplyHeader(ReplyTo(Msg("A long answer. The key part.", Bot), quote: "The key part."), BotId));
|
||||
|
||||
[Fact]
|
||||
public void Quote_of_self_uses_the_quote_text() =>
|
||||
Assert.Equal("[Reply quoting your own earlier message: \"milk\"]",
|
||||
MessageContext.ReplyHeader(ReplyTo(Msg("buy milk", Me), quote: "milk"), BotId));
|
||||
|
||||
private static Message Media(string kind)
|
||||
{
|
||||
var m = Msg(null, Bot);
|
||||
switch (kind)
|
||||
{
|
||||
case "photo": m.Photo = [new PhotoSize { FileId = "p", FileUniqueId = "pu", Width = 1, Height = 1 }]; break;
|
||||
case "video": m.Video = new Video { FileId = "v", FileUniqueId = "vu", Width = 1, Height = 1, Duration = 1 }; break;
|
||||
case "voice": m.Voice = new Voice { FileId = "o", FileUniqueId = "ou", Duration = 1 }; break;
|
||||
case "videonote": m.VideoNote = new VideoNote { FileId = "n", FileUniqueId = "nu", Length = 1, Duration = 1 }; break;
|
||||
case "audio": m.Audio = new Audio { FileId = "a", FileUniqueId = "au", Duration = 1, FileName = "song.mp3" }; break;
|
||||
case "unnamed-audio": m.Audio = new Audio { FileId = "a", FileUniqueId = "au", Duration = 1 }; break;
|
||||
case "document": m.Document = new Document { FileId = "d", FileUniqueId = "du", FileName = "report.pdf" }; break;
|
||||
case "unnamed-document": m.Document = new Document { FileId = "d", FileUniqueId = "du" }; break;
|
||||
case "sticker": m.Sticker = new Sticker { FileId = "s", FileUniqueId = "su", Type = StickerType.Regular, Width = 1, Height = 1, Emoji = "👍" }; break;
|
||||
}
|
||||
return m;
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("photo", "(photo)")]
|
||||
[InlineData("video", "(video)")]
|
||||
[InlineData("voice", "(voice message)")]
|
||||
[InlineData("videonote", "(video message)")]
|
||||
[InlineData("audio", "(audio: song.mp3)")]
|
||||
[InlineData("unnamed-audio", "(audio)")]
|
||||
[InlineData("document", "(document: report.pdf)")]
|
||||
[InlineData("unnamed-document", "(document)")]
|
||||
[InlineData("sticker", "(sticker 👍)")]
|
||||
public void Replied_media_is_described(string kind, string descriptor) =>
|
||||
Assert.Equal($"[Reply to your message: \"{descriptor}\"]", MessageContext.ReplyHeader(ReplyTo(Media(kind)), BotId));
|
||||
|
||||
[Fact]
|
||||
public void Caption_beats_the_descriptor()
|
||||
{
|
||||
var photo = Media("photo");
|
||||
photo.Caption = "my cat";
|
||||
Assert.Equal("[Reply to your message: \"my cat\"]", MessageContext.ReplyHeader(ReplyTo(photo), BotId));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Empty_reply_is_described_as_message() =>
|
||||
Assert.Equal("[Reply to your message: \"(message)\"]", MessageContext.ReplyHeader(ReplyTo(Msg(null, Bot)), BotId));
|
||||
|
||||
[Fact]
|
||||
public void Long_text_is_cut_to_499_runes_and_an_ellipsis_without_splitting_a_surrogate_pair()
|
||||
{
|
||||
// 600 runes; rune 499 is the emoji (a surrogate pair), so a char-based cut at 499 would split it.
|
||||
var text = new string('a', 498) + "😀" + new string('b', 101);
|
||||
|
||||
var cut = MessageContext.Truncate(text);
|
||||
|
||||
Assert.Equal(new string('a', 498) + "😀…", cut);
|
||||
Assert.Equal(500, cut.EnumerateRunes().Count());
|
||||
Assert.Equal("short", MessageContext.Truncate("short"));
|
||||
Assert.Equal($"[Reply to your message: \"{cut}\"]", MessageContext.ReplyHeader(ReplyTo(Msg(text, Bot)), BotId));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void External_reply_without_a_quote_describes_the_media()
|
||||
{
|
||||
var m = Msg("is this real?", Me);
|
||||
m.ExternalReply = new ExternalReplyInfo
|
||||
{
|
||||
Origin = Channel("Daily News"),
|
||||
Photo = [new PhotoSize { FileId = "p", FileUniqueId = "pu", Width = 1, Height = 1 }],
|
||||
};
|
||||
Assert.Equal("[Reply to a message from Daily News: \"(photo)\"]", MessageContext.ReplyHeader(m, BotId));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void External_reply_with_a_quote_uses_the_quote()
|
||||
{
|
||||
var m = Msg("is this real?", Me);
|
||||
m.ExternalReply = new ExternalReplyInfo { Origin = Channel("Daily News") };
|
||||
m.Quote = new TextQuote { Text = "Geothermal plant opens", Position = 0 };
|
||||
Assert.Equal("[Reply quoting a message from Daily News: \"Geothermal plant opens\"]", MessageContext.ReplyHeader(m, BotId));
|
||||
}
|
||||
|
||||
private static MessageOriginUser UserOrigin(string first, string? last = null, string? username = null) =>
|
||||
new() { SenderUser = new User { Id = 5, FirstName = first, LastName = last, Username = username }, Date = DateTime.UtcNow };
|
||||
|
||||
[Fact]
|
||||
public void Forward_labels_for_users()
|
||||
{
|
||||
Assert.Equal("@ada", MessageContext.ForwardLabel(UserOrigin("Ada", "Lovelace", "ada")));
|
||||
Assert.Equal("Ada Lovelace", MessageContext.ForwardLabel(UserOrigin("Ada", "Lovelace")));
|
||||
Assert.Equal("Ada", MessageContext.ForwardLabel(UserOrigin("Ada")));
|
||||
Assert.Equal("unknown user", MessageContext.ForwardLabel(UserOrigin("")));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Forward_labels_for_hidden_users()
|
||||
{
|
||||
Assert.Equal("Ada L.", MessageContext.ForwardLabel(new MessageOriginHiddenUser { SenderUserName = "Ada L.", Date = DateTime.UtcNow }));
|
||||
Assert.Equal("hidden user", MessageContext.ForwardLabel(new MessageOriginHiddenUser { SenderUserName = "", Date = DateTime.UtcNow }));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Forward_labels_for_chats()
|
||||
{
|
||||
MessageOriginChat Chat(string? title, string? username, string? signature) => new()
|
||||
{
|
||||
SenderChat = new Chat { Id = -5, Type = ChatType.Supergroup, Title = title, Username = username },
|
||||
AuthorSignature = signature,
|
||||
Date = DateTime.UtcNow,
|
||||
};
|
||||
Assert.Equal("Plant Lovers (admin)", MessageContext.ForwardLabel(Chat("Plant Lovers", "plants", "admin")));
|
||||
Assert.Equal("@plants", MessageContext.ForwardLabel(Chat(null, "plants", null)));
|
||||
Assert.Equal("chat", MessageContext.ForwardLabel(Chat(null, null, null)));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Forward_labels_for_channels()
|
||||
{
|
||||
Assert.Equal("Daily News (Anna K.)", MessageContext.ForwardLabel(Channel("Daily News", "dailynews", "Anna K.")));
|
||||
Assert.Equal("Daily News", MessageContext.ForwardLabel(Channel("Daily News")));
|
||||
Assert.Equal("@dailynews", MessageContext.ForwardLabel(Channel(null, "dailynews")));
|
||||
Assert.Equal("channel", MessageContext.ForwardLabel(Channel(null)));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Your_message_is_decided_by_the_bot_id_not_IsBot()
|
||||
{
|
||||
Assert.Equal("[Reply to your message: \"x\"]",
|
||||
MessageContext.ReplyHeader(ReplyTo(Msg("x", new User { Id = BotId, IsBot = false, FirstName = "B" })), BotId));
|
||||
Assert.Equal("[Reply to your own earlier message: \"x\"]",
|
||||
MessageContext.ReplyHeader(ReplyTo(Msg("x", new User { Id = 5, IsBot = true, FirstName = "Other" })), BotId));
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("2026-09-26T12:03:00Z", "Europe/Berlin", "[Sat 2026-09-26 14:03 Europe/Berlin]")]
|
||||
[InlineData("2026-09-26T23:30:00Z", "Asia/Tokyo", "[Sun 2026-09-27 08:30 Asia/Tokyo]")]
|
||||
public void Timestamp_header_follows_the_zone(string at, string zone, string expected) =>
|
||||
Assert.Equal(expected, MessageContext.TimestampHeader(
|
||||
DateTimeOffset.Parse(at, CultureInfo.InvariantCulture), TimeZoneInfo.FindSystemTimeZoneById(zone)));
|
||||
|
||||
[Fact]
|
||||
public void Timestamp_header_ignores_the_current_culture()
|
||||
{
|
||||
var culture = CultureInfo.CurrentCulture;
|
||||
CultureInfo.CurrentCulture = new CultureInfo("de-DE");
|
||||
try
|
||||
{
|
||||
Assert.Equal("[Sat 2026-09-26 14:03 Europe/Berlin]", MessageContext.TimestampHeader(
|
||||
new DateTimeOffset(2026, 9, 26, 12, 3, 0, TimeSpan.Zero), TimeZoneInfo.FindSystemTimeZoneById("Europe/Berlin")));
|
||||
}
|
||||
finally
|
||||
{
|
||||
CultureInfo.CurrentCulture = culture;
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Reply_header_comes_before_the_forward_label_and_the_body()
|
||||
{
|
||||
var m = Msg("Geothermal plant opens", Me);
|
||||
m.ReplyToMessage = Msg("Which plant do you mean?", Bot);
|
||||
m.ForwardOrigin = Channel("Daily News", signature: "Anna K.");
|
||||
var part = new InboundPart(m.Id, new DateTimeOffset(2026, 9, 26, 12, 3, 0, TimeSpan.Zero), null,
|
||||
MessageContext.ReplyHeader(m, BotId), MessageContext.ForwardLabel(m.ForwardOrigin), [m.Text!], []);
|
||||
|
||||
var (text, _) = InboundBatch.Render([part], TimeZoneInfo.FindSystemTimeZoneById("Europe/Berlin"));
|
||||
|
||||
Assert.Equal("""
|
||||
[Sat 2026-09-26 14:03 Europe/Berlin]
|
||||
[Reply to your message: "Which plant do you mean?"]
|
||||
[Forwarded from Daily News (Anna K.)]
|
||||
Geothermal plant opens
|
||||
""".ReplaceLineEndings("\n"), text);
|
||||
}
|
||||
}
|
||||
@@ -1,11 +1,13 @@
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Telegram.Bot.Types;
|
||||
using Volcanic.AgenticCore.Features.Agent;
|
||||
using Volcanic.AgenticCore.Features.Extensions;
|
||||
using Volcanic.AgenticCore.Features.Sessions;
|
||||
using Volcanic.AgenticCore.Hosting;
|
||||
using Volcanic.AgenticCore.Telegram.Chats;
|
||||
using Volcanic.AgenticCore.Telegram.Inbound;
|
||||
using Volcanic.AgenticCore.Telegram.Rendering;
|
||||
using Volcanic.AgenticCore.Testing;
|
||||
|
||||
@@ -26,7 +28,7 @@ public sealed class ChatHost : IAsyncDisposable
|
||||
private readonly ServiceProvider _services;
|
||||
private readonly string _root;
|
||||
|
||||
public ChatHost(Action<ChatHost, IServiceCollection>? configure = null, Action<AgenticCoreOptions>? core = null)
|
||||
public ChatHost(Action<ChatHost, IServiceCollection>? configure = null, Action<AgenticCoreOptions>? core = null, TimeProvider? time = null)
|
||||
{
|
||||
_root = Path.Combine(Path.GetTempPath(), "volcanic-telegram", Guid.NewGuid().ToString("N"));
|
||||
Options = new TelegramOptions
|
||||
@@ -37,6 +39,7 @@ public sealed class ChatHost : IAsyncDisposable
|
||||
Model = FauxProvider.ModelKey,
|
||||
EditInterval = TimeSpan.FromMilliseconds(20),
|
||||
TypingInterval = TimeSpan.FromMilliseconds(50),
|
||||
InputDebounce = TimeSpan.FromMilliseconds(20),
|
||||
};
|
||||
var store = new InMemorySessionStore();
|
||||
var services = new ServiceCollection();
|
||||
@@ -54,18 +57,23 @@ public sealed class ChatHost : IAsyncDisposable
|
||||
_services = services.BuildServiceProvider();
|
||||
ILoggerFactory loggers = NullLoggerFactory.Instance;
|
||||
var sender = new TelegramSender(Bot, NullLogger<TelegramSender>.Instance);
|
||||
Chats = new ChatRegistry(_services.GetRequiredService<IAgentSessionFactory>(), sender, Options, loggers);
|
||||
Router = new UpdateRouter(Options, Chats, new ChatCommands(sender), sender, NullLogger<UpdateRouter>.Instance);
|
||||
Chats = new ChatRegistry(_services.GetRequiredService<IAgentSessionFactory>(), sender, Options, loggers, time);
|
||||
Router = new UpdateRouter(Options, Chats, new ChatCommands(sender), sender,
|
||||
new InboundComposer(sender, NullLogger<InboundComposer>.Instance), NullLogger<UpdateRouter>.Instance);
|
||||
}
|
||||
|
||||
public Task SendAsync(string text, long userId = Updates.AllowedUser) => Router.RouteAsync(Updates.Text(text, userId), CancellationToken.None);
|
||||
|
||||
public Task RouteAsync(Update update) => Router.RouteAsync(update, CancellationToken.None);
|
||||
|
||||
public async Task<ChatContext> ChatAsync(long chatId = Updates.AllowedUser) => await Chats.GetAsync(chatId);
|
||||
|
||||
/// <summary>Wait until the chat's run has settled and everything it produced is rendered.</summary>
|
||||
/// <summary>Wait until the chat's inbox has flushed, its run has settled and everything it produced is rendered.</summary>
|
||||
/// <remarks>With a <see cref="Microsoft.Extensions.Time.Testing.FakeTimeProvider"/>, advance past the input window before settling, or the drain times out.</remarks>
|
||||
public async Task SettleAsync(long chatId = Updates.AllowedUser)
|
||||
{
|
||||
var chat = await ChatAsync(chatId);
|
||||
await chat.Inbox.DrainAsync().WaitAsync(TimeSpan.FromSeconds(10));
|
||||
await chat.LastPrompt.WaitAsync(TimeSpan.FromSeconds(10));
|
||||
await Wait.UntilAsync(() => !chat.Session.IsStreaming, "the run to settle");
|
||||
await chat.Renderer.DrainAsync().WaitAsync(TimeSpan.FromSeconds(5));
|
||||
|
||||
@@ -36,6 +36,7 @@ public sealed class FakeBotClient : ITelegramBotClient
|
||||
private readonly List<int?> _pollOffsets = [];
|
||||
public IReadOnlyList<SendMessageRequest> Sent => Requests.OfType<SendMessageRequest>().ToList();
|
||||
public IReadOnlyList<EditMessageTextRequest> Edits => Requests.OfType<EditMessageTextRequest>().ToList();
|
||||
public IReadOnlyList<SetMessageReactionRequest> Reactions => Requests.OfType<SetMessageReactionRequest>().ToList();
|
||||
|
||||
/// <summary>The text each message still in the chat shows now (after edits and deletes), by message id.</summary>
|
||||
public IReadOnlyDictionary<int, string> CurrentTexts => new Dictionary<int, string>(_texts);
|
||||
@@ -98,6 +99,9 @@ public sealed class FakeBotClient : ITelegramBotClient
|
||||
_texts.TryRemove(delete.MessageId, out _);
|
||||
result = true;
|
||||
break;
|
||||
case GetFileRequest getFile:
|
||||
result = new TGFile { FileId = getFile.FileId, FileUniqueId = getFile.FileId + "-u", FilePath = $"photos/{getFile.FileId}.jpg", FileSize = 3 };
|
||||
break;
|
||||
default:
|
||||
result = request is GetMeRequest ? new User { Id = BotId, IsBot = true, FirstName = "Fake", Username = "fake_bot" } : true;
|
||||
break;
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
using Volcanic.AgenticCore.Abstractions.Extensions;
|
||||
using Volcanic.AgenticCore.Abstractions.Tools;
|
||||
|
||||
namespace Volcanic.AgenticCore.Telegram.Tests.Support;
|
||||
|
||||
/// <summary>A tool that signals <c>started</c> and then waits until the test completes <c>gate</c>: a run that stays active on demand.</summary>
|
||||
public sealed class GateToolExtension(TaskCompletionSource started, TaskCompletionSource gate) : IExtension
|
||||
{
|
||||
public const string ToolName = "wait_for_gate";
|
||||
|
||||
public sealed class Args;
|
||||
|
||||
public string Name => "gate-tool";
|
||||
|
||||
public ValueTask ActivateAsync(IExtensionApi api, CancellationToken cancellationToken)
|
||||
{
|
||||
api.RegisterTool(new DelegateTool<Args>(ToolName, "Waits until the test opens the gate.", async (_, _, ct) =>
|
||||
{
|
||||
started.TrySetResult();
|
||||
await gate.Task.WaitAsync(ct);
|
||||
return ToolResult.Text("opened");
|
||||
}));
|
||||
return ValueTask.CompletedTask;
|
||||
}
|
||||
}
|
||||
@@ -41,6 +41,98 @@ public static class Updates
|
||||
|
||||
public static Update WithId(int id) => new() { Id = id, Message = Text("#" + id).Message };
|
||||
|
||||
/// <summary>The fake bot's user id (<see cref="FakeBotClient.BotId"/>).</summary>
|
||||
public const long BotUserId = 42;
|
||||
|
||||
private static Message NewMessage(int? id, long userId = AllowedUser) => new()
|
||||
{
|
||||
Id = id ?? Interlocked.Increment(ref _nextMessageId),
|
||||
From = new User { Id = userId, FirstName = "Test" },
|
||||
Chat = new Chat { Id = userId, Type = ChatType.Private },
|
||||
Date = DateTime.UtcNow,
|
||||
};
|
||||
|
||||
private static Update Wrap(Message message) => new() { Id = Interlocked.Increment(ref _nextUpdateId), Message = message };
|
||||
|
||||
/// <summary>An earlier message sent by the bot, for <see cref="Reply"/> and <see cref="Quote"/>.</summary>
|
||||
public static Message BotMessage(string text, int? id = null)
|
||||
{
|
||||
var m = NewMessage(id);
|
||||
m.From = new User { Id = BotUserId, IsBot = true, FirstName = "Fake", Username = "fake_bot" };
|
||||
m.Text = text;
|
||||
return m;
|
||||
}
|
||||
|
||||
/// <summary>A text message replying to <paramref name="replyTo"/>.</summary>
|
||||
public static Update Reply(string text, Message replyTo, int? id = null)
|
||||
{
|
||||
var m = NewMessage(id);
|
||||
m.Text = text;
|
||||
m.ReplyToMessage = replyTo;
|
||||
return Wrap(m);
|
||||
}
|
||||
|
||||
/// <summary>A text message replying to <paramref name="replyTo"/> with a selected <paramref name="quote"/>.</summary>
|
||||
public static Update Quote(string text, Message replyTo, string quote, int? id = null)
|
||||
{
|
||||
var update = Reply(text, replyTo, id);
|
||||
update.Message!.Quote = new TextQuote { Text = quote, Position = 0 };
|
||||
return update;
|
||||
}
|
||||
|
||||
/// <summary>A text message replying to a message from another chat (Telegram's "reply in another chat").</summary>
|
||||
public static Update ExternalReply(string text, MessageOrigin origin, string? quote = null, int? id = null)
|
||||
{
|
||||
var m = NewMessage(id);
|
||||
m.Text = text;
|
||||
m.ExternalReply = new ExternalReplyInfo { Origin = origin };
|
||||
if (quote is not null) m.Quote = new TextQuote { Text = quote, Position = 0 };
|
||||
return Wrap(m);
|
||||
}
|
||||
|
||||
/// <summary>A forwarded text message.</summary>
|
||||
public static Update Forward(string text, MessageOrigin origin, int? id = null)
|
||||
{
|
||||
var m = NewMessage(id);
|
||||
m.Text = text;
|
||||
m.ForwardOrigin = origin;
|
||||
return Wrap(m);
|
||||
}
|
||||
|
||||
/// <summary>One photo of an album (media group <paramref name="groupId"/>), with its own file id.</summary>
|
||||
public static Update AlbumPhoto(string groupId, string? caption = null, MessageOrigin? forwardedFrom = null, int? id = null)
|
||||
{
|
||||
var m = NewMessage(id);
|
||||
m.MediaGroupId = groupId;
|
||||
m.Photo = [new PhotoSize { FileId = $"photo-{m.Id}", FileUniqueId = $"photo-{m.Id}-u", Width = 10, Height = 10 }];
|
||||
m.Caption = caption;
|
||||
m.ForwardOrigin = forwardedFrom;
|
||||
return Wrap(m);
|
||||
}
|
||||
|
||||
public static Update StickerMessage(string emoji = "👍")
|
||||
{
|
||||
var m = NewMessage(null);
|
||||
m.Sticker = new Sticker { FileId = "sticker", FileUniqueId = "sticker-u", Type = StickerType.Regular, Width = 1, Height = 1, Emoji = emoji };
|
||||
return Wrap(m);
|
||||
}
|
||||
|
||||
/// <summary>A channel post origin, optionally signed by its author.</summary>
|
||||
public static MessageOriginChannel Channel(string title, string? signature = null) => new()
|
||||
{
|
||||
Chat = new Chat { Id = -100, Type = ChatType.Channel, Title = title },
|
||||
MessageId = 1,
|
||||
AuthorSignature = signature,
|
||||
Date = DateTime.UtcNow,
|
||||
};
|
||||
|
||||
/// <summary>A visible user origin. Named <c>UserOrigin</c> so it doesn't hide the <see cref="User"/> type in this class.</summary>
|
||||
public static MessageOriginUser UserOrigin(string username, string first, string? last = null) => new()
|
||||
{
|
||||
SenderUser = new User { Id = 7, FirstName = first, LastName = last, Username = username },
|
||||
Date = DateTime.UtcNow,
|
||||
};
|
||||
|
||||
public static Update Callback(string data, long chatId, long userId = AllowedUser) => new()
|
||||
{
|
||||
Id = Interlocked.Increment(ref _nextUpdateId),
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Telegram.Bot.Requests;
|
||||
using Telegram.Bot.Types;
|
||||
using Volcanic.AgenticCore.Telegram.Rendering;
|
||||
using Volcanic.AgenticCore.Telegram.Tests.Support;
|
||||
|
||||
namespace Volcanic.AgenticCore.Telegram.Tests;
|
||||
|
||||
public sealed class TelegramSenderTests
|
||||
{
|
||||
[Fact]
|
||||
public async Task ReactAsync_sets_an_emoji_reaction()
|
||||
{
|
||||
var bot = new FakeBotClient();
|
||||
var sender = new TelegramSender(bot, NullLogger<TelegramSender>.Instance);
|
||||
|
||||
await sender.ReactAsync(1001, 55, "👀");
|
||||
|
||||
var reaction = Assert.Single(bot.Reactions);
|
||||
Assert.Equal(1001, reaction.ChatId.Identifier);
|
||||
Assert.Equal(55, reaction.MessageId);
|
||||
Assert.Equal("👀", Assert.IsType<ReactionTypeEmoji>(Assert.Single(reaction.Reaction!)).Emoji);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ReactAsync_swallows_failures()
|
||||
{
|
||||
var bot = new FakeBotClient { OnSend = r => { if (r is SetMessageReactionRequest) throw FakeBotClient.NetworkDown(); } };
|
||||
var sender = new TelegramSender(bot, NullLogger<TelegramSender>.Instance);
|
||||
|
||||
await sender.ReactAsync(1001, 55, "👀"); // does not throw
|
||||
|
||||
Assert.Single(bot.Reactions);
|
||||
}
|
||||
}
|
||||
@@ -8,6 +8,7 @@
|
||||
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.14.1" />
|
||||
<PackageReference Include="xunit" Version="2.9.3" />
|
||||
<PackageReference Include="xunit.runner.visualstudio" Version="3.1.1" />
|
||||
<PackageReference Include="Microsoft.Extensions.TimeProvider.Testing" Version="10.9.0" />
|
||||
</ItemGroup>
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="../../hosts/Volcanic.AgenticCore.Telegram/Volcanic.AgenticCore.Telegram.csproj" />
|
||||
|
||||
Reference in New Issue
Block a user