From a21f3be7334906012fe2bb91ea61707833cfe476 Mon Sep 17 00:00:00 2001 From: EugeneTes Date: Sat, 26 Sep 2026 21:43:04 +0200 Subject: [PATCH 01/11] docs(plan): telegram-attachments implementation plan --- .../plans/2026-09-26-telegram-attachments.md | 2654 +++++++++++++++++ 1 file changed, 2654 insertions(+) create mode 100644 docs/superpowers/plans/2026-09-26-telegram-attachments.md diff --git a/docs/superpowers/plans/2026-09-26-telegram-attachments.md b/docs/superpowers/plans/2026-09-26-telegram-attachments.md new file mode 100644 index 0000000..bc2b48d --- /dev/null +++ b/docs/superpowers/plans/2026-09-26-telegram-attachments.md @@ -0,0 +1,2654 @@ +# Telegram Attachments Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** The Telegram bot accepts documents, videos, audio and voice notes (saved to `inbox/`, voice transcribed through OpenAI), and the agent can send workspace files back to the chat through a core `send` tool built on a new host-agnostic `IChatChannel` contract. + +**Architecture:** `IChatChannel` (Abstractions `Ui/`) is supplied per session through `AgentSessionOptions.Channel` and reached as `AgentSession.Channel` / `IExtensionContext.Channel`. The core registers a built-in `send` tool only when a session has a channel. The Telegram host implements the channel as `TelegramChatChannel`, which runs every delivery through `ChatRenderer.RunInOrderAsync` so files, notices and scheduler texts stay in order with rendered output. Inbound media is `InboundMedia`, content steps 2 (files) and 3 (speech) of `InboundComposer`, running under the message's `ChatInbox` lease; speech goes through a host service `ISpeechToText` / `OpenAiSpeechToText`. + +**Tech Stack:** .NET 10, C#, xunit 2, Telegram.Bot 22.10.3.1, `Microsoft.Extensions.Http` 10.0.0 (new, Telegram host only), `Microsoft.Extensions.TimeProvider.Testing` (tests, already referenced). Spec: `docs/superpowers/specs/2026-09-26-telegram-attachments-design.md`. + +## Global Constraints + +- Work only in the worktree `D:\Projects\Names\wt-telegram-attachments` (branch `feat/telegram-attachments`). Every Bash command starts with `cd /d/Projects/Names/wt-telegram-attachments && ...`; Read/Edit/Write use absolute paths under `D:\Projects\Names\wt-telegram-attachments`. Never switch branches, never `git worktree`/`checkout`/`reset --hard`/`push`. Never touch `D:\Projects\Names\VolcanicAgenticCore` or another `wt-*` directory. +- `Directory.Build.props` sets `TreatWarningsAsErrors=true` for every non-test project, so a new warning fails the build (watch CS9113 "parameter is unread" on primary constructors: add a constructor parameter only in the task that reads it). Test projects turn it off. +- Tests: xunit 2, no network, no paid API, no real Telegram or OpenAI. Use `FauxProvider`, `FakeBotClient`, a stub `HttpMessageHandler`, `FakeSpeechToText`, `FakeChatChannel`, `FakeTimeProvider`. +- Known baseline on the Windows dev machine: 3 tests in `Volcanic.AgenticCore.Tests` fail on `main` before any change (`SystemPromptBuilderTests.Loads_AGENTS_md_in_preference_to_CLAUDE_md_then_persona_and_memory`, `ExtensionLoaderTests.Discover_loads_a_real_assembly_from_the_user_directory`, `BuiltInToolTests.Ls_find_and_grep`). "Green" means no failures other than these three. Don't touch them. +- Tests that need symlinks or a Unix shell start with `if (OperatingSystem.IsWindows()) return;`. +- Exact values from the spec (copy verbatim): + - unsupported reply: `I can read text, photos, files and voice messages.` (replaces `I can read text and photos.`); + - limits: download **20 MB** (`TelegramLimits.MaxDownloadBytes = 20 * 1024 * 1024`), photo upload **10 MB**, document upload **50 MB**, caption **1024**, message **4096**; `Telegram:InlineImageMaxBytes` default **3 750 000**; + - inline image MIME types: `image/jpeg`, `image/png`, `image/webp`, `image/gif`; `send` photo extensions for `auto`: `.jpg`, `.jpeg`, `.png`, `.webp` (not `.gif`); + - inbox path: `/inbox/-<-N>`, stem ≀ 80 chars, extension ≀ 16 chars, fallback `file`; body line `[File received: inbox/2026-09-26-report.pdf (application/pdf, 1.2 MB)]`; too-large image suffix `(too large to show inline; downscale it with bash if you need to see it)`; + - notices: `That file is MB; Telegram lets bots download at most 20 MB. Share a link or a smaller file.`, `I could not download that file.`, `Voice messages need speech-to-text: set OPENAI_API_KEY (or Telegram:Speech:ApiKey) on the bot host.`, `I couldn't make out any words in that voice message.`, `Could not transcribe the voice message: `; + - echo: `🎀 `, plain text, cut to 4096 characters with `…`; voice body line `[Voice message] `; voice file name `voice.ogg`, MIME `audio/ogg`; + - `send` errors: `send is not available: this session has no chat`, `path escapes the workspace: `, `file not found: `, `path is a directory, not a file: `, `caption is characters; the limit is . Shorten it or put the text in your reply.`, `photo too large: MB > MB. Use kind "document" or downscale it.`, `file too large: MB > MB (the chat's upload limit).`; result `Sent as .` with details `{ path, kind, bytes, messageId }`; channel error `Telegram refused the file: `; + - `send` snippet `send: send a workspace file to the user (images inline, other files as documents)`; guidelines exactly as in the spec Β§2; `ExecutionMode = Sequential`; + - STT: `POST {BaseUrl}/audio/transcriptions`, bearer auth, multipart `file`/`model`/`response_format=json`/`language`; default model `gpt-4o-transcribe`, default base URL `https://api.openai.com/v1`; error prefix `OpenAI transcription failed: HTTP `; env fallbacks `OPENAI_API_KEY`, `STT_MODEL` (empty counts as unset); log lines `Speech to text: ` / `Speech to text: off (no OPENAI_API_KEY)`; HttpClient timeout 2 minutes; + - file upload retries after **1 s** and **3 s** on a network failure (`RequestException` that is not an `ApiRequestException`). +- **Wave C context (from the orchestrator):** no other worktree is active. Waves A and B are on `main`. The MCP extension's Browser prompt section already checks for a tool named `send` (`McpExtension.AddPromptSections`, tested with a stub `DelegateTool` named `send` in `McpExtensionTests`); T3 adds a test that the real built-in tool triggers the same line and that nothing collides. The scheduler (wave D) calls `IChatChannel.SendTextAsync` while no run is active: T5 tests that path. +- Open questions in the spec keep their current choices and are marked resolved in T9: Audio is transcribed; 3.75 MB inline threshold; no downscaling. +- The spec is the source of truth. When the implementation has to differ, edit the spec in the same commit and say so in the commit body. Each task lists the spec edits it needs. +- Match the surrounding code: file-scoped namespaces, `ConfigureAwait(false)` in library/host code, `` doc comments on public types, sealed classes and records, `CultureInfo.InvariantCulture` for every number in user- or model-facing text. +- Commits: Conventional Commits scoped by area (`feat(core): …`, `feat(telegram): …`, `test(telegram): …`, `docs(telegram): …`). Stage only files you meant to change (never `bin/`, `obj/`, `launchSettings.json`, `hosts/Volcanic.AgenticCore.Telegram/Properties/`, secrets). +- Test commands (from the worktree root): + - one class: `dotnet test tests/ --filter "FullyQualifiedName~"` + - end of every task: `dotnet build && dotnet test` (green = only the three baseline failures) + +--- + +## File map + +| File | Change | Task | +|---|---|---| +| `src/Volcanic.AgenticCore.Abstractions/Ui/IChatChannel.cs` | **new**: `ChatFileKind`, `ChatFile`, `ChatDelivery`, `ChatChannelLimits`, `IChatChannel`, `ChatDeliveryException` | T1 | +| `src/Volcanic.AgenticCore.Abstractions/Extensions/IExtensionContext.cs` | `IChatChannel? Channel => null;` (default member) | T1 | +| `src/Volcanic.AgenticCore/Features/Extensions/IExtensionHost.cs` | `IChatChannel? Channel => null;` (default member) | T1 | +| `src/Volcanic.AgenticCore/Features/Extensions/ExtensionContext.cs` | `Channel => Host.Channel` | T1 | +| `src/Volcanic.AgenticCore/Hosting/AgenticCoreOptions.cs` | `AgentSessionOptions.Channel` | T1 | +| `src/Volcanic.AgenticCore/Features/Agent/AgentSession.cs`, `AgentSession.Host.cs` | `AgentSession.Channel`, `IExtensionHost.Channel` | T1 | +| `src/Volcanic.AgenticCore/Features/Tools/BuiltIn/SendTool.cs` | **new**: `SendArgs`, `SendTool`, `WorkspacePaths` | T2 | +| `src/Volcanic.AgenticCore/Features/Agent/AgentSessionFactory.cs` | register `send` when the session has a channel | T3 | +| `tests/Volcanic.AgenticCore.Tests/Support/{FakeChatChannel,TestHost,StubExtensionContext}.cs` | fake channel, builder `Channel`, stub `Channel` | T1, T2 | +| `tests/Volcanic.AgenticCore.Tests/Features/{AgentSessionFactoryTests,SendToolTests}.cs` | **new** tests | T1–T3 | +| `tests/Volcanic.AgenticCore.Extensions.Tests/Mcp/{McpExtensionTests.cs,Support/McpTestHost.cs}` | real-`send` Browser section test | T3 | +| `hosts/Volcanic.AgenticCore.Telegram/Rendering/ChatRenderer.cs` | render items, `RunInOrderAsync`, `SayAsync`, `SendReplyAsync` result | T4 | +| `hosts/Volcanic.AgenticCore.Telegram/Rendering/TelegramSender.cs` | `SendFileAsync`, `FileSendResult`, `FileRetryDelays` | T4 | +| `hosts/Volcanic.AgenticCore.Telegram/Rendering/TelegramLimits.cs` | **new**: limits and size text | T5 | +| `hosts/Volcanic.AgenticCore.Telegram/Chats/TelegramChatChannel.cs` | **new** | T5 | +| `hosts/Volcanic.AgenticCore.Telegram/Chats/ChatRegistry.cs` | create the channel, `ChatContext.Channel`, `AgentSessionOptions.Channel` | T5 | +| `hosts/Volcanic.AgenticCore.Telegram/Speech/{ISpeechToText,OpenAiSpeechToText,SpeechOptions}.cs` | **new** | T6 | +| `hosts/Volcanic.AgenticCore.Telegram/TelegramOptions.cs` | `InlineImageMaxBytes`, `Speech`, env fallbacks, validation | T6 | +| `hosts/Volcanic.AgenticCore.Telegram/TelegramProcessEnvironment.cs` | speech key in `SecretValues` | T6 | +| `hosts/Volcanic.AgenticCore.Telegram/Volcanic.AgenticCore.Telegram.csproj` | `Microsoft.Extensions.Http` | T6 | +| `hosts/Volcanic.AgenticCore.Telegram/Program.cs` | `ISpeechToText` (T6), `InboundMedia` (T7) | T6, T7 | +| `hosts/Volcanic.AgenticCore.Telegram/Inbound/InboxFiles.cs` | **new**: safe names, `CreateNew` with suffixes | T7 | +| `hosts/Volcanic.AgenticCore.Telegram/Inbound/InboundMedia.cs` | **new**: step 2 (T7), step 3 (T8) | T7, T8 | +| `hosts/Volcanic.AgenticCore.Telegram/Inbound/InboundComposer.cs` | accepted kinds, new reply, steps 2–3, shared download | T7, T8 | +| `tests/Volcanic.AgenticCore.Telegram.Tests/Support/{FakeBotClient,Updates,ChatHost}.cs` | uploads (T4), files/downloads + helpers (T7), speech (T8) | T4, T7, T8 | +| `tests/Volcanic.AgenticCore.Telegram.Tests/Support/FakeSpeechToText.cs` | **new** | T8 | +| `tests/Volcanic.AgenticCore.Telegram.Tests/{ChatRendererOrderTests,TelegramChatChannelTests,SendToolChatTests,OpenAiSpeechToTextTests,InboxFilesTests,AttachmentChatTests,VoiceChatTests}.cs` | **new** tests | T4–T8 | +| `tests/Volcanic.AgenticCore.Telegram.Tests/{TelegramSenderTests,FormattingAndOptionsTests,ProcessEnvironmentTests,InboundComposerTests,MessageContextChatTests}.cs` | added/updated tests | T4, T6, T7 | +| `docs/telegram-bot.md`, `docs/ui-integration.md`, `docs/architecture.md`, `docs/extensions.md`, `CLAUDE.md` | docs | T9 | +| `docs/superpowers/specs/2026-09-26-telegram-attachments-design.md` | spec edits (per task), status, resolved questions | T4, T7, T8, T9 | +| `docs/superpowers/specs/2026-09-26-telegram-message-context-design.md` | drop the interim "until the attachments spec lands" paragraph | T9 | + +## Spec Testing β†’ test map + +| Spec bullet | Test (class.method) | Task | +|---|---|---| +| **Core support**: recording `FakeChatChannel`, temp workspace | `tests/Volcanic.AgenticCore.Tests/Support/FakeChatChannel.cs` | T1 | +| `auto` `.png` passes `Auto` | `SendToolTests.Auto_png_passes_Auto` | T2 | +| `auto` `.pdf` and `.gif` pass `Document` | `SendToolTests.Auto_non_photo_files_pass_Document` (theory: `.pdf`, `.gif`) | T2 | +| explicit `photo`/`document` honoured | `SendToolTests.Explicit_kinds_are_honoured` (theory) | T2 | +| `auto` image over the photo limit goes as `Document` | `SendToolTests.Auto_image_over_the_photo_limit_goes_as_Document` | T2 | +| explicit `photo` over the limit fails | `SendToolTests.Explicit_photo_over_the_limit_fails` | T2 | +| document over the limit fails, channel not called | `SendToolTests.Document_over_the_limit_fails` | T2 | +| caption over the limit fails, channel not called | `SendToolTests.Caption_over_the_limit_fails_before_anything_is_sent` | T2 | +| `../x` and an absolute path outside fail | `SendToolTests.Paths_outside_the_workspace_fail` (theory) | T2 | +| symlink inside pointing outside fails | `SendToolTests.A_symlink_inside_the_workspace_pointing_outside_fails`, `.A_symlinked_directory_pointing_outside_fails` (Unix only) | T2 | +| missing file and directory errors | `SendToolTests.Missing_file_fails`, `.Directory_fails` | T2 | +| absolute paths inside the workspace accepted | `SendToolTests.Absolute_path_inside_the_workspace_is_accepted` | T2 | +| `ChatDeliveryException` β†’ error result with its message | `SendToolTests.Delivery_failure_is_rethrown_with_its_message`, `AgentSessionFactoryTests.A_failed_delivery_becomes_an_error_tool_result` | T2, T3 | +| result text and details; sparse files | `SendToolTests.Result_names_the_path_and_how_it_was_sent` (+ sparse files in the limit tests) | T2 | +| (defensive) no channel | `SendToolTests.Without_a_channel_send_fails` | T2 | +| `send` registered and active with a channel | `AgentSessionFactoryTests.Send_is_registered_and_active_with_a_channel` | T3 | +| absent without one | `AgentSessionFactoryTests.Send_is_absent_without_a_channel` | T3 | +| explicit `ActiveTools` wins | `AgentSessionFactoryTests.Explicit_active_tools_win_over_the_send_default` | T3 | +| `ExtensionContext.Channel` is the session's channel | `AgentSessionFactoryTests.Session_and_extension_context_expose_the_channel`, `.Without_a_channel_both_are_null` | T1 | +| end-to-end with `FauxProvider` | `AgentSessionFactoryTests.A_scripted_send_reaches_the_channel_and_the_run_continues` | T3 | +| (wave context) real `send` drives the MCP Browser line | `McpExtensionTests.Browser_section_mentions_send_when_the_session_has_a_channel` | T3 | +| **Telegram support**: `FakeBotClient.Files`, `GetFileRequest` β†’ `TGFile`, `DownloadFile` writes bytes | `Support/FakeBotClient.cs` | T7 | +| **Telegram support**: `SendPhotoRequest`/`SendDocumentRequest` answered, stream copied (`Uploads`) | `Support/FakeBotClient.cs` | T4 | +| **Telegram support**: `OnSend` throws `ApiRequestException(400, "PHOTO_INVALID_DIMENSIONS")` | used in `TelegramSenderTests`, `SendToolChatTests` | T4, T5 | +| **Telegram support**: `Updates.Document/Voice/Audio/Sticker` | `Updates.DocumentMessage/VoiceMessage/AudioMessage/StickerMessage` (spec edit: `…Message` names, see T7) | T7 | +| **Telegram support**: `ChatHost` optional `ISpeechToText` + `FakeSpeechToText`, `TimeProvider` | `Support/ChatHost.cs`, `Support/FakeSpeechToText.cs` | T7, T8 | +| document with caption lands in `inbox/2026-09-26-report.pdf`, prompt has caption and `[File received: …]` | `AttachmentChatTests.A_document_with_a_caption_is_saved_to_the_inbox_and_prompted` | T7 | +| second upload of the same name gets `-1` | `AttachmentChatTests.A_second_upload_of_the_same_name_gets_a_suffix`, `InboxFilesTests.Collisions_get_numbered_suffixes` | T7 | +| hostile names sanitised, stay in `inbox/` | `InboxFilesTests.Hostile_names_are_sanitised` (theory), `AttachmentChatTests.A_hostile_file_name_stays_in_the_inbox` | T7 | +| over 20 MB: notice, no file, no provider call | `AttachmentChatTests.A_file_over_20_MB_gets_the_size_notice_and_no_run` | T7 | +| (error table) `getFile` "file is too big" | `AttachmentChatTests.A_getFile_too_big_error_gets_the_size_notice` | T7 | +| (error table) download fails β†’ notice, partial file deleted | `AttachmentChatTests.A_failed_download_deletes_the_partial_file_and_gets_a_notice` | T7 | +| image document ≀ limit saved and passed as `ImageContent` | `AttachmentChatTests.An_image_document_within_the_limit_is_attached_inline` | T7 | +| image document above the limit: path only with the note | `AttachmentChatTests.An_image_document_over_the_limit_is_path_only` | T7 | +| document with a `/new` caption doesn't run `/new` | `AttachmentChatTests.A_document_captioned_new_is_not_a_command` | T7 | +| a sticker gets the "I can read…" reply | `MessageContextChatTests.Unsupported_messages_get_the_reply_and_take_no_lease` (renamed, new text) | T7 | +| voice with STT: echo before the answer, `[Voice message] …`, `.ogg` name | `VoiceChatTests.A_voice_note_is_echoed_before_the_answer_and_prompted` | T8 | +| 5000-character transcript echo cut to 4096 | `VoiceChatTests.A_long_transcript_echo_is_cut_to_4096_characters` | T8 | +| voice without STT: notice, no run | `VoiceChatTests.Without_speech_to_text_a_voice_note_gets_the_setup_notice_and_no_run` (+ `.Without_speech_to_text_a_captioned_voice_note_still_prompts_the_caption`) | T8 | +| STT throwing: failure notice, no run | `VoiceChatTests.A_transcription_failure_gets_a_notice_and_no_run` (+ `.An_empty_transcript_gets_a_notice_and_no_run`) | T8 | +| (spec Β§4) audio saved and transcribed | `VoiceChatTests.An_audio_file_is_saved_and_transcribed` | T8 | +| voice then text β†’ one prompt, transcript first (STT delayed past the window) | `VoiceChatTests.A_voice_note_and_a_text_right_after_it_are_one_prompt_in_order` | T8 | +| `/stop` during a slow transcription handled at once | `VoiceChatTests.Stop_is_handled_during_a_slow_transcription` | T8 | +| voice note doesn't answer a pending `InputAsync` | `VoiceChatTests.A_voice_note_does_not_answer_a_pending_question` | T8 | +| `send` e2e: `sendPhoto` with caption before the final answer; status moved back to the bottom | `SendToolChatTests.A_sent_png_arrives_as_a_photo_before_the_answer_and_the_status_moves_below_it` | T5 | +| `.pdf` β†’ `sendDocument` | `SendToolChatTests.A_sent_pdf_arrives_as_a_document` | T5 | +| photo rejected with a 400 β†’ resent as a document, result says `as document` | `SendToolChatTests.A_rejected_photo_is_resent_as_a_document` | T5 | +| `SendTextAsync` while idle (scheduler case): HTML, split past 4096 | `TelegramChatChannelTests.SendTextAsync_while_idle_delivers_html_split_past_4096` | T5 | +| (error table) Telegram unreachable during `send`: 2 retries | `TelegramSenderTests.SendFileAsync_retries_a_network_failure_twice`, `.SendFileAsync_gives_up_after_two_retries` | T4 | +| **Speech**: multipart `file` (name, content type), `model`, `response_format=json` | `OpenAiSpeechToTextTests.Posts_a_multipart_form_with_file_model_and_response_format` | T6 | +| `language` when set | `OpenAiSpeechToTextTests.Language_is_sent_only_when_configured` | T6 | +| bearer header | `OpenAiSpeechToTextTests.Posts_a_multipart_form_with_file_model_and_response_format` (asserts the header) | T6 | +| transcript trimmed | `OpenAiSpeechToTextTests.The_transcript_is_trimmed` | T6 | +| non-2xx, non-JSON, missing `text`: own messages, never the key | `OpenAiSpeechToTextTests.A_non_success_status_names_the_status_and_hides_the_key`, `.A_non_json_body_has_its_own_message`, `.A_missing_text_has_its_own_message` | T6 | + +Nothing is deferred. Docs section β†’ T9 (plus the `/help`-free host: no command text changes). + +--- + +### Task 1: `IChatChannel` contract and session plumbing + +**Files:** +- Create: `src/Volcanic.AgenticCore.Abstractions/Ui/IChatChannel.cs` +- Modify: `src/Volcanic.AgenticCore.Abstractions/Extensions/IExtensionContext.cs` +- Modify: `src/Volcanic.AgenticCore/Features/Extensions/IExtensionHost.cs` +- Modify: `src/Volcanic.AgenticCore/Features/Extensions/ExtensionContext.cs` +- Modify: `src/Volcanic.AgenticCore/Hosting/AgenticCoreOptions.cs` (class `AgentSessionOptions`) +- Modify: `src/Volcanic.AgenticCore/Features/Agent/AgentSession.cs`, `src/Volcanic.AgenticCore/Features/Agent/AgentSession.Host.cs` +- Create: `tests/Volcanic.AgenticCore.Tests/Support/FakeChatChannel.cs` +- Modify: `tests/Volcanic.AgenticCore.Tests/Support/TestHost.cs` (builder `Channel`) +- Test: `tests/Volcanic.AgenticCore.Tests/Features/AgentSessionFactoryTests.cs` (new) + +**Interfaces:** +- Produces (namespace `Volcanic.AgenticCore.Abstractions.Ui`): `enum ChatFileKind { Auto, Photo, Document }`; `sealed record ChatFile(string Path, ChatFileKind Kind = ChatFileKind.Auto, string? Caption = null)`; `sealed record ChatDelivery(ChatFileKind? SentAs, string? MessageId)`; `sealed record ChatChannelLimits(long MaxPhotoBytes, long MaxDocumentBytes, int MaxCaptionLength)`; `interface IChatChannel { ChatChannelLimits Limits { get; } Task SendTextAsync(string markdown, CancellationToken cancellationToken); Task SendFileAsync(ChatFile file, CancellationToken cancellationToken); }`; `sealed class ChatDeliveryException(string message, Exception? inner = null) : Exception`. +- Produces: `IExtensionContext.Channel` (`IChatChannel? Channel => null;`), `IExtensionHost.Channel` (same default), `AgentSessionOptions.Channel { get; init; }`, `AgentSession.Channel` (read-only). +- Produces (tests): `FakeChatChannel` with `Limits` (init), `Files`, `Texts`, `Failure`, `AutoSendsAs`; `TestHost.AgentSessionOptionsBuilder.Channel`. + +- [ ] **Step 1: Write the contract** β€” `src/Volcanic.AgenticCore.Abstractions/Ui/IChatChannel.cs`, exactly the spec Β§1 code block: + +```csharp +namespace Volcanic.AgenticCore.Abstractions.Ui; + +/// How a file should be delivered. lets the channel choose. +public enum ChatFileKind { Auto, Photo, Document } + +/// Absolute path of an existing file; the caller has already confined and validated it. +/// Photo, document, or let the channel choose. +/// Plain text shown with the file, at most . +public sealed record ChatFile(string Path, ChatFileKind Kind = ChatFileKind.Auto, string? Caption = null); + +/// How a file was actually delivered (Photo or Document); null for text. +/// Host message id(s), for logs and tool details. +public sealed record ChatDelivery(ChatFileKind? SentAs, string? MessageId); + +/// Upload limits of a channel, in bytes and characters. +public sealed record ChatChannelLimits(long MaxPhotoBytes, long MaxDocumentBytes, int MaxCaptionLength); + +/// +/// Delivers content into the conversation's chat, outside the normal assistant-message rendering. Implementations throw +/// only and , are safe to call from any thread, +/// and work while no run is active. Nothing delivered is persisted by the core. +/// +public interface IChatChannel +{ + ChatChannelLimits Limits { get; } + + /// Markdown, rendered like assistant text (split when long). Not added to the session transcript. + Task SendTextAsync(string markdown, CancellationToken cancellationToken); + + /// + /// Deliver a file. lets the channel choose, and it may fall back from photo to + /// document. Throws with a message fit for the model or the user. + /// + Task SendFileAsync(ChatFile file, CancellationToken cancellationToken); +} + +/// A delivery failed; the message is fit for the model or the user. +public sealed class ChatDeliveryException(string message, Exception? inner = null) : Exception(message, inner); +``` + +- [ ] **Step 2: Add the members.** + - `IExtensionContext` (after `Ui`): `/// The session's chat channel, or null when the host has none (then send isn't registered). IChatChannel? Channel => null;` (`using Volcanic.AgenticCore.Abstractions.Ui;` is already there). + - `IExtensionHost` (after `Ui`): `IChatChannel? Channel => null;` with the same summary. A default member keeps `StubExtensionHost` compiling. + - `ExtensionContext`: `public IChatChannel? Channel => Host.Channel;`. + - `AgentSessionOptions` (after `Ui`): `/// Delivers files and texts into the conversation's chat; enables the send tool. public IChatChannel? Channel { get; init; }`. + - `AgentSession.cs` (state section, after `TimeZone`): `/// The host's chat channel for this session (e.g. for scheduled deliveries), or null. public IChatChannel? Channel => _options.Channel;`. + - `AgentSession.Host.cs` (after `IUserInterface IExtensionHost.Ui => _ui;`): `IChatChannel? IExtensionHost.Channel => _options.Channel;`. + +- [ ] **Step 3: Test support.** Create `tests/Volcanic.AgenticCore.Tests/Support/FakeChatChannel.cs`: + +```csharp +using Volcanic.AgenticCore.Abstractions.Ui; + +namespace Volcanic.AgenticCore.Tests.Support; + +/// Records deliveries. makes every file delivery fail. +public sealed class FakeChatChannel : IChatChannel +{ + public const long MB = 1024 * 1024; + public ChatChannelLimits Limits { get; init; } = new(10 * MB, 50 * MB, 1024); + public List Files { get; } = []; + public List Texts { get; } = []; + public Exception? Failure { get; set; } + /// What an file is reported as. + public ChatFileKind AutoSendsAs { get; set; } = ChatFileKind.Photo; + + public Task SendTextAsync(string markdown, CancellationToken cancellationToken) + { + lock (Texts) Texts.Add(markdown); + return Task.FromResult(new ChatDelivery(null, "t1")); + } + + public Task SendFileAsync(ChatFile file, CancellationToken cancellationToken) + { + if (Failure is not null) return Task.FromException(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)); + } +} +``` + + In `TestHost.AgentSessionOptionsBuilder` add `public Volcanic.AgenticCore.Abstractions.Ui.IChatChannel? Channel { get; set; }` and `Channel = Channel,` in `Build()`. + +- [ ] **Step 4: Write the failing tests** β€” `tests/Volcanic.AgenticCore.Tests/Features/AgentSessionFactoryTests.cs`: + +```csharp +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 Seen) Probe() + { + IChatChannel? seen = null; + var probe = new DelegateTool("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()); + } +} +``` + +- [ ] **Step 5: Run** `dotnet test tests/Volcanic.AgenticCore.Tests --filter "FullyQualifiedName~AgentSessionFactoryTests"` β€” fails before Steps 1–3 compile, passes after. +- [ ] **Step 6: Full check** `dotnet build && dotnet test` (baseline only). +- [ ] **Step 7: Commit** + +```bash +git add src/Volcanic.AgenticCore.Abstractions/Ui/IChatChannel.cs src/Volcanic.AgenticCore.Abstractions/Extensions/IExtensionContext.cs \ + src/Volcanic.AgenticCore/Features/Extensions/IExtensionHost.cs src/Volcanic.AgenticCore/Features/Extensions/ExtensionContext.cs \ + src/Volcanic.AgenticCore/Hosting/AgenticCoreOptions.cs src/Volcanic.AgenticCore/Features/Agent/AgentSession.cs \ + src/Volcanic.AgenticCore/Features/Agent/AgentSession.Host.cs tests/Volcanic.AgenticCore.Tests/Support/FakeChatChannel.cs \ + tests/Volcanic.AgenticCore.Tests/Support/TestHost.cs tests/Volcanic.AgenticCore.Tests/Features/AgentSessionFactoryTests.cs +git commit -m "feat(core): IChatChannel contract and AgentSessionOptions.Channel" +``` + +Spec edits: none (`IExtensionHost.Channel` is an internal plumbing detail of the core; mention it in the commit body). + +--- + +### Task 2: the `send` tool + +**Files:** +- Create: `src/Volcanic.AgenticCore/Features/Tools/BuiltIn/SendTool.cs` +- Modify: `tests/Volcanic.AgenticCore.Tests/Support/StubExtensionContext.cs` (add `Channel`) +- Test: `tests/Volcanic.AgenticCore.Tests/Features/SendToolTests.cs` (new) + +**Interfaces:** +- Consumes: T1 contract; `Paths.Resolve(cwd, path)` (internal, `FileTools.cs`). +- Produces (namespace `Volcanic.AgenticCore.Features.Tools.BuiltIn`): `public sealed record SendArgs(string Path, string? Kind = null, string? Caption = null)`; `public sealed class SendTool : Tool` (`Name == "send"`); `internal static class WorkspacePaths { public static string Confine(string cwd, string path); public static bool IsInside(string root, string fullPath); public static string Relative(string root, string fullPath); }`. + +- [ ] **Step 1: Stub context.** In `StubExtensionContext` add `public IChatChannel? Channel { get; init; }` (implicitly implements the default member). + +- [ ] **Step 2: Write the failing tests** β€” `tests/Volcanic.AgenticCore.Tests/Features/SendToolTests.cs`: + +```csharp +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.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 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 Fails(SendArgs args) + { + var ex = await Assert.ThrowsAnyAsync(() => 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 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_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 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(() => 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(() => 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); + } +} +``` + + If `VolcanicJson` serialises details with a different property casing, adjust the `GetProperty` names to match `ToolResult.Text` (it uses `VolcanicJson.ToElement`, camelCase). + +- [ ] **Step 3: Run** `dotnet test tests/Volcanic.AgenticCore.Tests --filter "FullyQualifiedName~SendToolTests"` β€” compile failure (no `SendTool`). + +- [ ] **Step 4: Implement** `src/Volcanic.AgenticCore/Features/Tools/BuiltIn/SendTool.cs`: + +```csharp +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); + +/// +/// Sends a workspace file into the session's chat through . Registered only for sessions that +/// have a channel. Confines the path to the workspace (symlinks included) and checks the channel's limits before sending. +/// +public sealed class SendTool : Tool +{ + private static readonly HashSet 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 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 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($"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); +} + +/// Workspace confinement for tools that hand files to the outside world. +internal static class WorkspacePaths +{ + private static StringComparison Comparison => OperatingSystem.IsWindows() ? StringComparison.OrdinalIgnoreCase : StringComparison.Ordinal; + + /// True when is strictly below (trailing-separator compare). + public static bool IsInside(string root, string fullPath) => + fullPath.StartsWith(Path.TrimEndingDirectorySeparator(root) + Path.DirectorySeparatorChar, Comparison); + + /// Workspace-relative path with / separators. + public static string Relative(string root, string fullPath) => Path.GetRelativePath(root, fullPath).Replace('\\', '/'); + + /// + /// The full path of when it, and the final target of every link between it and the + /// workspace, stays inside . Throws path escapes the workspace: … otherwise. + /// + public static string Confine(string root, string path) + { + var full = Paths.Resolve(root, path); + if (!IsInside(root, full)) throw Escapes(path); + for (var current = full; IsInside(root, current); current = Path.GetDirectoryName(current)!) + { + FileSystemInfo info = Directory.Exists(current) ? new DirectoryInfo(current) : new FileInfo(current); + if (info.LinkTarget is null) continue; + var target = info.ResolveLinkTarget(returnFinalTarget: true); + if (target is null || !IsInside(root, Path.GetFullPath(target.FullName))) throw Escapes(path); + } + return full; + } + + private static InvalidOperationException Escapes(string path) => new($"path escapes the workspace: {path}"); +} +``` + + Note: `Paths.Resolve` on an absolute `path` ignores `root`. The workspace root itself (`send .`) is not "inside" and fails as escaping: acceptable. + +- [ ] **Step 5: Run** the filter again β€” PASS (symlink tests are no-ops on Windows). +- [ ] **Step 6: Full check** `dotnet build && dotnet test`. +- [ ] **Step 7: Commit** + +```bash +git add src/Volcanic.AgenticCore/Features/Tools/BuiltIn/SendTool.cs tests/Volcanic.AgenticCore.Tests/Support/StubExtensionContext.cs tests/Volcanic.AgenticCore.Tests/Features/SendToolTests.cs +git commit -m "feat(core): send tool delivers workspace files through IChatChannel" +``` + +Spec edits: none. + +--- + +### Task 3: register `send` per session; end to end; MCP interplay + +**Files:** +- Modify: `src/Volcanic.AgenticCore/Features/Agent/AgentSessionFactory.cs` +- Modify: `tests/Volcanic.AgenticCore.Tests/Features/AgentSessionFactoryTests.cs` +- Modify: `tests/Volcanic.AgenticCore.Extensions.Tests/Mcp/Support/McpTestHost.cs` (`CreateSessionAsync` takes an optional channel) +- Modify: `tests/Volcanic.AgenticCore.Extensions.Tests/Mcp/McpExtensionTests.cs` + +**Interfaces:** +- Consumes: T1 `AgentSessionOptions.Channel`, T2 `SendTool`. +- Produces: sessions with a channel have a built-in tool `send`, active unless `options.ActiveTools` excludes it. + +- [ ] **Step 1: Write the failing tests** (append to `AgentSessionFactoryTests`; add `using Volcanic.AgenticCore.Abstractions.Messages;`): + +```csharp + [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().Single(); + Assert.False(result.IsError); + Assert.Equal("Sent chart.png as photo.", result.Text); + Assert.Equal("Here it is.", session.Messages.OfType().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().Single(); + Assert.True(result.IsError); + Assert.Contains("Telegram refused the file: Bad Request", result.Text, StringComparison.Ordinal); + } +``` + + If `session.Tools` exposes the source under another property name than `Source` (see `ToolInfo`), use that name. + +- [ ] **Step 2: Run** `dotnet test tests/Volcanic.AgenticCore.Tests --filter "FullyQualifiedName~AgentSessionFactoryTests"` β€” the new tests fail (no `send`). + +- [ ] **Step 3: Implement** in `AgentSessionFactory.CreateAsync`, inside `if (_options.IncludeBuiltInTools)` after the `foreach`: + +```csharp + // `send` exists only when the host can deliver into this session's chat. + if (options.Channel is not null) + { + var send = new SendTool(); + tools.AddBuiltIn(send, options.ActiveTools?.Contains(send.Name) ?? true); + } +``` + + (`using Volcanic.AgenticCore.Features.Tools.BuiltIn;`). The existing `if (options.ActiveTools is not null) tools.SetActive(options.ActiveTools);` still applies after extensions. + +- [ ] **Step 4: MCP interplay test.** In `McpTestHost.CreateSessionAsync` add a parameter `Volcanic.AgenticCore.Abstractions.Ui.IChatChannel? channel = null` and pass `Channel = channel` in the `AgentSessionOptions`. In `McpExtensionTests` add (next to `Browser_section_mentions_send_only_when_a_send_tool_exists`, which stays as is): + +```csharp + private sealed class NullChannel : Volcanic.AgenticCore.Abstractions.Ui.IChatChannel + { + public Volcanic.AgenticCore.Abstractions.Ui.ChatChannelLimits Limits { get; } = new(1, 1, 1); + public Task SendTextAsync(string markdown, CancellationToken cancellationToken) => throw new NotSupportedException(); + public Task 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); + } +``` + + The test's own nested `SendArgs` record and the stub `DelegateTool` named `send` don't collide with the core type (nested type wins; an extension tool named `send` replaces the built-in by design). + +- [ ] **Step 5: Run** `dotnet test tests/Volcanic.AgenticCore.Tests --filter "FullyQualifiedName~AgentSessionFactoryTests"` and `dotnet test tests/Volcanic.AgenticCore.Extensions.Tests --filter "FullyQualifiedName~McpExtensionTests"` β€” PASS. +- [ ] **Step 6: Full check** `dotnet build && dotnet test`. +- [ ] **Step 7: Commit** + +```bash +git add src/Volcanic.AgenticCore/Features/Agent/AgentSessionFactory.cs tests/Volcanic.AgenticCore.Tests/Features/AgentSessionFactoryTests.cs \ + tests/Volcanic.AgenticCore.Extensions.Tests/Mcp/Support/McpTestHost.cs tests/Volcanic.AgenticCore.Extensions.Tests/Mcp/McpExtensionTests.cs +git commit -m "feat(core): register send for sessions with a chat channel" +``` + +Spec edits: none. + +--- + +### Task 4: renderer work items and file uploads + +**Files:** +- Modify: `hosts/Volcanic.AgenticCore.Telegram/Rendering/ChatRenderer.cs` +- Modify: `hosts/Volcanic.AgenticCore.Telegram/Rendering/TelegramSender.cs` +- Modify: `tests/Volcanic.AgenticCore.Telegram.Tests/Support/FakeBotClient.cs` (uploads) +- Test: `tests/Volcanic.AgenticCore.Telegram.Tests/ChatRendererOrderTests.cs` (new), `tests/Volcanic.AgenticCore.Telegram.Tests/TelegramSenderTests.cs` (added tests) + +**Interfaces:** +- Produces (`ChatRenderer`): `public Task RunInOrderAsync(Func> work)` β€” queued behind pending events, runs on the render loop, marks the status buried afterwards, cancelled (the task) when the renderer is disposed first; `public Task SayAsync(string text)` β€” plain text via `TelegramSender.SendTextAsync` in order (cut to 4096 with `…` by the sender); `internal Task<(int? FirstId, bool Complete)> SendReplyAsync(string markdown, CancellationToken ct)` β€” the reply path, now returning the first message id and whether every chunk was delivered. +- Produces (`TelegramSender`): `public sealed record FileSendResult(int? MessageId, string? Error, bool PhotoRejected)`; `public Task SendFileAsync(long chatId, string path, bool asPhoto, string? caption, CancellationToken cancellationToken = default)`; `internal IReadOnlyList FileRetryDelays { get; set; } = [1 s, 3 s]` (test seam). +- Produces (tests): `FakeBotClient.Upload(string Method, string FileName, byte[] Bytes, string? Caption, ParseMode ParseMode, int MessageId)`, `FakeBotClient.Uploads`. + +- [ ] **Step 1: FakeBotClient uploads.** Add: + +```csharp + public sealed record Upload(string Method, string FileName, byte[] Bytes, string? Caption, Telegram.Bot.Types.Enums.ParseMode ParseMode, int MessageId); + private readonly List _uploads = []; + /// Photos and documents sent, with the uploaded bytes (copied inside the request). + public IReadOnlyList Uploads { get { lock (_uploads) return _uploads.ToList(); } } + + private Message RecordUpload(string method, ChatId chatId, InputFile file, string? caption, Telegram.Bot.Types.Enums.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 }; + } +``` + + and two `switch` cases before `default`: + +```csharp + 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; +``` + + (`OnSend` still runs first, so a test can make an upload throw before it is recorded.) + +- [ ] **Step 2: Write the failing sender tests** (append to `TelegramSenderTests`; add `using Telegram.Bot.Exceptions;` and `using Telegram.Bot.Types.Enums;`): + +```csharp + private static (FakeBotClient Bot, TelegramSender Sender, string Path) FileSetup(string name = "chart.png") + { + var bot = new FakeBotClient(); + var sender = new TelegramSender(bot, NullLogger.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)!); + 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: "Sales"); + var upload = Assert.Single(bot.Uploads); + Assert.Equal(("sendPhoto", "chart.png", "Sales", 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().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().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()); // 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); + } +``` + + Declare `FileSendResult` nested in `TelegramSender` or top-level in the same namespace; the tests above use `TelegramSender.FileSendResult` β€” if you make it top-level, drop the `TelegramSender.` prefix. + +- [ ] **Step 3: Write the failing renderer tests** β€” `tests/Volcanic.AgenticCore.Telegram.Tests/ChatRendererOrderTests.cs`: + +```csharp +using System.Text.Json; +using Microsoft.Extensions.Logging.Abstractions; +using Telegram.Bot.Requests; +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.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(() => renderer.RunInOrderAsync(_ => 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(() => renderer.RunInOrderAsync(_ => Task.FromResult(1))); + } + + [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(Telegram.Bot.Types.Enums.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(), d => d.MessageId == statusId); + Assert.StartsWith("βš™οΈ", bot.Sent[^1].Text, StringComparison.Ordinal); + } +} +``` +- [ ] **Step 4: Run** `dotnet test tests/Volcanic.AgenticCore.Telegram.Tests --filter "FullyQualifiedName~ChatRendererOrderTests|FullyQualifiedName~TelegramSenderTests"` β€” compile failures. + +- [ ] **Step 5: Implement `TelegramSender.SendFileAsync`:** + +```csharp + /// Result of : the message id, or the error; PhotoRejected when Telegram refused a photo with a 400. + public sealed record FileSendResult(int? MessageId, string? Error, bool PhotoRejected); + + /// Delays before retrying an upload after a network failure (1 s, 3 s, like replies). Settable for tests. + internal IReadOnlyList FileRetryDelays { get; set; } = [TimeSpan.FromSeconds(1), TimeSpan.FromSeconds(3)]; + + /// + /// 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 PhotoRejected). + /// + public async Task 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); + } + } + } +``` + + `ApiRequestException` derives from `RequestException`, so its clause must come first (as above). + +- [ ] **Step 6: Implement the renderer changes.** + - Replace `Channel _events` with a channel of render items: + +```csharp + private abstract record RenderItem; + private sealed record EventItem(AgentSessionEvent Event) : RenderItem; + private sealed record WorkItem(Func Run, Action Cancel) : RenderItem; + private readonly Channel _items = Channel.CreateUnbounded(new UnboundedChannelOptions { SingleReader = true }); + + public void Post(AgentSessionEvent e) => Enqueue(new EventItem(e)); + + private bool Enqueue(RenderItem item) + { + Interlocked.Increment(ref _pending); + if (_items.Writer.TryWrite(item)) return true; + Interlocked.Decrement(ref _pending); + return false; + } + + /// + /// 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. + /// + public Task RunInOrderAsync(Func> work) + { + ArgumentNullException.ThrowIfNull(work); + var done = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + var item = new WorkItem(async ct => + { + try { done.TrySetResult(await work(ct).ConfigureAwait(false)); } + catch (OperationCanceledException) { done.TrySetCanceled(); } + catch (Exception ex) { done.TrySetException(ex); } + }, () => done.TrySetCanceled()); + if (!Enqueue(item)) done.TrySetCanceled(); + return done.Task; + } + + /// Plain text (no parse mode) in order with rendered output; cut to 4096 characters by the sender. + public Task SayAsync(string text) => RunInOrderAsync(ct => _sender.SendTextAsync(_chatId, text, cancellationToken: ct)); +``` + + - In `RunAsync`, read `_items.Reader`; the inner loop becomes: + +```csharp + while (reader.TryRead(out var item)) + { + try + { + switch (item) + { + case EventItem { Event: var e }: + await RenderAsync(e, ct).ConfigureAwait(false); + break; + case WorkItem work: + await work.Run(ct).ConfigureAwait(false); + BuryStatus(); + break; + } + } + catch (Exception ex) when (ex is not OperationCanceledException) + { + _logger.LogWarning(ex, "Rendering {Item} for chat {ChatId} failed", item is EventItem ev ? ev.Event.GetType().Name : "work", _chatId); + } + finally + { + Interlocked.Decrement(ref _pending); + } + } +``` + + - In the `finally` of `RunAsync` (after `StopTyping()`), cancel what's left: `while (_items.Reader.TryRead(out var left)) { if (left is WorkItem w) w.Cancel(); Interlocked.Decrement(ref _pending); }`. + - `DisposeAsync` completes `_items.Writer`. + - `SendReplyAsync` becomes `internal async Task<(int? FirstId, bool Complete)> SendReplyAsync(string text, CancellationToken ct)`: empty/whitespace β†’ `(null, true)`; per chunk keep the retry loop; on success `first ??= sent; BuryStatus();`; on failure log as today and set `complete = false` (continue with the next chunk as today). `RenderAsync` ignores the result (`_ = await …` or discard). + - Update the class `` to mention work items. + +- [ ] **Step 7: Run** the Step 4 filter β€” PASS. Also `dotnet test tests/Volcanic.AgenticCore.Telegram.Tests --filter "FullyQualifiedName~ClaudeCodeReplayTests|FullyQualifiedName~ChatTests"` (renderer regressions). +- [ ] **Step 8: Full check** `dotnet build && dotnet test`. +- [ ] **Step 9: Commit** + +```bash +git add hosts/Volcanic.AgenticCore.Telegram/Rendering/ChatRenderer.cs hosts/Volcanic.AgenticCore.Telegram/Rendering/TelegramSender.cs \ + tests/Volcanic.AgenticCore.Telegram.Tests/Support/FakeBotClient.cs tests/Volcanic.AgenticCore.Telegram.Tests/ChatRendererOrderTests.cs \ + tests/Volcanic.AgenticCore.Telegram.Tests/TelegramSenderTests.cs docs/superpowers/specs/2026-09-26-telegram-attachments-design.md +git commit -m "feat(telegram): ordered renderer work items and file uploads" +``` + +Spec edits (same commit): in Β§3 "Ordering through the renderer", add that `ChatRenderer.SayAsync(text)` (a `RunInOrderAsync` around `TelegramSender.SendTextAsync`) sends plain-text notices and the `🎀` echo; in Β§4 change "Notices and the echo are posted through the renderer (`RunInOrderAsync` β†’ `SendTextAsync`)" to "(`ChatRenderer.SayAsync`, plain text)". In "Files" mention `TelegramSender.FileRetryDelays` as the test seam. + +--- + +### Task 5: `TelegramChatChannel`, wiring, and `send` end to end + +**Files:** +- Create: `hosts/Volcanic.AgenticCore.Telegram/Rendering/TelegramLimits.cs` +- Create: `hosts/Volcanic.AgenticCore.Telegram/Chats/TelegramChatChannel.cs` +- Modify: `hosts/Volcanic.AgenticCore.Telegram/Chats/ChatRegistry.cs` (`ChatContext` gets `Channel`; `CreateAsync` builds the channel and passes it) +- Test: `tests/Volcanic.AgenticCore.Telegram.Tests/TelegramChatChannelTests.cs` (new), `tests/Volcanic.AgenticCore.Telegram.Tests/SendToolChatTests.cs` (new) + +**Interfaces:** +- Consumes: T1 contract, T3 registration, T4 `RunInOrderAsync`, `SendReplyAsync`, `SendFileAsync`. +- Produces: `public static class TelegramLimits { MaxDownloadBytes = 20 MiB; MaxPhotoBytes = 10 MiB; MaxDocumentBytes = 50 MiB; MaxCaptionLength = 1024; string Megabytes(long bytes) /* "21.0" */; string FormatSize(long bytes) /* "5 bytes", "1.5 KB", "1.2 MB" */ }`; `public sealed class TelegramChatChannel(long chatId, TelegramSender sender, ChatRenderer renderer) : IChatChannel`; `ChatContext.Channel` (`IChatChannel`). + +- [ ] **Step 1: `TelegramLimits`:** + +```csharp +using System.Globalization; + +namespace Volcanic.AgenticCore.Telegram.Rendering; + +/// Bot API size limits (cloud Bot API server) and size text for users and the model. +public static class TelegramLimits +{ + private const long MiB = 1024 * 1024; + /// Bots can download files up to 20 MB. + public const long MaxDownloadBytes = 20 * MiB; + public const long MaxPhotoBytes = 10 * MiB; + public const long MaxDocumentBytes = 50 * MiB; + public const int MaxCaptionLength = 1024; + + /// Megabytes with one decimal, e.g. 21.0. + public static string Megabytes(long bytes) => (bytes / (double)MiB).ToString("0.0", CultureInfo.InvariantCulture); + + /// 5 bytes, 1.5 KB, 1.2 MB. + 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", + }; +} +``` + +- [ ] **Step 2: Write the failing channel tests** β€” `tests/Volcanic.AgenticCore.Telegram.Tests/TelegramChatChannelTests.cs`: + +```csharp +using Telegram.Bot.Exceptions; +using Telegram.Bot.Requests; +using Telegram.Bot.Types.Enums; +using Volcanic.AgenticCore.Abstractions.Ui; +using Volcanic.AgenticCore.Telegram.Rendering; +using Volcanic.AgenticCore.Telegram.Tests.Support; + +namespace Volcanic.AgenticCore.Telegram.Tests; + +public sealed class TelegramChatChannelTests +{ + private static async Task 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; + } + + [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("Reminder", 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); + Assert.Equal("sendPhoto", 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(() => 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); + } +} +``` + +- [ ] **Step 3: Write the failing end-to-end tests** β€” `tests/Volcanic.AgenticCore.Telegram.Tests/SendToolChatTests.cs`: + +```csharp +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().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()); + Assert.Equal("sendDocument", Assert.Single(host.Bot.Uploads).Method); + var chat = await host.ChatAsync(); + var result = chat.Session.Messages.OfType().Single(); + Assert.False(result.IsError); + Assert.Equal("Sent out/chart.png as document.", result.Text); + } +} +``` + +- [ ] **Step 4: Run** `dotnet test tests/Volcanic.AgenticCore.Telegram.Tests --filter "FullyQualifiedName~TelegramChatChannelTests|FullyQualifiedName~SendToolChatTests"` β€” compile failures. + +- [ ] **Step 5: Implement `TelegramChatChannel`:** + +```csharp +using System.Globalization; +using Volcanic.AgenticCore.Abstractions.Ui; +using Volcanic.AgenticCore.Telegram.Rendering; + +namespace Volcanic.AgenticCore.Telegram.Chats; + +/// +/// One chat's . Every delivery goes through the chat renderer's queue +/// (), so it can't overtake rendered output, and works while no run is active. +/// +public sealed class TelegramChatChannel(long chatId, TelegramSender sender, ChatRenderer renderer) : IChatChannel +{ + private static readonly HashSet PhotoExtensions = new(StringComparer.OrdinalIgnoreCase) { ".jpg", ".jpeg", ".png", ".webp" }; + + public ChatChannelLimits Limits { get; } = new(TelegramLimits.MaxPhotoBytes, TelegramLimits.MaxDocumentBytes, TelegramLimits.MaxCaptionLength); + + public async Task SendTextAsync(string markdown, CancellationToken cancellationToken) + { + ArgumentNullException.ThrowIfNull(markdown); + var (first, complete) = await renderer.RunInOrderAsync(async ct => + { + using var linked = CancellationTokenSource.CreateLinkedTokenSource(ct, cancellationToken); + return await renderer.SendReplyAsync(markdown, linked.Token).ConfigureAwait(false); + }).ConfigureAwait(false); + if (!complete) throw new ChatDeliveryException("Telegram did not accept the message."); + return new ChatDelivery(null, first?.ToString(CultureInfo.InvariantCulture)); + } + + public Task SendFileAsync(ChatFile file, CancellationToken cancellationToken) + { + ArgumentNullException.ThrowIfNull(file); + return renderer.RunInOrderAsync(async ct => + { + using var linked = CancellationTokenSource.CreateLinkedTokenSource(ct, cancellationToken); + var asPhoto = file.Kind == ChatFileKind.Photo || (file.Kind == ChatFileKind.Auto && LooksLikePhoto(file.Path)); + var result = await sender.SendFileAsync(chatId, file.Path, asPhoto, file.Caption, linked.Token).ConfigureAwait(false); + if (result.MessageId is { } id) return Delivered(asPhoto, id); + if (asPhoto && file.Kind == ChatFileKind.Auto && result.PhotoRejected) + { + result = await sender.SendFileAsync(chatId, file.Path, asPhoto: false, file.Caption, linked.Token).ConfigureAwait(false); + if (result.MessageId is { } documentId) return Delivered(false, documentId); + } + throw new ChatDeliveryException($"Telegram refused the file: {result.Error}"); + }); + } + + 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)); +} +``` + +- [ ] **Step 6: Wire it.** In `ChatRegistry.CreateAsync`, after `var ui = new TelegramUi(...)`: `var channel = new TelegramChatChannel(chatId, sender, renderer);`, add `Channel = channel,` to the `AgentSessionOptions`, and pass `channel` to the `ChatContext` constructor. `ChatContext` gets a trailing primary-constructor parameter `IChatChannel channel` and `/// Delivers files and texts into this chat in order with rendered output (the scheduler uses it too). public IChatChannel Channel => channel;` (`using Volcanic.AgenticCore.Abstractions.Ui;` is present). + +- [ ] **Step 7: Run** the Step 4 filter β€” PASS. If the status assertion in the first `SendToolChatTests` test is flaky, raise the `WithDelay` to 400 ms rather than weakening the assertion. +- [ ] **Step 8: Full check** `dotnet build && dotnet test`. +- [ ] **Step 9: Commit** + +```bash +git add hosts/Volcanic.AgenticCore.Telegram/Rendering/TelegramLimits.cs hosts/Volcanic.AgenticCore.Telegram/Chats/TelegramChatChannel.cs \ + hosts/Volcanic.AgenticCore.Telegram/Chats/ChatRegistry.cs tests/Volcanic.AgenticCore.Telegram.Tests/TelegramChatChannelTests.cs \ + tests/Volcanic.AgenticCore.Telegram.Tests/SendToolChatTests.cs +git commit -m "feat(telegram): TelegramChatChannel delivers send files and scheduler texts in order" +``` + +Spec edits: none (if `SendTextAsync` failure wording is needed in the spec, add "`Telegram did not accept the message.`" to Β§3 "Text"). + +--- + +### Task 6: speech to text service and options + +**Files:** +- Create: `hosts/Volcanic.AgenticCore.Telegram/Speech/ISpeechToText.cs` (`ISpeechToText`, `SpeechToTextException`) +- Create: `hosts/Volcanic.AgenticCore.Telegram/Speech/SpeechOptions.cs` +- Create: `hosts/Volcanic.AgenticCore.Telegram/Speech/OpenAiSpeechToText.cs` +- Modify: `hosts/Volcanic.AgenticCore.Telegram/TelegramOptions.cs` +- Modify: `hosts/Volcanic.AgenticCore.Telegram/TelegramProcessEnvironment.cs` +- Modify: `hosts/Volcanic.AgenticCore.Telegram/Volcanic.AgenticCore.Telegram.csproj` (``) +- Modify: `hosts/Volcanic.AgenticCore.Telegram/Program.cs` +- Test: `tests/Volcanic.AgenticCore.Telegram.Tests/OpenAiSpeechToTextTests.cs` (new), `FormattingAndOptionsTests.cs`, `ProcessEnvironmentTests.cs` (added tests) + +**Interfaces:** +- Produces (namespace `Volcanic.AgenticCore.Telegram.Speech`): `public interface ISpeechToText { Task TranscribeAsync(ReadOnlyMemory audio, string fileName, string mimeType, CancellationToken cancellationToken); }`; `public sealed class SpeechToTextException(string message, Exception? inner = null) : Exception`; `public sealed class SpeechOptions { const DefaultModel = "gpt-4o-transcribe"; const DefaultBaseUrl = "https://api.openai.com/v1"; string ApiKey = ""; string Model = ""; string BaseUrl = DefaultBaseUrl; string? Language; bool IsEnabled; string ResolvedModel; string Describe(); }`; `public sealed class OpenAiSpeechToText(HttpClient http, SpeechOptions options) : ISpeechToText`. +- Produces (`TelegramOptions`): `long InlineImageMaxBytes = 3_750_000`, `SpeechOptions Speech = new()`, env fallbacks, validation. + +- [ ] **Step 1: Write the failing STT tests** β€” `tests/Volcanic.AgenticCore.Telegram.Tests/OpenAiSpeechToTextTests.cs`: + +```csharp +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 Parts { get; } = []; + + protected override async Task 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(() => 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, "gateway"); + var ex = await Assert.ThrowsAsync(() => 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(() => stt.TranscribeAsync(new byte[] { 1 }, "voice.ogg", "audio/ogg", CancellationToken.None)); + Assert.Equal("OpenAI transcription failed: the response has no text.", ex.Message); + } +} +``` + +- [ ] **Step 2: Write the failing option tests** (append to `FormattingAndOptionsTests`; add `using Volcanic.AgenticCore.Telegram.Speech;`): + +```csharp + 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()); + } +``` + + And in `ProcessEnvironmentTests`: + +```csharp + [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); + } +``` + +- [ ] **Step 3: Run** `dotnet test tests/Volcanic.AgenticCore.Telegram.Tests --filter "FullyQualifiedName~OpenAiSpeechToTextTests|FullyQualifiedName~FormattingAndOptionsTests|FullyQualifiedName~ProcessEnvironmentTests"` β€” compile failures. + +- [ ] **Step 4: Implement.** + + `Speech/ISpeechToText.cs`: + +```csharp +namespace Volcanic.AgenticCore.Telegram.Speech; + +/// Turns a voice note or audio file into text before a prompt exists. The core only ever sees the text. +public interface ISpeechToText +{ + Task TranscribeAsync(ReadOnlyMemory audio, string fileName, string mimeType, CancellationToken cancellationToken); +} + +/// A transcription failed; the message is fit for the user and never contains the API key. +public sealed class SpeechToTextException(string message, Exception? inner = null) : Exception(message, inner); +``` + + `Speech/SpeechOptions.cs`: + +```csharp +namespace Volcanic.AgenticCore.Telegram.Speech; + +/// The Telegram:Speech section. Speech to text is on exactly when is set. +public sealed class SpeechOptions +{ + public const string DefaultModel = "gpt-4o-transcribe"; + public const string DefaultBaseUrl = "https://api.openai.com/v1"; + + /// Falls back to OPENAI_API_KEY. Empty turns speech to text off. + public string ApiKey { get; set; } = ""; + /// Falls back to STT_MODEL, then . + public string Model { get; set; } = ""; + /// Any OpenAI-compatible transcription endpoint. + public string BaseUrl { get; set; } = DefaultBaseUrl; + /// Optional ISO-639-1 hint. + public string? Language { get; set; } + + public bool IsEnabled => !string.IsNullOrWhiteSpace(ApiKey); + public string ResolvedModel => string.IsNullOrWhiteSpace(Model) ? DefaultModel : Model.Trim(); + + /// The startup log line (never the key). + public string Describe() => IsEnabled ? $"Speech to text: {ResolvedModel}" : "Speech to text: off (no OPENAI_API_KEY)"; +} +``` + + `Speech/OpenAiSpeechToText.cs`: + +```csharp +using System.Net.Http.Headers; +using System.Text.Json; + +namespace Volcanic.AgenticCore.Telegram.Speech; + +/// OpenAI's /audio/transcriptions (port of VolcanicSecretary's stt/openai.ts). +public sealed class OpenAiSpeechToText(HttpClient http, SpeechOptions options) : ISpeechToText +{ + private const string Prefix = "OpenAI transcription failed: "; + + public async Task TranscribeAsync(ReadOnlyMemory 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); + using var response = await http.SendAsync(request, cancellationToken).ConfigureAwait(false); + var body = await response.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false); + if (!response.IsSuccessStatusCode) + { + var hidden = Hide(body); // hide the key before cutting, so half a key can't leak + var excerpt = hidden.Length > 200 ? hidden[..200] : 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); +} +``` + + `TelegramOptions`: add + +```csharp + /// Image documents up to this size are also shown to the model inline (base64 stays under providers' 5 MB cap). + public long InlineImageMaxBytes { get; set; } = 3_750_000; + + /// Speech to text for voice notes and audio (Telegram:Speech). + public SpeechOptions Speech { get; set; } = new(); +``` + + In `ApplyEnvironment` (the "only when empty; empty variable counts as unset" rule): + +```csharp + 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(); +``` + + In `Validate`: + +```csharp + if (InlineImageMaxBytes <= 0) errors.Add("Telegram:InlineImageMaxBytes must be positive."); + 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."); +``` + + (On Linux `Uri.TryCreate("/v1", UriKind.Absolute, …)` yields a `file://` URI; the scheme check rejects it.) + + `TelegramProcessEnvironment.Configure`: after the bot token line, `if (!string.IsNullOrWhiteSpace(telegram.Speech?.ApiKey)) target.SecretValues.Add(telegram.Speech.ApiKey);` and update the summary ("…the bot token, the speech API key and the Google client secret…"). + + `.csproj`: add `` (in the local NuGet cache). If `dotnet restore` can't resolve it offline, instead register `builder.Services.AddSingleton(new OpenAiSpeechToText(new HttpClient { Timeout = TimeSpan.FromMinutes(2) }, options.Speech))`, drop the package, and edit spec Β§5 ("`HttpClient` comes from…") in this commit. + + `Program.cs` (after `builder.Services.AddSingleton(options);`, `using Volcanic.AgenticCore.Telegram.Speech;`): + +```csharp +builder.Services.AddSingleton(options.Speech); +if (options.Speech.IsEnabled) + builder.Services.AddHttpClient(client => client.Timeout = TimeSpan.FromMinutes(2)); +``` + + and after the MCP log line: `logger.LogInformation("{Speech}", options.Speech.Describe());`. + +- [ ] **Step 5: Run** the Step 3 filter β€” PASS. +- [ ] **Step 6: Full check** `dotnet build && dotnet test`. +- [ ] **Step 7: Commit** + +```bash +git add hosts/Volcanic.AgenticCore.Telegram/Speech hosts/Volcanic.AgenticCore.Telegram/TelegramOptions.cs \ + hosts/Volcanic.AgenticCore.Telegram/TelegramProcessEnvironment.cs hosts/Volcanic.AgenticCore.Telegram/Volcanic.AgenticCore.Telegram.csproj \ + hosts/Volcanic.AgenticCore.Telegram/Program.cs tests/Volcanic.AgenticCore.Telegram.Tests/OpenAiSpeechToTextTests.cs \ + tests/Volcanic.AgenticCore.Telegram.Tests/FormattingAndOptionsTests.cs tests/Volcanic.AgenticCore.Telegram.Tests/ProcessEnvironmentTests.cs +git commit -m "feat(telegram): OpenAI speech to text service and speech options" +``` + +Spec edits: none (unless the `Microsoft.Extensions.Http` fallback above was needed). + +--- + +### Task 7: inbound files (content step 2) and accepted kinds + +**Files:** +- Create: `hosts/Volcanic.AgenticCore.Telegram/Inbound/InboxFiles.cs` +- Create: `hosts/Volcanic.AgenticCore.Telegram/Inbound/InboundMedia.cs` +- Modify: `hosts/Volcanic.AgenticCore.Telegram/Inbound/InboundComposer.cs` +- Modify: `hosts/Volcanic.AgenticCore.Telegram/Program.cs` (`AddSingleton()`) +- Modify: `tests/Volcanic.AgenticCore.Telegram.Tests/Support/FakeBotClient.cs` (files, downloads), `Support/Updates.cs` (helpers), `Support/ChatHost.cs` (`Composer`, `InboundMedia`, `WorkspaceOf`) +- Modify: `tests/Volcanic.AgenticCore.Telegram.Tests/InboundComposerTests.cs`, `MessageContextChatTests.cs` +- Test: `tests/Volcanic.AgenticCore.Telegram.Tests/InboxFilesTests.cs` (new), `tests/Volcanic.AgenticCore.Telegram.Tests/AttachmentChatTests.cs` (new) + +**Interfaces:** +- Consumes: T4 `ChatRenderer.SayAsync`, T5 `TelegramLimits`, T6 `TelegramOptions.InlineImageMaxBytes`. +- Produces: `public static class InboxFiles { const string DirectoryName = "inbox"; string SafeName(string? fileName); (string Path, FileStream Stream) Create(string workspace, DateTimeOffset localNow, string? fileName); string Relative(string workspace, string path); }`; `public sealed class InboundMedia(TelegramSender sender, TelegramOptions options, TimeProvider time, ILogger logger)` with `internal Task DownloadAsync(string fileId, Stream destination, CancellationToken ct)`, `internal Task AddFilesAsync(ChatContext chat, Message message, InboundDraft draft, CancellationToken ct)`, notice constants `DownloadFailedNotice`, `static string TooBigNotice(long? bytes)`; `InboundDraft.SavedPath` (`string?`); `InboundComposer(TelegramSender sender, InboundMedia media, ILogger logger)`; `InboundComposer.UnsupportedReply = "I can read text, photos, files and voice messages."`. +- Produces (tests): `FakeBotClient.Files` (`ConcurrentDictionary`), `FakeBotClient.FailDownloads`; `Updates.DocumentMessage(string? fileName, string? mimeType, long? size, string? caption = null, string fileId = "doc")`, `Updates.VideoMessage(...)`, `Updates.VoiceMessage(long? size = 3, string? caption = null, string fileId = "voice")`, `Updates.AudioMessage(string? fileName, string? mimeType, long? size, string? caption = null, string fileId = "audio")`; `ChatHost.Composer`, `ChatHost.WorkspaceOf(long chatId = Updates.AllowedUser)`. + +- [ ] **Step 1: Test support.** + - `FakeBotClient`: `public ConcurrentDictionary Files { get; } = new();` and `public bool FailDownloads { get; set; }`. Add a `GetFileRequest` case **before** the existing one: + +```csharp + 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; +``` + + and replace the two `DownloadFile` members: + +```csharp + 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); +``` + + - `Updates` (names end in `Message` because a method named `Document`/`Voice`/`Audio`/`Sticker` would hide the Telegram type inside the class; `StickerMessage` already exists): + +```csharp + 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); + } + + 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); + } + + 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); + } + + 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); + } +``` + + (Set any other `required` members Telegram.Bot declares to dummy values.) + - `ChatHost`: build the composer once and expose it: + +```csharp + public InboundComposer Composer { get; } + public string WorkspaceOf(long chatId = Updates.AllowedUser) => Chats.WorkspaceFor(chatId); + // in the constructor, replacing the inline composer: + var media = new InboundMedia(sender, Options, time ?? TimeProvider.System, NullLogger.Instance); + Composer = new InboundComposer(sender, media, NullLogger.Instance); + Router = new UpdateRouter(Options, Chats, new ChatCommands(sender), sender, Composer, NullLogger.Instance); +``` + + - `InboundComposerTests.Composer(host)` becomes `host.Composer`. Replace `Only_text_captions_and_photos_are_supported_in_this_wave` with: + +```csharp + [Fact] + 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!)); + } +``` + + - `MessageContextChatTests.Unsupported_messages_keep_the_old_reply_and_take_no_lease` β†’ rename to `Unsupported_messages_get_the_reply_and_take_no_lease` and expect `"I can read text, photos, files and voice messages."`. + +- [ ] **Step 2: Write the failing unit tests** β€” `tests/Volcanic.AgenticCore.Telegram.Tests/InboxFilesTests.cs`: + +```csharp +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(); + 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)); +} +``` + +- [ ] **Step 3: Write the failing chat tests** β€” `tests/Volcanic.AgenticCore.Telegram.Tests/AttachmentChatTests.cs`: + +```csharp +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; + +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 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()); + 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"] = [9, 8, 7]; + host.Faux.Enqueue(FauxTurn.Say("A diagram.")); + + await RouteAndFlushAsync(host, time, Updates.DocumentMessage("diagram.png", "image/png", 3)); + + var image = Assert.Single(Prompt(host).Content.OfType()); + Assert.Equal(("image/png", Convert.ToBase64String([9, 8, 7])), (image.MimeType, image.Data)); + Assert.EndsWith("[File received: inbox/2026-09-26-diagram.png (image/png, 3 bytes)]", 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()); + 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); + } +} +``` + + `MessageContextChatTests.FlushAsync` is `internal static`; reuse it (don't copy it). + +- [ ] **Step 4: Run** `dotnet test tests/Volcanic.AgenticCore.Telegram.Tests --filter "FullyQualifiedName~InboxFilesTests|FullyQualifiedName~AttachmentChatTests|FullyQualifiedName~InboundComposerTests|FullyQualifiedName~MessageContextChatTests"` β€” compile failures. + +- [ ] **Step 5: Implement `InboxFiles`:** + +```csharp +using System.Globalization; +using System.Text; + +namespace Volcanic.AgenticCore.Telegram.Inbound; + +/// Where received files go: <workspace>/inbox/<yyyy-MM-dd>-<safe name>, never overwriting. +public static class InboxFiles +{ + public const string DirectoryName = "inbox"; + public const int MaxStemLength = 80; + public const int MaxExtensionLength = 16; + + /// + /// Drops any directory part, replaces everything but Unicode letters, digits, ., - and _ with + /// _, trims leading dots and underscores, caps stem (80) and extension (16, dot included); file when empty. + /// + 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]; + if (extension.Length > MaxExtensionLength) extension = extension[..MaxExtensionLength]; + if (stem.Length > MaxStemLength) stem = stem[..(char.IsHighSurrogate(stem[MaxStemLength - 1]) ? MaxStemLength - 1 : MaxStemLength)]; + if (stem.Length == 0) stem = "file"; + return stem + extension; + } + + /// Creates the next free inbox/<date>-<stem>[-N]<ext> with . + 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 + } + } + } + + /// Workspace-relative with /, e.g. inbox/2026-09-26-report.pdf. + public static string Relative(string workspace, string path) => Path.GetRelativePath(workspace, path).Replace('\\', '/'); +} +``` + + (Check the `__init__.py` expectation against the implementation: `TrimStart('.', '_')` gives `init__.py`. If a theory row disagrees with the spec rule, fix the row, not the rule.) + +- [ ] **Step 6: Implement `InboundMedia` (step 2 only):** + +```csharp +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 ImageContent = Volcanic.AgenticCore.Abstractions.Messages.ImageContent; + +namespace Volcanic.AgenticCore.Telegram.Inbound; + +/// +/// Content steps for attachments (spec: telegram-attachments Β§4): step 2 saves documents, videos and audio to the +/// workspace inbox. Notices go through the chat renderer () so they stay in order. +/// +public sealed class InboundMedia(TelegramSender sender, TelegramOptions options, TimeProvider time, ILogger logger) +{ + public const string DownloadFailedNotice = "I could not download that file."; + private static readonly HashSet InlineImageTypes = new(StringComparer.OrdinalIgnoreCase) { "image/jpeg", "image/png", "image/webp", "image/gif" }; + + 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."; + + /// Download a file by id into (shared with step 1's photos). + internal Task DownloadAsync(string fileId, Stream destination, CancellationToken cancellationToken) => + sender.Bot.GetInfoAndDownloadFile(fileId, destination, cancellationToken); + + internal static bool IsTooBig(ApiRequestException ex) => ex.Message.Contains("file is too big", StringComparison.OrdinalIgnoreCase); + + /// Step 2: a document, video or audio file goes to the inbox and gets a [File received: …] line. + 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"), + _ => (null, null, null, 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 (Exception ex) + { + TryDelete(path); + if (ex is OperationCanceledException) throw; + 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) + draft.Images.Add(new ImageContent(Convert.ToBase64String(await File.ReadAllBytesAsync(path, cancellationToken).ConfigureAwait(false)), mime.ToLowerInvariant())); + else + note = " (too large to show inline; downscale it with bash if you need to see it)"; + } + draft.Body.Add($"[File received: {InboxFiles.Relative(workspace, path)} ({mime}, {TelegramLimits.FormatSize(bytes)}){note}]"); + } + + private void TryDelete(string path) + { + try { File.Delete(path); } + catch (IOException ex) { logger.LogDebug(ex, "Could not delete the partial file {Path}", path); } + } +} +``` + +- [ ] **Step 7: Update `InboundComposer`.** + - Constructor `InboundComposer(TelegramSender sender, InboundMedia media, ILogger logger)`. + - `UnsupportedReply = "I can read text, photos, files and voice messages.";` and its summary. + - `IsSupported`: `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 }`. + - `InboundDraft` gets `/// Where step 2 saved the message's file, for step 3 (audio). public string? SavedPath { get; set; }`. + - `ComposeAsync`: after step 1, `if (!draft.Refused) await media.AddFilesAsync(chat, message, draft, cancellationToken).ConfigureAwait(false); // step 2` and keep a comment marking step 3 (added in T8). + - Step 1's photo download uses `await media.DownloadAsync(largest.FileId, stream, cancellationToken)`. + - Update the class summary to name steps 2–3. +- [ ] **Step 8: `Program.cs`:** `builder.Services.AddSingleton();` next to `AddSingleton()`. +- [ ] **Step 9: Run** the Step 4 filter β€” PASS. Then `dotnet test tests/Volcanic.AgenticCore.Telegram.Tests`. +- [ ] **Step 10: Full check** `dotnet build && dotnet test`. +- [ ] **Step 11: Commit** + +```bash +git add hosts/Volcanic.AgenticCore.Telegram/Inbound hosts/Volcanic.AgenticCore.Telegram/Program.cs \ + tests/Volcanic.AgenticCore.Telegram.Tests/Support/FakeBotClient.cs tests/Volcanic.AgenticCore.Telegram.Tests/Support/Updates.cs \ + tests/Volcanic.AgenticCore.Telegram.Tests/Support/ChatHost.cs tests/Volcanic.AgenticCore.Telegram.Tests/InboundComposerTests.cs \ + tests/Volcanic.AgenticCore.Telegram.Tests/MessageContextChatTests.cs tests/Volcanic.AgenticCore.Telegram.Tests/InboxFilesTests.cs \ + tests/Volcanic.AgenticCore.Telegram.Tests/AttachmentChatTests.cs docs/superpowers/specs/2026-09-26-telegram-attachments-design.md +git commit -m "feat(telegram): save documents, videos and audio to the chat inbox" +``` + +Spec edits (same commit): Testing section β€” `Updates` gets `DocumentMessage(...)`, `VideoMessage(...)`, `VoiceMessage(...)`, `AudioMessage(...)` and reuses `StickerMessage()` (a member named like a Telegram type would hide it). Β§4 Files step 1 β€” when `FileSize` is missing and `getFile` says "file is too big", the notice reads `That file is over 20 MB; …`. Β§4 Files step 4/5 β€” the too-large note is 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)]`; sizes are `N bytes`, `N.N KB` or `N.N MB`. + +--- + +### Task 8: voice and audio transcription (content step 3) + +**Files:** +- Modify: `hosts/Volcanic.AgenticCore.Telegram/Inbound/InboundMedia.cs` (add `ISpeechToText? speech = null`, `AddSpeechAsync`) +- Modify: `hosts/Volcanic.AgenticCore.Telegram/Inbound/InboundComposer.cs` (call step 3) +- Create: `tests/Volcanic.AgenticCore.Telegram.Tests/Support/FakeSpeechToText.cs` +- Modify: `tests/Volcanic.AgenticCore.Telegram.Tests/Support/ChatHost.cs` (optional `ISpeechToText? speech = null` constructor parameter) +- Test: `tests/Volcanic.AgenticCore.Telegram.Tests/VoiceChatTests.cs` (new) + +**Interfaces:** +- Consumes: T6 `ISpeechToText`, T7 `InboundMedia`, `InboundDraft.SavedPath`, `ChatRenderer.SayAsync`. +- Produces: `InboundMedia(TelegramSender, TelegramOptions, TimeProvider, ILogger, ISpeechToText? speech = null)`; constants `SpeechNotConfiguredNotice`, `NoWordsNotice`; `internal Task AddSpeechAsync(ChatContext chat, Message message, InboundDraft draft, CancellationToken ct)`. DI picks up `ISpeechToText` when T6 registered it (the default `null` otherwise); no `Program.cs` change. + +- [ ] **Step 1: Test support.** `FakeSpeechToText`: + +```csharp +using Volcanic.AgenticCore.Telegram.Speech; + +namespace Volcanic.AgenticCore.Telegram.Tests.Support; + +/// Scripted transcripts. holds a call until released; makes it throw. +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 TranscribeAsync(ReadOnlyMemory 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; + } +} +``` + + `ChatHost` constructor: add `Volcanic.AgenticCore.Telegram.Speech.ISpeechToText? speech = null` (last parameter) and pass it to `new InboundMedia(..., speech)`. + +- [ ] **Step 2: Write the failing tests** β€” `tests/Volcanic.AgenticCore.Telegram.Tests/VoiceChatTests.cs`: + +```csharp +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()); + } + + [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 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 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(("talk.mp3", "audio/mpeg"), (stt.Calls.Single().FileName, stt.Calls.Single().MimeType)); + Assert.True(File.Exists(Path.Combine(host.WorkspaceOf(), "inbox", "2026-09-26-talk.mp3"))); + Assert.Single(host.Bot.Requests.OfType()); // downloaded once, transcribed from the saved file + } + + [Fact] + public async Task A_voice_note_and_a_text_right_after_it_are_one_prompt_in_order() + { + var stt = new FakeSpeechToText("first the voice") { Delay = TimeSpan.FromMilliseconds(300) }; // well past the 20 ms window + 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.SendAsync("then the text"); + 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))); + } +} +``` + +- [ ] **Step 3: Run** `dotnet test tests/Volcanic.AgenticCore.Telegram.Tests --filter "FullyQualifiedName~VoiceChatTests"` β€” compile failure (`ChatHost` has no `speech`). + +- [ ] **Step 4: Implement step 3** in `InboundMedia` (add the constructor parameter `ISpeechToText? speech = null` and `using Volcanic.AgenticCore.Telegram.Speech;`; update the class summary to "step 2 … step 3 transcribes voice notes and audio"): + +```csharp + public const string SpeechNotConfiguredNotice = "Voice messages need speech-to-text: set OPENAI_API_KEY (or Telegram:Speech:ApiKey) on the bot host."; + public const string NoWordsNotice = "I couldn't make out any words in that voice message."; + + /// + /// Step 3: a voice note or audio file is transcribed, echoed as 🎀 … and added as [Voice message] …. + /// Without a transcript nothing is added (the caption, if any, is still prompted). + /// + 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, a.FileName ?? "audio", a.MimeType ?? "audio/mpeg", false), + _ => (null, null, null, 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 = await File.ReadAllBytesAsync(saved, cancellationToken).ConfigureAwait(false); // audio: step 2 saved it + } + else + { + using var buffer = new MemoryStream(); + try + { + await DownloadAsync(fileId, buffer, cancellationToken).ConfigureAwait(false); + } + catch (Exception ex) when (ex is not OperationCanceledException) + { + 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 (ex is not OperationCanceledException) + { + 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}"); + } +``` + + In `InboundComposer.ComposeAsync` after step 2: `if (!draft.Refused) await media.AddSpeechAsync(chat, message, draft, cancellationToken).ConfigureAwait(false); // step 3`, and remove the "added by the attachments spec" comment. + +- [ ] **Step 5: Run** the Step 3 filter β€” PASS. Then `dotnet test tests/Volcanic.AgenticCore.Telegram.Tests`. +- [ ] **Step 6: Full check** `dotnet build && dotnet test`. +- [ ] **Step 7: Commit** + +```bash +git add hosts/Volcanic.AgenticCore.Telegram/Inbound/InboundMedia.cs hosts/Volcanic.AgenticCore.Telegram/Inbound/InboundComposer.cs \ + tests/Volcanic.AgenticCore.Telegram.Tests/Support/FakeSpeechToText.cs tests/Volcanic.AgenticCore.Telegram.Tests/Support/ChatHost.cs \ + tests/Volcanic.AgenticCore.Telegram.Tests/VoiceChatTests.cs docs/superpowers/specs/2026-09-26-telegram-attachments-design.md +git commit -m "feat(telegram): transcribe voice notes and audio with an echo" +``` + +Spec edits (same commit): Β§4 Voice step 3 β€” audio is transcribed from the file step 2 saved (one download); a failed voice download sends `I could not download that file.` and the message isn't prompted. + +--- + +### Task 9: docs and spec wrap-up + +**Files:** +- Modify: `docs/telegram-bot.md`, `docs/ui-integration.md`, `docs/architecture.md`, `docs/extensions.md`, `CLAUDE.md` +- Modify: `docs/superpowers/specs/2026-09-26-telegram-attachments-design.md`, `docs/superpowers/specs/2026-09-26-telegram-message-context-design.md` + +**Interfaces:** none (documentation of T1–T8). + +- [ ] **Step 1: `docs/telegram-bot.md`.** + - Replace the sentence "Photos (with or without a caption) are passed to the model." with a subsection **"What you can send"**: + - text and photos (as before); + - documents, videos and audio files: saved to `inbox/-` in the chat's workspace (a number is added when the name is taken), and the agent gets the caption plus `[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; + - voice messages and audio files: transcribed with OpenAI (`gpt-4o-transcribe` by default); the bot echoes `🎀 ` and the agent gets `[Voice message] `; without an API key the bot says how to set one up; + - Telegram lets bots download at most 20 MB; bigger files are 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.` + - New subsection **"Files from the agent"**: the `send` tool delivers a workspace file into the chat before the agent's reply (images as photos up to 10 MB, other files as documents up to 50 MB, captions up to 1024 characters; a photo Telegram rejects is resent as a document); only files inside the chat's workspace, only to this chat. + - Settings table rows: `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`, `whisper-1` also work), `Telegram:Speech:BaseUrl` (`https://api.openai.com/v1`, any OpenAI-compatible endpoint), `Telegram:Speech:Language` (β€”, ISO-639-1 hint), `Telegram:InlineImageMaxBytes` (`3750000`). + - Owner re-login paragraph: "photos and `/commands` still behave normally" β†’ "photos, files, voice messages and `/commands` still behave normally". + - Security: one line that `send` refuses paths outside the workspace (symlinks included) and that the speech key is kept out of agent processes like the bot token. +- [ ] **Step 2: `docs/ui-integration.md`.** Input-mapping table: add rows `Document / video / audio` β†’ "download (≀ 20 MB), save into the workspace, mention the path in the prompt text (image documents also as `ImageContent`)" and `Voice` β†’ "transcribe on the host (the core only sees text), put the transcript in the prompt". Insert a new section **"5. Implement `IChatChannel`"** before the bot skeleton (renumber the skeleton to 6 and operational notes to 7): optional; pass it as `AgentSessionOptions.Channel`; the core then registers `send`; `Limits`; `SendTextAsync` (markdown, not persisted, callable while idle β€” schedulers use it); `SendFileAsync` (the core has already confined the path and checked limits; `Auto` may fall back; throw only `ChatDeliveryException`/`OperationCanceledException`); keep deliveries in order with your rendering (Telegram: `ChatRenderer.RunInOrderAsync`). Include the interface signature. +- [ ] **Step 3: `docs/architecture.md`, `docs/extensions.md`, `CLAUDE.md`.** + - `architecture.md` Abstractions paragraph: "`IUserInterface`, `IChatChannel` (delivering files and texts into the conversation's chat), the `AgentSessionEvent` family…"; the `Tools` slice row: "…the built-in tools (`send` only for sessions with a channel)". + - `extensions.md` line listing `IExtensionContext` members: add "`Channel` (the host's `IChatChannel`, or null)". + - `CLAUDE.md` Abstractions bullet: "`IUserInterface`, `IChatChannel`, and `AgentSessionEvent`"; Telegram host paragraph: add one sentence β€” files, audio and voice are saved to `/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`. +- [ ] **Step 4: Specs.** + - `2026-09-26-telegram-message-context-design.md`: delete the paragraph "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." + - `2026-09-26-telegram-attachments-design.md`: `Status: implemented`; rename "## Open questions" to "## Resolved questions" and answer them: 1. `Audio` is transcribed by default, as in VolcanicSecretary (cost on long files accepted); 2. 3.75 MB stays the inline threshold and the host doesn't downscale (no image dependency; the model gets the path and can downscale with `bash`). Check that every spec edit named in T4, T7 and T8 is present. +- [ ] **Step 5: Check** `dotnet build && dotnet test` (docs only; must stay green) and `grep -rn "I can read text and photos" docs hosts tests` returns nothing outside `docs/superpowers/plans/`. +- [ ] **Step 6: Commit** + +```bash +git add docs/telegram-bot.md docs/ui-integration.md docs/architecture.md docs/extensions.md CLAUDE.md \ + docs/superpowers/specs/2026-09-26-telegram-attachments-design.md docs/superpowers/specs/2026-09-26-telegram-message-context-design.md +git commit -m "docs(telegram): attachments, voice, send and IChatChannel" +``` + +--- + +## Self-review notes + +- Spec coverage: Β§1 β†’ T1; Β§2 β†’ T2 (tool) and T3 (registration); Β§3 β†’ T4 (renderer, sender) and T5 (channel, wiring); Β§4 β†’ T7 (router kinds, files) and T8 (speech); Β§5 β†’ T6; Β§6 β†’ T6; Scheduler reuse β†’ T5 (`SendTextAsync_while_idle…`, not persisted); Error handling table β†’ T2, T4, T5, T7, T8; Security β†’ T2 (confinement), T6 (secret value), T7 (sanitised names, `CreateNew`); Docs β†’ T9. +- Names used across tasks: `ChatRenderer.RunInOrderAsync`/`SayAsync`/`SendReplyAsync` (T4 β†’ T5, T7, T8); `TelegramSender.SendFileAsync`/`FileSendResult`/`FileRetryDelays` (T4 β†’ T5); `TelegramLimits` (T5 β†’ T7, T8); `InboundMedia.DownloadAsync`/`AddFilesAsync`/`AddSpeechAsync`/`TooBigNotice`/`IsTooBig` (T7 β†’ T8); `InboundDraft.SavedPath` (T7 β†’ T8); `ChatHost.Composer`/`WorkspaceOf` (T7 β†’ T8); `FakeBotClient.Files`/`FailDownloads`/`Uploads` (T4, T7). From f2d6b7e5dce3d1ddd60a79cc7a0b796b27587f73 Mon Sep 17 00:00:00 2001 From: EugeneTes Date: Sat, 26 Sep 2026 21:45:57 +0200 Subject: [PATCH 02/11] feat(core): IChatChannel contract and AgentSessionOptions.Channel Adds the host-agnostic IChatChannel contract (ChatFile, ChatDelivery, ChatChannelLimits, ChatDeliveryException) to Abstractions/Ui, AgentSessionOptions.Channel, AgentSession.Channel and IExtensionContext.Channel. IExtensionHost.Channel is an internal plumbing default member that ExtensionContext forwards. Test support: FakeChatChannel and the TestHost builder's Channel. --- .../Extensions/IExtensionContext.cs | 2 + .../Ui/IChatChannel.cs | 38 ++++++++++++++ .../Features/Agent/AgentSession.Host.cs | 1 + .../Features/Agent/AgentSession.cs | 2 + .../Features/Extensions/ExtensionContext.cs | 1 + .../Features/Extensions/IExtensionHost.cs | 2 + .../Hosting/AgenticCoreOptions.cs | 2 + .../Features/AgentSessionFactoryTests.cs | 49 +++++++++++++++++++ .../Support/FakeChatChannel.cs | 29 +++++++++++ .../Support/TestHost.cs | 3 +- 10 files changed, 128 insertions(+), 1 deletion(-) create mode 100644 src/Volcanic.AgenticCore.Abstractions/Ui/IChatChannel.cs create mode 100644 tests/Volcanic.AgenticCore.Tests/Features/AgentSessionFactoryTests.cs create mode 100644 tests/Volcanic.AgenticCore.Tests/Support/FakeChatChannel.cs diff --git a/src/Volcanic.AgenticCore.Abstractions/Extensions/IExtensionContext.cs b/src/Volcanic.AgenticCore.Abstractions/Extensions/IExtensionContext.cs index 9ebd533..868b009 100644 --- a/src/Volcanic.AgenticCore.Abstractions/Extensions/IExtensionContext.cs +++ b/src/Volcanic.AgenticCore.Abstractions/Extensions/IExtensionContext.cs @@ -22,6 +22,8 @@ public interface IExtensionContext /// False when the host uses ; extensions should then avoid dialogs. bool HasUi { get; } IUserInterface Ui { get; } + /// The session's chat channel, or null when the host has none (then send isn't registered). + IChatChannel? Channel => null; IReadOnlySessionManager SessionManager { get; } IModelRegistry ModelRegistry { get; } Model? Model { get; } diff --git a/src/Volcanic.AgenticCore.Abstractions/Ui/IChatChannel.cs b/src/Volcanic.AgenticCore.Abstractions/Ui/IChatChannel.cs new file mode 100644 index 0000000..dd1b1f6 --- /dev/null +++ b/src/Volcanic.AgenticCore.Abstractions/Ui/IChatChannel.cs @@ -0,0 +1,38 @@ +namespace Volcanic.AgenticCore.Abstractions.Ui; + +/// How a file should be delivered. lets the channel choose. +public enum ChatFileKind { Auto, Photo, Document } + +/// Absolute path of an existing file; the caller has already confined and validated it. +/// Photo, document, or let the channel choose. +/// Plain text shown with the file, at most . +public sealed record ChatFile(string Path, ChatFileKind Kind = ChatFileKind.Auto, string? Caption = null); + +/// How a file was actually delivered (Photo or Document); null for text. +/// Host message id(s), for logs and tool details. +public sealed record ChatDelivery(ChatFileKind? SentAs, string? MessageId); + +/// Upload limits of a channel, in bytes and characters. +public sealed record ChatChannelLimits(long MaxPhotoBytes, long MaxDocumentBytes, int MaxCaptionLength); + +/// +/// Delivers content into the conversation's chat, outside the normal assistant-message rendering. Implementations throw +/// only and , are safe to call from any thread, +/// and work while no run is active. Nothing delivered is persisted by the core. +/// +public interface IChatChannel +{ + ChatChannelLimits Limits { get; } + + /// Markdown, rendered like assistant text (split when long). Not added to the session transcript. + Task SendTextAsync(string markdown, CancellationToken cancellationToken); + + /// + /// Deliver a file. lets the channel choose, and it may fall back from photo to + /// document. Throws with a message fit for the model or the user. + /// + Task SendFileAsync(ChatFile file, CancellationToken cancellationToken); +} + +/// A delivery failed; the message is fit for the model or the user. +public sealed class ChatDeliveryException(string message, Exception? inner = null) : Exception(message, inner); diff --git a/src/Volcanic.AgenticCore/Features/Agent/AgentSession.Host.cs b/src/Volcanic.AgenticCore/Features/Agent/AgentSession.Host.cs index cafac1f..6f32ab4 100644 --- a/src/Volcanic.AgenticCore/Features/Agent/AgentSession.Host.cs +++ b/src/Volcanic.AgenticCore/Features/Agent/AgentSession.Host.cs @@ -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; diff --git a/src/Volcanic.AgenticCore/Features/Agent/AgentSession.cs b/src/Volcanic.AgenticCore/Features/Agent/AgentSession.cs index ff89008..9fdd8e4 100644 --- a/src/Volcanic.AgenticCore/Features/Agent/AgentSession.cs +++ b/src/Volcanic.AgenticCore/Features/Agent/AgentSession.cs @@ -102,6 +102,8 @@ public sealed partial class AgentSession : IAsyncDisposable public string Cwd => _options.Cwd; /// The session's time zone: the session option, else the host default, else the machine's zone. public TimeZoneInfo TimeZone => _options.TimeZone ?? _coreOptions.DefaultTimeZone ?? TimeZoneInfo.Local; + /// The host's chat channel for this session (e.g. for scheduled deliveries), or null. + public IChatChannel? Channel => _options.Channel; public Model? Model => _model; public ThinkingLevel ThinkingLevel => _thinkingLevel; public bool IsStreaming { get { lock (_stateLock) return _runTask is { IsCompleted: false }; } } diff --git a/src/Volcanic.AgenticCore/Features/Extensions/ExtensionContext.cs b/src/Volcanic.AgenticCore/Features/Extensions/ExtensionContext.cs index 37b8e47..ee56f3c 100644 --- a/src/Volcanic.AgenticCore/Features/Extensions/ExtensionContext.cs +++ b/src/Volcanic.AgenticCore/Features/Extensions/ExtensionContext.cs @@ -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; diff --git a/src/Volcanic.AgenticCore/Features/Extensions/IExtensionHost.cs b/src/Volcanic.AgenticCore/Features/Extensions/IExtensionHost.cs index 9e5327b..5654728 100644 --- a/src/Volcanic.AgenticCore/Features/Extensions/IExtensionHost.cs +++ b/src/Volcanic.AgenticCore/Features/Extensions/IExtensionHost.cs @@ -15,6 +15,8 @@ public interface IExtensionHost /// The session store scope, e.g. telegram-42. string Scope { get; } IUserInterface Ui { get; } + /// The session's chat channel, or null when the host has none (then send isn't registered). + IChatChannel? Channel => null; IReadOnlySessionManager SessionManager { get; } IModelRegistry ModelRegistry { get; } Model? Model { get; } diff --git a/src/Volcanic.AgenticCore/Hosting/AgenticCoreOptions.cs b/src/Volcanic.AgenticCore/Hosting/AgenticCoreOptions.cs index a396019..61fc8ea 100644 --- a/src/Volcanic.AgenticCore/Hosting/AgenticCoreOptions.cs +++ b/src/Volcanic.AgenticCore/Hosting/AgenticCoreOptions.cs @@ -63,6 +63,8 @@ public sealed class AgentSessionOptions /// The conversation's time zone. Falls back to , then . public TimeZoneInfo? TimeZone { get; init; } public IUserInterface? Ui { get; init; } + /// Delivers files and texts into the conversation's chat; enables the send tool. + public IChatChannel? Channel { get; init; } /// Active tool names. Defaults to read, bash, edit, write. public IReadOnlyList? ActiveTools { get; init; } public ToolExecutionMode ToolExecution { get; init; } = ToolExecutionMode.Parallel; diff --git a/tests/Volcanic.AgenticCore.Tests/Features/AgentSessionFactoryTests.cs b/tests/Volcanic.AgenticCore.Tests/Features/AgentSessionFactoryTests.cs new file mode 100644 index 0000000..d9a530a --- /dev/null +++ b/tests/Volcanic.AgenticCore.Tests/Features/AgentSessionFactoryTests.cs @@ -0,0 +1,49 @@ +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 Seen) Probe() + { + IChatChannel? seen = null; + var probe = new DelegateTool("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()); + } +} diff --git a/tests/Volcanic.AgenticCore.Tests/Support/FakeChatChannel.cs b/tests/Volcanic.AgenticCore.Tests/Support/FakeChatChannel.cs new file mode 100644 index 0000000..e14d1c1 --- /dev/null +++ b/tests/Volcanic.AgenticCore.Tests/Support/FakeChatChannel.cs @@ -0,0 +1,29 @@ +using Volcanic.AgenticCore.Abstractions.Ui; + +namespace Volcanic.AgenticCore.Tests.Support; + +/// Records deliveries. makes every file delivery fail. +public sealed class FakeChatChannel : IChatChannel +{ + public const long MB = 1024 * 1024; + public ChatChannelLimits Limits { get; init; } = new(10 * MB, 50 * MB, 1024); + public List Files { get; } = []; + public List Texts { get; } = []; + public Exception? Failure { get; set; } + /// What an file is reported as. + public ChatFileKind AutoSendsAs { get; set; } = ChatFileKind.Photo; + + public Task SendTextAsync(string markdown, CancellationToken cancellationToken) + { + lock (Texts) Texts.Add(markdown); + return Task.FromResult(new ChatDelivery(null, "t1")); + } + + public Task SendFileAsync(ChatFile file, CancellationToken cancellationToken) + { + if (Failure is not null) return Task.FromException(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)); + } +} diff --git a/tests/Volcanic.AgenticCore.Tests/Support/TestHost.cs b/tests/Volcanic.AgenticCore.Tests/Support/TestHost.cs index eb476f5..0c95b88 100644 --- a/tests/Volcanic.AgenticCore.Tests/Support/TestHost.cs +++ b/tests/Volcanic.AgenticCore.Tests/Support/TestHost.cs @@ -70,12 +70,13 @@ public sealed class TestHost : IAsyncDisposable public List 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, }; } } From 7dce84ddc594f2288dc9228e472d00e6ba940c7e Mon Sep 17 00:00:00 2001 From: EugeneTes Date: Sat, 26 Sep 2026 21:49:02 +0200 Subject: [PATCH 03/11] feat(core): send tool delivers workspace files through IChatChannel SendTool (Tool, sequential) confines the path to the workspace including symlinked files and parent directories (WorkspacePaths), checks caption, photo and document limits before sending, and picks Document for non-photo extensions (.gif included) or oversized images under kind auto. Not registered yet; that is the next task. --- .../Features/Tools/BuiltIn/SendTool.cs | 101 ++++++++ .../Features/SendToolTests.cs | 228 ++++++++++++++++++ .../Support/StubExtensionContext.cs | 1 + 3 files changed, 330 insertions(+) create mode 100644 src/Volcanic.AgenticCore/Features/Tools/BuiltIn/SendTool.cs create mode 100644 tests/Volcanic.AgenticCore.Tests/Features/SendToolTests.cs diff --git a/src/Volcanic.AgenticCore/Features/Tools/BuiltIn/SendTool.cs b/src/Volcanic.AgenticCore/Features/Tools/BuiltIn/SendTool.cs new file mode 100644 index 0000000..0533bf7 --- /dev/null +++ b/src/Volcanic.AgenticCore/Features/Tools/BuiltIn/SendTool.cs @@ -0,0 +1,101 @@ +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); + +/// +/// Sends a workspace file into the session's chat through . Registered only for sessions that +/// have a channel. Confines the path to the workspace (symlinks included) and checks the channel's limits before sending. +/// +public sealed class SendTool : Tool +{ + private static readonly HashSet 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 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 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); +} + +/// Workspace confinement for tools that hand files to the outside world. +internal static class WorkspacePaths +{ + private static StringComparison Comparison => OperatingSystem.IsWindows() ? StringComparison.OrdinalIgnoreCase : StringComparison.Ordinal; + + /// True when is strictly below (trailing-separator compare). + public static bool IsInside(string root, string fullPath) => + fullPath.StartsWith(Path.TrimEndingDirectorySeparator(root) + Path.DirectorySeparatorChar, Comparison); + + /// Workspace-relative path with / separators. + public static string Relative(string root, string fullPath) => Path.GetRelativePath(root, fullPath).Replace('\\', '/'); + + /// + /// The full path of when it, and the final target of every link between it and the + /// workspace, stays inside . Throws path escapes the workspace: … otherwise. + /// + public static string Confine(string root, string path) + { + var full = Paths.Resolve(root, path); + if (!IsInside(root, full)) throw Escapes(path); + for (var current = full; IsInside(root, current); current = Path.GetDirectoryName(current)!) + { + FileSystemInfo info = Directory.Exists(current) ? new DirectoryInfo(current) : new FileInfo(current); + if (info.LinkTarget is null) continue; + var target = info.ResolveLinkTarget(returnFinalTarget: true); + if (target is null || !IsInside(root, Path.GetFullPath(target.FullName))) throw Escapes(path); + } + return full; + } + + private static InvalidOperationException Escapes(string path) => new($"path escapes the workspace: {path}"); +} diff --git a/tests/Volcanic.AgenticCore.Tests/Features/SendToolTests.cs b/tests/Volcanic.AgenticCore.Tests/Features/SendToolTests.cs new file mode 100644 index 0000000..6a50854 --- /dev/null +++ b/tests/Volcanic.AgenticCore.Tests/Features/SendToolTests.cs @@ -0,0 +1,228 @@ +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 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 Fails(SendArgs args) + { + var ex = await Assert.ThrowsAnyAsync(() => 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 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(() => 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(() => 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); + } +} diff --git a/tests/Volcanic.AgenticCore.Tests/Support/StubExtensionContext.cs b/tests/Volcanic.AgenticCore.Tests/Support/StubExtensionContext.cs index a4fc7cb..e312e03 100644 --- a/tests/Volcanic.AgenticCore.Tests/Support/StubExtensionContext.cs +++ b/tests/Volcanic.AgenticCore.Tests/Support/StubExtensionContext.cs @@ -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; From b22d0d159e45ff438855a3444624b322c03f8792 Mon Sep 17 00:00:00 2001 From: EugeneTes Date: Sat, 26 Sep 2026 21:52:22 +0200 Subject: [PATCH 04/11] feat(core): register send for sessions with a chat channel --- .../Features/Agent/AgentSessionFactory.cs | 7 ++ .../Mcp/McpExtensionTests.cs | 19 ++++++ .../Mcp/Support/McpTestHost.cs | 4 +- .../Features/AgentSessionFactoryTests.cs | 65 +++++++++++++++++++ 4 files changed, 93 insertions(+), 2 deletions(-) diff --git a/src/Volcanic.AgenticCore/Features/Agent/AgentSessionFactory.cs b/src/Volcanic.AgenticCore/Features/Agent/AgentSessionFactory.cs index 8db7f10..6054aab 100644 --- a/src/Volcanic.AgenticCore/Features/Agent/AgentSessionFactory.cs +++ b/src/Volcanic.AgenticCore/Features/Agent/AgentSessionFactory.cs @@ -11,6 +11,7 @@ using Volcanic.AgenticCore.Features.Models; using Volcanic.AgenticCore.Features.Sessions; using Volcanic.AgenticCore.Features.SystemPrompt; using Volcanic.AgenticCore.Features.Tools; +using Volcanic.AgenticCore.Features.Tools.BuiltIn; using Volcanic.AgenticCore.Hosting; namespace Volcanic.AgenticCore.Features.Agent; @@ -68,6 +69,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 = new SendTool(); + tools.AddBuiltIn(send, options.ActiveTools?.Contains(send.Name) ?? true); + } } var commands = new CommandRegistry(); var models = new ModelRegistry(new ApiKeyResolver(_paths.AuthFile)); diff --git a/tests/Volcanic.AgenticCore.Extensions.Tests/Mcp/McpExtensionTests.cs b/tests/Volcanic.AgenticCore.Extensions.Tests/Mcp/McpExtensionTests.cs index 087de1c..ee4b1ae 100644 --- a/tests/Volcanic.AgenticCore.Extensions.Tests/Mcp/McpExtensionTests.cs +++ b/tests/Volcanic.AgenticCore.Extensions.Tests/Mcp/McpExtensionTests.cs @@ -171,6 +171,25 @@ 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 SendTextAsync(string markdown, CancellationToken cancellationToken) => throw new NotSupportedException(); + public Task 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_mentions_send_only_when_a_send_tool_exists() { diff --git a/tests/Volcanic.AgenticCore.Extensions.Tests/Mcp/Support/McpTestHost.cs b/tests/Volcanic.AgenticCore.Extensions.Tests/Mcp/Support/McpTestHost.cs index 603d665..5820166 100644 --- a/tests/Volcanic.AgenticCore.Extensions.Tests/Mcp/Support/McpTestHost.cs +++ b/tests/Volcanic.AgenticCore.Extensions.Tests/Mcp/Support/McpTestHost.cs @@ -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 CreateSessionAsync(TimeZoneInfo? zone = null) + public async Task CreateSessionAsync(TimeZoneInfo? zone = null, Volcanic.AgenticCore.Abstractions.Ui.IChatChannel? channel = null) { var session = await _services.GetRequiredService() - .CreateAsync(new AgentSessionOptions { Cwd = Workspace, TimeZone = zone }); + .CreateAsync(new AgentSessionOptions { Cwd = Workspace, TimeZone = zone, Channel = channel }); lock (_sessions) _sessions.Add(session); return session; } diff --git a/tests/Volcanic.AgenticCore.Tests/Features/AgentSessionFactoryTests.cs b/tests/Volcanic.AgenticCore.Tests/Features/AgentSessionFactoryTests.cs index d9a530a..b1385a5 100644 --- a/tests/Volcanic.AgenticCore.Tests/Features/AgentSessionFactoryTests.cs +++ b/tests/Volcanic.AgenticCore.Tests/Features/AgentSessionFactoryTests.cs @@ -1,3 +1,4 @@ +using Volcanic.AgenticCore.Abstractions.Messages; using Volcanic.AgenticCore.Abstractions.Tools; using Volcanic.AgenticCore.Abstractions.Ui; using Volcanic.AgenticCore.Features.Extensions; @@ -46,4 +47,68 @@ public class AgentSessionFactoryTests 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().Single(); + Assert.False(result.IsError); + Assert.Equal("Sent chart.png as photo.", result.Text); + Assert.Equal("Here it is.", session.Messages.OfType().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().Single(); + Assert.True(result.IsError); + Assert.Contains("Telegram refused the file: Bad Request", result.Text, StringComparison.Ordinal); + } } From 1eed03e8518ba7c678c6aa75dc87deba8cb2bee5 Mon Sep 17 00:00:00 2001 From: EugeneTes Date: Sat, 26 Sep 2026 21:56:05 +0200 Subject: [PATCH 05/11] feat(telegram): ordered renderer work items and file uploads --- .../2026-09-26-telegram-attachments-design.md | 9 +- .../Rendering/ChatRenderer.cs | 88 ++++++++++++++++--- .../Rendering/TelegramSender.cs | 41 +++++++++ .../ChatRendererOrderTests.cs | 85 ++++++++++++++++++ .../Support/FakeBotClient.cs | 22 +++++ .../TelegramSenderTests.cs | 75 ++++++++++++++++ 6 files changed, 303 insertions(+), 17 deletions(-) create mode 100644 tests/Volcanic.AgenticCore.Telegram.Tests/ChatRendererOrderTests.cs diff --git a/docs/superpowers/specs/2026-09-26-telegram-attachments-design.md b/docs/superpowers/specs/2026-09-26-telegram-attachments-design.md index be21876..defdaf2 100644 --- a/docs/superpowers/specs/2026-09-26-telegram-attachments-design.md +++ b/docs/superpowers/specs/2026-09-26-telegram-attachments-design.md @@ -155,14 +155,17 @@ it (`chat.Channel`). pending events and completes when the item has run. 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). A work item still queued when the renderer is disposed is cancelled. `ChatRenderer.SayAsync(text)` + (a `RunInOrderAsync` around `TelegramSender.SendTextAsync`) sends plain-text notices and the `🎀` echo. The reply path + (`ChatRenderer.SendReplyAsync`) returns the first message id and whether every chunk was delivered. - **Files.** `TelegramSender.SendFileAsync(chatId, path, asPhoto, caption, ct) β†’ Task`, 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: ")`. @@ -194,7 +197,7 @@ 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"`): diff --git a/hosts/Volcanic.AgenticCore.Telegram/Rendering/ChatRenderer.cs b/hosts/Volcanic.AgenticCore.Telegram/Rendering/ChatRenderer.cs index 6c528ab..f1f618a 100644 --- a/hosts/Volcanic.AgenticCore.Telegram/Rendering/ChatRenderer.cs +++ b/hosts/Volcanic.AgenticCore.Telegram/Rendering/ChatRenderer.cs @@ -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. /// +/// Host work (file uploads, notices) runs on the same loop through , so it stays in +/// order with the rendered output queued before it. +/// +/// /// While a run is active one status message ("βš™οΈ Working...") lists the last tool calls /// with their state, edited at most every . When something was sent below it, /// the next tool update moves it back to the bottom; it is deleted when the run settles. @@ -30,7 +34,10 @@ public sealed class ChatRenderer : IAsyncDisposable private readonly TelegramOptions _options; private readonly ILogger _logger; private readonly TimeProvider _time; - private readonly Channel _events = Channel.CreateUnbounded(new UnboundedChannelOptions { SingleReader = true }); + private abstract record RenderItem; + private sealed record EventItem(AgentSessionEvent Event) : RenderItem; + private sealed record WorkItem(Func Run, Action Cancel) : RenderItem; + private readonly Channel _items = Channel.CreateUnbounded(new UnboundedChannelOptions { SingleReader = true }); private readonly CancellationTokenSource _stop = new(); private readonly Task _loop; @@ -63,12 +70,37 @@ public sealed class ChatRenderer : IAsyncDisposable } /// Queue an event; safe to call from any thread (this is the session subscription). - 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; } + /// + /// 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. + /// + public Task RunInOrderAsync(Func> work) + { + ArgumentNullException.ThrowIfNull(work); + var done = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + var item = new WorkItem(async ct => + { + try { done.TrySetResult(await work(ct).ConfigureAwait(false)); } + catch (OperationCanceledException) { done.TrySetCanceled(); } + catch (Exception ex) { done.TrySetException(ex); } + }, () => done.TrySetCanceled()); + if (!Enqueue(item)) done.TrySetCanceled(); + return done.Task; + } + + /// Plain text (no parse mode) in order with rendered output; cut to 4096 characters by the sender. + public Task SayAsync(string text) => RunInOrderAsync(ct => _sender.SendTextAsync(_chatId, text, cancellationToken: ct)); + /// Completes once every queued event is rendered. For tests. internal async Task DrainAsync() { @@ -77,7 +109,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 +139,24 @@ public sealed class ChatRenderer : IAsyncDisposable break; } - while (reader.TryRead(out var e)) + while (reader.TryRead(out var item)) { try { - await RenderAsync(e, ct).ConfigureAwait(false); + switch (item) + { + case EventItem { Event: var e }: + await RenderAsync(e, ct).ConfigureAwait(false); + 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 +172,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 +199,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 +250,15 @@ public sealed class ChatRenderer : IAsyncDisposable if (_statusDirty) await FlushStatusAsync(ct).ConfigureAwait(false); } - /// Send a finished assistant message. A chunk that fails to send is retried a couple of times. - private async Task SendReplyAsync(string text, CancellationToken ct) + /// + /// 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. + /// + internal 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 +269,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 +399,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); diff --git a/hosts/Volcanic.AgenticCore.Telegram/Rendering/TelegramSender.cs b/hosts/Volcanic.AgenticCore.Telegram/Rendering/TelegramSender.cs index 749e300..e296ba3 100644 --- a/hosts/Volcanic.AgenticCore.Telegram/Rendering/TelegramSender.cs +++ b/hosts/Volcanic.AgenticCore.Telegram/Rendering/TelegramSender.cs @@ -102,6 +102,47 @@ public sealed class TelegramSender(ITelegramBotClient bot, ILoggerResult of : the message id, or the error; PhotoRejected when Telegram refused a photo with a 400. + public sealed record FileSendResult(int? MessageId, string? Error, bool PhotoRejected); + + /// Delays before retrying an upload after a network failure (1 s, 3 s, like replies). Settable for tests. + internal IReadOnlyList FileRetryDelays { get; set; } = [TimeSpan.FromSeconds(1), TimeSpan.FromSeconds(3)]; + + /// + /// 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 PhotoRejected). + /// + public async Task 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 diff --git a/tests/Volcanic.AgenticCore.Telegram.Tests/ChatRendererOrderTests.cs b/tests/Volcanic.AgenticCore.Telegram.Tests/ChatRendererOrderTests.cs new file mode 100644 index 0000000..2facfdc --- /dev/null +++ b/tests/Volcanic.AgenticCore.Telegram.Tests/ChatRendererOrderTests.cs @@ -0,0 +1,85 @@ +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.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(() => renderer.RunInOrderAsync(_ => 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(() => renderer.RunInOrderAsync(_ => Task.FromResult(1))); + } + + [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(), d => d.MessageId == statusId); + Assert.StartsWith("βš™οΈ", bot.Sent[^1].Text, StringComparison.Ordinal); + } +} diff --git a/tests/Volcanic.AgenticCore.Telegram.Tests/Support/FakeBotClient.cs b/tests/Volcanic.AgenticCore.Telegram.Tests/Support/FakeBotClient.cs index b31d6b2..80dd0bf 100644 --- a/tests/Volcanic.AgenticCore.Telegram.Tests/Support/FakeBotClient.cs +++ b/tests/Volcanic.AgenticCore.Telegram.Tests/Support/FakeBotClient.cs @@ -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; @@ -43,6 +44,21 @@ public sealed class FakeBotClient : ITelegramBotClient private readonly ConcurrentDictionary _texts = new(); + public sealed record Upload(string Method, string FileName, byte[] Bytes, string? Caption, ParseMode ParseMode, int MessageId); + private readonly List _uploads = []; + /// Photos and documents sent, with the uploaded bytes (copied inside the request). + public IReadOnlyList 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); @@ -102,6 +118,12 @@ public sealed class FakeBotClient : ITelegramBotClient 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; diff --git a/tests/Volcanic.AgenticCore.Telegram.Tests/TelegramSenderTests.cs b/tests/Volcanic.AgenticCore.Telegram.Tests/TelegramSenderTests.cs index 034da40..e7bac2f 100644 --- a/tests/Volcanic.AgenticCore.Telegram.Tests/TelegramSenderTests.cs +++ b/tests/Volcanic.AgenticCore.Telegram.Tests/TelegramSenderTests.cs @@ -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.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: "Sales"); + var upload = Assert.Single(bot.Uploads); + Assert.Equal(("sendPhoto", "chart.png", "Sales", 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().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().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()); // 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); + } } From ee3049e47a8cd76a890da6eb99f955f372aaaa4f Mon Sep 17 00:00:00 2001 From: EugeneTes Date: Sat, 26 Sep 2026 22:00:06 +0200 Subject: [PATCH 06/11] feat(telegram): TelegramChatChannel delivers send files and scheduler texts in order Add TelegramLimits, TelegramChatChannel (every delivery through ChatRenderer.RunInOrderAsync, auto photo falls back to a document on a 400) and wire it into ChatRegistry as AgentSessionOptions.Channel and ChatContext.Channel. Spec: SendTextAsync names its failure text ("Telegram did not accept the message.") and throws when any chunk fails. --- .../2026-09-26-telegram-attachments-design.md | 3 +- .../Chats/ChatRegistry.cs | 11 +- .../Chats/TelegramChatChannel.cs | 53 +++++++++ .../Rendering/TelegramLimits.cs | 32 ++++++ .../SendToolChatTests.cs | 76 +++++++++++++ .../TelegramChatChannelTests.cs | 106 ++++++++++++++++++ 6 files changed, 277 insertions(+), 4 deletions(-) create mode 100644 hosts/Volcanic.AgenticCore.Telegram/Chats/TelegramChatChannel.cs create mode 100644 hosts/Volcanic.AgenticCore.Telegram/Rendering/TelegramLimits.cs create mode 100644 tests/Volcanic.AgenticCore.Telegram.Tests/SendToolChatTests.cs create mode 100644 tests/Volcanic.AgenticCore.Telegram.Tests/TelegramChatChannelTests.cs diff --git a/docs/superpowers/specs/2026-09-26-telegram-attachments-design.md b/docs/superpowers/specs/2026-09-26-telegram-attachments-design.md index defdaf2..246528b 100644 --- a/docs/superpowers/specs/2026-09-26-telegram-attachments-design.md +++ b/docs/superpowers/specs/2026-09-26-telegram-attachments-design.md @@ -171,7 +171,8 @@ it (`chat.Channel`). - Anything else becomes `ChatDeliveryException("Telegram refused the file: ")`. - **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` diff --git a/hosts/Volcanic.AgenticCore.Telegram/Chats/ChatRegistry.cs b/hosts/Volcanic.AgenticCore.Telegram/Chats/ChatRegistry.cs index 22334d5..ef296c3 100644 --- a/hosts/Volcanic.AgenticCore.Telegram/Chats/ChatRegistry.cs +++ b/hosts/Volcanic.AgenticCore.Telegram/Chats/ChatRegistry.cs @@ -13,14 +13,17 @@ using Volcanic.AgenticCore.Telegram.Rendering; namespace Volcanic.AgenticCore.Telegram.Chats; /// Everything that belongs to one Telegram chat: its agent session, renderer and dialog surface. -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; + /// Delivers files and texts into this chat in order with rendered output (the scheduler uses it too). + public IChatChannel Channel => channel; + /// Groups this chat's incoming messages into prompts (see ). public ChatInbox Inbox => inbox; @@ -104,6 +107,7 @@ public sealed class ChatRegistry( var logger = loggerFactory.CreateLogger(); var renderer = new ChatRenderer(chatId, sender, options, loggerFactory.CreateLogger()); 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()); - 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 } diff --git a/hosts/Volcanic.AgenticCore.Telegram/Chats/TelegramChatChannel.cs b/hosts/Volcanic.AgenticCore.Telegram/Chats/TelegramChatChannel.cs new file mode 100644 index 0000000..5f91b32 --- /dev/null +++ b/hosts/Volcanic.AgenticCore.Telegram/Chats/TelegramChatChannel.cs @@ -0,0 +1,53 @@ +using System.Globalization; +using Volcanic.AgenticCore.Abstractions.Ui; +using Volcanic.AgenticCore.Telegram.Rendering; + +namespace Volcanic.AgenticCore.Telegram.Chats; + +/// +/// One chat's . Every delivery goes through the chat renderer's queue +/// (), so it can't overtake rendered output, and works while no run is active. +/// +public sealed class TelegramChatChannel(long chatId, TelegramSender sender, ChatRenderer renderer) : IChatChannel +{ + private static readonly HashSet PhotoExtensions = new(StringComparer.OrdinalIgnoreCase) { ".jpg", ".jpeg", ".png", ".webp" }; + + public ChatChannelLimits Limits { get; } = new(TelegramLimits.MaxPhotoBytes, TelegramLimits.MaxDocumentBytes, TelegramLimits.MaxCaptionLength); + + public async Task SendTextAsync(string markdown, CancellationToken cancellationToken) + { + ArgumentNullException.ThrowIfNull(markdown); + var (first, complete) = await renderer.RunInOrderAsync(async ct => + { + using var linked = CancellationTokenSource.CreateLinkedTokenSource(ct, cancellationToken); + return await renderer.SendReplyAsync(markdown, linked.Token).ConfigureAwait(false); + }).ConfigureAwait(false); + if (!complete) throw new ChatDeliveryException("Telegram did not accept the message."); + return new ChatDelivery(null, first?.ToString(CultureInfo.InvariantCulture)); + } + + public Task SendFileAsync(ChatFile file, CancellationToken cancellationToken) + { + ArgumentNullException.ThrowIfNull(file); + return renderer.RunInOrderAsync(async ct => + { + using var linked = CancellationTokenSource.CreateLinkedTokenSource(ct, cancellationToken); + var asPhoto = file.Kind == ChatFileKind.Photo || (file.Kind == ChatFileKind.Auto && LooksLikePhoto(file.Path)); + var result = await sender.SendFileAsync(chatId, file.Path, asPhoto, file.Caption, linked.Token).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, linked.Token).ConfigureAwait(false); + if (result.MessageId is { } documentId) return Delivered(false, documentId); + } + throw new ChatDeliveryException($"Telegram refused the file: {result.Error}"); + }); + } + + 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)); +} diff --git a/hosts/Volcanic.AgenticCore.Telegram/Rendering/TelegramLimits.cs b/hosts/Volcanic.AgenticCore.Telegram/Rendering/TelegramLimits.cs new file mode 100644 index 0000000..714ea97 --- /dev/null +++ b/hosts/Volcanic.AgenticCore.Telegram/Rendering/TelegramLimits.cs @@ -0,0 +1,32 @@ +using System.Globalization; + +namespace Volcanic.AgenticCore.Telegram.Rendering; + +/// Bot API size limits (cloud Bot API server) and size text for users and the model. +public static class TelegramLimits +{ + private const long MiB = 1024 * 1024; + + /// Bots can download files up to 20 MB. + public const long MaxDownloadBytes = 20 * MiB; + + /// Largest photo a bot can upload with sendPhoto. + public const long MaxPhotoBytes = 10 * MiB; + + /// Largest file a bot can upload with sendDocument. + public const long MaxDocumentBytes = 50 * MiB; + + /// Longest photo or document caption, in characters. + public const int MaxCaptionLength = 1024; + + /// Megabytes with one decimal, e.g. 21.0. + public static string Megabytes(long bytes) => (bytes / (double)MiB).ToString("0.0", CultureInfo.InvariantCulture); + + /// 5 bytes, 1.5 KB, 1.2 MB. + 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", + }; +} diff --git a/tests/Volcanic.AgenticCore.Telegram.Tests/SendToolChatTests.cs b/tests/Volcanic.AgenticCore.Telegram.Tests/SendToolChatTests.cs new file mode 100644 index 0000000..f279b07 --- /dev/null +++ b/tests/Volcanic.AgenticCore.Telegram.Tests/SendToolChatTests.cs @@ -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().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()); + Assert.Equal("sendDocument", Assert.Single(host.Bot.Uploads).Method); + var chat = await host.ChatAsync(); + var result = chat.Session.Messages.OfType().Single(); + Assert.False(result.IsError); + Assert.Equal("Sent out/chart.png as document.", result.Text); + } +} diff --git a/tests/Volcanic.AgenticCore.Telegram.Tests/TelegramChatChannelTests.cs b/tests/Volcanic.AgenticCore.Telegram.Tests/TelegramChatChannelTests.cs new file mode 100644 index 0000000..de5a638 --- /dev/null +++ b/tests/Volcanic.AgenticCore.Telegram.Tests/TelegramChatChannelTests.cs @@ -0,0 +1,106 @@ +using Telegram.Bot.Exceptions; +using Telegram.Bot.Requests; +using Telegram.Bot.Types.Enums; +using Volcanic.AgenticCore.Abstractions.Ui; +using Volcanic.AgenticCore.Telegram.Rendering; +using Volcanic.AgenticCore.Telegram.Tests.Support; + +namespace Volcanic.AgenticCore.Telegram.Tests; + +public sealed class TelegramChatChannelTests +{ + private static async Task 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("Reminder", 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(() => 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); + } +} From 8758f4b3d18e2d7bb2c929558fabb511fcb868b6 Mon Sep 17 00:00:00 2001 From: EugeneTes Date: Sat, 26 Sep 2026 22:04:27 +0200 Subject: [PATCH 07/11] feat(telegram): OpenAI speech to text service and speech options Spec 5: the HttpClient registration is named as AddHttpClient() (package Microsoft.Extensions.Http), which is how Program.cs registers it. --- .../2026-09-26-telegram-attachments-design.md | 2 +- .../Volcanic.AgenticCore.Telegram/Program.cs | 5 + .../Speech/ISpeechToText.cs | 11 +++ .../Speech/OpenAiSpeechToText.cs | 49 ++++++++++ .../Speech/SpeechOptions.cs | 27 ++++++ .../TelegramOptions.cs | 19 ++++ .../TelegramProcessEnvironment.cs | 3 +- .../Volcanic.AgenticCore.Telegram.csproj | 1 + .../FormattingAndOptionsTests.cs | 62 ++++++++++++ .../OpenAiSpeechToTextTests.cs | 95 +++++++++++++++++++ .../ProcessEnvironmentTests.cs | 10 ++ 11 files changed, 282 insertions(+), 2 deletions(-) create mode 100644 hosts/Volcanic.AgenticCore.Telegram/Speech/ISpeechToText.cs create mode 100644 hosts/Volcanic.AgenticCore.Telegram/Speech/OpenAiSpeechToText.cs create mode 100644 hosts/Volcanic.AgenticCore.Telegram/Speech/SpeechOptions.cs create mode 100644 tests/Volcanic.AgenticCore.Telegram.Tests/OpenAiSpeechToTextTests.cs diff --git a/docs/superpowers/specs/2026-09-26-telegram-attachments-design.md b/docs/superpowers/specs/2026-09-26-telegram-attachments-design.md index 246528b..21ed7b9 100644 --- a/docs/superpowers/specs/2026-09-26-telegram-attachments-design.md +++ b/docs/superpowers/specs/2026-09-26-telegram-attachments-design.md @@ -256,7 +256,7 @@ public interface ISpeechToText as given), `model`, `response_format=json`, and `language` when configured. - Non-2xx β†’ `SpeechToTextException("OpenAI transcription failed: HTTP ")`. A non-JSON body or a missing `text` gives its own message. The key never appears in messages or logs. -- `HttpClient` comes from `AddHttpClient()`, with a 2-minute timeout. +- `HttpClient` comes from `AddHttpClient()` (package `Microsoft.Extensions.Http`), with a 2-minute timeout. `SpeechOptions`, the `Telegram:Speech` section: diff --git a/hosts/Volcanic.AgenticCore.Telegram/Program.cs b/hosts/Volcanic.AgenticCore.Telegram/Program.cs index d6217c4..35726c2 100644 --- a/hosts/Volcanic.AgenticCore.Telegram/Program.cs +++ b/hosts/Volcanic.AgenticCore.Telegram/Program.cs @@ -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,9 @@ 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 + builder.Services.AddHttpClient(client => client.Timeout = TimeSpan.FromMinutes(2)); if (calendar is not null) builder.Services.AddSingleton(calendar); // ChatRegistry and BotSetupService take it optionally builder.Services.AddSingleton(); // The extension is constructed per session through ActivatorUtilities, which injects this. LoginUi resolves @@ -74,6 +78,7 @@ var logger = host.Services.GetRequiredService().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 diff --git a/hosts/Volcanic.AgenticCore.Telegram/Speech/ISpeechToText.cs b/hosts/Volcanic.AgenticCore.Telegram/Speech/ISpeechToText.cs new file mode 100644 index 0000000..8439e70 --- /dev/null +++ b/hosts/Volcanic.AgenticCore.Telegram/Speech/ISpeechToText.cs @@ -0,0 +1,11 @@ +namespace Volcanic.AgenticCore.Telegram.Speech; + +/// Turns a voice note or audio file into text before a prompt exists. The core only ever sees the text. +public interface ISpeechToText +{ + /// The trimmed transcript; throws when the service fails. + Task TranscribeAsync(ReadOnlyMemory audio, string fileName, string mimeType, CancellationToken cancellationToken); +} + +/// A transcription failed; the message is fit for the user and never contains the API key. +public sealed class SpeechToTextException(string message, Exception? inner = null) : Exception(message, inner); diff --git a/hosts/Volcanic.AgenticCore.Telegram/Speech/OpenAiSpeechToText.cs b/hosts/Volcanic.AgenticCore.Telegram/Speech/OpenAiSpeechToText.cs new file mode 100644 index 0000000..9883310 --- /dev/null +++ b/hosts/Volcanic.AgenticCore.Telegram/Speech/OpenAiSpeechToText.cs @@ -0,0 +1,49 @@ +using System.Net.Http.Headers; +using System.Text.Json; + +namespace Volcanic.AgenticCore.Telegram.Speech; + +/// OpenAI's /audio/transcriptions (port of VolcanicSecretary's stt/openai.ts). +public sealed class OpenAiSpeechToText(HttpClient http, SpeechOptions options) : ISpeechToText +{ + private const string Prefix = "OpenAI transcription failed: "; + private const int MaxExcerpt = 200; + + public async Task TranscribeAsync(ReadOnlyMemory 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); + using var response = await http.SendAsync(request, cancellationToken).ConfigureAwait(false); + var body = await response.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false); + 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); +} diff --git a/hosts/Volcanic.AgenticCore.Telegram/Speech/SpeechOptions.cs b/hosts/Volcanic.AgenticCore.Telegram/Speech/SpeechOptions.cs new file mode 100644 index 0000000..7edd194 --- /dev/null +++ b/hosts/Volcanic.AgenticCore.Telegram/Speech/SpeechOptions.cs @@ -0,0 +1,27 @@ +namespace Volcanic.AgenticCore.Telegram.Speech; + +/// The Telegram:Speech section. Speech to text is on exactly when is set. +public sealed class SpeechOptions +{ + public const string DefaultModel = "gpt-4o-transcribe"; + public const string DefaultBaseUrl = "https://api.openai.com/v1"; + + /// Falls back to OPENAI_API_KEY. Empty turns speech to text off. + public string ApiKey { get; set; } = ""; + + /// Falls back to STT_MODEL, then . + public string Model { get; set; } = ""; + + /// Any OpenAI-compatible transcription endpoint. + public string BaseUrl { get; set; } = DefaultBaseUrl; + + /// Optional ISO-639-1 hint. + public string? Language { get; set; } + + public bool IsEnabled => !string.IsNullOrWhiteSpace(ApiKey); + + public string ResolvedModel => string.IsNullOrWhiteSpace(Model) ? DefaultModel : Model.Trim(); + + /// The startup log line (never the key). + public string Describe() => IsEnabled ? $"Speech to text: {ResolvedModel}" : "Speech to text: off (no OPENAI_API_KEY)"; +} diff --git a/hosts/Volcanic.AgenticCore.Telegram/TelegramOptions.cs b/hosts/Volcanic.AgenticCore.Telegram/TelegramOptions.cs index ad7b6e9..5b39b6d 100644 --- a/hosts/Volcanic.AgenticCore.Telegram/TelegramOptions.cs +++ b/hosts/Volcanic.AgenticCore.Telegram/TelegramOptions.cs @@ -1,3 +1,5 @@ +using Volcanic.AgenticCore.Telegram.Speech; + namespace Volcanic.AgenticCore.Telegram; /// The Telegram configuration section. @@ -36,6 +38,12 @@ public sealed class TelegramOptions public TimeSpan ReconnectMaxDelay { get; set; } = TimeSpan.FromSeconds(60); + /// Image documents up to this size are also shown to the model inline (base64 stays under providers' 5 MB cap). + public long InlineImageMaxBytes { get; set; } = 3_750_000; + + /// Speech to text for voice notes and audio (Telegram:Speech). + public SpeechOptions Speech { get; set; } = new(); + /// Reads the environment-variable shortcuts the README documents, when the section leaves them empty. public void ApplyEnvironment(Func 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(); } /// Configuration problems that must stop the host, empty when valid. @@ -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; } diff --git a/hosts/Volcanic.AgenticCore.Telegram/TelegramProcessEnvironment.cs b/hosts/Volcanic.AgenticCore.Telegram/TelegramProcessEnvironment.cs index 5f2f0c1..39cad52 100644 --- a/hosts/Volcanic.AgenticCore.Telegram/TelegramProcessEnvironment.cs +++ b/hosts/Volcanic.AgenticCore.Telegram/TelegramProcessEnvironment.cs @@ -8,7 +8,7 @@ public static class TelegramProcessEnvironment { public const string AllowVariable = "PROCESS_ENV_ALLOW"; - /// Adds the section's lists, PROCESS_ENV_ALLOW, the bot token and the Google client secret to . + /// Adds the section's lists, PROCESS_ENV_ALLOW, the bot token, the speech API key and the Google client secret to . public static void Configure(ProcessEnvironmentOptions target, TelegramOptions telegram, ProcessEnvironmentSection section, Func 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); } diff --git a/hosts/Volcanic.AgenticCore.Telegram/Volcanic.AgenticCore.Telegram.csproj b/hosts/Volcanic.AgenticCore.Telegram/Volcanic.AgenticCore.Telegram.csproj index 5bf1fae..ff3d67d 100644 --- a/hosts/Volcanic.AgenticCore.Telegram/Volcanic.AgenticCore.Telegram.csproj +++ b/hosts/Volcanic.AgenticCore.Telegram/Volcanic.AgenticCore.Telegram.csproj @@ -9,6 +9,7 @@ + diff --git a/tests/Volcanic.AgenticCore.Telegram.Tests/FormattingAndOptionsTests.cs b/tests/Volcanic.AgenticCore.Telegram.Tests/FormattingAndOptionsTests.cs index 14dcd1d..6c538c7 100644 --- a/tests/Volcanic.AgenticCore.Telegram.Tests/FormattingAndOptionsTests.cs +++ b/tests/Volcanic.AgenticCore.Telegram.Tests/FormattingAndOptionsTests.cs @@ -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()); + } } diff --git a/tests/Volcanic.AgenticCore.Telegram.Tests/OpenAiSpeechToTextTests.cs b/tests/Volcanic.AgenticCore.Telegram.Tests/OpenAiSpeechToTextTests.cs new file mode 100644 index 0000000..9102634 --- /dev/null +++ b/tests/Volcanic.AgenticCore.Telegram.Tests/OpenAiSpeechToTextTests.cs @@ -0,0 +1,95 @@ +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 Parts { get; } = []; + + protected override async Task 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(() => 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, "gateway"); + var ex = await Assert.ThrowsAsync(() => 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(() => stt.TranscribeAsync(new byte[] { 1 }, "voice.ogg", "audio/ogg", CancellationToken.None)); + Assert.Equal("OpenAI transcription failed: the response has no text.", ex.Message); + } +} diff --git a/tests/Volcanic.AgenticCore.Telegram.Tests/ProcessEnvironmentTests.cs b/tests/Volcanic.AgenticCore.Telegram.Tests/ProcessEnvironmentTests.cs index d3d66a3..11bdc52 100644 --- a/tests/Volcanic.AgenticCore.Telegram.Tests/ProcessEnvironmentTests.cs +++ b/tests/Volcanic.AgenticCore.Telegram.Tests/ProcessEnvironmentTests.cs @@ -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() { From a0cd2975b826a6daa97c3fca40bc9219be892ed7 Mon Sep 17 00:00:00 2001 From: EugeneTes Date: Sat, 26 Sep 2026 22:09:16 +0200 Subject: [PATCH 08/11] feat(telegram): save documents, videos and audio to the chat inbox Content step 2 (InboundMedia.AddFilesAsync, InboxFiles): files land in /inbox/-[-N], image documents within Telegram:InlineImageMaxBytes are attached inline, oversize files and failed downloads get a notice and no run. Documents, videos, audio and voice are accepted; the unsupported reply is now 'I can read text, photos, files and voice messages.' Spec: Updates helpers are named DocumentMessage/VideoMessage/VoiceMessage/AudioMessage; the getFile 'file is too big' notice reads 'over 20 MB'; the too-large inline note is a parenthesised suffix after the size; size formats spelled out. --- .../2026-09-26-telegram-attachments-design.md | 11 +- .../Inbound/InboundComposer.cs | 20 ++- .../Inbound/InboundMedia.cs | 101 +++++++++++ .../Inbound/InboxFiles.cs | 65 +++++++ .../Volcanic.AgenticCore.Telegram/Program.cs | 1 + .../AttachmentChatTests.cs | 169 ++++++++++++++++++ .../InboundComposerTests.cs | 11 +- .../InboxFilesTests.cs | 51 ++++++ .../MessageContextChatTests.cs | 4 +- .../Support/ChatHost.cs | 11 +- .../Support/FakeBotClient.cs | 24 ++- .../Support/Updates.cs | 38 ++++ 12 files changed, 483 insertions(+), 23 deletions(-) create mode 100644 hosts/Volcanic.AgenticCore.Telegram/Inbound/InboundMedia.cs create mode 100644 hosts/Volcanic.AgenticCore.Telegram/Inbound/InboxFiles.cs create mode 100644 tests/Volcanic.AgenticCore.Telegram.Tests/AttachmentChatTests.cs create mode 100644 tests/Volcanic.AgenticCore.Telegram.Tests/InboxFilesTests.cs diff --git a/docs/superpowers/specs/2026-09-26-telegram-attachments-design.md b/docs/superpowers/specs/2026-09-26-telegram-attachments-design.md index 21ed7b9..677675f 100644 --- a/docs/superpowers/specs/2026-09-26-telegram-attachments-design.md +++ b/docs/superpowers/specs/2026-09-26-telegram-attachments-design.md @@ -205,7 +205,8 @@ Router changes (`UpdateRouter.OnMessageAsync`), on top of the message-context sp 1. `FileSize > 20 MB` (`TelegramLimits.MaxDownloadBytes`) β†’ notice `That file is 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: `/inbox/-<-N>`. `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 @@ -215,10 +216,11 @@ 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)`. + 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`): @@ -355,7 +357,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`). diff --git a/hosts/Volcanic.AgenticCore.Telegram/Inbound/InboundComposer.cs b/hosts/Volcanic.AgenticCore.Telegram/Inbound/InboundComposer.cs index ea3563a..5ad7c98 100644 --- a/hosts/Volcanic.AgenticCore.Telegram/Inbound/InboundComposer.cs +++ b/hosts/Volcanic.AgenticCore.Telegram/Inbound/InboundComposer.cs @@ -14,23 +14,27 @@ internal sealed class InboundDraft public List Images { get; } = []; /// A step refused the message (and sent its own notice): nothing is prompted. public bool Refused { get; set; } + /// Where step 2 saved the message's file, for step 3 (audio). + public string? SavedPath { get; set; } } /// /// Turns one Telegram message into an : 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 (), step 3 voice and +/// audio transcription. Any slow work for a message happens here, while the router holds that message's /// . A step never talks to the itself. /// -public sealed class InboundComposer(TelegramSender sender, ILogger logger) +public sealed class InboundComposer(TelegramSender sender, InboundMedia media, ILogger logger) { - /// The reply to a message kind the bot can't read (no lease is taken). - public const string UnsupportedReply = "I can read text and photos."; + /// The reply to a message kind the bot can't read (stickers, video notes, polls…; no lease is taken). + public const string UnsupportedReply = "I can read text, photos, files and voice messages."; /// Kinds the content steps can read. Checked by the router before it takes a lease. 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 }; } /// A message with an attachment is never a command or a pending answer; its caption is prompt text. @@ -51,8 +55,8 @@ public sealed class InboundComposer(TelegramSender sender, ILogger 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) diff --git a/hosts/Volcanic.AgenticCore.Telegram/Inbound/InboundMedia.cs b/hosts/Volcanic.AgenticCore.Telegram/Inbound/InboundMedia.cs new file mode 100644 index 0000000..b18c278 --- /dev/null +++ b/hosts/Volcanic.AgenticCore.Telegram/Inbound/InboundMedia.cs @@ -0,0 +1,101 @@ +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 ImageContent = Volcanic.AgenticCore.Abstractions.Messages.ImageContent; + +namespace Volcanic.AgenticCore.Telegram.Inbound; + +/// +/// Content steps for attachments (spec: telegram-attachments Β§4): step 2 saves documents, videos and audio to the +/// workspace inbox. Notices go through the chat renderer () so they stay in order. +/// +public sealed class InboundMedia(TelegramSender sender, TelegramOptions options, TimeProvider time, ILogger logger) +{ + /// Sent when a file's download fails (the partial file is deleted and the message isn't prompted). + public const string DownloadFailedNotice = "I could not download that file."; + + /// Suffix of a [File received: …] line for an image too large to attach inline. + public const string TooLargeInlineNote = " (too large to show inline; downscale it with bash if you need to see it)"; + + private static readonly HashSet InlineImageTypes = new(StringComparer.OrdinalIgnoreCase) { "image/jpeg", "image/png", "image/webp", "image/gif" }; + + /// The notice for a file over the 20 MB download limit; over 20 MB when the size is unknown. + 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."; + + /// Download a file by id into (shared with step 1's photos). + internal Task DownloadAsync(string fileId, Stream destination, CancellationToken cancellationToken) => + sender.Bot.GetInfoAndDownloadFile(fileId, destination, cancellationToken); + + /// Telegram's getFile refusal for a file over the download limit. + internal static bool IsTooBig(ApiRequestException ex) => ex.Message.Contains("file is too big", StringComparison.OrdinalIgnoreCase); + + /// Step 2: a document, video or audio file goes to the inbox and gets a [File received: …] line. + 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) + { + 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) + { + var data = await File.ReadAllBytesAsync(path, cancellationToken).ConfigureAwait(false); + draft.Images.Add(new ImageContent(Convert.ToBase64String(data), mime.ToLowerInvariant())); + } + else + { + note = TooLargeInlineNote; + } + } + draft.Body.Add($"[File received: {InboxFiles.Relative(workspace, path)} ({mime}, {TelegramLimits.FormatSize(bytes)}){note}]"); + } + + 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); + } + } +} diff --git a/hosts/Volcanic.AgenticCore.Telegram/Inbound/InboxFiles.cs b/hosts/Volcanic.AgenticCore.Telegram/Inbound/InboxFiles.cs new file mode 100644 index 0000000..c5b524b --- /dev/null +++ b/hosts/Volcanic.AgenticCore.Telegram/Inbound/InboxFiles.cs @@ -0,0 +1,65 @@ +using System.Globalization; +using System.Text; + +namespace Volcanic.AgenticCore.Telegram.Inbound; + +/// Where received files go: <workspace>/inbox/<yyyy-MM-dd>-<safe name>, never overwriting. +public static class InboxFiles +{ + /// The inbox directory under the chat's workspace. + public const string DirectoryName = "inbox"; + + /// Longest stem kept from a received file name, in characters. + public const int MaxStemLength = 80; + + /// Longest extension kept, dot included. + public const int MaxExtensionLength = 16; + + /// + /// Drops any directory part, replaces everything but Unicode letters, digits, ., - and _ with + /// _, trims leading dots and underscores, caps stem (80) and extension (16, dot included); file when empty. + /// + 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; + } + + /// Creates the next free inbox/<date>-<stem>[-N]<ext> with . + 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 + } + } + } + + /// Workspace-relative with /, e.g. inbox/2026-09-26-report.pdf. + public static string Relative(string workspace, string path) => Path.GetRelativePath(workspace, path).Replace('\\', '/'); + + /// At most characters, never splitting a surrogate pair. + private static string Cap(string text, int max) => + text.Length <= max ? text : text[..(char.IsHighSurrogate(text[max - 1]) ? max - 1 : max)]; +} diff --git a/hosts/Volcanic.AgenticCore.Telegram/Program.cs b/hosts/Volcanic.AgenticCore.Telegram/Program.cs index 35726c2..58eeda5 100644 --- a/hosts/Volcanic.AgenticCore.Telegram/Program.cs +++ b/hosts/Volcanic.AgenticCore.Telegram/Program.cs @@ -65,6 +65,7 @@ builder.Services.AddSingleton(); builder.Services.TryAddSingleton(TimeProvider.System); builder.Services.AddSingleton(); builder.Services.AddSingleton(); +builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddSingleton(); diff --git a/tests/Volcanic.AgenticCore.Telegram.Tests/AttachmentChatTests.cs b/tests/Volcanic.AgenticCore.Telegram.Tests/AttachmentChatTests.cs new file mode 100644 index 0000000..1f5d829 --- /dev/null +++ b/tests/Volcanic.AgenticCore.Telegram.Tests/AttachmentChatTests.cs @@ -0,0 +1,169 @@ +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; + +/// Documents and videos end to end: saved to the chat's inbox and prompted, or refused with a notice. +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()); + 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"] = [9, 8, 7]; + host.Faux.Enqueue(FauxTurn.Say("A diagram.")); + + await RouteAndFlushAsync(host, time, Updates.DocumentMessage("diagram.png", "image/png", 3)); + + var image = Assert.Single(Prompt(host).Content.OfType()); + Assert.Equal(("image/png", Convert.ToBase64String([9, 8, 7])), (image.MimeType, image.Data)); + Assert.EndsWith("[File received: inbox/2026-09-26-diagram.png (image/png, 3 bytes)]", 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()); + 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); + } +} diff --git a/tests/Volcanic.AgenticCore.Telegram.Tests/InboundComposerTests.cs b/tests/Volcanic.AgenticCore.Telegram.Tests/InboundComposerTests.cs index 9b28b8b..312cd43 100644 --- a/tests/Volcanic.AgenticCore.Telegram.Tests/InboundComposerTests.cs +++ b/tests/Volcanic.AgenticCore.Telegram.Tests/InboundComposerTests.cs @@ -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.Instance), NullLogger.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!)); diff --git a/tests/Volcanic.AgenticCore.Telegram.Tests/InboxFilesTests.cs b/tests/Volcanic.AgenticCore.Telegram.Tests/InboxFilesTests.cs new file mode 100644 index 0000000..85c528c --- /dev/null +++ b/tests/Volcanic.AgenticCore.Telegram.Tests/InboxFilesTests.cs @@ -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(); + 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)); +} diff --git a/tests/Volcanic.AgenticCore.Telegram.Tests/MessageContextChatTests.cs b/tests/Volcanic.AgenticCore.Telegram.Tests/MessageContextChatTests.cs index 90ffcd7..0a87dbe 100644 --- a/tests/Volcanic.AgenticCore.Telegram.Tests/MessageContextChatTests.cs +++ b/tests/Volcanic.AgenticCore.Telegram.Tests/MessageContextChatTests.cs @@ -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); } diff --git a/tests/Volcanic.AgenticCore.Telegram.Tests/Support/ChatHost.cs b/tests/Volcanic.AgenticCore.Telegram.Tests/Support/ChatHost.cs index 8810deb..a87b21a 100644 --- a/tests/Volcanic.AgenticCore.Telegram.Tests/Support/ChatHost.cs +++ b/tests/Volcanic.AgenticCore.Telegram.Tests/Support/ChatHost.cs @@ -59,10 +59,17 @@ public sealed class ChatHost : IAsyncDisposable ILoggerFactory loggers = NullLoggerFactory.Instance; var sender = new TelegramSender(Bot, NullLogger.Instance); Chats = new ChatRegistry(_services.GetRequiredService(), sender, Options, loggers, time, calendar); - Router = new UpdateRouter(Options, Chats, new ChatCommands(sender), sender, - new InboundComposer(sender, NullLogger.Instance), NullLogger.Instance); + var media = new InboundMedia(sender, Options, time ?? TimeProvider.System, NullLogger.Instance); + Composer = new InboundComposer(sender, media, NullLogger.Instance); + Router = new UpdateRouter(Options, Chats, new ChatCommands(sender), sender, Composer, NullLogger.Instance); } + /// The composer the router uses (content steps 1–3). + public InboundComposer Composer { get; } + + /// The chat's workspace directory (its session's cwd). + 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); diff --git a/tests/Volcanic.AgenticCore.Telegram.Tests/Support/FakeBotClient.cs b/tests/Volcanic.AgenticCore.Telegram.Tests/Support/FakeBotClient.cs index 80dd0bf..076d191 100644 --- a/tests/Volcanic.AgenticCore.Telegram.Tests/Support/FakeBotClient.cs +++ b/tests/Volcanic.AgenticCore.Telegram.Tests/Support/FakeBotClient.cs @@ -28,6 +28,12 @@ public sealed class FakeBotClient : ITelegramBotClient public event AsyncEventHandler? OnMakingApiRequest; public event AsyncEventHandler? OnApiResponseReceived; + /// Downloadable files by file id: getFile answers with their size and DownloadFile writes their bytes. + public ConcurrentDictionary Files { get; } = new(); + + /// When set, a download of a scripted file writes one byte and then fails with a network error. + public bool FailDownloads { get; set; } + /// Called for every non-poll request before it is answered; throw to simulate a failure. public Action? OnSend { get; set; } @@ -115,6 +121,9 @@ 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; @@ -132,6 +141,17 @@ public sealed class FakeBotClient : ITelegramBotClient } public Task 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); } diff --git a/tests/Volcanic.AgenticCore.Telegram.Tests/Support/Updates.cs b/tests/Volcanic.AgenticCore.Telegram.Tests/Support/Updates.cs index 336a078..aa9547d 100644 --- a/tests/Volcanic.AgenticCore.Telegram.Tests/Support/Updates.cs +++ b/tests/Volcanic.AgenticCore.Telegram.Tests/Support/Updates.cs @@ -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. + + /// A document (file id , scripted in ). + 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); + } + + /// A video (MIME video/mp4). + 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); + } + + /// A voice note (MIME audio/ogg). + 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); + } + + /// An audio file. + 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); + } + /// A channel post origin, optionally signed by its author. public static MessageOriginChannel Channel(string title, string? signature = null) => new() { From f90731baf00bc5b23dc1e6b097ff2b96d890db30 Mon Sep 17 00:00:00 2001 From: EugeneTes Date: Sat, 26 Sep 2026 22:13:10 +0200 Subject: [PATCH 09/11] feat(telegram): transcribe voice notes and audio with an echo MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Content step 3 (InboundMedia.AddSpeechAsync): voice and audio go through ISpeechToText, the transcript is echoed as '🎀 …' and prompted as '[Voice message] …'. Spec Β§4 edited: audio is transcribed from the file step 2 saved (one download); an oversize or undownloadable voice note sends its notice and isn't prompted. --- .../2026-09-26-telegram-attachments-design.md | 9 +- .../Inbound/InboundComposer.cs | 2 +- .../Inbound/InboundMedia.cs | 81 ++++++- .../Support/ChatHost.cs | 5 +- .../Support/FakeSpeechToText.cs | 22 ++ .../VoiceChatTests.cs | 223 ++++++++++++++++++ 6 files changed, 334 insertions(+), 8 deletions(-) create mode 100644 tests/Volcanic.AgenticCore.Telegram.Tests/Support/FakeSpeechToText.cs create mode 100644 tests/Volcanic.AgenticCore.Telegram.Tests/VoiceChatTests.cs diff --git a/docs/superpowers/specs/2026-09-26-telegram-attachments-design.md b/docs/superpowers/specs/2026-09-26-telegram-attachments-design.md index 677675f..091c3fd 100644 --- a/docs/superpowers/specs/2026-09-26-telegram-attachments-design.md +++ b/docs/superpowers/specs/2026-09-26-telegram-attachments-design.md @@ -227,9 +227,12 @@ Router changes (`UpdateRouter.OnMessageAsync`), on top of the message-context sp 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`). Audio keeps `FileName ?? "audio"` and its + `MimeType`. 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: `. 5. Echo `🎀 ` as plain text, cut to 4096 characters with `…`. diff --git a/hosts/Volcanic.AgenticCore.Telegram/Inbound/InboundComposer.cs b/hosts/Volcanic.AgenticCore.Telegram/Inbound/InboundComposer.cs index 5ad7c98..ad944b5 100644 --- a/hosts/Volcanic.AgenticCore.Telegram/Inbound/InboundComposer.cs +++ b/hosts/Volcanic.AgenticCore.Telegram/Inbound/InboundComposer.cs @@ -56,7 +56,7 @@ public sealed class InboundComposer(TelegramSender sender, InboundMedia media, I // 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 if (!draft.Refused) await media.AddFilesAsync(chat, message, draft, cancellationToken).ConfigureAwait(false); // step 2 - // Step 3 (voice and audio transcription) goes here. + 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( diff --git a/hosts/Volcanic.AgenticCore.Telegram/Inbound/InboundMedia.cs b/hosts/Volcanic.AgenticCore.Telegram/Inbound/InboundMedia.cs index b18c278..0ad0d1b 100644 --- a/hosts/Volcanic.AgenticCore.Telegram/Inbound/InboundMedia.cs +++ b/hosts/Volcanic.AgenticCore.Telegram/Inbound/InboundMedia.cs @@ -4,16 +4,25 @@ 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; /// /// Content steps for attachments (spec: telegram-attachments Β§4): step 2 saves documents, videos and audio to the -/// workspace inbox. Notices go through the chat renderer () so they stay in order. +/// workspace inbox, step 3 transcribes voice notes and audio through (null: speech is off). +/// Notices and the echo go through the chat renderer () so they stay in order. /// -public sealed class InboundMedia(TelegramSender sender, TelegramOptions options, TimeProvider time, ILogger logger) +public sealed class InboundMedia(TelegramSender sender, TelegramOptions options, TimeProvider time, ILogger logger, + ISpeechToText? speech = null) { + /// Sent for a voice note or audio file when no speech-to-text service is configured. + public const string SpeechNotConfiguredNotice = "Voice messages need speech-to-text: set OPENAI_API_KEY (or Telegram:Speech:ApiKey) on the bot host."; + + /// Sent when the transcript is empty. + public const string NoWordsNotice = "I couldn't make out any words in that voice message."; + /// Sent when a file's download fails (the partial file is deleted and the message isn't prompted). public const string DownloadFailedNotice = "I could not download that file."; @@ -90,6 +99,74 @@ public sealed class InboundMedia(TelegramSender sender, TelegramOptions options, draft.Body.Add($"[File received: {InboxFiles.Relative(workspace, path)} ({mime}, {TelegramLimits.FormatSize(bytes)}){note}]"); } + /// + /// Step 3: a voice note or audio file is transcribed, echoed as 🎀 … and added as [Voice message] …. + /// 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. + /// + 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, a.FileName ?? "audio", 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 (ex is not OperationCanceledException) + { + 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 (ex is not OperationCanceledException) + { + 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}"); + } + private void TryDelete(string path) { try { File.Delete(path); } diff --git a/tests/Volcanic.AgenticCore.Telegram.Tests/Support/ChatHost.cs b/tests/Volcanic.AgenticCore.Telegram.Tests/Support/ChatHost.cs index a87b21a..a40c7a2 100644 --- a/tests/Volcanic.AgenticCore.Telegram.Tests/Support/ChatHost.cs +++ b/tests/Volcanic.AgenticCore.Telegram.Tests/Support/ChatHost.cs @@ -29,7 +29,8 @@ public sealed class ChatHost : IAsyncDisposable private readonly string _root; public ChatHost(Action? configure = null, Action? 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,7 +60,7 @@ public sealed class ChatHost : IAsyncDisposable ILoggerFactory loggers = NullLoggerFactory.Instance; var sender = new TelegramSender(Bot, NullLogger.Instance); Chats = new ChatRegistry(_services.GetRequiredService(), sender, Options, loggers, time, calendar); - var media = new InboundMedia(sender, Options, time ?? TimeProvider.System, NullLogger.Instance); + var media = new InboundMedia(sender, Options, time ?? TimeProvider.System, NullLogger.Instance, speech); Composer = new InboundComposer(sender, media, NullLogger.Instance); Router = new UpdateRouter(Options, Chats, new ChatCommands(sender), sender, Composer, NullLogger.Instance); } diff --git a/tests/Volcanic.AgenticCore.Telegram.Tests/Support/FakeSpeechToText.cs b/tests/Volcanic.AgenticCore.Telegram.Tests/Support/FakeSpeechToText.cs new file mode 100644 index 0000000..ab81bb0 --- /dev/null +++ b/tests/Volcanic.AgenticCore.Telegram.Tests/Support/FakeSpeechToText.cs @@ -0,0 +1,22 @@ +using Volcanic.AgenticCore.Telegram.Speech; + +namespace Volcanic.AgenticCore.Telegram.Tests.Support; + +/// Scripted transcripts. holds a call until released; makes it throw. +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 TranscribeAsync(ReadOnlyMemory 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; + } +} diff --git a/tests/Volcanic.AgenticCore.Telegram.Tests/VoiceChatTests.cs b/tests/Volcanic.AgenticCore.Telegram.Tests/VoiceChatTests.cs new file mode 100644 index 0000000..5ff7cbd --- /dev/null +++ b/tests/Volcanic.AgenticCore.Telegram.Tests/VoiceChatTests.cs @@ -0,0 +1,223 @@ +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()); + } + + [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 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()); + 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(("talk.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()); // downloaded once, transcribed from the saved file + } + + [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 stt = new FakeSpeechToText("first the voice") { Delay = TimeSpan.FromMilliseconds(300) }; // well past the 20 ms window + 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.SendAsync("then the text"); + 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))); + } +} From c5ecd017a0d54dfc360bfd849e9de51f7437369c Mon Sep 17 00:00:00 2001 From: EugeneTes Date: Sat, 26 Sep 2026 22:16:29 +0200 Subject: [PATCH 10/11] docs(telegram): attachments, voice, send and IChatChannel telegram-bot.md: what you can send, files from the agent, speech settings, security notes. ui-integration.md: document/voice input rows and a new section 5 on IChatChannel. architecture, extensions and CLAUDE.md list IChatChannel. Drop the interim paragraph from the message-context spec; mark the attachments spec implemented with resolved questions. --- CLAUDE.md | 9 ++-- docs/architecture.md | 5 ++- docs/extensions.md | 3 +- .../2026-09-26-telegram-attachments-design.md | 13 +++--- ...6-09-26-telegram-message-context-design.md | 5 +-- docs/telegram-bot.md | 37 +++++++++++++-- docs/ui-integration.md | 45 +++++++++++++++++-- 7 files changed, 95 insertions(+), 22 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index e2ef9e2..c898718 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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`, `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 `/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`. diff --git a/docs/architecture.md b/docs/architecture.md index 2b8ceb5..79d0d8c 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -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 | diff --git a/docs/extensions.md b/docs/extensions.md index d575c0d..34345cb 100644 --- a/docs/extensions.md +++ b/docs/extensions.md @@ -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`. diff --git a/docs/superpowers/specs/2026-09-26-telegram-attachments-design.md b/docs/superpowers/specs/2026-09-26-telegram-attachments-design.md index 091c3fd..b3f49be 100644 --- a/docs/superpowers/specs/2026-09-26-telegram-attachments-design.md +++ b/docs/superpowers/specs/2026-09-26-telegram-attachments-design.md @@ -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`). @@ -404,9 +404,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. diff --git a/docs/superpowers/specs/2026-09-26-telegram-message-context-design.md b/docs/superpowers/specs/2026-09-26-telegram-message-context-design.md index 9112314..56c2a3f 100644 --- a/docs/superpowers/specs/2026-09-26-telegram-message-context-design.md +++ b/docs/superpowers/specs/2026-09-26-telegram-message-context-design.md @@ -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. diff --git a/docs/telegram-bot.md b/docs/telegram-bot.md index d91221c..8e6caf1 100644 --- a/docs/telegram-bot.md +++ b/docs/telegram-bot.md @@ -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/-` 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 reaches it as a path + only. +- **Voice messages and audio files** are transcribed with OpenAI (`gpt-4o-transcribe` by default). The bot echoes + `🎀 ` and the agent gets `[Voice message] ` (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 diff --git a/docs/ui-integration.md b/docs/ui-integration.md index 70f8419..1a34e94 100644 --- a/docs/ui-integration.md +++ b/docs/ui-integration.md @@ -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 ` | `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 SendTextAsync(string markdown, CancellationToken cancellationToken); + Task 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. From 6ab98a8155fd4ae3ed004d1bbf1bd82b80abd9e4 Mon Sep 17 00:00:00 2001 From: EugeneTes Date: Sat, 26 Sep 2026 22:35:20 +0200 Subject: [PATCH 11/11] fix(telegram): address review findings - A speech or download timeout (TaskCanceledException nobody asked for) is a failure with the user notice, not a silent shutdown; OpenAiSpeechToText maps it to "the request timed out."; UpdateRouter only treats the caller's cancellation as shutdown. - send: WorkspacePaths resolves the real path component by component (realpath), closing the directory-link-inside-target bypass; `.` reports a directory. - Image documents are attached inline only when their magic bytes are JPEG/PNG/GIF/WebP, under the detected type. - Audio goes to STT as an ASCII audio. name. - MCP Browser section mentions send only when it is active. - RunInOrderAsync takes a cancellation token; queued work is skipped once disposal gives up; SendReplyAsync stays private behind ReplyInOrderAsync. - Speech HttpClient handler recycles pooled connections; BuiltInTools.Send(); IExtensionHost.Channel is abstract; WorkspacePaths in its own file. - Tests: timeouts, symlink chains (Linux), sniffing, channel delivery failures, queued-work cancellation, gated voice ordering. Spec and docs updated. --- docs/mcp.md | 2 +- ...2026-09-26-mcp-client-playwright-design.md | 5 +- .../2026-09-26-telegram-attachments-design.md | 62 ++++++++---- docs/telegram-bot.md | 4 +- .../McpExtension.cs | 2 +- .../Chats/TelegramChatChannel.cs | 13 +-- .../Chats/UpdateRouter.cs | 2 +- .../Inbound/InboundComposer.cs | 2 +- .../Inbound/InboundMedia.cs | 47 ++++++++- .../Volcanic.AgenticCore.Telegram/Program.cs | 6 +- .../Rendering/ChatRenderer.cs | 44 +++++++-- .../Speech/OpenAiSpeechToText.cs | 25 ++++- .../Features/Agent/AgentSessionFactory.cs | 3 +- .../Features/Extensions/IExtensionHost.cs | 2 +- .../Features/Tools/BuiltIn/SendTool.cs | 33 ------- .../Features/Tools/BuiltIn/WorkspacePaths.cs | 98 +++++++++++++++++++ .../Features/Tools/BuiltInTools.cs | 3 + .../Mcp/McpExtensionTests.cs | 13 +++ .../AttachmentChatTests.cs | 39 +++++++- .../ChatRendererOrderTests.cs | 51 ++++++++++ .../OpenAiSpeechToTextTests.cs | 26 +++++ .../TelegramChatChannelTests.cs | 46 +++++++++ .../VoiceChatTests.cs | 56 ++++++++++- .../Features/SendToolTests.cs | 44 +++++++++ .../Support/StubExtensionHost.cs | 1 + 25 files changed, 536 insertions(+), 93 deletions(-) create mode 100644 src/Volcanic.AgenticCore/Features/Tools/BuiltIn/WorkspacePaths.cs diff --git a/docs/mcp.md b/docs/mcp.md index be7f9a2..8eb2d32 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -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 diff --git a/docs/superpowers/specs/2026-09-26-mcp-client-playwright-design.md b/docs/superpowers/specs/2026-09-26-mcp-client-playwright-design.md index 50e8ec3..df6e79f 100644 --- a/docs/superpowers/specs/2026-09-26-mcp-client-playwright-design.md +++ b/docs/superpowers/specs/2026-09-26-mcp-client-playwright-design.md @@ -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/.png`, PDFs, downloads) are under browser/ in the workspace." diff --git a/docs/superpowers/specs/2026-09-26-telegram-attachments-design.md b/docs/superpowers/specs/2026-09-26-telegram-attachments-design.md index b3f49be..d87e155 100644 --- a/docs/superpowers/specs/2026-09-26-telegram-attachments-design.md +++ b/docs/superpowers/specs/2026-09-26-telegram-attachments-design.md @@ -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: `, `file not found: ` and `path is a directory, not a file: `. +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: `, + `file not found: ` and `path is a directory, not a file: ` (also for `.`, the workspace itself). 3. Caption longer than `Limits.MaxCaptionLength` β†’ `caption is characters; the limit is . 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,13 +155,16 @@ 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 RunInOrderAsync(Func> 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 RunInOrderAsync(Func> 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). A work item still queued when the renderer is disposed is cancelled. `ChatRenderer.SayAsync(text)` - (a `RunInOrderAsync` around `TelegramSender.SendTextAsync`) sends plain-text notices and the `🎀` echo. The reply path - (`ChatRenderer.SendReplyAsync`) returns the first message id and whether every chunk was delivered. + 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`, 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 @@ -218,7 +225,12 @@ Router changes (`UpdateRouter.OnMessageAsync`), on top of the message-context sp `[File received: inbox/2026-09-26-report.pdf (application/pdf, 1.2 MB)]`. The path is relative to the workspace 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 + (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)]`. @@ -231,10 +243,15 @@ Router changes (`UpdateRouter.OnMessageAsync`), on top of the message-context sp 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`). Audio keeps `FileName ?? "audio"` and its - `MimeType`. + 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.`: 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: `. + 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 `🎀 ` as plain text, cut to 4096 characters with `…`. 6. Prompt text: the caption (if any), then `[Voice message] `. 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 @@ -261,7 +278,13 @@ public interface ISpeechToText as given), `model`, `response_format=json`, and `language` when configured. - Non-2xx β†’ `SpeechToTextException("OpenAI transcription failed: HTTP ")`. A non-JSON body or a missing `text` gives its own message. The key never appears in messages or logs. +- 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()` (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: @@ -274,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: ` or `Speech to text: off (no OPENAI_API_KEY)`. `OPENAI_API_KEY` isn't on the Claude Code provider's denied-variable @@ -304,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 | @@ -341,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 -> `), a link whose target has a `..` + after a directory link (`l -> x/../secret`, `x -> /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. @@ -374,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 @@ -389,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 diff --git a/docs/telegram-bot.md b/docs/telegram-bot.md index 8e6caf1..64bac0a 100644 --- a/docs/telegram-bot.md +++ b/docs/telegram-bot.md @@ -119,8 +119,8 @@ question in that chat (for example from an extension) replaces the pending promp - **Documents, videos and audio files** are saved to `inbox/-` 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 reaches it as a path - only. + 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 `🎀 ` and the agent gets `[Voice message] ` (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`). diff --git a/extensions/Volcanic.AgenticCore.Extensions.Mcp/McpExtension.cs b/extensions/Volcanic.AgenticCore.Extensions.Mcp/McpExtension.cs index 6207dd2..75e202a 100644 --- a/extensions/Volcanic.AgenticCore.Extensions.Mcp/McpExtension.cs +++ b/extensions/Volcanic.AgenticCore.Extensions.Mcp/McpExtension.cs @@ -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; diff --git a/hosts/Volcanic.AgenticCore.Telegram/Chats/TelegramChatChannel.cs b/hosts/Volcanic.AgenticCore.Telegram/Chats/TelegramChatChannel.cs index 5f91b32..c1ab6a7 100644 --- a/hosts/Volcanic.AgenticCore.Telegram/Chats/TelegramChatChannel.cs +++ b/hosts/Volcanic.AgenticCore.Telegram/Chats/TelegramChatChannel.cs @@ -17,11 +17,7 @@ public sealed class TelegramChatChannel(long chatId, TelegramSender sender, Chat public async Task SendTextAsync(string markdown, CancellationToken cancellationToken) { ArgumentNullException.ThrowIfNull(markdown); - var (first, complete) = await renderer.RunInOrderAsync(async ct => - { - using var linked = CancellationTokenSource.CreateLinkedTokenSource(ct, cancellationToken); - return await renderer.SendReplyAsync(markdown, linked.Token).ConfigureAwait(false); - }).ConfigureAwait(false); + 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)); } @@ -31,18 +27,17 @@ public sealed class TelegramChatChannel(long chatId, TelegramSender sender, Chat ArgumentNullException.ThrowIfNull(file); return renderer.RunInOrderAsync(async ct => { - using var linked = CancellationTokenSource.CreateLinkedTokenSource(ct, cancellationToken); var asPhoto = file.Kind == ChatFileKind.Photo || (file.Kind == ChatFileKind.Auto && LooksLikePhoto(file.Path)); - var result = await sender.SendFileAsync(chatId, file.Path, asPhoto, file.Caption, linked.Token).ConfigureAwait(false); + 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, linked.Token).ConfigureAwait(false); + 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) => diff --git a/hosts/Volcanic.AgenticCore.Telegram/Chats/UpdateRouter.cs b/hosts/Volcanic.AgenticCore.Telegram/Chats/UpdateRouter.cs index 4ff716f..606d36a 100644 --- a/hosts/Volcanic.AgenticCore.Telegram/Chats/UpdateRouter.cs +++ b/hosts/Volcanic.AgenticCore.Telegram/Chats/UpdateRouter.cs @@ -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 } diff --git a/hosts/Volcanic.AgenticCore.Telegram/Inbound/InboundComposer.cs b/hosts/Volcanic.AgenticCore.Telegram/Inbound/InboundComposer.cs index ad944b5..150e21f 100644 --- a/hosts/Volcanic.AgenticCore.Telegram/Inbound/InboundComposer.cs +++ b/hosts/Volcanic.AgenticCore.Telegram/Inbound/InboundComposer.cs @@ -81,7 +81,7 @@ public sealed class InboundComposer(TelegramSender sender, InboundMedia media, I 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."); diff --git a/hosts/Volcanic.AgenticCore.Telegram/Inbound/InboundMedia.cs b/hosts/Volcanic.AgenticCore.Telegram/Inbound/InboundMedia.cs index 0ad0d1b..1daf281 100644 --- a/hosts/Volcanic.AgenticCore.Telegram/Inbound/InboundMedia.cs +++ b/hosts/Volcanic.AgenticCore.Telegram/Inbound/InboundMedia.cs @@ -29,6 +29,9 @@ public sealed class InboundMedia(TelegramSender sender, TelegramOptions options, /// Suffix of a [File received: …] line for an image too large to attach inline. public const string TooLargeInlineNote = " (too large to show inline; downscale it with bash if you need to see it)"; + /// Suffix of a [File received: …] line for an image document whose bytes aren't a known image. + public const string NotAnImageNote = " (not shown inline: the contents are not a JPEG, PNG, WebP or GIF image)"; + private static readonly HashSet InlineImageTypes = new(StringComparer.OrdinalIgnoreCase) { "image/jpeg", "image/png", "image/webp", "image/gif" }; /// The notice for a file over the 20 MB download limit; over 20 MB when the size is unknown. @@ -67,7 +70,7 @@ public sealed class InboundMedia(TelegramSender sender, TelegramOptions options, await using (stream.ConfigureAwait(false)) await DownloadAsync(fileId, stream, cancellationToken).ConfigureAwait(false); } - catch (OperationCanceledException) + catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) { TryDelete(path); throw; @@ -88,8 +91,12 @@ public sealed class InboundMedia(TelegramSender sender, TelegramOptions options, { 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); - draft.Images.Add(new ImageContent(Convert.ToBase64String(data), mime.ToLowerInvariant())); + if (SniffImageType(data) is { } actual) draft.Images.Add(new ImageContent(Convert.ToBase64String(data), actual)); + else note = NotAnImageNote; } else { @@ -109,7 +116,7 @@ public sealed class InboundMedia(TelegramSender sender, TelegramOptions options, 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, a.FileName ?? "audio", a.MimeType ?? "audio/mpeg", false), + { 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; @@ -137,7 +144,7 @@ public sealed class InboundMedia(TelegramSender sender, TelegramOptions options, { await DownloadAsync(fileId, buffer, cancellationToken).ConfigureAwait(false); } - catch (Exception ex) when (ex is not OperationCanceledException) + 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); @@ -152,7 +159,7 @@ public sealed class InboundMedia(TelegramSender sender, TelegramOptions options, { transcript = (await speech.TranscribeAsync(audio, name, mime, cancellationToken).ConfigureAwait(false)).Trim(); } - catch (Exception ex) when (ex is not OperationCanceledException) + 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); @@ -167,6 +174,36 @@ public sealed class InboundMedia(TelegramSender sender, TelegramOptions options, draft.Body.Add($"[Voice message] {transcript}"); } + /// + /// The name an audio file is sent to speech to text under: OpenAI picks the format from the extension, so it's an + /// ASCII audio.<ext> from the original extension when OpenAI knows it, else from the MIME type. + /// + 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 + }); + } + + /// The image type the leading bytes show (JPEG, PNG, GIF or WebP), or null. + internal static string? SniffImageType(ReadOnlySpan 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); } diff --git a/hosts/Volcanic.AgenticCore.Telegram/Program.cs b/hosts/Volcanic.AgenticCore.Telegram/Program.cs index 58eeda5..2488e97 100644 --- a/hosts/Volcanic.AgenticCore.Telegram/Program.cs +++ b/hosts/Volcanic.AgenticCore.Telegram/Program.cs @@ -42,7 +42,11 @@ 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 - builder.Services.AddHttpClient(client => client.Timeout = TimeSpan.FromMinutes(2)); + // 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(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(); // The extension is constructed per session through ActivatorUtilities, which injects this. LoginUi resolves diff --git a/hosts/Volcanic.AgenticCore.Telegram/Rendering/ChatRenderer.cs b/hosts/Volcanic.AgenticCore.Telegram/Rendering/ChatRenderer.cs index f1f618a..05dd812 100644 --- a/hosts/Volcanic.AgenticCore.Telegram/Rendering/ChatRenderer.cs +++ b/hosts/Volcanic.AgenticCore.Telegram/Rendering/ChatRenderer.cs @@ -41,7 +41,8 @@ public sealed class ChatRenderer : IAsyncDisposable private readonly CancellationTokenSource _stop = new(); private readonly Task _loop; - private static readonly TimeSpan[] ReplyRetryDelays = [TimeSpan.FromSeconds(1), TimeSpan.FromSeconds(3)]; + /// Delays before resending a reply chunk that failed (1 s, 3 s). Settable for tests. + internal IReadOnlyList ReplyRetryDelays { get; set; } = [TimeSpan.FromSeconds(1), TimeSpan.FromSeconds(3)]; private DateTimeOffset _lastFlush = DateTimeOffset.MinValue; private CancellationTokenSource? _typing; @@ -83,23 +84,49 @@ public sealed class ChatRenderer : IAsyncDisposable /// /// 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. + /// 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. /// - public Task RunInOrderAsync(Func> work) + public Task RunInOrderAsync(Func> work, CancellationToken cancellationToken = default) { ArgumentNullException.ThrowIfNull(work); + if (cancellationToken.IsCancellationRequested) return Task.FromCanceled(cancellationToken); var done = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + var registration = cancellationToken.Register(() => done.TrySetCanceled(cancellationToken)); var item = new WorkItem(async ct => { - try { done.TrySetResult(await work(ct).ConfigureAwait(false)); } + 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); } - }, () => done.TrySetCanceled()); - if (!Enqueue(item)) done.TrySetCanceled(); + finally { await registration.DisposeAsync().ConfigureAwait(false); } + }, () => + { + registration.Dispose(); + done.TrySetCanceled(); + }); + if (!Enqueue(item)) + { + registration.Dispose(); + done.TrySetCanceled(); + } return done.Task; } /// Plain text (no parse mode) in order with rendered output; cut to 4096 characters by the sender. - public Task SayAsync(string text) => RunInOrderAsync(ct => _sender.SendTextAsync(_chatId, text, cancellationToken: ct)); + public Task SayAsync(string text, CancellationToken cancellationToken = default) => + RunInOrderAsync(ct => _sender.SendTextAsync(_chatId, text, cancellationToken: ct), cancellationToken); + + /// + /// 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. + /// + internal Task<(int? FirstId, bool Complete)> ReplyInOrderAsync(string markdown, CancellationToken cancellationToken) => + RunInOrderAsync(ct => SendReplyAsync(markdown, ct), cancellationToken); /// Completes once every queued event is rendered. For tests. internal async Task DrainAsync() @@ -148,6 +175,9 @@ public sealed class ChatRenderer : IAsyncDisposable 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(); @@ -254,7 +284,7 @@ public sealed class ChatRenderer : IAsyncDisposable /// 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. /// - internal async Task<(int? FirstId, bool Complete)> SendReplyAsync(string text, CancellationToken ct) + private async Task<(int? FirstId, bool Complete)> SendReplyAsync(string text, CancellationToken ct) { if (string.IsNullOrWhiteSpace(text)) return (null, true); int? first = null; diff --git a/hosts/Volcanic.AgenticCore.Telegram/Speech/OpenAiSpeechToText.cs b/hosts/Volcanic.AgenticCore.Telegram/Speech/OpenAiSpeechToText.cs index 9883310..458aa8b 100644 --- a/hosts/Volcanic.AgenticCore.Telegram/Speech/OpenAiSpeechToText.cs +++ b/hosts/Volcanic.AgenticCore.Telegram/Speech/OpenAiSpeechToText.cs @@ -21,8 +21,29 @@ public sealed class OpenAiSpeechToText(HttpClient http, SpeechOptions options) : using var request = new HttpRequestMessage(HttpMethod.Post, options.BaseUrl.TrimEnd('/') + "/audio/transcriptions") { Content = form }; request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", options.ApiKey); - using var response = await http.SendAsync(request, cancellationToken).ConfigureAwait(false); - var body = await response.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false); + 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 diff --git a/src/Volcanic.AgenticCore/Features/Agent/AgentSessionFactory.cs b/src/Volcanic.AgenticCore/Features/Agent/AgentSessionFactory.cs index 6054aab..4bca2da 100644 --- a/src/Volcanic.AgenticCore/Features/Agent/AgentSessionFactory.cs +++ b/src/Volcanic.AgenticCore/Features/Agent/AgentSessionFactory.cs @@ -11,7 +11,6 @@ using Volcanic.AgenticCore.Features.Models; using Volcanic.AgenticCore.Features.Sessions; using Volcanic.AgenticCore.Features.SystemPrompt; using Volcanic.AgenticCore.Features.Tools; -using Volcanic.AgenticCore.Features.Tools.BuiltIn; using Volcanic.AgenticCore.Hosting; namespace Volcanic.AgenticCore.Features.Agent; @@ -72,7 +71,7 @@ public sealed class AgentSessionFactory : IAgentSessionFactory // `send` exists only when the host can deliver into this session's chat. if (options.Channel is not null) { - var send = new SendTool(); + var send = BuiltInTools.Send(); tools.AddBuiltIn(send, options.ActiveTools?.Contains(send.Name) ?? true); } } diff --git a/src/Volcanic.AgenticCore/Features/Extensions/IExtensionHost.cs b/src/Volcanic.AgenticCore/Features/Extensions/IExtensionHost.cs index 5654728..52f103b 100644 --- a/src/Volcanic.AgenticCore/Features/Extensions/IExtensionHost.cs +++ b/src/Volcanic.AgenticCore/Features/Extensions/IExtensionHost.cs @@ -16,7 +16,7 @@ public interface IExtensionHost string Scope { get; } IUserInterface Ui { get; } /// The session's chat channel, or null when the host has none (then send isn't registered). - IChatChannel? Channel => null; + IChatChannel? Channel { get; } IReadOnlySessionManager SessionManager { get; } IModelRegistry ModelRegistry { get; } Model? Model { get; } diff --git a/src/Volcanic.AgenticCore/Features/Tools/BuiltIn/SendTool.cs b/src/Volcanic.AgenticCore/Features/Tools/BuiltIn/SendTool.cs index 0533bf7..a5ffbbc 100644 --- a/src/Volcanic.AgenticCore/Features/Tools/BuiltIn/SendTool.cs +++ b/src/Volcanic.AgenticCore/Features/Tools/BuiltIn/SendTool.cs @@ -66,36 +66,3 @@ public sealed class SendTool : Tool private static string Mb(long bytes) => (bytes / 1048576.0).ToString("0.0", CultureInfo.InvariantCulture); } - -/// Workspace confinement for tools that hand files to the outside world. -internal static class WorkspacePaths -{ - private static StringComparison Comparison => OperatingSystem.IsWindows() ? StringComparison.OrdinalIgnoreCase : StringComparison.Ordinal; - - /// True when is strictly below (trailing-separator compare). - public static bool IsInside(string root, string fullPath) => - fullPath.StartsWith(Path.TrimEndingDirectorySeparator(root) + Path.DirectorySeparatorChar, Comparison); - - /// Workspace-relative path with / separators. - public static string Relative(string root, string fullPath) => Path.GetRelativePath(root, fullPath).Replace('\\', '/'); - - /// - /// The full path of when it, and the final target of every link between it and the - /// workspace, stays inside . Throws path escapes the workspace: … otherwise. - /// - public static string Confine(string root, string path) - { - var full = Paths.Resolve(root, path); - if (!IsInside(root, full)) throw Escapes(path); - for (var current = full; IsInside(root, current); current = Path.GetDirectoryName(current)!) - { - FileSystemInfo info = Directory.Exists(current) ? new DirectoryInfo(current) : new FileInfo(current); - if (info.LinkTarget is null) continue; - var target = info.ResolveLinkTarget(returnFinalTarget: true); - if (target is null || !IsInside(root, Path.GetFullPath(target.FullName))) throw Escapes(path); - } - return full; - } - - private static InvalidOperationException Escapes(string path) => new($"path escapes the workspace: {path}"); -} diff --git a/src/Volcanic.AgenticCore/Features/Tools/BuiltIn/WorkspacePaths.cs b/src/Volcanic.AgenticCore/Features/Tools/BuiltIn/WorkspacePaths.cs new file mode 100644 index 0000000..1e8855a --- /dev/null +++ b/src/Volcanic.AgenticCore/Features/Tools/BuiltIn/WorkspacePaths.cs @@ -0,0 +1,98 @@ +namespace Volcanic.AgenticCore.Features.Tools.BuiltIn; + +/// Workspace confinement for tools that hand files to the outside world. +internal static class WorkspacePaths +{ + /// Links followed while resolving one path before it counts as a loop (Linux's MAXSYMLINKS). + private const int MaxLinkHops = 40; + + private static StringComparison Comparison => OperatingSystem.IsWindows() ? StringComparison.OrdinalIgnoreCase : StringComparison.Ordinal; + + /// True when is strictly below (trailing-separator compare). + public static bool IsInside(string root, string fullPath) => + fullPath.StartsWith(Path.TrimEndingDirectorySeparator(root) + Path.DirectorySeparatorChar, Comparison); + + /// , or the root itself. + public static bool IsInsideOrRoot(string root, string fullPath) => + string.Equals(Path.TrimEndingDirectorySeparator(root), Path.TrimEndingDirectorySeparator(fullPath), Comparison) + || IsInside(root, fullPath); + + /// Workspace-relative path with / separators. + public static string Relative(string root, string fullPath) => Path.GetRelativePath(root, fullPath).Replace('\\', '/'); + + /// + /// The full path of when both it and its real location (every link resolved, component by + /// component) stay inside . The root itself counts as inside, so the caller can report it as a + /// directory. Throws path escapes the workspace: … otherwise. + /// + 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; + } + + /// + /// realpath(3) 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 .. inside a link target applies to the real parent, and links inside the + /// target are followed too. Null when resolving takes more than links (a loop). + /// + internal static string? RealPath(string fullPath) + { + var current = Path.GetPathRoot(fullPath) ?? ""; + var pending = new Stack(); // 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 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}"); +} diff --git a/src/Volcanic.AgenticCore/Features/Tools/BuiltInTools.cs b/src/Volcanic.AgenticCore/Features/Tools/BuiltInTools.cs index 978996b..f10f843 100644 --- a/src/Volcanic.AgenticCore/Features/Tools/BuiltInTools.cs +++ b/src/Volcanic.AgenticCore/Features/Tools/BuiltInTools.cs @@ -8,5 +8,8 @@ public static class BuiltInTools public static readonly IReadOnlyList DefaultActive = ["read", "bash", "edit", "write"]; public static readonly IReadOnlyList ReadOnlySet = ["read", "grep", "find", "ls"]; + /// The send tool, registered only for sessions that have a chat channel. + public static ITool Send() => new SendTool(); + public static IReadOnlyList All() => [new ReadTool(), new BashTool(), new EditTool(), new WriteTool(), new LsTool(), new GrepTool(), new FindTool()]; } diff --git a/tests/Volcanic.AgenticCore.Extensions.Tests/Mcp/McpExtensionTests.cs b/tests/Volcanic.AgenticCore.Extensions.Tests/Mcp/McpExtensionTests.cs index ee4b1ae..be8a9cf 100644 --- a/tests/Volcanic.AgenticCore.Extensions.Tests/Mcp/McpExtensionTests.cs +++ b/tests/Volcanic.AgenticCore.Extensions.Tests/Mcp/McpExtensionTests.cs @@ -190,6 +190,19 @@ public class McpExtensionTests 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() { diff --git a/tests/Volcanic.AgenticCore.Telegram.Tests/AttachmentChatTests.cs b/tests/Volcanic.AgenticCore.Telegram.Tests/AttachmentChatTests.cs index 1f5d829..91d290b 100644 --- a/tests/Volcanic.AgenticCore.Telegram.Tests/AttachmentChatTests.cs +++ b/tests/Volcanic.AgenticCore.Telegram.Tests/AttachmentChatTests.cs @@ -112,14 +112,45 @@ public sealed class AttachmentChatTests { var time = new FakeTimeProvider(Today); await using var host = new ChatHost(time: time); - host.Bot.Files["doc"] = [9, 8, 7]; + 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()); + 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()).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)); - var image = Assert.Single(Prompt(host).Content.OfType()); - Assert.Equal(("image/png", Convert.ToBase64String([9, 8, 7])), (image.MimeType, image.Data)); - Assert.EndsWith("[File received: inbox/2026-09-26-diagram.png (image/png, 3 bytes)]", Prompt(host).Text, StringComparison.Ordinal); + Assert.Empty(Prompt(host).Content.OfType()); + 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] diff --git a/tests/Volcanic.AgenticCore.Telegram.Tests/ChatRendererOrderTests.cs b/tests/Volcanic.AgenticCore.Telegram.Tests/ChatRendererOrderTests.cs index 2facfdc..22eb8d8 100644 --- a/tests/Volcanic.AgenticCore.Telegram.Tests/ChatRendererOrderTests.cs +++ b/tests/Volcanic.AgenticCore.Telegram.Tests/ChatRendererOrderTests.cs @@ -52,6 +52,57 @@ public sealed class ChatRendererOrderTests await Assert.ThrowsAnyAsync(() => 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(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(() => 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(() => + 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(() => queued.WaitAsync(TimeSpan.FromSeconds(5))); + Assert.False(ran); + await Assert.ThrowsAnyAsync(() => stuck); + } + [Fact] public async Task SayAsync_sends_plain_text_cut_to_4096_characters() { diff --git a/tests/Volcanic.AgenticCore.Telegram.Tests/OpenAiSpeechToTextTests.cs b/tests/Volcanic.AgenticCore.Telegram.Tests/OpenAiSpeechToTextTests.cs index 9102634..893d34b 100644 --- a/tests/Volcanic.AgenticCore.Telegram.Tests/OpenAiSpeechToTextTests.cs +++ b/tests/Volcanic.AgenticCore.Telegram.Tests/OpenAiSpeechToTextTests.cs @@ -92,4 +92,30 @@ public sealed class OpenAiSpeechToTextTests var ex = await Assert.ThrowsAsync(() => 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 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(() => 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(() => stt.TranscribeAsync(new byte[] { 1 }, "voice.ogg", "audio/ogg", cts.Token)); + } } diff --git a/tests/Volcanic.AgenticCore.Telegram.Tests/TelegramChatChannelTests.cs b/tests/Volcanic.AgenticCore.Telegram.Tests/TelegramChatChannelTests.cs index de5a638..ebae080 100644 --- a/tests/Volcanic.AgenticCore.Telegram.Tests/TelegramChatChannelTests.cs +++ b/tests/Volcanic.AgenticCore.Telegram.Tests/TelegramChatChannelTests.cs @@ -1,7 +1,9 @@ +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; @@ -103,4 +105,48 @@ public sealed class TelegramChatChannelTests 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.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(() => 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(() => channel.SendFileAsync(new ChatFile(path), CancellationToken.None)); + Assert.StartsWith("Telegram refused the file: ", ex.Message, StringComparison.Ordinal); + Assert.Equal(3, bot.Requests.OfType().Count()); + } + finally + { + File.Delete(path); + } + } } diff --git a/tests/Volcanic.AgenticCore.Telegram.Tests/VoiceChatTests.cs b/tests/Volcanic.AgenticCore.Telegram.Tests/VoiceChatTests.cs index 5ff7cbd..c4527b4 100644 --- a/tests/Volcanic.AgenticCore.Telegram.Tests/VoiceChatTests.cs +++ b/tests/Volcanic.AgenticCore.Telegram.Tests/VoiceChatTests.cs @@ -85,6 +85,37 @@ public sealed class VoiceChatTests 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() { @@ -140,12 +171,22 @@ public sealed class VoiceChatTests 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(("talk.mp3", "audio/mpeg"), (stt.Calls.Single().FileName, stt.Calls.Single().MimeType)); + 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()); // 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() { @@ -165,13 +206,22 @@ public sealed class VoiceChatTests [Fact] public async Task A_voice_note_and_a_text_right_after_it_are_one_prompt_in_order() { - var stt = new FakeSpeechToText("first the voice") { Delay = TimeSpan.FromMilliseconds(300) }; // well past the 20 ms window - await using var host = new ChatHost(speech: stt); + 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); diff --git a/tests/Volcanic.AgenticCore.Tests/Features/SendToolTests.cs b/tests/Volcanic.AgenticCore.Tests/Features/SendToolTests.cs index 6a50854..49d834b 100644 --- a/tests/Volcanic.AgenticCore.Tests/Features/SendToolTests.cs +++ b/tests/Volcanic.AgenticCore.Tests/Features/SendToolTests.cs @@ -163,6 +163,50 @@ public sealed class SendToolTests : IDisposable 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 /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"))); diff --git a/tests/Volcanic.AgenticCore.Tests/Support/StubExtensionHost.cs b/tests/Volcanic.AgenticCore.Tests/Support/StubExtensionHost.cs index 7341afa..d022104 100644 --- a/tests/Volcanic.AgenticCore.Tests/Support/StubExtensionHost.cs +++ b/tests/Volcanic.AgenticCore.Tests/Support/StubExtensionHost.cs @@ -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; }