Merge branch 'feat/telegram-attachments'

This commit is contained in:
EugeneTes
2026-09-26 22:37:40 +02:00
62 changed files with 5438 additions and 95 deletions

View File

@@ -45,8 +45,8 @@ Read `docs/architecture.md` first. Key points:
- **`src/Volcanic.AgenticCore.Abstractions`** holds the contracts: messages, `Model`/`IProvider`, the streaming
event protocol, `ITool`/`Tool<TArgs>`, `IExtension`/`IExtensionApi` and extension events, session entries/stores,
`IUserInterface`, and `AgentSessionEvent`. Extensions reference **only** this assembly, so it stays small and
stable. Don't let runtime types leak into it.
`IUserInterface`, `IChatChannel`, and `AgentSessionEvent`. Extensions reference **only** this assembly, so it stays
small and stable. Don't let runtime types leak into it.
- **`src/Volcanic.AgenticCore`** is organised as **vertical slices** under `Features/` (`Agent`, `Sessions`, `Models`,
`Tools`, `Extensions`, `Commands`, `SystemPrompt`, `Compaction`, `Processes`). Each slice exposes one entry type
(`AgentSession`, `SessionManager`, `ModelRegistry`, `ToolRegistry`, `ExtensionRunner`, `CommandRegistry`,
@@ -103,7 +103,10 @@ Long polling (`Polling/`, with `ReconnectPolicy` backoff). Each private chat get
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
with a local-time header. Files, audio and voice are saved to `<workspace>/inbox/` (voice transcribed by
`Speech/OpenAiSpeechToText` when `OPENAI_API_KEY` is set) through `InboundComposer`'s content steps
(`Inbound/InboundMedia`), and `Chats/TelegramChatChannel` implements `IChatChannel` for the core `send` tool through
`ChatRenderer.RunInOrderAsync`. 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`.

View File

@@ -12,7 +12,8 @@ tests/* xunit
`Abstractions` is deliberately small and stable: messages and content blocks, `Model` and `IProvider`,
the streaming event protocol, `ITool`, the extension API and its events, session entries and stores,
`IUserInterface`, the `AgentSessionEvent` family a UI subscribes to, and the process contract in `Processes/`
`IUserInterface`, `IChatChannel` (delivering files and texts into the conversation's chat), the `AgentSessionEvent`
family a UI subscribes to, and the process contract in `Processes/`
(`IProcessSpawnPolicy`, `ProcessSpawnRequest`/`ProcessSpawnContext`, `ProcessSpawnHook`, `ProcessSpawnEvent`,
`ISecretRedactor`). Extensions reference only this assembly, which is what allows them to be loaded from separate
directories.
@@ -29,7 +30,7 @@ owns everything for that feature and exposes one entry type:
|---|---|---|
| `Models` | `ModelRegistry` | Provider registrations, `provider/id` lookup, API key resolution, transcript normalisation before a call |
| `Sessions` | `SessionManager` | The entry tree of one session, branch/context building, `ISessionStore` implementations |
| `Tools` | `ToolRegistry` | Known and active tools, argument validation, output truncation, the built-in tools |
| `Tools` | `ToolRegistry` | Known and active tools, argument validation, output truncation, the built-in tools (`send` only for sessions with a channel) |
| `Extensions` | `ExtensionRunner` | Activating extensions (two-phase), dispatching events, contexts, the event bus, assembly loading |
| `Commands` | `CommandRegistry` | Slash commands |
| `SystemPrompt` | `SystemPromptBuilder` | Base prompt, tools section, guidelines, project context files, environment |

View File

@@ -115,7 +115,8 @@ fails the spawn); `Cancel`, `Block` and `MarkHandled` stop further handlers.
status), `SessionManager` (read-only entries and branch), `ModelRegistry` (models and provider-neutral
`CompleteAsync` for nested model calls), `Model`, `ThinkingLevel`, `Signal` (cancelled on abort), `IsIdle()`,
`Abort()`, `HasPendingMessages()`, `GetContextUsage()`, `GetSystemPrompt()`, `Compact()`,
`PrepareProcessAsync(request)` (see [spawning processes](#spawning-processes)).
`PrepareProcessAsync(request)` (see [spawning processes](#spawning-processes)), `Channel` (the host's
`IChatChannel`, or null).
Command handlers get `IExtensionCommandContext`, which adds `WaitForIdleAsync`, `NewSessionAsync` and
`SwitchSessionAsync`.

View File

@@ -78,7 +78,7 @@ Configured values win over the variables; an empty variable counts as unset. The
- Each chat has its own browser profile under `.volcanic/browser-profile`, so site logins survive idle restarts,
`/new` and bot restarts. The agent's `bash` can read that profile, like everything else in its workspace.
- The system prompt gets a short "Browser" section while the browser tools are active. It mentions `send` only when
a `send` tool exists.
a `send` tool is active.
### Deployment requirements

File diff suppressed because it is too large Load Diff

View File

@@ -194,8 +194,9 @@ callTimeoutSeconds: 120, startupTimeoutSeconds: 120 (the first npx run downloads
- Don't enter passwords or payment details unless the user explicitly asks for that specific site.
```
The `send` line is included only when a tool named `send` is registered (checked at prompt time through
`GetAllTools()`), so the preset still reads correctly on hosts without attachments. Without `send`, that bullet
The `send` line is included only when a tool named `send` is active (checked at prompt time through
`GetActiveTools()`; a registered but inactive `send` isn't callable), so the preset still reads correctly on hosts
without attachments. Without `send`, that bullet
reads: "Files the browser saves (screenshots with a `filename` such as `browser/<name>.png`, PDFs, downloads) are
under browser/ in the workspace."

View File

@@ -1,7 +1,7 @@
# Telegram attachments (files in, voice in, files out) — design
Date: 2026-09-26
Status: draft
Status: implemented
Depends on: `2026-09-26-telegram-message-context-design.md` (`ChatInbox`, `InboundComposer` content steps, lease
contract); `2026-09-26-process-environment-hook-design.md` (keeps `OPENAI_API_KEY` out of `bash`). Used by:
`2026-09-26-scheduler-design.md` (`IChatChannel.SendTextAsync`).
@@ -118,10 +118,14 @@ ExecutionMode: Sequential (two sends in one turn arrive in call order)
Execution:
1. `ctx.Channel` is null → throw `send is not available: this session has no chat` (defensive; see registration).
2. Resolve with the existing `Paths.Resolve(ctx.Cwd, path)`. Reject unless the full path is inside `ctx.Cwd` (compare
with a trailing separator, case-insensitively on Windows). If the file or any parent is a symlink, resolve it with
`ResolveLinkTarget(returnFinalTarget: true)` and apply the same check to the target. Errors are
`path escapes the workspace: <path>`, `file not found: <path>` and `path is a directory, not a file: <path>`.
2. Resolve with the existing `Paths.Resolve(ctx.Cwd, path)`. Reject unless the full path is inside `ctx.Cwd` or is
`ctx.Cwd` itself (compare with a trailing separator, case-insensitively on Windows). Then resolve the real path the
way `realpath(3)` does (`WorkspacePaths.RealPath`): walk it from the filesystem root and replace each link by its
target as it is met, so links inside a link's target are followed too and a `..` in a target applies to the real
parent. That real path must be inside the real path of `ctx.Cwd`. More than 40 links (a loop) also fails.
(`ResolveLinkTarget(returnFinalTarget: true)` isn't enough: on Unix it follows only the last component of each hop,
so `a/l -> ../d/secret` with `d -> /etc` would pass.) Errors are `path escapes the workspace: <path>`,
`file not found: <path>` and `path is a directory, not a file: <path>` (also for `.`, the workspace itself).
3. Caption longer than `Limits.MaxCaptionLength` → `caption is <n> characters; the limit is <m>. Shorten it or put
the text in your reply.` The check runs before anything is sent, so a failure never delivers half an attachment.
4. Kind:
@@ -151,24 +155,31 @@ it (`chat.Channel`).
- `Limits`: photo 10 MB, document 50 MB, caption 1024 (Bot API limits), in `TelegramLimits` constants.
- **Ordering through the renderer.** Session events are rendered asynchronously from `ChatRenderer`'s queue. A file
sent directly could overtake an assistant message that ended just before the tool ran. `ChatRenderer` gets
`Task<T> RunInOrderAsync<T>(Func<CancellationToken, Task<T>> work)`. It queues a host-internal work item behind the
pending events and completes when the item has run. The renderer marks the status message as buried afterwards, as
`Task<T> RunInOrderAsync<T>(Func<CancellationToken, Task<T>> work, CancellationToken cancellationToken = default)`.
It queues a host-internal work item behind the pending events and completes when the item has run. The caller's
token cancels the returned task at once, even while the item still waits behind a slow upload; an item cancelled
before it starts is skipped, and a running one sees the token through the one it's given. The renderer marks the status message as buried afterwards, as
it does for replies, so the `⚙️ Working...` message moves back to the bottom on the next tool update.
`SendTextAsync` and `SendFileAsync` both go through it. The event channel becomes a channel of render items (an
event or a work item).
event or a work item). Disposing the renderer lets it drain its queue for up to 5 s; a work item still queued after
that, or queued after disposal, is cancelled without running. `ChatRenderer.SayAsync(text, ct)` (a `RunInOrderAsync`
around `TelegramSender.SendTextAsync`) sends plain-text notices and the `🎤` echo. `ChatRenderer.ReplyInOrderAsync`
runs the (private) reply path on the loop and returns the first message id and whether every chunk was delivered.
- **Files.** `TelegramSender.SendFileAsync(chatId, path, asPhoto, caption, ct) → Task<FileSendResult>`, with
`FileSendResult(int? MessageId, string? Error, bool PhotoRejected)`. It follows the sender's never-throw convention.
Each attempt opens a fresh `FileStream` and uses `InputFile.FromStream(stream, Path.GetFileName(path))` with
`SendPhoto` or `SendDocument`. Captions go as plain text (no parse mode), so the model's caption can't break the
HTML.
- A network failure (`RequestException` that is not an `ApiRequestException`) is retried after 1 s and 3 s, like
replies.
replies. The delays are `TelegramSender.FileRetryDelays`, an internal test seam.
- An API error is not retried. A 400 on `sendPhoto` comes back as `PhotoRejected = true`.
- `Auto` whose photo upload is rejected with a 400 (`PHOTO_INVALID_DIMENSIONS`, `IMAGE_PROCESS_FAILED`,
`PHOTO_SAVE_FILE_INVALID`, or any 400 on `sendPhoto`) is resent once as a document. `SentAs = Document`.
- Anything else becomes `ChatDeliveryException("Telegram refused the file: <description>")`.
- **Text.** `SendTextAsync(markdown)` reuses the renderer's reply path (`TelegramFormatter.Split` →
`ToHtml` → `SendHtmlAsync` with the plain fallback). It returns the id of the first message, or throws
`ChatDeliveryException` when every retry failed.
`ChatDeliveryException("Telegram did not accept the message.")` when a chunk still failed after its retries (the
other chunks are still sent).
### 4. Telegram — inbound: `InboundMedia`
@@ -194,14 +205,15 @@ Router changes (`UpdateRouter.OnMessageAsync`), on top of the message-context sp
message-context spec describes. All slow work (download, save, transcribe) happens inside `ComposeAsync` under the
lease, so polling never blocks and commands (`/stop`) act immediately. Ordering comes from the lease slots: a text
sent right after a voice note lands in the same batch, after the transcript. There is no separate inbound queue.
- Notices and the echo are posted through the renderer (`RunInOrderAsync` → `SendTextAsync`), so they appear in order
- Notices and the echo are posted through the renderer (`ChatRenderer.SayAsync`, plain text), so they appear in order
with rendered output and, on an idle chat, before the run's `⚙️ Working...`.
**Files** (`Document`, and `Video` with `FileName ?? "video.mp4"`):
1. `FileSize > 20 MB` (`TelegramLimits.MaxDownloadBytes`) → notice `That file is <x> MB; Telegram lets bots download
at most 20 MB. Share a link or a smaller file.` The message isn't prompted, caption included (the caption is
about the file). A `getFile` error saying "file is too big" (a missing `FileSize`) is reported the same way.
about the file). A `getFile` error saying "file is too big" (a missing `FileSize`) is reported the same way, as
`That file is over 20 MB; …`.
2. Target: `<cwd>/inbox/<date>-<stem><-N><ext>`. `date` is `TimeProvider.GetLocalNow()` as `yyyy-MM-dd`. The name
is `FileName ?? "file"`: `Path.GetFileName` drops any directory part, and every character other than a Unicode
letter or digit, `.`, `-` or `_` becomes `_`. Leading dots and underscores are trimmed (no hidden files). Stem is
@@ -211,21 +223,35 @@ Router changes (`UpdateRouter.OnMessageAsync`), on top of the message-context sp
notice `I could not download that file.` The message isn't prompted.
4. Prompt text: the caption (if any), a blank line, then
`[File received: inbox/2026-09-26-report.pdf (application/pdf, 1.2 MB)]`. The path is relative to the workspace
and uses `/`.
and uses `/`. Sizes read `N bytes`, `N.N KB` or `N.N MB`.
5. Image documents: when `MimeType` is `image/jpeg|png|webp|gif` and the size is ≤ `Telegram:InlineImageMaxBytes`
(default 3 750 000), the saved bytes are also attached as `ImageContent(base64, mime)`. Larger images are
path-only, and the line says `(too large to show inline; downscale it with bash if you need to see it)`.
(default 3 750 000), the saved bytes are also attached as `ImageContent(base64, mime)`. Documents aren't
re-encoded by Telegram, so the declared type can be wrong (HEIC named `.png`, a truncated file); a provider rejects
such an image, and the persisted prompt would then break every later turn. So the leading bytes decide: JPEG
`FF D8 FF`, PNG `89 50 4E 47 0D 0A 1A 0A`, GIF `47 49 46 38` or WebP `RIFF....WEBP`, and the image is attached
under the type its bytes show. Bytes that are none of these make the file path-only, with the suffix
` (not shown inline: the contents are not a JPEG, PNG, WebP or GIF image)`. Larger images are
path-only, and the line gets a parenthesised suffix after the size:
`[File received: inbox/x.png (image/png, 5.0 MB) (too large to show inline; downscale it with bash if you need to see it)]`.
**Voice and audio** (`Voice`, `Audio`):
1. No `ISpeechToText` configured → notice `Voice messages need speech-to-text: set OPENAI_API_KEY (or
Telegram:Speech:ApiKey) on the bot host.` `Audio` is still saved to the inbox as a file (below); `Voice` isn't
prompted unless it has a caption.
2. Over 20 MB → the file-size notice, as for files.
3. Download into memory. Voice arrives as `.oga`, which OpenAI rejects, so it's renamed to `voice.ogg`
(`audio/ogg`). Audio keeps `FileName ?? "audio"` and its `MimeType`.
2. Over 20 MB → the file-size notice, as for files, and the message isn't prompted (for `Audio`, step 2 has already
refused it).
3. Voice is downloaded into memory; a failed download sends `I could not download that file.` and the message isn't
prompted. Audio is read back from the file step 2 saved, so it's downloaded only once. Voice arrives as `.oga`,
which OpenAI rejects, so it's renamed to `voice.ogg` (`audio/ogg`). OpenAI picks the format from the file
extension, so audio goes as an ASCII `audio.<ext>`: the original extension when OpenAI knows it (`flac`, `m4a`,
`mp3`, `mp4`, `mpeg`, `mpga`, `ogg`, `wav`, `webm`; `.oga`/`.opus` become `.ogg`), else one from `MimeType`
(`mp3` when unknown), with its `MimeType`. A missing or non-ASCII name would otherwise lose the extension.
4. `Transcript = await stt.TranscribeAsync(bytes, fileName, mime, ct)`, trimmed. Empty → notice `I couldn't make
out any words in that voice message.` An exception → notice `Could not transcribe the voice message: <reason>`.
That includes a cancellation the message's own token didn't cause, such as the `HttpClient` timeout; only a
shutdown (the caller's token) ends composing silently. The same rule holds for downloads, and `UpdateRouter`
treats any other `OperationCanceledException` from `ComposeAsync` as a failure (`I could not read that message.`).
5. Echo `🎤 <transcript>` as plain text, cut to 4096 characters with `…`.
6. Prompt text: the caption (if any), then `[Voice message] <transcript>`. For `Audio`, the file is also saved to
the inbox like a document and gets its own `[File received: …]` line: it's something the user might want
@@ -252,7 +278,13 @@ public interface ISpeechToText
as given), `model`, `response_format=json`, and `language` when configured.
- Non-2xx → `SpeechToTextException("OpenAI transcription failed: HTTP <status> <first 200 chars of body>")`.
A non-JSON body or a missing `text` gives its own message. The key never appears in messages or logs.
- `HttpClient` comes from `AddHttpClient<OpenAiSpeechToText>()`, with a 2-minute timeout.
- The client timeout (a `TaskCanceledException` while the caller's token isn't cancelled) →
`SpeechToTextException("OpenAI transcription failed: the request timed out.")`; a transport failure
(`HttpRequestException`) → the same prefix plus its message. The caller's own cancellation still propagates.
- `HttpClient` comes from `AddHttpClient<ISpeechToText, OpenAiSpeechToText>()` (package `Microsoft.Extensions.Http`), with a 2-minute timeout.
The singleton `InboundMedia` keeps that typed client for the process lifetime, so the factory never rotates its
handler: the primary handler is a `SocketsHttpHandler` with `PooledConnectionLifetime = 5 min` (and an infinite
handler lifetime), so DNS changes are still picked up.
`SpeechOptions`, the `Telegram:Speech` section:
@@ -265,7 +297,7 @@ public interface ISpeechToText
The environment fallbacks (`OPENAI_API_KEY`, `STT_MODEL`) are read in `TelegramOptions.ApplyEnvironment`, next to
the existing `TELEGRAM_*` shortcuts, and only when the `Telegram:Speech` setting is empty. An empty variable counts
as unset (the Docker compose file passes both through, empty by default). The OpenAI provider
as unset (a deployment, such as a Docker compose file, may pass both through empty). The OpenAI provider
extension, if loaded, reads `OPENAI_API_KEY` on its own; the two don't share state.
`Program.cs` registers `ISpeechToText` only when a key is present, and logs `Speech to text: <model>` or
`Speech to text: off (no OPENAI_API_KEY)`. `OPENAI_API_KEY` isn't on the Claude Code provider's denied-variable
@@ -295,7 +327,8 @@ accepts the consequence that the agent doesn't see them.
| file > 20 MB sent to the bot | size notice, nothing reaches the agent |
| download fails | `I could not download that file.`, partial file deleted, no prompt |
| voice with no STT configured | not-configured notice; caption (if any) is still prompted |
| STT HTTP/parse error | `Could not transcribe the voice message: …`, no prompt unless there's a caption |
| STT HTTP/parse error or timeout | `Could not transcribe the voice message: …`, no prompt unless there's a caption (an audio file's `[File received: …]` line is still prompted) |
| image document whose bytes aren't JPEG/PNG/WebP/GIF | saved and prompted path-only, with the "not shown inline" note |
| `send` path outside the workspace / missing / directory | error tool result, nothing sent |
| `send` over the photo / document limit | error tool result naming the limit |
| caption over 1024 | error tool result, nothing sent |
@@ -332,7 +365,9 @@ xunit, no network, no paid API.
- `auto` `.png` passes `Auto`; `auto` `.pdf` and `.gif` pass `Document`; explicit `photo`/`document` are honoured.
- An `auto` image over the photo limit goes as `Document`; an explicit `photo` over the limit fails.
- A document over the limit, a caption over the limit, `../x`, an absolute path outside, and a symlink inside the
workspace pointing outside all fail, and the channel is never called.
workspace pointing outside all fail, and the channel is never called. So do (Unix only) a link whose target runs
through a directory link pointing outside (`a/l -> ../d/secret`, `d -> <outside>`), a link whose target has a `..`
after a directory link (`l -> x/../secret`, `x -> <outside>/deep`) and a link loop. `.` is a directory.
- Missing file and directory errors. Absolute paths inside the workspace are accepted.
- A `ChatDeliveryException` becomes an error tool result with its message.
- Result text and details. Sparse files (`FileStream.SetLength`) stand in for big files.
@@ -351,7 +386,8 @@ xunit, no network, no paid API.
tests can assert the uploaded bytes, name and caption (`Uploads`).
- `OnSend` can throw an `ApiRequestException(400, "PHOTO_INVALID_DIMENSIONS")` to exercise the fallback.
`Updates` gets `Document(...)`, `Voice(...)`, `Audio(...)` and `Sticker()`. `ChatHost` takes an optional
`Updates` gets `DocumentMessage(...)`, `VideoMessage(...)`, `VoiceMessage(...)` and `AudioMessage(...)`, and reuses
`StickerMessage()` (a member named like a Telegram type would hide that type inside the class). `ChatHost` takes an optional
`ISpeechToText` (a `FakeSpeechToText` returning a scripted transcript or throwing) and uses the optional
`TimeProvider` the message-context spec adds (`FakeTimeProvider` from `Microsoft.Extensions.TimeProvider.Testing`).
@@ -364,9 +400,9 @@ xunit, no network, no paid API.
note.
- Voice with STT: the `🎤` echo comes before the answer, the prompt contains `[Voice message] …`, and the file name
handed to STT ends in `.ogg`. A 5000-character transcript echo is cut to 4096.
- Voice without STT: the not-configured notice, and no run. STT throwing: the failure notice, and no run.
- Voice without STT: the not-configured notice, and no run. STT throwing, or timing out (a `TaskCanceledException` nobody asked for): the failure notice, and no run.
- Ordering: a voice note followed immediately by text produces one prompt with the transcript before the text (the
fake STT is delayed past the debounce window). `/stop` sent during a slow transcription is handled at once.
fake STT is held on a gate while a `FakeTimeProvider` moves well past the debounce window, and nothing is prompted until it is released). `/stop` sent during a slow transcription is handled at once.
- A document with a `/new` caption doesn't run `/new`. A voice note doesn't answer a pending `InputAsync`.
- A sticker gets the "I can read…" reply.
- `send` end to end: the faux provider calls `send` for `out/chart.png`, a `sendPhoto` upload with the caption
@@ -379,7 +415,7 @@ xunit, no network, no paid API.
**Speech (`OpenAiSpeechToTextTests`)** with a stub `HttpMessageHandler`: the multipart form has `file` (name and
content type), `model` and `response_format=json`, plus `language` when set; the bearer header is present; the
transcript is trimmed; non-2xx, non-JSON and missing `text` each give their own message, which never contains the
key.
key. A client timeout is a `SpeechToTextException`; the caller's cancellation stays a cancellation.
## Out of scope
@@ -394,9 +430,10 @@ key.
- Cleaning up `inbox/`: files stay until the user or agent removes them.
- Reply and forward context headers (the message-context spec).
## Open questions
## Resolved questions
1. Should `Audio` files (music, podcasts) be transcribed by default, or only saved? This spec follows
VolcanicSecretary and transcribes, which costs money on long files.
2. Is 3.75 MB the right inline threshold for image documents, or should the host downscale larger images (with a
dependency such as ImageSharp) rather than send only the path?
1. `Audio` files (music, podcasts) are transcribed by default, as in VolcanicSecretary, and also saved to the inbox.
The cost of transcribing long files is accepted.
2. 3.75 MB (`Telegram:InlineImageMaxBytes`) stays the inline threshold for image documents, and the host doesn't
downscale larger ones: no image-processing dependency. The model gets the path and can downscale with `bash` if it
needs to see the image.

