forked from admin/VolcanicAgenticCore
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.<ext> 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.
This commit is contained in:
@@ -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
|
||||
|
||||
|
||||
@@ -194,8 +194,9 @@ callTimeoutSeconds: 120, startupTimeoutSeconds: 120 (the first npx run downloads
|
||||
- Don't enter passwords or payment details unless the user explicitly asks for that specific site.
|
||||
```
|
||||
|
||||
The `send` line is included only when a tool named `send` is registered (checked at prompt time through
|
||||
`GetAllTools()`), so the preset still reads correctly on hosts without attachments. Without `send`, that bullet
|
||||
The `send` line is included only when a tool named `send` is active (checked at prompt time through
|
||||
`GetActiveTools()`; a registered but inactive `send` isn't callable), so the preset still reads correctly on hosts
|
||||
without attachments. Without `send`, that bullet
|
||||
reads: "Files the browser saves (screenshots with a `filename` such as `browser/<name>.png`, PDFs, downloads) are
|
||||
under browser/ in the workspace."
|
||||
|
||||
|
||||
@@ -118,10 +118,14 @@ ExecutionMode: Sequential (two sends in one turn arrive in call order)
|
||||
Execution:
|
||||
|
||||
1. `ctx.Channel` is null → throw `send is not available: this session has no chat` (defensive; see registration).
|
||||
2. Resolve with the existing `Paths.Resolve(ctx.Cwd, path)`. Reject unless the full path is inside `ctx.Cwd` (compare
|
||||
with a trailing separator, case-insensitively on Windows). If the file or any parent is a symlink, resolve it with
|
||||
`ResolveLinkTarget(returnFinalTarget: true)` and apply the same check to the target. Errors are
|
||||
`path escapes the workspace: <path>`, `file not found: <path>` and `path is a directory, not a file: <path>`.
|
||||
2. Resolve with the existing `Paths.Resolve(ctx.Cwd, path)`. Reject unless the full path is inside `ctx.Cwd` or is
|
||||
`ctx.Cwd` itself (compare with a trailing separator, case-insensitively on Windows). Then resolve the real path the
|
||||
way `realpath(3)` does (`WorkspacePaths.RealPath`): walk it from the filesystem root and replace each link by its
|
||||
target as it is met, so links inside a link's target are followed too and a `..` in a target applies to the real
|
||||
parent. That real path must be inside the real path of `ctx.Cwd`. More than 40 links (a loop) also fails.
|
||||
(`ResolveLinkTarget(returnFinalTarget: true)` isn't enough: on Unix it follows only the last component of each hop,
|
||||
so `a/l -> ../d/secret` with `d -> /etc` would pass.) Errors are `path escapes the workspace: <path>`,
|
||||
`file not found: <path>` and `path is a directory, not a file: <path>` (also for `.`, the workspace itself).
|
||||
3. Caption longer than `Limits.MaxCaptionLength` → `caption is <n> characters; the limit is <m>. Shorten it or put
|
||||
the text in your reply.` The check runs before anything is sent, so a failure never delivers half an attachment.
|
||||
4. Kind:
|
||||
@@ -151,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<T> RunInOrderAsync<T>(Func<CancellationToken, Task<T>> work)`. It queues a host-internal work item behind the
|
||||
pending events and completes when the item has run. The renderer marks the status message as buried afterwards, as
|
||||
`Task<T> RunInOrderAsync<T>(Func<CancellationToken, Task<T>> work, CancellationToken cancellationToken = default)`.
|
||||
It queues a host-internal work item behind the pending events and completes when the item has run. The caller's
|
||||
token cancels the returned task at once, even while the item still waits behind a slow upload; an item cancelled
|
||||
before it starts is skipped, and a running one sees the token through the one it's given. The renderer marks the status message as buried afterwards, as
|
||||
it does for replies, so the `⚙️ Working...` message moves back to the bottom on the next tool update.
|
||||
`SendTextAsync` and `SendFileAsync` both go through it. The event channel becomes a channel of render items (an
|
||||
event or a work item). 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<FileSendResult>`, with
|
||||
`FileSendResult(int? MessageId, string? Error, bool PhotoRejected)`. It follows the sender's never-throw convention.
|
||||
Each attempt opens a fresh `FileStream` and uses `InputFile.FromStream(stream, Path.GetFileName(path))` with
|
||||
@@ -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.<ext>`: the original extension when OpenAI knows it (`flac`, `m4a`,
|
||||
`mp3`, `mp4`, `mpeg`, `mpga`, `ogg`, `wav`, `webm`; `.oga`/`.opus` become `.ogg`), else one from `MimeType`
|
||||
(`mp3` when unknown), with its `MimeType`. A missing or non-ASCII name would otherwise lose the extension.
|
||||
4. `Transcript = await stt.TranscribeAsync(bytes, fileName, mime, ct)`, trimmed. Empty → notice `I couldn't make
|
||||
out any words in that voice message.` An exception → notice `Could not transcribe the voice message: <reason>`.
|
||||
That includes a cancellation the message's own token didn't cause, such as the `HttpClient` timeout; only a
|
||||
shutdown (the caller's token) ends composing silently. The same rule holds for downloads, and `UpdateRouter`
|
||||
treats any other `OperationCanceledException` from `ComposeAsync` as a failure (`I could not read that message.`).
|
||||
5. Echo `🎤 <transcript>` as plain text, cut to 4096 characters with `…`.
|
||||
6. Prompt text: the caption (if any), then `[Voice message] <transcript>`. For `Audio`, the file is also saved to
|
||||
the inbox like a document and gets its own `[File received: …]` line: it's something the user might want
|
||||
@@ -261,7 +278,13 @@ public interface ISpeechToText
|
||||
as given), `model`, `response_format=json`, and `language` when configured.
|
||||
- Non-2xx → `SpeechToTextException("OpenAI transcription failed: HTTP <status> <first 200 chars of body>")`.
|
||||
A non-JSON body or a missing `text` gives its own message. The key never appears in messages or logs.
|
||||
- The client timeout (a `TaskCanceledException` while the caller's token isn't cancelled) →
|
||||
`SpeechToTextException("OpenAI transcription failed: the request timed out.")`; a transport failure
|
||||
(`HttpRequestException`) → the same prefix plus its message. The caller's own cancellation still propagates.
|
||||
- `HttpClient` comes from `AddHttpClient<ISpeechToText, OpenAiSpeechToText>()` (package `Microsoft.Extensions.Http`), with a 2-minute timeout.
|
||||
The singleton `InboundMedia` keeps that typed client for the process lifetime, so the factory never rotates its
|
||||
handler: the primary handler is a `SocketsHttpHandler` with `PooledConnectionLifetime = 5 min` (and an infinite
|
||||
handler lifetime), so DNS changes are still picked up.
|
||||
|
||||
`SpeechOptions`, the `Telegram:Speech` section:
|
||||
|
||||
@@ -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: <model>` 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 -> <outside>`), a link whose target has a `..`
|
||||
after a directory link (`l -> x/../secret`, `x -> <outside>/deep`) and a link loop. `.` is a directory.
|
||||
- Missing file and directory errors. Absolute paths inside the workspace are accepted.
|
||||
- A `ChatDeliveryException` becomes an error tool result with its message.
|
||||
- Result text and details. Sparse files (`FileStream.SetLength`) stand in for big files.
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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/<yyyy-MM-dd>-<name>` in the chat's workspace (a number is
|
||||
added when the name is taken: `-1`, `-2`, ...). The agent gets the caption plus a line such as
|
||||
`[File received: inbox/2026-09-26-report.pdf (application/pdf, 1.2 MB)]`. An image sent "as file" (JPEG, PNG, WebP,
|
||||
GIF) up to 3.75 MB (`Telegram:InlineImageMaxBytes`) is also shown to the model; a larger one 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
|
||||
`🎤 <transcript>` and the agent gets `[Voice message] <transcript>` (an audio file also gets its `[File received: …]`
|
||||
line). Without an API key the bot replies with how to set one up (`OPENAI_API_KEY`).
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -17,11 +17,7 @@ public sealed class TelegramChatChannel(long chatId, TelegramSender sender, Chat
|
||||
public async Task<ChatDelivery> 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) =>
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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.");
|
||||
|
||||
@@ -29,6 +29,9 @@ public sealed class InboundMedia(TelegramSender sender, TelegramOptions options,
|
||||
/// <summary>Suffix of a <c>[File received: …]</c> line for an image too large to attach inline.</summary>
|
||||
public const string TooLargeInlineNote = " (too large to show inline; downscale it with bash if you need to see it)";
|
||||
|
||||
/// <summary>Suffix of a <c>[File received: …]</c> line for an image document whose bytes aren't a known image.</summary>
|
||||
public const string NotAnImageNote = " (not shown inline: the contents are not a JPEG, PNG, WebP or GIF image)";
|
||||
|
||||
private static readonly HashSet<string> InlineImageTypes = new(StringComparer.OrdinalIgnoreCase) { "image/jpeg", "image/png", "image/webp", "image/gif" };
|
||||
|
||||
/// <summary>The notice for a file over the 20 MB download limit; <c>over 20 MB</c> when the size is unknown.</summary>
|
||||
@@ -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}");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The name an audio file is sent to speech to text under: OpenAI picks the format from the extension, so it's an
|
||||
/// ASCII <c>audio.<ext></c> from the original extension when OpenAI knows it, else from the MIME type.
|
||||
/// </summary>
|
||||
internal static string SpeechFileName(string? fileName, string? mimeType)
|
||||
{
|
||||
var ext = Path.GetExtension(fileName ?? "").ToLowerInvariant();
|
||||
if (ext is ".flac" or ".m4a" or ".mp3" or ".mp4" or ".mpeg" or ".mpga" or ".ogg" or ".wav" or ".webm") return "audio" + ext;
|
||||
if (ext is ".oga" or ".opus") return "audio.ogg";
|
||||
return "audio" + (mimeType?.ToLowerInvariant() switch
|
||||
{
|
||||
"audio/mp4" or "audio/m4a" or "audio/x-m4a" => ".m4a",
|
||||
"audio/ogg" or "audio/opus" => ".ogg",
|
||||
"audio/wav" or "audio/x-wav" or "audio/wave" => ".wav",
|
||||
"audio/flac" or "audio/x-flac" => ".flac",
|
||||
"audio/webm" => ".webm",
|
||||
_ => ".mp3", // audio/mpeg, Telegram's default for audio
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>The image type the leading bytes show (JPEG, PNG, GIF or WebP), or null.</summary>
|
||||
internal static string? SniffImageType(ReadOnlySpan<byte> data) => data switch
|
||||
{
|
||||
[0xFF, 0xD8, 0xFF, ..] => "image/jpeg",
|
||||
[0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A, ..] => "image/png",
|
||||
[0x47, 0x49, 0x46, 0x38, ..] => "image/gif",
|
||||
[0x52, 0x49, 0x46, 0x46, _, _, _, _, 0x57, 0x45, 0x42, 0x50, ..] => "image/webp",
|
||||
_ => null,
|
||||
};
|
||||
|
||||
private void TryDelete(string path)
|
||||
{
|
||||
try { File.Delete(path); }
|
||||
|
||||
@@ -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<ISpeechToText, OpenAiSpeechToText>(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<ISpeechToText, OpenAiSpeechToText>(client => client.Timeout = TimeSpan.FromMinutes(2))
|
||||
.ConfigurePrimaryHttpMessageHandler(() => new SocketsHttpHandler { PooledConnectionLifetime = TimeSpan.FromMinutes(5) })
|
||||
.SetHandlerLifetime(Timeout.InfiniteTimeSpan);
|
||||
if (calendar is not null) builder.Services.AddSingleton(calendar); // ChatRegistry and BotSetupService take it optionally
|
||||
builder.Services.AddSingleton<OwnerLoginChannel>();
|
||||
// The extension is constructed per session through ActivatorUtilities, which injects this. LoginUi resolves
|
||||
|
||||
@@ -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)];
|
||||
/// <summary>Delays before resending a reply chunk that failed (1 s, 3 s). Settable for tests.</summary>
|
||||
internal IReadOnlyList<TimeSpan> ReplyRetryDelays { get; set; } = [TimeSpan.FromSeconds(1), TimeSpan.FromSeconds(3)];
|
||||
|
||||
private DateTimeOffset _lastFlush = DateTimeOffset.MinValue;
|
||||
private CancellationTokenSource? _typing;
|
||||
@@ -83,23 +84,49 @@ public sealed class ChatRenderer : IAsyncDisposable
|
||||
/// <summary>
|
||||
/// Run host work (an upload, a notice) on the render loop, after every event queued before it, so it stays in order
|
||||
/// with rendered output. Afterwards the status message counts as buried. Cancelled when the renderer is disposed first.
|
||||
/// <paramref name="cancellationToken"/> cancels the returned task at once, even while the work is still queued behind
|
||||
/// a slow upload; work that hasn't started by then is skipped, and running work sees it through its token.
|
||||
/// </summary>
|
||||
public Task<T> RunInOrderAsync<T>(Func<CancellationToken, Task<T>> work)
|
||||
public Task<T> RunInOrderAsync<T>(Func<CancellationToken, Task<T>> work, CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(work);
|
||||
if (cancellationToken.IsCancellationRequested) return Task.FromCanceled<T>(cancellationToken);
|
||||
var done = new TaskCompletionSource<T>(TaskCreationOptions.RunContinuationsAsynchronously);
|
||||
var registration = cancellationToken.Register(() => done.TrySetCanceled(cancellationToken));
|
||||
var item = new WorkItem(async ct =>
|
||||
{
|
||||
try { 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;
|
||||
}
|
||||
|
||||
/// <summary>Plain text (no parse mode) in order with rendered output; cut to 4096 characters by the sender.</summary>
|
||||
public Task<int?> SayAsync(string text) => RunInOrderAsync(ct => _sender.SendTextAsync(_chatId, text, cancellationToken: ct));
|
||||
public Task<int?> SayAsync(string text, CancellationToken cancellationToken = default) =>
|
||||
RunInOrderAsync(ct => _sender.SendTextAsync(_chatId, text, cancellationToken: ct), cancellationToken);
|
||||
|
||||
/// <summary>
|
||||
/// A Markdown reply rendered like an assistant message (HTML, split past 4096, each chunk retried), in order with
|
||||
/// rendered output. Returns the first message id and whether every chunk was delivered.
|
||||
/// </summary>
|
||||
internal Task<(int? FirstId, bool Complete)> ReplyInOrderAsync(string markdown, CancellationToken cancellationToken) =>
|
||||
RunInOrderAsync(ct => SendReplyAsync(markdown, ct), cancellationToken);
|
||||
|
||||
/// <summary>Completes once every queued event is rendered. For tests.</summary>
|
||||
internal async Task DrainAsync()
|
||||
@@ -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.
|
||||
/// </summary>
|
||||
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;
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -16,7 +16,7 @@ public interface IExtensionHost
|
||||
string Scope { get; }
|
||||
IUserInterface Ui { get; }
|
||||
/// <summary>The session's chat channel, or null when the host has none (then <c>send</c> isn't registered).</summary>
|
||||
IChatChannel? Channel => null;
|
||||
IChatChannel? Channel { get; }
|
||||
IReadOnlySessionManager SessionManager { get; }
|
||||
IModelRegistry ModelRegistry { get; }
|
||||
Model? Model { get; }
|
||||
|
||||
@@ -66,36 +66,3 @@ public sealed class SendTool : Tool<SendArgs>
|
||||
|
||||
private static string Mb(long bytes) => (bytes / 1048576.0).ToString("0.0", CultureInfo.InvariantCulture);
|
||||
}
|
||||
|
||||
/// <summary>Workspace confinement for tools that hand files to the outside world.</summary>
|
||||
internal static class WorkspacePaths
|
||||
{
|
||||
private static StringComparison Comparison => OperatingSystem.IsWindows() ? StringComparison.OrdinalIgnoreCase : StringComparison.Ordinal;
|
||||
|
||||
/// <summary>True when <paramref name="fullPath"/> is strictly below <paramref name="root"/> (trailing-separator compare).</summary>
|
||||
public static bool IsInside(string root, string fullPath) =>
|
||||
fullPath.StartsWith(Path.TrimEndingDirectorySeparator(root) + Path.DirectorySeparatorChar, Comparison);
|
||||
|
||||
/// <summary>Workspace-relative path with <c>/</c> separators.</summary>
|
||||
public static string Relative(string root, string fullPath) => Path.GetRelativePath(root, fullPath).Replace('\\', '/');
|
||||
|
||||
/// <summary>
|
||||
/// The full path of <paramref name="path"/> when it, and the final target of every link between it and the
|
||||
/// workspace, stays inside <paramref name="root"/>. Throws <c>path escapes the workspace: …</c> otherwise.
|
||||
/// </summary>
|
||||
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}");
|
||||
}
|
||||
|
||||
@@ -0,0 +1,98 @@
|
||||
namespace Volcanic.AgenticCore.Features.Tools.BuiltIn;
|
||||
|
||||
/// <summary>Workspace confinement for tools that hand files to the outside world.</summary>
|
||||
internal static class WorkspacePaths
|
||||
{
|
||||
/// <summary>Links followed while resolving one path before it counts as a loop (Linux's <c>MAXSYMLINKS</c>).</summary>
|
||||
private const int MaxLinkHops = 40;
|
||||
|
||||
private static StringComparison Comparison => OperatingSystem.IsWindows() ? StringComparison.OrdinalIgnoreCase : StringComparison.Ordinal;
|
||||
|
||||
/// <summary>True when <paramref name="fullPath"/> is strictly below <paramref name="root"/> (trailing-separator compare).</summary>
|
||||
public static bool IsInside(string root, string fullPath) =>
|
||||
fullPath.StartsWith(Path.TrimEndingDirectorySeparator(root) + Path.DirectorySeparatorChar, Comparison);
|
||||
|
||||
/// <summary><see cref="IsInside"/>, or the root itself.</summary>
|
||||
public static bool IsInsideOrRoot(string root, string fullPath) =>
|
||||
string.Equals(Path.TrimEndingDirectorySeparator(root), Path.TrimEndingDirectorySeparator(fullPath), Comparison)
|
||||
|| IsInside(root, fullPath);
|
||||
|
||||
/// <summary>Workspace-relative path with <c>/</c> separators.</summary>
|
||||
public static string Relative(string root, string fullPath) => Path.GetRelativePath(root, fullPath).Replace('\\', '/');
|
||||
|
||||
/// <summary>
|
||||
/// The full path of <paramref name="path"/> when both it and its real location (every link resolved, component by
|
||||
/// component) stay inside <paramref name="root"/>. The root itself counts as inside, so the caller can report it as a
|
||||
/// directory. Throws <c>path escapes the workspace: …</c> otherwise.
|
||||
/// </summary>
|
||||
public static string Confine(string root, string path)
|
||||
{
|
||||
var full = Paths.Resolve(root, path);
|
||||
if (!IsInsideOrRoot(root, full)) throw Escapes(path);
|
||||
// The OS opens `full` by resolving each component in turn, links included, so check where that really lands.
|
||||
if (RealPath(root) is not { } realRoot || RealPath(full) is not { } real || !IsInsideOrRoot(realRoot, real))
|
||||
throw Escapes(path);
|
||||
return full;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// <c>realpath(3)</c> for a full path that may not exist: walks it from the filesystem root and replaces each link by
|
||||
/// its target as it is met, so a <c>..</c> inside a link target applies to the real parent, and links inside the
|
||||
/// target are followed too. Null when resolving takes more than <see cref="MaxLinkHops"/> links (a loop).
|
||||
/// </summary>
|
||||
internal static string? RealPath(string fullPath)
|
||||
{
|
||||
var current = Path.GetPathRoot(fullPath) ?? "";
|
||||
var pending = new Stack<string>(); // components still to walk, the next one on top
|
||||
Push(pending, fullPath[current.Length..]);
|
||||
var hops = 0;
|
||||
while (pending.Count > 0)
|
||||
{
|
||||
var part = pending.Pop();
|
||||
if (part is "" or ".") continue;
|
||||
if (part == "..")
|
||||
{
|
||||
current = Path.GetDirectoryName(current) ?? current; // current is real, so its parent is too
|
||||
continue;
|
||||
}
|
||||
var next = Path.Join(current, part);
|
||||
if (LinkTarget(next) is not { } target)
|
||||
{
|
||||
current = next;
|
||||
continue;
|
||||
}
|
||||
if (++hops > MaxLinkHops) return null;
|
||||
if (Path.IsPathFullyQualified(target))
|
||||
{
|
||||
current = Path.GetPathRoot(target)!;
|
||||
Push(pending, target[current.Length..]);
|
||||
}
|
||||
else
|
||||
{
|
||||
Push(pending, target); // relative to the link's directory, which is `current`
|
||||
}
|
||||
}
|
||||
return current;
|
||||
}
|
||||
|
||||
private static void Push(Stack<string> pending, string relative)
|
||||
{
|
||||
var parts = relative.Split(['/', Path.DirectorySeparatorChar], StringSplitOptions.RemoveEmptyEntries);
|
||||
for (var i = parts.Length - 1; i >= 0; i--) pending.Push(parts[i]);
|
||||
}
|
||||
|
||||
private static string? LinkTarget(string path)
|
||||
{
|
||||
try
|
||||
{
|
||||
FileSystemInfo info = Directory.Exists(path) ? new DirectoryInfo(path) : new FileInfo(path);
|
||||
return info.LinkTarget;
|
||||
}
|
||||
catch (Exception ex) when (ex is IOException or UnauthorizedAccessException)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private static InvalidOperationException Escapes(string path) => new($"path escapes the workspace: {path}");
|
||||
}
|
||||
@@ -8,5 +8,8 @@ public static class BuiltInTools
|
||||
public static readonly IReadOnlyList<string> DefaultActive = ["read", "bash", "edit", "write"];
|
||||
public static readonly IReadOnlyList<string> ReadOnlySet = ["read", "grep", "find", "ls"];
|
||||
|
||||
/// <summary>The <c>send</c> tool, registered only for sessions that have a chat channel.</summary>
|
||||
public static ITool Send() => new SendTool();
|
||||
|
||||
public static IReadOnlyList<ITool> All() => [new ReadTool(), new BashTool(), new EditTool(), new WriteTool(), new LsTool(), new GrepTool(), new FindTool()];
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
{
|
||||
|
||||
@@ -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<ImageContent>());
|
||||
Assert.Equal(("image/png", Convert.ToBase64String(Png)), (image.MimeType, image.Data));
|
||||
Assert.EndsWith("[File received: inbox/2026-09-26-diagram.png (image/png, 10 bytes)]", Prompt(host).Text, StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
private static readonly byte[] Png = [0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A, 1, 2];
|
||||
|
||||
[Fact]
|
||||
public async Task An_image_document_is_attached_with_the_type_its_bytes_show()
|
||||
{
|
||||
var time = new FakeTimeProvider(Today);
|
||||
await using var host = new ChatHost(time: time);
|
||||
byte[] jpeg = [0xFF, 0xD8, 0xFF, 0xE0, 1, 2];
|
||||
host.Bot.Files["doc"] = jpeg;
|
||||
host.Faux.Enqueue(FauxTurn.Say("ok"));
|
||||
|
||||
await RouteAndFlushAsync(host, time, Updates.DocumentMessage("photo.png", "image/png", jpeg.Length));
|
||||
|
||||
Assert.Equal("image/jpeg", Assert.Single(Prompt(host).Content.OfType<ImageContent>()).MimeType);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task An_image_document_whose_bytes_are_no_known_image_is_path_only()
|
||||
{
|
||||
var time = new FakeTimeProvider(Today);
|
||||
await using var host = new ChatHost(time: time);
|
||||
host.Bot.Files["doc"] = [9, 8, 7]; // e.g. HEIC or a truncated file labelled image/png
|
||||
host.Faux.Enqueue(FauxTurn.Say("ok"));
|
||||
|
||||
await RouteAndFlushAsync(host, time, Updates.DocumentMessage("diagram.png", "image/png", 3));
|
||||
|
||||
var image = Assert.Single(Prompt(host).Content.OfType<ImageContent>());
|
||||
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<ImageContent>());
|
||||
Assert.EndsWith("[File received: inbox/2026-09-26-diagram.png (image/png, 3 bytes) (not shown inline: the contents are not a JPEG, PNG, WebP or GIF image)]",
|
||||
Prompt(host).Text, StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
|
||||
@@ -52,6 +52,57 @@ public sealed class ChatRendererOrderTests
|
||||
await Assert.ThrowsAnyAsync<OperationCanceledException>(() => renderer.RunInOrderAsync(_ => Task.FromResult(1)));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RunInOrderAsync_cancelled_while_queued_returns_at_once_and_skips_the_work()
|
||||
{
|
||||
var (_, _, renderer) = Setup();
|
||||
await using var _ = renderer;
|
||||
var gate = new TaskCompletionSource<int>(TaskCreationOptions.RunContinuationsAsynchronously);
|
||||
var slow = renderer.RunInOrderAsync(ct => gate.Task.WaitAsync(ct)); // e.g. a big upload with retries
|
||||
var ran = false;
|
||||
using var cts = new CancellationTokenSource();
|
||||
var queued = renderer.RunInOrderAsync(_ => { ran = true; return Task.FromResult(2); }, cts.Token);
|
||||
|
||||
await cts.CancelAsync();
|
||||
await Assert.ThrowsAnyAsync<OperationCanceledException>(() => queued.WaitAsync(TimeSpan.FromSeconds(5)));
|
||||
Assert.False(slow.IsCompleted);
|
||||
|
||||
gate.SetResult(1);
|
||||
Assert.Equal(1, await slow.WaitAsync(TimeSpan.FromSeconds(5)));
|
||||
await renderer.RunInOrderAsync(_ => Task.FromResult(0)).WaitAsync(TimeSpan.FromSeconds(5)); // the loop moved past it
|
||||
Assert.False(ran);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RunInOrderAsync_with_a_cancelled_token_never_queues()
|
||||
{
|
||||
var (_, _, renderer) = Setup();
|
||||
await using var _ = renderer;
|
||||
var ran = false;
|
||||
await Assert.ThrowsAnyAsync<OperationCanceledException>(() =>
|
||||
renderer.RunInOrderAsync(_ => { ran = true; return Task.FromResult(1); }, new CancellationToken(canceled: true)));
|
||||
Assert.False(ran);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task A_work_item_still_queued_when_disposal_gives_up_waiting_is_cancelled()
|
||||
{
|
||||
var (_, _, renderer) = Setup();
|
||||
var stuck = renderer.RunInOrderAsync(async ct =>
|
||||
{
|
||||
await Task.Delay(Timeout.Infinite, ct);
|
||||
return 1;
|
||||
});
|
||||
var ran = false;
|
||||
var queued = renderer.RunInOrderAsync(_ => { ran = true; return Task.FromResult(2); });
|
||||
|
||||
await renderer.DisposeAsync(); // drains for up to 5 s, then cancels the loop
|
||||
|
||||
await Assert.ThrowsAnyAsync<OperationCanceledException>(() => queued.WaitAsync(TimeSpan.FromSeconds(5)));
|
||||
Assert.False(ran);
|
||||
await Assert.ThrowsAnyAsync<OperationCanceledException>(() => stuck);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SayAsync_sends_plain_text_cut_to_4096_characters()
|
||||
{
|
||||
|
||||
@@ -92,4 +92,30 @@ public sealed class OpenAiSpeechToTextTests
|
||||
var ex = await Assert.ThrowsAsync<SpeechToTextException>(() => stt.TranscribeAsync(new byte[] { 1 }, "voice.ogg", "audio/ogg", CancellationToken.None));
|
||||
Assert.Equal("OpenAI transcription failed: the response has no text.", ex.Message);
|
||||
}
|
||||
|
||||
private sealed class HangingHandler : HttpMessageHandler
|
||||
{
|
||||
protected override async Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken cancellationToken)
|
||||
{
|
||||
await Task.Delay(Timeout.Infinite, cancellationToken);
|
||||
throw new InvalidOperationException("unreachable");
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task A_client_timeout_is_a_speech_failure_not_a_cancellation()
|
||||
{
|
||||
var http = new HttpClient(new HangingHandler()) { Timeout = TimeSpan.FromMilliseconds(50) };
|
||||
var stt = new OpenAiSpeechToText(http, new SpeechOptions { ApiKey = Key });
|
||||
var ex = await Assert.ThrowsAsync<SpeechToTextException>(() => stt.TranscribeAsync(new byte[] { 1 }, "voice.ogg", "audio/ogg", CancellationToken.None));
|
||||
Assert.Equal("OpenAI transcription failed: the request timed out.", ex.Message);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task The_callers_cancellation_still_cancels()
|
||||
{
|
||||
var stt = new OpenAiSpeechToText(new HttpClient(new HangingHandler()), new SpeechOptions { ApiKey = Key });
|
||||
using var cts = new CancellationTokenSource(TimeSpan.FromMilliseconds(50));
|
||||
await Assert.ThrowsAnyAsync<OperationCanceledException>(() => stt.TranscribeAsync(new byte[] { 1 }, "voice.ogg", "audio/ogg", cts.Token));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<TelegramSender>.Instance) { FileRetryDelays = [TimeSpan.Zero, TimeSpan.Zero] };
|
||||
var options = new TelegramOptions { EditInterval = TimeSpan.FromMilliseconds(20), TypingInterval = TimeSpan.FromSeconds(10) };
|
||||
var renderer = new ChatRenderer(1001, sender, options, NullLogger.Instance) { ReplyRetryDelays = [TimeSpan.Zero, TimeSpan.Zero] };
|
||||
return (bot, new TelegramChatChannel(1001, sender, renderer), renderer);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SendTextAsync_throws_when_a_chunk_still_fails_after_its_retries_but_sends_the_others()
|
||||
{
|
||||
var (bot, channel, renderer) = Direct();
|
||||
await using var _ = renderer;
|
||||
var markdown = new string('a', 3000) + "\n\n" + new string('b', 3000); // two chunks
|
||||
bot.OnSend = r => { if (r is SendMessageRequest m && m.Text.StartsWith('a')) throw FakeBotClient.NetworkDown(); };
|
||||
|
||||
var ex = await Assert.ThrowsAsync<ChatDeliveryException>(() => channel.SendTextAsync(markdown, CancellationToken.None));
|
||||
|
||||
Assert.Equal("Telegram did not accept the message.", ex.Message);
|
||||
Assert.Equal(3, bot.Sent.Count(m => m.Text.StartsWith('a'))); // the first try and two retries
|
||||
Assert.Single(bot.CurrentTexts.Values, t => t.StartsWith('b'));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SendFileAsync_throws_after_three_attempts_when_the_network_stays_down()
|
||||
{
|
||||
var (bot, channel, renderer) = Direct();
|
||||
await using var _ = renderer;
|
||||
var path = Path.Combine(Path.GetTempPath(), $"volcanic-channel-{Guid.NewGuid():N}.pdf");
|
||||
await File.WriteAllBytesAsync(path, [1, 2, 3]);
|
||||
try
|
||||
{
|
||||
bot.OnSend = r => { if (r is SendDocumentRequest) throw FakeBotClient.NetworkDown(); };
|
||||
var ex = await Assert.ThrowsAsync<ChatDeliveryException>(() => channel.SendFileAsync(new ChatFile(path), CancellationToken.None));
|
||||
Assert.StartsWith("Telegram refused the file: ", ex.Message, StringComparison.Ordinal);
|
||||
Assert.Equal(3, bot.Requests.OfType<SendDocumentRequest>().Count());
|
||||
}
|
||||
finally
|
||||
{
|
||||
File.Delete(path);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<GetFileRequest>()); // downloaded once, transcribed from the saved file
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("Песня.mp3", "audio/mpeg", "audio.mp3")]
|
||||
[InlineData("talk.M4A", "audio/mp4", "audio.m4a")]
|
||||
[InlineData(null, "audio/mp4", "audio.m4a")]
|
||||
[InlineData(null, "audio/ogg", "audio.ogg")]
|
||||
[InlineData("memo.oga", "audio/ogg", "audio.ogg")]
|
||||
[InlineData("noext", null, "audio.mp3")]
|
||||
public void Audio_goes_to_speech_to_text_under_an_ascii_name_with_a_known_extension(string? fileName, string? mime, string expected) =>
|
||||
Assert.Equal(expected, Volcanic.AgenticCore.Telegram.Inbound.InboundMedia.SpeechFileName(fileName, mime));
|
||||
|
||||
[Fact]
|
||||
public async Task Without_speech_to_text_an_audio_file_is_still_saved_and_prompted()
|
||||
{
|
||||
@@ -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);
|
||||
|
||||
@@ -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 <root>/outside/secret
|
||||
Assert.Equal("path escapes the workspace: l", await Fails(new SendArgs("l")));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task A_link_loop_fails()
|
||||
{
|
||||
if (OperatingSystem.IsWindows()) return;
|
||||
System.IO.File.CreateSymbolicLink(Path.Combine(_ws, "a"), "b");
|
||||
System.IO.File.CreateSymbolicLink(Path.Combine(_ws, "b"), "a");
|
||||
Assert.Equal("path escapes the workspace: a", await Fails(new SendArgs("a")));
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(".")]
|
||||
[InlineData("ROOT")]
|
||||
public async Task The_workspace_itself_is_a_directory(string path)
|
||||
{
|
||||
if (path == "ROOT") path = _ws;
|
||||
Assert.Equal($"path is a directory, not a file: {path}", await Fails(new SendArgs(path)));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Missing_file_fails() =>
|
||||
Assert.Equal("file not found: nope.pdf", await Fails(new SendArgs("nope.pdf")));
|
||||
|
||||
@@ -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; }
|
||||
|
||||
Reference in New Issue
Block a user