using System; using System.Collections.Generic; using System.IO; using System.Linq; using System.Threading; using System.Threading.Tasks; using Td = TdLib; using static telegram.Util; using static telegram.CommandManager; // ReSharper disable SwitchStatementMissingSomeEnumCasesNoDefault namespace telegram { /* * TODO: * waaay more error messages instead of just doing nothing or crashing (search for "do something") * add option to disable terminal bell * make commands & keybinds more consistent (maybe configurable?) * for commands with query, if query starting with @ only match where username matches *exactly* * command /sg -> search globally, some way to add contacts? * command /sc -> search in chat list & list matching chats, archived, muted indicator * mute,unmute chats * photo & document download & show externally * publish AUR package * maybe cursor input nav (cmd+del, left/right, up for last inputs, etc) * refactor everything */ // ReSharper disable once InconsistentNaming public static class tgcli { public static volatile Td.TdClient client = new Td.TdClient(); public static string dbdir = ""; public static volatile bool authorized; public static volatile string connectionState = "Connecting"; public static long currentChatId = 0; public static volatile int currentChatUserId = 0; public static volatile bool currentUserRead; public static volatile Td.TdApi.Message lastMessage; public static volatile bool quitting; public static volatile string currentInputLine = ""; public static volatile List messageQueue = new List(); public static volatile List missedMessages = new List(); public static volatile string prefix = "[tgcli"; public static volatile object @lock = new object(); private static void Main() { dbdir = $"{Environment.GetFolderPath(Environment.SpecialFolder.UserProfile)}{Path.DirectorySeparatorChar}.tgcli"; if (!Directory.Exists(dbdir)) Directory.CreateDirectory(dbdir); client.Send(new Td.TdApi.SetLogStream { LogStream = new Td.TdApi.LogStream.LogStreamFile { Path = Path.Combine(dbdir, "tdlib.log"), MaxFileSize = 10000000 } }); client.Send(new Td.TdApi.SetLogVerbosityLevel { NewVerbosityLevel = 2 }); Console.Clear(); ClearCurrentConsoleLine(); client.UpdateReceived += HandleUpdate; OnAuthUpdate(new Td.TdApi.Update.UpdateAuthorizationState() { AuthorizationState = new Td.TdApi.AuthorizationState.AuthorizationStateWaitTdlibParameters() }); while (!authorized) { Thread.Sleep(1); } ScreenUpdate(); while (!quitting) MainLoop(); ClearCurrentConsoleLine(); Console.WriteLine($"{Ansi.Yellow}[tgcli] Shutting down...{Ansi.ResetAll}"); } private static void MainLoop() { var key = Console.ReadKey(true); OnKeyPressed(key); } private static void HandleUpdate(object sender, Td.TdApi.Update e) { switch (e) { case Td.TdApi.Update.UpdateAuthorizationState state: OnAuthUpdate(state); break; case Td.TdApi.Update.UpdateNewMessage message: { Task.Run(() => AddMessageToQueue(message.Message)); break; } case Td.TdApi.Update.UpdateMessageContent message: Task.Run(() => AddMessageToQueue(message)); Task.Run(() => { var msg = GetMessage(message.ChatId, message.MessageId); if (msg.IsOutgoing && currentChatId == msg.ChatId) { lastMessage = msg; } }); break; case Td.TdApi.Update.UpdateMessageSendSucceeded sentMsg: lastMessage = sentMsg.Message; break; case Td.TdApi.Update.UpdateChatReadOutbox update: if (lastMessage != null && lastMessage.ChatId == update.ChatId) { currentUserRead = true; ScreenUpdate(); } break; case Td.TdApi.Update.UpdateConnectionState state: switch (state.State) { case Td.TdApi.ConnectionState.ConnectionStateConnecting _: connectionState = "Connecting"; if (!authorized) return; messageQueue.Add($"{Ansi.Yellow}[tgcli] Connecting to Telegram servers..."); ScreenUpdate(); break; case Td.TdApi.ConnectionState.ConnectionStateConnectingToProxy _: connectionState = "Connecting"; if (!authorized) return; messageQueue.Add($"{Ansi.Yellow}[tgcli] Connecting to Proxy..."); ScreenUpdate(); break; case Td.TdApi.ConnectionState.ConnectionStateReady _: if (!authorized) return; messageQueue.Add($"{Ansi.Yellow}[tgcli] Connected."); Task.Run(() => { HandleCommand("u"); connectionState = "Ready"; ScreenUpdate(); }); ScreenUpdate(); break; case Td.TdApi.ConnectionState.ConnectionStateUpdating _: connectionState = "Updating"; if (!authorized) return; messageQueue.Add($"{Ansi.Yellow}[tgcli] Updating message cache..."); ScreenUpdate(); break; case Td.TdApi.ConnectionState.ConnectionStateWaitingForNetwork _: connectionState = "Waiting for Network"; if (!authorized) return; messageQueue.Add($"{Ansi.Yellow}[tgcli] Lost connection. Waiting for network..."); ScreenUpdate(); break; } break; case Td.TdApi.Update.UpdateSecretChat update: var chat = update.SecretChat; switch (chat.State) { //TODO: send notifs here! case Td.TdApi.SecretChatState.SecretChatStateClosed _: lock (@lock) messageQueue.Add($"{Ansi.Red}[tgcli] Secret chat with {chat.Id} was closed."); ScreenUpdate(); break; case Td.TdApi.SecretChatState.SecretChatStatePending _: break; case Td.TdApi.SecretChatState.SecretChatStateReady _: lock (@lock) messageQueue.Add($"{Ansi.Green}[tgcli] Secret chat {chat.Id} connected."); ScreenUpdate(); break; } break; } } public static void ScreenUpdate() { lock (@lock) { ClearCurrentConsoleLine(); messageQueue.ForEach(p => Console.WriteLine(p + Ansi.ResetAll)); if (messageQueue.Count > 0) Console.Write("\a"); //ring terminal bell messageQueue.Clear(); var status = GetFormattedStatus(currentUserRead); var output = prefix; if (connectionState != "Ready") output += $" | {connectionState}"; if (currentChatUserId != 0) output += status; else output += "]"; output += " > "; output += TruncateMessageStart(currentInputLine, Console.LargestWindowWidth - output.Length); Console.Write(output); } } private static void OnKeyPressed(ConsoleKeyInfo key) { switch (key.Key) { case ConsoleKey.Enter when connectionState != "Ready": lock (@lock) messageQueue.Add($"{Ansi.Red}[tgcli] " + "Connection unstable. Check your network connection and try again."); ScreenUpdate(); break; case ConsoleKey.Enter when currentInputLine.StartsWith("/"): { var command = currentInputLine.Substring(1); currentInputLine = ""; HandleCommand(command); ScreenUpdate(); return; } case ConsoleKey.Enter when currentChatId == 0: { lock (@lock) messageQueue.Add($"{Ansi.Red}[tgcli] " + "No chat selected. Select a chat with /open "); ScreenUpdate(); return; } case ConsoleKey.Enter: SendMessage(currentInputLine, currentChatId); currentInputLine = ""; ScreenUpdate(); break; case ConsoleKey.Backspace when currentInputLine.Length >= 1: if (key.Modifiers.HasFlag(ConsoleModifiers.Alt)) { var lastIndex = currentInputLine.TrimEnd().LastIndexOf(" ", StringComparison.Ordinal); if (lastIndex < 0) lastIndex = 0; currentInputLine = currentInputLine.Substring(0, lastIndex); if (lastIndex != 0) currentInputLine += " "; ScreenUpdate(); return; } currentInputLine = currentInputLine.Substring(0, currentInputLine.Length - 1); ScreenUpdate(); break; default: { switch (key.Key) { case ConsoleKey.N when key.Modifiers.HasFlag(ConsoleModifiers.Control): currentInputLine += "⏎"; ScreenUpdate(); return; case ConsoleKey.D when key.Modifiers.HasFlag(ConsoleModifiers.Control): HandleCommand("q"); ScreenUpdate(); return; case ConsoleKey.Q when key.Modifiers.HasFlag(ConsoleModifiers.Control): HandleCommand("q"); ScreenUpdate(); return; case ConsoleKey.E when key.Modifiers.HasFlag(ConsoleModifiers.Control): HandleCommand("c"); ScreenUpdate(); return; case ConsoleKey.U when key.Modifiers.HasFlag(ConsoleModifiers.Control): HandleCommand("u"); ScreenUpdate(); return; case ConsoleKey.O when key.Modifiers.HasFlag(ConsoleModifiers.Control): if (string.IsNullOrWhiteSpace(currentInputLine)) currentInputLine = "/o "; ScreenUpdate(); return; case ConsoleKey.L when key.Modifiers.HasFlag(ConsoleModifiers.Control): HandleCommand("cl"); ScreenUpdate(); return; } if (!SpecialKeys.Contains(key.Key)) { currentInputLine += key.KeyChar; ScreenUpdate(); } break; } } } private static void OnAuthUpdate(Td.TdApi.Update.UpdateAuthorizationState state) { switch (state.AuthorizationState) { case Td.TdApi.AuthorizationState.AuthorizationStateWaitTdlibParameters _: client.Send(new Td.TdApi.SetTdlibParameters { Parameters = new Td.TdApi.TdlibParameters { ApiId = 600606, ApiHash = "c973f46778be4b35481ce45e93271e82", DatabaseDirectory = dbdir, UseMessageDatabase = true, SystemLanguageCode = "en_US", DeviceModel = Environment.MachineName, SystemVersion = ".NET Core CLR " + Environment.Version, ApplicationVersion = "0.1a", EnableStorageOptimizer = true, UseSecretChats = true } }); break; case Td.TdApi.AuthorizationState.AuthorizationStateWaitEncryptionKey _: client.Send(new Td.TdApi.CheckDatabaseEncryptionKey()); break; case Td.TdApi.AuthorizationState.AuthorizationStateWaitPhoneNumber _: { Console.Write("[tgcli] login> "); var phone = Console.ReadLine(); client.Send(new Td.TdApi.SetAuthenticationPhoneNumber { PhoneNumber = phone }); break; } case Td.TdApi.AuthorizationState.AuthorizationStateWaitCode _: { Console.Write("[tgcli] code> "); var code = Console.ReadLine(); client.Send(new Td.TdApi.CheckAuthenticationCode { Code = code }); break; } case Td.TdApi.AuthorizationState.AuthorizationStateWaitPassword _: { Console.Write("[tgcli] 2fa password> "); var pass = ReadConsolePassword(); client.Send(new Td.TdApi.CheckAuthenticationPassword { Password = pass }); break; } case Td.TdApi.AuthorizationState.AuthorizationStateReady _: Console.WriteLine("[tgcli] logged in."); authorized = true; connectionState = "Ready"; break; case Td.TdApi.AuthorizationState.AuthorizationStateClosed _: messageQueue.Add($"{Ansi.Yellow}[tgcli] Logged out successfully. All local data has been deleted."); ScreenUpdate(); Environment.Exit(0); break; case Td.TdApi.AuthorizationState.AuthorizationStateClosing _: messageQueue.Add($"{Ansi.Yellow}[tgcli] Logging out..."); ScreenUpdate(); break; case Td.TdApi.AuthorizationState.AuthorizationStateLoggingOut _: if (authorized) return; Console.WriteLine( "[tgcli] This session has been destroyed externally, to fix this delete ~/.tgcli"); Environment.Exit(1); break; default: Console.WriteLine($"unknown state: {state.AuthorizationState.DataType}"); Environment.Exit(1); break; } } public static string FormatMessage(Td.TdApi.Message msg) { string text; if (msg.Content is Td.TdApi.MessageContent.MessageText messageText) text = messageText.Text.Text; else if (msg.Content is Td.TdApi.MessageContent.MessagePhoto photo) text = !string.IsNullOrWhiteSpace(photo.Caption.Text) ? $"[unsupported {msg.Content.DataType}] {photo.Caption.Text}" : $"[unsupported {msg.Content.DataType}]"; else if (msg.Content is Td.TdApi.MessageContent.MessageDocument document) text = !string.IsNullOrWhiteSpace(document.Caption.Text) ? $"[unsupported {msg.Content.DataType}] {document.Caption.Text}" : $"[unsupported {msg.Content.DataType}]"; else text = $"[unsupported {msg.Content.DataType}]"; var sender = GetUser(msg.SenderUserId); var chat = GetChat(msg.ChatId); var username = TruncateString(GetFormattedUsername(sender), 10); var time = FormatTime(msg.Date); var isChannel = msg.IsChannelPost; var isPrivate = chat.Type is Td.TdApi.ChatType.ChatTypePrivate || chat.Type is Td.TdApi.ChatType.ChatTypeSecret; var isSecret = chat.Type is Td.TdApi.ChatType.ChatTypeSecret; var isReply = msg.ReplyToMessageId != 0; chat.Title = TruncateString(chat.Title, 20); Td.TdApi.Message replyMessage; var msgPrefix = $"{Ansi.Bold}{Ansi.Green}[{time}] {(isSecret ? $"{Ansi.Red}[sec] " : "")}{Ansi.Cyan}{chat.Title} " + $"{(isPrivate || isChannel ? "" : $"{Ansi.Yellow}{username} ")}"; var finalOutput = msgPrefix; var indent = new string(' ', GetActualStringWidth(msgPrefix)); var arrows = $"{(msg.IsOutgoing ? $"{Ansi.Blue}»»»" : $"{Ansi.Magenta}«««")} "; if (isReply) { try { replyMessage = GetMessage(chat.Id, msg.ReplyToMessageId); finalOutput = $"{FormatMessageReply(replyMessage, msgPrefix)}"; } catch { //ignored; reply to deleted msg } } var rest = $"{text}{(msg.EditDate == 0 ? "" : $"{Ansi.Yellow}*")}"; var lines = rest.Split("\n").ToList(); if (!isReply) { finalOutput += arrows + lines.First(); lines.RemoveAt(0); } lines.ForEach(l => finalOutput += "\n" + indent + arrows + l); return finalOutput; } public static string FormatMessageReply(Td.TdApi.Message msg, string origPrefix) { string text; if (msg.Content is Td.TdApi.MessageContent.MessageText messageText) text = messageText.Text.Text; else text = $"[unsupported {msg.Content.DataType}]"; var sender = GetUser(msg.SenderUserId); var chat = GetChat(msg.ChatId); var username = GetFormattedUsername(sender); var time = FormatTime(msg.Date); var isChannel = msg.IsChannelPost; var isPrivate = chat.Type is Td.TdApi.ChatType.ChatTypePrivate || chat.Type is Td.TdApi.ChatType.ChatTypeSecret; var isSecret = chat.Type is Td.TdApi.ChatType.ChatTypeSecret; chat.Title = TruncateString(chat.Title, 20); var finalOutput = ""; var replyPrefix = $"{origPrefix}{Ansi.Yellow}Re: {Ansi.Bold}{Ansi.Green}[{time}] " + $"{(isSecret ? $"{Ansi.Red}[sec] " : "")}{Ansi.Cyan}{chat.Title} " + $"{(isPrivate || isChannel ? "" : $"{Ansi.Yellow}{username} ")}"; var indent = new string(' ', GetActualStringWidth(replyPrefix)); var arrows = $"{(msg.IsOutgoing ? $"{Ansi.Blue}»»»" : $"{Ansi.Magenta}«««")} "; var rest = $"{text}{(msg.EditDate == 0 ? "" : $"{Ansi.Yellow}*")}"; finalOutput += replyPrefix; var lines = rest.Split("\n").ToList(); finalOutput += arrows + lines.First(); lines.RemoveAt(0); lines.ForEach(l => finalOutput += "\n" + indent + arrows + l); return finalOutput; } private static string FormatMessage(Td.TdApi.Update.UpdateMessageContent msg) { string text; if (msg.NewContent is Td.TdApi.MessageContent.MessageText messageText) text = messageText.Text.Text; else text = $"[unsupported {msg.NewContent.DataType}]"; var message = GetMessage(msg.ChatId, msg.MessageId); var sender = GetUser(message.SenderUserId); var chat = GetChat(msg.ChatId); var username = GetFormattedUsername(sender); var time = FormatTime(message.EditDate); var isChannel = message.IsChannelPost; var isPrivate = chat.Type is Td.TdApi.ChatType.ChatTypePrivate; return $"{Ansi.Bold}{Ansi.Green}[{time}] {Ansi.Cyan}{chat.Title} " + $"{(isPrivate || isChannel ? "" : $"{Ansi.Yellow}{username} ")}" + $"{(message.IsOutgoing ? $"{Ansi.Blue}»»»" : $"{Ansi.Magenta}«««")} " + $"{text}" + $"{Ansi.Yellow}*"; } public static void AddMessageToQueue(Td.TdApi.Message msg) { //handle muted if (GetChat(msg.ChatId).NotificationSettings.MuteFor > 0 && currentChatId != msg.ChatId) return; //we aren't interested in backlog if (connectionState != "Ready") return; var formattedMessage = FormatMessage(msg); if (currentChatId != 0 && msg.ChatId != currentChatId) lock (@lock) missedMessages.Add(formattedMessage); else lock (@lock) messageQueue.Add(formattedMessage); if (msg.ChatId == currentChatId) MarkRead(msg.ChatId, msg.Id); ScreenUpdate(); } public static void AddMessageToQueue(Td.TdApi.Update.UpdateMessageContent msg) { //handle muted if (GetChat(msg.ChatId).NotificationSettings.MuteFor > 0 && currentChatId != msg.ChatId || GetMessage(msg.ChatId, msg.MessageId).EditDate == 0) return; var formattedMessage = FormatMessage(msg); if (currentChatId != 0 && msg.ChatId != currentChatId) lock (@lock) missedMessages.Add(formattedMessage); else lock (@lock) messageQueue.Add(formattedMessage); ScreenUpdate(); } } }