Files
Playa/tests/Volcanic.AgenticCore.Telegram.Tests/Support/FakeBotClient.cs

165 lines
8.4 KiB
C#

using System.Collections.Concurrent;
using System.Net;
using Telegram.Bot;
using Telegram.Bot.Args;
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;
/// <summary>
/// In-memory <see cref="ITelegramBotClient"/>: records every request, answers sends with fake messages and plays a
/// script of <c>getUpdates</c> results. When the script is exhausted a poll blocks until cancelled, like an idle long poll.
/// </summary>
public sealed class FakeBotClient : ITelegramBotClient
{
private readonly ConcurrentQueue<Func<Update[]>> _polls = new();
private readonly List<IRequest> _requests = [];
private int _nextMessageId = 100;
public bool LocalBotServer => false;
public long BotId => 42;
public TimeSpan Timeout { get; set; } = TimeSpan.FromSeconds(50);
public IExceptionParser ExceptionsParser { get; set; } = new DefaultExceptionParser();
public event AsyncEventHandler<ApiRequestEventArgs>? OnMakingApiRequest;
public event AsyncEventHandler<ApiResponseEventArgs>? OnApiResponseReceived;
/// <summary>Downloadable files by file id: <c>getFile</c> answers with their size and <c>DownloadFile</c> writes their bytes.</summary>
public ConcurrentDictionary<string, byte[]> Files { get; } = new();
/// <summary>When set, a download of a scripted file writes one byte and then fails with a network error.</summary>
public bool FailDownloads { get; set; }
/// <summary>Users <c>getChatMember</c> reports as having left (everyone else is a member).</summary>
public ConcurrentDictionary<long, bool> NonMembers { get; } = new();
/// <summary>Called for every non-poll request before it is answered; throw to simulate a failure.</summary>
public Action<IRequest>? OnSend { get; set; }
public IReadOnlyList<IRequest> Requests { get { lock (_requests) return _requests.ToList(); } }
/// <summary>The offset of every <c>getUpdates</c> call (the library reuses one request object, so it is captured per call).</summary>
public IReadOnlyList<int?> PollOffsets { get { lock (_pollOffsets) return _pollOffsets.ToList(); } }
private readonly List<int?> _pollOffsets = [];
public IReadOnlyList<SendMessageRequest> Sent => Requests.OfType<SendMessageRequest>().ToList();
public IReadOnlyList<EditMessageTextRequest> Edits => Requests.OfType<EditMessageTextRequest>().ToList();
public IReadOnlyList<SetMessageReactionRequest> Reactions => Requests.OfType<SetMessageReactionRequest>().ToList();
/// <summary>The text each message still in the chat shows now (after edits and deletes), by message id.</summary>
public IReadOnlyDictionary<int, string> CurrentTexts => new Dictionary<int, string>(_texts);
private readonly ConcurrentDictionary<int, string> _texts = new();
public sealed record Upload(string Method, string FileName, byte[] Bytes, string? Caption, ParseMode ParseMode, int MessageId);
private readonly List<Upload> _uploads = [];
/// <summary>Photos and documents sent, with the uploaded bytes (copied inside the request).</summary>
public IReadOnlyList<Upload> Uploads { get { lock (_uploads) return _uploads.ToList(); } }
private Message RecordUpload(string method, ChatId chatId, InputFile file, string? caption, ParseMode parseMode)
{
var input = (InputFileStream)file;
using var copy = new MemoryStream();
input.Content.CopyTo(copy);
var id = Interlocked.Increment(ref _nextMessageId);
lock (_uploads) _uploads.Add(new Upload(method, input.FileName ?? "", copy.ToArray(), caption, parseMode, id));
return new Message { Id = id, Chat = new Chat { Id = chatId.Identifier ?? 0 }, Caption = caption };
}
public FakeBotClient EnqueueUpdates(params Update[] updates)
{
_polls.Enqueue(() => updates);
return this;
}
public FakeBotClient EnqueueFailure(Exception exception)
{
_polls.Enqueue(() => throw exception);
return this;
}
public static RequestException NetworkDown() =>
new("Bot API Service Failure: HttpRequestException: Name or service not known", new HttpRequestException("Name or service not known"));
public static ApiRequestException Unauthorized() => new("Unauthorized", (int)HttpStatusCode.Unauthorized);
public async Task<TResponse> SendRequest<TResponse>(IRequest<TResponse> request, CancellationToken cancellationToken = default)
{
lock (_requests) _requests.Add(request);
if (OnMakingApiRequest is not null) await OnMakingApiRequest(this, new ApiRequestEventArgs(request), cancellationToken);
if (request is GetUpdatesRequest getUpdates)
{
lock (_pollOffsets) _pollOffsets.Add(getUpdates.Offset);
if (!_polls.TryDequeue(out var poll))
{
await Task.Delay(System.Threading.Timeout.Infinite, cancellationToken);
throw new OperationCanceledException(cancellationToken);
}
var updates = poll(); // may throw: a failed poll
if (OnApiResponseReceived is not null)
{
using var response = new HttpResponseMessage(HttpStatusCode.OK);
await OnApiResponseReceived(this, new ApiResponseEventArgs(response, new ApiRequestEventArgs(request)), cancellationToken);
}
return (TResponse)(object)updates;
}
OnSend?.Invoke(request);
object result;
switch (request)
{
case SendMessageRequest send:
var id = Interlocked.Increment(ref _nextMessageId);
_texts[id] = send.Text;
result = new Message { Id = id, Chat = new Chat { Id = send.ChatId.Identifier ?? 0 }, Text = send.Text };
break;
case EditMessageTextRequest edit:
_texts[edit.MessageId] = edit.Text!;
result = new Message { Id = edit.MessageId, Text = edit.Text };
break;
case DeleteMessageRequest delete:
_texts.TryRemove(delete.MessageId, out _);
result = true;
break;
case GetFileRequest getFile when Files.TryGetValue(getFile.FileId, out var bytes):
result = new TGFile { FileId = getFile.FileId, FileUniqueId = getFile.FileId + "-u", FilePath = $"files/{getFile.FileId}", FileSize = bytes.Length };
break;
case GetFileRequest getFile:
result = new TGFile { FileId = getFile.FileId, FileUniqueId = getFile.FileId + "-u", FilePath = $"photos/{getFile.FileId}.jpg", FileSize = 3 };
break;
case GetChatMemberRequest member:
var user = new User { Id = member.UserId, FirstName = "Member" };
result = NonMembers.ContainsKey(member.UserId) ? new ChatMemberLeft { User = user } : new ChatMemberMember { User = user };
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;
}
return typeof(TResponse) == typeof(bool) ? (TResponse)(object)true : (TResponse)result;
}
public Task<bool> TestApi(CancellationToken cancellationToken = default) => Task.FromResult(true);
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);
}