From 9a05bae4a9c9a02e4fc5edf566beb2f6e2c87901 Mon Sep 17 00:00:00 2001 From: Erik Simon Date: Sat, 4 Apr 2026 04:12:11 +0200 Subject: [PATCH] Add channel system with CLI, Telegram, and Web channel implementations Define channel abstractions (IChannel, IChannelManager) with event-driven message and connection handling. Implement ChannelManager for multi-channel orchestration, CliChannel for terminal I/O, TelegramChannel with adapter pattern for bot API integration, and WebChannel stub. Includes DI registration and channel type enumeration. --- .../ChannelConnectionEventArgs.cs | 27 +++ .../ChannelMessageReceivedEventArgs.cs | 25 +++ Luna.Channels.Abstractions/ChannelType.cs | 8 + Luna.Channels.Abstractions/IChannel.cs | 60 +++++++ Luna.Channels.Abstractions/IChannelManager.cs | 37 ++++ .../Luna.Channels.Abstractions.csproj | 13 ++ Luna.Channels/ChannelManager.cs | 62 +++++++ Luna.Channels/Cli/CliChannel.cs | 38 +++++ .../Extensions/ServiceCollectionExtensions.cs | 28 +++ Luna.Channels/Luna.Channels.csproj | 22 +++ Luna.Channels/Telegram/TelegramAdapter.cs | 123 +++++++++++++ Luna.Channels/Telegram/TelegramChannel.cs | 161 ++++++++++++++++++ Luna.Channels/Web/WebChannel.cs | 38 +++++ 13 files changed, 642 insertions(+) create mode 100644 Luna.Channels.Abstractions/ChannelConnectionEventArgs.cs create mode 100644 Luna.Channels.Abstractions/ChannelMessageReceivedEventArgs.cs create mode 100644 Luna.Channels.Abstractions/ChannelType.cs create mode 100644 Luna.Channels.Abstractions/IChannel.cs create mode 100644 Luna.Channels.Abstractions/IChannelManager.cs create mode 100644 Luna.Channels.Abstractions/Luna.Channels.Abstractions.csproj create mode 100644 Luna.Channels/ChannelManager.cs create mode 100644 Luna.Channels/Cli/CliChannel.cs create mode 100644 Luna.Channels/Extensions/ServiceCollectionExtensions.cs create mode 100644 Luna.Channels/Luna.Channels.csproj create mode 100644 Luna.Channels/Telegram/TelegramAdapter.cs create mode 100644 Luna.Channels/Telegram/TelegramChannel.cs create mode 100644 Luna.Channels/Web/WebChannel.cs diff --git a/Luna.Channels.Abstractions/ChannelConnectionEventArgs.cs b/Luna.Channels.Abstractions/ChannelConnectionEventArgs.cs new file mode 100644 index 0000000..d5b1cbf --- /dev/null +++ b/Luna.Channels.Abstractions/ChannelConnectionEventArgs.cs @@ -0,0 +1,27 @@ +namespace Luna.Channels.Abstractions; + +/// +/// Event arguments for channel connection state changes +/// +public class ChannelConnectionEventArgs : EventArgs +{ + /// + /// The channel whose connection state changed + /// + public required IChannel Channel { get; init; } + + /// + /// The previous connection state + /// + public bool WasConnected { get; init; } + + /// + /// The new connection state + /// + public bool IsConnected { get; init; } + + /// + /// Optional error message if connection failed + /// + public string? ErrorMessage { get; init; } +} \ No newline at end of file diff --git a/Luna.Channels.Abstractions/ChannelMessageReceivedEventArgs.cs b/Luna.Channels.Abstractions/ChannelMessageReceivedEventArgs.cs new file mode 100644 index 0000000..7061719 --- /dev/null +++ b/Luna.Channels.Abstractions/ChannelMessageReceivedEventArgs.cs @@ -0,0 +1,25 @@ +using Luna.Shared; + +namespace Luna.Channels.Abstractions; + +/// +/// Event arguments for when a message is received from a channel +/// +public class ChannelMessageReceivedEventArgs : EventArgs +{ + /// + /// The received message + /// + public required ChannelMessage Message { get; init; } + + /// + /// The channel that received the message + /// + public required IChannel Channel { get; init; } + + /// + /// Set to true to indicate the message has been handled and should not be processed further + /// + public bool IsHandled { get; set; } + +} diff --git a/Luna.Channels.Abstractions/ChannelType.cs b/Luna.Channels.Abstractions/ChannelType.cs new file mode 100644 index 0000000..d135f14 --- /dev/null +++ b/Luna.Channels.Abstractions/ChannelType.cs @@ -0,0 +1,8 @@ +namespace Luna.Channels.Abstractions; + +public static class ChannelType +{ + public const string Cli = "cli"; + public const string Web = "web"; + public const string Telegram = "telegram"; +} \ No newline at end of file diff --git a/Luna.Channels.Abstractions/IChannel.cs b/Luna.Channels.Abstractions/IChannel.cs new file mode 100644 index 0000000..00f133a --- /dev/null +++ b/Luna.Channels.Abstractions/IChannel.cs @@ -0,0 +1,60 @@ +using Luna.Shared; + +namespace Luna.Channels.Abstractions; + +public delegate Task ChannelMessageReceivedEventHandler(object? sender, ChannelMessageReceivedEventArgs args); +public delegate Task ChannelConnectionChangedEventHandler(object? sender, ChannelConnectionEventArgs args); + +/// +/// Represents a communication channel for the Luna AI Assistant. +/// Channels handle sending and receiving messages via different platforms +/// (e.g., CLI, Telegram, Discord, Email). +/// +public interface IChannel : IDisposable +{ + /// + /// Unique identifier for this channel instance + /// + string ChannelId { get; } + + /// + /// Type of channel (e.g., "cli", "telegram", "discord") + /// + string ChannelType { get; } + + /// + /// Display name for the channel + /// + string DisplayName { get; } + + /// + /// Indicates whether the channel is currently connected and ready + /// + bool IsConnected { get; } + + /// + /// Event raised when a message is received from the channel + /// + event ChannelMessageReceivedEventHandler? MessageReceived; + + /// + /// Event raised when the channel connection state changes + /// + event ChannelConnectionChangedEventHandler? ConnectionStateChanged; + + /// + /// Send a message to the channel + /// + /// The message to send + /// Cancellation token + /// Task representing the send operation + Task SendMessageAsync(ChannelMessage message, CancellationToken cancellationToken = default); + + /// + /// Send a streaming response to the channel + /// + /// Async enumerable of response updates + /// Cancellation token + /// Task representing the send operation + Task SendStreamingMessageAsync(IAsyncEnumerable messageStream, CancellationToken cancellationToken = default); +} \ No newline at end of file diff --git a/Luna.Channels.Abstractions/IChannelManager.cs b/Luna.Channels.Abstractions/IChannelManager.cs new file mode 100644 index 0000000..e586faf --- /dev/null +++ b/Luna.Channels.Abstractions/IChannelManager.cs @@ -0,0 +1,37 @@ +namespace Luna.Channels.Abstractions; + +/// +/// Manages the lifecycle and routing of all registered communication channels. +/// +public interface IChannelManager +{ + /// + /// Registers a channel with this manager. + /// + /// + /// Thrown if a channel with the same ID is already registered. + void RegisterChannel(IChannel channel); + + /// + /// Unregisters a channel by its ID. No-op if the channel is not found. + /// + /// The ID of the channel to remove. + void UnregisterChannel(string channelId); + + /// + /// Retrieves a registered channel by its ID. + /// + /// The ID of the channel to retrieve. + /// The channel, or null if not found. + IChannel? GetChannel(string channelId); + + /// + /// Returns all currently registered channels. + /// + IReadOnlyList GetAllChannels(); + + /// + /// Raised after a message has been received and routed. + /// + event EventHandler? MessageRouted; +} diff --git a/Luna.Channels.Abstractions/Luna.Channels.Abstractions.csproj b/Luna.Channels.Abstractions/Luna.Channels.Abstractions.csproj new file mode 100644 index 0000000..86df37a --- /dev/null +++ b/Luna.Channels.Abstractions/Luna.Channels.Abstractions.csproj @@ -0,0 +1,13 @@ + + + + net10.0 + enable + enable + + + + + + + diff --git a/Luna.Channels/ChannelManager.cs b/Luna.Channels/ChannelManager.cs new file mode 100644 index 0000000..c728370 --- /dev/null +++ b/Luna.Channels/ChannelManager.cs @@ -0,0 +1,62 @@ +using System.Collections.Immutable; +using Luna.Channels.Abstractions; +using Luna.Core.Abstractions; +using Microsoft.Extensions.Logging; + +namespace Luna.Channels; + +public class ChannelManager(ISessionManager sessionManager, ILogger logger) : IChannelManager +{ + private readonly Dictionary channels = new(); + private readonly Dictionary sessionMap = new(); + + public event EventHandler? MessageRouted; + + public void RegisterChannel(IChannel channel) + { + if (!channels.TryAdd(channel.ChannelId, channel)) + throw new InvalidOperationException($"Channel with id {channel.ChannelId} is already registered."); + + channel.MessageReceived += OnMessageReceivedAsync; + } + + public void UnregisterChannel(string channelId) + { + if (!channels.TryGetValue(channelId, out var channel)) + return; + + channel.MessageReceived -= OnMessageReceivedAsync; + channels.Remove(channelId); + } + + public IChannel? GetChannel(string channelId) + { + channels.TryGetValue(channelId, out var channel); + return channel; + } + + public IReadOnlyList GetAllChannels() + => channels.Values.ToImmutableList(); + + private async Task OnMessageReceivedAsync(object? sender, ChannelMessageReceivedEventArgs args) + { + if (args.IsHandled) + return; + + var channel = args.Channel; + var message = args.Message; + + try + { + var response = sessionManager + .RouteMessagesAsync(message.Content, message.ConversationId, channel.ChannelId); + + await channel.SendStreamingMessageAsync(response); + MessageRouted?.Invoke(this, args); + } + catch (Exception exception) + { + logger.LogError(exception, "Message routing for channel of type {Type} with id {Id} failed", channel.ChannelType, channel.ChannelId); + } + } +} \ No newline at end of file diff --git a/Luna.Channels/Cli/CliChannel.cs b/Luna.Channels/Cli/CliChannel.cs new file mode 100644 index 0000000..b15da0b --- /dev/null +++ b/Luna.Channels/Cli/CliChannel.cs @@ -0,0 +1,38 @@ +using Luna.Channels.Abstractions; +using Luna.Core.Abstractions; +using Luna.Shared; + +namespace Luna.Channels.Cli; + +public class CliChannel(string channelId, IChatHubContext hubContext) : IChannel +{ + public string ChannelId { get; } = channelId; + public string ChannelType => Abstractions.ChannelType.Cli; + public string DisplayName { get; } = "Cli"; + public bool IsConnected { get; } + + public event ChannelMessageReceivedEventHandler? MessageReceived; + public event ChannelConnectionChangedEventHandler? ConnectionStateChanged; + + public async Task SendStreamingMessageAsync(IAsyncEnumerable messageStream, CancellationToken cancellationToken = default) + { + await hubContext.SendStreamingResponseAsync(ChannelId, messageStream, cancellationToken); + } + + public async Task SendMessageAsync(ChannelMessage message, CancellationToken cancellationToken = default) + { + await hubContext.SendResponseAsync(ChannelId, message, cancellationToken); + } + + public void RaiseMessageReceived(object? sender, ChannelMessage message) + { + MessageReceived?.Invoke(sender, new ChannelMessageReceivedEventArgs + { + Channel = this, + Message = message, + IsHandled = false + }); + } + + public void Dispose() { } +} \ No newline at end of file diff --git a/Luna.Channels/Extensions/ServiceCollectionExtensions.cs b/Luna.Channels/Extensions/ServiceCollectionExtensions.cs new file mode 100644 index 0000000..696616f --- /dev/null +++ b/Luna.Channels/Extensions/ServiceCollectionExtensions.cs @@ -0,0 +1,28 @@ +using Luna.Channels.Abstractions; +using Luna.Channels.Telegram; +using Luna.Configuration; +using Microsoft.Extensions.DependencyInjection; +using Telegram.Bot; + +namespace Luna.Channels.Extensions; + +public static class ServiceCollectionExtensions +{ + private const string TelegramBotToken = "REDACTED-TELEGRAM-BOT-TOKEN"; + + extension(IServiceCollection services) + { + public IServiceCollection AddChannels() + { + services.AddSingleton(); + + // Telegram + var telegramOptions = new TelegramOptions { BotToken = TelegramBotToken }; + services.AddSingleton(telegramOptions); + services.AddSingleton(_ => new TelegramBotClient(TelegramBotToken)); + services.AddHostedService(); + + return services; + } + } +} diff --git a/Luna.Channels/Luna.Channels.csproj b/Luna.Channels/Luna.Channels.csproj new file mode 100644 index 0000000..48e9cc5 --- /dev/null +++ b/Luna.Channels/Luna.Channels.csproj @@ -0,0 +1,22 @@ + + + + net10.0 + enable + enable + + + + + + + + + + + + + + + + diff --git a/Luna.Channels/Telegram/TelegramAdapter.cs b/Luna.Channels/Telegram/TelegramAdapter.cs new file mode 100644 index 0000000..7d98e67 --- /dev/null +++ b/Luna.Channels/Telegram/TelegramAdapter.cs @@ -0,0 +1,123 @@ +using Luna.Channels.Abstractions; +using Luna.Configuration; +using Luna.Shared; +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Logging; +using Telegram.Bot; +using Telegram.Bot.Polling; +using Telegram.Bot.Types; + +namespace Luna.Channels.Telegram; + +public class TelegramAdapter( + IChannelManager channelManager, + ITelegramBotClient botClient, + TelegramOptions options, + ILogger adapterLogger, + ILogger channelLogger) : IHostedService, IUpdateHandler +{ + private readonly Dictionary channels = new(); + private CancellationTokenSource? cts; + + public Task StartAsync(CancellationToken cancellationToken) + { + cts = new CancellationTokenSource(); + botClient.StartReceiving(this, new ReceiverOptions(), cts.Token); + adapterLogger.LogInformation("Telegram adapter started polling"); + return Task.CompletedTask; + } + + public Task StopAsync(CancellationToken cancellationToken) + { + cts?.Cancel(); + cts?.Dispose(); + + foreach (var (_, channel) in channels) + { + channelManager.UnregisterChannel(channel.ChannelId); + channel.Dispose(); + } + + channels.Clear(); + adapterLogger.LogInformation("Telegram adapter stopped"); + return Task.CompletedTask; + } + + public async Task HandleUpdateAsync(ITelegramBotClient client, Update update, CancellationToken cancellationToken) + { + if (update.Message is not { } message) + return; + + if (message.Text is null or "") + return; + + var chat = message.Chat; + var channel = GetOrCreateChannel(chat.Id); + + if (message.Text.StartsWith('/')) + { + await HandleCommandAsync(channel, message, cancellationToken); + return; + } + + var channelMessage = new ChannelMessage( + MessageId: message.Id.ToString(), + ConversationId: chat.Id.ToString(), + SenderId: message.From?.Id.ToString() ?? "unknown", + SenderName: message.From?.FirstName ?? "Unknown", + Content: message.Text, + Timestamp: new DateTimeOffset(message.Date, TimeSpan.Zero)); + + channel.RaiseMessageReceived(channelMessage); + } + + public Task HandleErrorAsync(ITelegramBotClient client, Exception exception, HandleErrorSource source, + CancellationToken cancellationToken) + { + adapterLogger.LogError(exception, "Telegram polling error from {Source}", source); + return Task.CompletedTask; + } + + private TelegramChannel GetOrCreateChannel(long chatId) + { + if (channels.TryGetValue(chatId, out var existing)) + return existing; + + var channel = new TelegramChannel( + chatId.ToString(), + botClient, + channelLogger, + options); + + channels[chatId] = channel; + channelManager.RegisterChannel(channel); + adapterLogger.LogInformation("Created TelegramChannel for chat {ChatId}", chatId); + + return channel; + } + + private async Task HandleCommandAsync(TelegramChannel channel, Message message, CancellationToken cancellationToken) + { + var command = message.Text!.Split(' ')[0].ToLowerInvariant(); + + var responseText = command switch + { + "/start" => "Welcome to Luna AI Assistant! Send me a message and I'll do my best to help.", + "/help" => "Luna AI Assistant\n\nCommands:\n/start - Start the bot\n/help - Show this help message\n\nJust send a message to chat with Luna!", + _ => null + }; + + if (responseText is null) + return; + + var responseMessage = new ChannelMessage( + MessageId: Guid.NewGuid().ToString(), + ConversationId: message.Chat.Id.ToString(), + SenderId: "luna", + SenderName: "Luna", + Content: responseText, + Timestamp: DateTimeOffset.UtcNow); + + await channel.SendMessageAsync(responseMessage, cancellationToken); + } +} diff --git a/Luna.Channels/Telegram/TelegramChannel.cs b/Luna.Channels/Telegram/TelegramChannel.cs new file mode 100644 index 0000000..40b18d1 --- /dev/null +++ b/Luna.Channels/Telegram/TelegramChannel.cs @@ -0,0 +1,161 @@ +using System.Text; +using Luna.Channels.Abstractions; +using Luna.Configuration; +using Luna.Shared; +using Microsoft.Extensions.Logging; +using Telegram.Bot; +using Telegram.Bot.Types.Enums; + +namespace Luna.Channels.Telegram; + +public class TelegramChannel( + string chatId, + ITelegramBotClient botClient, + ILogger logger, + TelegramOptions options) : IChannel +{ + private const int TelegramMessageCharLimit = 4096; + + public string ChannelId { get; } = chatId; + public string ChannelType => Abstractions.ChannelType.Telegram; + public string DisplayName { get; } = $"Telegram Chat {chatId}"; + public bool IsConnected { get; private set; } = true; + + public event ChannelMessageReceivedEventHandler? MessageReceived; + public event ChannelConnectionChangedEventHandler? ConnectionStateChanged; + + public async Task SendMessageAsync(ChannelMessage message, CancellationToken cancellationToken = default) + { + try + { + var targetChatId = long.Parse(ChannelId); + await botClient.SendMessage(targetChatId, message.Content, cancellationToken: cancellationToken); + } + catch (Exception ex) + { + logger.LogError(ex, "Failed to send message to Telegram chat {ChatId}", ChannelId); + } + } + + public async Task SendStreamingMessageAsync( + IAsyncEnumerable messageStream, + CancellationToken cancellationToken = default) + { + var targetChatId = long.Parse(ChannelId); + int? currentMessageId = null; + + var typingCts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); + var typingTask = KeepTypingAsync(targetChatId, typingCts.Token); + + var messageBuilder = new StringBuilder(); + await foreach (var update in messageStream.WithCancellation(cancellationToken)) + { + if (update.Content is not null) + messageBuilder.Append(update.Content); + + if (messageBuilder.Length <= TelegramMessageCharLimit) + continue; + + try + { + var currentMessageContent = messageBuilder.ToString(); + var splitByParagraph = currentMessageContent.Split('\n'); + + var i = 0; + var builder = new StringBuilder(); + foreach (var paragraph in splitByParagraph) + { + if (builder.Length + paragraph.Length < TelegramMessageCharLimit) + { + builder.AppendLine(paragraph); + i++; + continue; + } + + await botClient.SendMessage( + targetChatId, + builder.ToString(), + cancellationToken: cancellationToken); + + messageBuilder.Clear(); + messageBuilder.Append(string.Join('\n', splitByParagraph[i..])); + break; + } + } + catch (Exception ex) + { + await typingCts.CancelAsync(); + logger.LogError(ex, "Failed to send streaming update to Telegram chat {ChatId}", ChannelId); + } + } + + try + { + if (messageBuilder.Length > 0) + { + await botClient.SendMessage( + targetChatId, + messageBuilder.ToString(), + cancellationToken: cancellationToken); + } + } + catch (Exception ex) + { + await typingCts.CancelAsync(); + logger.LogError(ex, "Failed to send streaming update to Telegram chat {ChatId}", ChannelId); + } + + await typingCts.CancelAsync(); + } + + /// + /// Called by TelegramAdapter when an incoming message is received for this chat. + /// Applies AllowedUserIds filtering and null/empty text filtering before raising the event. + /// + public void RaiseMessageReceived(ChannelMessage message) + { + if (string.IsNullOrEmpty(message.Content)) + return; + + if (options.AllowedUserIds.Length > 0 && + !options.AllowedUserIds.Contains(message.SenderId)) + return; + + MessageReceived?.Invoke(this, new ChannelMessageReceivedEventArgs + { + Channel = this, + Message = message, + IsHandled = false + }); + } + + public void Dispose() + { + IsConnected = false; + ConnectionStateChanged?.Invoke(this, new ChannelConnectionEventArgs + { + Channel = this, + IsConnected = false + }); + } + + private async Task KeepTypingAsync(long chatId, CancellationToken ct) + { + while (!ct.IsCancellationRequested) + { + try + { + await botClient.SendChatAction(chatId, ChatAction.Typing, null, null, ct); + await Task.Delay(4000, ct); + } + catch (OperationCanceledException) + { + break; + } + catch (Exception ex) + { + logger.LogError(ex, "Failed to send typing action to Telegram chat {ChatId}", chatId); + } + } + } +} diff --git a/Luna.Channels/Web/WebChannel.cs b/Luna.Channels/Web/WebChannel.cs new file mode 100644 index 0000000..09a01c5 --- /dev/null +++ b/Luna.Channels/Web/WebChannel.cs @@ -0,0 +1,38 @@ +using Luna.Channels.Abstractions; +using Luna.Core.Abstractions; +using Luna.Shared; + +namespace Luna.Channels.Web; + +public class WebChannel(string channelId, IChatHubContext hubContext) : IChannel +{ + public string ChannelId { get; } = channelId; + public string ChannelType => Abstractions.ChannelType.Web; + public string DisplayName { get; } = "Web Interface"; + public bool IsConnected { get; } + + public event ChannelMessageReceivedEventHandler? MessageReceived; + public event ChannelConnectionChangedEventHandler? ConnectionStateChanged; + + public async Task SendStreamingMessageAsync(IAsyncEnumerable messageStream, CancellationToken cancellationToken = default) + { + await hubContext.SendStreamingResponseAsync(ChannelId, messageStream, cancellationToken); + } + + public async Task SendMessageAsync(ChannelMessage message, CancellationToken cancellationToken = default) + { + await hubContext.SendResponseAsync(ChannelId, message, cancellationToken); + } + + public void RaiseMessageReceived(object? sender, ChannelMessage message) + { + MessageReceived?.Invoke(sender, new ChannelMessageReceivedEventArgs + { + Channel = this, + Message = message, + IsHandled = false + }); + } + + public void Dispose() { } +} \ No newline at end of file