Compare commits

...

34 commits
0.2a ... dev

Author SHA1 Message Date
Laura Hausmann dd4d4fbd95
Make word jumps more consistent 2023-01-27 20:06:14 +01:00
Laura Hausmann f25c054b48
Add simple argument parser and help text 2023-01-23 21:14:00 +01:00
Laura Hausmann 9614e6400f
Use proper database path depending on OSPlatform 2023-01-23 20:59:01 +01:00
Laura Hausmann 60ad19e914
Lock input line to bottom of terminal 2023-01-23 20:52:16 +01:00
Laura Hausmann f3372f39e4
Refactor 2023-01-23 18:20:07 +01:00
Laura Hausmann 65d8141d18
Simplify algorithm for message input paging 2023-01-23 17:54:18 +01:00
Laura Hausmann d660339433
Refactor 2023-01-23 17:52:01 +01:00
Laura Hausmann 028b838bee
Add unit tests 2023-01-23 17:51:10 +01:00
Laura Hausmann 39617a58a8
Refactor 2023-01-23 17:48:28 +01:00
Laura Hausmann 65bf58731f
Remove while loop in GetViewIntoMessageBuffer, closes #12 2023-01-23 12:02:42 +01:00
Laura Hausmann 82678907ba
Proper fix for #1 2023-01-22 02:08:24 +01:00
Laura Hausmann 2133c73491
Improve inputline speed 2023-01-18 21:09:21 +01:00
Laura Hausmann bd4fd3ec36
Fix channel default mute behavior (#6) 2023-01-18 04:02:17 +01:00
Laura Hausmann 0cd33fa0a2
Properly handle mutes (closes #6) 2023-01-18 03:52:51 +01:00
Laura Hausmann 57a2f8720b
Fix parsing messages without reactions 2023-01-18 03:48:48 +01:00
Laura Hausmann 9bab0102e8
Receive emoji reactions (#11) 2023-01-18 03:32:49 +01:00
Laura Hausmann 65b83a6cd9
Temp fix for #1 2023-01-18 02:57:08 +01:00
Laura Hausmann e00a4c793d
Implement proper word navigation escape sequences (closes #2) 2023-01-18 02:44:30 +01:00
Laura Hausmann 9dd2f06160
Implement common emacs bindings (closes #3) 2023-01-18 02:34:36 +01:00
Laura Hausmann 6382f10dae
Add /me command 2023-01-15 03:08:14 +01:00
Laura Hausmann 35ec0d3f31
Fix oem symbol keys 2023-01-14 06:13:19 +01:00
Laura Hausmann b60b2ea5db
Bump version 2023-01-14 06:08:41 +01:00
Laura Hausmann 119f550ef0
Update submodule 2023-01-14 06:04:36 +01:00
Laura Hausmann b5719199d0
Update README 2023-01-14 06:04:11 +01:00
Laura Hausmann cb4d046adf
Fix build on osx-arm64 2023-01-14 05:52:54 +01:00
Laura Hausmann d321e3f780
Fix users with no username 2023-01-14 04:42:40 +01:00
Laura Hausmann 8a729a9d03
Fix users with no username 2023-01-14 04:41:45 +01:00
Laura Hausmann c211d5dfcd
Refactor, update to latest tdlib 2023-01-14 04:32:36 +01:00
Laura Hausmann 4e9588d080 fix CI 2020-02-20 22:03:06 +01:00
Laura Hausmann 14eb3e1494 update libtdjson.dylib 2020-01-26 19:26:12 +01:00
Laura Hausmann 91fd81e643
disable newlines, fix alt+delete 2019-12-22 00:25:14 +01:00
Laura Hausmann 893534935f
make modifiers consistent 2019-12-22 00:12:56 +01:00
Laura Hausmann 6ae94d33b0
add basic input navigation, newlines are broken for now 2019-12-21 23:45:23 +01:00
Laura Hausmann a3bbbd09a8
implement reply matching 2019-12-21 22:20:41 +01:00
21 changed files with 2155 additions and 1743 deletions

View file

@ -10,7 +10,7 @@ job_build_dotnet:
- pacman-key --init
- pacman-key --recv-keys 3FABB87C7C9F7E5FF2B6CB7B11A7E7E4DB9351DE
- pacman-key --lsign-key 3FABB87C7C9F7E5FF2B6CB7B11A7E7E4DB9351DE
- bash -c "echo -e '"'[zotancc]\nServer = https://arch.zotan.cc/zotancc/os/$arch'"' >> /etc/pacman.conf"
- bash -c "echo -e '"'[zotancc]\nServer = https://arch.prod.zotan.network/zotancc/os/$arch'"' >> /etc/pacman.conf"
- pacman -Syu --needed dotnet-sdk-bin --noconfirm
- curl -Lo warp-packer https://github.com/dgiagio/warp/releases/download/v0.3.0/linux-x64.warp-packer && chmod +x warp-packer
- dotnet publish -c Release -r linux-x64

3
.gitmodules vendored
View file

@ -1,3 +1,6 @@
[submodule "tdsharp"]
path = tdsharp
url = https://github.com/egramtel/tdsharp
[submodule "tdlib.native.osx.arm64"]
path = tdlib.native.osx.arm64
url = https://git.ztn.sh/zotan/tdlib.native.osx.arm64

View file

@ -1 +1,4 @@
A better cross-platform console client for telegram, inspired by telegram-cli.
A better cross-platform console client for telegram, inspired by telegram-cli.
Important build notes:
Make sure you run `git submodule update --init` before build to get the osx-arm64 native binaries.

6
nuget.config Normal file
View file

@ -0,0 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<configuration>
<packageSources>
<add key="TdlibNativeLocalSource" value="tdlib.native.osx.arm64" />
</packageSources>
</configuration>

@ -0,0 +1 @@
Subproject commit 99a7f6441b7f981bd915041f1eaf4adf8891a4ef

@ -1 +0,0 @@
Subproject commit 45f128104467b249bddccaa91ea8a6c9fac05d66

View file

@ -1,718 +0,0 @@
using System;
using System.Collections.Generic;
using System.Linq;
using TdLib;
using static telegram.tgcli;
using static telegram.Util;
namespace telegram {
public abstract class Command {
public string trigger;
public string shortcut;
public string description;
public string syntax;
public int paramCount;
public abstract void Handler(List<string> inputParams);
protected Command(string trigger, string shortcut, string description, string syntax, int paramCount) {
this.trigger = trigger;
this.shortcut = shortcut;
this.description = description;
this.paramCount = paramCount;
this.syntax = syntax;
}
}
public static class CommandManager {
public static readonly List<Command> Commands = new List<Command> {
new ClearCommand(),
new CloseCommand(),
new EditCommand(),
new ReplyCommand(),
new HistoryCommand(),
new OpenCommand(),
new UnreadsCommand(),
new CloseUnreadCommand(),
new ListChatsCommand(),
new NewChatCommand(),
new ListSecretChatsCommand(),
new OpenSecretCommand(),
new OpenSecretDirectCommand(),
new NewSecretChatCommand(),
new CloseSecretChatCommand(),
new SearchUserCommand(),
//new AddContactCommand(),
new QuitCommand(),
new HelpCommand(),
new LogoutCommand(),
};
public static void HandleCommand(string input) {
var split = input.Split(" ").ToList();
var trigger = split.First();
var command = Commands.Find(p => p.trigger == trigger || p.shortcut == trigger);
if (command == null) {
lock (@lock)
messageQueue.Add($"{Ansi.Red}[tgcli] Invalid command. Check /help for all available commands.");
return;
}
split.RemoveAt(0);
if (command.paramCount == -1) {
command.Handler(split);
}
else if (split.Count == command.paramCount) {
command.Handler(split);
}
else {
lock (@lock)
messageQueue.Add($"{Ansi.Red}[tgcli] Invalid command syntax. Check /help for more information.");
}
}
}
public class OpenCommand : Command {
public OpenCommand() : base("o", "^O", "opens a chat. queries chat list", "<query>", -1) { }
public override void Handler(List<string> inputParams) {
if (inputParams.Count == 0) {
lock (@lock)
messageQueue.Add($"{Ansi.Red}[tgcli] Invalid command syntax. Check /help for more information.");
return;
}
var query = inputParams.Aggregate((current, param) => current + " " + param).Trim();
var chatId = SearchChatId(query);
if (chatId == 0)
return;
currentChatId = 0;
currentChatUserId = 0;
currentUserRead = false;
var chat = GetChat(chatId);
if (chat.Type is TdApi.ChatType.ChatTypePrivate privChat) {
currentChatUserId = privChat.UserId;
}
currentChatId = chat.Id;
chat.Title = TruncateString(chat.Title, 20);
prefix = $"[{chat.Title}";
lock (@lock) {
messageQueue.Add($"{Ansi.Yellow}[tgcli] Opening chat: {chat.Title}");
messageQueue.Add($"{Ansi.Yellow}" + $"[tgcli] You have {chat.UnreadCount} unread message" + $"{(chat.UnreadCount == 1 ? "." : "s.")}");
if (chat.UnreadCount >= 5) {
var capped = chat.UnreadCount > 50;
GetHistory(chatId, capped ? 50 : chat.UnreadCount).ForEach(AddMessageToQueue);
if (capped)
messageQueue.Add($"{Ansi.Yellow}[tgcli] " + $"Showing 50 of {chat.UnreadCount} unread messages.");
}
else if (chat.UnreadCount > 0) {
var unreads = GetHistory(chatId, chat.UnreadCount);
var rest = GetHistory(chatId, 5 - unreads.Count, unreads.First().Id);
rest.ForEach(AddMessageToQueue);
messageQueue.Add($"{Ansi.Yellow}[tgcli] ---UNREAD---");
unreads.ForEach(AddMessageToQueue);
}
else {
GetHistory(chatId).ForEach(AddMessageToQueue);
}
}
var history = GetHistory(currentChatId, 50);
if (history.Count != 0)
MarkRead(chat.Id, history.First().Id);
var last = history.LastOrDefault(p => p.IsOutgoing);
if (last == null) {
currentUserRead = true;
return;
}
lastMessage = last;
currentUserRead = IsMessageRead(last.ChatId, last.Id);
}
}
public class NewChatCommand : Command {
public NewChatCommand() : base("n", "", "starts a new chat.", "<username>", 1) { }
public override void Handler(List<string> inputParams) {
var chat = GetChatByUsernameGlobal(inputParams[0]);
if (chat == null) {
lock (@lock)
messageQueue.Add($"{Ansi.Red}[tgcli] User not found. Try /s <query> to find valid usernames.");
return;
}
currentChatId = 0;
currentChatUserId = 0;
currentUserRead = false;
if (chat.Type is TdApi.ChatType.ChatTypePrivate privChat) {
currentChatUserId = privChat.UserId;
}
currentChatId = chat.Id;
chat.Title = TruncateString(chat.Title, 20);
prefix = $"[{chat.Title}";
lock (@lock) {
messageQueue.Add($"{Ansi.Yellow}[tgcli] Opening chat: {chat.Title}");
messageQueue.Add($"{Ansi.Yellow}" + $"[tgcli] You have {chat.UnreadCount} unread message" + $"{(chat.UnreadCount == 1 ? "." : "s.")}");
if (chat.UnreadCount >= 5) {
var capped = chat.UnreadCount > 50;
GetHistory(chat.Id, capped ? 50 : chat.UnreadCount).ForEach(AddMessageToQueue);
if (capped)
messageQueue.Add($"{Ansi.Yellow}[tgcli] " + $"Showing 50 of {chat.UnreadCount} unread messages.");
}
else if (chat.UnreadCount > 0) {
var unreads = GetHistory(chat.Id, chat.UnreadCount);
var rest = GetHistory(chat.Id, 5 - unreads.Count, unreads.First().Id);
rest.ForEach(AddMessageToQueue);
messageQueue.Add($"{Ansi.Yellow}[tgcli] ---UNREAD---");
unreads.ForEach(AddMessageToQueue);
}
else {
GetHistory(chat.Id).ForEach(AddMessageToQueue);
}
}
var history = GetHistory(currentChatId, 50);
if (history.Count != 0)
MarkRead(chat.Id, history.First().Id);
var last = history.LastOrDefault(p => p.IsOutgoing);
if (last == null) {
currentUserRead = true;
return;
}
lastMessage = last;
currentUserRead = IsMessageRead(last.ChatId, last.Id);
}
}
public class CloseSecretChatCommand : Command {
public CloseSecretChatCommand() : base("cs", "", "closes a secret chat (permanently)", "", 0) { }
public override void Handler(List<string> _) {
if (currentChatId != 0 && GetChat(currentChatId).Type is TdApi.ChatType.ChatTypeSecret type) {
CloseSecretChat(type.SecretChatId);
DeleteChatHistory(currentChatId);
CommandManager.HandleCommand("c");
}
else {
lock (@lock)
messageQueue.Add($"{Ansi.Red}[tgcli] No secret chat selected, cannot continue.");
}
}
}
public class NewSecretChatCommand : Command {
public NewSecretChatCommand() : base("ns", "", "creates a new secret chat.", "<username>", 1) { }
public override void Handler(List<string> inputParams) {
var userId = GetUserIdByUsername(inputParams[0]);
if (userId == 0) {
lock (@lock)
messageQueue.Add($"{Ansi.Red}[tgcli] User not found. Try /s <query> to find valid usernames.");
return;
}
if (GetSecretChats().Count(p => ((TdApi.ChatType.ChatTypeSecret) p.Type).UserId == userId) > 0) {
lock (@lock)
messageQueue.Add($"{Ansi.Red}[tgcli] You already have a secret chat with the specified user.");
return;
}
var chat = CreateSecretChat(userId);
CommandManager.HandleCommand("osd " + chat.Id);
}
}
public class OpenSecretDirectCommand : Command {
public OpenSecretDirectCommand() : base("osd", "", "opens a secret chat by chat id", "<chat_id>", 1) { }
public override void Handler(List<string> inputParams) {
var id = inputParams[0];
if (!long.TryParse(id, out var chatId)) {
lock (@lock)
messageQueue.Add($"{Ansi.Red}[tgcli] Invalid chat id.");
return;
}
var chat = GetChat(chatId);
if (chat == null) {
lock (@lock)
messageQueue.Add($"{Ansi.Red}[tgcli] Invalid chat id.");
return;
}
TdApi.SecretChat secChat;
if (chat.Type is TdApi.ChatType.ChatTypeSecret secretChat) {
currentChatUserId = secretChat.UserId;
currentChatId = chat.Id;
currentUserRead = false;
secChat = GetSecretChat(secretChat.SecretChatId);
}
else {
lock (@lock)
messageQueue.Add($"{Ansi.Red}[tgcli] The specified chat isn't a secret chat.");
return;
}
chat.Title = TruncateString(chat.Title, 20);
prefix = $"[{Ansi.Red}sec {Ansi.ResetAll}{chat.Title}]";
lock (@lock) {
messageQueue.Add($"{Ansi.Yellow}[tgcli] Opening secret chat: {chat.Title}");
messageQueue.Add($"{Ansi.Yellow}" + $"[tgcli] You have {chat.UnreadCount} unread message" + $"{(chat.UnreadCount == 1 ? "." : "s.")}");
if (secChat.State is TdApi.SecretChatState.SecretChatStateClosed) {
messageQueue.Add($"{Ansi.Red}[tgcli] Secret chat has ended. No messages can be sent.");
}
else if (secChat.State is TdApi.SecretChatState.SecretChatStatePending) {
messageQueue.Add($"{Ansi.Red}[tgcli] Secret chat is pending. No messages can be sent.");
}
if (chat.UnreadCount >= 5) {
var capped = chat.UnreadCount > 50;
GetHistory(chatId, capped ? 50 : chat.UnreadCount, isSecret: true).ForEach(AddMessageToQueue);
if (capped)
messageQueue.Add($"{Ansi.Yellow}[tgcli] " + $"Showing 50 of {chat.UnreadCount} unread messages.");
}
else if (chat.UnreadCount > 0) {
var unreads = GetHistory(chatId, chat.UnreadCount, isSecret: true);
var rest = GetHistory(chatId, 5 - unreads.Count, unreads.First().Id, isSecret: true);
rest.ForEach(AddMessageToQueue);
messageQueue.Add($"{Ansi.Yellow}[tgcli] ---UNREAD---");
unreads.ForEach(AddMessageToQueue);
}
else {
GetHistory(chatId, isSecret: true).ForEach(AddMessageToQueue);
}
}
var history = GetHistory(currentChatId, 50, isSecret: true);
if (history.Count != 0)
MarkRead(chat.Id, history.First().Id);
var last = history.LastOrDefault(p => p.IsOutgoing);
if (last == null) {
currentUserRead = true;
return;
}
lastMessage = last;
currentUserRead = IsMessageRead(last.ChatId, last.Id);
}
}
public class OpenSecretCommand : Command {
public OpenSecretCommand() : base("os", "", "opens a secret chat. queries chat list.", "<query>", -1) { }
public override void Handler(List<string> inputParams) {
if (inputParams.Count == 0) {
lock (@lock)
messageQueue.Add($"{Ansi.Red}[tgcli] No matching chat found.");
return;
}
var query = inputParams.Aggregate((current, param) => current + " " + param).Trim();
var userId = SearchUserInChats(query);
if (userId == 0 || query.Length == 0) {
lock (@lock)
messageQueue.Add($"{Ansi.Red}[tgcli] No matching chat found.");
return;
}
var chat = GetSecretChats().Find(p => ((TdApi.ChatType.ChatTypeSecret) p.Type).UserId == userId);
if (chat == null) {
lock (@lock)
messageQueue.Add($"{Ansi.Red}[tgcli] No matching secret chat found.");
return;
}
TdApi.SecretChat secChat;
if (chat.Type is TdApi.ChatType.ChatTypeSecret secretChat) {
currentChatUserId = secretChat.UserId;
currentChatId = chat.Id;
currentUserRead = false;
secChat = GetSecretChat(secretChat.SecretChatId);
}
else {
lock (@lock)
messageQueue.Add($"{Ansi.Red}[tgcli] No matching secret chat found. (this error should be impossible to produce)");
return;
}
chat.Title = TruncateString(chat.Title, 20);
prefix = $"[{Ansi.Red}sec {Ansi.ResetAll}{chat.Title}";
lock (@lock) {
messageQueue.Add($"{Ansi.Yellow}[tgcli] Opening secret chat: {chat.Title}");
messageQueue.Add($"{Ansi.Yellow}" + $"[tgcli] You have {chat.UnreadCount} unread message" + $"{(chat.UnreadCount == 1 ? "." : "s.")}");
if (secChat.State is TdApi.SecretChatState.SecretChatStateClosed) {
messageQueue.Add($"{Ansi.Red}[tgcli] Secret chat has ended. No messages can be sent.");
}
else if (secChat.State is TdApi.SecretChatState.SecretChatStatePending) {
messageQueue.Add($"{Ansi.Red}[tgcli] Secret chat is pending. No messages can be sent.");
}
if (chat.UnreadCount >= 5) {
var capped = chat.UnreadCount > 50;
GetHistory(chat.Id, capped ? 50 : chat.UnreadCount, isSecret: true).ForEach(AddMessageToQueue);
if (capped)
messageQueue.Add($"{Ansi.Yellow}[tgcli] " + $"Showing 50 of {chat.UnreadCount} unread messages.");
}
else if (chat.UnreadCount > 0) {
var unreads = GetHistory(chat.Id, chat.UnreadCount, isSecret: true);
var rest = GetHistory(chat.Id, 5 - unreads.Count, unreads.First().Id, isSecret: true);
rest.ForEach(AddMessageToQueue);
messageQueue.Add($"{Ansi.Yellow}[tgcli] ---UNREAD---");
unreads.ForEach(AddMessageToQueue);
}
else {
GetHistory(chat.Id, isSecret: true).ForEach(AddMessageToQueue);
}
}
var history = GetHistory(currentChatId, 50, isSecret: true);
if (history.Count != 0)
MarkRead(chat.Id, history.First().Id);
var last = history.LastOrDefault(p => p.IsOutgoing);
if (last == null) {
currentUserRead = true;
return;
}
lastMessage = last;
currentUserRead = IsMessageRead(last.ChatId, last.Id);
}
}
public class CloseUnreadCommand : Command {
public CloseUnreadCommand() : base("cu", "", "closes a chat, marking it as unread", "", 0) { }
public override void Handler(List<string> inputParams) {
if (currentChatId == 0) {
lock (@lock)
messageQueue.Add($"{Ansi.Red}[tgcli] No open chat, cannot continue.");
return;
}
MarkUnread(currentChatId);
CommandManager.HandleCommand("c");
}
}
public class CloseCommand : Command {
public CloseCommand() : base("c", "^E", "closes a chat", "", 0) { }
public override void Handler(List<string> inputParams) {
if (currentChatId == 0) {
lock (@lock)
messageQueue.Add($"{Ansi.Red}[tgcli] No open chat, cannot continue.");
return;
}
currentChatId = 0;
currentChatUserId = 0;
currentUserRead = false;
lastMessage = null;
prefix = "[tgcli";
lock (@lock) {
messageQueue.Add($"{Ansi.Yellow}[tgcli] Closing chat.");
var count = missedMessages.Count;
if (count == 0)
return;
messageQueue.Add($"{Ansi.Yellow}" + $"[tgcli] You have {count} missed message" + $"{(count == 1 ? "." : "s.")}");
messageQueue.AddRange(missedMessages);
missedMessages.Clear();
}
}
}
public class HistoryCommand : Command {
public HistoryCommand() : base("h", "", "shows chat history. default limit is 5", "[1-50]", -1) { }
public override void Handler(List<string> inputParams) {
if (currentChatId == 0) {
lock (@lock)
messageQueue.Add($"{Ansi.Red}[tgcli] No open chat, cannot continue.");
return;
}
List<TdApi.Message> history;
if (inputParams.Count == 1 && int.TryParse(inputParams[0], out var limit)) {
history = GetHistory(currentChatId, Math.Min(limit, 50));
while (limit > 50) {
limit -= 50;
history.InsertRange(0, GetHistory(currentChatId, Math.Min(limit, 50), history.First().Id));
}
}
else {
history = GetHistory(currentChatId);
}
lock (@lock) {
messageQueue.Add($"{Ansi.Yellow}[tgcli] Last {history.Count} messages in " + $"{GetChat(currentChatId).Title}");
}
foreach (var msg in history) {
AddMessageToQueue(msg);
}
}
}
public class ClearCommand : Command {
public ClearCommand() : base("cl", "^L", "clears console", "", 0) { }
public override void Handler(List<string> inputParams) {
lock (@lock) {
Console.Clear();
}
}
}
public class UnreadsCommand : Command {
public UnreadsCommand() : base("u", "^U", "displays unread chat", "[all]", -1) { }
public override void Handler(List<string> inputParams) {
var unreads = GetUnreadChats(inputParams.Count == 1 && inputParams[0].Equals("all"));
lock (@lock) {
messageQueue.Add($"{Ansi.Yellow}[tgcli] You have {unreads.Count} unread chats.");
unreads.ForEach(chat => {
string line;
if (chat.UnreadCount == 0)
line = $"{Ansi.Bold}{Ansi.Yellow}[M] {chat.Title}";
else if (chat.Type is TdApi.ChatType.ChatTypeSecret)
line = $"{Ansi.Bold}{Ansi.Red}[{chat.UnreadCount}] [sec] {chat.Title}";
else
line = $"{Ansi.Bold}{Ansi.Green}[{chat.UnreadCount}] {chat.Title}";
messageQueue.Add(line);
});
}
}
}
public class ListChatsCommand : Command {
public ListChatsCommand() : base("lc", "", "lists all chats, optionally filtered", "[query]", -1) { }
public override void Handler(List<string> inputParams) {
var chats = GetChats();
lock (@lock) {
if (inputParams.Count > 0) {
var query = inputParams.Aggregate((current, param) => current + " " + param).Trim();
chats = chats.FindAll(p => p.Title.ToLower().Contains(query.ToLower()));
}
messageQueue.Add($"{Ansi.Yellow}[tgcli] Listing {chats.Count} chats.");
chats.ForEach(chat => {
string line;
if (chat.UnreadCount == 0)
line = $"{Ansi.Bold}{Ansi.Blue}[0] {chat.Title}";
else
line = $"{Ansi.Bold}{Ansi.Green}[{chat.UnreadCount}] {chat.Title}";
messageQueue.Add(line);
});
}
}
}
public class SearchUserCommand : Command {
public SearchUserCommand() : base("s", "", "searches for users globally", "<query>", -1) { }
public override void Handler(List<string> inputParams) {
if (inputParams.Count == 0) {
lock (@lock)
messageQueue.Add($"{Ansi.Red}[tgcli] Invalid syntax, check /help for more information.");
return;
}
var query = inputParams.Aggregate((current, param) => current + " " + param).Trim();
var chats = SearchChatsGlobal(query);
chats = chats.FindAll(p => p.Type is TdApi.ChatType.ChatTypePrivate);
lock (@lock) {
messageQueue.Add($"{Ansi.Yellow}[tgcli] Listing {chats.Count} chats.");
chats.ForEach(chat => {
string line;
var type = (TdApi.ChatType.ChatTypePrivate) chat.Type;
var user = GetUser(type.UserId);
line = $"{Ansi.Bold}{Ansi.Yellow}@{user.Username} {Ansi.Magenta}{chat.Title}";
messageQueue.Add(line);
});
}
}
}
public class AddContactCommand : Command {
public AddContactCommand() : base("ac", "", "adds user to contact list", "<username>", 1) { }
public override void Handler(List<string> inputParams) {
/*
var query = inputParams[0];
var chat = GetChatByUsernameGlobal(query);
if (chat.Type is TdApi.ChatType.ChatTypePrivate type)
{
//TODO implement when TDLib 1.6 is released
}
else
{
lock (@lock)
messageQueue.Add($"{Ansi.Red}[tgcli] Username does not refer to a user.");
}
*/
}
}
public class ListSecretChatsCommand : Command {
public ListSecretChatsCommand() : base("ls", "", "displays all open secret chats", "", 0) { }
public override void Handler(List<string> inputParams) {
var secretChats = GetSecretChats();
lock (@lock) {
messageQueue.Add($"{Ansi.Yellow}[tgcli] Listing {secretChats.Count} secret chats:");
secretChats.ForEach(chat => {
messageQueue.Add($"{Ansi.Bold}{Ansi.Red}[sec] {chat.Title} -> {chat.Id} ({GetSecretChat(((TdApi.ChatType.ChatTypeSecret) chat.Type).SecretChatId).State.DataType})");
});
}
}
}
public class HelpCommand : Command {
public HelpCommand() : base("help", "", "lists all commands", "", 0) { }
public override void Handler(List<string> inputParams) {
lock (@lock) {
messageQueue.Add($"{Ansi.Yellow}[tgcli] Listing {CommandManager.Commands.Count} commands:");
CommandManager.Commands.ForEach(command => {
var commandText = $"/{command.trigger}";
if (!string.IsNullOrWhiteSpace(command.syntax))
commandText += $" {command.syntax}";
commandText += $": {command.description}";
if (!string.IsNullOrWhiteSpace(command.shortcut))
commandText += $" ({command.shortcut})";
messageQueue.Add($"{Ansi.Yellow}{commandText}");
});
}
}
}
public class QuitCommand : Command {
public QuitCommand() : base("q", "^D", "quits the program", "", 0) { }
public override void Handler(List<string> inputParams) {
quitting = true;
}
}
public class EditCommand : Command {
public EditCommand() : base("e", "", "edits last message. param empty adds last message to inputline", "[message]", -1) { }
public override void Handler(List<string> inputParams) {
try {
if (inputParams.Count == 0) {
currentInputLine = "/e " + ((TdApi.MessageContent.MessageText) lastMessage?.Content)?.Text?.Text;
Emojis.ForEach(em => currentInputLine = currentInputLine.Replace(em.Item2, em.Item1));
return;
}
var message = inputParams.Aggregate((current, param) => current + " " + param).Trim();
if (currentChatId == 0) {
lock (@lock)
messageQueue.Add($"{Ansi.Red}[tgcli] No open chat, cannot continue.");
return;
}
if (lastMessage == null) {
//try to find last message
var history = GetHistory(currentChatId, 50);
var last = history.LastOrDefault(p => p.IsOutgoing);
if (last == null) {
lock (@lock)
messageQueue.Add($"{Ansi.Red}[tgcli] No message to edit found, cannot continue.");
return;
}
lastMessage = last;
}
if (string.IsNullOrWhiteSpace(message)) {
lock (@lock)
messageQueue.Add($"{Ansi.Red}[tgcli] No message specified, cannot continue.");
return;
}
EditMessage(message, lastMessage);
}
catch {
lock (@lock)
messageQueue.Add($"{Ansi.Red}[tgcli] Unknown error editing message.");
}
}
}
public class ReplyCommand : Command {
public ReplyCommand() : base("r", "", "replies to message", "<offset> <message>", -1) { }
public override void Handler(List<string> inputParams) {
try {
if (inputParams.Count < 2) {
lock (@lock)
messageQueue.Add($"{Ansi.Red}[tgcli] Invalid syntax, check /help for more information.");
return;
}
var history = GetHistory(currentChatId, 50);
var parsed = int.TryParse(inputParams[0], out var offset);
inputParams.RemoveAt(0);
history.Reverse();
var message = inputParams.Aggregate((current, param) => current + " " + param).Trim();
if (!parsed || string.IsNullOrWhiteSpace(message) || history.Count < offset) {
lock (@lock)
messageQueue.Add($"{Ansi.Red}Invalid syntax, check /help for more information.");
return;
}
var replyMessage = history[offset - 1];
if (currentChatId == 0) {
lock (@lock)
messageQueue.Add($"{Ansi.Red}[tgcli] No open chat, cannot continue.");
return;
}
SendMessage(message, currentChatId, replyMessage.Id);
}
catch {
lock (@lock)
messageQueue.Add($"{Ansi.Red}[tgcli] Unknown error sending message.");
}
}
}
public class LogoutCommand : Command {
public LogoutCommand() : base("logout", "", "log out this session (destroys all local data)", "", 0) { }
public override void Handler(List<string> inputParams) {
LogOut();
}
}
}

View file

@ -1,474 +0,0 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading;
using NeoSmart.Unicode;
using static TdLib.TdApi;
using static telegram.tgcli;
namespace telegram {
public class Util {
public static class Ansi {
public const string ResetAll = "\x1B[0m";
public const string Red = "\x1b[31m";
public const string Green = "\x1b[32m";
public const string Yellow = "\x1b[33m";
public const string Blue = "\x1b[34m";
public const string Magenta = "\x1b[35m";
public const string Cyan = "\x1b[36m";
public const string Bold = "\x1b[1m";
public const string BoldOff = "\x1b[22m";
}
public static User GetUser(int uid) {
try {
var uinfo = client.ExecuteAsync(new GetUser {UserId = uid}).Result;
return uinfo;
}
catch {
var user = new User();
user.FirstName = "null";
user.LastName = "null";
return user;
}
}
public static Chat GetChat(long chatId) {
try {
return client.ExecuteAsync(new GetChat {ChatId = chatId}).Result;
}
catch {
return null;
}
}
public static User GetMe() {
return client.ExecuteAsync(new GetMe()).Result;
}
public static Message GetMessage(long chatId, long messageId) {
return client.ExecuteAsync(new GetMessage {ChatId = chatId, MessageId = messageId}).Result;
}
public static int GetTotalMessages(long chatId) {
try {
var response = client.ExecuteAsync(new SearchChatMessages {ChatId = chatId, Query = "+", Limit = 1});
return response.Result.TotalCount;
}
catch {
return 9999;
}
}
public static List<Message> GetHistory(long chatId, int limit = 5, long fromMessageId = 0, int offset = 0, bool isSecret = false,
bool skipTotal = false) {
var history = new List<Message>();
var total = GetTotalMessages(chatId);
var chat = GetChat(chatId);
if (chat.Type is ChatType.ChatTypeSupergroup || isSecret)
skipTotal = true;
if (limit > total && !skipTotal)
limit = total;
for (var i = 5; i > 0; i--) {
if (limit <= 0) {
if (total == 0)
return history;
lock (@lock)
messageQueue.Add($"{Ansi.Red}[tgcli] " + $"Limit cannot be less than one. Usage: /history <count>");
return history;
}
var response = client.ExecuteAsync(new GetChatHistory {
ChatId = chatId,
FromMessageId = fromMessageId,
Limit = limit,
Offset = offset,
OnlyLocal = false
})
.Result;
if (response.Messages_.Length < limit && i > 1 && !isSecret) {
Thread.Sleep(100);
continue;
}
history.AddRange(response.Messages_);
history.Reverse();
return history;
}
return history;
}
public static List<Chat> GetUnreadChats(bool all = false) {
var output = new List<Chat>();
var offset = 0L;
while (true) {
if (offset == 0) {
var response = client.ExecuteAsync(new GetChats {OffsetOrder = long.MaxValue, Limit = int.MaxValue}).Result;
offset = GetChat(response.ChatIds.Last()).Order;
output.AddRange(all
? response.ChatIds.Select(GetChat).Where(c => c.UnreadCount > 0 || c.IsMarkedAsUnread).ToList()
: response.ChatIds.Select(GetChat)
.Where(c => (c.UnreadCount > 0 || c.IsMarkedAsUnread) && c.NotificationSettings.MuteFor == 0)
.ToList());
}
else {
var response = client.ExecuteAsync(new GetChats {OffsetOrder = offset, Limit = int.MaxValue}).Result;
if (response.ChatIds.Length == 0)
break;
offset = GetChat(response.ChatIds.Last()).Order;
output.AddRange(all
? response.ChatIds.Select(GetChat).Where(c => c.UnreadCount > 0 || c.IsMarkedAsUnread).ToList()
: response.ChatIds.Select(GetChat)
.Where(c => (c.UnreadCount > 0 || c.IsMarkedAsUnread) && c.NotificationSettings.MuteFor == 0)
.ToList());
}
}
return output;
}
public static List<Chat> GetChats() {
var response = client.ExecuteAsync(new GetChats {OffsetOrder = long.MaxValue, Limit = int.MaxValue}).Result;
return response.ChatIds.Select(GetChat).ToList();
}
public static List<Chat> SearchChatsGlobal(string query) {
if (query.TrimStart('@').Length < 5) {
return new List<Chat>();
}
var response = client.ExecuteAsync(new SearchPublicChats {Query = query}).Result;
var chats = response.ChatIds.Select(GetChat).ToList();
chats.AddRange(client.ExecuteAsync(new SearchChats {Query = query, Limit = int.MaxValue}).Result.ChatIds.Select(GetChat));
return chats;
}
public static Chat GetChatByUsernameGlobal(string username) {
try {
var response = client.ExecuteAsync(new SearchPublicChat {Username = username}).Result;
return response;
}
catch {
return null;
}
}
public static int GetUserIdByUsername(string username) {
try {
var response = client.ExecuteAsync(new SearchPublicChat {Username = username}).Result;
if (response.Type is ChatType.ChatTypePrivate priv)
return priv.UserId;
return 0;
}
catch {
return 0;
}
}
public static void AddUserToContacts(int userId, string name) {
//TODO implement when TDLib 1.6 is released
}
public static List<Chat> GetSecretChats() {
var response = client.ExecuteAsync(new GetChats {OffsetOrder = long.MaxValue, Limit = int.MaxValue}).Result;
return response.ChatIds.Select(GetChat).Where(c => c.Type is ChatType.ChatTypeSecret).ToList();
}
public static void CloseSecretChat(int secretChatId) {
client.ExecuteAsync(new CloseSecretChat() {SecretChatId = secretChatId}).Wait();
}
public static Chat CreateSecretChat(int userId) {
return client.ExecuteAsync(new CreateNewSecretChat {UserId = userId}).Result;
}
public static void DeleteChatHistory(long chatId) {
client.ExecuteAsync(new DeleteChatHistory {ChatId = chatId, RemoveFromChatList = true, Revoke = true}).Wait();
}
public static SecretChat GetSecretChat(int secretChatId) {
var response = client.ExecuteAsync(new GetSecretChat {SecretChatId = secretChatId}).Result;
return response;
}
public static void ClearCurrentConsoleLine() {
Console.Write("\u001b[2K\r");
//Console.SetCursorPosition(0, Console.WindowHeight);
//Console.Write(new string(' ', Console.WindowWidth));
//Console.SetCursorPosition(0, Console.WindowHeight);
}
public static string ReadConsolePassword() {
var pass = "";
do {
var key = Console.ReadKey(true);
if (key.Key != ConsoleKey.Backspace && key.Key != ConsoleKey.Enter) {
pass += key.KeyChar;
Console.Write("*");
}
else {
if (key.Key == ConsoleKey.Backspace && pass.Length > 0) {
pass = pass.Substring(0, (pass.Length - 1));
Console.Write("\b \b");
}
else if (key.Key == ConsoleKey.Enter) {
break;
}
}
} while (true);
Console.WriteLine();
return pass;
}
public static void SendMessage(string message, long chatId, long replyTo = 0) {
if (string.IsNullOrWhiteSpace(message))
return;
Emojis.ForEach(em => message = message.Replace(em.Item1, em.Item2));
client.ExecuteAsync(new SendMessage {
ChatId = chatId,
InputMessageContent = new InputMessageContent.InputMessageText {Text = new FormattedText() {Text = message}},
ReplyToMessageId = replyTo,
});
currentUserRead = false;
}
public static Message EditMessage(string newText, Message message) {
Emojis.ForEach(em => newText = newText.Replace(em.Item1, em.Item2));
var msg = client.ExecuteAsync(new EditMessageText {
ChatId = message.ChatId,
MessageId = message.Id,
InputMessageContent = new InputMessageContent.InputMessageText {Text = new FormattedText() {Text = newText}}
})
.Result;
return msg;
}
public static void MarkRead(long chatId, long messageId) {
client.ExecuteAsync(new ViewMessages {ChatId = chatId, MessageIds = new[] {messageId}, ForceRead = true});
}
public static void MarkUnread(long chatId) {
client.ExecuteAsync(new ToggleChatIsMarkedAsUnread {ChatId = chatId, IsMarkedAsUnread = true,});
}
public static long SearchChatId(string query) {
try {
var results = client.ExecuteAsync(new SearchChats {Query = query, Limit = 5}).Result;
if (query.StartsWith("@"))
return results.ChatIds.First(p => GetChat(p).Type is ChatType.ChatTypePrivate type && GetUser(type.UserId).Username == query.Substring(1));
return results.ChatIds.First(p => !(GetChat(p).Type is ChatType.ChatTypeSecret));
}
catch {
lock (@lock)
messageQueue.Add($"{Ansi.Red}[tgcli] No results found.");
return 0;
}
}
public static int SearchUserInChats(string query) {
var results = client.ExecuteAsync(new SearchChatsOnServer {Query = query, Limit = 5}).Result;
if (results.ChatIds.Length == 0)
return 0;
var output = results.ChatIds.Select(GetChat).Where(p => p.Type is ChatType.ChatTypePrivate).Select(p => ((ChatType.ChatTypePrivate) p.Type).UserId);
return output.Any() ? output.First() : 0;
}
public static int SearchContacts(string query) {
//TODO implement when TDLib 1.6 is released
try {
var results = client.ExecuteAsync(new SearchContacts {Query = query, Limit = 5}).Result;
return query.StartsWith("@") ? results.UserIds.First(p => GetUser(p).Username == query.Substring(1)) : results.UserIds.First();
}
catch {
lock (@lock)
messageQueue.Add($"{Ansi.Red}[tgcli] No results found.");
return 0;
}
}
public static void LogOut() {
lock (@lock)
messageQueue.Add($"{Ansi.Yellow}[tgcli] Logging out...");
client.ExecuteAsync(new LogOut()).Wait();
}
public static string GetFormattedUsername(User sender) {
var username = sender.Username;
if (string.IsNullOrWhiteSpace(username))
username = sender.FirstName + " " + sender.LastName;
else
username = "@" + username;
return username;
}
public static string FormatTime(long unix) {
var time = DateTimeOffset.FromUnixTimeSeconds(unix).DateTime.ToLocalTime();
var currentTime = DateTime.Now.ToLocalTime();
return time.ToString(time.Date.Ticks == currentTime.Date.Ticks ? "HH:mm" : "yyyy-MM-dd HH:mm");
}
public static bool IsMessageRead(long chatId, long messageId) {
var chat = GetChat(chatId);
return chat.LastReadOutboxMessageId >= messageId;
}
public static int GetActualStringWidth(string input) {
input = input.Replace(Ansi.Blue, "");
input = input.Replace(Ansi.Bold, "");
input = input.Replace(Ansi.Cyan, "");
input = input.Replace(Ansi.Green, "");
input = input.Replace(Ansi.Magenta, "");
input = input.Replace(Ansi.Red, "");
input = input.Replace(Ansi.Yellow, "");
input = input.Replace(Ansi.BoldOff, "");
input = input.Replace(Ansi.ResetAll, "");
return input.Length;
}
public static string GetFormattedStatus(bool isRead) {
var output = " ";
output += (isRead ? Ansi.Green : Ansi.Red) + "r";
return output + $"{Ansi.ResetAll}]";
}
public static string TruncateString(string input, int maxLen) {
if (maxLen < 2)
maxLen = 2;
return input.Length <= maxLen ? input : input.Substring(0, maxLen - 1) + "~";
}
public static string TruncateMessageStart(string input, int maxLen) {
if (maxLen < 2)
maxLen = 2;
if (input.Contains("⏎ "))
input = "⏎ " + input.Split("⏎ ").Last();
return input.Length < maxLen ? input : "<" + input.Substring(input.Length - maxLen + 2);
}
public static readonly List<Tuple<string, string>> Emojis = new List<Tuple<string, string>> {
new Tuple<string, string>("⏎ ", "\n"),
new Tuple<string, string>(":xd:", Emoji.FaceWithTearsOfJoy.Sequence.AsString),
new Tuple<string, string>(":check:", Emoji.WhiteHeavyCheckMark.Sequence.AsString),
new Tuple<string, string>(":thinking:", Emoji.ThinkingFace.Sequence.AsString),
new Tuple<string, string>(":eyes:", Emoji.Eyes.Sequence.AsString),
new Tuple<string, string>(":heart:", Emoji.RedHeart.Sequence.AsString),
new Tuple<string, string>(":shrug:", Emoji.PersonShrugging.Sequence.AsString),
new Tuple<string, string>(":shrugf:", Emoji.WomanShrugging.Sequence.AsString),
new Tuple<string, string>(":shrugm:", Emoji.ManShrugging.Sequence.AsString)
};
public static readonly List<ConsoleKey> SpecialKeys = new List<ConsoleKey> {
ConsoleKey.Backspace,
ConsoleKey.Tab,
ConsoleKey.Clear,
ConsoleKey.Enter,
ConsoleKey.Pause,
ConsoleKey.Escape,
ConsoleKey.PageUp,
ConsoleKey.PageDown,
ConsoleKey.End,
ConsoleKey.Home,
ConsoleKey.LeftArrow,
ConsoleKey.UpArrow,
ConsoleKey.RightArrow,
ConsoleKey.DownArrow,
ConsoleKey.Select,
ConsoleKey.Print,
ConsoleKey.Execute,
ConsoleKey.PrintScreen,
ConsoleKey.Insert,
ConsoleKey.Delete,
ConsoleKey.Help,
ConsoleKey.LeftWindows,
ConsoleKey.RightWindows,
ConsoleKey.Applications,
ConsoleKey.Sleep,
ConsoleKey.F1,
ConsoleKey.F2,
ConsoleKey.F3,
ConsoleKey.F4,
ConsoleKey.F5,
ConsoleKey.F6,
ConsoleKey.F7,
ConsoleKey.F8,
ConsoleKey.F9,
ConsoleKey.F10,
ConsoleKey.F11,
ConsoleKey.F12,
ConsoleKey.F13,
ConsoleKey.F14,
ConsoleKey.F15,
ConsoleKey.F16,
ConsoleKey.F17,
ConsoleKey.F18,
ConsoleKey.F19,
ConsoleKey.F20,
ConsoleKey.F21,
ConsoleKey.F22,
ConsoleKey.F23,
ConsoleKey.F24,
ConsoleKey.BrowserBack,
ConsoleKey.BrowserForward,
ConsoleKey.BrowserRefresh,
ConsoleKey.BrowserStop,
ConsoleKey.BrowserSearch,
ConsoleKey.BrowserFavorites,
ConsoleKey.BrowserHome,
ConsoleKey.VolumeMute,
ConsoleKey.VolumeDown,
ConsoleKey.VolumeUp,
ConsoleKey.MediaNext,
ConsoleKey.MediaPrevious,
ConsoleKey.MediaStop,
ConsoleKey.MediaPlay,
ConsoleKey.LaunchMail,
ConsoleKey.LaunchMediaSelect,
ConsoleKey.LaunchApp1,
ConsoleKey.LaunchApp2,
ConsoleKey.Oem1,
ConsoleKey.OemPlus,
ConsoleKey.OemComma,
ConsoleKey.OemMinus,
ConsoleKey.OemPeriod,
ConsoleKey.Oem2,
ConsoleKey.Oem3,
ConsoleKey.Oem4,
ConsoleKey.Oem5,
ConsoleKey.Oem6,
ConsoleKey.Oem7,
ConsoleKey.Oem8,
ConsoleKey.Oem102,
ConsoleKey.Process,
ConsoleKey.Packet,
ConsoleKey.Attention,
ConsoleKey.CrSel,
ConsoleKey.ExSel,
ConsoleKey.EraseEndOfFile,
ConsoleKey.Play,
ConsoleKey.Zoom,
ConsoleKey.NoName,
ConsoleKey.Pa1,
ConsoleKey.OemClear
};
}
}

Binary file not shown.

Binary file not shown.

View file

@ -1,25 +0,0 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFramework>netcoreapp3.1</TargetFramework>
</PropertyGroup>
<ItemGroup>
<ProjectReference Include="..\tdsharp\TDLib\TDLib.csproj" />
</ItemGroup>
<ItemGroup>
<None Update="libtdjson.dylib">
<CopyToOutputDirectory>Always</CopyToOutputDirectory>
</None>
<None Update="libtdjson.so">
<CopyToOutputDirectory>Always</CopyToOutputDirectory>
</None>
</ItemGroup>
<ItemGroup>
<PackageReference Include="Unicode.net" Version="0.1.2" />
</ItemGroup>
</Project>

View file

@ -1,511 +0,0 @@
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:
* fuzzy matching for replies?
* unreads are unreliable in secret chats!
* mute,unmute chats
* photo & document download & show externally
* publish AUR package
* cursor input nav (up/down history, (alt +) left/right)
* ref: http://www.lihaoyi.com/post/BuildyourownCommandLinewithANSIescapecodes.html#cursor-navigation
* ref: https://en.wikipedia.org/wiki/ANSI_escape_code#Escape_sequences
* 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 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<string> messageQueue = new List<string>();
public static volatile List<string> missedMessages = new List<string>();
public static volatile string prefix = "[tgcli";
public static volatile bool silent;
public static volatile object @lock = new object();
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 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) {
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 && !silent)
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 - GetActualStringWidth(output));
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 <query>");
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 += " ";
if (currentInputLine.EndsWith("⏎"))
currentInputLine = currentInputLine.Remove(currentInputLine.Length - 1);
ScreenUpdate();
return;
}
currentInputLine = currentInputLine.Substring(0, currentInputLine.Length - 1);
if (currentInputLine.EndsWith("⏎"))
currentInputLine = currentInputLine.Remove(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.2a",
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();
}
}
}