View File

@@ -146,10 +146,7 @@ if the part has a body or images, as in the reference.
Unsupported kinds never reach the composer: the router checks the message kind before taking a lease. Anything other
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.
sibling spec's text, `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.

View File

@@ -34,6 +34,11 @@ settings use the `GoogleCalendar` section):
| `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 |
| `OPENAI_API_KEY` / `Telegram:Speech:ApiKey` | — | Turns voice transcription on. Hidden from `bash` and redacted from tool output |
| `STT_MODEL` / `Telegram:Speech:Model` | `gpt-4o-transcribe` | `gpt-4o-mini-transcribe` and `whisper-1` also work |
| `Telegram:Speech:BaseUrl` | `https://api.openai.com/v1` | Any OpenAI-compatible transcription endpoint |
| `Telegram:Speech:Language` | — | Optional ISO-639-1 hint, e.g. `de` |
| `Telegram:InlineImageMaxBytes` | `3750000` | Image files up to this size are also shown to the model; must be positive |
| `GOOGLE_CLIENT_ID` / `GoogleCalendar:ClientId` | — | Optional. With the secret, every chat gets the `calendar` tool and the `/calendar_*` commands; see [google-calendar.md](google-calendar.md) |
| `GOOGLE_CLIENT_SECRET` / `GoogleCalendar:ClientSecret` | — | Optional; redacted from tool output and hidden from `bash` |
| `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` |
@@ -61,7 +66,7 @@ It lists the last five tool calls (`▶` running, `✓` done, `✗` failed) and
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, 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.
added. Besides text, you can send photos, files and voice messages (see [What you can send](#what-you-can-send)).
Along with your text, the agent sees:
@@ -103,11 +108,33 @@ 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. 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
is taken as the code and is not sent to the agent; photos, files, voice messages 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).
### What you can send
- **Text and photos.** A photo, with or without a caption, is passed to the model as an image.
- **Documents, videos and audio files** are saved to `inbox/<yyyy-MM-dd>-<name>` in the chat's workspace (a number is
added when the name is taken: `-1`, `-2`, ...). The agent gets the caption plus a line such as
`[File received: inbox/2026-09-26-report.pdf (application/pdf, 1.2 MB)]`. An image sent "as file" (JPEG, PNG, WebP,
GIF) up to 3.75 MB (`Telegram:InlineImageMaxBytes`) is also shown to the model; a larger one, or one whose contents
aren't really one of those formats, reaches it as a path only.
- **Voice messages and audio files** are transcribed with OpenAI (`gpt-4o-transcribe` by default). The bot echoes
`🎤 <transcript>` and the agent gets `[Voice message] <transcript>` (an audio file also gets its `[File received: …]`
line). Without an API key the bot replies with how to set one up (`OPENAI_API_KEY`).
- Telegram lets bots download at most 20 MB. A bigger file is refused with a note, and nothing reaches the agent.
- A message with an attachment is never a command and never answers a pending question: its caption is prompt text.
- Anything else (stickers, video notes, locations, polls) gets `I can read text, photos, files and voice messages.`
### Files from the agent
The agent's `send` tool delivers a file from the chat's workspace into the chat, before the agent's reply. Images go
as photos (up to 10 MB), other files as documents (up to 50 MB), with an optional caption of up to 1024 characters. A
photo Telegram rejects (for example for its dimensions) is resent as a document. `send` accepts only files inside the
chat's workspace and delivers only to that chat.
### Google Calendar
Each chat connects its own Google account with `/calendar_connect`, and the agent's `calendar` tool only uses that
@@ -121,6 +148,10 @@ The agent has the built-in tools, including `bash`, and runs them as the bot's O
are served (everyone else is ignored and logged), and only in private chats. Run the bot as a dedicated user or in a
container, and treat the workspace root as writable by anyone on the allow-list.
`send` refuses paths outside the chat's workspace, symlinks included, so a prompt-injected "send ~/.ssh/id_rsa" fails
visibly (it is no barrier against `bash cp`). The speech API key is kept out of the agent's processes like the bot
token.
Secrets are removed from the environment of `bash`, scheduled scripts and MCP servers: every variable on the deny-list
(see `ProcessEnvironment:Deny` above) is dropped, and so is any variable whose value is the bot token, whatever its
name (a Coolify alias, for example). `PROCESS_ENV_ALLOW` lets a named variable through, for example `GH_TOKEN` so the

View File

@@ -5,7 +5,8 @@ The core has no user interface. A UI is a process that
1. creates one `AgentSession` per conversation,
2. turns incoming user actions into calls on the session (`PromptAsync`, `AbortAsync`, `SetModelAsync`, ...),
3. subscribes to `AgentSessionEvent`s and renders them,
4. implements `IUserInterface` so extensions can ask the user questions.
4. implements `IUserInterface` so extensions can ask the user questions,
5. optionally implements `IChatChannel` so the agent can send files (and schedulers text) into the chat.
This guide uses Telegram as the worked example; the finished bot lives in `hosts/Volcanic.AgenticCore.Telegram`
(see [telegram-bot.md](telegram-bot.md)) and is the reference for everything below. Everything transfers to Slack, Discord, a web socket
@@ -59,6 +60,8 @@ list) and set `AgenticCoreOptions.SessionStoreFactory`.
| Text message while idle | `session.PromptAsync(text, images)` |
| Text message while `session.IsStreaming` | `session.PromptAsync(text, images, StreamingBehavior.Steer)` (or `FollowUp`) |
| Photo | download it, wrap as `ImageContent(base64, "image/jpeg")`, pass in `images` |
| Document / video / audio | download (≤ 20 MB), save into the workspace, mention the path in the prompt text (image documents also as `ImageContent`) |
| Voice | transcribe on the host (the core only sees text), put the transcript in the prompt |
| `/stop` | `session.AbortAsync()` |
| `/model` | list `session.Models.ListModels()` where `IsAvailable`, then `session.SetModelAsync(key)` |
| `/thinking <level>` | `session.SetThinkingLevelAsync(level)` (`session.AvailableThinkingLevels`) |
@@ -163,7 +166,43 @@ When the user replies to a `ForceReply` prompt, route the text to `TryResolveInp
Because `Ui` is not `NullUserInterface`, `ctx.HasUi` is true and extensions like a permission gate will ask
instead of blocking.
## 5. Minimal bot skeleton (Telegram.Bot)
## 5. Implement `IChatChannel`
Optional. `IChatChannel` delivers content into the conversation's chat outside the rendering of assistant
messages. Pass one per session as `AgentSessionOptions.Channel`. The core then registers the built-in `send` tool for
that session (active by default; an explicit `ActiveTools` still wins), and tools and extensions reach the channel as
`ctx.Channel`, host code as `session.Channel`. Sessions without a channel never see `send`.
```csharp
public enum ChatFileKind { Auto, Photo, Document }
public sealed record ChatFile(string Path, ChatFileKind Kind = ChatFileKind.Auto, string? Caption = null);
public sealed record ChatDelivery(ChatFileKind? SentAs, string? MessageId);
public sealed record ChatChannelLimits(long MaxPhotoBytes, long MaxDocumentBytes, int MaxCaptionLength);
public interface IChatChannel
{
ChatChannelLimits Limits { get; }
Task<ChatDelivery> SendTextAsync(string markdown, CancellationToken cancellationToken);
Task<ChatDelivery> SendFileAsync(ChatFile file, CancellationToken cancellationToken);
}
public sealed class ChatDeliveryException(string message, Exception? inner = null) : Exception(message, inner);
```
- `Limits`: your platform's upload limits (Telegram: photo 10 MB, document 50 MB, caption 1024 characters). `send`
checks them before calling you, and its errors name them.
- `SendTextAsync`: markdown, rendered like assistant text and split when long. It is not added to the session
transcript, and it must work while no run is active: schedulers use it for reminders.
- `SendFileAsync`: the core has already confined the path to the workspace (symlinks included), checked that the file
exists and applied the limits. `Auto` lets you choose, and you may fall back (Telegram resends a rejected photo as a
document); report what you did in `SentAs`. Throw only `ChatDeliveryException`, with a message the model or the
user can read, or `OperationCanceledException`.
- Keep deliveries in order with your rendering: a file sent directly could overtake an assistant message that is
still queued for rendering. The Telegram host runs every delivery on its renderer's queue
(`ChatRenderer.RunInOrderAsync`), so the file lands after the text that preceded the tool call.
- Calls can come from any thread. Nothing delivered is persisted by the core; the `send` tool's result records it.
## 6. Minimal bot skeleton (Telegram.Bot)
```csharp
var services = new ServiceCollection();
@@ -224,7 +263,7 @@ bot.OnUpdate += async (update, _) =>
subscribed to; `NewSessionAsync`/`SwitchSessionAsync` keep the same `AgentSession` object, so one
subscription per `AgentSession` is enough.
## 6. Operational notes
## 7. Operational notes
- **Working directories**: the agent's tools (`bash`, `write`, ...) run with the bot process's permissions in
the session's `Cwd`. Run the bot in a container or with a dedicated user and one workspace per chat.

View File

