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.
This commit is contained in:
2026-04-04 16:09:10 +02:00
parent 3138436965
commit 2a07e07180
26 changed files with 779 additions and 465 deletions
+55 -2
View File
@@ -1,10 +1,11 @@
using Luna.Channels.Abstractions;
using Luna.Core.Abstractions;
using Luna.Identity.Abstractions;
using Luna.Shared;
namespace Luna.Channels.Web;
public class WebChannel(string channelId, IChatHubContext hubContext) : IChannel
public class WebChannel(string channelId, IChatHubContext hubContext, IUserIdentityService identityService) : IChannel
{
public string ChannelId { get; } = channelId;
public string ChannelType => Abstractions.ChannelType.Web;
@@ -13,6 +14,9 @@ public class WebChannel(string channelId, IChatHubContext hubContext) : IChannel
public event ChannelMessageReceivedEventHandler? MessageReceived;
public event ChannelConnectionChangedEventHandler? ConnectionStateChanged;
public Task<bool> VerifyChannelLinkAsync()
=> Task.FromResult(true);
public async Task SendStreamingMessageAsync(IAsyncEnumerable<ChatStreamUpdate> messageStream, CancellationToken cancellationToken = default)
{
@@ -24,8 +28,14 @@ public class WebChannel(string channelId, IChatHubContext hubContext) : IChannel
await hubContext.SendResponseAsync(ChannelId, message, cancellationToken);
}
public void RaiseMessageReceived(object? sender, ChannelMessage message)
public async Task RaiseMessageReceivedAsync(object? sender, ChannelMessage message)
{
if (message.Content.StartsWith("/"))
{
await HandleCommandAsync(message.Content);
return;
}
MessageReceived?.Invoke(sender, new ChannelMessageReceivedEventArgs
{
Channel = this,
@@ -33,6 +43,49 @@ public class WebChannel(string channelId, IChatHubContext hubContext) : IChannel
IsHandled = false
});
}
private async Task HandleCommandAsync(string command)
{
var parts = command.Split(' ', StringSplitOptions.RemoveEmptyEntries);
var commandName = parts[0].ToLowerInvariant();
var responseText = commandName switch
{
"/link" => HandleLinkCommand(parts),
_ => $"Unknown command: {commandName}"
};
await SendMessageAsync(new ChannelMessage(
MessageId: Guid.NewGuid().ToString(),
ConversationId: Guid.NewGuid().ToString(),
SenderId: "luna",
SenderName: "Luna",
Content: responseText,
Timestamp: DateTimeOffset.UtcNow));
}
private string HandleLinkCommand(string[] parts)
{
if (parts.Length < 3)
return "Usage: /link <channel-type> <pairing-code>";
var channelType = parts[1].ToLowerInvariant();
var code = parts[2].ToUpperInvariant();
var currentUser = identityService.GetCurrentUser();
if (identityService.IsChannelLinked(currentUser.UserId, channelType))
return $"Channel {channelType} is already linked to your account.";
try
{
identityService.CompleteChannelLink(currentUser.UserId, channelType, code);
return $"Successfully linked {channelType} channel to your account.";
}
catch (InvalidOperationException ex)
{
return $"Failed to link channel: {ex.Message}";
}
}
public void Dispose() { }
}