View file

@ -0,0 +1,57 @@
namespace tgcli.Tests;
public class PagedMessageInput {
[Theory]
[InlineData(0)]
[InlineData(76)]
[InlineData(77)]
[InlineData(147)]
[InlineData(148)]
[InlineData(218)]
[InlineData(219)]
[InlineData(289)]
public void TestGetPagedMessageInput(int offset) {
const string testMessage =
"this is a test string please ignore 1, this is a test string please ignore 2, this is a test string please ignore 3, this is a test string please ignore 4, this is a test string please ignore 5, this is a test string please ignore 6, this is a test string please ignore 7, this is a test str.";
const int testBufferWidth = 80;
Assert.Equal(ReferenceMethods.GetPagedMessageInputLine(testMessage, offset, testBufferWidth), Util.GetPagedMessageInputLine(testMessage, offset, testBufferWidth));
}
private static class ReferenceMethods {
internal static (string messageBuffer, int relCursorPos) GetPagedMessageInputLine(string message, int absCursorPos, int bufferWidth) {
const int wrapOffsetPre = 2; // number of "untouchable" characters moving the cursor onto will cause a wrap on the right screen edge
const int wrapOffsetPost = 5; // number of "untouchable" characters moving the cursor onto will cause a wrap on the left screen edge
const int wrapOffsetPreI = wrapOffsetPre + 1; // offset + 1 (character on the edge), for easier calculations
const int wrapOffsetPostI = wrapOffsetPost + 1; // offset + 1 (character on the edge), for easier calculations
if (absCursorPos > message.Length)
throw new ArgumentOutOfRangeException(nameof(absCursorPos), "Cursor position exceeds message length");
if (message.Length < bufferWidth)
return (message, absCursorPos);
if (absCursorPos < bufferWidth - wrapOffsetPre - 1)
return (Util.TruncateString(message, bufferWidth, $"{Util.Ansi.Inverse}>{Util.Ansi.InverseOff}"), absCursorPos);
// now we can be sure the message needs at least one wrap
// first wrap
// get rid of the content shown on the zeroth wrap, which is buf width minus wraparoundPreW (respects > character on screen edge)
var finalMessage = message[(bufferWidth - wrapOffsetPreI - wrapOffsetPost)..];
var finalCursorPos = absCursorPos - bufferWidth + wrapOffsetPreI + wrapOffsetPostI;
// successive wraps
// repeat above steps (but counting the new < character) until the string fits into the buffer
// it fits into the buffer when cursorPos >= bufferwidth minus wraparound (this time respecting > character absent on first wrap)
while (finalCursorPos >= bufferWidth - wrapOffsetPreI) {
finalMessage = finalMessage[(bufferWidth - wrapOffsetPreI - wrapOffsetPostI)..];
finalCursorPos = finalCursorPos - bufferWidth + wrapOffsetPreI + wrapOffsetPostI;
}
finalMessage = Util.TruncateString(finalMessage, bufferWidth - 1, $"{Util.Ansi.Inverse}>{Util.Ansi.InverseOff}");
return ($"{Util.Ansi.Inverse}<{Util.Ansi.InverseOff}" + finalMessage, finalCursorPos);
}
}
}

