Add infrastructure for update handling

This commit is contained in:
Sergey Nazarov 2024-03-20 19:48:43 +04:00
parent b17f3dbd0a
commit f2b4864237
18 changed files with 443 additions and 20 deletions

View File

@ -1,16 +1,23 @@
<Project>
<PropertyGroup>
<TargetFramework>net8.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<MicrosoftVersion>8.0.0</MicrosoftVersion>
</PropertyGroup>
<ItemGroup Label="Serilog">
<PackageVersion Include="Serilog" Version="3.1.1"/>
<PackageVersion Include="Serilog.AspNetCore" Version="8.0.1"/>
<PackageVersion Include="Serilog.Settings.Configuration" Version="8.0.0"/>
</ItemGroup>
<ItemGroup Label="Microsoft">
<PackageVersion Include="Microsoft.Extensions.Logging.Abstractions" Version="$(MicrosoftVersion)"/>
</ItemGroup>
</Project>
<PropertyGroup>
<TargetFramework>net8.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<MicrosoftVersion>8.0.0</MicrosoftVersion>
</PropertyGroup>
<ItemGroup Label="Telegram">
<PackageVersion Include="Microsoft.Extensions.Options.ConfigurationExtensions" Version="8.0.0" />
<PackageVersion Include="WTelegramClient" Version="3.7.1" />
</ItemGroup>
<ItemGroup Label="Serilog">
<PackageVersion Include="Serilog" Version="3.1.1" />
<PackageVersion Include="Serilog.AspNetCore" Version="8.0.1" />
<PackageVersion Include="Serilog.Settings.Configuration" Version="8.0.0" />
</ItemGroup>
<ItemGroup Label="Microsoft">
<PackageVersion Include="Microsoft.Extensions.Options" Version="$(MicrosoftVersion)" />
<PackageVersion Include="Microsoft.Extensions.Configuration.Binder" Version="$(MicrosoftVersion)" />
<PackageVersion Include="Microsoft.Extensions.Logging.Abstractions" Version="$(MicrosoftVersion)" />
<PackageVersion Include="Microsoft.Extensions.Hosting.Abstractions" Version="$(MicrosoftVersion)" />
</ItemGroup>
</Project>

View File

@ -2,10 +2,12 @@
<ItemGroup>
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" />
<PackageReference Include="Microsoft.Extensions.Options.ConfigurationExtensions" />
<PackageReference Include="WTelegramClient" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\..\Nocr.TelegramListener.Async.Api.Contracts\Nocr.TelegramListener.Async.Api.Contracts.csproj" />
<ProjectReference Include="..\Nocr.TelegramListener.Async.Api.Contracts\Nocr.TelegramListener.Async.Api.Contracts.csproj" />
<ProjectReference Include="..\Nocr.TelegramListener.Core\Nocr.TelegramListener.Core.csproj" />
</ItemGroup>

View File