@@ -129,7 +129,7 @@ public sealed class McpExtension : IExtension
private void AddPromptSections(IExtensionApi api, SystemPromptOptions options)
{
var active = api.GetActiveTools().ToHashSet(StringComparer.Ordinal);
var hasSend = api.GetAllTools().Any(t => t.Name == "send");
var hasSend = active.Contains("send"); // registered but inactive: the model can't call it
foreach (var server in _servers)
{
if (!server.RegisteredTools.Any(active.Contains)) continue;

View File

@@ -13,14 +13,17 @@ 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, ChatInbox inbox, ILogger logger)
: IAsyncDisposable
public sealed class ChatContext(long chatId, AgentSession session, TelegramUi ui, ChatRenderer renderer, IDisposable subscription, ChatInbox inbox, ILogger logger,
IChatChannel channel) : IAsyncDisposable
{
public long ChatId => chatId;
public AgentSession Session => session;
public TelegramUi Ui => ui;
public ChatRenderer Renderer => renderer;
/// <summary>Delivers files and texts into this chat in order with rendered output (the scheduler uses it too).</summary>
public IChatChannel Channel => channel;
/// <summary>Groups this chat's incoming messages into prompts (see <see cref="ChatInbox"/>).</summary>
public ChatInbox Inbox => inbox;
@@ -104,6 +107,7 @@ public sealed class ChatRegistry(
var logger = loggerFactory.CreateLogger<ChatContext>();
var renderer = new ChatRenderer(chatId, sender, options, loggerFactory.CreateLogger<ChatRenderer>());
var ui = new TelegramUi(chatId, sender, renderer);
var channel = new TelegramChatChannel(chatId, sender, renderer);
AgentSession session;
try
{
@@ -114,6 +118,7 @@ public sealed class ChatRegistry(
SessionId = last?.SessionId,
Model = options.Model,
Ui = ui,
Channel = channel,
ExtraExtensions = calendar is null ? null : [GoogleCalendarSetup.Registration(calendar)],
}).ConfigureAwait(false);
}
@@ -129,7 +134,7 @@ public sealed class ChatRegistry(
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);
context = new ChatContext(chatId, session, ui, renderer, subscription, inbox, logger, channel);
return context; // no lease can be taken before this returns, so the flush always sees the context
}

View File

@@ -0,0 +1,48 @@
using System.Globalization;
using Volcanic.AgenticCore.Abstractions.Ui;
using Volcanic.AgenticCore.Telegram.Rendering;
namespace Volcanic.AgenticCore.Telegram.Chats;
/// <summary>
/// One chat's <see cref="IChatChannel"/>. Every delivery goes through the chat renderer's queue
/// (<see cref="ChatRenderer.RunInOrderAsync{T}"/>), so it can't overtake rendered output, and works while no run is active.
/// </summary>
public sealed class TelegramChatChannel(long chatId, TelegramSender sender, ChatRenderer renderer) : IChatChannel
{
private static readonly HashSet<string> PhotoExtensions = new(StringComparer.OrdinalIgnoreCase) { ".jpg", ".jpeg", ".png", ".webp" };
public ChatChannelLimits Limits { get; } = new(TelegramLimits.MaxPhotoBytes, TelegramLimits.MaxDocumentBytes, TelegramLimits.MaxCaptionLength);
public async Task<ChatDelivery> SendTextAsync(string markdown, CancellationToken cancellationToken)
{
ArgumentNullException.ThrowIfNull(markdown);
var (first, complete) = await renderer.ReplyInOrderAsync(markdown, cancellationToken).ConfigureAwait(false);
if (!complete) throw new ChatDeliveryException("Telegram did not accept the message.");
return new ChatDelivery(null, first?.ToString(CultureInfo.InvariantCulture));
}
public Task<ChatDelivery> SendFileAsync(ChatFile file, CancellationToken cancellationToken)
{
ArgumentNullException.ThrowIfNull(file);
return renderer.RunInOrderAsync(async ct =>
{
var asPhoto = file.Kind == ChatFileKind.Photo || (file.Kind == ChatFileKind.Auto && LooksLikePhoto(file.Path));
var result = await sender.SendFileAsync(chatId, file.Path, asPhoto, file.Caption, ct).ConfigureAwait(false);
if (result.MessageId is { } id) return Delivered(asPhoto, id);
if (asPhoto && file.Kind == ChatFileKind.Auto && result.PhotoRejected)
{
// Telegram refuses some images as photos (dimensions, aspect ratio): send the file as a document instead.
result = await sender.SendFileAsync(chatId, file.Path, asPhoto: false, file.Caption, ct).ConfigureAwait(false);
if (result.MessageId is { } documentId) return Delivered(false, documentId);
}
throw new ChatDeliveryException($"Telegram refused the file: {result.Error}");
}, cancellationToken);
}
private bool LooksLikePhoto(string path) =>
PhotoExtensions.Contains(Path.GetExtension(path)) && new FileInfo(path) is { Exists: true } info && info.Length <= Limits.MaxPhotoBytes;
private static ChatDelivery Delivered(bool asPhoto, int messageId) =>
new(asPhoto ? ChatFileKind.Photo : ChatFileKind.Document, messageId.ToString(CultureInfo.InvariantCulture));
}

View File

@@ -92,7 +92,7 @@ public sealed class UpdateRouter(
{
lease.Complete(await composer.ComposeAsync(chat, message, cancellationToken).ConfigureAwait(false));
}
catch (OperationCanceledException)
catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
{
// shutting down: the lease completes empty
}

View File

@@ -14,23 +14,27 @@ internal sealed class InboundDraft
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>Where step 2 saved the message's file, for step 3 (audio).</summary>
public string? SavedPath { 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
/// fixed order: step 1 text and photo, step 2 files saved to the inbox (<see cref="InboundMedia"/>), step 3 voice and
/// audio transcription. 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)
public sealed class InboundComposer(TelegramSender sender, InboundMedia media, 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>The reply to a message kind the bot can't read (stickers, video notes, polls…; no lease is taken).</summary>
public const string UnsupportedReply = "I can read text, photos, files and voice messages.";
/// <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 };
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.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>
@@ -51,8 +55,8 @@ public sealed class InboundComposer(TelegramSender sender, ILogger<InboundCompos
// 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) await media.AddFilesAsync(chat, message, draft, cancellationToken).ConfigureAwait(false); // step 2
if (!draft.Refused) await media.AddSpeechAsync(chat, message, draft, cancellationToken).ConfigureAwait(false); // step 3
if (draft.Refused || (draft.Body.Count == 0 && draft.Images.Count == 0)) return null;
return new InboundPart(
@@ -74,10 +78,10 @@ public sealed class InboundComposer(TelegramSender sender, ILogger<InboundCompos
{
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);
await media.DownloadAsync(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)
catch (Exception ex) when (!cancellationToken.IsCancellationRequested)
{
logger.LogWarning(ex, "Downloading a photo in chat {ChatId} failed", chat.ChatId);
chat.Report("I could not download that photo.");

View File

@@ -0,0 +1,215 @@
using Microsoft.Extensions.Logging;
using Telegram.Bot;
using Telegram.Bot.Exceptions;
using Telegram.Bot.Types;
using Volcanic.AgenticCore.Telegram.Chats;
using Volcanic.AgenticCore.Telegram.Rendering;
using Volcanic.AgenticCore.Telegram.Speech;
using ImageContent = Volcanic.AgenticCore.Abstractions.Messages.ImageContent;
namespace Volcanic.AgenticCore.Telegram.Inbound;
/// <summary>
/// Content steps for attachments (spec: telegram-attachments §4): step 2 saves documents, videos and audio to the
/// workspace inbox, step 3 transcribes voice notes and audio through <see cref="ISpeechToText"/> (null: speech is off).
/// Notices and the echo go through the chat renderer (<see cref="ChatRenderer.SayAsync"/>) so they stay in order.
/// </summary>
public sealed class InboundMedia(TelegramSender sender, TelegramOptions options, TimeProvider time, ILogger<InboundMedia> logger,
ISpeechToText? speech = null)
{
/// <summary>Sent for a voice note or audio file when no speech-to-text service is configured.</summary>
public const string SpeechNotConfiguredNotice = "Voice messages need speech-to-text: set OPENAI_API_KEY (or Telegram:Speech:ApiKey) on the bot host.";
/// <summary>Sent when the transcript is empty.</summary>
public const string NoWordsNotice = "I couldn't make out any words in that voice message.";
/// <summary>Sent when a file's download fails (the partial file is deleted and the message isn't prompted).</summary>
public const string DownloadFailedNotice = "I could not download that file.";
/// <summary>Suffix of a <c>[File received: …]</c> line for an image too large to attach inline.</summary>
public const string TooLargeInlineNote = " (too large to show inline; downscale it with bash if you need to see it)";
/// <summary>Suffix of a <c>[File received: …]</c> line for an image document whose bytes aren't a known image.</summary>
public const string NotAnImageNote = " (not shown inline: the contents are not a JPEG, PNG, WebP or GIF image)";
private static readonly HashSet<string> InlineImageTypes = new(StringComparer.OrdinalIgnoreCase) { "image/jpeg", "image/png", "image/webp", "image/gif" };
/// <summary>The notice for a file over the 20 MB download limit; <c>over 20 MB</c> when the size is unknown.</summary>
public static string TooBigNotice(long? bytes) =>
$"That file is {(bytes is { } b ? TelegramLimits.Megabytes(b) + " MB" : "over 20 MB")}; Telegram lets bots download at most 20 MB. Share a link or a smaller file.";
/// <summary>Download a file by id into <paramref name="destination"/> (shared with step 1's photos).</summary>
internal Task DownloadAsync(string fileId, Stream destination, CancellationToken cancellationToken) =>
sender.Bot.GetInfoAndDownloadFile(fileId, destination, cancellationToken);
/// <summary>Telegram's <c>getFile</c> refusal for a file over the download limit.</summary>
internal static bool IsTooBig(ApiRequestException ex) => ex.Message.Contains("file is too big", StringComparison.OrdinalIgnoreCase);
/// <summary>Step 2: a document, video or audio file goes to the inbox and gets a <c>[File received: …]</c> line.</summary>
internal async Task AddFilesAsync(ChatContext chat, Message message, InboundDraft draft, CancellationToken cancellationToken)
{
var (fileId, size, name, mime) = message switch
{
{ Document: { } d } => (d.FileId, d.FileSize, d.FileName ?? "file", d.MimeType ?? "application/octet-stream"),
{ Video: { } v } => (v.FileId, v.FileSize, v.FileName ?? "video.mp4", v.MimeType ?? "video/mp4"),
{ Audio: { } a } => (a.FileId, a.FileSize, a.FileName ?? "audio", a.MimeType ?? "audio/mpeg"),
_ => ((string?)null, (long?)null, "", ""),
};
if (fileId is null) return;
if (size > TelegramLimits.MaxDownloadBytes)
{
await chat.Renderer.SayAsync(TooBigNotice(size)).ConfigureAwait(false);
draft.Refused = true;
return;
}
var workspace = chat.Session.Cwd;
var (path, stream) = InboxFiles.Create(workspace, time.GetLocalNow(), name);
try
{
await using (stream.ConfigureAwait(false))
await DownloadAsync(fileId, stream, cancellationToken).ConfigureAwait(false);
}
catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
{
TryDelete(path);
throw;
}
catch (Exception ex)
{
TryDelete(path);
logger.LogWarning(ex, "Downloading {File} in chat {ChatId} failed", name, chat.ChatId);
await chat.Renderer.SayAsync(ex is ApiRequestException api && IsTooBig(api) ? TooBigNotice(size) : DownloadFailedNotice).ConfigureAwait(false);
draft.Refused = true;
return;
}
var bytes = new FileInfo(path).Length;
draft.SavedPath = path;
var note = "";
if (InlineImageTypes.Contains(mime))
{
if (bytes <= options.InlineImageMaxBytes)
{
// Documents aren't re-encoded by Telegram, so the declared type can be wrong (HEIC named .png, a
// truncated file). A provider rejects such an image, and the persisted prompt would break every later
// turn: attach only bytes that really are one of the four types, under the type they show.
var data = await File.ReadAllBytesAsync(path, cancellationToken).ConfigureAwait(false);
if (SniffImageType(data) is { } actual) draft.Images.Add(new ImageContent(Convert.ToBase64String(data), actual));
else note = NotAnImageNote;
}
else
{
note = TooLargeInlineNote;
}
}
draft.Body.Add($"[File received: {InboxFiles.Relative(workspace, path)} ({mime}, {TelegramLimits.FormatSize(bytes)}){note}]");
}
/// <summary>
/// Step 3: a voice note or audio file is transcribed, echoed as <c>🎤 …</c> and added as <c>[Voice message] …</c>.
/// Audio is read back from the file step 2 saved (one download). Without a transcript nothing is added (the caption,
/// if any, is still prompted); an oversize or undownloadable voice note refuses the message.
/// </summary>
internal async Task AddSpeechAsync(ChatContext chat, Message message, InboundDraft draft, CancellationToken cancellationToken)
{
var (fileId, size, name, mime, isVoice) = message switch
{
{ Voice: { } v } => (v.FileId, v.FileSize, "voice.ogg", "audio/ogg", true), // Telegram's .oga is rejected by OpenAI
{ Audio: { } a } => (a.FileId, a.FileSize, SpeechFileName(a.FileName, a.MimeType), a.MimeType ?? "audio/mpeg", false),
_ => ((string?)null, (long?)null, "", "", false),
};
if (fileId is null) return;
if (speech is null)
{
await chat.Renderer.SayAsync(SpeechNotConfiguredNotice).ConfigureAwait(false);
return;
}
if (isVoice && size > TelegramLimits.MaxDownloadBytes)
{
await chat.Renderer.SayAsync(TooBigNotice(size)).ConfigureAwait(false);
draft.Refused = true;
return;
}
byte[] audio;
if (draft.SavedPath is { } saved) // audio: step 2 saved it
{
audio = await File.ReadAllBytesAsync(saved, cancellationToken).ConfigureAwait(false);
}
else
{
using var buffer = new MemoryStream();
try
{
await DownloadAsync(fileId, buffer, cancellationToken).ConfigureAwait(false);
}
catch (Exception ex) when (!cancellationToken.IsCancellationRequested) // a client timeout is a failure, not a shutdown
{
logger.LogWarning(ex, "Downloading a voice message in chat {ChatId} failed", chat.ChatId);
await chat.Renderer.SayAsync(ex is ApiRequestException api && IsTooBig(api) ? TooBigNotice(size) : DownloadFailedNotice).ConfigureAwait(false);
draft.Refused = true;
return;
}
audio = buffer.ToArray();
}
string transcript;
try
{
transcript = (await speech.TranscribeAsync(audio, name, mime, cancellationToken).ConfigureAwait(false)).Trim();
}
catch (Exception ex) when (!cancellationToken.IsCancellationRequested) // a client timeout is a failure, not a shutdown
{
logger.LogWarning(ex, "Transcribing a voice message in chat {ChatId} failed", chat.ChatId);
await chat.Renderer.SayAsync($"Could not transcribe the voice message: {ex.Message}").ConfigureAwait(false);
return;
}
if (transcript.Length == 0)
{
await chat.Renderer.SayAsync(NoWordsNotice).ConfigureAwait(false);
return;
}
await chat.Renderer.SayAsync("🎤 " + transcript).ConfigureAwait(false); // plain text, cut to 4096 by the sender
draft.Body.Add($"[Voice message] {transcript}");
}
/// <summary>
/// The name an audio file is sent to speech to text under: OpenAI picks the format from the extension, so it's an
/// ASCII <c>audio.&lt;ext&gt;</c> from the original extension when OpenAI knows it, else from the MIME type.
/// </summary>
internal static string SpeechFileName(string? fileName, string? mimeType)
{
var ext = Path.GetExtension(fileName ?? "").ToLowerInvariant();
if (ext is ".flac" or ".m4a" or ".mp3" or ".mp4" or ".mpeg" or ".mpga" or ".ogg" or ".wav" or ".webm") return "audio" + ext;
if (ext is ".oga" or ".opus") return "audio.ogg";
return "audio" + (mimeType?.ToLowerInvariant() switch
{
"audio/mp4" or "audio/m4a" or "audio/x-m4a" => ".m4a",
"audio/ogg" or "audio/opus" => ".ogg",
"audio/wav" or "audio/x-wav" or "audio/wave" => ".wav",
"audio/flac" or "audio/x-flac" => ".flac",
"audio/webm" => ".webm",
_ => ".mp3", // audio/mpeg, Telegram's default for audio
});
}
/// <summary>The image type the leading bytes show (JPEG, PNG, GIF or WebP), or null.</summary>
internal static string? SniffImageType(ReadOnlySpan<byte> data) => data switch
{
[0xFF, 0xD8, 0xFF, ..] => "image/jpeg",
[0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A, ..] => "image/png",
[0x47, 0x49, 0x46, 0x38, ..] => "image/gif",
[0x52, 0x49, 0x46, 0x46, _, _, _, _, 0x57, 0x45, 0x42, 0x50, ..] => "image/webp",
_ => null,
};
private void TryDelete(string path)
{
try { File.Delete(path); }
catch (Exception ex) when (ex is IOException or UnauthorizedAccessException)
{
logger.LogDebug(ex, "Could not delete the partial file {Path}", path);
}
}
}

View File

@@ -0,0 +1,65 @@
using System.Globalization;
using System.Text;
namespace Volcanic.AgenticCore.Telegram.Inbound;
/// <summary>Where received files go: <c>&lt;workspace&gt;/inbox/&lt;yyyy-MM-dd&gt;-&lt;safe name&gt;</c>, never overwriting.</summary>
public static class InboxFiles
{
/// <summary>The inbox directory under the chat's workspace.</summary>
public const string DirectoryName = "inbox";
/// <summary>Longest stem kept from a received file name, in characters.</summary>
public const int MaxStemLength = 80;
/// <summary>Longest extension kept, dot included.</summary>
public const int MaxExtensionLength = 16;
/// <summary>
/// Drops any directory part, replaces everything but Unicode letters, digits, <c>.</c>, <c>-</c> and <c>_</c> with
/// <c>_</c>, trims leading dots and underscores, caps stem (80) and extension (16, dot included); <c>file</c> when empty.
/// </summary>
public static string SafeName(string? fileName)
{
var name = Path.GetFileName((fileName ?? "").Replace('\\', '/'));
var cleaned = new StringBuilder(name.Length);
foreach (var rune in name.EnumerateRunes())
cleaned.Append(Rune.IsLetterOrDigit(rune) || rune.Value is '.' or '-' or '_' ? rune.ToString() : "_");
var safe = cleaned.ToString().TrimStart('.', '_');
var extension = Path.GetExtension(safe);
var stem = extension.Length == 0 ? safe : safe[..^extension.Length];
extension = Cap(extension, MaxExtensionLength);
stem = Cap(stem, MaxStemLength);
if (stem.Length == 0) stem = "file";
return stem + extension;
}
/// <summary>Creates the next free <c>inbox/&lt;date&gt;-&lt;stem&gt;[-N]&lt;ext&gt;</c> with <see cref="FileMode.CreateNew"/>.</summary>
public static (string Path, FileStream Stream) Create(string workspace, DateTimeOffset localNow, string? fileName)
{
var directory = Path.Combine(workspace, DirectoryName);
Directory.CreateDirectory(directory);
var safe = SafeName(fileName);
var extension = Path.GetExtension(safe);
var prefix = localNow.ToString("yyyy-MM-dd", CultureInfo.InvariantCulture) + "-" + safe[..^extension.Length];
for (var n = 0; ; n++)
{
var path = Path.Combine(directory, prefix + (n == 0 ? "" : "-" + n.ToString(CultureInfo.InvariantCulture)) + extension);
try
{
return (path, new FileStream(path, FileMode.CreateNew, FileAccess.Write, FileShare.None, 81920, useAsync: true));
}
catch (IOException) when (File.Exists(path) && n < 10_000)
{
// taken: try the next suffix
}
}
}
/// <summary>Workspace-relative with <c>/</c>, e.g. <c>inbox/2026-09-26-report.pdf</c>.</summary>
public static string Relative(string workspace, string path) => Path.GetRelativePath(workspace, path).Replace('\\', '/');
/// <summary>At most <paramref name="max"/> characters, never splitting a surrogate pair.</summary>
private static string Cap(string text, int max) =>
text.Length <= max ? text : text[..(char.IsHighSurrogate(text[max - 1]) ? max - 1 : max)];
}

View File

@@ -17,6 +17,7 @@ using Volcanic.AgenticCore.Telegram.Chats;
using Volcanic.AgenticCore.Telegram.Inbound;
using Volcanic.AgenticCore.Telegram.Polling;
using Volcanic.AgenticCore.Telegram.Rendering;
using Volcanic.AgenticCore.Telegram.Speech;
var builder = Host.CreateApplicationBuilder(args);
@@ -39,6 +40,13 @@ if (errors.Count > 0)
builder.Services.AddSingleton(options);
builder.Services.AddSingleton(mcp);
builder.Services.AddSingleton(options.Speech);
if (options.Speech.IsEnabled) // no key, no ISpeechToText: voice notes get the setup notice
// InboundMedia (a singleton) keeps this typed client for the process lifetime, so the factory never rotates its
// handler: let the handler recycle its pooled connections instead, so DNS changes are still picked up.
builder.Services.AddHttpClient<ISpeechToText, OpenAiSpeechToText>(client => client.Timeout = TimeSpan.FromMinutes(2))
.ConfigurePrimaryHttpMessageHandler(() => new SocketsHttpHandler { PooledConnectionLifetime = TimeSpan.FromMinutes(5) })
.SetHandlerLifetime(Timeout.InfiniteTimeSpan);
if (calendar is not null) builder.Services.AddSingleton(calendar); // ChatRegistry and BotSetupService take it optionally
builder.Services.AddSingleton<OwnerLoginChannel>();
// The extension is constructed per session through ActivatorUtilities, which injects this. LoginUi resolves
@@ -61,6 +69,7 @@ builder.Services.AddSingleton<TelegramSender>();
builder.Services.TryAddSingleton(TimeProvider.System);
builder.Services.AddSingleton<ChatRegistry>();
builder.Services.AddSingleton<ChatCommands>();
builder.Services.AddSingleton<InboundMedia>();
builder.Services.AddSingleton<InboundComposer>();
builder.Services.AddSingleton<IUpdateRouter, UpdateRouter>();
builder.Services.AddSingleton<PollingService>();
@@ -74,6 +83,7 @@ var logger = host.Services.GetRequiredService<ILoggerFactory>().CreateLogger("Vo
HostProcessHardening.Apply(processEnvironment, logger);
logger.LogInformation("{ProcessEnvironment}", TelegramProcessEnvironment.Describe(processEnvironment));
logger.LogInformation("{Mcp}", mcp.DescribePlaywright());
logger.LogInformation("{Speech}", options.Speech.Describe());
if (ClaudeInstallation.Locate(Environment.GetEnvironmentVariable) is { } claude)
logger.LogInformation("Using Claude Code at {Path}, model {Model}", claude, options.Model);
else

View File

@@ -15,6 +15,10 @@ namespace Volcanic.AgenticCore.Telegram.Rendering;
/// rendered by a single task. Each assistant message is sent once, complete, when it ends (split into several Telegram
/// messages past the size limit); "typing…" repeats while the agent runs.
/// <para>
/// Host work (file uploads, notices) runs on the same loop through <see cref="RunInOrderAsync{T}"/>, so it stays in
/// order with the rendered output queued before it.
/// </para>
/// <para>
/// While a run is active one status message ("⚙️ Working...") lists the last <see cref="StatusToolCount"/> tool calls
/// with their state, edited at most every <see cref="TelegramOptions.EditInterval"/>. When something was sent below it,
/// the next tool update moves it back to the bottom; it is deleted when the run settles.
@@ -30,11 +34,15 @@ public sealed class ChatRenderer : IAsyncDisposable
private readonly TelegramOptions _options;
private readonly ILogger _logger;
private readonly TimeProvider _time;
private readonly Channel<AgentSessionEvent> _events = Channel.CreateUnbounded<AgentSessionEvent>(new UnboundedChannelOptions { SingleReader = true });
private abstract record RenderItem;
private sealed record EventItem(AgentSessionEvent Event) : RenderItem;
private sealed record WorkItem(Func<CancellationToken, Task> Run, Action Cancel) : RenderItem;
private readonly Channel<RenderItem> _items = Channel.CreateUnbounded<RenderItem>(new UnboundedChannelOptions { SingleReader = true });
private readonly CancellationTokenSource _stop = new();
private readonly Task _loop;
private static readonly TimeSpan[] ReplyRetryDelays = [TimeSpan.FromSeconds(1), TimeSpan.FromSeconds(3)];
/// <summary>Delays before resending a reply chunk that failed (1 s, 3 s). Settable for tests.</summary>
internal IReadOnlyList<TimeSpan> ReplyRetryDelays { get; set; } = [TimeSpan.FromSeconds(1), TimeSpan.FromSeconds(3)];
private DateTimeOffset _lastFlush = DateTimeOffset.MinValue;
private CancellationTokenSource? _typing;
@@ -63,12 +71,63 @@ public sealed class ChatRenderer : IAsyncDisposable
}
/// <summary>Queue an event; safe to call from any thread (this is the session subscription).</summary>
public void Post(AgentSessionEvent e)
public void Post(AgentSessionEvent e) => Enqueue(new EventItem(e));
private bool Enqueue(RenderItem item)
{
Interlocked.Increment(ref _pending);
if (!_events.Writer.TryWrite(e)) Interlocked.Decrement(ref _pending);
if (_items.Writer.TryWrite(item)) return true;
Interlocked.Decrement(ref _pending);
return false;
}
/// <summary>
/// Run host work (an upload, a notice) on the render loop, after every event queued before it, so it stays in order
/// with rendered output. Afterwards the status message counts as buried. Cancelled when the renderer is disposed first.
/// <paramref name="cancellationToken"/> cancels the returned task at once, even while the work is still queued behind
/// a slow upload; work that hasn't started by then is skipped, and running work sees it through its token.
/// </summary>
public Task<T> RunInOrderAsync<T>(Func<CancellationToken, Task<T>> work, CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(work);
if (cancellationToken.IsCancellationRequested) return Task.FromCanceled<T>(cancellationToken);
var done = new TaskCompletionSource<T>(TaskCreationOptions.RunContinuationsAsynchronously);
var registration = cancellationToken.Register(() => done.TrySetCanceled(cancellationToken));
var item = new WorkItem(async ct =>
{
try
{
if (done.Task.IsCompleted) return; // cancelled while queued
using var linked = CancellationTokenSource.CreateLinkedTokenSource(ct, cancellationToken);
done.TrySetResult(await work(linked.Token).ConfigureAwait(false));
}
catch (OperationCanceledException) { done.TrySetCanceled(); }
catch (Exception ex) { done.TrySetException(ex); }
finally { await registration.DisposeAsync().ConfigureAwait(false); }
}, () =>
{
registration.Dispose();
done.TrySetCanceled();
});
if (!Enqueue(item))
{
registration.Dispose();
done.TrySetCanceled();
}
return done.Task;
}
/// <summary>Plain text (no parse mode) in order with rendered output; cut to 4096 characters by the sender.</summary>
public Task<int?> SayAsync(string text, CancellationToken cancellationToken = default) =>
RunInOrderAsync(ct => _sender.SendTextAsync(_chatId, text, cancellationToken: ct), cancellationToken);
/// <summary>
/// A Markdown reply rendered like an assistant message (HTML, split past 4096, each chunk retried), in order with
/// rendered output. Returns the first message id and whether every chunk was delivered.
/// </summary>
internal Task<(int? FirstId, bool Complete)> ReplyInOrderAsync(string markdown, CancellationToken cancellationToken) =>
RunInOrderAsync(ct => SendReplyAsync(markdown, ct), cancellationToken);
/// <summary>Completes once every queued event is rendered. For tests.</summary>
internal async Task DrainAsync()
{
@@ -77,7 +136,7 @@ public sealed class ChatRenderer : IAsyncDisposable
private async Task RunAsync()
{
var reader = _events.Reader;
var reader = _items.Reader;
var ct = _stop.Token;
try
{
@@ -107,15 +166,27 @@ public sealed class ChatRenderer : IAsyncDisposable
break;
}
while (reader.TryRead(out var e))
while (reader.TryRead(out var item))
{
try
{
switch (item)
{
case EventItem { Event: var e }:
await RenderAsync(e, ct).ConfigureAwait(false);
break;
case WorkItem work when ct.IsCancellationRequested: // disposal gave up waiting
work.Cancel();
break;
case WorkItem work:
await work.Run(ct).ConfigureAwait(false);
BuryStatus();
break;
}
}
catch (Exception ex) when (ex is not OperationCanceledException)
{
_logger.LogWarning(ex, "Rendering {Event} for chat {ChatId} failed", e.GetType().Name, _chatId);
_logger.LogWarning(ex, "Rendering {Item} for chat {ChatId} failed", item is EventItem ev ? ev.Event.GetType().Name : "work", _chatId);
}
finally
{
@@ -131,6 +202,11 @@ public sealed class ChatRenderer : IAsyncDisposable
finally
{
StopTyping();
while (_items.Reader.TryRead(out var left))
{
if (left is WorkItem work) work.Cancel();
Interlocked.Decrement(ref _pending);
}
}
}
@@ -153,7 +229,7 @@ public sealed class ChatRenderer : IAsyncDisposable
break;
case MessageEnd { Message: AssistantMessage message }:
await SendReplyAsync(message.Text, ct).ConfigureAwait(false);
_ = await SendReplyAsync(message.Text, ct).ConfigureAwait(false);
if (message.StopReason == StopReason.Error)
await SendLineAsync($"❌ {message.ErrorMessage ?? "The model request failed."}", ct).ConfigureAwait(false);
else if (message.StopReason == StopReason.Aborted)
@@ -204,10 +280,15 @@ public sealed class ChatRenderer : IAsyncDisposable
if (_statusDirty) await FlushStatusAsync(ct).ConfigureAwait(false);
}
/// <summary>Send a finished assistant message. A chunk that fails to send is retried a couple of times.</summary>
private async Task SendReplyAsync(string text, CancellationToken ct)
/// <summary>
/// Send a finished assistant message. A chunk that fails to send is retried a couple of times. Returns the first
/// message id and whether every chunk was delivered.
/// </summary>
private async Task<(int? FirstId, bool Complete)> SendReplyAsync(string text, CancellationToken ct)
{
if (string.IsNullOrWhiteSpace(text)) return;
if (string.IsNullOrWhiteSpace(text)) return (null, true);
int? first = null;
var complete = true;
foreach (var chunk in TelegramFormatter.Split(text))
{
var html = TelegramFormatter.ToHtml(chunk);
@@ -218,9 +299,18 @@ public sealed class ChatRenderer : IAsyncDisposable
await Task.Delay(delay, ct).ConfigureAwait(false);
sent = await _sender.SendHtmlAsync(_chatId, html, chunk, cancellationToken: ct).ConfigureAwait(false);
}
if (sent is null) _logger.LogError("Could not deliver part of a reply to chat {ChatId}", _chatId);
else BuryStatus();
if (sent is null)
{
_logger.LogError("Could not deliver part of a reply to chat {ChatId}", _chatId);
complete = false;
}
else
{
first ??= sent;
BuryStatus();
}
}
return (first, complete);
}
private async Task SendLineAsync(string text, CancellationToken ct)
@@ -339,7 +429,7 @@ public sealed class ChatRenderer : IAsyncDisposable
public async ValueTask DisposeAsync()
{
_events.Writer.TryComplete();
_items.Writer.TryComplete();
try
{
await _loop.WaitAsync(TimeSpan.FromSeconds(5)).ConfigureAwait(false);

View File

@@ -0,0 +1,32 @@
using System.Globalization;
namespace Volcanic.AgenticCore.Telegram.Rendering;
/// <summary>Bot API size limits (cloud Bot API server) and size text for users and the model.</summary>
public static class TelegramLimits
{
private const long MiB = 1024 * 1024;
/// <summary>Bots can download files up to 20 MB.</summary>
public const long MaxDownloadBytes = 20 * MiB;
/// <summary>Largest photo a bot can upload with <c>sendPhoto</c>.</summary>
public const long MaxPhotoBytes = 10 * MiB;
/// <summary>Largest file a bot can upload with <c>sendDocument</c>.</summary>
public const long MaxDocumentBytes = 50 * MiB;
/// <summary>Longest photo or document caption, in characters.</summary>
public const int MaxCaptionLength = 1024;
/// <summary>Megabytes with one decimal, e.g. <c>21.0</c>.</summary>
public static string Megabytes(long bytes) => (bytes / (double)MiB).ToString("0.0", CultureInfo.InvariantCulture);
/// <summary><c>5 bytes</c>, <c>1.5 KB</c>, <c>1.2 MB</c>.</summary>
public static string FormatSize(long bytes) => bytes switch
{
< 1024 => $"{bytes.ToString(CultureInfo.InvariantCulture)} bytes",
< MiB => $"{(bytes / 1024.0).ToString("0.0", CultureInfo.InvariantCulture)} KB",
_ => $"{Megabytes(bytes)} MB",
};
}

View File

@@ -102,6 +102,47 @@ public sealed class TelegramSender(ITelegramBotClient bot, ILogger<TelegramSende
}
}
/// <summary>Result of <see cref="SendFileAsync"/>: the message id, or the error; <c>PhotoRejected</c> when Telegram refused a photo with a 400.</summary>
public sealed record FileSendResult(int? MessageId, string? Error, bool PhotoRejected);
/// <summary>Delays before retrying an upload after a network failure (1 s, 3 s, like replies). Settable for tests.</summary>
internal IReadOnlyList<TimeSpan> FileRetryDelays { get; set; } = [TimeSpan.FromSeconds(1), TimeSpan.FromSeconds(3)];
/// <summary>
/// Upload a file as a photo or a document with a plain-text caption. Never throws except on cancellation: a network
/// failure is retried, an API error is returned (a 400 on a photo sets <c>PhotoRejected</c>).
/// </summary>
public async Task<FileSendResult> SendFileAsync(long chatId, string path, bool asPhoto, string? caption, CancellationToken cancellationToken = default)
{
for (var attempt = 0; ; attempt++)
{
try
{
await using var stream = new FileStream(path, FileMode.Open, FileAccess.Read, FileShare.Read, 81920, useAsync: true);
var file = InputFile.FromStream(stream, Path.GetFileName(path));
var message = asPhoto
? await bot.SendPhoto(chatId, file, caption: caption, cancellationToken: cancellationToken).ConfigureAwait(false)
: await bot.SendDocument(chatId, file, caption: caption, cancellationToken: cancellationToken).ConfigureAwait(false);
return new FileSendResult(message.Id, null, false);
}
catch (ApiRequestException ex)
{
logger.LogWarning(ex, "Uploading {File} to chat {ChatId} failed", Path.GetFileName(path), chatId);
return new FileSendResult(null, ex.Message, asPhoto && ex.ErrorCode == 400);
}
catch (RequestException ex) when (attempt < FileRetryDelays.Count)
{
logger.LogDebug(ex, "Uploading {File} to chat {ChatId} failed; retrying", Path.GetFileName(path), chatId);
await Task.Delay(FileRetryDelays[attempt], cancellationToken).ConfigureAwait(false);
}
catch (Exception ex) when (ex is not OperationCanceledException)
{
logger.LogWarning(ex, "Uploading {File} to chat {ChatId} failed", Path.GetFileName(path), chatId);
return new FileSendResult(null, ex.Message, false);
}
}
}
public async Task SendTypingAsync(long chatId, CancellationToken cancellationToken = default)
{
try

View File

@@ -0,0 +1,11 @@
namespace Volcanic.AgenticCore.Telegram.Speech;
/// <summary>Turns a voice note or audio file into text before a prompt exists. The core only ever sees the text.</summary>
public interface ISpeechToText
{
/// <summary>The trimmed transcript; throws <see cref="SpeechToTextException"/> when the service fails.</summary>
Task<string> TranscribeAsync(ReadOnlyMemory<byte> audio, string fileName, string mimeType, CancellationToken cancellationToken);
}
/// <summary>A transcription failed; the message is fit for the user and never contains the API key.</summary>
public sealed class SpeechToTextException(string message, Exception? inner = null) : Exception(message, inner);

View File

@@ -0,0 +1,70 @@
using System.Net.Http.Headers;
using System.Text.Json;
namespace Volcanic.AgenticCore.Telegram.Speech;
/// <summary>OpenAI's <c>/audio/transcriptions</c> (port of VolcanicSecretary's <c>stt/openai.ts</c>).</summary>
public sealed class OpenAiSpeechToText(HttpClient http, SpeechOptions options) : ISpeechToText
{
private const string Prefix = "OpenAI transcription failed: ";
private const int MaxExcerpt = 200;
public async Task<string> TranscribeAsync(ReadOnlyMemory<byte> audio, string fileName, string mimeType, CancellationToken cancellationToken)
{
using var form = new MultipartFormDataContent();
var file = new ReadOnlyMemoryContent(audio);
file.Headers.ContentType = new MediaTypeHeaderValue(mimeType);
form.Add(file, "file", fileName);
form.Add(new StringContent(options.ResolvedModel), "model");
form.Add(new StringContent("json"), "response_format");
if (!string.IsNullOrWhiteSpace(options.Language)) form.Add(new StringContent(options.Language.Trim()), "language");
using var request = new HttpRequestMessage(HttpMethod.Post, options.BaseUrl.TrimEnd('/') + "/audio/transcriptions") { Content = form };
request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", options.ApiKey);
HttpResponseMessage response;
string body;
try
{
response = await http.SendAsync(request, cancellationToken).ConfigureAwait(false);
}
catch (OperationCanceledException ex) when (!cancellationToken.IsCancellationRequested) // HttpClient.Timeout
{
throw new SpeechToTextException(Prefix + "the request timed out.", ex);
}
catch (HttpRequestException ex)
{
throw new SpeechToTextException(Prefix + Hide(ex.Message), ex);
}
using var _ = response;
try
{
body = await response.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false);
}
catch (OperationCanceledException ex) when (!cancellationToken.IsCancellationRequested)
{
throw new SpeechToTextException(Prefix + "the request timed out.", ex);
}
if (!response.IsSuccessStatusCode)
{
var hidden = Hide(body); // hide the key before cutting, so half a key can't leak
var excerpt = hidden.Length > MaxExcerpt ? hidden[..MaxExcerpt] : hidden;
throw new SpeechToTextException($"{Prefix}HTTP {(int)response.StatusCode} {excerpt}".TrimEnd());
}
try
{
using var json = JsonDocument.Parse(body);
if (json.RootElement.ValueKind == JsonValueKind.Object
&& json.RootElement.TryGetProperty("text", out var text) && text.ValueKind == JsonValueKind.String)
return text.GetString()!.Trim();
}
catch (JsonException ex)
{
throw new SpeechToTextException(Prefix + "the response is not JSON.", ex);
}
throw new SpeechToTextException(Prefix + "the response has no text.");
}
private string Hide(string text) =>
string.IsNullOrEmpty(options.ApiKey) ? text : text.Replace(options.ApiKey, "[REDACTED]", StringComparison.Ordinal);
}

View File

@@ -0,0 +1,27 @@
namespace Volcanic.AgenticCore.Telegram.Speech;
/// <summary>The <c>Telegram:Speech</c> section. Speech to text is on exactly when <see cref="ApiKey"/> is set.</summary>
public sealed class SpeechOptions
{
public const string DefaultModel = "gpt-4o-transcribe";
public const string DefaultBaseUrl = "https://api.openai.com/v1";
/// <summary>Falls back to <c>OPENAI_API_KEY</c>. Empty turns speech to text off.</summary>
public string ApiKey { get; set; } = "";
/// <summary>Falls back to <c>STT_MODEL</c>, then <see cref="DefaultModel"/>.</summary>
public string Model { get; set; } = "";
/// <summary>Any OpenAI-compatible transcription endpoint.</summary>
public string BaseUrl { get; set; } = DefaultBaseUrl;
/// <summary>Optional ISO-639-1 hint.</summary>
public string? Language { get; set; }
public bool IsEnabled => !string.IsNullOrWhiteSpace(ApiKey);
public string ResolvedModel => string.IsNullOrWhiteSpace(Model) ? DefaultModel : Model.Trim();
/// <summary>The startup log line (never the key).</summary>
public string Describe() => IsEnabled ? $"Speech to text: {ResolvedModel}" : "Speech to text: off (no OPENAI_API_KEY)";
}

View File

@@ -1,3 +1,5 @@
using Volcanic.AgenticCore.Telegram.Speech;
namespace Volcanic.AgenticCore.Telegram;
/// <summary>The <c>Telegram</c> configuration section.</summary>
@@ -36,6 +38,12 @@ public sealed class TelegramOptions
public TimeSpan ReconnectMaxDelay { get; set; } = TimeSpan.FromSeconds(60);
/// <summary>Image documents up to this size are also shown to the model inline (base64 stays under providers' 5 MB cap).</summary>
public long InlineImageMaxBytes { get; set; } = 3_750_000;
/// <summary>Speech to text for voice notes and audio (<c>Telegram:Speech</c>).</summary>
public SpeechOptions Speech { get; set; } = new();
/// <summary>Reads the environment-variable shortcuts the README documents, when the section leaves them empty.</summary>
public void ApplyEnvironment(Func<string, string?> getEnv)
{
@@ -45,6 +53,13 @@ public sealed class TelegramOptions
foreach (var part in ids.Split([',', ';', ' '], StringSplitOptions.RemoveEmptyEntries))
if (long.TryParse(part, out var id)) AllowedUserIds.Add(id);
}
// Speech: the environment fills only empty settings, and an empty variable counts as unset.
Speech ??= new SpeechOptions();
if (string.IsNullOrWhiteSpace(Speech.ApiKey) && getEnv("OPENAI_API_KEY") is { } key && !string.IsNullOrWhiteSpace(key))
Speech.ApiKey = key.Trim();
if (string.IsNullOrWhiteSpace(Speech.Model) && getEnv("STT_MODEL") is { } model && !string.IsNullOrWhiteSpace(model))
Speech.Model = model.Trim();
}
/// <summary>Configuration problems that must stop the host, empty when valid.</summary>
@@ -62,6 +77,10 @@ public sealed class TelegramOptions
if (ReconnectInitialDelay <= TimeSpan.Zero || ReconnectMaxDelay < ReconnectInitialDelay)
errors.Add("Telegram reconnect delays must be positive with max >= initial.");
if (InputDebounce < TimeSpan.Zero) errors.Add("Telegram:InputDebounce must not be negative.");
if (InlineImageMaxBytes <= 0) errors.Add("Telegram:InlineImageMaxBytes must be positive.");
// On Linux "/v1" parses as an absolute file:// URI; the scheme check rejects it.
if (!Uri.TryCreate(Speech?.BaseUrl, UriKind.Absolute, out var sttUri) || sttUri.Scheme is not ("http" or "https"))
errors.Add("Telegram:Speech:BaseUrl must be an absolute http(s) URL.");
return errors;
}

View File

@@ -8,7 +8,7 @@ public static class TelegramProcessEnvironment
{
public const string AllowVariable = "PROCESS_ENV_ALLOW";
/// <summary>Adds the section's lists, <c>PROCESS_ENV_ALLOW</c>, the bot token and the Google client secret to <paramref name="target"/>.</summary>
/// <summary>Adds the section's lists, <c>PROCESS_ENV_ALLOW</c>, the bot token, the speech API key and the Google client secret to <paramref name="target"/>.</summary>
public static void Configure(ProcessEnvironmentOptions target, TelegramOptions telegram, ProcessEnvironmentSection section, Func<string, string?> getEnv,
GoogleCalendarOptions? calendar = null)
{
@@ -22,6 +22,7 @@ public static class TelegramProcessEnvironment
target.Allow.AddRange(allow.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries));
target.HardenHostProcess = section.HardenHostProcess;
if (!string.IsNullOrWhiteSpace(telegram.BotToken)) target.SecretValues.Add(telegram.BotToken);
if (!string.IsNullOrWhiteSpace(telegram.Speech?.ApiKey)) target.SecretValues.Add(telegram.Speech.ApiKey);
if (!string.IsNullOrWhiteSpace(calendar?.ClientSecret)) target.SecretValues.Add(calendar.ClientSecret);
}

View File

@@ -9,6 +9,7 @@
<ProjectReference Include="../../extensions/Volcanic.AgenticCore.Extensions.GoogleCalendar/Volcanic.AgenticCore.Extensions.GoogleCalendar.csproj" />
<ProjectReference Include="../../extensions/Volcanic.AgenticCore.Extensions.Mcp/Volcanic.AgenticCore.Extensions.Mcp.csproj" />
<PackageReference Include="Microsoft.Extensions.Hosting" Version="10.0.0" />
<PackageReference Include="Microsoft.Extensions.Http" Version="10.0.0" />
<PackageReference Include="Telegram.Bot" Version="22.10.3.1" />
</ItemGroup>
<ItemGroup>

View File

@@ -22,6 +22,8 @@ public interface IExtensionContext
/// <summary>False when the host uses <see cref="NullUserInterface"/>; extensions should then avoid dialogs.</summary>
bool HasUi { get; }
IUserInterface Ui { get; }
/// <summary>The session's chat channel, or null when the host has none (then <c>send</c> isn't registered).</summary>
IChatChannel? Channel => null;
IReadOnlySessionManager SessionManager { get; }
IModelRegistry ModelRegistry { get; }
Model? Model { get; }

View File

@@ -0,0 +1,38 @@
namespace Volcanic.AgenticCore.Abstractions.Ui;
/// <summary>How a file should be delivered. <see cref="Auto"/> lets the channel choose.</summary>
public enum ChatFileKind { Auto, Photo, Document }
/// <param name="Path">Absolute path of an existing file; the caller has already confined and validated it.</param>
/// <param name="Kind">Photo, document, or let the channel choose.</param>
/// <param name="Caption">Plain text shown with the file, at most <see cref="ChatChannelLimits.MaxCaptionLength"/>.</param>
public sealed record ChatFile(string Path, ChatFileKind Kind = ChatFileKind.Auto, string? Caption = null);
/// <param name="SentAs">How a file was actually delivered (Photo or Document); null for text.</param>
/// <param name="MessageId">Host message id(s), for logs and tool details.</param>
public sealed record ChatDelivery(ChatFileKind? SentAs, string? MessageId);
/// <summary>Upload limits of a channel, in bytes and characters.</summary>
public sealed record ChatChannelLimits(long MaxPhotoBytes, long MaxDocumentBytes, int MaxCaptionLength);
/// <summary>
/// Delivers content into the conversation's chat, outside the normal assistant-message rendering. Implementations throw
/// only <see cref="ChatDeliveryException"/> and <see cref="OperationCanceledException"/>, are safe to call from any thread,
/// and work while no run is active. Nothing delivered is persisted by the core.
/// </summary>
public interface IChatChannel
{
ChatChannelLimits Limits { get; }
/// <summary>Markdown, rendered like assistant text (split when long). Not added to the session transcript.</summary>
Task<ChatDelivery> SendTextAsync(string markdown, CancellationToken cancellationToken);
/// <summary>
/// Deliver a file. <see cref="ChatFileKind.Auto"/> lets the channel choose, and it may fall back from photo to
/// document. Throws <see cref="ChatDeliveryException"/> with a message fit for the model or the user.
/// </summary>
Task<ChatDelivery> SendFileAsync(ChatFile file, CancellationToken cancellationToken);
}
/// <summary>A delivery failed; the message is fit for the model or the user.</summary>
public sealed class ChatDeliveryException(string message, Exception? inner = null) : Exception(message, inner);

View File

@@ -16,6 +16,7 @@ public sealed partial class AgentSession : IExtensionHost
{
string IExtensionHost.Scope => _session.Scope;
IUserInterface IExtensionHost.Ui => _ui;
IChatChannel? IExtensionHost.Channel => _options.Channel;
IReadOnlySessionManager IExtensionHost.SessionManager => _session;
IModelRegistry IExtensionHost.ModelRegistry => _models;
Model? IExtensionHost.Model => _model;

View File

@@ -102,6 +102,8 @@ public sealed partial class AgentSession : IAsyncDisposable
public string Cwd => _options.Cwd;
/// <summary>The session's time zone: the session option, else the host default, else the machine's zone.</summary>
public TimeZoneInfo TimeZone => _options.TimeZone ?? _coreOptions.DefaultTimeZone ?? TimeZoneInfo.Local;
/// <summary>The host's chat channel for this session (e.g. for scheduled deliveries), or null.</summary>
public IChatChannel? Channel => _options.Channel;
public Model? Model => _model;
public ThinkingLevel ThinkingLevel => _thinkingLevel;
public bool IsStreaming { get { lock (_stateLock) return _runTask is { IsCompleted: false }; } }

View File

@@ -68,6 +68,12 @@ public sealed class AgentSessionFactory : IAgentSessionFactory
{
var active = options.ActiveTools ?? BuiltInTools.DefaultActive;
foreach (var tool in BuiltInTools.All()) tools.AddBuiltIn(tool, active.Contains(tool.Name));
// `send` exists only when the host can deliver into this session's chat.
if (options.Channel is not null)
{
var send = BuiltInTools.Send();
tools.AddBuiltIn(send, options.ActiveTools?.Contains(send.Name) ?? true);
}
}
var commands = new CommandRegistry();
var models = new ModelRegistry(new ApiKeyResolver(_paths.AuthFile));

View File

@@ -15,6 +15,7 @@ internal class ExtensionContext(ExtensionRunner runner, IExtensionHost host, Fun
public string SessionId => Host.SessionId;
public bool HasUi => Host.Ui is not NullUserInterface;
public IUserInterface Ui => Host.Ui;
public IChatChannel? Channel => Host.Channel;
public IReadOnlySessionManager SessionManager => Host.SessionManager;
public IModelRegistry ModelRegistry => Host.ModelRegistry;
public Model? Model => Host.Model;

View File

@@ -15,6 +15,8 @@ public interface IExtensionHost
/// <summary>The session store scope, e.g. <c>telegram-42</c>.</summary>
string Scope { get; }
IUserInterface Ui { get; }
/// <summary>The session's chat channel, or null when the host has none (then <c>send</c> isn't registered).</summary>
IChatChannel? Channel { get; }
IReadOnlySessionManager SessionManager { get; }
IModelRegistry ModelRegistry { get; }
Model? Model { get; }

View File

@@ -0,0 +1,68 @@
using System.ComponentModel;
using System.Globalization;
using Volcanic.AgenticCore.Abstractions.Tools;
using Volcanic.AgenticCore.Abstractions.Ui;
namespace Volcanic.AgenticCore.Features.Tools.BuiltIn;
public sealed record SendArgs(
[property: Description("Workspace file to send, relative to the working directory (or absolute inside it)")] string Path,
[property: Description("\"auto\" (default: images inline, other files as documents), \"photo\" or \"document\"")] string? Kind = null,
[property: Description("Plain-text caption shown with the file")] string? Caption = null);
/// <summary>
/// Sends a workspace file into the session's chat through <see cref="IChatChannel"/>. Registered only for sessions that
/// have a channel. Confines the path to the workspace (symlinks included) and checks the channel's limits before sending.
/// </summary>
public sealed class SendTool : Tool<SendArgs>
{
private static readonly HashSet<string> PhotoExtensions = new(StringComparer.OrdinalIgnoreCase) { ".jpg", ".jpeg", ".png", ".webp" };
public override string Name => "send";
public override string Description => "Send a file from the workspace to the user in this chat. Images go inline as photos, other files as documents.";
public override string? PromptSnippet => "send: send a workspace file to the user (images inline, other files as documents)";
public override IReadOnlyList<string> PromptGuidelines =>
[
"When the user asks for a file, image, screenshot or report, write it into the workspace and call send; your reply arrives after the attachment, so a one-line confirmation is enough.",
"Files the user sends you are saved under inbox/.",
];
public override ToolExecutionMode ExecutionMode => ToolExecutionMode.Sequential;
protected override async Task<ToolResult> ExecuteAsync(SendArgs args, ToolInvocation invocation, CancellationToken cancellationToken)
{
var ctx = invocation.Context;
var channel = ctx.Channel ?? throw new InvalidOperationException("send is not available: this session has no chat");
var root = System.IO.Path.GetFullPath(ctx.Cwd);
var full = WorkspacePaths.Confine(root, args.Path);
if (Directory.Exists(full)) throw new InvalidOperationException($"path is a directory, not a file: {args.Path}");
if (!File.Exists(full)) throw new FileNotFoundException($"file not found: {args.Path}");
var kind = (args.Kind ?? "auto").Trim().ToLowerInvariant() switch
{
"" or "auto" => ChatFileKind.Auto,
"photo" => ChatFileKind.Photo,
"document" => ChatFileKind.Document,
_ => throw new ArgumentException($"kind must be \"auto\", \"photo\" or \"document\", not \"{args.Kind}\"."),
};
var caption = string.IsNullOrEmpty(args.Caption) ? null : args.Caption;
var limits = channel.Limits;
if (caption is not null && caption.Length > limits.MaxCaptionLength)
throw new InvalidOperationException(string.Create(CultureInfo.InvariantCulture,
$"caption is {caption.Length} characters; the limit is {limits.MaxCaptionLength}. Shorten it or put the text in your reply."));
var bytes = new FileInfo(full).Length;
if (kind == ChatFileKind.Auto && !(PhotoExtensions.Contains(System.IO.Path.GetExtension(full)) && bytes <= limits.MaxPhotoBytes))
kind = ChatFileKind.Document;
if (kind == ChatFileKind.Photo && bytes > limits.MaxPhotoBytes)
throw new InvalidOperationException($"photo too large: {Mb(bytes)} MB > {Mb(limits.MaxPhotoBytes)} MB. Use kind \"document\" or downscale it.");
if (kind == ChatFileKind.Document && bytes > limits.MaxDocumentBytes)
throw new InvalidOperationException($"file too large: {Mb(bytes)} MB > {Mb(limits.MaxDocumentBytes)} MB (the chat's upload limit).");
var delivery = await channel.SendFileAsync(new ChatFile(full, kind, caption), cancellationToken).ConfigureAwait(false);
var sentAs = delivery.SentAs == ChatFileKind.Photo ? "photo" : "document";
var display = WorkspacePaths.Relative(root, full);
return ToolResult.Text($"Sent {display} as {sentAs}.", new { path = display, kind = sentAs, bytes, messageId = delivery.MessageId });
}
private static string Mb(long bytes) => (bytes / 1048576.0).ToString("0.0", CultureInfo.InvariantCulture);
}

View File

@@ -0,0 +1,98 @@
namespace Volcanic.AgenticCore.Features.Tools.BuiltIn;
/// <summary>Workspace confinement for tools that hand files to the outside world.</summary>
internal static class WorkspacePaths
{
/// <summary>Links followed while resolving one path before it counts as a loop (Linux's <c>MAXSYMLINKS</c>).</summary>
private const int MaxLinkHops = 40;
private static StringComparison Comparison => OperatingSystem.IsWindows() ? StringComparison.OrdinalIgnoreCase : StringComparison.Ordinal;
/// <summary>True when <paramref name="fullPath"/> is strictly below <paramref name="root"/> (trailing-separator compare).</summary>
public static bool IsInside(string root, string fullPath) =>
fullPath.StartsWith(Path.TrimEndingDirectorySeparator(root) + Path.DirectorySeparatorChar, Comparison);
/// <summary><see cref="IsInside"/>, or the root itself.</summary>
public static bool IsInsideOrRoot(string root, string fullPath) =>
string.Equals(Path.TrimEndingDirectorySeparator(root), Path.TrimEndingDirectorySeparator(fullPath), Comparison)
|| IsInside(root, fullPath);
/// <summary>Workspace-relative path with <c>/</c> separators.</summary>
public static string Relative(string root, string fullPath) => Path.GetRelativePath(root, fullPath).Replace('\\', '/');
/// <summary>
/// The full path of <paramref name="path"/> when both it and its real location (every link resolved, component by
/// component) stay inside <paramref name="root"/>. The root itself counts as inside, so the caller can report it as a
/// directory. Throws <c>path escapes the workspace: …</c> otherwise.
/// </summary>
public static string Confine(string root, string path)
{
var full = Paths.Resolve(root, path);
if (!IsInsideOrRoot(root, full)) throw Escapes(path);
// The OS opens `full` by resolving each component in turn, links included, so check where that really lands.
if (RealPath(root) is not { } realRoot || RealPath(full) is not { } real || !IsInsideOrRoot(realRoot, real))
throw Escapes(path);
return full;
}
/// <summary>
/// <c>realpath(3)</c> for a full path that may not exist: walks it from the filesystem root and replaces each link by
/// its target as it is met, so a <c>..</c> inside a link target applies to the real parent, and links inside the
/// target are followed too. Null when resolving takes more than <see cref="MaxLinkHops"/> links (a loop).
/// </summary>
internal static string? RealPath(string fullPath)
{
var current = Path.GetPathRoot(fullPath) ?? "";
var pending = new Stack<string>(); // components still to walk, the next one on top
Push(pending, fullPath[current.Length..]);
var hops = 0;
while (pending.Count > 0)
{
var part = pending.Pop();
if (part is "" or ".") continue;
if (part == "..")
{
current = Path.GetDirectoryName(current) ?? current; // current is real, so its parent is too
continue;
}
var next = Path.Join(current, part);
if (LinkTarget(next) is not { } target)
{
current = next;
continue;
}
if (++hops > MaxLinkHops) return null;
if (Path.IsPathFullyQualified(target))
{
current = Path.GetPathRoot(target)!;
Push(pending, target[current.Length..]);
}
else
{
Push(pending, target); // relative to the link's directory, which is `current`
}
}
return current;
}
private static void Push(Stack<string> pending, string relative)
{
var parts = relative.Split(['/', Path.DirectorySeparatorChar], StringSplitOptions.RemoveEmptyEntries);
for (var i = parts.Length - 1; i >= 0; i--) pending.Push(parts[i]);
}
private static string? LinkTarget(string path)
{
try
{
FileSystemInfo info = Directory.Exists(path) ? new DirectoryInfo(path) : new FileInfo(path);
return info.LinkTarget;
}
catch (Exception ex) when (ex is IOException or UnauthorizedAccessException)
{
return null;
}
}
private static InvalidOperationException Escapes(string path) => new($"path escapes the workspace: {path}");
}

View File

@@ -8,5 +8,8 @@ public static class BuiltInTools
public static readonly IReadOnlyList<string> DefaultActive = ["read", "bash", "edit", "write"];
public static readonly IReadOnlyList<string> ReadOnlySet = ["read", "grep", "find", "ls"];
/// <summary>The <c>send</c> tool, registered only for sessions that have a chat channel.</summary>
public static ITool Send() => new SendTool();
public static IReadOnlyList<ITool> All() => [new ReadTool(), new BashTool(), new EditTool(), new WriteTool(), new LsTool(), new GrepTool(), new FindTool()];
}

View File

@@ -63,6 +63,8 @@ public sealed class AgentSessionOptions
/// <summary>The conversation's time zone. Falls back to <see cref="AgenticCoreOptions.DefaultTimeZone"/>, then <see cref="TimeZoneInfo.Local"/>.</summary>
public TimeZoneInfo? TimeZone { get; init; }
public IUserInterface? Ui { get; init; }
/// <summary>Delivers files and texts into the conversation's chat; enables the <c>send</c> tool.</summary>
public IChatChannel? Channel { get; init; }
/// <summary>Active tool names. Defaults to read, bash, edit, write.</summary>
public IReadOnlyList<string>? ActiveTools { get; init; }
public ToolExecutionMode ToolExecution { get; init; } = ToolExecutionMode.Parallel;

View File

@@ -171,6 +171,38 @@ public class McpExtensionTests
Assert.DoesNotContain("## Browser", host.Faux.Requests[1].SystemPrompt, StringComparison.Ordinal);
}
private sealed class NullChannel : Volcanic.AgenticCore.Abstractions.Ui.IChatChannel
{
public Volcanic.AgenticCore.Abstractions.Ui.ChatChannelLimits Limits { get; } = new(1, 1, 1);
public Task<Volcanic.AgenticCore.Abstractions.Ui.ChatDelivery> SendTextAsync(string markdown, CancellationToken cancellationToken) => throw new NotSupportedException();
public Task<Volcanic.AgenticCore.Abstractions.Ui.ChatDelivery> SendFileAsync(Volcanic.AgenticCore.Abstractions.Ui.ChatFile file, CancellationToken cancellationToken) => throw new NotSupportedException();
}
[Fact]
public async Task Browser_section_mentions_send_when_the_session_has_a_channel()
{
await using var host = new McpTestHost(PresetOnFake());
host.Faux.Enqueue(FauxTurn.Say("ok"));
var session = await host.CreateSessionAsync(channel: new NullChannel());
await session.PromptAsync("hi");
Assert.Contains("send", session.ActiveTools);
Assert.Contains("call `send` with that", host.Faux.Requests[0].SystemPrompt, StringComparison.Ordinal);
}
[Fact]
public async Task Browser_section_does_not_mention_an_inactive_send()
{
await using var host = new McpTestHost(PresetOnFake());
host.Faux.Enqueue(FauxTurn.Say("ok"));
var session = await host.CreateSessionAsync(channel: new NullChannel());
session.SetActiveTools(session.ActiveTools.Where(t => t != "send").ToList());
await session.PromptAsync("hi");
Assert.Contains("## Browser", host.Faux.Requests[0].SystemPrompt, StringComparison.Ordinal);
Assert.DoesNotContain("`send`", host.Faux.Requests[0].SystemPrompt, StringComparison.Ordinal);
}
[Fact]
public async Task Browser_section_mentions_send_only_when_a_send_tool_exists()
{

View File

@@ -65,10 +65,10 @@ internal sealed class McpTestHost : IAsyncDisposable
public string AgentDir => Path.Combine(_root, "agent");
public string LogPath => Path.Combine(_root, "fake.log");
public async Task<AgentSession> CreateSessionAsync(TimeZoneInfo? zone = null)
public async Task<AgentSession> CreateSessionAsync(TimeZoneInfo? zone = null, Volcanic.AgenticCore.Abstractions.Ui.IChatChannel? channel = null)
{
var session = await _services.GetRequiredService<IAgentSessionFactory>()
.CreateAsync(new AgentSessionOptions { Cwd = Workspace, TimeZone = zone });
.CreateAsync(new AgentSessionOptions { Cwd = Workspace, TimeZone = zone, Channel = channel });
lock (_sessions) _sessions.Add(session);
return session;
}

View File

@@ -0,0 +1,200 @@
using Microsoft.Extensions.Time.Testing;
using Telegram.Bot.Exceptions;
using Telegram.Bot.Requests;
using Volcanic.AgenticCore.Abstractions.Messages;
using Volcanic.AgenticCore.Telegram.Tests.Support;
using Volcanic.AgenticCore.Testing;
namespace Volcanic.AgenticCore.Telegram.Tests;
/// <summary>Documents and videos end to end: saved to the chat's inbox and prompted, or refused with a notice.</summary>
public sealed class AttachmentChatTests
{
private static readonly DateTimeOffset Today = new(2026, 9, 26, 10, 0, 0, TimeSpan.Zero);
private const long MB = 1024 * 1024;
private static UserMessage Prompt(ChatHost host) => (UserMessage)host.Faux.Requests.Single().Messages.Last();
private static string Inbox(ChatHost host) => Path.Combine(host.WorkspaceOf(), "inbox");
private static string[] InboxFiles(ChatHost host) => Directory.Exists(Inbox(host)) ? Directory.GetFiles(Inbox(host)) : [];
private static async Task RouteAndFlushAsync(ChatHost host, FakeTimeProvider time, params global::Telegram.Bot.Types.Update[] updates)
{
foreach (var update in updates) await host.RouteAsync(update);
await MessageContextChatTests.FlushAsync(host, time, updates.Length);
await host.SettleAsync();
}
[Fact]
public async Task A_document_with_a_caption_is_saved_to_the_inbox_and_prompted()
{
var time = new FakeTimeProvider(Today);
await using var host = new ChatHost(time: time);
host.Bot.Files["doc"] = "hello world"u8.ToArray();
host.Faux.Enqueue(FauxTurn.Say("Summary."));
await RouteAndFlushAsync(host, time, Updates.DocumentMessage("report.pdf", "application/pdf", 11, caption: "Please summarise"));
Assert.Equal("hello world"u8.ToArray(), await File.ReadAllBytesAsync(Path.Combine(Inbox(host), "2026-09-26-report.pdf")));
Assert.EndsWith("\nPlease summarise\n\n[File received: inbox/2026-09-26-report.pdf (application/pdf, 11 bytes)]", Prompt(host).Text, StringComparison.Ordinal);
}
[Fact]
public async Task A_second_upload_of_the_same_name_gets_a_suffix()
{
var time = new FakeTimeProvider(Today);
await using var host = new ChatHost(time: time);
host.Bot.Files["doc"] = [1];
host.Faux.Enqueue(FauxTurn.Say("One."), FauxTurn.Say("Two."));
await RouteAndFlushAsync(host, time, Updates.DocumentMessage("report.pdf", "application/pdf", 1));
await RouteAndFlushAsync(host, time, Updates.DocumentMessage("report.pdf", "application/pdf", 1));
Assert.Contains("[File received: inbox/2026-09-26-report-1.pdf (application/pdf, 1 bytes)]", host.Faux.Requests[1].Messages.Last().Text, StringComparison.Ordinal);
Assert.Equal(2, InboxFiles(host).Length);
}
[Fact]
public async Task A_hostile_file_name_stays_in_the_inbox()
{
var time = new FakeTimeProvider(Today);
await using var host = new ChatHost(time: time);
host.Bot.Files["doc"] = [1];
host.Faux.Enqueue(FauxTurn.Say("ok"));
await RouteAndFlushAsync(host, time, Updates.DocumentMessage("../../evil.sh", "text/x-sh", 1));
Assert.Equal(Path.Combine(Inbox(host), "2026-09-26-evil.sh"), Assert.Single(InboxFiles(host)));
Assert.False(File.Exists(Path.Combine(host.WorkspaceOf(), "..", "evil.sh")));
}
[Fact]
public async Task A_file_over_20_MB_gets_the_size_notice_and_no_run()
{
await using var host = new ChatHost();
await host.RouteAsync(Updates.DocumentMessage("big.zip", "application/zip", 21 * MB, caption: "look"));
await host.SettleAsync();
Assert.Contains("That file is 21.0 MB; Telegram lets bots download at most 20 MB. Share a link or a smaller file.", host.Texts);
Assert.Empty(host.Bot.Requests.OfType<GetFileRequest>());
Assert.Empty(InboxFiles(host));
Assert.Empty(host.Faux.Requests);
}
[Fact]
public async Task A_getFile_too_big_error_gets_the_size_notice()
{
await using var host = new ChatHost();
host.Bot.OnSend = r => { if (r is GetFileRequest) throw new ApiRequestException("Bad Request: file is too big", 400); };
await host.RouteAsync(Updates.DocumentMessage("big.zip", "application/zip", size: null));
await host.SettleAsync();
Assert.Contains("That file is over 20 MB; Telegram lets bots download at most 20 MB. Share a link or a smaller file.", host.Texts);
Assert.Empty(InboxFiles(host));
Assert.Empty(host.Faux.Requests);
}
[Fact]
public async Task A_failed_download_deletes_the_partial_file_and_gets_a_notice()
{
await using var host = new ChatHost();
host.Bot.Files["doc"] = [1, 2, 3];
host.Bot.FailDownloads = true;
await host.RouteAsync(Updates.DocumentMessage("report.pdf", "application/pdf", 3, caption: "read this"));
await host.SettleAsync();
Assert.Contains("I could not download that file.", host.Texts);
Assert.Empty(InboxFiles(host));
Assert.Empty(host.Faux.Requests);
}
[Fact]
public async Task An_image_document_within_the_limit_is_attached_inline()
{
var time = new FakeTimeProvider(Today);
await using var host = new ChatHost(time: time);
host.Bot.Files["doc"] = Png;
host.Faux.Enqueue(FauxTurn.Say("A diagram."));
await RouteAndFlushAsync(host, time, Updates.DocumentMessage("diagram.png", "image/png", Png.Length));
var image = Assert.Single(Prompt(host).Content.OfType<ImageContent>());
Assert.Equal(("image/png", Convert.ToBase64String(Png)), (image.MimeType, image.Data));
Assert.EndsWith("[File received: inbox/2026-09-26-diagram.png (image/png, 10 bytes)]", Prompt(host).Text, StringComparison.Ordinal);
}
private static readonly byte[] Png = [0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A, 1, 2];
[Fact]
public async Task An_image_document_is_attached_with_the_type_its_bytes_show()
{
var time = new FakeTimeProvider(Today);
await using var host = new ChatHost(time: time);
byte[] jpeg = [0xFF, 0xD8, 0xFF, 0xE0, 1, 2];
host.Bot.Files["doc"] = jpeg;
host.Faux.Enqueue(FauxTurn.Say("ok"));
await RouteAndFlushAsync(host, time, Updates.DocumentMessage("photo.png", "image/png", jpeg.Length));
Assert.Equal("image/jpeg", Assert.Single(Prompt(host).Content.OfType<ImageContent>()).MimeType);
}
[Fact]
public async Task An_image_document_whose_bytes_are_no_known_image_is_path_only()
{
var time = new FakeTimeProvider(Today);
await using var host = new ChatHost(time: time);
host.Bot.Files["doc"] = [9, 8, 7]; // e.g. HEIC or a truncated file labelled image/png
host.Faux.Enqueue(FauxTurn.Say("ok"));
await RouteAndFlushAsync(host, time, Updates.DocumentMessage("diagram.png", "image/png", 3));
Assert.Empty(Prompt(host).Content.OfType<ImageContent>());
Assert.EndsWith("[File received: inbox/2026-09-26-diagram.png (image/png, 3 bytes) (not shown inline: the contents are not a JPEG, PNG, WebP or GIF image)]",
Prompt(host).Text, StringComparison.Ordinal);
}
[Fact]
public async Task An_image_document_over_the_limit_is_path_only()
{
var time = new FakeTimeProvider(Today);
await using var host = new ChatHost(time: time);
host.Options.InlineImageMaxBytes = 2;
host.Bot.Files["doc"] = [9, 8, 7];
host.Faux.Enqueue(FauxTurn.Say("ok"));
await RouteAndFlushAsync(host, time, Updates.DocumentMessage("diagram.png", "image/png", 3));
Assert.Empty(Prompt(host).Content.OfType<ImageContent>());
Assert.EndsWith("[File received: inbox/2026-09-26-diagram.png (image/png, 3 bytes) (too large to show inline; downscale it with bash if you need to see it)]",
Prompt(host).Text, StringComparison.Ordinal);
}
[Fact]
public async Task A_document_captioned_new_is_not_a_command()
{
await using var host = new ChatHost();
host.Bot.Files["doc"] = [1];
host.Faux.Enqueue(FauxTurn.Say("ok"));
var chat = await host.ChatAsync();
var sessionId = chat.Session.SessionId;
await host.RouteAsync(Updates.DocumentMessage("notes.txt", "text/plain", 1, caption: "/new"));
await host.SettleAsync();
Assert.Equal(sessionId, chat.Session.SessionId);
Assert.Contains("/new\n\n[File received: inbox/", host.Faux.Requests.Single().Messages.Last().Text, StringComparison.Ordinal);
}
[Fact]
public async Task A_video_without_a_name_is_saved_as_video_mp4()
{
var time = new FakeTimeProvider(Today);
await using var host = new ChatHost(time: time);
host.Bot.Files["video"] = [1];
host.Faux.Enqueue(FauxTurn.Say("ok"));
await RouteAndFlushAsync(host, time, Updates.VideoMessage(null, 1));
Assert.EndsWith("[File received: inbox/2026-09-26-video.mp4 (video/mp4, 1 bytes)]", Prompt(host).Text, StringComparison.Ordinal);
}
}

View File

@@ -0,0 +1,136 @@
using System.Text.Json;
using Microsoft.Extensions.Logging.Abstractions;
using Telegram.Bot.Requests;
using Telegram.Bot.Types.Enums;
using Volcanic.AgenticCore.Abstractions.Events;
using Volcanic.AgenticCore.Abstractions.Messages;
using Volcanic.AgenticCore.Abstractions.Providers;
using Volcanic.AgenticCore.Telegram.Rendering;
using Volcanic.AgenticCore.Telegram.Tests.Support;
namespace Volcanic.AgenticCore.Telegram.Tests;
public sealed class ChatRendererOrderTests
{
private static (FakeBotClient Bot, TelegramSender Sender, ChatRenderer Renderer) Setup()
{
var bot = new FakeBotClient();
var sender = new TelegramSender(bot, NullLogger<TelegramSender>.Instance);
var options = new TelegramOptions { EditInterval = TimeSpan.FromMilliseconds(20), TypingInterval = TimeSpan.FromSeconds(10) };
return (bot, sender, new ChatRenderer(1001, sender, options, NullLogger.Instance));
}
private static AssistantMessage Answer(string text) =>
new() { Content = [new TextContent(text)], Provider = "faux", Api = "faux", Model = "faux", StopReason = StopReason.Stop };
[Fact]
public async Task RunInOrderAsync_runs_after_the_events_posted_before_it()
{
var (bot, sender, renderer) = Setup();
await using var _ = renderer;
renderer.Post(new AgentSessionEvent.MessageEnd(Answer("first")));
var id = await renderer.RunInOrderAsync(ct => sender.SendTextAsync(1001, "second", cancellationToken: ct)).WaitAsync(TimeSpan.FromSeconds(5));
Assert.Equal(["first", "second"], bot.Sent.Select(s => s.Text));
Assert.Equal("second", bot.CurrentTexts[id!.Value]);
}
[Fact]
public async Task RunInOrderAsync_propagates_the_work_exception()
{
var (_, _, renderer) = Setup();
await using var __ = renderer;
var ex = await Assert.ThrowsAsync<InvalidOperationException>(() => renderer.RunInOrderAsync<int>(_ => throw new InvalidOperationException("boom")));
Assert.Equal("boom", ex.Message);
}
[Fact]
public async Task RunInOrderAsync_after_dispose_is_cancelled()
{
var (_, _, renderer) = Setup();
await renderer.DisposeAsync();
await Assert.ThrowsAnyAsync<OperationCanceledException>(() => renderer.RunInOrderAsync(_ => Task.FromResult(1)));
}
[Fact]
public async Task RunInOrderAsync_cancelled_while_queued_returns_at_once_and_skips_the_work()
{
var (_, _, renderer) = Setup();
await using var _ = renderer;
var gate = new TaskCompletionSource<int>(TaskCreationOptions.RunContinuationsAsynchronously);
var slow = renderer.RunInOrderAsync(ct => gate.Task.WaitAsync(ct)); // e.g. a big upload with retries
var ran = false;
using var cts = new CancellationTokenSource();
var queued = renderer.RunInOrderAsync(_ => { ran = true; return Task.FromResult(2); }, cts.Token);
await cts.CancelAsync();
await Assert.ThrowsAnyAsync<OperationCanceledException>(() => queued.WaitAsync(TimeSpan.FromSeconds(5)));
Assert.False(slow.IsCompleted);
gate.SetResult(1);
Assert.Equal(1, await slow.WaitAsync(TimeSpan.FromSeconds(5)));
await renderer.RunInOrderAsync(_ => Task.FromResult(0)).WaitAsync(TimeSpan.FromSeconds(5)); // the loop moved past it
Assert.False(ran);
}
[Fact]
public async Task RunInOrderAsync_with_a_cancelled_token_never_queues()
{
var (_, _, renderer) = Setup();
await using var _ = renderer;
var ran = false;
await Assert.ThrowsAnyAsync<OperationCanceledException>(() =>
renderer.RunInOrderAsync(_ => { ran = true; return Task.FromResult(1); }, new CancellationToken(canceled: true)));
Assert.False(ran);
}
[Fact]
public async Task A_work_item_still_queued_when_disposal_gives_up_waiting_is_cancelled()
{
var (_, _, renderer) = Setup();
var stuck = renderer.RunInOrderAsync(async ct =>
{
await Task.Delay(Timeout.Infinite, ct);
return 1;
});
var ran = false;
var queued = renderer.RunInOrderAsync(_ => { ran = true; return Task.FromResult(2); });
await renderer.DisposeAsync(); // drains for up to 5 s, then cancels the loop
await Assert.ThrowsAnyAsync<OperationCanceledException>(() => queued.WaitAsync(TimeSpan.FromSeconds(5)));
Assert.False(ran);
await Assert.ThrowsAnyAsync<OperationCanceledException>(() => stuck);
}
[Fact]
public async Task SayAsync_sends_plain_text_cut_to_4096_characters()
{
var (bot, _, renderer) = Setup();
await using var _ = renderer;
await renderer.SayAsync("🎤 " + new string('a', 5000));
var sent = Assert.Single(bot.Sent);
Assert.Equal(4096, sent.Text.Length);
Assert.EndsWith("…", sent.Text, StringComparison.Ordinal);
Assert.Equal(ParseMode.None, sent.ParseMode); // plain text
}
[Fact]
public async Task A_work_item_buries_the_status_so_the_next_tool_update_moves_it_down()
{
var (bot, sender, renderer) = Setup();
await using var _ = renderer;
renderer.Post(new AgentSessionEvent.AgentStart());
await renderer.DrainAsync().WaitAsync(TimeSpan.FromSeconds(5));
var status = Assert.Single(bot.Sent);
Assert.StartsWith("⚙️", status.Text, StringComparison.Ordinal);
var statusId = bot.CurrentTexts.Single().Key;
await renderer.RunInOrderAsync(ct => sender.SendTextAsync(1001, "file", cancellationToken: ct));
renderer.Post(new AgentSessionEvent.ToolExecutionStart("t1", "send", JsonDocument.Parse("{\"path\":\"a.png\"}").RootElement));
await renderer.DrainAsync().WaitAsync(TimeSpan.FromSeconds(5));
Assert.Contains(bot.Requests.OfType<DeleteMessageRequest>(), d => d.MessageId == statusId);
Assert.StartsWith("⚙️", bot.Sent[^1].Text, StringComparison.Ordinal);
}
}

View File

@@ -1,5 +1,6 @@
using Volcanic.AgenticCore.Telegram.Chats;
using Volcanic.AgenticCore.Telegram.Rendering;
using Volcanic.AgenticCore.Telegram.Speech;
namespace Volcanic.AgenticCore.Telegram.Tests;
@@ -98,4 +99,65 @@ public sealed class FormattingAndOptionsTests
Assert.False(options.IsAllowed(4));
Assert.False(options.IsAllowed(null));
}
private static TelegramOptions Valid() => new() { BotToken = "t", AllowedUserIds = [1] };
[Fact]
public void Speech_falls_back_to_OPENAI_API_KEY_and_STT_MODEL()
{
var options = Valid();
options.ApplyEnvironment(name => name switch { "OPENAI_API_KEY" => "sk-env", "STT_MODEL" => "whisper-1", _ => null });
Assert.Equal("sk-env", options.Speech.ApiKey);
Assert.Equal("whisper-1", options.Speech.ResolvedModel);
Assert.True(options.Speech.IsEnabled);
Assert.Equal("Speech to text: whisper-1", options.Speech.Describe());
}
[Fact]
public void Empty_speech_variables_count_as_unset()
{
var options = Valid();
options.ApplyEnvironment(name => name is "OPENAI_API_KEY" or "STT_MODEL" ? "" : null);
Assert.False(options.Speech.IsEnabled);
Assert.Equal(SpeechOptions.DefaultModel, options.Speech.ResolvedModel);
Assert.Equal("Speech to text: off (no OPENAI_API_KEY)", options.Speech.Describe());
}
[Fact]
public void Configured_speech_settings_win_over_the_environment()
{
var options = Valid();
options.Speech.ApiKey = "sk-config";
options.Speech.Model = "gpt-4o-mini-transcribe";
options.ApplyEnvironment(name => name switch { "OPENAI_API_KEY" => "sk-env", "STT_MODEL" => "whisper-1", _ => null });
Assert.Equal(("sk-config", "gpt-4o-mini-transcribe"), (options.Speech.ApiKey, options.Speech.ResolvedModel));
}
[Fact]
public void Speech_defaults_to_gpt_4o_transcribe_on_the_openai_endpoint()
{
var speech = new SpeechOptions();
Assert.Equal(("gpt-4o-transcribe", "https://api.openai.com/v1"), (speech.ResolvedModel, speech.BaseUrl));
Assert.False(speech.IsEnabled);
}
[Fact]
public void Inline_image_limit_defaults_to_3_75_MB_and_must_be_positive()
{
Assert.Equal(3_750_000, Valid().InlineImageMaxBytes);
var options = Valid();
options.InlineImageMaxBytes = 0;
Assert.Contains("Telegram:InlineImageMaxBytes must be positive.", options.Validate());
}
[Theory]
[InlineData("ftp://stt.example/v1")]
[InlineData("/v1")]
[InlineData("")]
public void Speech_base_url_must_be_an_absolute_http_url(string url)
{
var options = Valid();
options.Speech.BaseUrl = url;
Assert.Contains("Telegram:Speech:BaseUrl must be an absolute http(s) URL.", options.Validate());
}
}

View File

@@ -1,15 +1,12 @@
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);
private static InboundComposer Composer(ChatHost host) => host.Composer;
[Fact]
public async Task Text_becomes_the_body_with_reply_and_forward_headers()
@@ -77,10 +74,14 @@ public sealed class InboundComposerTests
}
[Fact]
public void Only_text_captions_and_photos_are_supported_in_this_wave()
public void Text_photos_files_and_voice_are_supported()
{
Assert.True(InboundComposer.IsSupported(Updates.Text("hi").Message!));
Assert.True(InboundComposer.IsSupported(Updates.Photo().Message!));
Assert.True(InboundComposer.IsSupported(Updates.DocumentMessage("a.pdf", "application/pdf", 3).Message!));
Assert.True(InboundComposer.IsSupported(Updates.VideoMessage(null, 3).Message!));
Assert.True(InboundComposer.IsSupported(Updates.AudioMessage("a.mp3", "audio/mpeg", 3).Message!));
Assert.True(InboundComposer.IsSupported(Updates.VoiceMessage().Message!));
Assert.False(InboundComposer.IsSupported(Updates.StickerMessage().Message!));
Assert.True(InboundComposer.HasAttachment(Updates.Photo("caption").Message!));
Assert.False(InboundComposer.HasAttachment(Updates.Text("/status").Message!));

View File

@@ -0,0 +1,51 @@
using Volcanic.AgenticCore.Telegram.Inbound;
using Volcanic.AgenticCore.Telegram.Rendering;
namespace Volcanic.AgenticCore.Telegram.Tests;
public sealed class InboxFilesTests : IDisposable
{
private readonly string _ws = Path.Combine(Path.GetTempPath(), "volcanic-inbox", Guid.NewGuid().ToString("N"));
private static readonly DateTimeOffset Now = new(2026, 9, 26, 10, 0, 0, TimeSpan.Zero);
public void Dispose() { try { Directory.Delete(_ws, true); } catch (IOException) { } }
[Theory]
[InlineData("report.pdf", "report.pdf")]
[InlineData("../../evil.sh", "evil.sh")]
[InlineData("..\\..\\win.bat", "win.bat")]
[InlineData(".bashrc", "bashrc")]
[InlineData("__init__.py", "init__.py")]
[InlineData("my report (final).pdf", "my_report__final_.pdf")]
[InlineData("отчёт.pdf", "отчёт.pdf")]
[InlineData("...", "file")]
[InlineData("", "file")]
[InlineData(null, "file")]
public void Hostile_names_are_sanitised(string? name, string expected) => Assert.Equal(expected, InboxFiles.SafeName(name));
[Fact]
public void Long_stems_and_extensions_are_capped()
{
Assert.Equal(new string('a', 80) + ".txt", InboxFiles.SafeName(new string('a', 300) + ".txt"));
Assert.Equal("x." + new string('b', 15), InboxFiles.SafeName("x." + new string('b', 30)));
}
[Fact]
public void Collisions_get_numbered_suffixes()
{
var paths = new List<string>();
for (var i = 0; i < 3; i++)
{
var (path, stream) = InboxFiles.Create(_ws, Now, "report.pdf");
stream.Dispose();
paths.Add(InboxFiles.Relative(_ws, path));
}
Assert.Equal(["inbox/2026-09-26-report.pdf", "inbox/2026-09-26-report-1.pdf", "inbox/2026-09-26-report-2.pdf"], paths);
}
[Theory]
[InlineData(5, "5 bytes")]
[InlineData(1536, "1.5 KB")]
[InlineData(1_258_291, "1.2 MB")]
public void Sizes_are_formatted(long bytes, string expected) => Assert.Equal(expected, TelegramLimits.FormatSize(bytes));
}

View File

@@ -282,13 +282,13 @@ public sealed class MessageContextChatTests
}
[Fact]
public async Task Unsupported_messages_keep_the_old_reply_and_take_no_lease()
public async Task Unsupported_messages_get_the_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.Equal("I can read text, photos, files and voice messages.", Assert.Single(host.Bot.Sent).Text);
Assert.Null(host.Chats.Find(Updates.AllowedUser));
Assert.Empty(host.Faux.Requests);
}

View File

@@ -0,0 +1,121 @@
using System.Net;
using System.Text;
using Volcanic.AgenticCore.Telegram.Speech;
namespace Volcanic.AgenticCore.Telegram.Tests;
public sealed class OpenAiSpeechToTextTests
{
private const string Key = "sk-test-secret-key-123";
private sealed record Part(string? FileName, string? ContentType, string Text);
private sealed class StubHandler(HttpStatusCode status, string body, string mediaType = "application/json") : HttpMessageHandler
{
public HttpRequestMessage? Request { get; private set; }
public Dictionary<string, Part> Parts { get; } = [];
protected override async Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken cancellationToken)
{
Request = request;
foreach (var part in (MultipartFormDataContent)request.Content!)
{
var disposition = part.Headers.ContentDisposition!;
Parts[disposition.Name!.Trim('"')] = new Part(disposition.FileName?.Trim('"'), part.Headers.ContentType?.MediaType,
await part.ReadAsStringAsync(cancellationToken));
}
return new HttpResponseMessage(status) { Content = new StringContent(body, Encoding.UTF8, mediaType) };
}
}
private static (OpenAiSpeechToText Stt, StubHandler Handler) Create(HttpStatusCode status, string body, string? language = null)
{
var handler = new StubHandler(status, body);
var options = new SpeechOptions { ApiKey = Key, Language = language, BaseUrl = "https://stt.example/v1/" };
return (new OpenAiSpeechToText(new HttpClient(handler), options), handler);
}
[Fact]
public async Task Posts_a_multipart_form_with_file_model_and_response_format()
{
var (stt, handler) = Create(HttpStatusCode.OK, "{\"text\":\"hello\"}");
await stt.TranscribeAsync(new byte[] { 1, 2, 3 }, "voice.ogg", "audio/ogg", CancellationToken.None);
Assert.Equal(HttpMethod.Post, handler.Request!.Method);
Assert.Equal("https://stt.example/v1/audio/transcriptions", handler.Request.RequestUri!.ToString());
Assert.Equal("Bearer", handler.Request.Headers.Authorization!.Scheme);
Assert.Equal(Key, handler.Request.Headers.Authorization.Parameter);
Assert.Equal(("voice.ogg", "audio/ogg"), (handler.Parts["file"].FileName, handler.Parts["file"].ContentType));
Assert.Equal("gpt-4o-transcribe", handler.Parts["model"].Text);
Assert.Equal("json", handler.Parts["response_format"].Text);
Assert.False(handler.Parts.ContainsKey("language"));
}
[Fact]
public async Task Language_is_sent_only_when_configured()
{
var (stt, handler) = Create(HttpStatusCode.OK, "{\"text\":\"hallo\"}", language: "de");
await stt.TranscribeAsync(new byte[] { 1 }, "voice.ogg", "audio/ogg", CancellationToken.None);
Assert.Equal("de", handler.Parts["language"].Text);
}
[Fact]
public async Task The_transcript_is_trimmed()
{
var (stt, _) = Create(HttpStatusCode.OK, "{\"text\":\" hello there \\n\"}");
Assert.Equal("hello there", await stt.TranscribeAsync(new byte[] { 1 }, "voice.ogg", "audio/ogg", CancellationToken.None));
}
[Fact]
public async Task A_non_success_status_names_the_status_and_hides_the_key()
{
var body = "{\"error\":{\"message\":\"Incorrect API key provided: " + Key + "\"}}" + new string('x', 300);
var (stt, _) = Create(HttpStatusCode.Unauthorized, body);
var ex = await Assert.ThrowsAsync<SpeechToTextException>(() => stt.TranscribeAsync(new byte[] { 1 }, "voice.ogg", "audio/ogg", CancellationToken.None));
Assert.StartsWith("OpenAI transcription failed: HTTP 401 ", ex.Message, StringComparison.Ordinal);
Assert.DoesNotContain(Key, ex.Message, StringComparison.Ordinal);
Assert.True(ex.Message.Length <= "OpenAI transcription failed: HTTP 401 ".Length + 200);
}
[Fact]
public async Task A_non_json_body_has_its_own_message()
{
var (stt, _) = Create(HttpStatusCode.OK, "<html>gateway</html>");
var ex = await Assert.ThrowsAsync<SpeechToTextException>(() => stt.TranscribeAsync(new byte[] { 1 }, "voice.ogg", "audio/ogg", CancellationToken.None));
Assert.Equal("OpenAI transcription failed: the response is not JSON.", ex.Message);
}
[Fact]
public async Task A_missing_text_has_its_own_message()
{
var (stt, _) = Create(HttpStatusCode.OK, "{\"segments\":[]}");
var ex = await Assert.ThrowsAsync<SpeechToTextException>(() => stt.TranscribeAsync(new byte[] { 1 }, "voice.ogg", "audio/ogg", CancellationToken.None));
Assert.Equal("OpenAI transcription failed: the response has no text.", ex.Message);
}
private sealed class HangingHandler : HttpMessageHandler
{
protected override async Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken cancellationToken)
{
await Task.Delay(Timeout.Infinite, cancellationToken);
throw new InvalidOperationException("unreachable");
}
}
[Fact]
public async Task A_client_timeout_is_a_speech_failure_not_a_cancellation()
{
var http = new HttpClient(new HangingHandler()) { Timeout = TimeSpan.FromMilliseconds(50) };
var stt = new OpenAiSpeechToText(http, new SpeechOptions { ApiKey = Key });
var ex = await Assert.ThrowsAsync<SpeechToTextException>(() => stt.TranscribeAsync(new byte[] { 1 }, "voice.ogg", "audio/ogg", CancellationToken.None));
Assert.Equal("OpenAI transcription failed: the request timed out.", ex.Message);
}
[Fact]
public async Task The_callers_cancellation_still_cancels()
{
var stt = new OpenAiSpeechToText(new HttpClient(new HangingHandler()), new SpeechOptions { ApiKey = Key });
using var cts = new CancellationTokenSource(TimeSpan.FromMilliseconds(50));
await Assert.ThrowsAnyAsync<OperationCanceledException>(() => stt.TranscribeAsync(new byte[] { 1 }, "voice.ogg", "audio/ogg", cts.Token));
}
}

View File

@@ -32,6 +32,16 @@ public sealed class ProcessEnvironmentTests
Assert.Empty(target.Validate());
}
[Fact]
public void Configure_adds_the_speech_api_key_to_the_secret_values()
{
var target = new ProcessEnvironmentOptions();
var telegram = new TelegramOptions { BotToken = Token };
telegram.Speech.ApiKey = "sk-speech-secret";
TelegramProcessEnvironment.Configure(target, telegram, new ProcessEnvironmentSection(), _ => null);
Assert.Contains("sk-speech-secret", target.SecretValues);
}
[Fact]
public void The_section_binds_from_configuration()
{

View File

@@ -0,0 +1,76 @@
using Telegram.Bot.Exceptions;
using Telegram.Bot.Requests;
using Volcanic.AgenticCore.Abstractions.Messages;
using Volcanic.AgenticCore.Telegram.Tests.Support;
using Volcanic.AgenticCore.Testing;
namespace Volcanic.AgenticCore.Telegram.Tests;
public sealed class SendToolChatTests
{
private static async Task WriteAsync(ChatHost host, string relative)
{
var chat = await host.ChatAsync();
var path = Path.Combine(chat.Session.Cwd, relative);
Directory.CreateDirectory(Path.GetDirectoryName(path)!);
await File.WriteAllBytesAsync(path, [1, 2, 3]);
}
[Fact]
public async Task A_sent_png_arrives_as_a_photo_before_the_answer_and_the_status_moves_below_it()
{
await using var host = new ChatHost();
await WriteAsync(host, "out/chart.png");
host.Faux.Enqueue(
FauxTurn.Tool("send", new { path = "out/chart.png", caption = "Sales by month" }),
FauxTurn.Say("Here is the chart.").WithDelay(TimeSpan.FromMilliseconds(200))); // lets the status update render
await host.SendAsync("chart please");
await host.SettleAsync();
var upload = Assert.Single(host.Bot.Uploads);
Assert.Equal(("sendPhoto", "chart.png", "Sales by month"), (upload.Method, upload.FileName, upload.Caption));
Assert.Equal(new byte[] { 1, 2, 3 }, upload.Bytes);
var requests = host.Bot.Requests.ToList();
var uploadAt = requests.FindIndex(r => r is SendPhotoRequest);
var answerAt = requests.FindIndex(r => r is SendMessageRequest m && m.Text.Contains("Here is the chart.", StringComparison.Ordinal));
Assert.InRange(uploadAt, 0, answerAt - 1);
// the status message was deleted and sent again below the photo, before the answer
Assert.Contains(requests.Skip(uploadAt + 1).Take(answerAt - uploadAt - 1), r => r is DeleteMessageRequest);
Assert.Contains(requests.Skip(uploadAt + 1).Take(answerAt - uploadAt - 1), r => r is SendMessageRequest m && m.Text.StartsWith("⚙️", StringComparison.Ordinal));
}
[Fact]
public async Task A_sent_pdf_arrives_as_a_document()
{
await using var host = new ChatHost();
await WriteAsync(host, "report.pdf");
host.Faux.Enqueue(FauxTurn.Tool("send", new { path = "report.pdf" }), FauxTurn.Say("Done."));
await host.SendAsync("the report");
await host.SettleAsync();
Assert.Equal(("sendDocument", "report.pdf"), (Assert.Single(host.Bot.Uploads).Method, host.Bot.Uploads[0].FileName));
var chat = await host.ChatAsync();
Assert.Equal("Sent report.pdf as document.", chat.Session.Messages.OfType<ToolResultMessage>().Single().Text);
}
[Fact]
public async Task A_rejected_photo_is_resent_as_a_document()
{
await using var host = new ChatHost();
await WriteAsync(host, "out/chart.png");
host.Bot.OnSend = r => { if (r is SendPhotoRequest) throw new ApiRequestException("Bad Request: PHOTO_INVALID_DIMENSIONS", 400); };
host.Faux.Enqueue(FauxTurn.Tool("send", new { path = "out/chart.png" }), FauxTurn.Say("Done."));
await host.SendAsync("chart please");
await host.SettleAsync();
Assert.Single(host.Bot.Requests.OfType<SendPhotoRequest>());
Assert.Equal("sendDocument", Assert.Single(host.Bot.Uploads).Method);
var chat = await host.ChatAsync();
var result = chat.Session.Messages.OfType<ToolResultMessage>().Single();
Assert.False(result.IsError);
Assert.Equal("Sent out/chart.png as document.", result.Text);
}
}

View File

@@ -29,7 +29,8 @@ public sealed class ChatHost : IAsyncDisposable
private readonly string _root;
public ChatHost(Action<ChatHost, IServiceCollection>? configure = null, Action<AgenticCoreOptions>? core = null, TimeProvider? time = null,
Volcanic.AgenticCore.Extensions.GoogleCalendar.GoogleCalendarOptions? calendar = null)
Volcanic.AgenticCore.Extensions.GoogleCalendar.GoogleCalendarOptions? calendar = null,
Volcanic.AgenticCore.Telegram.Speech.ISpeechToText? speech = null)
{
_root = Path.Combine(Path.GetTempPath(), "volcanic-telegram", Guid.NewGuid().ToString("N"));
Options = new TelegramOptions
@@ -59,10 +60,17 @@ public sealed class ChatHost : IAsyncDisposable
ILoggerFactory loggers = NullLoggerFactory.Instance;
var sender = new TelegramSender(Bot, NullLogger<TelegramSender>.Instance);
Chats = new ChatRegistry(_services.GetRequiredService<IAgentSessionFactory>(), sender, Options, loggers, time, calendar);
Router = new UpdateRouter(Options, Chats, new ChatCommands(sender), sender,
new InboundComposer(sender, NullLogger<InboundComposer>.Instance), NullLogger<UpdateRouter>.Instance);
var media = new InboundMedia(sender, Options, time ?? TimeProvider.System, NullLogger<InboundMedia>.Instance, speech);
Composer = new InboundComposer(sender, media, NullLogger<InboundComposer>.Instance);
Router = new UpdateRouter(Options, Chats, new ChatCommands(sender), sender, Composer, NullLogger<UpdateRouter>.Instance);
}
/// <summary>The composer the router uses (content steps 1–3).</summary>
public InboundComposer Composer { get; }
/// <summary>The chat's workspace directory (its session's cwd).</summary>
public string WorkspaceOf(long chatId = Updates.AllowedUser) => Chats.WorkspaceFor(chatId);
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);