1
tgcli.Tests/Usings.cs Normal file
View file

@ -0,0 +1 @@
global using Xunit;

View file

@ -0,0 +1,28 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net7.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<IsPackable>false</IsPackable>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.3.2" />
<PackageReference Include="xunit" Version="2.4.2" />
<PackageReference Include="xunit.runner.visualstudio" Version="2.4.5">
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
<PrivateAssets>all</PrivateAssets>
</PackageReference>
<PackageReference Include="coverlet.collector" Version="3.1.2">
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
<PrivateAssets>all</PrivateAssets>
</PackageReference>
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\tgcli\tgcli.csproj" />
</ItemGroup>
</Project>

View file

@ -1,10 +1,9 @@

Microsoft Visual Studio Solution File, Format Version 12.00
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "telegram", "telegram\telegram.csproj", "{26C5A85E-DDBB-4358-84A7-4A6A577428CB}"
#
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "tgcli", "tgcli\tgcli.csproj", "{26C5A85E-DDBB-4358-84A7-4A6A577428CB}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "TDLib", "tdsharp\TDLib\TDLib.csproj", "{9134FECE-FD08-418D-B3FF-E1FB135A98C8}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "TDLib.Api", "tdsharp\TDLib.Api\TDLib.Api.csproj", "{3BCC90D7-1303-42EE-ACF6-11DA6251A52F}"
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "tgcli.Tests", "tgcli.Tests\tgcli.Tests.csproj", "{2A95F0DD-72BF-4B43-BB81-E143A7C800FB}"
EndProject
Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution
@ -16,13 +15,9 @@ Global
{26C5A85E-DDBB-4358-84A7-4A6A577428CB}.Debug|Any CPU.Build.0 = Debug|Any CPU
{26C5A85E-DDBB-4358-84A7-4A6A577428CB}.Release|Any CPU.ActiveCfg = Release|Any CPU
{26C5A85E-DDBB-4358-84A7-4A6A577428CB}.Release|Any CPU.Build.0 = Release|Any CPU
{9134FECE-FD08-418D-B3FF-E1FB135A98C8}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{9134FECE-FD08-418D-B3FF-E1FB135A98C8}.Debug|Any CPU.Build.0 = Debug|Any CPU
{9134FECE-FD08-418D-B3FF-E1FB135A98C8}.Release|Any CPU.ActiveCfg = Release|Any CPU
{9134FECE-FD08-418D-B3FF-E1FB135A98C8}.Release|Any CPU.Build.0 = Release|Any CPU
{3BCC90D7-1303-42EE-ACF6-11DA6251A52F}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{3BCC90D7-1303-42EE-ACF6-11DA6251A52F}.Debug|Any CPU.Build.0 = Debug|Any CPU
{3BCC90D7-1303-42EE-ACF6-11DA6251A52F}.Release|Any CPU.ActiveCfg = Release|Any CPU
{3BCC90D7-1303-42EE-ACF6-11DA6251A52F}.Release|Any CPU.Build.0 = Release|Any CPU
{2A95F0DD-72BF-4B43-BB81-E143A7C800FB}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{2A95F0DD-72BF-4B43-BB81-E143A7C800FB}.Debug|Any CPU.Build.0 = Debug|Any CPU
{2A95F0DD-72BF-4B43-BB81-E143A7C800FB}.Release|Any CPU.ActiveCfg = Release|Any CPU
{2A95F0DD-72BF-4B43-BB81-E143A7C800FB}.Release|Any CPU.Build.0 = Release|Any CPU
EndGlobalSection
EndGlobal