@ -1,15 +1,25 @@
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Nocr.TelegramListener.AppServices.UpdateListeners;
using Nocr.TelegramListener.AppServices.UpdateListeners.Handlers;
namespace Nocr.TelegramListener.AppServices;
public static class ServiceCollectionExtensions
{
public static IServiceCollection AddAppServices(this IServiceCollection services)
public static IServiceCollection AddAppServices(this IServiceCollection services, IConfiguration configuration)
{
if (services == null)
if (services == null)
throw new ArgumentNullException(nameof(services));
// Add registrations here
services.Configure<WTelegramClientOptions>(configuration.GetSection(nameof(WTelegramClientOptions)));
services.AddHostedService<UpdateListenerBackgroundService>();
services.AddScoped<IUpdateHandler, UpdateHandler>();
services.AddScoped<IMessageHandler, MessageHandler>();
services.AddSingleton<ITelegramClientContainer, TelegramClientContainer>();
services.AddSingleton<TelegramRegistry>();
return services;
}

View File

@ -0,0 +1,8 @@
using TL;
namespace Nocr.TelegramListener.AppServices.UpdateListeners.Handlers;
public interface IMessageHandler
{
Task HandleMessage(MessageBase messageBase, bool edit = false);
}

View File

@ -0,0 +1,8 @@
using TL;
namespace Nocr.TelegramListener.AppServices.UpdateListeners.Handlers;
public interface IUpdateHandler
{
Task HandleUpdate(UpdatesBase updates, CancellationToken cancellationToken = default);
}

View File

@ -0,0 +1,31 @@
using TL;
namespace Nocr.TelegramListener.AppServices.UpdateListeners.Handlers;
public sealed class MessageHandler : IMessageHandler
{
private readonly TelegramRegistry _telegramRegistry;
public MessageHandler(TelegramRegistry telegramRegistry)
{
_telegramRegistry = telegramRegistry ?? throw new ArgumentNullException(nameof(telegramRegistry));
}
public Task HandleMessage(MessageBase messageBase, bool edit = false)
{
if (edit) Console.Write("(Edit): ");
switch (messageBase)
{
case Message m:
Console.WriteLine(
$"{m.from_id.Peer(_telegramRegistry.Users, _telegramRegistry.Chats) ?? m.post_author} in {m.peer_id.Peer(_telegramRegistry.Users, _telegramRegistry.Chats)}> {m.message}");
break;
case MessageService ms:
Console.WriteLine(
$"{ms.from_id.Peer(_telegramRegistry.Users, _telegramRegistry.Chats)} in {ms.peer_id.Peer(_telegramRegistry.Users, _telegramRegistry.Chats)} [{ms.action.GetType().Name[13..]}]");
break;
}
return Task.CompletedTask;
}
}

View File

@ -0,0 +1,82 @@
using TL;
namespace Nocr.TelegramListener.AppServices.UpdateListeners.Handlers;
public sealed class UpdateHandler : IUpdateHandler
{
private readonly ITelegramClientContainer _clientContainer;
private readonly TelegramRegistry _telegramRegistry;
private readonly IMessageHandler _messageHandler;
public UpdateHandler(ITelegramClientContainer clientContainer, TelegramRegistry telegramRegistry,
IMessageHandler messageHandler)
{
_clientContainer = clientContainer ?? throw new ArgumentNullException(nameof(clientContainer));
_telegramRegistry = telegramRegistry ?? throw new ArgumentNullException(nameof(telegramRegistry));
_messageHandler = messageHandler ?? throw new ArgumentNullException(nameof(messageHandler));
}
public async Task HandleUpdate(UpdatesBase updates, CancellationToken cancellationToken = default)
{
var (client, users, chats) =
(_clientContainer.Client, _telegramRegistry.Users, _telegramRegistry.Chats);
updates.CollectUsersChats(users, chats);
if (updates is UpdateShortMessage usm && !users.ContainsKey(usm.user_id))
(await client.Updates_GetDifference(usm.pts - usm.pts_count, usm.date, 0)).CollectUsersChats(
users, chats);
else if (updates is UpdateShortChatMessage uscm &&
(!users.ContainsKey(uscm.from_id) ||
!chats.ContainsKey(uscm.chat_id)))
(await client.Updates_GetDifference(uscm.pts - uscm.pts_count, uscm.date, 0)).CollectUsersChats(
users,
chats);
foreach (var update in updates.UpdateList)
switch (update)
{
case UpdateNewMessage unm:
await _messageHandler.HandleMessage(unm.message);
break;
case UpdateEditMessage uem:
await _messageHandler.HandleMessage(uem.message, true);
break;
// Note: UpdateNewChannelMessage and UpdateEditChannelMessage are also handled by above cases
case UpdateDeleteChannelMessages udcm:
Console.WriteLine(
$"{udcm.messages.Length} message(s) deleted in {chats.Chat(udcm.channel_id)}");
break;
case UpdateDeleteMessages udm:
Console.WriteLine($"{udm.messages.Length} message(s) deleted");
break;
case UpdateUserTyping uut:
Console.WriteLine($"{users.User(uut.user_id)} is {uut.action}");
break;
case UpdateChatUserTyping ucut:
Console.WriteLine(
$"{ucut.from_id.Peer(users, chats)} is {ucut.action} in {chats.Chat(ucut.chat_id)}");
break;
case UpdateChannelUserTyping ucut2:
Console.WriteLine(
$"{ucut2.from_id.Peer(users, chats)} is {ucut2.action} in {chats.Chat(ucut2.channel_id)}");
break;
case UpdateChatParticipants { participants: ChatParticipants cp }:
Console.WriteLine(
$"{cp.participants.Length} participants in {chats.Chat(cp.chat_id)}");
break;
case UpdateUserStatus uus:
Console.WriteLine(
$"{users.User(uus.user_id)} is now {uus.status.GetType().Name[10..]}");
break;
case UpdateUserName uun:
Console.WriteLine(
$"{users.User(uun.user_id)} has changed profile name: {uun.first_name} {uun.last_name}");
break;
case UpdateUser uu:
Console.WriteLine($"{users.User(uu.user_id)} has changed infos/photo");
break;
default:
Console.WriteLine(update.GetType().Name);
break; // there are much more update types than the above example cases
}
}
}

View File

@ -0,0 +1,14 @@
using WTelegram;
namespace Nocr.TelegramListener.AppServices.UpdateListeners;
public interface ITelegramClientContainer
{
Client Client { get; }
public bool Initialized { get; }
public void Initialize();
public void Reset();
}

View File

@ -0,0 +1,58 @@
using Microsoft.Extensions.Options;
using WTelegram;
namespace Nocr.TelegramListener.AppServices.UpdateListeners;
public sealed class TelegramClientContainer : ITelegramClientContainer, IDisposable
{
private Client? _client;
private readonly WTelegramClientOptions _options;
public Client Client => _client ?? throw new InvalidOperationException("Client not initialized yet");
public bool Initialized { get; private set; }
public TelegramClientContainer(IOptions<WTelegramClientOptions> options)
{
_options = options.Value ?? throw new ArgumentNullException(nameof(options));
}
public void Initialize()
{
if (Initialized)
return;
_client = new Client(ConfigureWTelegramClient);
Initialized = true;
}
public void Reset()
{
Initialized = false;
Dispose();
_client = null;
}
private string ConfigureWTelegramClient(string what)
{
switch (what)
{
case "api_id": return _options.ApiId;
case "api_hash": return _options.ApiHash;
case "phone_number": return _options.PhoneNumber;
case "verification_code":
Console.Write("Code: ");
return Console.ReadLine();
//case "first_name": return "Dmitry"; // if sign-up is required
//case "last_name": return "Charushnikov"; // if sign-up is required
//case "password": return ""; // if user has enabled 2FA
default: return null; // let WTelegramClient decide the default config
}
}
public void Dispose()
{
_client?.Dispose();
}
}

View File

@ -0,0 +1,16 @@
using TL;
namespace Nocr.TelegramListener.AppServices.UpdateListeners;
public static class TelegramObjectExtensions
{
public static string User(this IDictionary<long, User> dictionary, long id) =>
dictionary.TryGetValue(id, out var user) ? user.ToString() : $"User {id}";
public static string Chat(this IDictionary<long, ChatBase> dictionary, long id) =>
dictionary.TryGetValue(id, out var chat) ? chat.ToString() : $"Chat {id}";
public static string Peer(this Peer peer, IDictionary<long, User> users, IDictionary<long, ChatBase> chats) => peer is null ? null
: peer is PeerUser user ? users.User(user.user_id)
: peer is PeerChat or PeerChannel ? chats.Chat(peer.ID) : $"Peer {peer.ID}";
}

View File

@ -0,0 +1,34 @@
using System.Collections.Concurrent;
using TL;
namespace Nocr.TelegramListener.AppServices.UpdateListeners;
public sealed class TelegramRegistry
{
public User My { get; private set; }
public ConcurrentDictionary<long, User> Users = new();
public ConcurrentDictionary<long, ChatBase> Chats = new();
public void SetMy(User my)
{
if (my == null)
{
throw new ArgumentNullException(nameof(my));
}
if (My == null)
{
My = my;
return;
}
throw new InvalidOperationException("My already set");
}
public void Clear()
{
My = null;
Users.Clear();
Chats.Clear();
}
}

View File

@ -0,0 +1,70 @@
using System.Collections.Concurrent;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Options;
using Nocr.TelegramListener.AppServices.UpdateListeners.Handlers;
using TL;
using TL.Methods;
using WTelegram;
namespace Nocr.TelegramListener.AppServices.UpdateListeners;
public sealed class UpdateListenerBackgroundService : BackgroundService
{
private readonly IServiceProvider _serviceProvider;
private readonly ITelegramClientContainer _telegramClientContainer;
private readonly TelegramRegistry _telegramRegistry;
private readonly WTelegramClientOptions _wTelegramClientOptions;
public UpdateListenerBackgroundService(IServiceProvider serviceProvider,
IOptions<WTelegramClientOptions> wTelegramClientOptions,
ITelegramClientContainer telegramClientContainer,
TelegramRegistry telegramRegistry)
{
_serviceProvider = serviceProvider ?? throw new ArgumentNullException(nameof(serviceProvider));
_telegramClientContainer = telegramClientContainer ?? throw new ArgumentNullException(nameof(telegramClientContainer));
_telegramRegistry = telegramRegistry ?? throw new ArgumentNullException(nameof(telegramRegistry));
_wTelegramClientOptions = wTelegramClientOptions.Value ??
throw new ArgumentNullException(nameof(wTelegramClientOptions));
}
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
Console.WriteLine(
"The program will display updates received for the logged-in user. Press any key to terminate");
WTelegram.Helpers.Log = (l, s) => System.Diagnostics.Debug.WriteLine(s);
Client? client = null;
while (!stoppingToken.IsCancellationRequested)
{
if (client == null)
{
_telegramRegistry.Clear();
_telegramClientContainer.Reset();
_telegramClientContainer.Initialize();
client = _telegramClientContainer.Client;
client.OnUpdate += HandleUpdates;
var my = await client.LoginUserIfNeeded();
_telegramRegistry.SetMy(my);
_telegramRegistry.Users[my.id] = my;
// Note: on login, Telegram may sends a bunch of updates/messages that happened in the past and were not acknowledged
Console.WriteLine(
$"We are logged-in as {_telegramRegistry.My.username ?? _telegramRegistry.My.first_name + " " + _telegramRegistry.My.last_name} (id {_telegramRegistry.My.id})");
// We collect all infos about the users/chats so that updates can be printed with their names
var dialogs = await client.Messages_GetAllDialogs(); // dialogs = groups/channels/users
dialogs.CollectUsersChats(_telegramRegistry.Users, _telegramRegistry.Chats);
}
await Task.Delay(TimeSpan.FromMinutes(5), stoppingToken);
}
}
private async Task HandleUpdates(UpdatesBase updates)
{
using var scope = _serviceProvider.CreateScope();
var updateHandler = scope.ServiceProvider.GetRequiredService<IUpdateHandler>();
await updateHandler.HandleUpdate(updates);
}
}

