forked from admin/VolcanicAgenticCore
165 lines
8.4 KiB
C#
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);
|
|
}
|