View file

@ -74,10 +74,12 @@
<s:Boolean x:Key="/Default/CodeStyle/Naming/CSharpAutoNaming/IsNotificationDisabled/@EntryValue">True</s:Boolean>
<s:Boolean x:Key="/Default/Environment/Editor/StructuralNavigationIsEnabled/@EntryValue">False</s:Boolean>
<s:Int64 x:Key="/Default/Environment/Hierarchy/GeneratedFilesCacheKey/Timestamp/@EntryValue">6</s:Int64>
<s:Boolean x:Key="/Default/Environment/SettingsMigration/IsMigratorApplied/=JetBrains_002EdotCover_002EInteractive_002ECore_002EFilterManagement_002EMigration_002EGlobalFilterSettingsManagerMigrateSettings/@EntryIndexedValue">True</s:Boolean>
<s:Boolean x:Key="/Default/Environment/SettingsMigration/IsMigratorApplied/=JetBrains_002EReSharper_002EPsi_002ECSharp_002ECodeStyle_002ECSharpKeepExistingMigration/@EntryIndexedValue">True</s:Boolean>
<s:Boolean x:Key="/Default/Environment/SettingsMigration/IsMigratorApplied/=JetBrains_002EReSharper_002EPsi_002ECSharp_002ECodeStyle_002ECSharpPlaceEmbeddedOnSameLineMigration/@EntryIndexedValue">True</s:Boolean>
<s:Boolean x:Key="/Default/Environment/SettingsMigration/IsMigratorApplied/=JetBrains_002EReSharper_002EPsi_002ECSharp_002ECodeStyle_002ECSharpUseContinuousIndentInsideBracesMigration/@EntryIndexedValue">True</s:Boolean>
<s:Boolean x:Key="/Default/Environment/SettingsMigration/IsMigratorApplied/=JetBrains_002EReSharper_002EPsi_002ECSharp_002ECodeStyle_002ESettingsUpgrade_002EMigrateBlankLinesAroundFieldToBlankLinesAroundProperty/@EntryIndexedValue">True</s:Boolean>
<s:Boolean x:Key="/Default/Environment/SettingsMigration/IsMigratorApplied/=JetBrains_002ERider_002EBackend_002EFeatures_002EDebugger_002ESettings_002EMigration_002ERiderSymbolServersSettingsUpgrade/@EntryIndexedValue">True</s:Boolean>
<s:String x:Key="/Default/GlobalFilterSettingsManager/AppliedDefaultAttributeFilterString/@EntryValue">;System.Diagnostics.CodeAnalysis.ExcludeFromCodeCoverageAttribute;System.CodeDom.Compiler.GeneratedCodeAttribute;Microsoft.VisualStudio.TestPlatform.TestSDKAutoGeneratedCode*</s:String>
<s:String x:Key="/Default/GlobalFilterSettingsManager/AttributeFilterXml/@EntryValue">&lt;data&gt;&lt;AttributeFilter ClassMask="System.Diagnostics.CodeAnalysis.ExcludeFromCodeCoverageAttribute" IsEnabled="True" /&gt;&lt;AttributeFilter ClassMask="System.CodeDom.Compiler.GeneratedCodeAttribute" IsEnabled="True" /&gt;&lt;AttributeFilter ClassMask="Microsoft.VisualStudio.TestPlatform.TestSDKAutoGeneratedCode*" IsEnabled="True" /&gt;&lt;/data&gt;</s:String>
<s:Boolean x:Key="/Default/Housekeeping/FeatureSuggestion/FeatureSuggestionManager/DisabledSuggesters/=SwitchToGoToActionSuggester/@EntryIndexedValue">True</s:Boolean>
@ -98,5 +100,6 @@
<s:Boolean x:Key="/Default/Housekeeping/RiderFindResultsBrowser/GroupingRules/=Type/@EntryIndexedValue">True</s:Boolean>
<s:String x:Key="/Default/Profilers/Common/ConfigurationsStore/SelectedProfileConfigurationId/=Net/@EntryIndexedValue">2704f639-7a6d-4dc0-8931-351a3f933333</s:String>
<s:String x:Key="/Default/Profilers/Common/ConfigurationsStore/SelectedProfileConfigurationId/=NotSupported/@EntryIndexedValue">2704f639-7a6d-4dc0-8931-351a3f933333</s:String>
<s:Boolean x:Key="/Default/RiderDebugger/RiderRestoreDecompile/RestoreDecompileSetting/@EntryValue">False</s:Boolean>
<s:String x:Key="/Default/SnapshotsStore/CurrentStore/@EntryValue">/Users/laura/Library/Preferences/Rider2019.3/resharper-host/Sessions</s:String>
<s:Boolean x:Key="/Default/SymbolServers/RestoreDecompileSetting/@EntryValue">False</s:Boolean></wpf:ResourceDictionary>