View File

@ -0,0 +1,19 @@
namespace Nocr.TelegramListener.AppServices.UpdateListeners;
/// <summary>
/// Options for WTelegramClient
/// </summary>
public sealed class WTelegramClientOptions
{
public string ApiId { get; set; }
public string ApiHash { get; set; }
public string PhoneNumber { get; set; }
public string? FirstName { get; set; }
public string? LastName { get; set; }
public string? Password { get; set; }
}

View File

@ -0,0 +1,6 @@
namespace Nocr.TelegramListener.Core.BackgroundServices;
public interface IRepeatableBackgroundServiceHandler
{
Task Handle(CancellationToken cancellationToken = default);
}

View File

@ -0,0 +1,42 @@
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;
namespace Nocr.TelegramListener.Core.BackgroundServices;
public abstract class RepeatableBackgroundService<THandler, TOptions> : BackgroundService
where THandler : IRepeatableBackgroundServiceHandler
where TOptions : RepeatableServiceOptions
{
private readonly ILogger _logger;
private readonly TOptions _options;
private readonly IServiceProvider _serviceProvider;
protected RepeatableBackgroundService(ILogger logger, TOptions options, IServiceProvider serviceProvider)
{
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
_options = options ?? throw new ArgumentNullException(nameof(options));
_serviceProvider = serviceProvider ?? throw new ArgumentNullException(nameof(serviceProvider));
}
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
while (!stoppingToken.IsCancellationRequested)
{
try
{
using var scope = _serviceProvider.CreateScope();
var handler = scope.ServiceProvider.GetRequiredService<THandler>();
await handler.Handle(stoppingToken);
await Task.Delay(_options.Interval, stoppingToken);
}
catch (Exception ex)
{
_logger.LogCritical(ex, "Failed to process...");
await Task.Delay(_options.ExceptionInterval, stoppingToken);
}
}
}
}

View File

@ -0,0 +1,8 @@
namespace Nocr.TelegramListener.Core.BackgroundServices;
public abstract class RepeatableServiceOptions
{
public TimeSpan Interval { get; set; } = TimeSpan.FromSeconds(60);
public TimeSpan ExceptionInterval { get; set; } = TimeSpan.FromSeconds(300);
}

View File

@ -1 +1,8 @@
<Project Sdk="Microsoft.NET.Sdk" />
<Project Sdk="Microsoft.NET.Sdk">
<ItemGroup>
<PackageReference Include="Microsoft.Extensions.Hosting.Abstractions" />
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" />
<PackageReference Include="Microsoft.Extensions.Options" />
</ItemGroup>
</Project>

View File

@ -1,4 +1,5 @@
using Nocr.TelegramListener.AppServices;
using Nocr.TelegramListener.AppServices.UpdateListeners;
using Nocr.TelegramListener.Core.Dates;
namespace Nocr.TelegramListener.Host.Infrastructure;
@ -16,7 +17,7 @@ public class Startup
{
services.AddSingleton<ICurrentDateProvider, DefaultCurrentDateProvider>();
services.AddAppServices();
services.AddAppServices(Configuration);
}
public void Configure(IApplicationBuilder app)