diff --git a/backend/src/SentinelKnowledgebase.Api/Controllers/TelegramIntegrationsController.cs b/backend/src/SentinelKnowledgebase.Api/Controllers/TelegramIntegrationsController.cs new file mode 100644 index 0000000..ae031c1 --- /dev/null +++ b/backend/src/SentinelKnowledgebase.Api/Controllers/TelegramIntegrationsController.cs @@ -0,0 +1,57 @@ +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; +using SentinelKnowledgebase.Api.Extensions; +using SentinelKnowledgebase.Application.DTOs.Integrations; +using SentinelKnowledgebase.Application.Services.Interfaces; + +namespace SentinelKnowledgebase.Api.Controllers; + +[ApiController] +[Authorize] +[Route("api/v1/integrations/telegram")] +public class TelegramIntegrationsController : ControllerBase +{ + private readonly ITelegramIntegrationService _telegramIntegrationService; + + public TelegramIntegrationsController(ITelegramIntegrationService telegramIntegrationService) + { + _telegramIntegrationService = telegramIntegrationService; + } + + [HttpGet("status")] + [ProducesResponseType(typeof(TelegramLinkStatusDto), StatusCodes.Status200OK)] + public async Task> GetStatus() + { + if (!User.TryGetUserId(out var userId)) + { + return Unauthorized(); + } + + return Ok(await _telegramIntegrationService.GetStatusAsync(userId)); + } + + [HttpPost("link-code")] + [ProducesResponseType(typeof(TelegramLinkCodeResponseDto), StatusCodes.Status200OK)] + public async Task> IssueLinkCode() + { + if (!User.TryGetUserId(out var userId)) + { + return Unauthorized(); + } + + return Ok(await _telegramIntegrationService.IssueLinkCodeAsync(userId)); + } + + [HttpDelete("link")] + [ProducesResponseType(StatusCodes.Status204NoContent)] + public async Task Unlink() + { + if (!User.TryGetUserId(out var userId)) + { + return Unauthorized(); + } + + await _telegramIntegrationService.UnlinkAsync(userId); + return NoContent(); + } +} diff --git a/backend/src/SentinelKnowledgebase.Api/Program.cs b/backend/src/SentinelKnowledgebase.Api/Program.cs index bfe1724..fac8332 100644 --- a/backend/src/SentinelKnowledgebase.Api/Program.cs +++ b/backend/src/SentinelKnowledgebase.Api/Program.cs @@ -7,6 +7,7 @@ using SentinelKnowledgebase.Api.HealthChecks; using SentinelKnowledgebase.Application; using SentinelKnowledgebase.Application.Services; +using SentinelKnowledgebase.Application.Services.Telegram; using SentinelKnowledgebase.Infrastructure.Authentication; using SentinelKnowledgebase.Infrastructure.Data; using SentinelKnowledgebase.Infrastructure; @@ -83,6 +84,7 @@ builder.Services.AddApplication(); builder.Services.AddInfrastructure(builder.Configuration); +builder.Services.Configure(builder.Configuration.GetSection(TelegramIntegrationOptions.SectionName)); var hangfireRetryAttempts = builder.Configuration.GetValue("Hangfire:RetryAttempts") ?? 10; var hangfireRetryDelays = builder.Configuration.GetSection("Hangfire:RetryDelaysInSeconds").Get(); var retryFilter = new AutomaticRetryAttribute diff --git a/backend/src/SentinelKnowledgebase.Api/appsettings.Development.json b/backend/src/SentinelKnowledgebase.Api/appsettings.Development.json index 7851aa3..0716cfe 100644 --- a/backend/src/SentinelKnowledgebase.Api/appsettings.Development.json +++ b/backend/src/SentinelKnowledgebase.Api/appsettings.Development.json @@ -57,4 +57,13 @@ "BootstrapAdminDisplayName": "Sentinel Admin" }, "VectorSize": 1536 + , + "Telegram": { + "BotToken": "", + "PollTimeoutSeconds": 20, + "PollLimit": 25, + "PollCadenceSeconds": 3, + "LinkCodeTtlMinutes": 10, + "MaxRawContentLength": 8000 + } } diff --git a/backend/src/SentinelKnowledgebase.Api/appsettings.json b/backend/src/SentinelKnowledgebase.Api/appsettings.json index af6e61a..9f08979 100644 --- a/backend/src/SentinelKnowledgebase.Api/appsettings.json +++ b/backend/src/SentinelKnowledgebase.Api/appsettings.json @@ -60,4 +60,13 @@ "BootstrapAdminDisplayName": "Sentinel Admin" }, "VectorSize": 1536 + , + "Telegram": { + "BotToken": "", + "PollTimeoutSeconds": 20, + "PollLimit": 25, + "PollCadenceSeconds": 3, + "LinkCodeTtlMinutes": 10, + "MaxRawContentLength": 8000 + } } diff --git a/backend/src/SentinelKnowledgebase.Application/DTOs/Integrations/TelegramDtos.cs b/backend/src/SentinelKnowledgebase.Application/DTOs/Integrations/TelegramDtos.cs new file mode 100644 index 0000000..d2e10c3 --- /dev/null +++ b/backend/src/SentinelKnowledgebase.Application/DTOs/Integrations/TelegramDtos.cs @@ -0,0 +1,17 @@ +namespace SentinelKnowledgebase.Application.DTOs.Integrations; + +public sealed class TelegramLinkCodeResponseDto +{ + public string Code { get; set; } = string.Empty; + public DateTimeOffset ExpiresAt { get; set; } +} + +public sealed class TelegramLinkStatusDto +{ + public bool IsLinked { get; set; } + public long? TelegramChatId { get; set; } + public string? ChatDisplayName { get; set; } + public string? SenderDisplayName { get; set; } + public DateTimeOffset? LinkedAt { get; set; } + public TelegramLinkCodeResponseDto? PendingCode { get; set; } +} diff --git a/backend/src/SentinelKnowledgebase.Application/DependencyInjection.cs b/backend/src/SentinelKnowledgebase.Application/DependencyInjection.cs index f60d6eb..af612be 100644 --- a/backend/src/SentinelKnowledgebase.Application/DependencyInjection.cs +++ b/backend/src/SentinelKnowledgebase.Application/DependencyInjection.cs @@ -4,6 +4,7 @@ using SentinelKnowledgebase.Application.DTOs.Search; using SentinelKnowledgebase.Application.Services; using SentinelKnowledgebase.Application.Services.Interfaces; +using SentinelKnowledgebase.Application.Services.Telegram; using SentinelKnowledgebase.Application.Validators; namespace SentinelKnowledgebase.Application; @@ -23,6 +24,7 @@ public static IServiceCollection AddApplication(this IServiceCollection services services.AddSingleton(); services.AddScoped(); services.AddHttpClient(); + services.AddScoped(); services.AddValidatorsFromAssemblyContaining(); services.AddValidatorsFromAssemblyContaining(); diff --git a/backend/src/SentinelKnowledgebase.Application/Services/Interfaces/ITelegramIntegrationService.cs b/backend/src/SentinelKnowledgebase.Application/Services/Interfaces/ITelegramIntegrationService.cs new file mode 100644 index 0000000..fcc7205 --- /dev/null +++ b/backend/src/SentinelKnowledgebase.Application/Services/Interfaces/ITelegramIntegrationService.cs @@ -0,0 +1,11 @@ +using SentinelKnowledgebase.Application.DTOs.Integrations; + +namespace SentinelKnowledgebase.Application.Services.Interfaces; + +public interface ITelegramIntegrationService +{ + Task GetStatusAsync(Guid ownerUserId); + Task IssueLinkCodeAsync(Guid ownerUserId); + Task UnlinkAsync(Guid ownerUserId); + Task PollAndIngestAsync(CancellationToken cancellationToken); +} diff --git a/backend/src/SentinelKnowledgebase.Application/Services/Telegram/TelegramIntegrationOptions.cs b/backend/src/SentinelKnowledgebase.Application/Services/Telegram/TelegramIntegrationOptions.cs new file mode 100644 index 0000000..fdeff3e --- /dev/null +++ b/backend/src/SentinelKnowledgebase.Application/Services/Telegram/TelegramIntegrationOptions.cs @@ -0,0 +1,13 @@ +namespace SentinelKnowledgebase.Application.Services.Telegram; + +public class TelegramIntegrationOptions +{ + public const string SectionName = "Telegram"; + + public string BotToken { get; set; } = string.Empty; + public int PollTimeoutSeconds { get; set; } = 20; + public int PollLimit { get; set; } = 25; + public int PollCadenceSeconds { get; set; } = 3; + public int LinkCodeTtlMinutes { get; set; } = 10; + public int MaxRawContentLength { get; set; } = 8000; +} diff --git a/backend/src/SentinelKnowledgebase.Application/Services/Telegram/TelegramIntegrationService.cs b/backend/src/SentinelKnowledgebase.Application/Services/Telegram/TelegramIntegrationService.cs new file mode 100644 index 0000000..4092bd0 --- /dev/null +++ b/backend/src/SentinelKnowledgebase.Application/Services/Telegram/TelegramIntegrationService.cs @@ -0,0 +1,274 @@ +using System.Text; +using System.Text.Json; +using System.Text.RegularExpressions; + +using Hangfire; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; + +using SentinelKnowledgebase.Application.DTOs.Capture; +using SentinelKnowledgebase.Application.DTOs.Integrations; +using SentinelKnowledgebase.Application.DTOs.Labels; +using SentinelKnowledgebase.Application.Services.Interfaces; +using SentinelKnowledgebase.Domain.Entities; +using SentinelKnowledgebase.Domain.Enums; +using SentinelKnowledgebase.Infrastructure.Data; + +namespace SentinelKnowledgebase.Application.Services.Telegram; + +public sealed class TelegramIntegrationService : ITelegramIntegrationService +{ + private static readonly Regex UrlRegex = new(@"https?://\S+", RegexOptions.Compiled | RegexOptions.IgnoreCase); + private readonly ApplicationDbContext _dbContext; + private readonly ICaptureService _captureService; + private readonly ICaptureProcessingAdminService _captureProcessingAdminService; + private readonly IBackgroundJobClient _backgroundJobClient; + private readonly IHttpClientFactory _httpClientFactory; + private readonly TelegramIntegrationOptions _options; + private readonly ILogger _logger; + + public TelegramIntegrationService( + ApplicationDbContext dbContext, + ICaptureService captureService, + ICaptureProcessingAdminService captureProcessingAdminService, + IBackgroundJobClient backgroundJobClient, + IHttpClientFactory httpClientFactory, + IOptions options, + ILogger logger) + { + _dbContext = dbContext; + _captureService = captureService; + _captureProcessingAdminService = captureProcessingAdminService; + _backgroundJobClient = backgroundJobClient; + _httpClientFactory = httpClientFactory; + _options = options.Value; + _logger = logger; + } + + public async Task GetStatusAsync(Guid ownerUserId) + { + var link = await _dbContext.TelegramChatLinks + .Where(item => item.OwnerUserId == ownerUserId && item.UnlinkedAt == null) + .OrderByDescending(item => item.LinkedAt) + .FirstOrDefaultAsync(); + var pendingCode = await _dbContext.TelegramLinkCodes + .Where(item => item.OwnerUserId == ownerUserId && item.ConsumedAt == null && item.ExpiresAt > DateTimeOffset.UtcNow) + .OrderByDescending(item => item.CreatedAt) + .FirstOrDefaultAsync(); + + return new TelegramLinkStatusDto + { + IsLinked = link != null, + TelegramChatId = link?.TelegramChatId, + ChatDisplayName = link?.ChatDisplayName, + SenderDisplayName = link?.SenderDisplayName, + LinkedAt = link?.LinkedAt, + PendingCode = pendingCode == null ? null : new TelegramLinkCodeResponseDto + { + Code = pendingCode.Code, + ExpiresAt = pendingCode.ExpiresAt + } + }; + } + + public async Task IssueLinkCodeAsync(Guid ownerUserId) + { + var activeCode = await _dbContext.TelegramLinkCodes + .Where(item => item.OwnerUserId == ownerUserId && item.ConsumedAt == null && item.ExpiresAt > DateTimeOffset.UtcNow) + .OrderByDescending(item => item.CreatedAt) + .FirstOrDefaultAsync(); + if (activeCode != null) + { + return new TelegramLinkCodeResponseDto { Code = activeCode.Code, ExpiresAt = activeCode.ExpiresAt }; + } + + var code = $"SNT-{Guid.NewGuid():N}"[..12].ToUpperInvariant(); + var expiresAt = DateTimeOffset.UtcNow.AddMinutes(_options.LinkCodeTtlMinutes); + _dbContext.TelegramLinkCodes.Add(new TelegramLinkCode + { + Id = Guid.NewGuid(), + OwnerUserId = ownerUserId, + Code = code, + CreatedAt = DateTimeOffset.UtcNow, + ExpiresAt = expiresAt + }); + await _dbContext.SaveChangesAsync(); + + return new TelegramLinkCodeResponseDto { Code = code, ExpiresAt = expiresAt }; + } + + public async Task UnlinkAsync(Guid ownerUserId) + { + var activeLinks = await _dbContext.TelegramChatLinks + .Where(item => item.OwnerUserId == ownerUserId && item.UnlinkedAt == null) + .ToListAsync(); + + foreach (var link in activeLinks) + { + link.UnlinkedAt = DateTimeOffset.UtcNow; + } + + await _dbContext.SaveChangesAsync(); + } + + public async Task PollAndIngestAsync(CancellationToken cancellationToken) + { + if (string.IsNullOrWhiteSpace(_options.BotToken)) + { + return; + } + + var state = await _dbContext.TelegramIngestionStates.FirstOrDefaultAsync(item => item.Id == TelegramIngestionState.SingletonId, cancellationToken) + ?? new TelegramIngestionState { Id = TelegramIngestionState.SingletonId, UpdatedAt = DateTimeOffset.UtcNow }; + + if (_dbContext.Entry(state).State == EntityState.Detached) + { + _dbContext.TelegramIngestionStates.Add(state); + await _dbContext.SaveChangesAsync(cancellationToken); + } + + var endpoint = $"https://api.telegram.org/bot{_options.BotToken}/getUpdates?offset={state.LastProcessedUpdateId + 1}&timeout={_options.PollTimeoutSeconds}&limit={_options.PollLimit}"; + var httpClient = _httpClientFactory.CreateClient(); + var response = await httpClient.GetAsync(endpoint, cancellationToken); + response.EnsureSuccessStatusCode(); + + await using var stream = await response.Content.ReadAsStreamAsync(cancellationToken); + using var json = await JsonDocument.ParseAsync(stream, cancellationToken: cancellationToken); + var updates = json.RootElement.GetProperty("result"); + long maxUpdateId = state.LastProcessedUpdateId; + + foreach (var update in updates.EnumerateArray()) + { + var updateId = update.GetProperty("update_id").GetInt64(); + if (updateId > maxUpdateId) + { + maxUpdateId = updateId; + } + + if (!update.TryGetProperty("message", out var message)) + { + continue; + } + + if (!message.TryGetProperty("chat", out var chat) || !chat.TryGetProperty("type", out var chatType) || chatType.GetString() != "private") + { + continue; + } + + var text = message.TryGetProperty("text", out var textNode) ? textNode.GetString() : null; + if (string.IsNullOrWhiteSpace(text)) + { + continue; + } + + var chatId = chat.GetProperty("id").GetInt64(); + var telegramUserId = message.TryGetProperty("from", out var fromNode) && fromNode.TryGetProperty("id", out var fromId) + ? fromId.GetInt64() + : 0; + var messageId = message.GetProperty("message_id").GetInt64(); + + if (await TryConsumeLinkCodeAsync(text.Trim(), chatId, telegramUserId, chat, fromNode: message.TryGetProperty("from", out var sender) ? sender : null, cancellationToken)) + { + continue; + } + + var link = await _dbContext.TelegramChatLinks + .Where(item => item.TelegramChatId == chatId && item.UnlinkedAt == null) + .OrderByDescending(item => item.LinkedAt) + .FirstOrDefaultAsync(cancellationToken); + if (link == null) + { + continue; + } + + var url = UrlRegex.Match(text).Value; + var metadata = JsonSerializer.Serialize(new Dictionary + { + ["source"] = "telegram", + ["importSource"] = "telegram_bot", + ["telegramChatId"] = chatId, + ["telegramUserId"] = telegramUserId, + ["telegramMessageId"] = messageId, + ["telegramUpdateId"] = updateId, + ["receivedAt"] = DateTimeOffset.UtcNow, + ["chatDisplayName"] = link.ChatDisplayName, + ["senderDisplayName"] = link.SenderDisplayName + }); + + var request = new CaptureRequestDto + { + ContentType = ContentType.Note, + RawContent = text.Length > _options.MaxRawContentLength ? text[.._options.MaxRawContentLength] : text, + SourceUrl = string.IsNullOrWhiteSpace(url) ? string.Empty : url, + Metadata = metadata, + Tags = ["telegram"], + Labels = [new LabelAssignmentDto { Category = "Source", Value = "Telegram" }] + }; + + var capture = await _captureService.CreateCaptureAsync(link.OwnerUserId, request); + if (!await _captureProcessingAdminService.IsPausedAsync()) + { + _backgroundJobClient.Enqueue(service => service.ProcessCaptureAsync(capture.Id)); + } + } + + if (maxUpdateId > state.LastProcessedUpdateId) + { + state.LastProcessedUpdateId = maxUpdateId; + state.UpdatedAt = DateTimeOffset.UtcNow; + await _dbContext.SaveChangesAsync(cancellationToken); + } + } + + private async Task TryConsumeLinkCodeAsync( + string incomingText, + long chatId, + long telegramUserId, + JsonElement chat, + JsonElement? fromNode, + CancellationToken cancellationToken) + { + var code = incomingText.Trim().ToUpperInvariant(); + var linkCode = await _dbContext.TelegramLinkCodes + .Where(item => item.Code == code && item.ConsumedAt == null && item.ExpiresAt > DateTimeOffset.UtcNow) + .OrderByDescending(item => item.CreatedAt) + .FirstOrDefaultAsync(cancellationToken); + if (linkCode == null) + { + return false; + } + + var existingLinks = await _dbContext.TelegramChatLinks + .Where(item => item.OwnerUserId == linkCode.OwnerUserId && item.UnlinkedAt == null) + .ToListAsync(cancellationToken); + foreach (var existingLink in existingLinks) + { + existingLink.UnlinkedAt = DateTimeOffset.UtcNow; + } + + var chatTitle = chat.TryGetProperty("username", out var username) + ? username.GetString() + : chat.TryGetProperty("first_name", out var firstName) ? firstName.GetString() : null; + var sender = fromNode?.TryGetProperty("username", out var senderUsername) == true + ? senderUsername.GetString() + : fromNode?.TryGetProperty("first_name", out var senderFirstName) == true ? senderFirstName.GetString() : null; + + _dbContext.TelegramChatLinks.Add(new TelegramChatLink + { + Id = Guid.NewGuid(), + OwnerUserId = linkCode.OwnerUserId, + TelegramChatId = chatId, + TelegramUserId = telegramUserId, + ChatDisplayName = chatTitle, + SenderDisplayName = sender, + LinkedAt = DateTimeOffset.UtcNow + }); + + linkCode.ConsumedAt = DateTimeOffset.UtcNow; + await _dbContext.SaveChangesAsync(cancellationToken); + _logger.LogInformation("Linked telegram chat {ChatId} to user {OwnerUserId}", chatId, linkCode.OwnerUserId); + + return true; + } +} diff --git a/backend/src/SentinelKnowledgebase.Domain/Entities/TelegramChatLink.cs b/backend/src/SentinelKnowledgebase.Domain/Entities/TelegramChatLink.cs new file mode 100644 index 0000000..20dc093 --- /dev/null +++ b/backend/src/SentinelKnowledgebase.Domain/Entities/TelegramChatLink.cs @@ -0,0 +1,26 @@ +using System.ComponentModel.DataAnnotations; + +namespace SentinelKnowledgebase.Domain.Entities; + +public class TelegramChatLink +{ + [Key] + public Guid Id { get; set; } + + [Required] + public Guid OwnerUserId { get; set; } + + public long TelegramChatId { get; set; } + + public long TelegramUserId { get; set; } + + [MaxLength(256)] + public string? ChatDisplayName { get; set; } + + [MaxLength(256)] + public string? SenderDisplayName { get; set; } + + public DateTimeOffset LinkedAt { get; set; } + + public DateTimeOffset? UnlinkedAt { get; set; } +} diff --git a/backend/src/SentinelKnowledgebase.Domain/Entities/TelegramIngestionState.cs b/backend/src/SentinelKnowledgebase.Domain/Entities/TelegramIngestionState.cs new file mode 100644 index 0000000..841f953 --- /dev/null +++ b/backend/src/SentinelKnowledgebase.Domain/Entities/TelegramIngestionState.cs @@ -0,0 +1,15 @@ +using System.ComponentModel.DataAnnotations; + +namespace SentinelKnowledgebase.Domain.Entities; + +public class TelegramIngestionState +{ + public const int SingletonId = 1; + + [Key] + public int Id { get; set; } = SingletonId; + + public long LastProcessedUpdateId { get; set; } + + public DateTimeOffset UpdatedAt { get; set; } +} diff --git a/backend/src/SentinelKnowledgebase.Domain/Entities/TelegramLinkCode.cs b/backend/src/SentinelKnowledgebase.Domain/Entities/TelegramLinkCode.cs new file mode 100644 index 0000000..3757953 --- /dev/null +++ b/backend/src/SentinelKnowledgebase.Domain/Entities/TelegramLinkCode.cs @@ -0,0 +1,22 @@ +using System.ComponentModel.DataAnnotations; + +namespace SentinelKnowledgebase.Domain.Entities; + +public class TelegramLinkCode +{ + [Key] + public Guid Id { get; set; } + + [Required] + public Guid OwnerUserId { get; set; } + + [Required] + [MaxLength(32)] + public string Code { get; set; } = string.Empty; + + public DateTimeOffset ExpiresAt { get; set; } + + public DateTimeOffset CreatedAt { get; set; } + + public DateTimeOffset? ConsumedAt { get; set; } +} diff --git a/backend/src/SentinelKnowledgebase.Infrastructure/Data/ApplicationDbContext.cs b/backend/src/SentinelKnowledgebase.Infrastructure/Data/ApplicationDbContext.cs index 9556f00..ae25fb8 100644 --- a/backend/src/SentinelKnowledgebase.Infrastructure/Data/ApplicationDbContext.cs +++ b/backend/src/SentinelKnowledgebase.Infrastructure/Data/ApplicationDbContext.cs @@ -32,6 +32,9 @@ public ApplicationDbContext(DbContextOptions options) : ba public DbSet DeviceAuthorizations { get; set; } public DbSet RefreshTokens { get; set; } public DbSet UserPreservedLanguages { get; set; } + public DbSet TelegramChatLinks { get; set; } + public DbSet TelegramLinkCodes { get; set; } + public DbSet TelegramIngestionStates { get; set; } protected override void OnModelCreating(ModelBuilder modelBuilder) { @@ -246,6 +249,51 @@ protected override void OnModelCreating(ModelBuilder modelBuilder) }); }); + + modelBuilder.Entity(entity => + { + entity.HasKey(e => e.Id); + entity.Property(e => e.OwnerUserId).IsRequired(); + entity.Property(e => e.LinkedAt).HasDefaultValueSql("CURRENT_TIMESTAMP"); + entity.HasIndex(e => new { e.OwnerUserId, e.UnlinkedAt }); + entity.HasIndex(e => new { e.TelegramChatId, e.UnlinkedAt }); + + entity.HasOne() + .WithMany() + .HasForeignKey(e => e.OwnerUserId) + .OnDelete(DeleteBehavior.Cascade); + }); + + modelBuilder.Entity(entity => + { + entity.HasKey(e => e.Id); + entity.Property(e => e.OwnerUserId).IsRequired(); + entity.Property(e => e.Code).HasMaxLength(32).IsRequired(); + entity.Property(e => e.CreatedAt).HasDefaultValueSql("CURRENT_TIMESTAMP"); + entity.HasIndex(e => e.Code).IsUnique(); + entity.HasIndex(e => new { e.OwnerUserId, e.ExpiresAt }); + + entity.HasOne() + .WithMany() + .HasForeignKey(e => e.OwnerUserId) + .OnDelete(DeleteBehavior.Cascade); + }); + + modelBuilder.Entity(entity => + { + entity.HasKey(e => e.Id); + entity.Property(e => e.Id).ValueGeneratedNever(); + entity.Property(e => e.LastProcessedUpdateId).HasDefaultValue(0L); + entity.Property(e => e.UpdatedAt).HasDefaultValueSql("CURRENT_TIMESTAMP"); + + entity.HasData(new TelegramIngestionState + { + Id = TelegramIngestionState.SingletonId, + LastProcessedUpdateId = 0L, + UpdatedAt = DateTimeOffset.UtcNow + }); + }); + modelBuilder.Entity(entity => { entity.HasKey(e => e.Id); diff --git a/backend/src/SentinelKnowledgebase.Worker/Program.cs b/backend/src/SentinelKnowledgebase.Worker/Program.cs index 5e04cc4..d2e1c1f 100644 --- a/backend/src/SentinelKnowledgebase.Worker/Program.cs +++ b/backend/src/SentinelKnowledgebase.Worker/Program.cs @@ -18,6 +18,7 @@ builder.Services.AddApplication(); builder.Services.AddInfrastructure(builder.Configuration); +builder.Services.Configure(builder.Configuration.GetSection(TelegramIntegrationOptions.SectionName)); builder.Services.AddSingleton(); var hangfireRetryAttempts = builder.Configuration.GetValue("Hangfire:RetryAttempts") ?? 10; @@ -42,6 +43,7 @@ .UsePostgreSqlStorage(options => options.UseNpgsqlConnection(builder.Configuration.GetConnectionString("DefaultConnection")))); builder.Services.AddHangfireServer(options => options.Queues = hangfireQueues); +builder.Services.AddHostedService(); var host = builder.Build(); GlobalJobFilters.Filters.Add(host.Services.GetRequiredService()); diff --git a/backend/src/SentinelKnowledgebase.Worker/TelegramPollingHostedService.cs b/backend/src/SentinelKnowledgebase.Worker/TelegramPollingHostedService.cs new file mode 100644 index 0000000..7d445e2 --- /dev/null +++ b/backend/src/SentinelKnowledgebase.Worker/TelegramPollingHostedService.cs @@ -0,0 +1,51 @@ +using Microsoft.Extensions.Options; +using SentinelKnowledgebase.Application.Services.Interfaces; +using SentinelKnowledgebase.Application.Services.Telegram; + +namespace SentinelKnowledgebase.Worker; + +public sealed class TelegramPollingHostedService : BackgroundService +{ + private readonly IServiceScopeFactory _scopeFactory; + private readonly TelegramIntegrationOptions _options; + private readonly ILogger _logger; + + public TelegramPollingHostedService( + IServiceScopeFactory scopeFactory, + IOptions options, + ILogger logger) + { + _scopeFactory = scopeFactory; + _options = options.Value; + _logger = logger; + } + + protected override async Task ExecuteAsync(CancellationToken stoppingToken) + { + if (string.IsNullOrWhiteSpace(_options.BotToken)) + { + _logger.LogInformation("Telegram polling is disabled because no bot token is configured."); + return; + } + + while (!stoppingToken.IsCancellationRequested) + { + try + { + using var scope = _scopeFactory.CreateScope(); + var service = scope.ServiceProvider.GetRequiredService(); + await service.PollAndIngestAsync(stoppingToken); + } + catch (OperationCanceledException) + { + break; + } + catch (Exception exception) + { + _logger.LogWarning(exception, "Telegram polling cycle failed."); + } + + await Task.Delay(TimeSpan.FromSeconds(_options.PollCadenceSeconds), stoppingToken); + } + } +} diff --git a/backend/src/SentinelKnowledgebase.Worker/appsettings.Development.json b/backend/src/SentinelKnowledgebase.Worker/appsettings.Development.json index 9dc1a33..6ea49f6 100644 --- a/backend/src/SentinelKnowledgebase.Worker/appsettings.Development.json +++ b/backend/src/SentinelKnowledgebase.Worker/appsettings.Development.json @@ -38,4 +38,13 @@ "Microsoft.Hosting.Lifetime": "Information" } } + , + "Telegram": { + "BotToken": "", + "PollTimeoutSeconds": 20, + "PollLimit": 25, + "PollCadenceSeconds": 3, + "LinkCodeTtlMinutes": 10, + "MaxRawContentLength": 8000 + } } diff --git a/backend/src/SentinelKnowledgebase.Worker/appsettings.json b/backend/src/SentinelKnowledgebase.Worker/appsettings.json index 8a166c4..77a025c 100644 --- a/backend/src/SentinelKnowledgebase.Worker/appsettings.json +++ b/backend/src/SentinelKnowledgebase.Worker/appsettings.json @@ -41,4 +41,13 @@ "Microsoft.Hosting.Lifetime": "Information" } } + , + "Telegram": { + "BotToken": "", + "PollTimeoutSeconds": 20, + "PollLimit": 25, + "PollCadenceSeconds": 3, + "LinkCodeTtlMinutes": 10, + "MaxRawContentLength": 8000 + } } diff --git a/backend/tests/SentinelKnowledgebase.IntegrationTests/IntegrationTestFixture.cs b/backend/tests/SentinelKnowledgebase.IntegrationTests/IntegrationTestFixture.cs index be49b06..d38ca1a 100644 --- a/backend/tests/SentinelKnowledgebase.IntegrationTests/IntegrationTestFixture.cs +++ b/backend/tests/SentinelKnowledgebase.IntegrationTests/IntegrationTestFixture.cs @@ -2,6 +2,7 @@ using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.DependencyInjection.Extensions; using System.Collections.Concurrent; using SentinelKnowledgebase.Application.DTOs.Auth; using SentinelKnowledgebase.Application.Services.Interfaces; @@ -71,7 +72,13 @@ public WebApplicationFactory CreateApplicationFactory(string environmen ["Authentication:JwtSigningKey"] = "integration-tests-signing-key-integration-tests-signing-key", ["Authentication:BootstrapAdminEmail"] = BootstrapAdminEmail, ["Authentication:BootstrapAdminPassword"] = BootstrapAdminPassword, - ["Authentication:BootstrapAdminDisplayName"] = "Integration Admin" + ["Authentication:BootstrapAdminDisplayName"] = "Integration Admin", + ["Telegram:BotToken"] = "test-bot-token", + ["Telegram:PollTimeoutSeconds"] = "1", + ["Telegram:PollLimit"] = "25", + ["Telegram:PollCadenceSeconds"] = "1", + ["Telegram:LinkCodeTtlMinutes"] = "10", + ["Telegram:MaxRawContentLength"] = "8000" }); }); @@ -90,6 +97,9 @@ public WebApplicationFactory CreateApplicationFactory(string environmen }); services.AddScoped(); + services.RemoveAll(); + services.AddSingleton(); + services.AddSingleton(sp => sp.GetRequiredService()); }); }); } @@ -174,6 +184,11 @@ public IServiceScope CreateScope() return _factory.Services.CreateScope(); } + public FakeTelegramApiHttpClientFactory GetFakeTelegramApi() + { + return _factory.Services.GetRequiredService(); + } + public async Task DisposeAsync() { HttpClient?.Dispose(); @@ -270,3 +285,51 @@ public Task GenerateClusterMetadataAsync(IReadOnlyCollection _responses = new(); + + public void Reset() + { + while (_responses.TryDequeue(out _)) + { + } + } + + public void EnqueueGetUpdatesResponse(string jsonPayload) + { + _responses.Enqueue(jsonPayload); + } + + public HttpClient CreateClient(string name) + { + return new HttpClient(new QueueMessageHandler(_responses)); + } + + private sealed class QueueMessageHandler : HttpMessageHandler + { + private readonly ConcurrentQueue _responses; + + public QueueMessageHandler(ConcurrentQueue responses) + { + _responses = responses; + } + + protected override Task SendAsync(HttpRequestMessage request, CancellationToken cancellationToken) + { + if (!_responses.TryDequeue(out var payload)) + { + payload = "{\"ok\":true,\"result\":[]}"; + } + + var response = new HttpResponseMessage(System.Net.HttpStatusCode.OK) + { + Content = new StringContent(payload) + }; + + return Task.FromResult(response); + } + } +} diff --git a/backend/tests/SentinelKnowledgebase.IntegrationTests/TelegramIntegrationsControllerTests.cs b/backend/tests/SentinelKnowledgebase.IntegrationTests/TelegramIntegrationsControllerTests.cs new file mode 100644 index 0000000..dc4a123 --- /dev/null +++ b/backend/tests/SentinelKnowledgebase.IntegrationTests/TelegramIntegrationsControllerTests.cs @@ -0,0 +1,160 @@ +using System.Net; +using System.Net.Http.Json; +using System.Text.Json; + +using AwesomeAssertions; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.DependencyInjection; + +using SentinelKnowledgebase.Application.DTOs.Integrations; +using SentinelKnowledgebase.Application.Services.Interfaces; +using SentinelKnowledgebase.Domain.Entities; +using SentinelKnowledgebase.Infrastructure.Data; + +using Xunit; + +namespace SentinelKnowledgebase.IntegrationTests; + +[Collection("IntegrationTests")] +public class TelegramIntegrationsControllerTests +{ + private readonly IntegrationTestFixture _fixture; + + public TelegramIntegrationsControllerTests(IntegrationTestFixture fixture) + { + _fixture = fixture; + } + + [Fact] + public async Task StatusAndLinkCodeEndpoints_ShouldReturnPendingCode() + { + var member = await _fixture.CreateMemberClientAsync(); + using var client = member.Client; + + var initialStatusResponse = await client.GetAsync("/api/v1/integrations/telegram/status"); + initialStatusResponse.StatusCode.Should().Be(HttpStatusCode.OK); + + var initialStatus = await initialStatusResponse.Content.ReadFromJsonAsync(); + initialStatus.Should().NotBeNull(); + initialStatus!.IsLinked.Should().BeFalse(); + initialStatus.PendingCode.Should().BeNull(); + + var codeResponse = await client.PostAsync("/api/v1/integrations/telegram/link-code", null); + codeResponse.StatusCode.Should().Be(HttpStatusCode.OK); + var issuedCode = await codeResponse.Content.ReadFromJsonAsync(); + issuedCode.Should().NotBeNull(); + issuedCode!.Code.Should().StartWith("SNT-"); + + var statusAfterIssueResponse = await client.GetAsync("/api/v1/integrations/telegram/status"); + statusAfterIssueResponse.StatusCode.Should().Be(HttpStatusCode.OK); + var statusAfterIssue = await statusAfterIssueResponse.Content.ReadFromJsonAsync(); + statusAfterIssue.Should().NotBeNull(); + statusAfterIssue!.PendingCode.Should().NotBeNull(); + statusAfterIssue.PendingCode!.Code.Should().Be(issuedCode.Code); + } + + [Fact] + public async Task Polling_ShouldConsumeLinkCode_AndCreateCaptureForLinkedChat() + { + var fakeTelegramApi = _fixture.GetFakeTelegramApi(); + fakeTelegramApi.Reset(); + + var member = await _fixture.CreateMemberClientAsync(); + using var client = member.Client; + var memberUserId = await _fixture.GetUserIdByEmailAsync(member.Email); + + var codeResponse = await client.PostAsync("/api/v1/integrations/telegram/link-code", null); + codeResponse.StatusCode.Should().Be(HttpStatusCode.OK); + var issuedCode = await codeResponse.Content.ReadFromJsonAsync(); + issuedCode.Should().NotBeNull(); + + fakeTelegramApi.EnqueueGetUpdatesResponse($$""" + { + "ok": true, + "result": [ + { + "update_id": 1001, + "message": { + "message_id": 11, + "text": "{{issuedCode!.Code}}", + "chat": { "id": 555001, "type": "private", "username": "member_chat" }, + "from": { "id": 987001, "username": "member_sender" } + } + }, + { + "update_id": 1002, + "message": { + "message_id": 12, + "text": "hello from telegram https://example.com/telegram-link", + "chat": { "id": 555001, "type": "private", "username": "member_chat" }, + "from": { "id": 987001, "username": "member_sender" } + } + } + ] + } + """); + + using (var scope = _fixture.CreateScope()) + { + var service = scope.ServiceProvider.GetRequiredService(); + await service.PollAndIngestAsync(CancellationToken.None); + } + + using var assertScope = _fixture.CreateScope(); + var dbContext = assertScope.ServiceProvider.GetRequiredService(); + + var link = await dbContext.TelegramChatLinks + .Where(item => item.OwnerUserId == memberUserId && item.UnlinkedAt == null) + .SingleOrDefaultAsync(); + link.Should().NotBeNull(); + link!.TelegramChatId.Should().Be(555001); + + var capture = await dbContext.RawCaptures + .Where(item => item.OwnerUserId == memberUserId) + .SingleOrDefaultAsync(); + capture.Should().NotBeNull(); + capture!.SourceUrl.Should().Be("https://example.com/telegram-link"); + capture.RawContent.Should().Contain("hello from telegram"); + + capture.Metadata.Should().NotBeNullOrWhiteSpace(); + using var metadata = JsonDocument.Parse(capture.Metadata!); + metadata.RootElement.GetProperty("source").GetString().Should().Be("telegram"); + metadata.RootElement.GetProperty("importSource").GetString().Should().Be("telegram_bot"); + metadata.RootElement.GetProperty("telegramChatId").GetInt64().Should().Be(555001); + metadata.RootElement.GetProperty("telegramUpdateId").GetInt64().Should().Be(1002); + } + + [Fact] + public async Task UnlinkEndpoint_ShouldDeactivateActiveLink() + { + var member = await _fixture.CreateMemberClientAsync(); + using var client = member.Client; + var memberUserId = await _fixture.GetUserIdByEmailAsync(member.Email); + + await _fixture.ExecuteDbContextAsync(dbContext => + { + dbContext.TelegramChatLinks.Add(new TelegramChatLink + { + Id = Guid.NewGuid(), + OwnerUserId = memberUserId, + TelegramChatId = 700100, + TelegramUserId = 800100, + ChatDisplayName = "to-unlink", + SenderDisplayName = "sender", + LinkedAt = DateTimeOffset.UtcNow + }); + + return Task.CompletedTask; + }); + + var unlinkResponse = await client.DeleteAsync("/api/v1/integrations/telegram/link"); + unlinkResponse.StatusCode.Should().Be(HttpStatusCode.NoContent); + + using var scope = _fixture.CreateScope(); + var dbContext = scope.ServiceProvider.GetRequiredService(); + var activeLink = await dbContext.TelegramChatLinks + .Where(item => item.OwnerUserId == memberUserId && item.UnlinkedAt == null) + .SingleOrDefaultAsync(); + activeLink.Should().BeNull(); + } +} diff --git a/frontend/src/app/core/services/auth.service.ts b/frontend/src/app/core/services/auth.service.ts index cde2c18..e253a22 100644 --- a/frontend/src/app/core/services/auth.service.ts +++ b/frontend/src/app/core/services/auth.service.ts @@ -52,6 +52,21 @@ export interface InvitationPreview { expiresAt: string; } + +export interface TelegramLinkCodeResponse { + code: string; + expiresAt: string; +} + +export interface TelegramLinkStatus { + isLinked: boolean; + telegramChatId?: number; + chatDisplayName?: string; + senderDisplayName?: string; + linkedAt?: string; + pendingCode?: TelegramLinkCodeResponse; +} + export type AuthStatus = 'unknown' | 'authenticated' | 'anonymous'; @Injectable({ @@ -122,6 +137,25 @@ export class AuthService { return preferences; } + + async getTelegramStatus(): Promise { + return await firstValueFrom( + this.http.get(`${environment.apiBaseUrl}/v1/integrations/telegram/status`) + ); + } + + async issueTelegramLinkCode(): Promise { + return await firstValueFrom( + this.http.post(`${environment.apiBaseUrl}/v1/integrations/telegram/link-code`, {}) + ); + } + + async unlinkTelegram(): Promise { + await firstValueFrom( + this.http.delete(`${environment.apiBaseUrl}/v1/integrations/telegram/link`) + ); + } + async createInvitation(request: InvitationRequest): Promise { return await firstValueFrom( this.http.post(`${this.apiBaseUrl}/invitations`, request) diff --git a/frontend/src/app/features/settings/settings.component.html b/frontend/src/app/features/settings/settings.component.html index a109110..7bb78a6 100644 --- a/frontend/src/app/features/settings/settings.component.html +++ b/frontend/src/app/features/settings/settings.component.html @@ -52,6 +52,43 @@

Preserved languages

+
+
+
+

Messaging Ingestion

+

Telegram bot

+
+ +
+ +

{{ telegramError() }}

+ + +

+ Linked to chat {{ telegram.chatDisplayName || telegram.telegramChatId }} + since {{ telegram.linkedAt | date: 'medium' }}. +

+ + +

Telegram is not linked. Request a one-time link code and send it to the Sentinel bot in a private chat.

+
+ +
+

Link code: {{ pendingCode.code }}

+

Expires in {{ telegramCodeSecondsRemaining() }}s.

+
+ +
+ + +
+
+
+
Loading language preferences...
diff --git a/frontend/src/app/features/settings/settings.component.scss b/frontend/src/app/features/settings/settings.component.scss index a96e06f..0663c0b 100644 --- a/frontend/src/app/features/settings/settings.component.scss +++ b/frontend/src/app/features/settings/settings.component.scss @@ -161,3 +161,42 @@ select { width: 100%; } } + + +.telegram-header { + display: flex; + justify-content: space-between; + align-items: center; + gap: 1rem; +} + +h2 { + margin: 0; +} + +.telegram-code code { + padding: 0.2rem 0.45rem; + border-radius: 0.4rem; + background: rgba(15, 23, 42, 0.72); +} + +.secondary-btn, +.danger-btn { + border-radius: 999px; + padding: 0.75rem 1.25rem; + border: 1px solid rgba(148, 163, 184, 0.35); + background: rgba(15, 23, 42, 0.65); + color: #e2e8f0; + cursor: pointer; +} + +.danger-btn { + border-color: rgba(248, 113, 113, 0.5); + color: #fecaca; +} + +.secondary-btn[disabled], +.danger-btn[disabled] { + opacity: 0.6; + cursor: not-allowed; +} diff --git a/frontend/src/app/features/settings/settings.component.spec.ts b/frontend/src/app/features/settings/settings.component.spec.ts index a1e6904..5b1564d 100644 --- a/frontend/src/app/features/settings/settings.component.spec.ts +++ b/frontend/src/app/features/settings/settings.component.spec.ts @@ -44,7 +44,12 @@ describe('SettingsComponent', () => { { code: 'fr', displayName: 'French' } ] }), - updatePreferences + updatePreferences, + getTelegramStatus: vi.fn().mockResolvedValue({ + isLinked: false + }), + issueTelegramLinkCode: vi.fn().mockResolvedValue({ code: "SNT-TEST", expiresAt: new Date(Date.now() + 60000).toISOString() }), + unlinkTelegram: vi.fn().mockResolvedValue(undefined) }; await TestBed.configureTestingModule({ diff --git a/frontend/src/app/features/settings/settings.component.ts b/frontend/src/app/features/settings/settings.component.ts index f7b5841..a162e7f 100644 --- a/frontend/src/app/features/settings/settings.component.ts +++ b/frontend/src/app/features/settings/settings.component.ts @@ -1,10 +1,11 @@ -import { Component, OnInit, inject, signal } from '@angular/core'; +import { Component, OnDestroy, OnInit, inject, signal } from '@angular/core'; import { CommonModule } from '@angular/common'; import { FormsModule } from '@angular/forms'; import { AuthService, SupportedLanguage, + TelegramLinkStatus, UserLanguagePreferences } from '../../core/services/auth.service'; @@ -15,8 +16,9 @@ import { templateUrl: './settings.component.html', styleUrl: './settings.component.scss' }) -export class SettingsComponent implements OnInit { +export class SettingsComponent implements OnInit, OnDestroy { private readonly authService = inject(AuthService); + private countdownTimer: ReturnType | null = null; loading = signal(true); saving = signal(false); @@ -26,8 +28,18 @@ export class SettingsComponent implements OnInit { preservedLanguageCodes = signal([]); supportedLanguages = signal([]); + telegramLoading = signal(false); + telegramStatus = signal(null); + telegramError = signal(null); + telegramCodeSecondsRemaining = signal(0); + async ngOnInit(): Promise { await this.loadPreferences(); + await this.loadTelegramStatus(); + } + + ngOnDestroy(): void { + this.stopCountdown(); } isPreserved(languageCode: string): boolean { @@ -73,6 +85,38 @@ export class SettingsComponent implements OnInit { } } + async issueTelegramCode(): Promise { + this.telegramLoading.set(true); + this.telegramError.set(null); + + try { + await this.authService.issueTelegramLinkCode(); + await this.loadTelegramStatus(); + } catch { + this.telegramError.set('Unable to issue Telegram link code.'); + } finally { + this.telegramLoading.set(false); + } + } + + async unlinkTelegram(): Promise { + this.telegramLoading.set(true); + this.telegramError.set(null); + + try { + await this.authService.unlinkTelegram(); + await this.loadTelegramStatus(); + } catch { + this.telegramError.set('Unable to unlink Telegram chat.'); + } finally { + this.telegramLoading.set(false); + } + } + + async refreshTelegramStatus(): Promise { + await this.loadTelegramStatus(); + } + private async loadPreferences(): Promise { this.loading.set(true); this.errorMessage.set(null); @@ -87,9 +131,48 @@ export class SettingsComponent implements OnInit { } } + private async loadTelegramStatus(): Promise { + this.telegramLoading.set(true); + this.telegramError.set(null); + + try { + const status = await this.authService.getTelegramStatus(); + this.telegramStatus.set(status); + this.startCountdown(status); + } catch { + this.telegramError.set('Unable to load Telegram status.'); + } finally { + this.telegramLoading.set(false); + } + } + private applyPreferences(preferences: UserLanguagePreferences): void { this.defaultLanguageCode.set(preferences.defaultLanguageCode); this.preservedLanguageCodes.set([...preferences.preservedLanguageCodes]); this.supportedLanguages.set([...preferences.supportedLanguages]); } + + private startCountdown(status: TelegramLinkStatus): void { + this.stopCountdown(); + const expiresAt = status.pendingCode?.expiresAt; + if (!expiresAt) { + this.telegramCodeSecondsRemaining.set(0); + return; + } + + const update = (): void => { + const ms = new Date(expiresAt).getTime() - Date.now(); + this.telegramCodeSecondsRemaining.set(Math.max(0, Math.floor(ms / 1000))); + }; + + update(); + this.countdownTimer = setInterval(update, 1000); + } + + private stopCountdown(): void { + if (this.countdownTimer) { + clearInterval(this.countdownTimer); + this.countdownTimer = null; + } + } }