View File

@@ -6,6 +6,7 @@ using Telegram.Bot.Exceptions;
using Telegram.Bot.Requests;
using Telegram.Bot.Requests.Abstractions;
using Telegram.Bot.Types;
using Telegram.Bot.Types.Enums;
namespace Volcanic.AgenticCore.Telegram.Tests.Support;
@@ -27,6 +28,12 @@ public sealed class FakeBotClient : ITelegramBotClient
public event AsyncEventHandler<ApiRequestEventArgs>? OnMakingApiRequest;
public event AsyncEventHandler<ApiResponseEventArgs>? OnApiResponseReceived;
/// <summary>Downloadable files by file id: <c>getFile</c> answers with their size and <c>DownloadFile</c> writes their bytes.</summary>
public ConcurrentDictionary<string, byte[]> Files { get; } = new();
/// <summary>When set, a download of a scripted file writes one byte and then fails with a network error.</summary>
public bool FailDownloads { get; set; }
/// <summary>Called for every non-poll request before it is answered; throw to simulate a failure.</summary>
public Action<IRequest>? OnSend { get; set; }
@@ -43,6 +50,21 @@ public sealed class FakeBotClient : ITelegramBotClient
private readonly ConcurrentDictionary<int, string> _texts = new();
public sealed record Upload(string Method, string FileName, byte[] Bytes, string? Caption, ParseMode ParseMode, int MessageId);
private readonly List<Upload> _uploads = [];
/// <summary>Photos and documents sent, with the uploaded bytes (copied inside the request).</summary>
public IReadOnlyList<Upload> Uploads { get { lock (_uploads) return _uploads.ToList(); } }
private Message RecordUpload(string method, ChatId chatId, InputFile file, string? caption, ParseMode parseMode)
{
var input = (InputFileStream)file;
using var copy = new MemoryStream();
input.Content.CopyTo(copy);
var id = Interlocked.Increment(ref _nextMessageId);
lock (_uploads) _uploads.Add(new Upload(method, input.FileName ?? "", copy.ToArray(), caption, parseMode, id));
return new Message { Id = id, Chat = new Chat { Id = chatId.Identifier ?? 0 }, Caption = caption };
}
public FakeBotClient EnqueueUpdates(params Update[] updates)
{
_polls.Enqueue(() => updates);
@@ -99,9 +121,18 @@ public sealed class FakeBotClient : ITelegramBotClient
_texts.TryRemove(delete.MessageId, out _);
result = true;
break;
case GetFileRequest getFile when Files.TryGetValue(getFile.FileId, out var bytes):
result = new TGFile { FileId = getFile.FileId, FileUniqueId = getFile.FileId + "-u", FilePath = $"files/{getFile.FileId}", FileSize = bytes.Length };
break;
case GetFileRequest getFile:
result = new TGFile { FileId = getFile.FileId, FileUniqueId = getFile.FileId + "-u", FilePath = $"photos/{getFile.FileId}.jpg", FileSize = 3 };
break;
case SendPhotoRequest photo:
result = RecordUpload("sendPhoto", photo.ChatId, photo.Photo, photo.Caption, photo.ParseMode);
break;
case SendDocumentRequest document:
result = RecordUpload("sendDocument", document.ChatId, document.Document, document.Caption, document.ParseMode);
break;
default:
result = request is GetMeRequest ? new User { Id = BotId, IsBot = true, FirstName = "Fake", Username = "fake_bot" } : true;
break;
@@ -110,6 +141,17 @@ public sealed class FakeBotClient : ITelegramBotClient
}
public Task<bool> TestApi(CancellationToken cancellationToken = default) => Task.FromResult(true);
public Task DownloadFile(string filePath, Stream destination, CancellationToken cancellationToken = default) => Task.CompletedTask;
public Task DownloadFile(TGFile file, Stream destination, CancellationToken cancellationToken = default) => Task.CompletedTask;
public async Task DownloadFile(string filePath, Stream destination, CancellationToken cancellationToken = default)
{
if (!filePath.StartsWith("files/", StringComparison.Ordinal) || !Files.TryGetValue(filePath["files/".Length..], out var bytes)) return;
if (FailDownloads)
{
await destination.WriteAsync(bytes.AsMemory(0, Math.Min(1, bytes.Length)), cancellationToken); // a partial file
throw NetworkDown();
}
await destination.WriteAsync(bytes, cancellationToken);
}
public Task DownloadFile(TGFile file, Stream destination, CancellationToken cancellationToken = default) =>
DownloadFile(file.FilePath ?? "", destination, cancellationToken);
}

