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.
This commit is contained in:
@@ -0,0 +1,27 @@
|
|||||||
|
namespace Luna.Channels.Abstractions;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Event arguments for channel connection state changes
|
||||||
|
/// </summary>
|
||||||
|
public class ChannelConnectionEventArgs : EventArgs
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// The channel whose connection state changed
|
||||||
|
/// </summary>
|
||||||
|
public required IChannel Channel { get; init; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The previous connection state
|
||||||
|
/// </summary>
|
||||||
|
public bool WasConnected { get; init; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The new connection state
|
||||||
|
/// </summary>
|
||||||
|
public bool IsConnected { get; init; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Optional error message if connection failed
|
||||||
|
/// </summary>
|
||||||
|
public string? ErrorMessage { get; init; }
|
||||||
|
}
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
using Luna.Shared;
|
||||||
|
|
||||||
|
namespace Luna.Channels.Abstractions;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Event arguments for when a message is received from a channel
|
||||||
|
/// </summary>
|
||||||
|
public class ChannelMessageReceivedEventArgs : EventArgs
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// The received message
|
||||||
|
/// </summary>
|
||||||
|
public required ChannelMessage Message { get; init; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The channel that received the message
|
||||||
|
/// </summary>
|
||||||
|
public required IChannel Channel { get; init; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Set to true to indicate the message has been handled and should not be processed further
|
||||||
|
/// </summary>
|
||||||
|
public bool IsHandled { get; set; }
|
||||||
|
|
||||||
|
}
|
||||||
@@ -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";
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Represents a communication channel for the Luna AI Assistant.
|
||||||
|
/// Channels handle sending and receiving messages via different platforms
|
||||||
|
/// (e.g., CLI, Telegram, Discord, Email).
|
||||||
|
/// </summary>
|
||||||
|
public interface IChannel : IDisposable
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Unique identifier for this channel instance
|
||||||
|
/// </summary>
|
||||||
|
string ChannelId { get; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Type of channel (e.g., "cli", "telegram", "discord")
|
||||||
|
/// </summary>
|
||||||
|
string ChannelType { get; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Display name for the channel
|
||||||
|
/// </summary>
|
||||||
|
string DisplayName { get; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Indicates whether the channel is currently connected and ready
|
||||||
|
/// </summary>
|
||||||
|
bool IsConnected { get; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Event raised when a message is received from the channel
|
||||||
|
/// </summary>
|
||||||
|
event ChannelMessageReceivedEventHandler? MessageReceived;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Event raised when the channel connection state changes
|
||||||
|
/// </summary>
|
||||||
|
event ChannelConnectionChangedEventHandler? ConnectionStateChanged;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Send a message to the channel
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="message">The message to send</param>
|
||||||
|
/// <param name="cancellationToken">Cancellation token</param>
|
||||||
|
/// <returns>Task representing the send operation</returns>
|
||||||
|
Task SendMessageAsync(ChannelMessage message, CancellationToken cancellationToken = default);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Send a streaming response to the channel
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="messageStream">Async enumerable of response updates</param>
|
||||||
|
/// <param name="cancellationToken">Cancellation token</param>
|
||||||
|
/// <returns>Task representing the send operation</returns>
|
||||||
|
Task SendStreamingMessageAsync(IAsyncEnumerable<ChatStreamUpdate> messageStream, CancellationToken cancellationToken = default);
|
||||||
|
}
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
namespace Luna.Channels.Abstractions;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Manages the lifecycle and routing of all registered communication channels.
|
||||||
|
/// </summary>
|
||||||
|
public interface IChannelManager
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Registers a channel with this manager.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="channel"></param>
|
||||||
|
/// <exception cref="InvalidOperationException">Thrown if a channel with the same ID is already registered.</exception>
|
||||||
|
void RegisterChannel(IChannel channel);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Unregisters a channel by its ID. No-op if the channel is not found.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="channelId">The ID of the channel to remove.</param>
|
||||||
|
void UnregisterChannel(string channelId);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Retrieves a registered channel by its ID.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="channelId">The ID of the channel to retrieve.</param>
|
||||||
|
/// <returns>The channel, or null if not found.</returns>
|
||||||
|
IChannel? GetChannel(string channelId);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Returns all currently registered channels.
|
||||||
|
/// </summary>
|
||||||
|
IReadOnlyList<IChannel> GetAllChannels();
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Raised after a message has been received and routed.
|
||||||
|
/// </summary>
|
||||||
|
event EventHandler<ChannelMessageReceivedEventArgs>? MessageRouted;
|
||||||
|
}
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
<Project Sdk="Microsoft.NET.Sdk">
|
||||||
|
|
||||||
|
<PropertyGroup>
|
||||||
|
<TargetFramework>net10.0</TargetFramework>
|
||||||
|
<ImplicitUsings>enable</ImplicitUsings>
|
||||||
|
<Nullable>enable</Nullable>
|
||||||
|
</PropertyGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<ProjectReference Include="..\Luna.Shared\Luna.Shared.csproj" />
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
</Project>
|
||||||
@@ -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<ChannelManager> logger) : IChannelManager
|
||||||
|
{
|
||||||
|
private readonly Dictionary<string, IChannel> channels = new();
|
||||||
|
private readonly Dictionary<string, string> sessionMap = new();
|
||||||
|
|
||||||
|
public event EventHandler<ChannelMessageReceivedEventArgs>? 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<IChannel> 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<ChatStreamUpdate> 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() { }
|
||||||
|
}
|
||||||
@@ -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<IChannelManager, ChannelManager>();
|
||||||
|
|
||||||
|
// Telegram
|
||||||
|
var telegramOptions = new TelegramOptions { BotToken = TelegramBotToken };
|
||||||
|
services.AddSingleton(telegramOptions);
|
||||||
|
services.AddSingleton<ITelegramBotClient>(_ => new TelegramBotClient(TelegramBotToken));
|
||||||
|
services.AddHostedService<TelegramAdapter>();
|
||||||
|
|
||||||
|
return services;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
<Project Sdk="Microsoft.NET.Sdk">
|
||||||
|
|
||||||
|
<PropertyGroup>
|
||||||
|
<TargetFramework>net10.0</TargetFramework>
|
||||||
|
<ImplicitUsings>enable</ImplicitUsings>
|
||||||
|
<Nullable>enable</Nullable>
|
||||||
|
</PropertyGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<ProjectReference Include="..\Luna.Channels.Abstractions\Luna.Channels.Abstractions.csproj" />
|
||||||
|
<ProjectReference Include="..\Luna.Configuration\Luna.Configuration.csproj" />
|
||||||
|
<ProjectReference Include="..\Luna.Core.Abstractions\Luna.Core.Abstractions.csproj" />
|
||||||
|
<ProjectReference Include="..\Luna.Shared\Luna.Shared.csproj" />
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<PackageReference Include="Microsoft.Extensions.Hosting.Abstractions" Version="10.0.5" />
|
||||||
|
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" Version="10.0.5" />
|
||||||
|
<PackageReference Include="Telegram.Bot" Version="22.9.5.3" />
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
</Project>
|
||||||
@@ -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<TelegramAdapter> adapterLogger,
|
||||||
|
ILogger<TelegramChannel> channelLogger) : IHostedService, IUpdateHandler
|
||||||
|
{
|
||||||
|
private readonly Dictionary<long, TelegramChannel> 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<TelegramChannel> 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<ChatStreamUpdate> 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();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Called by TelegramAdapter when an incoming message is received for this chat.
|
||||||
|
/// Applies AllowedUserIds filtering and null/empty text filtering before raising the event.
|
||||||
|
/// </summary>
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<ChatStreamUpdate> 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() { }
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user