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