View File

@@ -0,0 +1,22 @@
using Volcanic.AgenticCore.Telegram.Speech;
namespace Volcanic.AgenticCore.Telegram.Tests.Support;
/// <summary>Scripted transcripts. <see cref="Gate"/> holds a call until released; <see cref="Failure"/> makes it throw.</summary>
public sealed class FakeSpeechToText(string transcript = "hello there") : ISpeechToText
{
public string Transcript { get; set; } = transcript;
public Exception? Failure { get; set; }
public TimeSpan Delay { get; set; }
public TaskCompletionSource? Gate { get; set; }
public List<(string FileName, string MimeType, byte[] Audio)> Calls { get; } = [];
public async Task<string> TranscribeAsync(ReadOnlyMemory<byte> audio, string fileName, string mimeType, CancellationToken cancellationToken)
{
lock (Calls) Calls.Add((fileName, mimeType, audio.ToArray()));
if (Gate is not null) await Gate.Task.WaitAsync(cancellationToken);
if (Delay > TimeSpan.Zero) await Task.Delay(Delay, cancellationToken);
if (Failure is not null) throw Failure;
return Transcript;
}
}

View File

@@ -117,6 +117,44 @@ public static class Updates
return Wrap(m);
}
// Media helpers end in "Message": a member named Document/Video/Voice/Audio would hide the Telegram type in this class.
/// <summary>A document (file id <paramref name="fileId"/>, scripted in <see cref="FakeBotClient.Files"/>).</summary>
public static Update DocumentMessage(string? fileName, string? mimeType, long? size, string? caption = null, string fileId = "doc")
{
var m = NewMessage(null);
m.Document = new Document { FileId = fileId, FileUniqueId = fileId + "-u", FileName = fileName, MimeType = mimeType, FileSize = size };
m.Caption = caption;
return Wrap(m);
}
/// <summary>A video (MIME <c>video/mp4</c>).</summary>
public static Update VideoMessage(string? fileName, long? size, string? caption = null, string fileId = "video")
{
var m = NewMessage(null);
m.Video = new Video { FileId = fileId, FileUniqueId = fileId + "-u", Width = 1, Height = 1, Duration = 1, FileName = fileName, MimeType = "video/mp4", FileSize = size };
m.Caption = caption;
return Wrap(m);
}
/// <summary>A voice note (MIME <c>audio/ogg</c>).</summary>
public static Update VoiceMessage(long? size = 3, string? caption = null, string fileId = "voice")
{
var m = NewMessage(null);
m.Voice = new Voice { FileId = fileId, FileUniqueId = fileId + "-u", Duration = 2, MimeType = "audio/ogg", FileSize = size };
m.Caption = caption;
return Wrap(m);
}
/// <summary>An audio file.</summary>
public static Update AudioMessage(string? fileName, string? mimeType, long? size, string? caption = null, string fileId = "audio")
{
var m = NewMessage(null);
m.Audio = new Audio { FileId = fileId, FileUniqueId = fileId + "-u", Duration = 60, FileName = fileName, MimeType = mimeType, FileSize = size };
m.Caption = caption;
return Wrap(m);
}
/// <summary>A channel post origin, optionally signed by its author.</summary>
public static MessageOriginChannel Channel(string title, string? signature = null) => new()
{

View File

@@ -0,0 +1,152 @@
using Microsoft.Extensions.Logging.Abstractions;
using Telegram.Bot.Exceptions;
using Telegram.Bot.Requests;
using Telegram.Bot.Types.Enums;
using Volcanic.AgenticCore.Abstractions.Ui;
using Volcanic.AgenticCore.Telegram.Chats;
using Volcanic.AgenticCore.Telegram.Rendering;
using Volcanic.AgenticCore.Telegram.Tests.Support;
namespace Volcanic.AgenticCore.Telegram.Tests;
public sealed class TelegramChatChannelTests
{
private static async Task<string> WorkspaceFile(ChatHost host, string relative, byte[]? bytes = null)
{
var chat = await host.ChatAsync();
var path = Path.Combine(chat.Session.Cwd, relative);
Directory.CreateDirectory(Path.GetDirectoryName(path)!);
await File.WriteAllBytesAsync(path, bytes ?? [1, 2, 3]);
return path;
}
[Theory]
[InlineData(5L, "5 bytes")]
[InlineData(1536L, "1.5 KB")]
[InlineData(1_258_291L, "1.2 MB")]
public void FormatSize_uses_bytes_kilobytes_or_megabytes(long bytes, string expected) =>
Assert.Equal(expected, TelegramLimits.FormatSize(bytes));
[Fact]
public void Megabytes_has_one_decimal() => Assert.Equal("21.0", TelegramLimits.Megabytes(21L * 1024 * 1024));
[Fact]
public async Task Limits_are_the_bot_api_limits()
{
await using var host = new ChatHost();
var chat = await host.ChatAsync();
Assert.Equal(new ChatChannelLimits(10L * 1024 * 1024, 50L * 1024 * 1024, 1024), chat.Channel.Limits);
Assert.Same(chat.Channel, chat.Session.Channel);
}
[Fact]
public async Task SendTextAsync_while_idle_delivers_html_split_past_4096()
{
await using var host = new ChatHost();
var chat = await host.ChatAsync();
var markdown = "**Reminder**\n\n" + string.Join("\n\n", Enumerable.Repeat(new string('a', 1000), 6));
var delivery = await chat.Channel.SendTextAsync(markdown, CancellationToken.None);
Assert.False(chat.Session.IsStreaming);
Assert.True(host.Bot.Sent.Count >= 2);
Assert.All(host.Bot.Sent, s => Assert.Equal(ParseMode.Html, s.ParseMode));
Assert.Contains("<b>Reminder</b>", host.Bot.Sent[0].Text, StringComparison.Ordinal);
Assert.Null(delivery.SentAs);
Assert.Equal(host.Bot.CurrentTexts.Keys.Min().ToString(System.Globalization.CultureInfo.InvariantCulture), delivery.MessageId);
Assert.Empty(chat.Session.Messages); // never persisted
Assert.Empty(host.Faux.Requests);
}
[Fact]
public async Task Auto_png_goes_as_a_photo()
{
await using var host = new ChatHost();
var chat = await host.ChatAsync();
var path = await WorkspaceFile(host, "chart.png");
var delivery = await chat.Channel.SendFileAsync(new ChatFile(path, ChatFileKind.Auto, "Sales"), CancellationToken.None);
Assert.Equal(ChatFileKind.Photo, delivery.SentAs);
var upload = Assert.Single(host.Bot.Uploads);
Assert.Equal(("sendPhoto", "Sales"), (upload.Method, upload.Caption));
Assert.Equal(upload.MessageId.ToString(System.Globalization.CultureInfo.InvariantCulture), delivery.MessageId);
}
[Fact]
public async Task Auto_non_photo_goes_as_a_document()
{
await using var host = new ChatHost();
var chat = await host.ChatAsync();
var path = await WorkspaceFile(host, "anim.gif");
var delivery = await chat.Channel.SendFileAsync(new ChatFile(path), CancellationToken.None);
Assert.Equal(ChatFileKind.Document, delivery.SentAs);
Assert.Equal("sendDocument", Assert.Single(host.Bot.Uploads).Method);
}
[Fact]
public async Task Auto_photo_rejected_with_a_400_falls_back_to_a_document()
{
await using var host = new ChatHost();
var chat = await host.ChatAsync();
var path = await WorkspaceFile(host, "wide.png");
host.Bot.OnSend = r => { if (r is SendPhotoRequest) throw new ApiRequestException("Bad Request: PHOTO_INVALID_DIMENSIONS", 400); };
var delivery = await chat.Channel.SendFileAsync(new ChatFile(path), CancellationToken.None);
Assert.Equal(ChatFileKind.Document, delivery.SentAs);
Assert.Equal("sendDocument", Assert.Single(host.Bot.Uploads).Method);
}
[Fact]
public async Task Explicit_photo_rejected_is_not_resent()
{
await using var host = new ChatHost();
var chat = await host.ChatAsync();
var path = await WorkspaceFile(host, "wide.png");
host.Bot.OnSend = r => { if (r is SendPhotoRequest) throw new ApiRequestException("Bad Request: PHOTO_INVALID_DIMENSIONS", 400); };
var ex = await Assert.ThrowsAsync<ChatDeliveryException>(() => chat.Channel.SendFileAsync(new ChatFile(path, ChatFileKind.Photo), CancellationToken.None));
Assert.Equal("Telegram refused the file: Bad Request: PHOTO_INVALID_DIMENSIONS", ex.Message);
Assert.Empty(host.Bot.Uploads);
}
private static (FakeBotClient Bot, TelegramChatChannel Channel, ChatRenderer Renderer) Direct()
{
var bot = new FakeBotClient();
var sender = new TelegramSender(bot, NullLogger<TelegramSender>.Instance) { FileRetryDelays = [TimeSpan.Zero, TimeSpan.Zero] };
var options = new TelegramOptions { EditInterval = TimeSpan.FromMilliseconds(20), TypingInterval = TimeSpan.FromSeconds(10) };
var renderer = new ChatRenderer(1001, sender, options, NullLogger.Instance) { ReplyRetryDelays = [TimeSpan.Zero, TimeSpan.Zero] };
return (bot, new TelegramChatChannel(1001, sender, renderer), renderer);
}
[Fact]
public async Task SendTextAsync_throws_when_a_chunk_still_fails_after_its_retries_but_sends_the_others()
{
var (bot, channel, renderer) = Direct();
await using var _ = renderer;
var markdown = new string('a', 3000) + "\n\n" + new string('b', 3000); // two chunks
bot.OnSend = r => { if (r is SendMessageRequest m && m.Text.StartsWith('a')) throw FakeBotClient.NetworkDown(); };
var ex = await Assert.ThrowsAsync<ChatDeliveryException>(() => channel.SendTextAsync(markdown, CancellationToken.None));
Assert.Equal("Telegram did not accept the message.", ex.Message);
Assert.Equal(3, bot.Sent.Count(m => m.Text.StartsWith('a'))); // the first try and two retries
Assert.Single(bot.CurrentTexts.Values, t => t.StartsWith('b'));
}
[Fact]
public async Task SendFileAsync_throws_after_three_attempts_when_the_network_stays_down()
{
var (bot, channel, renderer) = Direct();
await using var _ = renderer;
var path = Path.Combine(Path.GetTempPath(), $"volcanic-channel-{Guid.NewGuid():N}.pdf");
await File.WriteAllBytesAsync(path, [1, 2, 3]);
try
{
bot.OnSend = r => { if (r is SendDocumentRequest) throw FakeBotClient.NetworkDown(); };
var ex = await Assert.ThrowsAsync<ChatDeliveryException>(() => channel.SendFileAsync(new ChatFile(path), CancellationToken.None));
Assert.StartsWith("Telegram refused the file: ", ex.Message, StringComparison.Ordinal);
Assert.Equal(3, bot.Requests.OfType<SendDocumentRequest>().Count());
}
finally
{
File.Delete(path);
}
}
}

View File

@@ -1,6 +1,8 @@
using Microsoft.Extensions.Logging.Abstractions;
using Telegram.Bot.Exceptions;
using Telegram.Bot.Requests;
using Telegram.Bot.Types;
using Telegram.Bot.Types.Enums;
using Volcanic.AgenticCore.Telegram.Rendering;
using Volcanic.AgenticCore.Telegram.Tests.Support;
@@ -32,4 +34,77 @@ public sealed class TelegramSenderTests
Assert.Single(bot.Reactions);
}
private static (FakeBotClient Bot, TelegramSender Sender, string Path) FileSetup(string name = "chart.png")
{
var bot = new FakeBotClient();
var sender = new TelegramSender(bot, NullLogger<TelegramSender>.Instance) { FileRetryDelays = [TimeSpan.Zero, TimeSpan.Zero] };
var path = System.IO.Path.Combine(System.IO.Path.GetTempPath(), "volcanic-upload-" + Guid.NewGuid().ToString("N"), name);
Directory.CreateDirectory(System.IO.Path.GetDirectoryName(path)!);
System.IO.File.WriteAllBytes(path, [1, 2, 3]);
return (bot, sender, path);
}
[Fact]
public async Task SendFileAsync_uploads_a_photo_with_a_plain_caption()
{
var (bot, sender, path) = FileSetup();
var result = await sender.SendFileAsync(1001, path, asPhoto: true, caption: "<b>Sales</b>");
var upload = Assert.Single(bot.Uploads);
Assert.Equal(("sendPhoto", "chart.png", "<b>Sales</b>", ParseMode.None), (upload.Method, upload.FileName, upload.Caption, upload.ParseMode));
Assert.Equal(new byte[] { 1, 2, 3 }, upload.Bytes);
Assert.Equal(new TelegramSender.FileSendResult(upload.MessageId, null, false), result);
}
[Fact]
public async Task SendFileAsync_sends_a_document()
{
var (bot, sender, path) = FileSetup("report.pdf");
var result = await sender.SendFileAsync(1001, path, asPhoto: false, caption: null);
Assert.Equal("sendDocument", Assert.Single(bot.Uploads).Method);
Assert.NotNull(result.MessageId);
}
[Fact]
public async Task SendFileAsync_retries_a_network_failure_twice()
{
var (bot, sender, path) = FileSetup("report.pdf");
var failures = 2;
bot.OnSend = r => { if (r is SendDocumentRequest && failures-- > 0) throw FakeBotClient.NetworkDown(); };
var result = await sender.SendFileAsync(1001, path, asPhoto: false, caption: null);
Assert.NotNull(result.MessageId);
Assert.Equal(3, bot.Requests.OfType<SendDocumentRequest>().Count());
}
[Fact]
public async Task SendFileAsync_gives_up_after_two_retries()
{
var (bot, sender, path) = FileSetup("report.pdf");
bot.OnSend = r => { if (r is SendDocumentRequest) throw FakeBotClient.NetworkDown(); };
var result = await sender.SendFileAsync(1001, path, asPhoto: false, caption: null);
Assert.Null(result.MessageId);
Assert.NotNull(result.Error);
Assert.False(result.PhotoRejected);
Assert.Equal(3, bot.Requests.OfType<SendDocumentRequest>().Count());
}
[Fact]
public async Task A_400_on_sendPhoto_is_reported_as_PhotoRejected()
{
var (bot, sender, path) = FileSetup();
bot.OnSend = r => { if (r is SendPhotoRequest) throw new ApiRequestException("Bad Request: PHOTO_INVALID_DIMENSIONS", 400); };
var result = await sender.SendFileAsync(1001, path, asPhoto: true, caption: null);
Assert.True(result.PhotoRejected);
Assert.Contains("PHOTO_INVALID_DIMENSIONS", result.Error, StringComparison.Ordinal);
Assert.Single(bot.Requests.OfType<SendPhotoRequest>()); // an API error is not retried
}
[Fact]
public async Task A_400_on_sendDocument_is_an_error_not_a_rejected_photo()
{
var (bot, sender, path) = FileSetup("report.pdf");
bot.OnSend = r => { if (r is SendDocumentRequest) throw new ApiRequestException("Bad Request: file is empty", 400); };
var result = await sender.SendFileAsync(1001, path, asPhoto: false, caption: null);
Assert.Equal(new TelegramSender.FileSendResult(null, "Bad Request: file is empty", false), result);
}
}