835
tgcli/Command.cs Normal file
View file

@ -0,0 +1,835 @@
using System;
using System.Collections.Generic;
using System.Linq;
using TdLib;
using static tgcli.tgcli;
using static tgcli.Util;
namespace tgcli;
public abstract class Command {
public string trigger;
public string shortcut;
public string description;
public string syntax;
public int paramCount;
public abstract void Handler(List<string> inputParams);
protected Command(string trigger, string shortcut, string description, string syntax, int paramCount) {
this.trigger = trigger;
this.shortcut = shortcut;
this.description = description;
this.paramCount = paramCount;
this.syntax = syntax;
}
}
public static class CommandManager {
public static readonly List<Command> Commands = new() {
new ClearCommand(),
new CloseCommand(),
new EditCommand(),
new ReplyCommand(),
new MeCommand(),
new ReplyOffsetCommand(),
new ReplyDirectCommand(),
new HistoryCommand(),
new OpenCommand(),
new UnreadsCommand(),
new CloseUnreadCommand(),
new ListChatsCommand(),
new NewChatCommand(),
new ListSecretChatsCommand(),
new OpenSecretCommand(),
new OpenSecretDirectCommand(),
new NewSecretChatCommand(),
new CloseSecretChatCommand(),
new SearchUserCommand(),
//new AddContactCommand(),
new QuitCommand(),
new HelpCommand(),
new LogoutCommand(),
};
public static void HandleCommand(string input) {
var split = input.Split(" ").ToList();
var trigger = split.First();
var command = Commands.Find(p => p.trigger == trigger || p.shortcut == trigger);
if (command == null) {
lock (@lock)
messageQueue.Add($"{Ansi.Red}[tgcli] Invalid command. Check /help for all available commands.");
return;
}
split.RemoveAt(0);
if (command.paramCount == -1) {
command.Handler(split);
}
else if (split.Count == command.paramCount) {
command.Handler(split);
}
else {
lock (@lock)
messageQueue.Add($"{Ansi.Red}[tgcli] Invalid command syntax. Check /help for more information.");
}
}
}
public class OpenCommand : Command {
public OpenCommand() : base("o", "^O", "opens a chat. queries chat list", "<query>", -1) { }
public override void Handler(List<string> inputParams) {
if (inputParams.Count == 0) {
lock (@lock)
messageQueue.Add($"{Ansi.Red}[tgcli] Invalid command syntax. Check /help for more information.");
return;
}
var query = inputParams.Aggregate((current, param) => current + " " + param).Trim();
var chatId = SearchChatId(query);
if (chatId == 0)
return;
currentChatId = 0;
currentChatUserId = 0;
currentUserRead = false;
var chat = GetChat(chatId);
if (chat.Type is TdApi.ChatType.ChatTypePrivate privChat) {
currentChatUserId = privChat.UserId;
}
currentChatId = chat.Id;
chat.Title = TruncateString(chat.Title, 20);
prefix = $"[{chat.Title}";
lock (@lock) {
messageQueue.Add($"{Ansi.Yellow}[tgcli] Opening chat: {chat.Title}");
messageQueue.Add($"{Ansi.Yellow}" + $"[tgcli] You have {chat.UnreadCount} unread message" + $"{(chat.UnreadCount == 1 ? "." : "s.")}");
if (chat.UnreadCount >= 5) {
var capped = chat.UnreadCount > 50;
GetHistory(chatId, capped ? 50 : chat.UnreadCount).ForEach(AddMessageToQueue);
if (capped)
messageQueue.Add($"{Ansi.Yellow}[tgcli] " + $"Showing 50 of {chat.UnreadCount} unread messages.");
}
else if (chat.UnreadCount > 0) {
var unreads = GetHistory(chatId, chat.UnreadCount);
var rest = GetHistory(chatId, 5 - unreads.Count, unreads.First().Id);
rest.ForEach(AddMessageToQueue);
messageQueue.Add($"{Ansi.Yellow}[tgcli] ---UNREAD---");
unreads.ForEach(AddMessageToQueue);
}
else {
GetHistory(chatId).ForEach(AddMessageToQueue);
}
}
var history = GetHistory(currentChatId, 50);
if (history.Count != 0)
MarkRead(chat.Id, history.First().Id);
var last = history.LastOrDefault(p => p.IsOutgoing);
if (last == null) {
currentUserRead = true;
return;
}
lastMessage = last;
currentUserRead = IsMessageRead(last.ChatId, last.Id);
}
}
public class NewChatCommand : Command {
public NewChatCommand() : base("n", "", "starts a new chat.", "<username>", 1) { }
public override void Handler(List<string> inputParams) {
var chat = GetChatByUsernameGlobal(inputParams[0]);
if (chat == null) {
lock (@lock)
messageQueue.Add($"{Ansi.Red}[tgcli] User not found. Try /s <query> to find valid usernames.");
return;
}
currentChatId = 0;
currentChatUserId = 0;
currentUserRead = false;
if (chat.Type is TdApi.ChatType.ChatTypePrivate privChat) {
currentChatUserId = privChat.UserId;
}
currentChatId = chat.Id;
chat.Title = TruncateString(chat.Title, 20);
prefix = $"[{chat.Title}";
lock (@lock) {
messageQueue.Add($"{Ansi.Yellow}[tgcli] Opening chat: {chat.Title}");
messageQueue.Add($"{Ansi.Yellow}" + $"[tgcli] You have {chat.UnreadCount} unread message" + $"{(chat.UnreadCount == 1 ? "." : "s.")}");
if (chat.UnreadCount >= 5) {
var capped = chat.UnreadCount > 50;
GetHistory(chat.Id, capped ? 50 : chat.UnreadCount).ForEach(AddMessageToQueue);
if (capped)
messageQueue.Add($"{Ansi.Yellow}[tgcli] " + $"Showing 50 of {chat.UnreadCount} unread messages.");
}
else if (chat.UnreadCount > 0) {
var unreads = GetHistory(chat.Id, chat.UnreadCount);
var rest = GetHistory(chat.Id, 5 - unreads.Count, unreads.First().Id);
rest.ForEach(AddMessageToQueue);
messageQueue.Add($"{Ansi.Yellow}[tgcli] ---UNREAD---");
unreads.ForEach(AddMessageToQueue);
}
else {
GetHistory(chat.Id).ForEach(AddMessageToQueue);
}
}
var history = GetHistory(currentChatId, 50);
if (history.Count != 0)
MarkRead(chat.Id, history.First().Id);
var last = history.LastOrDefault(p => p.IsOutgoing);
if (last == null) {
currentUserRead = true;
return;
}
lastMessage = last;
currentUserRead = IsMessageRead(last.ChatId, last.Id);
}
}
public class CloseSecretChatCommand : Command {
public CloseSecretChatCommand() : base("cs", "", "closes a secret chat (permanently)", "", 0) { }
public override void Handler(List<string> _) {
if (currentChatId != 0 && GetChat(currentChatId).Type is TdApi.ChatType.ChatTypeSecret type) {
CloseSecretChat(type.SecretChatId);
DeleteChatHistory(currentChatId);
CommandManager.HandleCommand("c");
}
else {
lock (@lock)
messageQueue.Add($"{Ansi.Red}[tgcli] No secret chat selected, cannot continue.");
}
}
}
public class NewSecretChatCommand : Command {
public NewSecretChatCommand() : base("ns", "", "creates a new secret chat.", "<username>", 1) { }
public override void Handler(List<string> inputParams) {
var userId = GetUserIdByUsername(inputParams[0]);
if (userId == 0) {
lock (@lock)
messageQueue.Add($"{Ansi.Red}[tgcli] User not found. Try /s <query> to find valid usernames.");
return;
}
if (GetSecretChats().Count(p => ((TdApi.ChatType.ChatTypeSecret)p.Type).UserId == userId) > 0) {
lock (@lock)
messageQueue.Add($"{Ansi.Red}[tgcli] You already have a secret chat with the specified user.");
return;
}
var chat = CreateSecretChat(userId);
CommandManager.HandleCommand("osd " + chat.Id);
}
}
public class OpenSecretDirectCommand : Command {
public OpenSecretDirectCommand() : base("osd", "", "opens a secret chat by chat id", "<chat_id>", 1) { }
public override void Handler(List<string> inputParams) {
var id = inputParams[0];
if (!long.TryParse(id, out var chatId)) {
lock (@lock)
messageQueue.Add($"{Ansi.Red}[tgcli] Invalid chat id.");
return;
}
var chat = GetChat(chatId);
if (chat == null) {
lock (@lock)
messageQueue.Add($"{Ansi.Red}[tgcli] Invalid chat id.");
return;
}
TdApi.SecretChat secChat;
if (chat.Type is TdApi.ChatType.ChatTypeSecret secretChat) {
currentChatUserId = secretChat.UserId;
currentChatId = chat.Id;
currentUserRead = false;
secChat = GetSecretChat(secretChat.SecretChatId);
}
else {
lock (@lock)
messageQueue.Add($"{Ansi.Red}[tgcli] The specified chat isn't a secret chat.");
return;
}
chat.Title = TruncateString(chat.Title, 20);
prefix = $"[{Ansi.Red}sec {Ansi.ResetAll}{chat.Title}]";
lock (@lock) {
messageQueue.Add($"{Ansi.Yellow}[tgcli] Opening secret chat: {chat.Title}");
messageQueue.Add($"{Ansi.Yellow}" + $"[tgcli] You have {chat.UnreadCount} unread message" + $"{(chat.UnreadCount == 1 ? "." : "s.")}");
if (secChat.State is TdApi.SecretChatState.SecretChatStateClosed) {
messageQueue.Add($"{Ansi.Red}[tgcli] Secret chat has ended. No messages can be sent.");
}
else if (secChat.State is TdApi.SecretChatState.SecretChatStatePending) {
messageQueue.Add($"{Ansi.Red}[tgcli] Secret chat is pending. No messages can be sent.");
}
if (chat.UnreadCount >= 5) {
var capped = chat.UnreadCount > 50;
GetHistory(chatId, capped ? 50 : chat.UnreadCount, isSecret: true).ForEach(AddMessageToQueue);
if (capped)
messageQueue.Add($"{Ansi.Yellow}[tgcli] " + $"Showing 50 of {chat.UnreadCount} unread messages.");
}
else if (chat.UnreadCount > 0) {
var unreads = GetHistory(chatId, chat.UnreadCount, isSecret: true);
var rest = GetHistory(chatId, 5 - unreads.Count, unreads.First().Id, isSecret: true);
rest.ForEach(AddMessageToQueue);
messageQueue.Add($"{Ansi.Yellow}[tgcli] ---UNREAD---");
unreads.ForEach(AddMessageToQueue);
}
else {
GetHistory(chatId, isSecret: true).ForEach(AddMessageToQueue);
}
}
var history = GetHistory(currentChatId, 50, isSecret: true);
if (history.Count != 0)
MarkRead(chat.Id, history.First().Id);
var last = history.LastOrDefault(p => p.IsOutgoing);
if (last == null) {
currentUserRead = true;
return;
}
lastMessage = last;
currentUserRead = IsMessageRead(last.ChatId, last.Id);
}
}
public class OpenSecretCommand : Command {
public OpenSecretCommand() : base("os", "", "opens a secret chat. queries chat list.", "<query>", -1) { }
public override void Handler(List<string> inputParams) {
if (inputParams.Count == 0) {
lock (@lock)
messageQueue.Add($"{Ansi.Red}[tgcli] No matching chat found.");
return;
}
var query = inputParams.Aggregate((current, param) => current + " " + param).Trim();
var userId = SearchUserInChats(query);
if (userId == 0 || query.Length == 0) {
lock (@lock)
messageQueue.Add($"{Ansi.Red}[tgcli] No matching chat found.");
return;
}
var chat = GetSecretChats().Find(p => ((TdApi.ChatType.ChatTypeSecret)p.Type).UserId == userId);
if (chat == null) {
lock (@lock)
messageQueue.Add($"{Ansi.Red}[tgcli] No matching secret chat found.");
return;
}
TdApi.SecretChat secChat;
if (chat.Type is TdApi.ChatType.ChatTypeSecret secretChat) {
currentChatUserId = secretChat.UserId;
currentChatId = chat.Id;
currentUserRead = false;
secChat = GetSecretChat(secretChat.SecretChatId);
}
else {
lock (@lock)
messageQueue.Add($"{Ansi.Red}[tgcli] No matching secret chat found. (this error should be impossible to produce)");
return;
}
chat.Title = TruncateString(chat.Title, 20);
prefix = $"[{Ansi.Red}sec {Ansi.ResetAll}{chat.Title}";
lock (@lock) {
messageQueue.Add($"{Ansi.Yellow}[tgcli] Opening secret chat: {chat.Title}");
messageQueue.Add($"{Ansi.Yellow}" + $"[tgcli] You have {chat.UnreadCount} unread message" + $"{(chat.UnreadCount == 1 ? "." : "s.")}");
if (secChat.State is TdApi.SecretChatState.SecretChatStateClosed) {
messageQueue.Add($"{Ansi.Red}[tgcli] Secret chat has ended. No messages can be sent.");
}
else if (secChat.State is TdApi.SecretChatState.SecretChatStatePending) {
messageQueue.Add($"{Ansi.Red}[tgcli] Secret chat is pending. No messages can be sent.");
}
if (chat.UnreadCount >= 5) {
var capped = chat.UnreadCount > 50;
GetHistory(chat.Id, capped ? 50 : chat.UnreadCount, isSecret: true).ForEach(AddMessageToQueue);
if (capped)
messageQueue.Add($"{Ansi.Yellow}[tgcli] " + $"Showing 50 of {chat.UnreadCount} unread messages.");
}
else if (chat.UnreadCount > 0) {
var unreads = GetHistory(chat.Id, chat.UnreadCount, isSecret: true);
var rest = GetHistory(chat.Id, 5 - unreads.Count, unreads.First().Id, isSecret: true);
rest.ForEach(AddMessageToQueue);
messageQueue.Add($"{Ansi.Yellow}[tgcli] ---UNREAD---");
unreads.ForEach(AddMessageToQueue);
}
else {
GetHistory(chat.Id, isSecret: true).ForEach(AddMessageToQueue);
}
}
var history = GetHistory(currentChatId, 50, isSecret: true);
if (history.Count != 0)
MarkRead(chat.Id, history.First().Id);
var last = history.LastOrDefault(p => p.IsOutgoing);
if (last == null) {
currentUserRead = true;
return;
}
lastMessage = last;
currentUserRead = IsMessageRead(last.ChatId, last.Id);
}
}
public class CloseUnreadCommand : Command {
public CloseUnreadCommand() : base("cu", "", "closes a chat, marking it as unread", "", 0) { }
public override void Handler(List<string> inputParams) {
if (currentChatId == 0) {
lock (@lock)
messageQueue.Add($"{Ansi.Red}[tgcli] No open chat, cannot continue.");
return;
}
MarkUnread(currentChatId);
CommandManager.HandleCommand("c");
}
}
public class CloseCommand : Command {
public CloseCommand() : base("c", "^D", "closes a chat", "", 0) { }
public override void Handler(List<string> inputParams) {
if (currentChatId == 0) {
lock (@lock)
messageQueue.Add($"{Ansi.Red}[tgcli] No open chat, cannot continue.");
return;
}
currentChatId = 0;
currentChatUserId = 0;
currentUserRead = false;
lastMessage = null;
prefix = "[tgcli";
lock (@lock) {
messageQueue.Add($"{Ansi.Yellow}[tgcli] Closing chat.");
var count = missedMessages.Count;
if (count == 0)
return;
messageQueue.Add($"{Ansi.Yellow}" + $"[tgcli] You have {count} missed message" + $"{(count == 1 ? "." : "s.")}");
messageQueue.AddRange(missedMessages);
missedMessages.Clear();
}
}
}
public class HistoryCommand : Command {
public HistoryCommand() : base("h", "", "shows chat history. default limit is 5", "[1-50]", -1) { }
public override void Handler(List<string> inputParams) {
if (currentChatId == 0) {
lock (@lock)
messageQueue.Add($"{Ansi.Red}[tgcli] No open chat, cannot continue.");
return;
}
List<TdApi.Message> history;
if (inputParams.Count == 1 && int.TryParse(inputParams[0], out var limit)) {
history = GetHistory(currentChatId, Math.Min(limit, 50));
while (limit > 50) {
limit -= 50;
history.InsertRange(0, GetHistory(currentChatId, Math.Min(limit, 50), history.First().Id));
}
}
else {
history = GetHistory(currentChatId);
}
lock (@lock) {
messageQueue.Add($"{Ansi.Yellow}[tgcli] Last {history.Count} messages in " + $"{GetChat(currentChatId).Title}");
}
foreach (var msg in history) {
AddMessageToQueue(msg);
}
}
}
public class ClearCommand : Command {
public ClearCommand() : base("cl", "^L", "clears console", "", 0) { }
public override void Handler(List<string> inputParams) {
lock (@lock) {
Console.Clear();
if (lockInputToBottom)
Console.SetCursorPosition(0, Console.LargestWindowHeight);
}
}
}
public class UnreadsCommand : Command {
public UnreadsCommand() : base("u", "", "displays unread chat", "[all]", -1) { }
public override void Handler(List<string> inputParams) {
var unreads = GetUnreadChats(inputParams.Count == 1 && inputParams[0].Equals("all"));
lock (@lock) {
messageQueue.Add($"{Ansi.Yellow}[tgcli] You have {unreads.Count} unread chats.");
unreads.ForEach(chat => {
string line;
if (chat.UnreadCount == 0)
line = $"{Ansi.Bold}{Ansi.Yellow}[M] {chat.Title}";
else if (chat.Type is TdApi.ChatType.ChatTypeSecret)
line = $"{Ansi.Bold}{Ansi.Red}[{chat.UnreadCount}] [sec] {chat.Title}";
else
line = $"{Ansi.Bold}{Ansi.Green}[{chat.UnreadCount}] {chat.Title}";
messageQueue.Add(line);
});
}
}
}
public class ListChatsCommand : Command {
public ListChatsCommand() : base("lc", "", "lists all chats, optionally filtered", "[query]", -1) { }
public override void Handler(List<string> inputParams) {
var chats = GetChats();
lock (@lock) {
if (inputParams.Count > 0) {
var query = inputParams.Aggregate((current, param) => current + " " + param).Trim();
chats = chats.FindAll(p => p.Title.ToLower().Contains(query.ToLower()));
}
messageQueue.Add($"{Ansi.Yellow}[tgcli] Listing {chats.Count} chats.");
chats.ForEach(chat => {
string line;
if (chat.UnreadCount == 0)
line = $"{Ansi.Bold}{Ansi.Blue}[0] {chat.Title}";
else
line = $"{Ansi.Bold}{Ansi.Green}[{chat.UnreadCount}] {chat.Title}";
messageQueue.Add(line);
});
}
}
}
public class SearchUserCommand : Command {
public SearchUserCommand() : base("s", "", "searches for users globally", "<query>", -1) { }
public override void Handler(List<string> inputParams) {
if (inputParams.Count == 0) {
lock (@lock)
messageQueue.Add($"{Ansi.Red}[tgcli] Invalid syntax, check /help for more information.");
return;
}
var query = inputParams.Aggregate((current, param) => current + " " + param).Trim();
var chats = SearchChatsGlobal(query);
chats = chats.FindAll(p => p.Type is TdApi.ChatType.ChatTypePrivate);
lock (@lock) {
messageQueue.Add($"{Ansi.Yellow}[tgcli] Listing {chats.Count} chats.");
chats.ForEach(chat => {
string line;
var type = (TdApi.ChatType.ChatTypePrivate)chat.Type;
var user = GetUser(type.UserId);
line = $"{Ansi.Bold}{Ansi.Yellow}@{user.Usernames.ActiveUsernames.First()} {Ansi.Magenta}{chat.Title}";
messageQueue.Add(line);
});
}
}
}
public class AddContactCommand : Command {
public AddContactCommand() : base("ac", "", "adds user to contact list", "<username>", 1) { }
public override void Handler(List<string> inputParams) {
/*
var query = inputParams[0];
var chat = GetChatByUsernameGlobal(query);
if (chat.Type is TdApi.ChatType.ChatTypePrivate type)
{
//TODO implement when TDLib 1.6 is released
}
else
{
lock (@lock)
messageQueue.Add($"{Ansi.Red}[tgcli] Username does not refer to a user.");
}
*/
}
}
public class ListSecretChatsCommand : Command {
public ListSecretChatsCommand() : base("ls", "", "displays all open secret chats", "", 0) { }
public override void Handler(List<string> inputParams) {
var secretChats = GetSecretChats();
lock (@lock) {
messageQueue.Add($"{Ansi.Yellow}[tgcli] Listing {secretChats.Count} secret chats:");
secretChats.ForEach(chat => {
messageQueue.Add($"{Ansi.Bold}{Ansi.Red}[sec] {chat.Title} -> {chat.Id} ({GetSecretChat(((TdApi.ChatType.ChatTypeSecret)chat.Type).SecretChatId).State.DataType})");
});
}
}
}
public class HelpCommand : Command {
public HelpCommand() : base("help", "", "lists all commands", "", 0) { }
public override void Handler(List<string> inputParams) {
lock (@lock) {
messageQueue.Add($"{Ansi.Yellow}[tgcli] Listing {CommandManager.Commands.Count} commands:");
CommandManager.Commands.ForEach(command => {
var commandText = $"/{command.trigger}";
if (!string.IsNullOrWhiteSpace(command.syntax))
commandText += $" {command.syntax}";
commandText += $": {command.description}";
if (!string.IsNullOrWhiteSpace(command.shortcut))
commandText += $" ({command.shortcut})";
messageQueue.Add($"{Ansi.Yellow}{commandText}");
});
}
}
}
public class QuitCommand : Command {
public QuitCommand() : base("q", "^D", "quits the program", "", 0) { }
public override void Handler(List<string> inputParams) {
quitting = true;
}
}
public class EditCommand : Command {
public EditCommand() : base("e", "", "edits last message. param empty adds last message to inputline", "[message]", -1) { }
public override void Handler(List<string> inputParams) {
try {
if (inputParams.Count == 0) {
SetInputLine("/e " + ((TdApi.MessageContent.MessageText)lastMessage?.Content)?.Text?.Text);
Emojis.ForEach(em => SetInputLine(currentInputLine.Replace(em.Item2, em.Item1)));
return;
}
var message = inputParams.Aggregate((current, param) => current + " " + param).Trim();
if (currentChatId == 0) {
lock (@lock)
messageQueue.Add($"{Ansi.Red}[tgcli] No open chat, cannot continue.");
return;
}
if (lastMessage == null) {
//try to find last message
var history = GetHistory(currentChatId, 50);
var last = history.LastOrDefault(p => p.IsOutgoing);
if (last == null) {
lock (@lock)
messageQueue.Add($"{Ansi.Red}[tgcli] No message to edit found, cannot continue.");
return;
}
lastMessage = last;
}
if (string.IsNullOrWhiteSpace(message)) {
lock (@lock)
messageQueue.Add($"{Ansi.Red}[tgcli] No message specified, cannot continue.");
return;
}
EditMessage(message, lastMessage);
}
catch {
lock (@lock)
messageQueue.Add($"{Ansi.Red}[tgcli] Unknown error editing message.");
}
}
}
public class MeCommand : Command {
public MeCommand() : base("me", "", "sends an action message", "<message>", -1) { }
public override void Handler(List<string> inputParams) {
try {
if (inputParams.Count == 0) {
lock (@lock)
messageQueue.Add($"{Ansi.Red}[tgcli] Invalid syntax, check /help for more information.");
return;
}
var message = inputParams.Aggregate((current, param) => current + " " + param).Trim();
if (currentChatId == 0) {
lock (@lock)
messageQueue.Add($"{Ansi.Red}[tgcli] No open chat, cannot continue.");
return;
}
if (string.IsNullOrWhiteSpace(message)) {
lock (@lock)
messageQueue.Add($"{Ansi.Red}[tgcli] No message specified, cannot continue.");
return;
}
var user = client.GetMeAsync().Result;
var username = user.Usernames?.ActiveUsernames?.FirstOrDefault() ?? user.FirstName;
SendMessage($"* {username} {message}", currentChatId);
}
catch {
lock (@lock)
messageQueue.Add($"{Ansi.Red}[tgcli] Unknown error sending action messsage.");
}
}
}
public class ReplyCommand : Command {
public ReplyCommand() : base("r", "", "matches message in history to reply to", "<message query>", -1) { }
public override void Handler(List<string> inputParams) {
try {
if (inputParams.Count == 0) {
lock (@lock)
messageQueue.Add($"{Ansi.Red}[tgcli] Invalid syntax, check /help for more information.");
return;
}
if (currentChatId == 0) {
lock (@lock)
messageQueue.Add($"{Ansi.Red}[tgcli] No open chat, cannot continue.");
return;
}
var history = GetHistory(currentChatId, 50);
history.Reverse();
var query = inputParams.Aggregate((current, param) => current + " " + param).Trim();
var result = history.Where(m => m.Content is TdApi.MessageContent.MessageText)
.FirstOrDefault(m => ((TdApi.MessageContent.MessageText)m.Content).Text.Text.ToLowerInvariant().Contains(query.ToLower()));
if (result == null) {
lock (@lock)
messageQueue.Add($"{Ansi.Red}[tgcli] No match found.");
return;
}
SetInputLine($"/rd {result.Id} ");
}
catch {
lock (@lock)
messageQueue.Add($"{Ansi.Red}[tgcli] Unknown error searching for message.");
}
}
}
public class ReplyOffsetCommand : Command {
public ReplyOffsetCommand() : base("ro", "", "replies to message", "<offset> <message>", -1) { }
public override void Handler(List<string> inputParams) {
try {
if (inputParams.Count < 2) {
lock (@lock)
messageQueue.Add($"{Ansi.Red}[tgcli] Invalid syntax, check /help for more information.");
return;
}
if (currentChatId == 0) {
lock (@lock)
messageQueue.Add($"{Ansi.Red}[tgcli] No open chat, cannot continue.");
return;
}
var history = GetHistory(currentChatId, 50);
var parsed = int.TryParse(inputParams[0], out var offset);
inputParams.RemoveAt(0);
history.Reverse();
var message = inputParams.Aggregate((current, param) => current + " " + param).Trim();
if (!parsed || string.IsNullOrWhiteSpace(message) || history.Count < offset) {
lock (@lock)
messageQueue.Add($"{Ansi.Red}Invalid syntax, check /help for more information.");
return;
}
var replyMessage = history[offset - 1];
SendMessage(message, currentChatId, replyMessage.Id);
}
catch {
lock (@lock)
messageQueue.Add($"{Ansi.Red}[tgcli] Unknown error sending message.");
}
}
}
public class ReplyDirectCommand : Command {
public ReplyDirectCommand() : base("rd", "", "replies to message by id", "<id> <message>", -1) { }
public override void Handler(List<string> inputParams) {
try {
if (inputParams.Count < 2) {
lock (@lock)
messageQueue.Add($"{Ansi.Red}[tgcli] Invalid syntax, check /help for more information.");
return;
}
if (currentChatId == 0) {
lock (@lock)
messageQueue.Add($"{Ansi.Red}[tgcli] No open chat, cannot continue.");
return;
}
var parsed = long.TryParse(inputParams[0], out var replyId);
inputParams.RemoveAt(0);
var message = inputParams.Aggregate((current, param) => current + " " + param).Trim();
if (!parsed || string.IsNullOrWhiteSpace(message)) {
lock (@lock)
messageQueue.Add($"{Ansi.Red}Invalid syntax, check /help for more information.");
return;
}
SendMessage(message, currentChatId, replyId);
}
catch {
lock (@lock)
messageQueue.Add($"{Ansi.Red}[tgcli] Unknown error sending message.");
}
}
}
public class LogoutCommand : Command {
public LogoutCommand() : base("logout", "", "log out this session (destroys all local data)", "", 0) { }
public override void Handler(List<string> inputParams) {
LogOut();
}
}

