forked from admin/VolcanicAgenticCore
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.
100 lines
5.0 KiB
C#
100 lines
5.0 KiB
C#
using Microsoft.Extensions.DependencyInjection;
|
||
using Microsoft.Extensions.Logging;
|
||
using Microsoft.Extensions.Logging.Abstractions;
|
||
using Telegram.Bot.Types;
|
||
using Volcanic.AgenticCore.Features.Agent;
|
||
using Volcanic.AgenticCore.Features.Extensions;
|
||
using Volcanic.AgenticCore.Features.Sessions;
|
||
using Volcanic.AgenticCore.Hosting;
|
||
using Volcanic.AgenticCore.Telegram.Chats;
|
||
using Volcanic.AgenticCore.Telegram.Inbound;
|
||
using Volcanic.AgenticCore.Telegram.Rendering;
|
||
using Volcanic.AgenticCore.Testing;
|
||
|
||
namespace Volcanic.AgenticCore.Telegram.Tests.Support;
|
||
|
||
/// <summary>
|
||
/// The bot's chat side wired to a <see cref="FakeBotClient"/> and the <see cref="FauxProvider"/>; <paramref name="configure"/>
|
||
/// adds services and <paramref name="core"/> adjusts the core options (extensions, tools) before the provider is built;
|
||
/// either may capture the host, whose <see cref="Chats"/> exist by the time a session runs.
|
||
/// </summary>
|
||
public sealed class ChatHost : IAsyncDisposable
|
||
{
|
||
public FakeBotClient Bot { get; } = new();
|
||
public FauxProvider Faux { get; } = new();
|
||
public TelegramOptions Options { get; }
|
||
public ChatRegistry Chats { get; }
|
||
public UpdateRouter Router { get; }
|
||
private readonly ServiceProvider _services;
|
||
private readonly string _root;
|
||
|
||
public ChatHost(Action<ChatHost, IServiceCollection>? configure = null, Action<AgenticCoreOptions>? core = null, TimeProvider? time = 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
|
||
{
|
||
BotToken = "test",
|
||
AllowedUserIds = [Updates.AllowedUser],
|
||
WorkspaceRoot = Path.Combine(_root, "workspaces"),
|
||
Model = FauxProvider.ModelKey,
|
||
EditInterval = TimeSpan.FromMilliseconds(20),
|
||
TypingInterval = TimeSpan.FromMilliseconds(50),
|
||
InputDebounce = TimeSpan.FromMilliseconds(20),
|
||
};
|
||
var store = new InMemorySessionStore();
|
||
var services = new ServiceCollection();
|
||
configure?.Invoke(this, services);
|
||
services.AddAgenticCore(o =>
|
||
{
|
||
o.AgentDir = Path.Combine(_root, "agent");
|
||
o.DiscoverExtensionAssemblies = false;
|
||
o.DefaultModel = FauxProvider.ModelKey;
|
||
o.Extensions.Add(ExtensionRegistration.FromInstance(Faux.Extension));
|
||
o.SessionStoreFactory = _ => store;
|
||
o.ProcessEnvironment.HardenHostProcess = false;
|
||
core?.Invoke(o);
|
||
});
|
||
_services = services.BuildServiceProvider();
|
||
ILoggerFactory loggers = NullLoggerFactory.Instance;
|
||
var sender = new TelegramSender(Bot, NullLogger<TelegramSender>.Instance);
|
||
Chats = new ChatRegistry(_services.GetRequiredService<IAgentSessionFactory>(), sender, Options, loggers, time, calendar);
|
||
var media = new InboundMedia(sender, Options, time ?? TimeProvider.System, NullLogger<InboundMedia>.Instance, speech);
|
||
Composer = new InboundComposer(sender, media, NullLogger<InboundComposer>.Instance);
|
||
Router = new UpdateRouter(Options, Chats, new ChatCommands(sender), sender, Composer, NullLogger<UpdateRouter>.Instance);
|
||
}
|
||
|
||
/// <summary>The composer the router uses (content steps 1–3).</summary>
|
||
public InboundComposer Composer { get; }
|
||
|
||
/// <summary>The chat's workspace directory (its session's cwd).</summary>
|
||
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);
|
||
|
||
public async Task<ChatContext> ChatAsync(long chatId = Updates.AllowedUser) => await Chats.GetAsync(chatId);
|
||
|
||
/// <summary>Wait until the chat's inbox has flushed, its run has settled and everything it produced is rendered.</summary>
|
||
/// <remarks>With a <see cref="Microsoft.Extensions.Time.Testing.FakeTimeProvider"/>, advance past the input window before settling, or the drain times out.</remarks>
|
||
public async Task SettleAsync(long chatId = Updates.AllowedUser)
|
||
{
|
||
var chat = await ChatAsync(chatId);
|
||
await chat.Inbox.DrainAsync().WaitAsync(TimeSpan.FromSeconds(10));
|
||
await chat.LastPrompt.WaitAsync(TimeSpan.FromSeconds(10));
|
||
await Wait.UntilAsync(() => !chat.Session.IsStreaming, "the run to settle");
|
||
await chat.Renderer.DrainAsync().WaitAsync(TimeSpan.FromSeconds(5));
|
||
}
|
||
|
||
public IEnumerable<string> Texts => Bot.CurrentTexts.OrderBy(kv => kv.Key).Select(kv => kv.Value);
|
||
|
||
public async ValueTask DisposeAsync()
|
||
{
|
||
await Chats.DisposeAsync();
|
||
await _services.DisposeAsync();
|
||
try { Directory.Delete(_root, true); } catch (IOException) { }
|
||
}
|
||
}
|