| 1 | using System; |
| 2 | using System.Collections.Generic; |
| 3 | using System.Linq; |
| 4 | using System.Text; |
| 5 | using System.Threading.Tasks; |
| 6 | |
| 7 | using TL; |
| 8 | |
| 9 | using WTelegram; |
| 10 | |
| 11 | namespace TelegramNotifier |
| 12 | { |
| 13 | public class TelegramManager |
| 14 | { |
| 15 | private const string USER_PRIVACY_RESTRICTED_MESSAGE = "USER_PRIVACY_RESTRICTED"; |
| 16 | private const string USER_NOT_MUTUAL_CONTACT = "USER_NOT_MUTUAL_CONTACT"; |
| 17 | private const long SUPERGROUP_CHAT_ID_THRESHOLD = -1000000000000L; |
| 18 | |
| 19 | readonly Client _telegramClient; |
| 20 | private Dictionary<long, InputPeer>? _peerCache; |
| 21 | private Dictionary<long, InputChannel>? _channelCache; |
| 22 | |
| 23 | public TelegramManager(Client client) |
| 24 | { |
| 25 | _telegramClient = client; |
| 26 | } |
| 27 | |
| 28 | /// <summary> |
| 29 | /// Resolves InputPeer for the given chat_id. Basic groups use InputPeerChat; |
| 30 | /// supergroups (forum-capable) use InputPeerChannel from dialogs. |
| 31 | /// </summary> |
| 32 | private async Task<InputPeer> GetInputPeer(long chatId) |
| 33 | { |
| 34 | // Basic group: small positive id (e.g. 840666093). Supergroups from list-chats: 1e9..1e12 |
| 35 | if (chatId > 0 && chatId < 1000000000L) |
| 36 | return new InputPeerChat(chatId); |
| 37 | |
| 38 | if (_peerCache != null && _peerCache.TryGetValue(chatId, out var cached)) |
| 39 | return cached; |
| 40 | |
| 41 | var dialogs = await _telegramClient.Messages_GetAllDialogs(); |
| 42 | // Supergroup: from list-chats we use positive 1000000000000 - ch.id; standard is negative -1000000000000 - ch.id |
| 43 | long channelId = chatId > 0 ? 1000000000000L - chatId : -SUPERGROUP_CHAT_ID_THRESHOLD - chatId; |
| 44 | if (dialogs is Messages_DialogsSlice slice) |
| 45 | { |
| 46 | foreach (var (id, chat) in slice.chats) |
| 47 | { |
| 48 | if (chat is Channel ch && ch.id == channelId) |
| 49 | { |
| 50 | var peer = new InputPeerChannel(channelId, ch.access_hash); |
| 51 | _peerCache ??= new Dictionary<long, InputPeer>(); |
| 52 | _peerCache[chatId] = peer; |
| 53 | return peer; |
| 54 | } |
| 55 | } |
| 56 | } |
| 57 | throw new InvalidOperationException($"Channel for chat_id {chatId} not found in dialogs. Is the account in the group?"); |
| 58 | } |
| 59 | |
| 60 | /// <summary> |
| 61 | /// Resolves InputChannel for a supergroup (required for forum topic API). Throws for basic groups. |
| 62 | /// </summary> |
| 63 | private async Task<InputChannel> GetInputChannel(long chatId) |
| 64 | { |
| 65 | if (chatId > 0 && chatId < 1000000000L) |
| 66 | throw new InvalidOperationException("create-topic works only for supergroups (forum). Use a forum chat_id from list-chats."); |
| 67 | if (_channelCache != null && _channelCache.TryGetValue(chatId, out var cached)) |
| 68 | return cached; |
| 69 | var dialogs = await _telegramClient.Messages_GetAllDialogs(); |
| 70 | long channelId = chatId > 0 ? 1000000000000L - chatId : -SUPERGROUP_CHAT_ID_THRESHOLD - chatId; |
| 71 | if (dialogs is Messages_DialogsSlice slice) |
| 72 | { |
| 73 | foreach (var (_, chat) in slice.chats) |
| 74 | { |
| 75 | if (chat is Channel ch && ch.id == channelId) |
| 76 | { |
| 77 | var inputChannel = new InputChannel(channelId, ch.access_hash); |
| 78 | _channelCache ??= new Dictionary<long, InputChannel>(); |
| 79 | _channelCache[chatId] = inputChannel; |
| 80 | return inputChannel; |
| 81 | } |
| 82 | } |
| 83 | } |
| 84 | throw new InvalidOperationException($"Channel for chat_id {chatId} not found in dialogs. Is the account in the group?"); |
| 85 | } |
| 86 | |
| 87 | /// <summary> |
| 88 | /// List all chats (groups and supergroups/channels) the user is in. Returns (chat_id, title) for each. |
| 89 | /// </summary> |
| 90 | public async Task<IReadOnlyList<(long ChatId, string Title)>> ListChatsAsync() |
| 91 | { |
| 92 | var dialogs = await _telegramClient.Messages_GetAllDialogs(); |
| 93 | var list = new List<(long, string)>(); |
| 94 | IEnumerable<KeyValuePair<long, ChatBase>>? chats = null; |
| 95 | if (dialogs is Messages_DialogsSlice slice) |
| 96 | chats = slice.chats; |
| 97 | else if (dialogs is Messages_Dialogs full) |
| 98 | chats = full.chats; |
| 99 | if (chats == null) |
| 100 | return list; |
| 101 | foreach (var (_, chat) in chats) |
| 102 | { |
| 103 | if (chat is Channel ch) |
| 104 | { |
| 105 | // This codebase uses positive chat_id for supergroups: 1000000000000 - ch.id |
| 106 | long chatId = -SUPERGROUP_CHAT_ID_THRESHOLD - ch.id; |
| 107 | list.Add((chatId, ch.Title ?? "")); |
| 108 | } |
| 109 | else if (chat is Chat c) |
| 110 | { |
| 111 | list.Add((c.id, c.Title ?? "")); |
| 112 | } |
| 113 | } |
| 114 | return list; |
| 115 | } |
| 116 | |
| 117 | /// <summary> |
| 118 | /// Create a forum topic in a supergroup (requires forum enabled and manage_topics rights). |
| 119 | /// Returns the created topic's message id (top_msg_id). |
| 120 | /// </summary> |
| 121 | public async Task<int> CreateForumTopic(long chatId, string title) |
| 122 | { |
| 123 | var channel = await GetInputChannel(chatId); |
| 124 | var randomId = (long)(DateTime.UtcNow.Ticks % long.MaxValue); |
| 125 | var updates = await _telegramClient.Channels_CreateForumTopic(channel, title, randomId); |
| 126 | if (updates is Updates updatesObj && updatesObj.updates?.Length > 0) |
| 127 | { |
| 128 | foreach (var u in updatesObj.updates) |
| 129 | { |
| 130 | if (u is UpdateMessageID mid) |
| 131 | return mid.id; |
| 132 | } |
| 133 | } |
| 134 | return 0; |
| 135 | } |
| 136 | |
| 137 | public async Task DoLogin(string loginInfo) // (add this method to your code) |
| 138 | { |
| 139 | while (_telegramClient.User == null) |
| 140 | { |
| 141 | switch (await _telegramClient.Login(loginInfo)) // returns which config is needed to continue login |
| 142 | { |
| 143 | case "verification_code": Console.Write("Code: "); loginInfo = Console.ReadLine(); break; |
| 144 | case "name": loginInfo = "John Doe"; break; // if sign-up is required (first/last_name) |
| 145 | case "password": loginInfo = Console.ReadLine(); break;// if user has enabled 2FA |
| 146 | default: loginInfo = null; break; |
| 147 | } |
| 148 | } |
| 149 | |
| 150 | Console.WriteLine($"We are logged-in as {_telegramClient.User} (id {_telegramClient.User.id})"); |
| 151 | } |
| 152 | public async Task CreateChat(string groupName, string invitedUserName) |
| 153 | { |
| 154 | var result = await _telegramClient.Contacts_ResolveUsername(invitedUserName); |
| 155 | var data = await _telegramClient.Messages_CreateChat(new InputUser[] { result.User }, groupName); |
| 156 | Console.WriteLine($"Chat created with id={data.Chats.Keys.ElementAt(0)}"); |
| 157 | } |
| 158 | public async Task DeleteChat(long chat_id) |
| 159 | { |
| 160 | await _telegramClient.DeleteChat(new InputPeerChat(chat_id)); |
| 161 | Console.WriteLine($"chat with id = {chat_id} is deleted"); |
| 162 | } |
| 163 | public async Task AddUserToChat(long chat_id, string invitedUserName) |
| 164 | { |
| 165 | var result = await _telegramClient.Contacts_ResolveUsername(invitedUserName); |
| 166 | |
| 167 | try |
| 168 | { |
| 169 | await _telegramClient.AddChatUser(new InputPeerChat(chat_id), result.User); |
| 170 | } |
| 171 | catch (RpcException e) |
| 172 | { |
| 173 | if (e.Message== USER_PRIVACY_RESTRICTED_MESSAGE) |
| 174 | { |
| 175 | Console.WriteLine($"Addition for user @{invitedUserName} to chat {chat_id} is restricted by privacy settings of invited user"); |
| 176 | } |
| 177 | if (e.Message==USER_NOT_MUTUAL_CONTACT) |
| 178 | { |
| 179 | Console.WriteLine($"User @{invitedUserName} already left chat {chat_id} and I can't add it"); |
| 180 | } |
| 181 | } |
| 182 | } |
| 183 | public async Task DeleteUserFromChat(long chat_id, string deletedUserName) |
| 184 | { |
| 185 | var result = await _telegramClient.Contacts_ResolveUsername(deletedUserName); |
| 186 | await _telegramClient.DeleteChatUser(new InputPeerChat(chat_id), result.User); |
| 187 | } |
| 188 | public async Task SendMessageToChat(long chat_id, string message, int? topicId = null) |
| 189 | { |
| 190 | var peer = await GetInputPeer(chat_id); |
| 191 | string html = TelegramFormatHelper.MdToTelegramHtml(message); |
| 192 | MessageEntity[]? entities = _telegramClient.HtmlToEntities(ref html); |
| 193 | if (topicId is int topId) |
| 194 | await _telegramClient.SendMessageAsync(peer, html, reply_to_msg_id: topId, entities: entities); |
| 195 | else |
| 196 | await _telegramClient.SendMessageAsync(peer, html, entities: entities); |
| 197 | } |
| 198 | |
| 199 | /// <summary> |
| 200 | /// Edit an existing message in a chat. Only messages sent by this account can be edited. |
| 201 | /// </summary> |
| 202 | public async Task EditMessage(long chatId, int messageId, string newText) |
| 203 | { |
| 204 | var peer = await GetInputPeer(chatId); |
| 205 | string html = TelegramFormatHelper.MdToTelegramHtml(newText); |
| 206 | MessageEntity[]? entities = _telegramClient.HtmlToEntities(ref html); |
| 207 | const int flagMessage = 1 << 11; |
| 208 | const int flagEntities = 1 << 15; // optional entities |
| 209 | var req = new TL.Methods.Messages_EditMessage |
| 210 | { |
| 211 | flags = (TL.Methods.Messages_EditMessage.Flags)(flagMessage | flagEntities), |
| 212 | peer = peer, |
| 213 | id = messageId, |
| 214 | message = html, |
| 215 | entities = entities |
| 216 | }; |
| 217 | await _telegramClient.Invoke(req); |
| 218 | } |
| 219 | |
| 220 | /// <summary> |
| 221 | /// Delete a message in a chat. Only messages sent by this account (or with admin rights in channels). |
| 222 | /// </summary> |
| 223 | public async Task DeleteMessage(long chatId, int messageId) |
| 224 | { |
| 225 | var peer = await GetInputPeer(chatId); |
| 226 | await _telegramClient.DeleteMessages(peer, [messageId]); |
| 227 | } |
| 228 | |
| 229 | public async Task SendMessageToUser(string targetUserName, string message) |
| 230 | { |
| 231 | var result = await _telegramClient.Contacts_ResolveUsername(targetUserName); |
| 232 | string html = TelegramFormatHelper.MdToTelegramHtml(message); |
| 233 | MessageEntity[]? entities = _telegramClient.HtmlToEntities(ref html); |
| 234 | try |
| 235 | { |
| 236 | await _telegramClient.SendMessageAsync(result.User, html, entities: entities); |
| 237 | } |
| 238 | catch (RpcException e) |
| 239 | { |
| 240 | Console.WriteLine($"I have a trouble {e.Message} when send message to @{targetUserName}"); |
| 241 | } |
| 242 | } |
| 243 | /// <summary> |
| 244 | /// Re-sends each of our messages in the chat through MD→HTML so they get formatting (bold, code, etc.). |
| 245 | /// Only messages sent by the current account are edited. Uses the last page of history. |
| 246 | /// </summary> |
| 247 | public async Task<int> UpdateOldMessagesInChat(long chatId, int? topicId = null) |
| 248 | { |
| 249 | long? ourUserId = _telegramClient.User?.id; |
| 250 | if (ourUserId == null) |
| 251 | return 0; |
| 252 | var messages = await GetMessagesFromChat(chatId, topicId); |
| 253 | var ours = messages.Where(m => m.from_id is PeerUser pu && pu.user_id == ourUserId && !string.IsNullOrEmpty(m.message)).ToList(); |
| 254 | int edited = 0; |
| 255 | foreach (var msg in ours) |
| 256 | { |
| 257 | try |
| 258 | { |
| 259 | await EditMessage(chatId, msg.id, msg.message ?? "").ConfigureAwait(false); |
| 260 | edited++; |
| 261 | } |
| 262 | catch (RpcException) |
| 263 | { |
| 264 | // skip failed (e.g. message too old, no change, etc.) |
| 265 | } |
| 266 | } |
| 267 | return edited; |
| 268 | } |
| 269 | |
| 270 | public async Task<IEnumerable<Message>> GetMessagesFromChat(long chat_id, int? topicId = null) |
| 271 | { |
| 272 | var peer = await GetInputPeer(chat_id); |
| 273 | var history = await _telegramClient.Messages_GetHistory(peer); |
| 274 | var messages = history.Messages.OfType<Message>(); |
| 275 | if (topicId is int topId) |
| 276 | messages = messages.Where(m => m.reply_to?.reply_to_top_id == topId || m.reply_to?.reply_to_msg_id == topId || (m.reply_to == null && topId == 0)); |
| 277 | return messages; |
| 278 | } |
| 279 | |
| 280 | /// <summary> |
| 281 | /// Get messages as DTOs for JSON output (MCP / scripting). |
| 282 | /// </summary> |
| 283 | public async Task<IReadOnlyList<ChatMessageDto>> GetMessagesFromChatAsDtos(long chatId, int? topicId = null) |
| 284 | { |
| 285 | var messages = await GetMessagesFromChat(chatId, topicId); |
| 286 | var dtos = new List<ChatMessageDto>(); |
| 287 | foreach (var msg in messages) |
| 288 | { |
| 289 | long? fromId = msg.from_id is PeerUser pu ? pu.user_id : null; |
| 290 | int? replyTo = msg.reply_to?.reply_to_msg_id; |
| 291 | int? topId = msg.reply_to?.reply_to_top_id ?? replyTo; |
| 292 | var dateUnix = (int)((DateTimeOffset)DateTime.SpecifyKind(msg.date, DateTimeKind.Utc)).ToUnixTimeSeconds(); |
| 293 | dtos.Add(new ChatMessageDto |
| 294 | { |
| 295 | Id = msg.id, |
| 296 | Date = dateUnix, |
| 297 | FromUserId = fromId, |
| 298 | Text = msg.message ?? "", |
| 299 | ReplyToMsgId = replyTo, |
| 300 | TopicId = topId |
| 301 | }); |
| 302 | } |
| 303 | return dtos; |
| 304 | } |
| 305 | public async Task DownloadMediaFromMessage(Message message, string mediaFolder) |
| 306 | { |
| 307 | var d = (MessageMediaDocument) message.media; |
| 308 | |
| 309 | string directory = Path.Combine(Directory.GetCurrentDirectory(), mediaFolder); |
| 310 | if (!Directory.Exists(directory)) |
| 311 | { |
| 312 | Directory.CreateDirectory(directory); |
| 313 | } |
| 314 | |
| 315 | using var outStr = new FileStream(Path.Combine(directory, (d.document as Document).Filename), FileMode.OpenOrCreate); |
| 316 | _ = await _telegramClient.DownloadFileAsync(d.document as Document, outStr); |
| 317 | } |
| 318 | |
| 319 | } |
| 320 | } |
| 321 | |