Add message deduplication and versioning for text match notifications
This update implements a comprehensive solution to prevent duplicate notifications when Telegram messages are edited, while maintaining a full history of changes. Features: - New TextMatch entity to store match history with versioning - Database migration for TextMatches table with proper indexes - TextMatchRepository for managing match records - TextSubscriptionUpdated event for message update notifications - Enhanced MessageReceivedHandler with deduplication logic: * First match creates version 1 and publishes TextSubscriptionMatched * Subsequent updates create new versions and publish TextSubscriptionUpdated * Skips notifications if message text hasn't changed Technical details: - MessageId changed from int to long to match Telegram API types - Proper indexes on (MessageId, SubscriptionId) and UserId - Full audit trail of message edits in database 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
parent
68cd5a0b1a
commit
78d1099bfc
@ -0,0 +1,24 @@
|
|||||||
|
namespace Nocr.TextMatcher.AppServices.TextMatches.Repositories;
|
||||||
|
|
||||||
|
public interface ITextMatchRepository
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Gets the latest version of a match for a specific message and subscription
|
||||||
|
/// </summary>
|
||||||
|
Task<TextMatch?> GetLatestByMessageAndSubscription(long messageId, long subscriptionId, CancellationToken cancellationToken = default);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets all matches for a specific message across all subscriptions
|
||||||
|
/// </summary>
|
||||||
|
Task<List<TextMatch>> GetByMessageId(long messageId, CancellationToken cancellationToken = default);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Creates a new match record
|
||||||
|
/// </summary>
|
||||||
|
Task<TextMatch> Create(TextMatch match, CancellationToken cancellationToken = default);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets the current version number for a message-subscription pair (0 if not exists)
|
||||||
|
/// </summary>
|
||||||
|
Task<int> GetCurrentVersion(long messageId, long subscriptionId, CancellationToken cancellationToken = default);
|
||||||
|
}
|
||||||
121
src/Nocr.TextMatcher.AppServices/TextMatches/TextMatch.cs
Normal file
121
src/Nocr.TextMatcher.AppServices/TextMatches/TextMatch.cs
Normal file
@ -0,0 +1,121 @@
|
|||||||
|
namespace Nocr.TextMatcher.AppServices.TextMatches;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Represents a historical record of a text match between a message and a subscription.
|
||||||
|
/// Each update to the same message creates a new version.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class TextMatch
|
||||||
|
{
|
||||||
|
public long Id { get; private set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Telegram message ID
|
||||||
|
/// </summary>
|
||||||
|
public long MessageId { get; private set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Text subscription that matched
|
||||||
|
/// </summary>
|
||||||
|
public long SubscriptionId { get; private set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// User ID who owns the subscription
|
||||||
|
/// </summary>
|
||||||
|
public long UserId { get; private set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Telegram chat username where the message was posted
|
||||||
|
/// </summary>
|
||||||
|
public string ChatUsername { get; private set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Message text at the time of this version
|
||||||
|
/// </summary>
|
||||||
|
public string MessageText { get; private set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Author of the message
|
||||||
|
/// </summary>
|
||||||
|
public string From { get; private set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Version number (1 for first match, increments with each update)
|
||||||
|
/// </summary>
|
||||||
|
public int Version { get; private set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// When the original message was posted in Telegram
|
||||||
|
/// </summary>
|
||||||
|
public DateTimeOffset MessageOccuredDateTime { get; private set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// When this match/version was created in our system
|
||||||
|
/// </summary>
|
||||||
|
public DateTimeOffset CreatedDateTime { get; private set; }
|
||||||
|
|
||||||
|
private TextMatch(
|
||||||
|
long messageId,
|
||||||
|
long subscriptionId,
|
||||||
|
long userId,
|
||||||
|
string chatUsername,
|
||||||
|
string messageText,
|
||||||
|
string from,
|
||||||
|
int version,
|
||||||
|
DateTimeOffset messageOccuredDateTime,
|
||||||
|
DateTimeOffset createdDateTime)
|
||||||
|
{
|
||||||
|
MessageId = messageId;
|
||||||
|
SubscriptionId = subscriptionId;
|
||||||
|
UserId = userId;
|
||||||
|
ChatUsername = chatUsername;
|
||||||
|
MessageText = messageText;
|
||||||
|
From = from;
|
||||||
|
Version = version;
|
||||||
|
MessageOccuredDateTime = messageOccuredDateTime;
|
||||||
|
CreatedDateTime = createdDateTime;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static TextMatch Create(
|
||||||
|
long messageId,
|
||||||
|
long subscriptionId,
|
||||||
|
long userId,
|
||||||
|
string chatUsername,
|
||||||
|
string messageText,
|
||||||
|
string from,
|
||||||
|
int version,
|
||||||
|
DateTimeOffset messageOccuredDateTime,
|
||||||
|
DateTimeOffset createdDateTime)
|
||||||
|
{
|
||||||
|
if (messageId <= 0)
|
||||||
|
throw new ArgumentException("Message ID must be greater than 0", nameof(messageId));
|
||||||
|
|
||||||
|
if (subscriptionId <= 0)
|
||||||
|
throw new ArgumentException("Subscription ID must be greater than 0", nameof(subscriptionId));
|
||||||
|
|
||||||
|
if (userId <= 0)
|
||||||
|
throw new ArgumentException("User ID must be greater than 0", nameof(userId));
|
||||||
|
|
||||||
|
if (string.IsNullOrWhiteSpace(chatUsername))
|
||||||
|
throw new ArgumentException("Chat username cannot be empty", nameof(chatUsername));
|
||||||
|
|
||||||
|
if (string.IsNullOrWhiteSpace(messageText))
|
||||||
|
throw new ArgumentException("Message text cannot be empty", nameof(messageText));
|
||||||
|
|
||||||
|
if (string.IsNullOrWhiteSpace(from))
|
||||||
|
throw new ArgumentException("From cannot be empty", nameof(from));
|
||||||
|
|
||||||
|
if (version <= 0)
|
||||||
|
throw new ArgumentException("Version must be greater than 0", nameof(version));
|
||||||
|
|
||||||
|
return new TextMatch(
|
||||||
|
messageId,
|
||||||
|
subscriptionId,
|
||||||
|
userId,
|
||||||
|
chatUsername,
|
||||||
|
messageText,
|
||||||
|
from,
|
||||||
|
version,
|
||||||
|
messageOccuredDateTime,
|
||||||
|
createdDateTime);
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -1,5 +1,7 @@
|
|||||||
using Microsoft.Extensions.Logging;
|
using Microsoft.Extensions.Logging;
|
||||||
using Nocr.TelegramListener.Async.Api.Contracts.Events;
|
using Nocr.TelegramListener.Async.Api.Contracts.Events;
|
||||||
|
using Nocr.TextMatcher.AppServices.TextMatches;
|
||||||
|
using Nocr.TextMatcher.AppServices.TextMatches.Repositories;
|
||||||
using Nocr.TextMatcher.AppServices.TextSubscriptions.Repositories;
|
using Nocr.TextMatcher.AppServices.TextSubscriptions.Repositories;
|
||||||
using Nocr.TextMatcher.Async.Api.Contracts;
|
using Nocr.TextMatcher.Async.Api.Contracts;
|
||||||
using Nocr.TextMatcher.Core.Dates;
|
using Nocr.TextMatcher.Core.Dates;
|
||||||
@ -12,17 +14,20 @@ public sealed class MessageReceivedHandler : IHandleMessages<MessageReceived>
|
|||||||
{
|
{
|
||||||
private readonly ILogger<MessageReceivedHandler> _logger;
|
private readonly ILogger<MessageReceivedHandler> _logger;
|
||||||
private readonly IBus _bus;
|
private readonly IBus _bus;
|
||||||
private readonly ITextSubscriptionRepository _textSubscriptionService;
|
private readonly ITextSubscriptionRepository _textSubscriptionRepository;
|
||||||
|
private readonly ITextMatchRepository _textMatchRepository;
|
||||||
private readonly ICurrentDateProvider _dateProvider;
|
private readonly ICurrentDateProvider _dateProvider;
|
||||||
|
|
||||||
public MessageReceivedHandler(ILogger<MessageReceivedHandler> logger,
|
public MessageReceivedHandler(ILogger<MessageReceivedHandler> logger,
|
||||||
IBus bus,
|
IBus bus,
|
||||||
ITextSubscriptionRepository textSubscriptionRepository,
|
ITextSubscriptionRepository textSubscriptionRepository,
|
||||||
|
ITextMatchRepository textMatchRepository,
|
||||||
ICurrentDateProvider dateProvider)
|
ICurrentDateProvider dateProvider)
|
||||||
{
|
{
|
||||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||||
_bus = bus ?? throw new ArgumentNullException(nameof(bus));
|
_bus = bus ?? throw new ArgumentNullException(nameof(bus));
|
||||||
_textSubscriptionService = textSubscriptionRepository ?? throw new ArgumentNullException(nameof(textSubscriptionRepository));
|
_textSubscriptionRepository = textSubscriptionRepository ?? throw new ArgumentNullException(nameof(textSubscriptionRepository));
|
||||||
|
_textMatchRepository = textMatchRepository ?? throw new ArgumentNullException(nameof(textMatchRepository));
|
||||||
_dateProvider = dateProvider ?? throw new ArgumentNullException(nameof(dateProvider));
|
_dateProvider = dateProvider ?? throw new ArgumentNullException(nameof(dateProvider));
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -30,28 +35,98 @@ public sealed class MessageReceivedHandler : IHandleMessages<MessageReceived>
|
|||||||
{
|
{
|
||||||
_logger.LogInformation("Received message: {@Message}.", message);
|
_logger.LogInformation("Received message: {@Message}.", message);
|
||||||
|
|
||||||
var matches = await _textSubscriptionService.Get();
|
var subscriptions = await _textSubscriptionRepository.Get();
|
||||||
|
|
||||||
foreach (var match in matches.Where(x => x.Active))
|
foreach (var subscription in subscriptions.Where(x => x.Active))
|
||||||
{
|
{
|
||||||
if (match.IsMatches(message.ChatUsername, message.Text))
|
if (subscription.IsMatches(message.ChatUsername, message.Text))
|
||||||
{
|
{
|
||||||
_logger.LogInformation("Message {@Message} matched {@Match}.", message, match);
|
_logger.LogInformation("Message {@Message} matched subscription {@Subscription}.", message, subscription);
|
||||||
var @event = new TextSubscriptionMatched
|
|
||||||
|
// Check if we already have a match for this message and subscription
|
||||||
|
var existingMatch = await _textMatchRepository.GetLatestByMessageAndSubscription(
|
||||||
|
message.MessageId,
|
||||||
|
subscription.Id);
|
||||||
|
|
||||||
|
if (existingMatch == null)
|
||||||
{
|
{
|
||||||
SubscriptionId = match.Id,
|
// First time match - create version 1 and publish TextSubscriptionMatched
|
||||||
SubscriptionUserId = match.UserId,
|
var textMatch = TextMatch.Create(
|
||||||
ChatUsername = match.ChatUsername,
|
messageId: message.MessageId,
|
||||||
MessageId = message.MessageId,
|
subscriptionId: subscription.Id,
|
||||||
Rule = match.Rule,
|
userId: subscription.UserId,
|
||||||
Template = match.Template,
|
chatUsername: message.ChatUsername,
|
||||||
Text = message.Text,
|
messageText: message.Text,
|
||||||
From = message.From,
|
from: message.From ?? "Unknown",
|
||||||
OccuredDateTime = message.OccuredDateTime,
|
version: 1,
|
||||||
PublishedDateTime = _dateProvider.UtcNow
|
messageOccuredDateTime: message.OccuredDateTime,
|
||||||
};
|
createdDateTime: _dateProvider.UtcNow);
|
||||||
|
|
||||||
await _bus.Advanced.Topics.Publish(Constants.RoutingKeys.MatchedSubscriptions, @event);
|
await _textMatchRepository.Create(textMatch);
|
||||||
|
|
||||||
|
var @event = new TextSubscriptionMatched
|
||||||
|
{
|
||||||
|
SubscriptionId = subscription.Id,
|
||||||
|
SubscriptionUserId = subscription.UserId,
|
||||||
|
ChatUsername = subscription.ChatUsername,
|
||||||
|
MessageId = message.MessageId,
|
||||||
|
Rule = subscription.Rule,
|
||||||
|
Template = subscription.Template,
|
||||||
|
Text = message.Text,
|
||||||
|
From = message.From,
|
||||||
|
OccuredDateTime = message.OccuredDateTime,
|
||||||
|
PublishedDateTime = _dateProvider.UtcNow
|
||||||
|
};
|
||||||
|
|
||||||
|
await _bus.Advanced.Topics.Publish(Constants.RoutingKeys.MatchedSubscriptions, @event);
|
||||||
|
_logger.LogInformation("Published TextSubscriptionMatched for message {MessageId}, subscription {SubscriptionId}, version 1.",
|
||||||
|
message.MessageId, subscription.Id);
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
// Message was updated - create new version and publish TextSubscriptionUpdated
|
||||||
|
// Only create new version if text actually changed
|
||||||
|
if (existingMatch.MessageText != message.Text)
|
||||||
|
{
|
||||||
|
var newVersion = existingMatch.Version + 1;
|
||||||
|
|
||||||
|
var textMatch = TextMatch.Create(
|
||||||
|
messageId: message.MessageId,
|
||||||
|
subscriptionId: subscription.Id,
|
||||||
|
userId: subscription.UserId,
|
||||||
|
chatUsername: message.ChatUsername,
|
||||||
|
messageText: message.Text,
|
||||||
|
from: message.From ?? "Unknown",
|
||||||
|
version: newVersion,
|
||||||
|
messageOccuredDateTime: message.OccuredDateTime,
|
||||||
|
createdDateTime: _dateProvider.UtcNow);
|
||||||
|
|
||||||
|
await _textMatchRepository.Create(textMatch);
|
||||||
|
|
||||||
|
var updateEvent = new TextSubscriptionUpdated
|
||||||
|
{
|
||||||
|
SubscriptionId = subscription.Id,
|
||||||
|
SubscriptionUserId = subscription.UserId,
|
||||||
|
ChatUsername = subscription.ChatUsername,
|
||||||
|
MessageId = message.MessageId,
|
||||||
|
Rule = subscription.Rule,
|
||||||
|
Template = subscription.Template,
|
||||||
|
Text = message.Text,
|
||||||
|
From = message.From,
|
||||||
|
Version = newVersion,
|
||||||
|
OccuredDateTime = message.OccuredDateTime,
|
||||||
|
PublishedDateTime = _dateProvider.UtcNow
|
||||||
|
};
|
||||||
|
|
||||||
|
await _bus.Advanced.Topics.Publish(Constants.RoutingKeys.MatchedSubscriptions, updateEvent);
|
||||||
|
_logger.LogInformation("Published TextSubscriptionUpdated for message {MessageId}, subscription {SubscriptionId}, version {Version}.",
|
||||||
|
message.MessageId, subscription.Id, newVersion);
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
_logger.LogDebug("Message {MessageId} text unchanged, skipping duplicate notification.", message.MessageId);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -0,0 +1,66 @@
|
|||||||
|
using Nocr.TextMatcher.Contracts;
|
||||||
|
|
||||||
|
namespace Nocr.TextMatcher.Async.Api.Contracts;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Event published when a previously matched message is updated (edited in Telegram)
|
||||||
|
/// </summary>
|
||||||
|
public class TextSubscriptionUpdated : IEvent
|
||||||
|
{
|
||||||
|
public Guid Id => Guid.NewGuid();
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Идентификатор матча
|
||||||
|
/// </summary>
|
||||||
|
public long SubscriptionId { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Идентификатор владельца матча
|
||||||
|
/// </summary>
|
||||||
|
public long SubscriptionUserId { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Username чата
|
||||||
|
/// </summary>
|
||||||
|
public required string ChatUsername { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Идентификатор сообщения
|
||||||
|
/// </summary>
|
||||||
|
public long MessageId { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Правило совпадения
|
||||||
|
/// </summary>
|
||||||
|
public TextSubscriptionRule Rule { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Шаблон совпадения
|
||||||
|
/// </summary>
|
||||||
|
public required string Template { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Имя или username отправителя
|
||||||
|
/// </summary>
|
||||||
|
public string? From { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Обновленный текст сообщения
|
||||||
|
/// </summary>
|
||||||
|
public required string Text { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Номер версии сообщения (2, 3, 4, ...)
|
||||||
|
/// </summary>
|
||||||
|
public int Version { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Дата получения оригинального сообщения слушателем
|
||||||
|
/// </summary>
|
||||||
|
public DateTimeOffset OccuredDateTime { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Дата публикации события в очередь
|
||||||
|
/// </summary>
|
||||||
|
public DateTimeOffset PublishedDateTime { get; set; }
|
||||||
|
}
|
||||||
117
src/Nocr.TextMatcher.Migrator/Migrations/20251014085918_AddTextMatchesTable.Designer.cs
generated
Normal file
117
src/Nocr.TextMatcher.Migrator/Migrations/20251014085918_AddTextMatchesTable.Designer.cs
generated
Normal file
@ -0,0 +1,117 @@
|
|||||||
|
// <auto-generated />
|
||||||
|
using System;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using Microsoft.EntityFrameworkCore.Infrastructure;
|
||||||
|
using Microsoft.EntityFrameworkCore.Metadata;
|
||||||
|
using Microsoft.EntityFrameworkCore.Migrations;
|
||||||
|
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
|
||||||
|
using Nocr.TextMatcher.Persistence;
|
||||||
|
|
||||||
|
#nullable disable
|
||||||
|
|
||||||
|
namespace Nocr.TextMatcher.Migrator.Migrations
|
||||||
|
{
|
||||||
|
[DbContext(typeof(TextMatcherContext))]
|
||||||
|
[Migration("20251014085918_AddTextMatchesTable")]
|
||||||
|
partial class AddTextMatchesTable
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void BuildTargetModel(ModelBuilder modelBuilder)
|
||||||
|
{
|
||||||
|
#pragma warning disable 612, 618
|
||||||
|
modelBuilder
|
||||||
|
.HasAnnotation("ProductVersion", "8.0.3")
|
||||||
|
.HasAnnotation("Relational:MaxIdentifierLength", 64);
|
||||||
|
|
||||||
|
MySqlModelBuilderExtensions.AutoIncrementColumns(modelBuilder);
|
||||||
|
|
||||||
|
modelBuilder.Entity("Nocr.TextMatcher.AppServices.TextMatches.TextMatch", b =>
|
||||||
|
{
|
||||||
|
b.Property<long>("Id")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("bigint");
|
||||||
|
|
||||||
|
MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property<long>("Id"));
|
||||||
|
|
||||||
|
b.Property<string>("ChatUsername")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(1024)
|
||||||
|
.HasColumnType("varchar(1024)");
|
||||||
|
|
||||||
|
b.Property<DateTimeOffset>("CreatedDateTime")
|
||||||
|
.HasColumnType("datetime(6)");
|
||||||
|
|
||||||
|
b.Property<string>("From")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(512)
|
||||||
|
.HasColumnType("varchar(512)");
|
||||||
|
|
||||||
|
b.Property<int>("MessageId")
|
||||||
|
.HasColumnType("int");
|
||||||
|
|
||||||
|
b.Property<DateTimeOffset>("MessageOccuredDateTime")
|
||||||
|
.HasColumnType("datetime(6)");
|
||||||
|
|
||||||
|
b.Property<string>("MessageText")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(4096)
|
||||||
|
.HasColumnType("varchar(4096)");
|
||||||
|
|
||||||
|
b.Property<long>("SubscriptionId")
|
||||||
|
.HasColumnType("bigint");
|
||||||
|
|
||||||
|
b.Property<long>("UserId")
|
||||||
|
.HasColumnType("bigint");
|
||||||
|
|
||||||
|
b.Property<int>("Version")
|
||||||
|
.HasColumnType("int");
|
||||||
|
|
||||||
|
b.HasKey("Id");
|
||||||
|
|
||||||
|
b.HasIndex("UserId")
|
||||||
|
.HasDatabaseName("IX_TextMatches_UserId");
|
||||||
|
|
||||||
|
b.HasIndex("MessageId", "SubscriptionId")
|
||||||
|
.HasDatabaseName("IX_TextMatches_MessageId_SubscriptionId");
|
||||||
|
|
||||||
|
b.ToTable("TextMatches", (string)null);
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("Nocr.TextMatcher.AppServices.TextSubscriptions.TextSubscription", b =>
|
||||||
|
{
|
||||||
|
b.Property<long>("Id")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("bigint");
|
||||||
|
|
||||||
|
MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property<long>("Id"));
|
||||||
|
|
||||||
|
b.Property<bool>("Active")
|
||||||
|
.HasColumnType("tinyint(1)");
|
||||||
|
|
||||||
|
b.Property<string>("ChatUsername")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(1024)
|
||||||
|
.HasColumnType("varchar(1024)");
|
||||||
|
|
||||||
|
b.Property<DateTimeOffset>("CreatedDateTime")
|
||||||
|
.HasColumnType("datetime(6)");
|
||||||
|
|
||||||
|
b.Property<int>("Rule")
|
||||||
|
.HasColumnType("int");
|
||||||
|
|
||||||
|
b.Property<string>("Template")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(1024)
|
||||||
|
.HasColumnType("varchar(1024)");
|
||||||
|
|
||||||
|
b.Property<long>("UserId")
|
||||||
|
.HasColumnType("bigint");
|
||||||
|
|
||||||
|
b.HasKey("Id");
|
||||||
|
|
||||||
|
b.ToTable("TextSubscriptions");
|
||||||
|
});
|
||||||
|
#pragma warning restore 612, 618
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,58 @@
|
|||||||
|
using System;
|
||||||
|
using Microsoft.EntityFrameworkCore.Metadata;
|
||||||
|
using Microsoft.EntityFrameworkCore.Migrations;
|
||||||
|
|
||||||
|
#nullable disable
|
||||||
|
|
||||||
|
namespace Nocr.TextMatcher.Migrator.Migrations
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
public partial class AddTextMatchesTable : Migration
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Up(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.CreateTable(
|
||||||
|
name: "TextMatches",
|
||||||
|
columns: table => new
|
||||||
|
{
|
||||||
|
Id = table.Column<long>(type: "bigint", nullable: false)
|
||||||
|
.Annotation("MySql:ValueGenerationStrategy", MySqlValueGenerationStrategy.IdentityColumn),
|
||||||
|
MessageId = table.Column<long>(type: "bigint", nullable: false),
|
||||||
|
SubscriptionId = table.Column<long>(type: "bigint", nullable: false),
|
||||||
|
UserId = table.Column<long>(type: "bigint", nullable: false),
|
||||||
|
ChatUsername = table.Column<string>(type: "varchar(1024)", maxLength: 1024, nullable: false)
|
||||||
|
.Annotation("MySql:CharSet", "utf8mb4"),
|
||||||
|
MessageText = table.Column<string>(type: "varchar(4096)", maxLength: 4096, nullable: false)
|
||||||
|
.Annotation("MySql:CharSet", "utf8mb4"),
|
||||||
|
From = table.Column<string>(type: "varchar(512)", maxLength: 512, nullable: false)
|
||||||
|
.Annotation("MySql:CharSet", "utf8mb4"),
|
||||||
|
Version = table.Column<int>(type: "int", nullable: false),
|
||||||
|
MessageOccuredDateTime = table.Column<DateTimeOffset>(type: "datetime(6)", nullable: false),
|
||||||
|
CreatedDateTime = table.Column<DateTimeOffset>(type: "datetime(6)", nullable: false)
|
||||||
|
},
|
||||||
|
constraints: table =>
|
||||||
|
{
|
||||||
|
table.PrimaryKey("PK_TextMatches", x => x.Id);
|
||||||
|
})
|
||||||
|
.Annotation("MySql:CharSet", "utf8mb4");
|
||||||
|
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "IX_TextMatches_MessageId_SubscriptionId",
|
||||||
|
table: "TextMatches",
|
||||||
|
columns: new[] { "MessageId", "SubscriptionId" });
|
||||||
|
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "IX_TextMatches_UserId",
|
||||||
|
table: "TextMatches",
|
||||||
|
column: "UserId");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Down(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.DropTable(
|
||||||
|
name: "TextMatches");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -22,6 +22,58 @@ namespace Nocr.TextMatcher.Migrator.Migrations
|
|||||||
|
|
||||||
MySqlModelBuilderExtensions.AutoIncrementColumns(modelBuilder);
|
MySqlModelBuilderExtensions.AutoIncrementColumns(modelBuilder);
|
||||||
|
|
||||||
|
modelBuilder.Entity("Nocr.TextMatcher.AppServices.TextMatches.TextMatch", b =>
|
||||||
|
{
|
||||||
|
b.Property<long>("Id")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("bigint");
|
||||||
|
|
||||||
|
MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property<long>("Id"));
|
||||||
|
|
||||||
|
b.Property<string>("ChatUsername")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(1024)
|
||||||
|
.HasColumnType("varchar(1024)");
|
||||||
|
|
||||||
|
b.Property<DateTimeOffset>("CreatedDateTime")
|
||||||
|
.HasColumnType("datetime(6)");
|
||||||
|
|
||||||
|
b.Property<string>("From")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(512)
|
||||||
|
.HasColumnType("varchar(512)");
|
||||||
|
|
||||||
|
b.Property<int>("MessageId")
|
||||||
|
.HasColumnType("int");
|
||||||
|
|
||||||
|
b.Property<DateTimeOffset>("MessageOccuredDateTime")
|
||||||
|
.HasColumnType("datetime(6)");
|
||||||
|
|
||||||
|
b.Property<string>("MessageText")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(4096)
|
||||||
|
.HasColumnType("varchar(4096)");
|
||||||
|
|
||||||
|
b.Property<long>("SubscriptionId")
|
||||||
|
.HasColumnType("bigint");
|
||||||
|
|
||||||
|
b.Property<long>("UserId")
|
||||||
|
.HasColumnType("bigint");
|
||||||
|
|
||||||
|
b.Property<int>("Version")
|
||||||
|
.HasColumnType("int");
|
||||||
|
|
||||||
|
b.HasKey("Id");
|
||||||
|
|
||||||
|
b.HasIndex("UserId")
|
||||||
|
.HasDatabaseName("IX_TextMatches_UserId");
|
||||||
|
|
||||||
|
b.HasIndex("MessageId", "SubscriptionId")
|
||||||
|
.HasDatabaseName("IX_TextMatches_MessageId_SubscriptionId");
|
||||||
|
|
||||||
|
b.ToTable("TextMatches", (string)null);
|
||||||
|
});
|
||||||
|
|
||||||
modelBuilder.Entity("Nocr.TextMatcher.AppServices.TextSubscriptions.TextSubscription", b =>
|
modelBuilder.Entity("Nocr.TextMatcher.AppServices.TextSubscriptions.TextSubscription", b =>
|
||||||
{
|
{
|
||||||
b.Property<long>("Id")
|
b.Property<long>("Id")
|
||||||
|
|||||||
@ -1,7 +1,9 @@
|
|||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
using Microsoft.Extensions.DependencyInjection;
|
using Microsoft.Extensions.DependencyInjection;
|
||||||
using Microsoft.Extensions.Logging;
|
using Microsoft.Extensions.Logging;
|
||||||
|
using Nocr.TextMatcher.AppServices.TextMatches.Repositories;
|
||||||
using Nocr.TextMatcher.AppServices.TextSubscriptions.Repositories;
|
using Nocr.TextMatcher.AppServices.TextSubscriptions.Repositories;
|
||||||
|
using Nocr.TextMatcher.Persistence.TextMatches;
|
||||||
using Nocr.TextMatcher.Persistence.TextSubscriptions;
|
using Nocr.TextMatcher.Persistence.TextSubscriptions;
|
||||||
|
|
||||||
namespace Nocr.TextMatcher.Persistence;
|
namespace Nocr.TextMatcher.Persistence;
|
||||||
@ -12,11 +14,12 @@ public static class ServiceCollectionExtensions
|
|||||||
{
|
{
|
||||||
if (services == null)
|
if (services == null)
|
||||||
throw new ArgumentNullException(nameof(services));
|
throw new ArgumentNullException(nameof(services));
|
||||||
|
|
||||||
if (string.IsNullOrWhiteSpace(connectionString))
|
if (string.IsNullOrWhiteSpace(connectionString))
|
||||||
throw new ArgumentNullException(nameof(connectionString));
|
throw new ArgumentNullException(nameof(connectionString));
|
||||||
|
|
||||||
services.AddScoped<ITextSubscriptionRepository, TextSubscriptionRepository>();
|
services.AddScoped<ITextSubscriptionRepository, TextSubscriptionRepository>();
|
||||||
|
services.AddScoped<ITextMatchRepository, TextMatchRepository>();
|
||||||
|
|
||||||
services.AddDbContext<TextMatcherContext>(
|
services.AddDbContext<TextMatcherContext>(
|
||||||
(ctx, context) =>
|
(ctx, context) =>
|
||||||
|
|||||||
@ -1,5 +1,7 @@
|
|||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using Nocr.TextMatcher.AppServices.TextMatches;
|
||||||
using Nocr.TextMatcher.AppServices.TextSubscriptions;
|
using Nocr.TextMatcher.AppServices.TextSubscriptions;
|
||||||
|
using Nocr.TextMatcher.Persistence.TextMatches;
|
||||||
using Nocr.TextMatcher.Persistence.TextSubscriptions;
|
using Nocr.TextMatcher.Persistence.TextSubscriptions;
|
||||||
|
|
||||||
namespace Nocr.TextMatcher.Persistence;
|
namespace Nocr.TextMatcher.Persistence;
|
||||||
@ -7,6 +9,7 @@ namespace Nocr.TextMatcher.Persistence;
|
|||||||
public class TextMatcherContext : DbContext
|
public class TextMatcherContext : DbContext
|
||||||
{
|
{
|
||||||
public DbSet<TextSubscription> TextSubscriptions { get; set; }
|
public DbSet<TextSubscription> TextSubscriptions { get; set; }
|
||||||
|
public DbSet<TextMatch> TextMatches { get; set; }
|
||||||
|
|
||||||
public TextMatcherContext(DbContextOptions options)
|
public TextMatcherContext(DbContextOptions options)
|
||||||
: base(options)
|
: base(options)
|
||||||
@ -16,6 +19,7 @@ public class TextMatcherContext : DbContext
|
|||||||
protected override void OnModelCreating(ModelBuilder modelBuilder)
|
protected override void OnModelCreating(ModelBuilder modelBuilder)
|
||||||
{
|
{
|
||||||
modelBuilder.ApplyConfiguration(new TextSubscriptionConfiguration());
|
modelBuilder.ApplyConfiguration(new TextSubscriptionConfiguration());
|
||||||
|
modelBuilder.ApplyConfiguration(new TextMatchConfiguration());
|
||||||
|
|
||||||
base.OnModelCreating(modelBuilder);
|
base.OnModelCreating(modelBuilder);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -0,0 +1,53 @@
|
|||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using Microsoft.EntityFrameworkCore.Metadata.Builders;
|
||||||
|
using Nocr.TextMatcher.AppServices.TextMatches;
|
||||||
|
|
||||||
|
namespace Nocr.TextMatcher.Persistence.TextMatches;
|
||||||
|
|
||||||
|
public class TextMatchConfiguration : IEntityTypeConfiguration<TextMatch>
|
||||||
|
{
|
||||||
|
public void Configure(EntityTypeBuilder<TextMatch> builder)
|
||||||
|
{
|
||||||
|
builder.ToTable("TextMatches");
|
||||||
|
|
||||||
|
builder.HasKey(x => x.Id);
|
||||||
|
|
||||||
|
builder.Property(x => x.MessageId)
|
||||||
|
.IsRequired();
|
||||||
|
|
||||||
|
builder.Property(x => x.SubscriptionId)
|
||||||
|
.IsRequired();
|
||||||
|
|
||||||
|
builder.Property(x => x.UserId)
|
||||||
|
.IsRequired();
|
||||||
|
|
||||||
|
builder.Property(x => x.ChatUsername)
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(1024);
|
||||||
|
|
||||||
|
builder.Property(x => x.MessageText)
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(4096); // Telegram message max length
|
||||||
|
|
||||||
|
builder.Property(x => x.From)
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(512);
|
||||||
|
|
||||||
|
builder.Property(x => x.Version)
|
||||||
|
.IsRequired();
|
||||||
|
|
||||||
|
builder.Property(x => x.MessageOccuredDateTime)
|
||||||
|
.IsRequired();
|
||||||
|
|
||||||
|
builder.Property(x => x.CreatedDateTime)
|
||||||
|
.IsRequired();
|
||||||
|
|
||||||
|
// Create index for fast lookups by MessageId and SubscriptionId
|
||||||
|
builder.HasIndex(x => new { x.MessageId, x.SubscriptionId })
|
||||||
|
.HasDatabaseName("IX_TextMatches_MessageId_SubscriptionId");
|
||||||
|
|
||||||
|
// Create index for user queries
|
||||||
|
builder.HasIndex(x => x.UserId)
|
||||||
|
.HasDatabaseName("IX_TextMatches_UserId");
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,48 @@
|
|||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using Nocr.TextMatcher.AppServices.TextMatches;
|
||||||
|
using Nocr.TextMatcher.AppServices.TextMatches.Repositories;
|
||||||
|
|
||||||
|
namespace Nocr.TextMatcher.Persistence.TextMatches;
|
||||||
|
|
||||||
|
public class TextMatchRepository : ITextMatchRepository
|
||||||
|
{
|
||||||
|
private readonly TextMatcherContext _context;
|
||||||
|
|
||||||
|
public TextMatchRepository(TextMatcherContext context)
|
||||||
|
{
|
||||||
|
_context = context ?? throw new ArgumentNullException(nameof(context));
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<TextMatch?> GetLatestByMessageAndSubscription(long messageId, long subscriptionId, CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
return await _context.TextMatches
|
||||||
|
.Where(x => x.MessageId == messageId && x.SubscriptionId == subscriptionId)
|
||||||
|
.OrderByDescending(x => x.Version)
|
||||||
|
.FirstOrDefaultAsync(cancellationToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<List<TextMatch>> GetByMessageId(long messageId, CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
return await _context.TextMatches
|
||||||
|
.Where(x => x.MessageId == messageId)
|
||||||
|
.OrderBy(x => x.SubscriptionId)
|
||||||
|
.ThenByDescending(x => x.Version)
|
||||||
|
.ToListAsync(cancellationToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<TextMatch> Create(TextMatch match, CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
await _context.TextMatches.AddAsync(match, cancellationToken);
|
||||||
|
await _context.SaveChangesAsync(cancellationToken);
|
||||||
|
return match;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<int> GetCurrentVersion(long messageId, long subscriptionId, CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
var maxVersion = await _context.TextMatches
|
||||||
|
.Where(x => x.MessageId == messageId && x.SubscriptionId == subscriptionId)
|
||||||
|
.MaxAsync(x => (int?)x.Version, cancellationToken);
|
||||||
|
|
||||||
|
return maxVersion ?? 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue
Block a user