forked from admin/VolcanicAgenticCore
Merge branch 'feat/telegram-attachments'
This commit is contained in:
@@ -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`.
|
||||
|
||||
|
||||
@@ -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 |
|
||||
|
||||
@@ -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`.
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
2654
docs/superpowers/plans/2026-09-26-telegram-attachments.md
Normal file
2654
docs/superpowers/plans/2026-09-26-telegram-attachments.md
Normal file
File diff suppressed because it is too large
Load Diff
@@ -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."
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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.");
|
||||
|
||||
215
hosts/Volcanic.AgenticCore.Telegram/Inbound/InboundMedia.cs
Normal file
215
hosts/Volcanic.AgenticCore.Telegram/Inbound/InboundMedia.cs
Normal 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.<ext></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);
|
||||
}
|
||||
}
|
||||
}
|
||||
65
hosts/Volcanic.AgenticCore.Telegram/Inbound/InboxFiles.cs
Normal file
65
hosts/Volcanic.AgenticCore.Telegram/Inbound/InboxFiles.cs
Normal file
@@ -0,0 +1,65 @@
|
||||
using System.Globalization;
|
||||
using System.Text;
|
||||
|
||||
namespace Volcanic.AgenticCore.Telegram.Inbound;
|
||||
|
||||
/// <summary>Where received files go: <c><workspace>/inbox/<yyyy-MM-dd>-<safe name></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/<date>-<stem>[-N]<ext></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)];
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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",
|
||||
};
|
||||
}
|
||||
@@ -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
|
||||
|
||||
11
hosts/Volcanic.AgenticCore.Telegram/Speech/ISpeechToText.cs
Normal file
11
hosts/Volcanic.AgenticCore.Telegram/Speech/ISpeechToText.cs
Normal 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);
|
||||
@@ -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);
|
||||
}
|
||||
27
hosts/Volcanic.AgenticCore.Telegram/Speech/SpeechOptions.cs
Normal file
27
hosts/Volcanic.AgenticCore.Telegram/Speech/SpeechOptions.cs
Normal 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)";
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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; }
|
||||
|
||||
38
src/Volcanic.AgenticCore.Abstractions/Ui/IChatChannel.cs
Normal file
38
src/Volcanic.AgenticCore.Abstractions/Ui/IChatChannel.cs
Normal 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);
|
||||
@@ -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;
|
||||
|
||||
@@ -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 }; } }
|
||||
|
||||
@@ -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));
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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; }
|
||||
|
||||
68
src/Volcanic.AgenticCore/Features/Tools/BuiltIn/SendTool.cs
Normal file
68
src/Volcanic.AgenticCore/Features/Tools/BuiltIn/SendTool.cs
Normal 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);
|
||||
}
|
||||
@@ -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}");
|
||||
}
|
||||
@@ -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()];
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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()
|
||||
{
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
200
tests/Volcanic.AgenticCore.Telegram.Tests/AttachmentChatTests.cs
Normal file
200
tests/Volcanic.AgenticCore.Telegram.Tests/AttachmentChatTests.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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!));
|
||||
|
||||
51
tests/Volcanic.AgenticCore.Telegram.Tests/InboxFilesTests.cs
Normal file
51
tests/Volcanic.AgenticCore.Telegram.Tests/InboxFilesTests.cs
Normal 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));
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
{
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
{
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
273
tests/Volcanic.AgenticCore.Telegram.Tests/VoiceChatTests.cs
Normal file
273
tests/Volcanic.AgenticCore.Telegram.Tests/VoiceChatTests.cs
Normal 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)));
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
272
tests/Volcanic.AgenticCore.Tests/Features/SendToolTests.cs
Normal file
272
tests/Volcanic.AgenticCore.Tests/Features/SendToolTests.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
29
tests/Volcanic.AgenticCore.Tests/Support/FakeChatChannel.cs
Normal file
29
tests/Volcanic.AgenticCore.Tests/Support/FakeChatChannel.cs
Normal 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));
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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; }
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user