558
tgcli/Util.cs Normal file
View file

@ -0,0 +1,558 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading;
using NeoSmart.Unicode;
using static TdLib.TdApi;
using static tgcli.tgcli;
namespace tgcli;
public static class Util {
public static class Ansi {
public const string ResetAll = "\x1B[0m";
public const string Red = "\x1b[31m";
public const string Green = "\x1b[32m";
public const string Yellow = "\x1b[33m";
public const string Blue = "\x1b[34m";
public const string Magenta = "\x1b[35m";
public const string Cyan = "\x1b[36m";
public const string Bold = "\x1b[1m";
public const string BoldOff = "\x1b[22m";
public const string Inverse = "\x1b[7m";
public const string InverseOff = "\x1b[27m";
}
public static User GetUser(long uid) {
try {
var uinfo = client.ExecuteAsync(new GetUser { UserId = uid }).Result;
return uinfo;
}
catch {
var user = new User();
user.FirstName = "null";
user.LastName = "null";
return user;
}
}
public static Chat GetChat(long chatId) {
try {
return client.ExecuteAsync(new GetChat { ChatId = chatId }).Result;
}
catch {
return null;
}
}
public static User GetMe() {
return client.ExecuteAsync(new GetMe()).Result;
}
public static Message GetMessage(long chatId, long messageId) {
return client.ExecuteAsync(new GetMessage { ChatId = chatId, MessageId = messageId }).Result;
}
public static int GetTotalMessages(long chatId) {
try {
var response = client.ExecuteAsync(new SearchChatMessages { ChatId = chatId, Query = "+", Limit = 1 });
return response.Result.TotalCount;
}
catch {
return 9999;
}
}
public static List<Message> GetHistory(long chatId, int limit = 5, long fromMessageId = 0, int offset = 0, bool isSecret = false, bool skipTotal = false) {
var history = new List<Message>();
var total = GetTotalMessages(chatId);
var chat = GetChat(chatId);
if (chat.Type is ChatType.ChatTypeSupergroup || isSecret)
skipTotal = true;
if (limit > total && !skipTotal)
limit = total;
for (var i = 5; i > 0; i--) {
if (limit <= 0) {
if (total == 0)
return history;
lock (@lock)
messageQueue.Add($"{Ansi.Red}[tgcli] " + "Limit cannot be less than one. Usage: /history <count>");
return history;
}
var response = client.ExecuteAsync(new GetChatHistory {
ChatId = chatId,
FromMessageId = fromMessageId,
Limit = limit,
Offset = offset,
OnlyLocal = false
})
.Result;
if (response.Messages_.Length < limit && i > 1 && !isSecret) {
Thread.Sleep(100);
continue;
}
history.AddRange(response.Messages_);
history.Reverse();
return history;
}
return history;
}
public static bool IsMuted(Chat c) {
if (c.NotificationSettings.MuteFor == 0 && !c.NotificationSettings.UseDefaultMuteFor)
return false;
NotificationSettingsScope scope = c.Type switch {
ChatType.ChatTypeBasicGroup => new NotificationSettingsScope.NotificationSettingsScopeGroupChats(),
ChatType.ChatTypeSupergroup t => t.IsChannel
? new NotificationSettingsScope.NotificationSettingsScopeChannelChats()
: new NotificationSettingsScope.NotificationSettingsScopeGroupChats(),
ChatType.ChatTypePrivate => new NotificationSettingsScope.NotificationSettingsScopePrivateChats(),
ChatType.ChatTypeSecret => new NotificationSettingsScope.NotificationSettingsScopePrivateChats(),
_ => throw new ArgumentOutOfRangeException()
};
return client.GetScopeNotificationSettingsAsync(scope).Result.MuteFor != 0;
}
public static List<Chat> GetUnreadChats(bool all = false) {
var output = new List<Chat>();
var response = client.ExecuteAsync(new GetChats { Limit = int.MaxValue }).Result;
output.AddRange(all
? response.ChatIds.Select(GetChat).Where(c => c.UnreadCount > 0 || c.IsMarkedAsUnread).ToList()
: response.ChatIds.Select(GetChat).Where(c => (c.UnreadCount > 0 || c.IsMarkedAsUnread) && !IsMuted(c)).ToList());
return output;
}
public static List<Chat> GetChats() {
var response = client.ExecuteAsync(new GetChats { Limit = int.MaxValue }).Result;
return response.ChatIds.Select(GetChat).ToList();
}
public static List<Chat> SearchChatsGlobal(string query) {
if (query.TrimStart('@').Length < 5) {
return new List<Chat>();
}
var response = client.ExecuteAsync(new SearchPublicChats { Query = query }).Result;
var chats = response.ChatIds.Select(GetChat).ToList();
chats.AddRange(client.ExecuteAsync(new SearchChats { Query = query, Limit = int.MaxValue }).Result.ChatIds.Select(GetChat));
return chats;
}
public static Chat GetChatByUsernameGlobal(string username) {
try {
var response = client.ExecuteAsync(new SearchPublicChat { Username = username }).Result;
return response;
}
catch {
return null;
}
}
public static long GetUserIdByUsername(string username) {
try {
var response = client.ExecuteAsync(new SearchPublicChat { Username = username }).Result;
if (response.Type is ChatType.ChatTypePrivate priv)
return priv.UserId;
return 0;
}
catch {
return 0;
}
}
public static void AddUserToContacts(int userId, string name) {
//TODO implement when TDLib 1.6 is released
}
public static List<Chat> GetSecretChats() {
var response = client.ExecuteAsync(new GetChats { Limit = int.MaxValue }).Result;
return response.ChatIds.Select(GetChat).Where(c => c.Type is ChatType.ChatTypeSecret).ToList();
}
public static void CloseSecretChat(int secretChatId) {
client.ExecuteAsync(new CloseSecretChat { SecretChatId = secretChatId }).Wait();
}
public static Chat CreateSecretChat(long userId) {
return client.ExecuteAsync(new CreateNewSecretChat { UserId = userId }).Result;
}
public static void DeleteChatHistory(long chatId) {
client.ExecuteAsync(new DeleteChatHistory { ChatId = chatId, RemoveFromChatList = true, Revoke = true }).Wait();
}
public static SecretChat GetSecretChat(int secretChatId) {
var response = client.ExecuteAsync(new GetSecretChat { SecretChatId = secretChatId }).Result;
return response;
}
public static void ClearCurrentConsoleLine() {
Console.Write("\u001b[2K\r");
//Console.SetCursorPosition(0, Console.WindowHeight);
//Console.Write(new string(' ', Console.WindowWidth));
//Console.SetCursorPosition(0, Console.WindowHeight);
}
public static string ReadConsolePassword() {
var pass = "";
do {
var key = Console.ReadKey(true);
if (key.Key != ConsoleKey.Backspace && key.Key != ConsoleKey.Enter) {
pass += key.KeyChar;
Console.Write("*");
}
else {
if (key.Key == ConsoleKey.Backspace && pass.Length > 0) {
pass = pass[..^1];
Console.Write("\b \b");
}
else if (key.Key == ConsoleKey.Enter) {
break;
}
}
} while (true);
Console.WriteLine();
return pass;
}
public static void SendMessage(string message, long chatId, long replyTo = 0) {
if (string.IsNullOrWhiteSpace(message))
return;
Emojis.ForEach(em => message = message.Replace(em.Item1, em.Item2));
client.ExecuteAsync(new SendMessage {
ChatId = chatId, InputMessageContent = new InputMessageContent.InputMessageText { Text = new FormattedText { Text = message } }, ReplyToMessageId = replyTo,
});
currentUserRead = false;
}
public static Message EditMessage(string newText, Message message) {
Emojis.ForEach(em => newText = newText.Replace(em.Item1, em.Item2));
var msg = client.ExecuteAsync(new EditMessageText {
ChatId = message.ChatId,
MessageId = message.Id,
InputMessageContent = new InputMessageContent.InputMessageText { Text = new FormattedText { Text = newText } }
})
.Result;
return msg;
}
public static void MarkRead(long chatId, long messageId) {
client.ExecuteAsync(new ViewMessages { ChatId = chatId, MessageIds = new[] { messageId }, ForceRead = true });
}
public static void MarkUnread(long chatId) {
client.ExecuteAsync(new ToggleChatIsMarkedAsUnread { ChatId = chatId, IsMarkedAsUnread = true, });
}
public static long SearchChatId(string query) {
try {
var results = client.ExecuteAsync(new SearchChats { Query = query, Limit = 5 }).Result;
return query.StartsWith("@")
? results.ChatIds.First(p => GetChat(p).Type is ChatType.ChatTypePrivate type && GetUser(type.UserId).Usernames.ActiveUsernames.Contains(query[1..]))
: results.ChatIds.First(p => !(GetChat(p).Type is ChatType.ChatTypeSecret));
}
catch {
lock (@lock)
messageQueue.Add($"{Ansi.Red}[tgcli] No results found.");
return 0;
}
}
public static long SearchUserInChats(string query) {
var results = client.ExecuteAsync(new SearchChatsOnServer { Query = query, Limit = 5 }).Result;
if (results.ChatIds.Length == 0)
return 0;
var output = results.ChatIds.Select(GetChat).Where(p => p.Type is ChatType.ChatTypePrivate).Select(p => ((ChatType.ChatTypePrivate)p.Type).UserId);
return output.Any() ? output.First() : 0;
}
public static long SearchContacts(string query) {
//TODO implement when TDLib 1.6 is released
try {
var results = client.ExecuteAsync(new SearchContacts { Query = query, Limit = 5 }).Result;
return query.StartsWith("@") ? results.UserIds.First(p => GetUser(p).Usernames.ActiveUsernames.Contains(query[1..])) : results.UserIds.First();
}
catch {
lock (@lock)
messageQueue.Add($"{Ansi.Red}[tgcli] No results found.");
return 0;
}
}
public static void LogOut() {
lock (@lock)
messageQueue.Add($"{Ansi.Yellow}[tgcli] Logging out...");
client.ExecuteAsync(new LogOut()).Wait();
}
public static string GetFormattedUsername(MessageSender sender) {
return sender switch {
MessageSender.MessageSenderUser user => GetFormattedUsername(GetUser(user.UserId)),
MessageSender.MessageSenderChat chat => GetFormattedUsername(GetChat(chat.ChatId)),
_ => throw new InvalidCastException()
};
}
private static string GetFormattedUsername(User sender) {
var username = sender.Usernames?.ActiveUsernames?.FirstOrDefault();
if (string.IsNullOrWhiteSpace(username))
username = sender.FirstName + " " + sender.LastName;
else
username = "@" + username;
return username;
}
private static string GetFormattedUsername(Chat sender) {
return $"{sender.Title} [as chat]";
}
public static string FormatTime(long unix) {
var time = DateTimeOffset.FromUnixTimeSeconds(unix).DateTime.ToLocalTime();
var currentTime = DateTime.Now.ToLocalTime();
return time.ToString(time.Date.Ticks == currentTime.Date.Ticks ? "HH:mm" : "yyyy-MM-dd HH:mm");
}
public static bool IsMessageRead(long chatId, long messageId) {
var chat = GetChat(chatId);
return chat.LastReadOutboxMessageId >= messageId;
}
public static int GetActualStringWidth(string input) {
input = input.Replace(Ansi.Blue, "");
input = input.Replace(Ansi.Bold, "");
input = input.Replace(Ansi.Cyan, "");
input = input.Replace(Ansi.Green, "");
input = input.Replace(Ansi.Magenta, "");
input = input.Replace(Ansi.Red, "");
input = input.Replace(Ansi.Yellow, "");
input = input.Replace(Ansi.Bold, "");
input = input.Replace(Ansi.BoldOff, "");
input = input.Replace(Ansi.Inverse, "");
input = input.Replace(Ansi.InverseOff, "");
input = input.Replace(Ansi.ResetAll, "");
return input.Length;
}
public static string GetFormattedStatus(bool isRead) {
var output = " ";
output += (isRead ? Ansi.Green : Ansi.Red) + "r";
return output + $"{Ansi.ResetAll}]";
}
public static string TruncateString(string input, int maxLen, string truncateMarker = "~") {
if (maxLen < 2)
maxLen = 2;
return input.Length <= maxLen ? input : input[..(maxLen - 1)] + truncateMarker;
}
public static (string messageBuffer, int relCursorPos) GetPagedMessageInputLine(string message, int absCursorPos, int bufferWidth) {
const int wrapdOffsetPre = 2; // number of "untouchable" characters moving the cursor onto will cause a wrap on the right screen edge
const int wrapOffsetPost = 5; // number of "untouchable" characters moving the cursor onto will cause a wrap on the left screen edge
const int wrapOffsetPreI = wrapdOffsetPre + 1; // offset + 1 (indicator on the edge), for easier calculations
const int wrapOffsetPostI = wrapOffsetPost + 1; // offset + 1 (indicator on the edge), for easier calculations
if (absCursorPos > message.Length)
throw new ArgumentOutOfRangeException(nameof(absCursorPos), "Cursor position exceeds message length");
if (message.Length < bufferWidth) // entire message fits in buffer
return (message, absCursorPos); // return input as-is
if (absCursorPos < bufferWidth - wrapdOffsetPre - 1) // message is longer than buffer but we're on the first page
return (TruncateString(message, bufferWidth, $"{Ansi.Inverse}>{Ansi.InverseOff}"), absCursorPos); // return input as-is but truncated and with a > indicator
var wraps = (absCursorPos - wrapOffsetPostI) / (bufferWidth - wrapOffsetPreI - wrapOffsetPostI); // black magic
var finalCursorPos = absCursorPos - bufferWidth + wrapOffsetPreI + wrapOffsetPostI * wraps; // respect the special case of the first page & add one post offset per wrap
finalCursorPos %= bufferWidth - wrapOffsetPreI; // make sure the final cursor position is within the acceptable range (between zero and bufWidth - wrapOffsetPreI)
var messageOffset = (bufferWidth - wrapOffsetPreI - wrapOffsetPostI) * wraps + 1; // +1 to account for the first wrap not having a < indicator
var finalMessage = message[messageOffset..]; // we only care about the message starting from the current page
finalMessage = TruncateString(finalMessage, bufferWidth - 1, $"{Ansi.Inverse}>{Ansi.InverseOff}"); // replace the last character with a > indicator if required
return ($"{Ansi.Inverse}<{Ansi.InverseOff}" + finalMessage, finalCursorPos);
}
public static readonly List<Tuple<string, string>> Emojis = new() {
new Tuple<string, string>("⏎ ", "\n"),
new Tuple<string, string>(":xd:", Emoji.FaceWithTearsOfJoy.Sequence.AsString),
new Tuple<string, string>(":check:", Emoji.CheckMark.Sequence.AsString),
new Tuple<string, string>(":thinking:", Emoji.ThinkingFace.Sequence.AsString),
new Tuple<string, string>(":eyes:", Emoji.Eyes.Sequence.AsString),
new Tuple<string, string>(":heart:", Emoji.RedHeart.Sequence.AsString),
new Tuple<string, string>(":shrug:", Emoji.PersonShrugging.Sequence.AsString),
new Tuple<string, string>(":shrugf:", Emoji.WomanShrugging.Sequence.AsString),
new Tuple<string, string>(":shrugm:", Emoji.ManShrugging.Sequence.AsString)
};
public static void InsertToInputLine(string strToInsert) {
var part1 = currentInputLine[..currentInputPos];
var part2 = currentInputLine[currentInputPos..];
currentInputLine = part1 + strToInsert + part2;
currentInputPos += strToInsert.Length;
}
public static void SetInputLine(string newInputLine) {
currentInputLine = newInputLine;
currentInputPos = newInputLine.Length;
}
public static void RemoveFromInputLine(bool word = false) {
var part1 = currentInputLine[..currentInputPos];
var oldlen = part1.Length;
var part2 = currentInputLine[currentInputPos..];
if (word) {
var lastIndex = part1.TrimEnd().LastIndexOf(" ", StringComparison.Ordinal);
if (lastIndex < 0)
lastIndex = 0;
part1 = part1[..lastIndex];
if (lastIndex != 0)
part1 += " ";
//if (part1.EndsWith("⏎"))
// part1 = part1.Remove(part1.Length - 1);
var newlen = part1.Length;
currentInputLine = part1 + part2;
currentInputPos -= oldlen - newlen;
return;
}
currentInputLine = part1[..^1] + part2;
currentInputPos--;
}
public static void RemoveFromInputLineForward(bool word = false) {
var part1 = currentInputLine[..currentInputPos];
var part2 = currentInputLine[currentInputPos..].TrimStart();
if (word) {
var index = part2.IndexOf(" ", StringComparison.Ordinal);
if (index < 0)
index = part2.Length - 1;
part2 = part2[(index + 1)..];
if (index != 0)
part2 = " " + part2;
//if (part2.StartsWith("⏎"))
// part2 = part2.Remove(part1.Length - 1);
currentInputLine = part1 + part2;
return;
}
currentInputLine = part1 + part2[1..];
}
public static readonly List<ConsoleKey> SpecialKeys = new() {
ConsoleKey.Backspace,
ConsoleKey.Tab,
ConsoleKey.Clear,
ConsoleKey.Enter,
ConsoleKey.Pause,
ConsoleKey.Escape,
ConsoleKey.PageUp,
ConsoleKey.PageDown,
ConsoleKey.End,
ConsoleKey.Home,
ConsoleKey.LeftArrow,
ConsoleKey.UpArrow,
ConsoleKey.RightArrow,
ConsoleKey.DownArrow,
ConsoleKey.Select,
ConsoleKey.Print,
ConsoleKey.Execute,
ConsoleKey.PrintScreen,
ConsoleKey.Insert,
ConsoleKey.Delete,
ConsoleKey.Help,
ConsoleKey.LeftWindows,
ConsoleKey.RightWindows,
ConsoleKey.Applications,
ConsoleKey.Sleep,
ConsoleKey.F1,
ConsoleKey.F2,
ConsoleKey.F3,
ConsoleKey.F4,
ConsoleKey.F5,
ConsoleKey.F6,
ConsoleKey.F7,
ConsoleKey.F8,
ConsoleKey.F9,
ConsoleKey.F10,
ConsoleKey.F11,
ConsoleKey.F12,
ConsoleKey.F13,
ConsoleKey.F14,
ConsoleKey.F15,
ConsoleKey.F16,
ConsoleKey.F17,
ConsoleKey.F18,
ConsoleKey.F19,
ConsoleKey.F20,
ConsoleKey.F21,
ConsoleKey.F22,
ConsoleKey.F23,
ConsoleKey.F24,
ConsoleKey.BrowserBack,
ConsoleKey.BrowserForward,
ConsoleKey.BrowserRefresh,
ConsoleKey.BrowserStop,
ConsoleKey.BrowserSearch,
ConsoleKey.BrowserFavorites,
ConsoleKey.BrowserHome,
ConsoleKey.VolumeMute,
ConsoleKey.VolumeDown,
ConsoleKey.VolumeUp,
ConsoleKey.MediaNext,
ConsoleKey.MediaPrevious,
ConsoleKey.MediaStop,
ConsoleKey.MediaPlay,
ConsoleKey.LaunchMail,
ConsoleKey.LaunchMediaSelect,
ConsoleKey.LaunchApp1,
ConsoleKey.LaunchApp2,
ConsoleKey.Oem1,
ConsoleKey.Oem2,
ConsoleKey.Oem3,
ConsoleKey.Oem4,
ConsoleKey.Oem5,
ConsoleKey.Oem6,
ConsoleKey.Oem7,
ConsoleKey.Oem8,
ConsoleKey.Oem102,
ConsoleKey.Process,
ConsoleKey.Packet,
ConsoleKey.Attention,
ConsoleKey.CrSel,
ConsoleKey.ExSel,
ConsoleKey.EraseEndOfFile,
ConsoleKey.Play,
ConsoleKey.Zoom,
ConsoleKey.NoName,
ConsoleKey.Pa1,
ConsoleKey.OemClear
};
}

