Files
Deal/src/telegram-service/Deal.Telegram/Telegram/WTelegramSessionClient.cs
T
Rustam Khalimov 713d554dc2 Нормализовать переводы строк в LF
Решение по TD-STYLE-ANALYZERS: LF — инструменты проекта (Python/Node) пишут LF,
CRLF-.sh не работают на Linux CI (sh scripts/ci.sh), большинство файлов уже были
LF. Добавлен .gitattributes (* text=auto eol=lf, бинарные исключения),
.editorconfig переведён на lf, 1029 файлов конвертированы, git add --renormalize.
Из индекса убраны закравшиеся archive/**/__pycache__/*.pyc.
2026-09-11 19:01:42 +03:00

1083 lines
44 KiB
C#

using System.Globalization;
using Deal.Telegram.Caching;
using Deal.Telegram.Sessions;
using Grpc.Core;
using TL;
using WTelegram;
using RpcException = TL.RpcException;
namespace Deal.Telegram.Telegram;
#pragma warning disable CS0618 // Auth_SendCode/Auth_SignIn используются осознанно: ручной веб-вход 1:1 с прототипом
/// <summary>
/// Реальная реализация <see cref="ISessionClient"/> поверх WTelegramClient.
/// </summary>
public sealed class WTelegramSessionClient : ISessionClient
{
private readonly Client _client;
private readonly int _apiId;
private readonly string _apiHash;
private byte[]? _latestSessionBytes;
private bool _connected;
private string? _phone;
private string? _phoneCodeHash;
private bool _phoneAlreadyAuthorized;
// Ёмкость LRU-кэша access_hash сущностей (ограничивает память на длинной истории диалогов).
private const int AccessHashCacheCapacity = 2000;
// Ёмкость LRU-кэша сущностей чатов/каналов (последние использованные имена/forum).
private const int ChatEntityCacheCapacity = 1000;
// Ёмкость LRU-кэша сущностей пользователей (последние использованные имена).
private const int UserEntityCacheCapacity = 2000;
// LRU-кэш access_hash сущностей (ключ — подписанный id диалога; заполняется из ответов).
private readonly LruCache<string, long> _entityAccessHashes = new(AccessHashCacheCapacity);
private readonly LruCache<long, ChatBase> _chatsById = new(ChatEntityCacheCapacity);
private readonly LruCache<long, User> _usersById = new(UserEntityCacheCapacity);
// Защита кэшей сущностей (обновляются из потоков reactor/вызовов).
private readonly object _entityCacheGate = new();
// Штатный UpdateManager библиотеки: единственный нормализованный колбэк на каждое обновление
// (порядок/pts, восстановление пропусков через getDifference, дедупликация устаревших).
// Новые сообщения всех типов приходят как TL.UpdateNewMessage (каналы/супергруппы —
// подкласс UpdateNewChannelMessage; короткие UpdateShort* — синтез в UpdateList).
private readonly UpdateManager _updateManager;
/// <summary>
/// Создаёт клиент WTelegramClient с сессией в памяти.
/// </summary>
/// <param name="apiId">api_id приложения Telegram.</param>
/// <param name="apiHash">api_hash приложения Telegram (же ключ внутреннего шифрования сессии).</param>
/// <param name="storedSession">Сохранённые байты сессии (null — новая сессия).</param>
public WTelegramSessionClient(
int apiId,
string apiHash,
byte[]? storedSession)
{
_apiId = apiId;
_apiHash = apiHash;
_client = new Client(ConfigProvider, storedSession ?? [], OnSessionSaved);
_updateManager = new UpdateManager(_client, OnSingleUpdateAsync);
}
/// <inheritdoc />
public bool IsAuthorized => _client.UserId != 0;
/// <inheritdoc />
public bool IsConnected => _connected && !_client.Disconnected;
/// <inheritdoc />
public int ApiId => _apiId;
/// <inheritdoc />
public string ApiHash => _apiHash;
/// <inheritdoc />
public byte[]? SessionBytes => Volatile.Read(ref _latestSessionBytes);
/// <inheritdoc />
public async Task ConnectAsync(CancellationToken cancellationToken)
{
if (IsConnected)
{
return;
}
await _client.ConnectAsync().WaitAsync(cancellationToken).ConfigureAwait(false);
_connected = true;
}
/// <inheritdoc />
public async Task RequestCodeAsync(string phone, CancellationToken cancellationToken)
{
_phone = phone;
_phoneCodeHash = null;
_phoneAlreadyAuthorized = false;
Auth_SentCodeBase sentCode = await SendCodeOnceAsync(phone, cancellationToken).ConfigureAwait(false);
switch (sentCode)
{
case Auth_SentCode { phone_code_hash: not null } code:
_phoneCodeHash = code.phone_code_hash;
return;
case Auth_SentCodeSuccess:
// Номер уже авторизован в этой сессии: код не нужен (SubmitCodeAsync вернёт готово).
_phoneAlreadyAuthorized = true;
return;
default:
throw new SessionException(
StatusCode.InvalidArgument,
"Telegram не запросил код для этого номера — повторите вход по номеру телефона");
}
}
/// <inheritdoc />
public async Task<string?> SubmitCodeAsync(string code, CancellationToken cancellationToken)
{
if (_phoneAlreadyAuthorized)
{
return null;
}
if (_phone is null || _phoneCodeHash is null)
{
throw new SessionException(StatusCode.FailedPrecondition, SessionErrorMessages.CodeNotRequested);
}
Auth_AuthorizationBase authorization;
try
{
authorization = await _client.Auth_SignIn(_phone, _phoneCodeHash, code).WaitAsync(cancellationToken).ConfigureAwait(false);
}
catch (RpcException exception) when (exception.Code == 401 && exception.Message == "SESSION_PASSWORD_NEEDED")
{
// Включён 2FA — следующий шаг: облачный пароль (submit_password).
return "password";
}
catch (RpcException exception) when (exception.Code == 400 && exception.Message == "PHONE_CODE_INVALID")
{
throw new SessionException(StatusCode.InvalidArgument, SessionErrorMessages.WrongCode, exception);
}
catch (RpcException exception) when (exception.Code == 400 && exception.Message == "PHONE_CODE_EXPIRED")
{
throw new SessionException(StatusCode.InvalidArgument, SessionErrorMessages.CodeExpired, exception);
}
catch (RpcException exception)
{
throw MapRpcException(exception);
}
CompleteAuthorization(authorization);
return null;
}
/// <inheritdoc />
public async Task SubmitPasswordAsync(string password, CancellationToken cancellationToken)
{
try
{
Account_Password accountPassword = await _client.Account_GetPassword().WaitAsync(cancellationToken).ConfigureAwait(false);
InputCheckPasswordSRP checkPassword = await Client.InputCheckPassword(accountPassword, password).WaitAsync(cancellationToken).ConfigureAwait(false);
Auth_AuthorizationBase authorization = await _client.Auth_CheckPassword(checkPassword).WaitAsync(cancellationToken).ConfigureAwait(false);
CompleteAuthorization(authorization);
}
catch (RpcException exception) when (exception.Code == 400 && exception.Message == "PASSWORD_HASH_INVALID")
{
throw new SessionException(StatusCode.InvalidArgument, SessionErrorMessages.WrongPassword, exception);
}
catch (RpcException exception)
{
throw MapRpcException(exception);
}
}
/// <inheritdoc />
public async Task StartQrAsync(Action<string> onQrUrl, CancellationToken cancellationToken)
{
try
{
// logoutFirst=false: сессия без пользователя (проверено TenantSession до запуска QR);
// завершившись, библиотека сама сохраняет авторизованную сессию в память (LoginAlreadyDone).
await _client
.LoginWithQRCode(url => onQrUrl(url), except_ids: null, logoutFirst: false, ct: cancellationToken)
.WaitAsync(cancellationToken)
.ConfigureAwait(false);
}
catch (RpcException exception)
{
throw MapRpcException(exception);
}
}
/// <inheritdoc />
public async Task LogOutAsync(CancellationToken cancellationToken)
{
await _client.Auth_LogOut().WaitAsync(cancellationToken).ConfigureAwait(false);
}
/// <inheritdoc />
public async Task<string> GetAccountAsync(CancellationToken cancellationToken)
{
UserBase[] users = await _client.Users_GetUsers(InputUser.Self).WaitAsync(cancellationToken).ConfigureAwait(false);
string username = users.OfType<User>().FirstOrDefault()?.username ?? string.Empty;
return "@" + (string.IsNullOrEmpty(username) ? "user" : username);
}
/// <inheritdoc />
public ValueTask DisposeAsync()
{
_connected = false;
return _client.DisposeAsync();
}
/// <inheritdoc />
public event Func<TelegramMessage, Task>? MessageReceived;
/// <inheritdoc />
public async Task<IReadOnlyList<TelegramDialog>> GetDialogsAsync(int limit, CancellationToken cancellationToken)
{
Messages_DialogsBase result = await RunTlCallAsync(() => _client.Messages_GetDialogs(limit: limit), cancellationToken).ConfigureAwait(false);
(DialogBase[] dialogs, Dictionary<long, ChatBase> chats, Dictionary<long, User> users) = UnpackDialogs(result);
CacheEntities(chats.Values, users.Values);
var items = new List<TelegramDialog>(dialogs.Length);
foreach (DialogBase dialog in dialogs)
{
TelegramDialog? item = TlMessageMapper.ToDialog(dialog, chats, users);
if (item is not null)
{
items.Add(item);
}
}
return items;
}
/// <inheritdoc />
public async Task<IReadOnlyList<TelegramMessage>> GetMessagesAsync(
string dialogId,
int limit,
CancellationToken cancellationToken)
{
InputPeer peer = await ResolvePeerAsync(dialogId, cancellationToken).ConfigureAwait(false);
Messages_MessagesBase result = await RunTlCallAsync(() => _client.Messages_GetHistory(peer, limit: limit), cancellationToken).ConfigureAwait(false);
(MessageBase[] messages, Dictionary<long, ChatBase> chats, Dictionary<long, User> users) = UnpackMessages(result);
CacheEntities(chats.Values, users.Values);
var items = new List<TelegramMessage>(messages.Length);
foreach (MessageBase message in messages)
{
TelegramMessage? item = TlMessageMapper.ToMessage(message, chats, users);
if (item is not null)
{
items.Add(item);
}
}
return items;
}
/// <inheritdoc />
public async Task<TelegramMessage?> GetMessageAsync(
string dialogId,
long msgId,
CancellationToken cancellationToken)
{
// Диалог обязан быть доступен аккаунту (иначе INVALID_ARGUMENT, как в истории).
await ResolvePeerAsync(dialogId, cancellationToken).ConfigureAwait(false);
// messages.getMessages по голому id (InputMessageID) — без привязки к peer.
InputMessage[] ids = [new InputMessageID { id = (int)msgId }];
Messages_MessagesBase result = await RunTlCallAsync(
() => _client.Messages_GetMessages(ids),
cancellationToken).ConfigureAwait(false);
(MessageBase[] messages, Dictionary<long, ChatBase> chats, Dictionary<long, User> users) = UnpackMessages(result);
CacheEntities(chats.Values, users.Values);
foreach (MessageBase message in messages)
{
TelegramMessage? mapped = TlMessageMapper.ToMessage(message, chats, users);
if (mapped is not null && mapped.Id == msgId && mapped.DialogId == dialogId)
{
return mapped;
}
}
// Медиа без текста или удалённое сообщение — не найдено (found=false).
return null;
}
/// <inheritdoc />
public async Task MarkReadAsync(string dialogId, CancellationToken cancellationToken)
{
InputPeer peer = await ResolvePeerAsync(dialogId, cancellationToken).ConfigureAwait(false);
await RunTlCallAsync(() => _client.ReadHistory(peer), cancellationToken).ConfigureAwait(false);
}
/// <inheritdoc />
public async Task<IReadOnlyList<TelegramDialog>> SearchAsync(
string query,
int limit,
CancellationToken cancellationToken)
{
// contacts.SearchRequest(q, limit): результат — сущности chats/users (поиск каналов/групп/людей).
Contacts_Found found = await RunTlCallAsync(() => _client.Contacts_Search(query, limit), cancellationToken).ConfigureAwait(false);
CacheEntities(found.chats.Values, found.users.Values);
var items = new List<TelegramDialog>(found.chats.Count + found.users.Count);
foreach (ChatBase chat in found.chats.Values)
{
items.Add(TlMessageMapper.ToFoundChat(chat));
}
foreach (User user in found.users.Values)
{
items.Add(TlMessageMapper.ToFoundUser(user));
}
return items;
}
/// <inheritdoc />
public async Task<TelegramSourceInfo> GetInfoAsync(string dialogId, CancellationToken cancellationToken)
{
TelegramSourceInfo unknown = DefaultSourceInfo(dialogId);
if (!TryParseSignedId(dialogId, out bool isChannel, out bool isChat, out bool isUser, out long rawId))
{
return unknown;
}
try
{
if (isChannel)
{
return await GetChannelInfoAsync(dialogId, rawId, unknown, cancellationToken).ConfigureAwait(false);
}
if (isChat)
{
return await GetChatInfoAsync(dialogId, rawId, unknown, cancellationToken).ConfigureAwait(false);
}
if (isUser)
{
return GetUserInfo(dialogId, rawId, unknown);
}
}
catch (SessionException)
{
return unknown;
}
return unknown;
}
/// <inheritdoc />
public async Task<DiscoveryReadResult> ReadForEvalAsync(
string dialogId,
int limit,
CancellationToken cancellationToken)
{
if (limit <= 0)
{
return DiscoveryReadResult.Empty;
}
if (!TryParseSignedId(dialogId, out bool isChannel, out bool isChat, out bool isUser, out long rawId))
{
return DiscoveryReadResult.NoHistory();
}
InputPeer peer;
try
{
peer = await ResolvePeerAsync(dialogId, cancellationToken).ConfigureAwait(false);
}
catch (SessionException)
{
return DiscoveryReadResult.NoHistory();
}
if (isChannel && IsForumChannel(rawId))
{
IReadOnlyList<DiscoveryMessage> forumMessages = await ReadForumTopicsAsync(peer, limit, cancellationToken).ConfigureAwait(false);
if (forumMessages.Count > 0)
{
return new DiscoveryReadResult(true, null, forumMessages);
}
}
// Обычная лента (каналы/группы; для форума — General-тема; форум без читаемых тем → тоже лента).
try
{
Messages_MessagesBase result = await RunTlCallAsync(() => _client.Messages_GetHistory(peer, limit: limit), cancellationToken).ConfigureAwait(false);
(MessageBase[] messages, Dictionary<long, ChatBase> chats, Dictionary<long, User> users) = UnpackMessages(result);
CacheEntities(chats.Values, users.Values);
return new DiscoveryReadResult(true, null, MapEvalMessages(messages));
}
catch (SessionException)
{
return DiscoveryReadResult.NoHistory();
}
}
/// <inheritdoc />
public async Task JoinAsync(string username, CancellationToken cancellationToken)
{
Contacts_ResolvedPeer resolved = await RunTlCallAsync(() => _client.Contacts_ResolveUsername(username), cancellationToken).ConfigureAwait(false);
CacheEntities(resolved.chats.Values, resolved.users.Values);
Channel? channel = resolved.chats.Values.OfType<Channel>().FirstOrDefault();
if (channel is null)
{
throw new SessionException(StatusCode.InvalidArgument, SessionErrorMessages.JoinTargetNotChannel);
}
await RunTlCallAsync(() => _client.Channels_JoinChannel(new InputChannel(channel.id, channel.access_hash)), cancellationToken).ConfigureAwait(false);
}
/// <inheritdoc />
public async Task LeaveAsync(string dialogId, CancellationToken cancellationToken)
{
if (!TryParseSignedId(dialogId, out bool isChannel, out _, out _, out long rawId) || !isChannel)
{
throw new SessionException(StatusCode.InvalidArgument, SessionErrorMessages.InvalidDialogId);
}
InputChannel channel = await ResolveInputChannelAsync(dialogId, rawId, cancellationToken).ConfigureAwait(false);
await RunTlCallAsync(() => _client.Channels_LeaveChannel(channel), cancellationToken).ConfigureAwait(false);
}
private async Task<TelegramSourceInfo> GetChannelInfoAsync(
string dialogId,
long rawId,
TelegramSourceInfo unknown,
CancellationToken cancellationToken)
{
Channel? cached;
lock (_entityCacheGate)
{
cached = _chatsById.TryGetValue(rawId, out ChatBase? chat) ? chat as Channel : null;
}
if (cached is not null)
{
int? participants = await TryFetchChannelParticipantsAsync(dialogId, rawId, cancellationToken).ConfigureAwait(false);
return DescribeChannel(dialogId, cached, participants);
}
InputChannel input = await ResolveInputChannelAsync(dialogId, rawId, cancellationToken).ConfigureAwait(false);
Messages_ChatFull full = await RunTlCallAsync(() => _client.Channels_GetFullChannel(input), cancellationToken).ConfigureAwait(false);
CacheEntities(full.chats.Values, full.users.Values);
Channel? channel = full.chats.TryGetValue(rawId, out ChatBase? found) ? found as Channel : null;
if (channel is null)
{
return unknown;
}
int? fullParticipants = (full.full_chat as ChannelFull)?.participants_count is int count && count > 0 ? count : null;
return DescribeChannel(dialogId, channel, fullParticipants);
}
private async Task<int?> TryFetchChannelParticipantsAsync(
string dialogId,
long rawId,
CancellationToken cancellationToken)
{
try
{
InputChannel input = await ResolveInputChannelAsync(dialogId, rawId, cancellationToken).ConfigureAwait(false);
Messages_ChatFull full = await RunTlCallAsync(() => _client.Channels_GetFullChannel(input), cancellationToken).ConfigureAwait(false);
CacheEntities(full.chats.Values, full.users.Values);
return (full.full_chat as ChannelFull)?.participants_count is int count && count > 0 ? count : null;
}
catch (SessionException)
{
return null;
}
}
// Инфо о базовой группе: entity из кэша/полного чата, участники — GetFullChat (только при членстве).
// dialogId: Подписанный id.
// rawId: Raw id группы (положительный).
// unknown: Инфо по умолчанию.
// cancellationToken: Отмена операции.
private async Task<TelegramSourceInfo> GetChatInfoAsync(
string dialogId,
long rawId,
TelegramSourceInfo unknown,
CancellationToken cancellationToken)
{
Chat? cached;
lock (_entityCacheGate)
{
cached = _chatsById.TryGetValue(rawId, out ChatBase? chat) ? chat as Chat : null;
}
if (cached is not null)
{
int? participants = await TryFetchBasicParticipantsAsync(rawId, cancellationToken).ConfigureAwait(false);
return DescribeGroup(dialogId, cached, participants);
}
Messages_ChatFull full = await RunTlCallAsync(() => _client.Messages_GetFullChat(rawId), cancellationToken).ConfigureAwait(false);
CacheEntities(full.chats.Values, full.users.Values);
Chat? group = full.chats.TryGetValue(rawId, out ChatBase? found) ? found as Chat : null;
if (group is null)
{
return unknown;
}
int? fullParticipants = full.full_chat is ChatFull fullChat && fullChat.participants is ChatParticipants members
? members.participants.Length
: null;
return DescribeGroup(dialogId, group, fullParticipants);
}
private async Task<int?> TryFetchBasicParticipantsAsync(long rawId, CancellationToken cancellationToken)
{
try
{
Messages_ChatFull full = await RunTlCallAsync(() => _client.Messages_GetFullChat(rawId), cancellationToken).ConfigureAwait(false);
CacheEntities(full.chats.Values, full.users.Values);
return full.full_chat is ChatFull fullChat && fullChat.participants is ChatParticipants members
? members.participants.Length
: null;
}
catch (SessionException)
{
return null;
}
}
// Собирает инфо базовой группы из entity (имя/username/kind group, без форума).
// dialogId: Подписанный id.
// group: Entity группы.
// participants: Число участников (null — определить не удалось).
private static TelegramSourceInfo DescribeGroup(
string dialogId,
Chat group,
int? participants)
=> new(
dialogId,
DisplayName(dialogId, group),
group.MainUsername ?? string.Empty,
DialogKinds.Group,
participants,
isForum: false);
private TelegramSourceInfo GetUserInfo(
string dialogId,
long rawId,
TelegramSourceInfo unknown)
{
User? user;
lock (_entityCacheGate)
{
_usersById.TryGetValue(rawId, out user);
}
if (user is null)
{
return unknown;
}
string name = (user.first_name + " " + user.last_name).Trim();
string username = user.username ?? string.Empty;
if (name.Length == 0)
{
name = username.Length > 0 ? username : dialogId;
}
return new TelegramSourceInfo(dialogId, name, username, DialogKinds.Chat, participants: null, isForum: false);
}
private async Task<IReadOnlyList<DiscoveryMessage>> ReadForumTopicsAsync(
InputPeer peer,
int limit,
CancellationToken cancellationToken)
{
var outMessages = new List<DiscoveryMessage>();
try
{
Messages_ForumTopics forum = await RunTlCallAsync(
() => _client.Messages_GetForumTopics(peer, offset_date: default, offset_id: 0, offset_topic: 0, limit: ForumTopicsLimit),
cancellationToken).ConfigureAwait(false);
CacheEntities(forum.chats.Values, forum.users.Values);
ForumTopic[] topics = forum.topics.OfType<ForumTopic>().ToArray();
if (topics.Length == 0)
{
return outMessages;
}
int perTopic = Math.Min(Math.Max(3, (int)Math.Ceiling(limit / (double)topics.Length)), ForumMessagesPerTopicCap);
DateTimeOffset now = DateTimeOffset.UtcNow;
foreach (ForumTopic topic in topics)
{
try
{
Messages_MessagesBase result = await RunTlCallAsync(
() => _client.Messages_GetReplies(peer, topic.id, limit: perTopic),
cancellationToken).ConfigureAwait(false);
(MessageBase[] messages, Dictionary<long, ChatBase> chats, Dictionary<long, User> users) = UnpackMessages(result);
CacheEntities(chats.Values, users.Values);
foreach (MessageBase message in messages)
{
DiscoveryMessage? item = TlMessageMapper.ToEvalMessage(message, topic.id, topic.title, now);
if (item is not null)
{
outMessages.Add(item);
}
}
}
catch (SessionException)
{
}
}
}
catch (SessionException)
{
outMessages.Clear();
}
return outMessages;
}
// Маппит сообщения ленты/тем в нейтральные (только непустые тексты, темы пусты).
// messages: Сообщения выборки (MessageBase).
private static IReadOnlyList<DiscoveryMessage> MapEvalMessages(MessageBase[] messages)
{
var items = new List<DiscoveryMessage>(messages.Length);
DateTimeOffset now = DateTimeOffset.UtcNow;
foreach (MessageBase message in messages)
{
DiscoveryMessage? item = TlMessageMapper.ToEvalMessage(message, null, null, now);
if (item is not null)
{
items.Add(item);
}
}
return items;
}
// Инфо по умолчанию (сущность недоступна): name=id, username/kind пусты, без участников.
// dialogId: Подписанный id источника.
private static TelegramSourceInfo DefaultSourceInfo(string dialogId)
=> new(dialogId, dialogId, string.Empty, string.Empty, participants: null, isForum: false);
// Собирает инфо канала из entity полного чата (имя/username/kind/forum + участники).
// dialogId: Подписанный id.
// channel: Entity канала из ответа полного чата.
// participants: Число участников (full_chat) либо null.
private static TelegramSourceInfo DescribeChannel(
string dialogId,
Channel channel,
int? participants)
=> new(
dialogId,
DisplayName(dialogId, channel),
channel.MainUsername ?? string.Empty,
TlMessageMapper.KindOf(channel),
participants,
isForum: (channel.flags & Channel.Flags.forum) != 0);
private static string DisplayName(string dialogId, ChatBase chat)
{
string title = chat.Title ?? string.Empty;
return title.Length > 0 ? title : dialogId;
}
private bool IsForumChannel(long rawId)
{
lock (_entityCacheGate)
{
return _chatsById.TryGetValue(rawId, out ChatBase? chat)
&& chat is Channel channel
&& (channel.flags & Channel.Flags.forum) != 0;
}
}
private const int ForumTopicsLimit = 5;
private const int ForumMessagesPerTopicCap = 10;
private async Task OnSingleUpdateAsync(Update update)
{
try
{
// TlMessageMapper.NewMessageFrom: UpdateNewMessage/UpdateNewChannelMessage (каналы —
// подкласс) и синтезированные из коротких вариантов; прочие обновления игнорируются.
MessageBase? message = TlMessageMapper.NewMessageFrom(update);
if (message is null)
{
return;
}
// UpdateManager собрал сущности сообщения в коллектор (Users/Chats) до вызова колбэка —
// имя/username диалога и access_hash (для read-ack) доступны без сетевых запросов.
Dictionary<long, ChatBase> chats = _updateManager.Chats;
Dictionary<long, User> users = _updateManager.Users;
TelegramMessage? item = TlMessageMapper.ToMessage(message, chats, users);
if (item is not null)
{
await DispatchMessageAsync(item).ConfigureAwait(false);
}
}
catch (Exception exception) when (exception is not OperationCanceledException)
{
// Сбой обработки обновления не должен останавливать realtime; ошибки подписчиков
// обрабатывает TenantSession (логгер есть на уровне сессии).
}
}
// Последовательно вызывает подписчиков события нового сообщения.
// message: Входящее сообщение диалога.
private async Task DispatchMessageAsync(TelegramMessage message)
{
Func<TelegramMessage, Task>? handler = MessageReceived;
if (handler is null)
{
return;
}
foreach (Delegate subscriber in handler.GetInvocationList())
{
try
{
await ((Func<TelegramMessage, Task>)subscriber)(message).ConfigureAwait(false);
}
catch (Exception exception) when (exception is not OperationCanceledException)
{
// Исключение подписчика (например, ядро недоступно) логирует сам подписчик
// (TenantSession) и не влияет на остальных.
}
}
}
// Возвращает значения конфигурации WTelegramClient (api_id/api_hash; остальное — по умолчанию).
// what: Запрашиваемый библиотекой ключ конфигурации.
private string? ConfigProvider(string what)
=> what switch
{
"api_id" => _apiId.ToString(System.Globalization.CultureInfo.InvariantCulture),
"api_hash" => _apiHash,
_ => null,
};
// Колбэк сохранения сессии библиотекой: держим последние байты в памяти процесса.
// sessionBytes: Новые байты сессии (готовый самодостаточный блок).
private void OnSessionSaved(byte[] sessionBytes)
=> Volatile.Write(ref _latestSessionBytes, sessionBytes);
private async Task<Auth_SentCodeBase> SendCodeOnceAsync(string phone, CancellationToken cancellationToken)
{
try
{
return await _client.Auth_SendCode(phone, _apiId, _apiHash, new CodeSettings()).WaitAsync(cancellationToken).ConfigureAwait(false);
}
catch (RpcException exception) when (exception.Code == 500 && exception.Message == "AUTH_RESTART")
{
return await _client.Auth_SendCode(phone, _apiId, _apiHash, new CodeSettings()).WaitAsync(cancellationToken).ConfigureAwait(false);
}
catch (RpcException exception)
{
throw MapRpcException(exception);
}
}
// Завершает авторизацию по объекту Auth_AuthorizationBase: регистрирует пользователя в сессии
// (LoginAlreadyDone сохраняет сессию — колбэк OnSessionSaved обновит байты для файла).
// authorization: Ответ Auth_SignIn/Auth_CheckPassword.
private void CompleteAuthorization(Auth_AuthorizationBase authorization)
{
if (authorization is Auth_AuthorizationSignUpRequired)
{
throw new SessionException(StatusCode.InvalidArgument, SessionErrorMessages.SignUpRequired);
}
try
{
_client.LoginAlreadyDone(authorization);
}
catch (WTException exception)
{
throw new SessionException(StatusCode.Internal, exception.Message, exception);
}
}
private static SessionException MapRpcException(RpcException exception)
{
if (exception.Message.StartsWith("FLOOD_WAIT_", StringComparison.Ordinal))
{
return new SessionException(StatusCode.ResourceExhausted, "flood: " + exception.Message, exception);
}
if (exception.Code == 400)
{
return new SessionException(StatusCode.InvalidArgument, exception.Message, exception);
}
return new SessionException(StatusCode.Unavailable, SessionErrorMessages.TelegramUnavailable, exception);
}
// Исполняет TL-вызов с единым переводом ошибок (RpcException Telegram → SessionException;
// прочие сбои — UNAVAILABLE «Telegram недоступен…»).
// TResult: Тип результата TL-вызова.
// call: Фабрика задачи TL-вызова (методы библиотеки токенов не принимают).
// cancellationToken: Отмена операции.
private async Task<TResult> RunTlCallAsync<TResult>(Func<Task<TResult>> call, CancellationToken cancellationToken)
{
try
{
return await call().WaitAsync(cancellationToken).ConfigureAwait(false);
}
catch (OperationCanceledException)
{
throw;
}
catch (RpcException exception)
{
throw MapRpcException(exception);
}
catch (Exception exception)
{
throw new SessionException(StatusCode.Unavailable, SessionErrorMessages.TelegramUnavailable, exception);
}
}
// Разворачивает результат messages.getDialogs в диалоги и словари сущностей.
// result: Результат TL-вызова списка диалогов.
private static (DialogBase[] Dialogs, Dictionary<long, ChatBase> Chats, Dictionary<long, User> Users) UnpackDialogs(Messages_DialogsBase result)
=> result switch
{
// Slice наследует Messages_Dialogs — сначала конкретный тип (иначе ветка недостижима).
Messages_DialogsSlice slice => (slice.dialogs, slice.chats, slice.users),
Messages_Dialogs dialogs => (dialogs.dialogs, dialogs.chats, dialogs.users),
_ => throw new SessionException(StatusCode.Internal, "Telegram вернул неподдерживаемый список диалогов"),
};
// Разворачивает результат messages.getHistory в сообщения и словари сущностей.
// result: Результат TL-вызова истории.
private static (MessageBase[] Messages, Dictionary<long, ChatBase> Chats, Dictionary<long, User> Users) UnpackMessages(Messages_MessagesBase result)
=> result switch
{
Messages_Messages messages => (messages.messages, messages.chats, messages.users),
Messages_ChannelMessages channelMessages => (channelMessages.messages, channelMessages.chats, channelMessages.users),
// Прочие варианты (rare/новые слои) — сообщения отдаёт базовый доступор, сущностей нет:
// имена диалогов в таких сообщениях будут подменены id (кадр редкий, реальный поток не страдает).
_ => (result.Messages, new Dictionary<long, ChatBase>(), new Dictionary<long, User>()),
};
// Кэширует access_hash и сущности из ответа (для InputPeer по id и имён/forum discovery).
// chats: Каналы/группы ответа (по raw id).
// users: Пользователи ответа (по raw id).
private void CacheEntities(IEnumerable<ChatBase> chats, IEnumerable<UserBase> users)
{
lock (_entityCacheGate)
{
foreach (ChatBase chat in chats)
{
switch (chat)
{
case Channel channel:
_entityAccessHashes.Set("-100" + channel.id.ToString(CultureInfo.InvariantCulture), channel.access_hash);
_chatsById.Set(channel.id, channel);
break;
case Chat group:
_chatsById.Set(group.id, group);
break;
}
}
foreach (UserBase user in users)
{
if (user is User regularUser)
{
_entityAccessHashes.Set("+" + regularUser.id.ToString(CultureInfo.InvariantCulture), regularUser.access_hash);
_usersById.Set(regularUser.id, regularUser);
}
}
}
}
// Строит InputPeer диалога; при пустом кэше — доливка списком диалогов (первая страница).
// dialogId: Подписанный id диалога.
// cancellationToken: Отмена операции.
private async Task<InputPeer> ResolvePeerAsync(string dialogId, CancellationToken cancellationToken)
{
if (!TryParseSignedId(dialogId, out bool isChannel, out bool isChat, out bool isUser, out long rawId))
{
throw new SessionException(StatusCode.InvalidArgument, SessionErrorMessages.InvalidDialogId);
}
InputPeer? peer = BuildPeer(isChannel, isChat, isUser, rawId);
if (peer is not null)
{
return peer;
}
// Сущность может быть уже собрана коллектором UpdateManager (realtime-сообщение пришло до
// списка диалогов) — access_hash берётся оттуда без сетевых запросов.
if (TryImportPeerFromCollector(isChannel, isUser, rawId))
{
InputPeer? imported = BuildPeer(isChannel, isChat, isUser, rawId);
if (imported is not null)
{
return imported;
}
}
// Канал/пользователь без access_hash (рестарт/новый источник): доливаем сущности
// первой страницей списка диалогов — каталог ядра строится из неё же (лимит 500, как refresh).
await GetDialogsAsync(DialogResolvePageSize, cancellationToken).ConfigureAwait(false);
return BuildPeer(isChannel, isChat, isUser, rawId)
?? throw new SessionException(StatusCode.InvalidArgument, SessionErrorMessages.UnknownDialog);
}
// InputChannel по подписанному id канала (join/leave/полный чат); как ResolvePeerAsync.
// dialogId: Подписанный id канала («-100…»).
// rawId: Raw id канала.
// cancellationToken: Отмена операции.
private async Task<InputChannel> ResolveInputChannelAsync(
string dialogId,
long rawId,
CancellationToken cancellationToken)
{
lock (_entityCacheGate)
{
if (_entityAccessHashes.TryGetValue(dialogId, out long cached))
{
return new InputChannel(rawId, cached);
}
}
await GetDialogsAsync(DialogResolvePageSize, cancellationToken).ConfigureAwait(false);
lock (_entityCacheGate)
{
return _entityAccessHashes.TryGetValue(dialogId, out long accessHash)
? new InputChannel(rawId, accessHash)
: throw new SessionException(StatusCode.InvalidArgument, SessionErrorMessages.UnknownDialog);
}
}
// Импортирует access_hash сущности из коллектора UpdateManager в кэш (базовая группа хеша не
// требует — возвращает true сразу). False — сущности в коллекторе нет или хеш нулевой (min-entity).
// isChannel: Признак канала («-100…»).
// isUser: Признак личного чата («+…»).
// rawId: Raw id (канала/пользователя).
private bool TryImportPeerFromCollector(
bool isChannel,
bool isUser,
long rawId)
{
if (!isChannel && !isUser)
{
return true;
}
string signedId = (isChannel ? "-100" : "+") + rawId.ToString(CultureInfo.InvariantCulture);
lock (_entityCacheGate)
{
if (_entityAccessHashes.ContainsKey(signedId))
{
return true;
}
}
long accessHash = 0;
if (isChannel
&& _updateManager.Chats.TryGetValue(rawId, out ChatBase? chat)
&& chat is Channel channel)
{
accessHash = channel.access_hash;
}
else if (isUser && _updateManager.Users.TryGetValue(rawId, out User? user))
{
accessHash = user.access_hash;
}
if (accessHash == 0)
{
return false;
}
lock (_entityCacheGate)
{
_entityAccessHashes.Set(signedId, accessHash);
}
return true;
}
// Строит InputPeer по разобранному id и access_hash из кэша (null — сущность неизвестна).
// isChannel: Признак канала («-100…»).
// isChat: Признак базовой группы («-…»).
// isUser: Признак личного чата («+…»).
// rawId: Raw id (канала/группы/пользователя).
private InputPeer? BuildPeer(
bool isChannel,
bool isChat,
bool isUser,
long rawId)
{
if (isChat)
{
return new InputPeerChat(rawId);
}
string signedId = (isChannel ? "-100" : "+") + rawId.ToString(CultureInfo.InvariantCulture);
long accessHash;
lock (_entityCacheGate)
{
if (!_entityAccessHashes.TryGetValue(signedId, out accessHash))
{
return null;
}
}
return isChannel
? new InputPeerChannel(rawId, accessHash)
: new InputPeerUser(rawId, accessHash);
}
// Разбирает подписанный id канона контракта («-100…» канал, «-…» группа, «+…» личный).
// dialogId: Подписанный id.
// isChannel: True — канал.
// isChat: True — базовая группа.
// isUser: True — личный чат.
// rawId: Raw id без префикса.
private static bool TryParseSignedId(
string dialogId,
out bool isChannel,
out bool isChat,
out bool isUser,
out long rawId)
{
isChannel = false;
isChat = false;
isUser = false;
rawId = 0;
string digits;
if (dialogId.StartsWith("-100", StringComparison.Ordinal))
{
digits = dialogId[4..];
isChannel = true;
}
else if (dialogId.StartsWith('-'))
{
digits = dialogId[1..];
isChat = true;
}
else if (dialogId.StartsWith('+'))
{
digits = dialogId[1..];
isUser = true;
}
else
{
return false;
}
return digits.Length > 0 && long.TryParse(digits, NumberStyles.None, CultureInfo.InvariantCulture, out rawId);
}
// Размер страницы списка диалогов для доливки кэша сущностей (как refresh_dialogs limit).
private const int DialogResolvePageSize = 500;
}
#pragma warning restore CS0618