View File

@@ -0,0 +1,273 @@
using Microsoft.Extensions.Time.Testing;
using Telegram.Bot.Requests;
using Volcanic.AgenticCore.Telegram.Speech;
using Volcanic.AgenticCore.Telegram.Tests.Support;
using Volcanic.AgenticCore.Testing;
namespace Volcanic.AgenticCore.Telegram.Tests;
public sealed class VoiceChatTests
{
private const string NotConfigured = "Voice messages need speech-to-text: set OPENAI_API_KEY (or Telegram:Speech:ApiKey) on the bot host.";
private static string Prompt(ChatHost host) => host.Faux.Requests.Single().Messages.Last().Text;
[Fact]
public async Task A_voice_note_is_echoed_before_the_answer_and_prompted()
{
var stt = new FakeSpeechToText("remind me to call Ada");
await using var host = new ChatHost(speech: stt);
host.Bot.Files["voice"] = [1, 2, 3];
host.Faux.Enqueue(FauxTurn.Say("Will do."));
await host.RouteAsync(Updates.VoiceMessage());
await host.SettleAsync();
var texts = host.Texts.ToList();
Assert.InRange(texts.IndexOf("🎤 remind me to call Ada"), 0, texts.IndexOf("Will do.") - 1);
Assert.EndsWith("\n[Voice message] remind me to call Ada", Prompt(host), StringComparison.Ordinal);
var call = Assert.Single(stt.Calls);
Assert.Equal(("voice.ogg", "audio/ogg"), (call.FileName, call.MimeType));
Assert.Equal(new byte[] { 1, 2, 3 }, call.Audio);
}
[Fact]
public async Task A_long_transcript_echo_is_cut_to_4096_characters()
{
var stt = new FakeSpeechToText(new string('a', 5000));
await using var host = new ChatHost(speech: stt);
host.Bot.Files["voice"] = [1];
host.Faux.Enqueue(FauxTurn.Say("ok"));
await host.RouteAsync(Updates.VoiceMessage());
await host.SettleAsync();
var echo = host.Bot.Sent.Single(s => s.Text.StartsWith("🎤", StringComparison.Ordinal)).Text;
Assert.Equal(4096, echo.Length);
Assert.EndsWith("…", echo, StringComparison.Ordinal);
Assert.Contains(new string('a', 5000), Prompt(host), StringComparison.Ordinal); // the model gets all of it
}
[Fact]
public async Task Without_speech_to_text_a_voice_note_gets_the_setup_notice_and_no_run()
{
await using var host = new ChatHost();
host.Bot.Files["voice"] = [1];
await host.RouteAsync(Updates.VoiceMessage());
await host.SettleAsync();
Assert.Contains(NotConfigured, host.Texts);
Assert.Empty(host.Faux.Requests);
Assert.Empty(host.Bot.Requests.OfType<GetFileRequest>());
}
[Fact]
public async Task Without_speech_to_text_a_captioned_voice_note_still_prompts_the_caption()
{
await using var host = new ChatHost();
host.Faux.Enqueue(FauxTurn.Say("ok"));
await host.RouteAsync(Updates.VoiceMessage(caption: "listen to this"));
await host.SettleAsync();
Assert.Contains(NotConfigured, host.Texts);
Assert.EndsWith("\nlisten to this", Prompt(host), StringComparison.Ordinal);
}
[Fact]
public async Task A_transcription_failure_gets_a_notice_and_no_run()
{
var stt = new FakeSpeechToText { Failure = new SpeechToTextException("OpenAI transcription failed: HTTP 500 oops") };
await using var host = new ChatHost(speech: stt);
host.Bot.Files["voice"] = [1];
await host.RouteAsync(Updates.VoiceMessage());
await host.SettleAsync();
Assert.Contains("Could not transcribe the voice message: OpenAI transcription failed: HTTP 500 oops", host.Texts);
Assert.Empty(host.Faux.Requests);
}
[Fact]
public async Task A_transcription_timeout_gets_a_notice_and_no_run()
{
// HttpClient.Timeout surfaces as a TaskCanceledException although nobody cancelled the message.
var stt = new FakeSpeechToText { Failure = new TaskCanceledException("timeout", new TimeoutException()) };
await using var host = new ChatHost(speech: stt);
host.Bot.Files["voice"] = [1];
await host.RouteAsync(Updates.VoiceMessage());
await host.SettleAsync();
Assert.Contains("Could not transcribe the voice message: timeout", host.Texts);
Assert.Empty(host.Faux.Requests);
}
[Fact]
public async Task An_audio_transcription_timeout_still_prompts_the_saved_file()
{
var time = new FakeTimeProvider(new DateTimeOffset(2026, 9, 26, 10, 0, 0, TimeSpan.Zero));
var stt = new FakeSpeechToText { Failure = new TaskCanceledException("timeout", new TimeoutException()) };
await using var host = new ChatHost(time: time, speech: stt);
host.Bot.Files["audio"] = [4, 5, 6];
host.Faux.Enqueue(FauxTurn.Say("ok"));
await host.RouteAsync(Updates.AudioMessage("talk.mp3", "audio/mpeg", 3));
await MessageContextChatTests.FlushAsync(host, time, 1);
await host.SettleAsync();
Assert.Contains("Could not transcribe the voice message: timeout", host.Texts);
Assert.EndsWith("[File received: inbox/2026-09-26-talk.mp3 (audio/mpeg, 3 bytes)]", Prompt(host), StringComparison.Ordinal);
}
[Fact]
public async Task An_empty_transcript_gets_a_notice_and_no_run()
{
await using var host = new ChatHost(speech: new FakeSpeechToText(" "));
host.Bot.Files["voice"] = [1];
await host.RouteAsync(Updates.VoiceMessage());
await host.SettleAsync();
Assert.Contains("I couldn't make out any words in that voice message.", host.Texts);
Assert.Empty(host.Faux.Requests);
}
[Fact]
public async Task A_voice_note_over_20_MB_gets_the_size_notice_and_no_download()
{
var stt = new FakeSpeechToText();
await using var host = new ChatHost(speech: stt);
await host.RouteAsync(Updates.VoiceMessage(size: 21L * 1024 * 1024, caption: "about this"));
await host.SettleAsync();
Assert.Contains("That file is 21.0 MB; Telegram lets bots download at most 20 MB. Share a link or a smaller file.", host.Texts);
Assert.Empty(host.Faux.Requests);
Assert.Empty(host.Bot.Requests.OfType<GetFileRequest>());
Assert.Empty(stt.Calls);
}
[Fact]
public async Task A_failed_voice_download_gets_a_notice_and_no_run()
{
var stt = new FakeSpeechToText();
await using var host = new ChatHost(speech: stt);
host.Bot.Files["voice"] = [1];
host.Bot.FailDownloads = true;
await host.RouteAsync(Updates.VoiceMessage(caption: "about this"));
await host.SettleAsync();
Assert.Contains("I could not download that file.", host.Texts);
Assert.Empty(host.Faux.Requests);
Assert.Empty(stt.Calls);
}
[Fact]
public async Task An_audio_file_is_saved_and_transcribed()
{
var time = new FakeTimeProvider(new DateTimeOffset(2026, 9, 26, 10, 0, 0, TimeSpan.Zero));
var stt = new FakeSpeechToText("welcome to the show");
await using var host = new ChatHost(time: time, speech: stt);
host.Bot.Files["audio"] = [4, 5, 6];
host.Faux.Enqueue(FauxTurn.Say("ok"));
await host.RouteAsync(Updates.AudioMessage("talk.mp3", "audio/mpeg", 3));
await MessageContextChatTests.FlushAsync(host, time, 1);
await host.SettleAsync();
Assert.EndsWith("\n[File received: inbox/2026-09-26-talk.mp3 (audio/mpeg, 3 bytes)]\n\n[Voice message] welcome to the show", Prompt(host), StringComparison.Ordinal);
Assert.Equal(("audio.mp3", "audio/mpeg"), (stt.Calls.Single().FileName, stt.Calls.Single().MimeType));
Assert.Equal(new byte[] { 4, 5, 6 }, stt.Calls.Single().Audio);
Assert.True(File.Exists(Path.Combine(host.WorkspaceOf(), "inbox", "2026-09-26-talk.mp3")));
Assert.Single(host.Bot.Requests.OfType<GetFileRequest>()); // downloaded once, transcribed from the saved file
}
[Theory]
[InlineData("Песня.mp3", "audio/mpeg", "audio.mp3")]
[InlineData("talk.M4A", "audio/mp4", "audio.m4a")]
[InlineData(null, "audio/mp4", "audio.m4a")]
[InlineData(null, "audio/ogg", "audio.ogg")]
[InlineData("memo.oga", "audio/ogg", "audio.ogg")]
[InlineData("noext", null, "audio.mp3")]
public void Audio_goes_to_speech_to_text_under_an_ascii_name_with_a_known_extension(string? fileName, string? mime, string expected) =>
Assert.Equal(expected, Volcanic.AgenticCore.Telegram.Inbound.InboundMedia.SpeechFileName(fileName, mime));
[Fact]
public async Task Without_speech_to_text_an_audio_file_is_still_saved_and_prompted()
{
var time = new FakeTimeProvider(new DateTimeOffset(2026, 9, 26, 10, 0, 0, TimeSpan.Zero));
await using var host = new ChatHost(time: time);
host.Bot.Files["audio"] = [4, 5, 6];
host.Faux.Enqueue(FauxTurn.Say("ok"));
await host.RouteAsync(Updates.AudioMessage("talk.mp3", "audio/mpeg", 3));
await MessageContextChatTests.FlushAsync(host, time, 1);
await host.SettleAsync();
Assert.Contains(NotConfigured, host.Texts);
Assert.EndsWith("[File received: inbox/2026-09-26-talk.mp3 (audio/mpeg, 3 bytes)]", Prompt(host), StringComparison.Ordinal);
}
[Fact]
public async Task A_voice_note_and_a_text_right_after_it_are_one_prompt_in_order()
{
var time = new FakeTimeProvider(new DateTimeOffset(2026, 9, 26, 10, 0, 0, TimeSpan.Zero));
var stt = new FakeSpeechToText("first the voice") { Gate = new TaskCompletionSource() };
await using var host = new ChatHost(time: time, speech: stt);
host.Bot.Files["voice"] = [1];
host.Faux.Enqueue(FauxTurn.Say("ok"));
await host.RouteAsync(Updates.VoiceMessage());
await host.SendAsync("then the text");
var chat = await host.ChatAsync();
await Wait.UntilAsync(() => stt.Calls.Count == 1 && chat.Inbox.Buffered == 1 && chat.Inbox.OpenLeases == 1, "the text composed, the voice held");
time.Advance(host.Options.InputDebounce * 10); // the debounce window elapses while the voice lease is still open
await Task.Delay(50);
Assert.Empty(host.Faux.Requests);
stt.Gate.SetResult();
await MessageContextChatTests.FlushAsync(host, time, 2);
await host.SettleAsync();
var prompt = Prompt(host);
Assert.InRange(prompt.IndexOf("[Voice message] first the voice", StringComparison.Ordinal), 0, prompt.IndexOf("then the text", StringComparison.Ordinal) - 1);
}
[Fact]
public async Task Stop_is_handled_during_a_slow_transcription()
{
var stt = new FakeSpeechToText("late") { Gate = new TaskCompletionSource() };
await using var host = new ChatHost(speech: stt);
host.Bot.Files["voice"] = [1];
host.Faux.Enqueue(FauxTurn.Say("too late").WithDelay(TimeSpan.FromSeconds(30)), FauxTurn.Say("Heard you."));
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.VoiceMessage()).WaitAsync(TimeSpan.FromSeconds(2)); // returns while transcription hangs
await Wait.UntilAsync(() => stt.Calls.Count == 1, "the transcription to start");
await host.SendAsync("/stop").WaitAsync(TimeSpan.FromSeconds(2));
await Wait.UntilAsync(() => !chat.Session.IsStreaming, "the run to stop");
Assert.False(stt.Gate.Task.IsCompleted);
stt.Gate.SetResult();
await host.SettleAsync();
Assert.Contains("⏹ Stopped.", host.Texts);
}
[Fact]
public async Task A_voice_note_does_not_answer_a_pending_question()
{
await using var host = new ChatHost(speech: new FakeSpeechToText("Ada Lovelace"));
host.Bot.Files["voice"] = [1];
host.Faux.Enqueue(FauxTurn.Say("ok"));
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.VoiceMessage());
await host.SettleAsync();
Assert.True(chat.Ui.IsWaitingForInput);
Assert.False(answer.IsCompleted);
Assert.EndsWith("[Voice message] Ada Lovelace", Prompt(host), StringComparison.Ordinal);
await host.SendAsync("Ada");
Assert.Equal("Ada", await answer.WaitAsync(TimeSpan.FromSeconds(5)));
}
}

