Files
Luna/Luna.Channels/Telegram/TelegramChannel.cs
T
darman 2a07e07180 Add user identity system with channel pairing and web /link command
Introduce Luna.Identity project with user identity service, pairing code
generation, and channel linking. Channels (Telegram, CLI) now verify link
status before routing messages and prompt users to pair via the web
interface. WebChannel handles /link <channel-type> <code> commands to
complete the pairing flow.

Fix inverted expiration check in IsPairingCodeExpired that caused codes
to be treated as expired immediately after creation.
2026-04-04 16:09:10 +02:00

183 lines
6.1 KiB
C#

using System.Text;
using Luna.Channels.Abstractions;
using Luna.Configuration;
using Luna.Identity.Abstractions;
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,
IUserIdentityService identityService,
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<bool> VerifyChannelLinkAsync()
{
var currentUser = identityService.GetCurrentUser();
if (identityService.IsChannelLinked(currentUser.UserId, ChannelType))
return true;
var pairingCode = identityService.StartChannelLink(currentUser.UserId, ChannelType);
await SendMessageAsync(new ChannelMessage(
MessageId: Guid.NewGuid().ToString(),
ConversationId: Guid.NewGuid().ToString(),
SenderId: Guid.NewGuid().ToString(),
SenderName: "luna",
Content:
$"{ChannelType} channel is not linked to current user.\nGo to web interface and execute command \"/link [pairing code]\".\nYou will have to provide following PairingCode: {pairingCode}",
Timestamp: DateTimeOffset.UtcNow));
return false;
}
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);
var typingCts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
var _ = 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);
}
}
}
}