Решение по 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.
1083 lines
44 KiB
C#
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
|