View File

@@ -0,0 +1,114 @@
using Volcanic.AgenticCore.Abstractions.Messages;
using Volcanic.AgenticCore.Abstractions.Tools;
using Volcanic.AgenticCore.Abstractions.Ui;
using Volcanic.AgenticCore.Features.Extensions;
using Volcanic.AgenticCore.Hosting;
using Volcanic.AgenticCore.Testing;
using Volcanic.AgenticCore.Tests.Support;
namespace Volcanic.AgenticCore.Tests.Features;
public class AgentSessionFactoryTests
{
public sealed record ProbeArgs(string Value);
private static (LambdaExtension Extension, Func<IChatChannel?> Seen) Probe()
{
IChatChannel? seen = null;
var probe = new DelegateTool<ProbeArgs>("probe", "p", (_, inv, _) => { seen = inv.Context.Channel; return Task.FromResult(ToolResult.Text("ok")); });
return (new LambdaExtension("probe", api => { api.RegisterTool(probe); return ValueTask.CompletedTask; }), () => seen);
}
[Fact]
public async Task Session_and_extension_context_expose_the_channel()
{
var channel = new FakeChatChannel();
var (extension, seen) = Probe();
await using var host = new TestHost(null, extension);
host.Faux.Enqueue(FauxTurn.Tool("probe", new { value = "x" }), FauxTurn.Say("done"));
await using var session = await host.CreateSessionAsync(b => b.Channel = channel);
await session.PromptAsync("go");
Assert.Same(channel, session.Channel);
Assert.Same(channel, seen());
}
[Fact]
public async Task Without_a_channel_both_are_null()
{
var (extension, seen) = Probe();
await using var host = new TestHost(null, extension);
host.Faux.Enqueue(FauxTurn.Tool("probe", new { value = "x" }), FauxTurn.Say("done"));
await using var session = await host.CreateSessionAsync();
await session.PromptAsync("go");
Assert.Null(session.Channel);
Assert.Null(seen());
}
[Fact]
public async Task Send_is_registered_and_active_with_a_channel()
{
await using var host = new TestHost();
await using var session = await host.CreateSessionAsync(b => b.Channel = new FakeChatChannel());
Assert.Contains("send", session.ActiveTools);
Assert.Equal("built-in", session.Tools.Single(t => t.Name == "send").Source);
Assert.Contains("send: send a workspace file to the user", session.SystemPrompt, StringComparison.Ordinal);
}
[Fact]
public async Task Send_is_absent_without_a_channel()
{
await using var host = new TestHost();
await using var session = await host.CreateSessionAsync();
Assert.DoesNotContain(session.Tools, t => t.Name == "send");
Assert.DoesNotContain("send", session.ActiveTools);
}
[Fact]
public async Task Explicit_active_tools_win_over_the_send_default()
{
await using var host = new TestHost();
await using var session = await host.CreateSessionAsync(b => { b.Channel = new FakeChatChannel(); b.ActiveTools = ["read"]; });
Assert.Contains(session.Tools, t => t.Name == "send");
Assert.Equal(["read"], session.ActiveTools);
}
[Fact]
public async Task A_scripted_send_reaches_the_channel_and_the_run_continues()
{
var channel = new FakeChatChannel();
await using var host = new TestHost();
await File.WriteAllBytesAsync(Path.Combine(host.Cwd, "chart.png"), [1, 2, 3]);
host.Faux.Enqueue(FauxTurn.Tool("send", new { path = "chart.png", caption = "Sales" }), FauxTurn.Say("Here it is."));
await using var session = await host.CreateSessionAsync(b => b.Channel = channel);
await session.PromptAsync("chart please");
var file = Assert.Single(channel.Files);
Assert.Equal(Path.Combine(host.Cwd, "chart.png"), file.Path);
Assert.Equal("Sales", file.Caption);
var result = session.Messages.OfType<ToolResultMessage>().Single();
Assert.False(result.IsError);
Assert.Equal("Sent chart.png as photo.", result.Text);
Assert.Equal("Here it is.", session.Messages.OfType<AssistantMessage>().Last().Text);
}
[Fact]
public async Task A_failed_delivery_becomes_an_error_tool_result()
{
var channel = new FakeChatChannel { Failure = new ChatDeliveryException("Telegram refused the file: Bad Request") };
await using var host = new TestHost();
await File.WriteAllBytesAsync(Path.Combine(host.Cwd, "a.pdf"), [1]);
host.Faux.Enqueue(FauxTurn.Tool("send", new { path = "a.pdf" }), FauxTurn.Say("Sorry."));
await using var session = await host.CreateSessionAsync(b => b.Channel = channel);
await session.PromptAsync("send it");
var result = session.Messages.OfType<ToolResultMessage>().Single();
Assert.True(result.IsError);
Assert.Contains("Telegram refused the file: Bad Request", result.Text, StringComparison.Ordinal);
}
}

