using System; using System.Collections.Generic; using System.IO; using System.Linq; using System.Threading; using System.Threading.Tasks; using Td = TdLib; using static TdLib.TdApi; using static tgcli.Util; using static tgcli.CommandManager; // ReSharper disable SwitchStatementMissingSomeEnumCasesNoDefault namespace tgcli; /* * TODO: * fix newlines with input nav... * unreads are unreliable in secret chats! * mute,unmute chats * photo & document download & show externally * refactor everything * re-evaluate ClearCurrentConsoleLine function * When TDLib 1.6 is released: implement contacts */ // ReSharper disable once InconsistentNaming public static class tgcli { public static volatile Td.TdClient client = new(); public static string dbdir = ""; public static volatile bool authorized; public static volatile string connectionState = "Connecting"; public static long currentChatId = 0; public static long currentChatUserId = 0; public static volatile bool currentUserRead; public static volatile Message lastMessage; public static volatile bool quitting; public static volatile string currentInputLine = ""; public static volatile int currentInputPos; public static volatile List messageQueue = new(); public static volatile List missedMessages = new(); public static volatile string prefix = "[tgcli"; public static volatile bool silent; public static volatile object @lock = new(); private static void Main(string[] args) { if (args.Length == 1 && args[0] == "-s") silent = true; dbdir = $"{Environment.GetFolderPath(Environment.SpecialFolder.UserProfile)}{Path.DirectorySeparatorChar}.tgcli"; if (!Directory.Exists(dbdir)) Directory.CreateDirectory(dbdir); client.Send(new SetLogStream { LogStream = new LogStream.LogStreamFile { Path = Path.Combine(dbdir, "tdlib.log"), MaxFileSize = 10000000 } }); client.Send(new SetLogVerbosityLevel { NewVerbosityLevel = 2 }); Console.Clear(); ClearCurrentConsoleLine(); client.UpdateReceived += HandleUpdate; OnAuthUpdate(new Update.UpdateAuthorizationState { AuthorizationState = new 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, Update e) { switch (e) { case Update.UpdateAuthorizationState state: OnAuthUpdate(state); break; case Update.UpdateNewMessage message: { Task.Run(() => AddMessageToQueue(message.Message)); break; } case 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 Update.UpdateMessageUnreadReactions message: Task.Run(() => AddMessageToQueue(message)); break; case Update.UpdateMessageSendSucceeded sentMsg: lastMessage = sentMsg.Message; break; case Update.UpdateChatReadOutbox update: if (lastMessage != null && lastMessage.ChatId == update.ChatId) { currentUserRead = true; ScreenUpdate(); } break; case Update.UpdateConnectionState state: switch (state.State) { case ConnectionState.ConnectionStateConnecting _: connectionState = "Connecting"; if (!authorized) return; messageQueue.Add($"{Ansi.Yellow}[tgcli] Connecting to Telegram servers..."); ScreenUpdate(); break; case ConnectionState.ConnectionStateConnectingToProxy _: connectionState = "Connecting"; if (!authorized) return; messageQueue.Add($"{Ansi.Yellow}[tgcli] Connecting to Proxy..."); ScreenUpdate(); break; case ConnectionState.ConnectionStateReady _: if (!authorized) return; messageQueue.Add($"{Ansi.Yellow}[tgcli] Connected."); Task.Run(() => { HandleCommand("u"); connectionState = "Ready"; ScreenUpdate(); }); ScreenUpdate(); break; case ConnectionState.ConnectionStateUpdating _: connectionState = "Updating"; if (!authorized) return; messageQueue.Add($"{Ansi.Yellow}[tgcli] Updating message cache..."); ScreenUpdate(); break; case ConnectionState.ConnectionStateWaitingForNetwork _: connectionState = "Waiting for Network"; if (!authorized) return; messageQueue.Add($"{Ansi.Yellow}[tgcli] Lost connection. Waiting for network..."); ScreenUpdate(); break; } break; case Update.UpdateSecretChat update: var chat = update.SecretChat; switch (chat.State) { case SecretChatState.SecretChatStateClosed _: lock (@lock) messageQueue.Add($"{Ansi.Red}[tgcli] Secret chat with {chat.Id} was closed."); ScreenUpdate(); break; case SecretChatState.SecretChatStatePending _: break; case SecretChatState.SecretChatStateReady _: lock (@lock) messageQueue.Add($"{Ansi.Green}[tgcli] Secret chat {chat.Id} connected."); ScreenUpdate(); break; } break; } } public static void ScreenUpdate() { lock (@lock) { var status = GetFormattedStatus(currentUserRead); var output = prefix; if (connectionState != "Ready") output += $" | {connectionState}"; if (currentChatUserId != 0) output += status; else output += "]"; output += " > "; var prefixlen = GetActualStringWidth(output); var inputLine = GetPagedMessageInputLine(currentInputLine, currentInputPos, Console.LargestWindowWidth - prefixlen); output += inputLine.messageBuffer; ClearCurrentConsoleLine(); messageQueue.ForEach(p => Console.WriteLine(p + Ansi.ResetAll)); if (messageQueue.Count > 0 && !silent) Console.Write("\a"); //ring terminal bell messageQueue.Clear(); Console.Write(output); Console.Write($"\u001b[{inputLine.relCursorPos + prefixlen + 1}G"); } } 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[1..]; SetInputLine(""); 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); SetInputLine(""); ScreenUpdate(); break; case ConsoleKey.Backspace when currentInputLine.Length >= 1 && currentInputPos >= 1: if (key.Modifiers.HasFlag(ConsoleModifiers.Alt)) { RemoveFromInputLine(true); ScreenUpdate(); return; } RemoveFromInputLine(); //if (currentInputLine.EndsWith("⏎")) // currentInputLine = currentInputLine.Remove(currentInputLine.Length - 1); ScreenUpdate(); break; case ConsoleKey.Delete when currentInputLine.Length >= 1 && currentInputPos < currentInputLine.Length: if (key.Modifiers.HasFlag(ConsoleModifiers.Alt)) { ScreenUpdate(); return; } RemoveFromInputLineForward(); //if (currentInputLine.EndsWith("⏎")) // currentInputLine = currentInputLine.Remove(currentInputLine.Length - 1); ScreenUpdate(); break; case ConsoleKey.B when key.Modifiers.HasFlag(ConsoleModifiers.Alt): case ConsoleKey.LeftArrow when key.Modifiers.HasFlag(ConsoleModifiers.Alt): if (currentInputPos == 0) break; var part1 = currentInputLine[..currentInputPos]; var lastIndex = part1.TrimEnd().LastIndexOf(" ", StringComparison.Ordinal); if (lastIndex < 0) lastIndex = 0; currentInputPos = lastIndex; ScreenUpdate(); break; case ConsoleKey.F when key.Modifiers.HasFlag(ConsoleModifiers.Alt): case ConsoleKey.RightArrow when key.Modifiers.HasFlag(ConsoleModifiers.Alt): if (currentInputPos >= currentInputLine.Length) break; var index = currentInputLine.IndexOf(" ", currentInputPos + 1, StringComparison.Ordinal); currentInputPos = index + 1; if (index < 0) currentInputPos = currentInputLine.Length; ScreenUpdate(); break; case ConsoleKey.LeftArrow: if (currentInputPos > 0) currentInputPos--; ScreenUpdate(); break; case ConsoleKey.RightArrow: if (currentInputPos < currentInputLine.Length) currentInputPos++; ScreenUpdate(); break; case ConsoleKey.UpArrow: break; case ConsoleKey.DownArrow: break; default: { switch (key.Key) { //case ConsoleKey.N when key.Modifiers.HasFlag(ConsoleModifiers.Control): // InsertToInputLine("⏎ "); // ScreenUpdate(); // return; case ConsoleKey.Q when key.Modifiers.HasFlag(ConsoleModifiers.Control): HandleCommand("q"); ScreenUpdate(); return; case ConsoleKey.O when key.Modifiers.HasFlag(ConsoleModifiers.Control): SetInputLine(currentInputLine switch { "/o " => "/os ", "/os " => "/o ", "" => "/o ", _ => currentInputLine }); ScreenUpdate(); return; // standard terminal commands case ConsoleKey.L when key.Modifiers.HasFlag(ConsoleModifiers.Control): HandleCommand("cl"); ScreenUpdate(); return; case ConsoleKey.D when key.Modifiers.HasFlag(ConsoleModifiers.Control): HandleCommand(currentChatId == 0 ? "q" : "c"); ScreenUpdate(); return; // input navigation case ConsoleKey.U when key.Modifiers.HasFlag(ConsoleModifiers.Control): SetInputLine(""); ScreenUpdate(); return; case ConsoleKey.A when key.Modifiers.HasFlag(ConsoleModifiers.Control): currentInputPos = 0; ScreenUpdate(); return; case ConsoleKey.E when key.Modifiers.HasFlag(ConsoleModifiers.Control): currentInputPos = currentInputLine.Length; ScreenUpdate(); return; } if (!SpecialKeys.Contains(key.Key)) { InsertToInputLine(key.KeyChar.ToString()); ScreenUpdate(); } break; } } } private static void OnAuthUpdate(Update.UpdateAuthorizationState state) { switch (state.AuthorizationState) { case AuthorizationState.AuthorizationStateWaitTdlibParameters _: client.Send(new SetTdlibParameters { ApiId = 600606, ApiHash = "c973f46778be4b35481ce45e93271e82", DatabaseDirectory = dbdir, UseMessageDatabase = true, SystemLanguageCode = "en_US", DeviceModel = Environment.MachineName, SystemVersion = ".NET Core CLR " + Environment.Version, ApplicationVersion = "0.3a", EnableStorageOptimizer = true, UseSecretChats = true }); break; // case AuthorizationState.AuthorizationStateWaitEncryptionKey _: // client.Send(new Td.TdApi.CheckDatabaseEncryptionKey()); // break; case AuthorizationState.AuthorizationStateWaitPhoneNumber _: { Console.Write("[tgcli] login> "); var phone = Console.ReadLine(); client.Send(new SetAuthenticationPhoneNumber { PhoneNumber = phone }); break; } case AuthorizationState.AuthorizationStateWaitCode _: { Console.Write("[tgcli] code> "); var code = Console.ReadLine(); client.Send(new CheckAuthenticationCode { Code = code }); break; } case AuthorizationState.AuthorizationStateWaitPassword _: { Console.Write("[tgcli] 2fa password> "); var pass = ReadConsolePassword(); client.Send(new CheckAuthenticationPassword { Password = pass }); break; } case AuthorizationState.AuthorizationStateReady _: Console.WriteLine("[tgcli] logged in."); authorized = true; connectionState = "Ready"; break; case AuthorizationState.AuthorizationStateClosed _: messageQueue.Add($"{Ansi.Yellow}[tgcli] Logged out successfully. All local data has been deleted."); ScreenUpdate(); Environment.Exit(0); break; case AuthorizationState.AuthorizationStateClosing _: messageQueue.Add($"{Ansi.Yellow}[tgcli] Logging out..."); ScreenUpdate(); break; case 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(Message msg) { string text; if (msg.Content is MessageContent.MessageText messageText) text = messageText.Text.Text; else if (msg.Content is 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 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 chat = GetChat(msg.ChatId); var username = TruncateString(GetFormattedUsername(msg.SenderId), 10); var time = FormatTime(msg.Date); var isChannel = msg.IsChannelPost; var isPrivate = chat.Type is ChatType.ChatTypePrivate || chat.Type is ChatType.ChatTypeSecret; var isSecret = chat.Type is ChatType.ChatTypeSecret; var isReply = msg.ReplyToMessageId != 0; chat.Title = TruncateString(chat.Title, 20); 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}*")}"; if (msg.InteractionInfo != null && msg.InteractionInfo.Reactions.Any(p => p.Type is ReactionType.ReactionTypeEmoji)) { rest = $"{rest} {Ansi.Cyan}<--"; foreach (var reaction in msg.InteractionInfo.Reactions) if (reaction.Type is ReactionType.ReactionTypeEmoji emoji) rest += $" {reaction.TotalCount} {emoji.Emoji}"; } 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(Message msg, string origPrefix) { string text; if (msg.Content is MessageContent.MessageText messageText) text = messageText.Text.Text; else text = $"[unsupported {msg.Content.DataType}]"; var chat = GetChat(msg.ChatId); var username = TruncateString(GetFormattedUsername(msg.SenderId), 10); var time = FormatTime(msg.Date); var isChannel = msg.IsChannelPost; var isPrivate = chat.Type is ChatType.ChatTypePrivate or ChatType.ChatTypeSecret; var isSecret = chat.Type is 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(Update.UpdateMessageContent msg) { string text; if (msg.NewContent is MessageContent.MessageText messageText) text = messageText.Text.Text; else text = $"[unsupported {msg.NewContent.DataType}]"; var message = GetMessage(msg.ChatId, msg.MessageId); var chat = GetChat(msg.ChatId); var username = TruncateString(GetFormattedUsername(message.SenderId), 10); var time = FormatTime(message.EditDate); var isChannel = message.IsChannelPost; var isPrivate = chat.Type is ChatType.ChatTypePrivate; chat.Title = TruncateString(chat.Title, 20); return $"{Ansi.Bold}{Ansi.Green}[{time}] {Ansi.Cyan}{chat.Title} " + $"{(isPrivate || isChannel ? "" : $"{Ansi.Yellow}{username} ")}" + $"{(message.IsOutgoing ? $"{Ansi.Blue}»»»" : $"{Ansi.Magenta}«««")} " + $"{text}" + $"{Ansi.Yellow}*"; } private static string FormatMessage(Update.UpdateMessageUnreadReactions msg) { string text; var message = GetMessage(msg.ChatId, msg.MessageId); if (message.Content is MessageContent.MessageText messageText) text = messageText.Text.Text; else text = $"[unsupported {message.Content.DataType}]"; var chat = GetChat(msg.ChatId); var username = TruncateString(GetFormattedUsername(message.SenderId), 10); var time = FormatTime(message.Date); var isChannel = message.IsChannelPost; var isPrivate = chat.Type is ChatType.ChatTypePrivate; chat.Title = TruncateString(chat.Title, 20); text = $"{text}{Ansi.Yellow} <-- "; foreach (var reaction in msg.UnreadReactions) if (reaction.Type is ReactionType.ReactionTypeEmoji emoji) text += $"{emoji.Emoji} ({GetFormattedUsername(reaction.SenderId)})"; return $"{Ansi.Bold}{Ansi.Green}[{time}] {Ansi.Cyan}{chat.Title} " + $"{(isPrivate || isChannel ? "" : $"{Ansi.Yellow}{username} ")}" + $"{(message.IsOutgoing ? $"{Ansi.Blue}»»»" : $"{Ansi.Magenta}«««")} " + $"{(message.EditDate == 0 ? "" : $"{Ansi.Yellow}*")}" + $"{text}"; } public static void AddMessageToQueue(Message msg) { //handle muted if (IsMuted(GetChat(msg.ChatId)) && 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(Update.UpdateMessageContent msg) { //handle muted if (IsMuted(GetChat(msg.ChatId)) && 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(); } public static void AddMessageToQueue(Update.UpdateMessageUnreadReactions msg) { //handle muted if (IsMuted(GetChat(msg.ChatId)) && currentChatId != msg.ChatId) return; if (!msg.UnreadReactions.Any(p => p.Type is ReactionType.ReactionTypeEmoji)) return; var formattedMessage = FormatMessage(msg); if (currentChatId != 0 && msg.ChatId != currentChatId) lock (@lock) missedMessages.Add(formattedMessage); else lock (@lock) messageQueue.Add(formattedMessage); ScreenUpdate(); } }