636
tgcli/tgcli.cs Normal file
View file

@ -0,0 +1,636 @@
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Runtime.InteropServices;
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 const string version = "0.4a";
public static volatile bool lockInputToBottom;
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<string> messageQueue = new();
public static volatile List<string> 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.Contains("-s"))
silent = true;
if (args.Contains("-l"))
lockInputToBottom = true;
if (args.Contains("-h") || args.Contains("-?") || args.Contains("--help")) {
Console.WriteLine($"""
tgcli v{version}
Laura Hausmann <laura@hausmann.dev>
Source Code: https://git.ztn.sh/zotan/tgcli
Arguments:
-s Silent mode. Disables terminal bell on new message.
-l Locks input line to bottom, like in an IRC client.
""");
return;
}
dbdir = RuntimeInformation.IsOSPlatform(OSPlatform.Windows)
? $"{Environment.GetFolderPath(Environment.SpecialFolder.UserProfile)}{Path.DirectorySeparatorChar}.tgcli"
: $"{Environment.GetFolderPath(Environment.SpecialFolder.UserProfile)}{Path.DirectorySeparatorChar}.local{Path.DirectorySeparatorChar}share{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);
}
if (lockInputToBottom)
Console.SetCursorPosition(0, Console.LargestWindowHeight);
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 <query>");
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++;
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;
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 = version,
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();
}
}

15
tgcli/tgcli.csproj Normal file
View file

@ -0,0 +1,15 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFramework>net70</TargetFramework>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="TDLib" Version="1.8.9" />
<PackageReference Include="tdlib.native" Version="1.8.9" />
<PackageReference Include="tdlib.native.osx.arm64" Version="1.8.9" />
<PackageReference Include="Unicode.net" Version="2.0.0" />
</ItemGroup>
</Project>