View File

@@ -0,0 +1,272 @@
using Volcanic.AgenticCore.Abstractions.Json;
using Volcanic.AgenticCore.Abstractions.Messages;
using Volcanic.AgenticCore.Abstractions.Tools;
using Volcanic.AgenticCore.Abstractions.Ui;
using Volcanic.AgenticCore.Features.Tools.BuiltIn;
using Volcanic.AgenticCore.Tests.Support;
namespace Volcanic.AgenticCore.Tests.Features;
public sealed class SendToolTests : IDisposable
{
private const long MB = FakeChatChannel.MB;
private readonly string _root = Path.Combine(Path.GetTempPath(), "volcanic-send", Guid.NewGuid().ToString("N"));
private readonly string _ws;
private readonly FakeChatChannel _channel = new();
public SendToolTests()
{
_ws = Path.Combine(_root, "ws");
Directory.CreateDirectory(_ws);
}
public void Dispose() { try { Directory.Delete(_root, true); } catch (IOException) { } }
private string File(string relative, long size = 3)
{
var path = Path.GetFullPath(Path.Combine(_ws, relative));
Directory.CreateDirectory(Path.GetDirectoryName(path)!);
using var stream = System.IO.File.Create(path);
stream.SetLength(size); // sparse stand-in for big files
return path;
}
private Task<ToolResult> Send(SendArgs args, IChatChannel? channel = null, bool noChannel = false)
{
var ctx = new StubExtensionContext(_ws) { Channel = noChannel ? null : channel ?? _channel };
ITool tool = new SendTool();
return tool.ExecuteAsync(new ToolCallContent("c1", "send", VolcanicJson.ToElement(args)), new ToolInvocation("c1", ctx, null), CancellationToken.None);
}
private async Task<string> Fails(SendArgs args)
{
var ex = await Assert.ThrowsAnyAsync<Exception>(() => Send(args));
Assert.Empty(_channel.Files);
return ex.Message;
}
[Fact]
public async Task Auto_png_passes_Auto()
{
File("out/chart.png");
await Send(new SendArgs("out/chart.png"));
Assert.Equal(ChatFileKind.Auto, Assert.Single(_channel.Files).Kind);
}
[Theory]
[InlineData("report.pdf")]
[InlineData("anim.gif")]
public async Task Auto_non_photo_files_pass_Document(string name)
{
File(name);
await Send(new SendArgs(name, "auto"));
Assert.Equal(ChatFileKind.Document, Assert.Single(_channel.Files).Kind);
}
[Theory]
[InlineData("photo", ChatFileKind.Photo)]
[InlineData("document", ChatFileKind.Document)]
[InlineData("DOCUMENT", ChatFileKind.Document)]
public async Task Explicit_kinds_are_honoured(string kind, ChatFileKind expected)
{
File("a.png");
await Send(new SendArgs("a.png", kind));
Assert.Equal(expected, Assert.Single(_channel.Files).Kind);
}
[Fact]
public async Task Unknown_kind_fails()
{
File("a.png");
Assert.Equal("kind must be \"auto\", \"photo\" or \"document\", not \"video\".", await Fails(new SendArgs("a.png", "video")));
}
[Fact]
public async Task Auto_image_over_the_photo_limit_goes_as_Document()
{
File("big.jpg", 11 * MB);
await Send(new SendArgs("big.jpg"));
Assert.Equal(ChatFileKind.Document, Assert.Single(_channel.Files).Kind);
}
[Fact]
public async Task Explicit_photo_over_the_limit_fails()
{
File("big.jpg", 11 * MB);
Assert.Equal("photo too large: 11.0 MB > 10.0 MB. Use kind \"document\" or downscale it.", await Fails(new SendArgs("big.jpg", "photo")));
}
[Fact]
public async Task Document_over_the_limit_fails()
{
File("huge.zip", 51 * MB);
Assert.Equal("file too large: 51.0 MB > 50.0 MB (the chat's upload limit).", await Fails(new SendArgs("huge.zip")));
}
[Fact]
public async Task Caption_over_the_limit_fails_before_anything_is_sent()
{
File("a.png");
Assert.Equal("caption is 1025 characters; the limit is 1024. Shorten it or put the text in your reply.",
await Fails(new SendArgs("a.png", Caption: new string('x', 1025))));
}
[Theory]
[InlineData("../outside.txt")]
[InlineData("sub/../../outside.txt")]
[InlineData("ABSOLUTE")]
public async Task Paths_outside_the_workspace_fail(string path)
{
var outside = Path.Combine(_root, "outside.txt");
System.IO.File.WriteAllText(outside, "secret");
if (path == "ABSOLUTE") path = outside;
Assert.Equal($"path escapes the workspace: {path}", await Fails(new SendArgs(path)));
}
[Fact]
public async Task A_sibling_directory_sharing_the_workspace_prefix_is_outside()
{
var sibling = Path.Combine(_root, "ws-other", "a.txt");
Directory.CreateDirectory(Path.GetDirectoryName(sibling)!);
System.IO.File.WriteAllText(sibling, "x");
Assert.Equal($"path escapes the workspace: {sibling}", await Fails(new SendArgs(sibling)));
}
[Fact]
public async Task A_symlink_inside_the_workspace_pointing_outside_fails()
{
if (OperatingSystem.IsWindows()) return;
var outside = Path.Combine(_root, "id_rsa");
System.IO.File.WriteAllText(outside, "key");
System.IO.File.CreateSymbolicLink(Path.Combine(_ws, "key.txt"), outside);
Assert.Equal("path escapes the workspace: key.txt", await Fails(new SendArgs("key.txt")));
}
[Fact]
public async Task A_symlinked_directory_pointing_outside_fails()
{
if (OperatingSystem.IsWindows()) return;
var outsideDir = Path.Combine(_root, "ssh");
Directory.CreateDirectory(outsideDir);
System.IO.File.WriteAllText(Path.Combine(outsideDir, "id_rsa"), "key");
Directory.CreateSymbolicLink(Path.Combine(_ws, "linked"), outsideDir);
Assert.Equal("path escapes the workspace: linked/id_rsa", await Fails(new SendArgs("linked/id_rsa")));
}
[Fact]
public async Task A_symlink_pointing_inside_the_workspace_is_accepted()
{
if (OperatingSystem.IsWindows()) return;
var target = File("out/report.pdf");
System.IO.File.CreateSymbolicLink(Path.Combine(_ws, "latest.pdf"), target);
await Send(new SendArgs("latest.pdf"));
Assert.Single(_channel.Files);
}
[Fact]
public async Task A_link_whose_target_runs_through_a_directory_link_pointing_outside_fails()
{
if (OperatingSystem.IsWindows()) return;
var outsideDir = Path.Combine(_root, "etc");
Directory.CreateDirectory(outsideDir);
System.IO.File.WriteAllText(Path.Combine(outsideDir, "secret"), "key");
Directory.CreateSymbolicLink(Path.Combine(_ws, "d"), outsideDir);
Directory.CreateDirectory(Path.Combine(_ws, "a"));
System.IO.File.CreateSymbolicLink(Path.Combine(_ws, "a", "l"), "../d/secret"); // textually ws/d/secret
Assert.Equal("path escapes the workspace: a/l", await Fails(new SendArgs("a/l")));
}
[Fact]
public async Task A_dotdot_inside_a_link_target_applies_to_the_real_parent()
{
if (OperatingSystem.IsWindows()) return;
var deep = Path.Combine(_root, "outside", "deep");
Directory.CreateDirectory(deep);
System.IO.File.WriteAllText(Path.Combine(_root, "outside", "secret"), "key");
Directory.CreateSymbolicLink(Path.Combine(_ws, "x"), deep);
File("secret"); // the decoy the textual reading would pick
System.IO.File.CreateSymbolicLink(Path.Combine(_ws, "l"), "x/../secret"); // really <root>/outside/secret
Assert.Equal("path escapes the workspace: l", await Fails(new SendArgs("l")));
}
[Fact]
public async Task A_link_loop_fails()
{
if (OperatingSystem.IsWindows()) return;
System.IO.File.CreateSymbolicLink(Path.Combine(_ws, "a"), "b");
System.IO.File.CreateSymbolicLink(Path.Combine(_ws, "b"), "a");
Assert.Equal("path escapes the workspace: a", await Fails(new SendArgs("a")));
}
[Theory]
[InlineData(".")]
[InlineData("ROOT")]
public async Task The_workspace_itself_is_a_directory(string path)
{
if (path == "ROOT") path = _ws;
Assert.Equal($"path is a directory, not a file: {path}", await Fails(new SendArgs(path)));
}
[Fact]
public async Task Missing_file_fails() =>
Assert.Equal("file not found: nope.pdf", await Fails(new SendArgs("nope.pdf")));
[Fact]
public async Task Directory_fails()
{
Directory.CreateDirectory(Path.Combine(_ws, "out"));
Assert.Equal("path is a directory, not a file: out", await Fails(new SendArgs("out")));
}
[Fact]
public async Task Absolute_path_inside_the_workspace_is_accepted()
{
var path = File("out/report.pdf");
await Send(new SendArgs(path));
Assert.Equal(path, Assert.Single(_channel.Files).Path);
}
[Fact]
public async Task Delivery_failure_is_rethrown_with_its_message()
{
File("a.pdf");
_channel.Failure = new ChatDeliveryException("Telegram refused the file: Bad Request");
var ex = await Assert.ThrowsAsync<ChatDeliveryException>(() => Send(new SendArgs("a.pdf")));
Assert.Equal("Telegram refused the file: Bad Request", ex.Message);
}
[Fact]
public async Task Without_a_channel_send_fails()
{
File("a.pdf");
var ex = await Assert.ThrowsAnyAsync<Exception>(() => Send(new SendArgs("a.pdf"), noChannel: true));
Assert.Equal("send is not available: this session has no chat", ex.Message);
}
[Fact]
public async Task Result_names_the_path_and_how_it_was_sent()
{
var full = File("out/chart.png", 1234);
var result = await Send(new SendArgs("out/chart.png", Caption: "Sales"));
Assert.Equal("Sent out/chart.png as photo.", result.TextContent);
var details = result.Details!.Value;
Assert.Equal("out/chart.png", details.GetProperty("path").GetString());
Assert.Equal("photo", details.GetProperty("kind").GetString());
Assert.Equal(1234, details.GetProperty("bytes").GetInt64());
Assert.Equal("m1", details.GetProperty("messageId").GetString());
var sent = Assert.Single(_channel.Files);
Assert.Equal(full, sent.Path);
Assert.Equal("Sales", sent.Caption);
}
[Fact]
public void Send_is_sequential_and_describes_itself_for_the_prompt()
{
ITool tool = new SendTool();
Assert.Equal("send", tool.Name);
Assert.Equal(ToolExecutionMode.Sequential, tool.ExecutionMode);
Assert.Equal("send: send a workspace file to the user (images inline, other files as documents)", tool.PromptSnippet);
Assert.Contains("Files the user sends you are saved under inbox/.", tool.PromptGuidelines);
}
}

View File

@@ -0,0 +1,29 @@
using Volcanic.AgenticCore.Abstractions.Ui;
namespace Volcanic.AgenticCore.Tests.Support;
/// <summary>Records deliveries. <see cref="Failure"/> makes every file delivery fail.</summary>
public sealed class FakeChatChannel : IChatChannel
{
public const long MB = 1024 * 1024;
public ChatChannelLimits Limits { get; init; } = new(10 * MB, 50 * MB, 1024);
public List<ChatFile> Files { get; } = [];
public List<string> Texts { get; } = [];
public Exception? Failure { get; set; }
/// <summary>What an <see cref="ChatFileKind.Auto"/> file is reported as.</summary>
public ChatFileKind AutoSendsAs { get; set; } = ChatFileKind.Photo;
public Task<ChatDelivery> SendTextAsync(string markdown, CancellationToken cancellationToken)
{
lock (Texts) Texts.Add(markdown);
return Task.FromResult(new ChatDelivery(null, "t1"));
}
public Task<ChatDelivery> SendFileAsync(ChatFile file, CancellationToken cancellationToken)
{
if (Failure is not null) return Task.FromException<ChatDelivery>(Failure);
int count;
lock (Files) { Files.Add(file); count = Files.Count; }
return Task.FromResult(new ChatDelivery(file.Kind == ChatFileKind.Auto ? AutoSendsAs : file.Kind, "m" + count));
}
}

View File

@@ -22,6 +22,7 @@ public sealed class StubExtensionContext(string cwd) : IExtensionContext
public Model? Model => null;
public ThinkingLevel ThinkingLevel => ThinkingLevel.Off;
public TimeZoneInfo TimeZone { get; init; } = TimeZoneInfo.Utc;
public IChatChannel? Channel { get; init; }
public CancellationToken Signal => CancellationToken.None;
public ILogger Logger => NullLogger.Instance;
public bool IsIdle() => true;

View File

@@ -16,6 +16,7 @@ public sealed class StubExtensionHost : IExtensionHost
public string SessionId => "session-1";
public string Scope { get; init; } = "test-scope";
public IUserInterface Ui { get; init; } = NullUserInterface.Instance;
public IChatChannel? Channel { get; init; }
public IReadOnlySessionManager SessionManager => throw new NotSupportedException();
public IModelRegistry ModelRegistry { get; init; } = new ModelRegistry(new ApiKeyResolver(null, _ => null));
public Model? Model { get; set; }

View File

@@ -70,12 +70,13 @@ public sealed class TestHost : IAsyncDisposable
public List<ExtensionRegistration> Extra { get; } = [];
public QueueMode SteeringMode { get; set; } = QueueMode.OneAtATime;
public TimeZoneInfo? TimeZone { get; set; }
public Volcanic.AgenticCore.Abstractions.Ui.IChatChannel? Channel { get; set; }
public AgentSessionOptions Build() => new()
{
Cwd = cwd, SessionId = SessionId, Model = Model, ActiveTools = ActiveTools, ToolExecution = ToolExecution,
Ui = Ui, AutoCompaction = AutoCompaction, ExtraExtensions = Extra, SteeringMode = SteeringMode,
TimeZone = TimeZone,
TimeZone = TimeZone, Channel = Channel,
};
}
}