From 3f768ff00f26596ce60d7d86e7aa3e5dcfe35fb1 Mon Sep 17 00:00:00 2001 From: Ari Archer Date: Thu, 2 Dec 2021 19:40:19 +0200 Subject: [PATCH] TODOs, improved README and in general code improvements. - Added upgrading instructions in README - Improved installation instructions - Better keymapping support (more customisable) - More low-level customisation - Proper daemon initialisation - Completed some TODOs in code and in README - Made input design more consistant - Custom extensions - Removed/renabled useless variables - Fixed bugs with update handlers - Fixed requirement formatting and versioning --- BUGS.md | 15 ++ README.md | 44 +++- arigram/__main__.py | 3 +- arigram/config.py | 20 +- arigram/controllers.py | 83 +++++-- arigram/exceptions.py | 4 + arigram/models.py | 2 +- arigram/tdlib.py | 495 +++++++++++++++++++++++-------------- arigram/update_handlers.py | 45 ++-- arigram/utils.py | 2 + arigram/views.py | 15 +- pyproject.toml | 1 + requirements.txt | 4 +- 13 files changed, 477 insertions(+), 256 deletions(-) create mode 100644 BUGS.md create mode 100644 arigram/exceptions.py diff --git a/BUGS.md b/BUGS.md new file mode 100644 index 0000000..5ded34f --- /dev/null +++ b/BUGS.md @@ -0,0 +1,15 @@ +# Bugs +A place of important bugs to fix outside of issues + +## States +- 0 -- unsolved +- 1 -- solved (dev build) +- 2 -- solved (release) +- 3 -- won't solve +- 4 -- intended +- 5 -- critical + +| `state` | `issue` | `extra_info` | `id` | +| :---------: | :---------------------------------------------------------------------: | :-----------------------------------------------: | :----------: | +| 0 | When pinning chats the UI does not update until arigram is quit | | 0x0000000000 | + diff --git a/README.md b/README.md index 447c517..d827477 100644 --- a/README.md +++ b/README.md @@ -7,12 +7,12 @@ A fork of [tg](https://github.com/paul-nameless/tg) -- a hackable telegram TUI c ## Features -- [X] view mediafiles: photo, video, voice/video notes, documents +- [X] view media: photo, video, voice/video notes, documents - [X] ability to send pictures, documents, audio, video - [X] reply, edit, forward, delete, send messages - [X] stickers - [X] notifications -- [X] record and send voice msgs +- [X] record and send voice messages - [X] auto download files - [X] toggle chats: pin/unpin, mark as read/unread, mute/unmute - [X] message history @@ -22,8 +22,15 @@ A fork of [tg](https://github.com/paul-nameless/tg) -- a hackable telegram TUI c - [x] automation - [x] better default file picker - [x] custom keybindings +- [x] consistent styling +- [ ] drafts +- [ ] scheduled messages +- [ ] local passwords +- [ ] debug mode +- [ ] modules/addons - [ ] stickers (sticker keyboard) -- [ ] search +- [ ] less crowded UI +- [ ] search (for users) - [ ] bots (bot keyboard) @@ -66,12 +73,34 @@ brew install arigram This option is recommended: ```sh +mkdir ~/.local/src +cd ~/.local/src git clone https://github.com/TruncatedDinosour/arigram.git cd arigram pip install --upgrade --user -r requirements.txt pip install --upgrade --user . ``` +## Upgrading + +### Homebrew + +```sh +brew upgrade +``` + +### From sources + +This option is recommended: + +```sh +cd ~/.local/src/arigram +git reset --hard # This discards every change you made +git pull +pip install --upgrade --user -r requirements.txt +pip install --upgrade --user . +``` + And add this to `~/.bashrc` or whatever POSIX complient shell you use: ```sh @@ -227,10 +256,17 @@ def send_hello(ctrl, *args) -> None: ctrl.model.send_message(text=f"Hello people!") # Sends a message # CUSTOM_KEYBINDS = {"KEY": {"func": SOME_FUNCTION, "handler": CONTEXT_HANDLER}} -CUSTOM_KEYBINDS = {"z": {"func": send_hello, "handler": msg_handler}} +CUSTOM_KEYBINDS = {"z": {"func": send_hello, "handler": msg_handler, "repeat": False, "is_remap": False}} # What to add before file picker (while using fzf (default)) EXTRA_FILE_CHOOSER_PATHS = ["..", "/", "~"] + +# This is the max truncation limit when truncating paths, messages, etc. +TRUNCATE_LIMIT = 10 + +# If you set this to True this will automatically disable link previews +# WARNING: only do this if you know what you are doing, this is a dangerous option +EXTRA_TDLIB_HEADERS = {"disable_web_page_preview": True} ``` ### Mailcap file diff --git a/arigram/__main__.py b/arigram/__main__.py index c7fa1ef..0b1ab0b 100644 --- a/arigram/__main__.py +++ b/arigram/__main__.py @@ -38,7 +38,7 @@ def run(tg: Tdlib, stdscr: window) -> None: tg.add_update_handler(msg_type, partial(handler, controller)) thread = threading.Thread(target=controller.run) - thread.daemon = True + thread.setDaemon(True) thread.start() controller.load_custom_keybindings() @@ -60,6 +60,7 @@ def main() -> None: parse_args() utils.cleanup_cache() tg = Tdlib( + config.EXTRA_TDLIB_HEADEARS, api_id=config.API_ID, api_hash=config.API_HASH, phone=config.PHONE, diff --git a/arigram/config.py b/arigram/config.py index e168992..3cbb565 100644 --- a/arigram/config.py +++ b/arigram/config.py @@ -2,6 +2,7 @@ Every parameter (except for CONFIG_FILE) can be overwritten by external config file """ +import mailcap import os import platform import runpy @@ -9,6 +10,7 @@ from typing import Any, Dict, Optional _os_name = platform.system() _linux = "Linux" +_global_mailcap = mailcap.getcaps() CONFIG_DIR = os.path.expanduser("~/.config/arigram/") @@ -30,7 +32,6 @@ TDLIB_VERBOSITY = 0 MAX_DOWNLOAD_SIZE = "10MB" -# TODO: check platform NOTIFY_FUNCTION = None VIEW_TEXT_CMD = "less" @@ -46,9 +47,14 @@ else: "ffmpeg -f avfoundation -i ':0' -c:a libopus -b:a 32k {file_path}" ) -# TODO: use mailcap instead of editor + EDITOR = os.environ.get("EDITOR", "vim") -LONG_MSG_CMD = f"{EDITOR} -- {{file_path}}" +_, __MAILCAP_EDITOR = mailcap.findmatch(_global_mailcap, "text/markdown") + +if __MAILCAP_EDITOR: + EDITOR = str(__MAILCAP_EDITOR["view"]).split(" ", 1)[0] + +LONG_MSG_CMD = f"{EDITOR} '{{file_path}}'" if _os_name == _linux: DEFAULT_OPEN = "xdg-open {file_path}" @@ -83,6 +89,10 @@ EXTRA_FILE_CHOOSER_PATHS = ["..", "/", "~"] CUSTOM_KEYBINDS: Dict[str, Dict[str, Any]] = {} +TRUNCATE_LIMIT: int = 15 + +EXTRA_TDLIB_HEADEARS: Dict[Any, Any] = {} + if os.path.isfile(CONFIG_FILE): config_params = runpy.run_path(CONFIG_FILE) # type: ignore for param, value in config_params.items(): @@ -95,9 +105,9 @@ else: print( "Enter your phone number in international format, including country code (example: +5037754762346)" ) - PHONE = input("phone: ") + PHONE = input("(phone) ") if not PHONE.startswith("+"): PHONE = "+" + PHONE with open(CONFIG_FILE, "a") as f: - f.write(f"PHONE = '{PHONE}'\n") + f.write(f'\nPHONE = "{PHONE}"\n') diff --git a/arigram/controllers.py b/arigram/controllers.py index 36b61d7..4f75cd7 100644 --- a/arigram/controllers.py +++ b/arigram/controllers.py @@ -30,6 +30,8 @@ from arigram.utils import ( ) from arigram.views import View +from arigram import exceptions as ag_exception + log = logging.getLogger(__name__) # start scrolling to next page when number of the msgs left is less than value. @@ -48,6 +50,7 @@ def bind( binding: Dict[str, HandlerType], keys: List[str], repeat_factor: bool = False, + remap: bool = False, ) -> Callable: """bind handlers to given keys""" @@ -61,9 +64,20 @@ def bind( return fun(self) for key in keys: - assert ( - key not in binding - ), f"Key {key} already binded to {binding[key]}" + if key in binding and not remap: + val = binding.get(key) + val_mem = hex(id(val)) + + if val is None: + log.error( + f"Most likely a bug: {key} is bound to {val} (in {val_mem}), this should not happen" + ) + message = f"Key {key} already bound to {val} (in {val_mem})" + + log.error(message) + + raise ag_exception.KeyBoundError(message) + binding[key] = fun if repeat_factor else _no_repeat_factor # type: ignore return wrapper @@ -289,7 +303,7 @@ class Controller: if chat_id is None: return reply_to_msg = self.model.current_msg_id - if msg := self.view.status.get_input(): + if msg := self.view.status.get_input("reply"): self.model.view_all_msgs() self.tg.reply_message(chat_id, reply_to_msg, msg) self.present_info("Message reply sent") @@ -327,7 +341,7 @@ class Controller: self.present_info("Can't send msg in this chat") return self.tg.send_chat_action(chat_id, ChatAction.chatActionTyping) - if msg := self.view.status.get_input(): + if msg := self.view.status.get_input("message"): self.model.send_message(text=msg) self.present_info("Message sent") else: @@ -395,7 +409,7 @@ class Controller: with suspend(self.view) as s: resp = self.view.status.get_input( - f"Review <{file_path}>? [Y/n]: " + f"review <{file_path}>? (Y/n)" ) if resp is None: @@ -405,7 +419,7 @@ class Controller: sv.open_file(file_path, None) resp_up = self.view.status.get_input( - f"Upload <{file_path}>? [y/N]: " + f"upload <{file_path}>? (y/N)" ) if resp_up is None or is_no(resp_up): @@ -423,7 +437,7 @@ class Controller: mime = get_mime(file_path) if mime in ("image", "video", "animation"): resp = self.view.status.get_input( - f"Upload <{file_path}> compressed? [Y/n]: " + f"upload <{file_path}> compressed? (Y/n)" ) self.render_status() if resp is None: @@ -457,7 +471,7 @@ class Controller: @bind(msg_handler, ["sv"]) def send_video(self) -> None: """Enter file path and send compressed video""" - file_path = self.view.status.get_input() + file_path = self.view.status.get_input("file path") if not file_path or not os.path.isfile(file_path): return chat_id = self.model.chats.id_by_index(self.model.current_chat) @@ -474,7 +488,7 @@ class Controller: self, send_file_fun: Callable[[str, int], AsyncResult], ) -> None: - _input = self.view.status.get_input() + _input = self.view.status.get_input("file path") if _input is None: return file_path = os.path.expanduser(_input) @@ -495,7 +509,7 @@ class Controller: ) ) resp = self.view.status.get_input( - f"Do you want to send recording: {file_path}? [Y/n]" + f"do you want to send recording: <{file_path}>? (Y/n)" ) if resp is None or not is_yes(resp): return self.present_info("Voice message discarded") @@ -556,7 +570,7 @@ class Controller: def open_msg_with_cmd(self) -> None: """Open msg or file with cmd: less %s""" msg = MsgProxy(self.model.current_msg) - cmd = self.view.status.get_input() + cmd = self.view.status.get_input("command") if not cmd: return if "%s" not in cmd: @@ -569,6 +583,22 @@ class Controller: def open_current_msg(self) -> None: """Open msg or file with cmd in mailcap""" msg = MsgProxy(self.model.current_msg) + + extra = str( + msg.text_content + if msg.is_text + else msg.caption or msg.local_path or msg.msg_id + ) + + resp = self.view.status.get_input( + f"open <{os.path.basename(extra)[:config.TRUNCATE_LIMIT] + '...'}>? (Y/n)" + ) + if resp is None or not is_yes(resp): + self.present_info( + f"not opening message <{msg.msg_id}> from <{msg.sender_id}>" + ) + return + self._open_msg(msg) @bind(msg_handler, ["e"]) @@ -627,7 +657,7 @@ class Controller: user_ids = self._get_user_ids(is_multiple=True) if not user_ids: return - title = self.view.status.get_input("Group name: ") + title = self.view.status.get_input("group name") if title is None: return self.present_info("Cancelling creating group") if not title: @@ -647,7 +677,7 @@ class Controller: ChatType.channel, ): resp = self.view.status.get_input( - "Are you sure you want to leave this group/channel?[y/N]" + "are you sure you want to leave this group/channel? (y/N)" ) if is_no(resp or ""): return self.present_info("Not leaving group/channel") @@ -658,14 +688,14 @@ class Controller: return resp = self.view.status.get_input( - "Are you sure you want to delete the chat?[y/N]" + "are you sure you want to delete the chat? (y/N)" ) if is_no(resp or ""): return self.present_info("Not deleting chat") is_revoke = False if chat["can_be_deleted_for_all_users"]: - resp = self.view.status.get_input("Delete for all users?[y/N]") + resp = self.view.status.get_input("delete for all users? (y/N)") if resp is None: return self.present_info("Not deleting chat") self.render_status() @@ -698,7 +728,7 @@ class Controller: @bind(chat_handler, ["/"]) def search_contacts(self) -> None: """Search contacts and set jumps to it if found""" - msg = self.view.status.get_input("/") + msg = self.view.status.get_input("search") if not msg: return self.present_info("Search discarded") @@ -789,7 +819,7 @@ class Controller: def toggle_pin(self) -> None: chat = self.model.chats.chats[self.model.current_chat] chat_id = chat["id"] - toggle = not chat["is_pinned"] + toggle = not chat["positions"][0].get("is_pinned") self.tg.toggle_chat_is_pinned(chat_id, toggle) self.render() @@ -852,10 +882,10 @@ class Controller: log.exception("Error happened in draw loop") def present_error(self, msg: str) -> None: - return self.update_status("Error", msg) + return self.update_status("Error", msg.capitalize()) def present_info(self, msg: str) -> None: - return self.update_status("Info", msg) + return self.update_status("Info", msg.capitalize()) def update_status(self, level: str, msg: str) -> None: self.queue.put(partial(self._update_status, level, msg)) @@ -914,12 +944,11 @@ class Controller: return # TODO: handle cases when all chats muted on global level - if chat["notification_settings"]["mute_for"]: + if chat["notification_settings"]["mute_for"] or self.model.is_me( + msg["sender"].get("user_id") + ): return - # notify - if self.model.is_me(msg["sender"].get("user_id")): - return user = self.model.users.get_user(msg.sender_id) name = f"{user['first_name']} {user['last_name']}" @@ -946,8 +975,10 @@ class Controller: function = binding_info["func"] handler = binding_info["handler"] + repeat = binding_info.get("is_repeat", False) + remap = binding_info.get("is_remap", False) - @bind(handler, [binding]) + @bind(handler, [binding], repeat_factor=repeat, remap=remap) @rename_function(function_name) def handle(*args, fn: Callable = function) -> None: # type: ignore try: @@ -958,7 +989,7 @@ class Controller: ) log.info( - f"{function_name} = {function.__name__}() --> {binding}" + f"{function_name} = {function.__name__}() (repeat = {repeat}, remap = {remap}) --> {binding}" ) setattr( diff --git a/arigram/exceptions.py b/arigram/exceptions.py new file mode 100644 index 0000000..58fafab --- /dev/null +++ b/arigram/exceptions.py @@ -0,0 +1,4 @@ +class KeyBoundError(Exception): + """Exception to raise when a key is already bound""" + + pass diff --git a/arigram/models.py b/arigram/models.py index b5f946a..a816177 100644 --- a/arigram/models.py +++ b/arigram/models.py @@ -466,7 +466,7 @@ class ChatModel: ) def update_chat(self, chat_id: int, **updates: Dict[str, Any]) -> bool: - for i, chat in enumerate(self.chats): + for _, chat in enumerate(self.chats): if chat["id"] != chat_id: continue chat.update(updates) diff --git a/arigram/tdlib.py b/arigram/tdlib.py index 17b1023..4bd041a 100644 --- a/arigram/tdlib.py +++ b/arigram/tdlib.py @@ -56,6 +56,16 @@ class SecretChatState(Enum): class Tdlib(Telegram): + def __init__(self, extra_headers, *args, **kwargs) -> None: + self.extra_headers = extra_headers + super().__init__(*args, **kwargs) + + def _parse_data(self, data: Dict[Any, Any]) -> Dict[Any, Any]: + _tmp_data = data.copy() + _tmp_data.update(self.extra_headers) + + return _tmp_data + def parse_text_entities( self, text: str, @@ -63,11 +73,13 @@ class Tdlib(Telegram): version: int = 2, ) -> AsyncResult: """Offline synchronous method which returns parsed entities""" - data = { - "@type": "parseTextEntities", - "text": text, - "parse_mode": {"@type": parse_mode.name, "version": version}, - } + data = self._parse_data( + { + "@type": "parseTextEntities", + "text": text, + "parse_mode": {"@type": parse_mode.name, "version": version}, + } + ) return self._send_data(data) @@ -79,14 +91,16 @@ class Tdlib(Telegram): if not result.error: text = result.update - data = { - "@type": "sendMessage", - "chat_id": chat_id, - "input_message_content": { - "@type": "inputMessageText", - "text": text, - }, - } + data = self._parse_data( + { + "@type": "sendMessage", + "chat_id": chat_id, + "input_message_content": { + "@type": "inputMessageText", + "text": text, + }, + } + ) return self._send_data(data) @@ -98,77 +112,99 @@ class Tdlib(Telegram): limit: int = 0, synchronous: bool = False, ) -> None: - data = { - "@type": "downloadFile", - "file_id": file_id, - "priority": priority, - "offset": offset, - "limit": limit, - "synchronous": synchronous, - } + data = self._parse_data( + { + "@type": "downloadFile", + "file_id": file_id, + "priority": priority, + "offset": offset, + "limit": limit, + "synchronous": synchronous, + } + ) + return self._send_data(data) def reply_message( self, chat_id: int, reply_to_message_id: int, text: str ) -> AsyncResult: - data = { - "@type": "sendMessage", - "chat_id": chat_id, - "reply_to_message_id": reply_to_message_id, - "input_message_content": { - "@type": "inputMessageText", - "text": {"@type": "formattedText", "text": text}, - }, - } + data = self._parse_data( + { + "@type": "sendMessage", + "chat_id": chat_id, + "reply_to_message_id": reply_to_message_id, + "input_message_content": { + "@type": "inputMessageText", + "text": {"@type": "formattedText", "text": text}, + }, + } + ) return self._send_data(data) def search_contacts(self, target: str, limit: int = 10) -> AsyncResult: - data = {"@type": "searchChats", "query": target, "limit": limit} + data = self._parse_data( + {"@type": "searchChats", "query": target, "limit": limit} + ) return self._send_data(data, block=True) def send_doc(self, file_path: str, chat_id: int) -> AsyncResult: - data = { - "@type": "sendMessage", - "chat_id": chat_id, - "input_message_content": { - "@type": "inputMessageDocument", - "document": {"@type": "inputFileLocal", "path": file_path}, - }, - } + data = self._parse_data( + { + "@type": "sendMessage", + "chat_id": chat_id, + "input_message_content": { + "@type": "inputMessageDocument", + "document": {"@type": "inputFileLocal", "path": file_path}, + }, + } + ) + return self._send_data(data) def send_audio(self, file_path: str, chat_id: int) -> AsyncResult: - data = { - "@type": "sendMessage", - "chat_id": chat_id, - "input_message_content": { - "@type": "inputMessageAudio", - "audio": {"@type": "inputFileLocal", "path": file_path}, - }, - } + data = self._parse_data( + { + "@type": "sendMessage", + "chat_id": chat_id, + "input_message_content": { + "@type": "inputMessageAudio", + "audio": {"@type": "inputFileLocal", "path": file_path}, + }, + } + ) + return self._send_data(data) def send_animation(self, file_path: str, chat_id: int) -> AsyncResult: - data = { - "@type": "sendMessage", - "chat_id": chat_id, - "input_message_content": { - "@type": "inputMessageAnimation", - "animation": {"@type": "inputFileLocal", "path": file_path}, - }, - } + data = self._parse_data( + { + "@type": "sendMessage", + "chat_id": chat_id, + "input_message_content": { + "@type": "inputMessageAnimation", + "animation": { + "@type": "inputFileLocal", + "path": file_path, + }, + }, + } + ) + return self._send_data(data) def send_photo(self, file_path: str, chat_id: int) -> AsyncResult: - data = { - "@type": "sendMessage", - "chat_id": chat_id, - "input_message_content": { - "@type": "inputMessagePhoto", - "photo": {"@type": "inputFileLocal", "path": file_path}, - }, - } + data = self._parse_data( + { + "@type": "sendMessage", + "chat_id": chat_id, + "input_message_content": { + "@type": "inputMessagePhoto", + "photo": {"@type": "inputFileLocal", "path": file_path}, + }, + } + ) + return self._send_data(data) def send_video( @@ -179,97 +215,124 @@ class Tdlib(Telegram): height: int, duration: int, ) -> AsyncResult: - data = { - "@type": "sendMessage", - "chat_id": chat_id, - "input_message_content": { - "@type": "inputMessageVideo", - "width": width, - "height": height, - "duration": duration, - "video": {"@type": "inputFileLocal", "path": file_path}, - }, - } + data = self._parse_data( + { + "@type": "sendMessage", + "chat_id": chat_id, + "input_message_content": { + "@type": "inputMessageVideo", + "width": width, + "height": height, + "duration": duration, + "video": {"@type": "inputFileLocal", "path": file_path}, + }, + } + ) + return self._send_data(data) def send_voice( self, file_path: str, chat_id: int, duration: int, waveform: str ) -> AsyncResult: - data = { - "@type": "sendMessage", - "chat_id": chat_id, - "input_message_content": { - "@type": "inputMessageVoiceNote", - "duration": duration, - "waveform": waveform, - "voice_note": {"@type": "inputFileLocal", "path": file_path}, - }, - } + data = self._parse_data( + { + "@type": "sendMessage", + "chat_id": chat_id, + "input_message_content": { + "@type": "inputMessageVoiceNote", + "duration": duration, + "waveform": waveform, + "voice_note": { + "@type": "inputFileLocal", + "path": file_path, + }, + }, + } + ) + return self._send_data(data) def edit_message_text( self, chat_id: int, message_id: int, text: str ) -> AsyncResult: - data = { - "@type": "editMessageText", - "message_id": message_id, - "chat_id": chat_id, - "input_message_content": { - "@type": "inputMessageText", - "text": {"@type": "formattedText", "text": text}, - }, - } + data = self._parse_data( + { + "@type": "editMessageText", + "message_id": message_id, + "chat_id": chat_id, + "input_message_content": { + "@type": "inputMessageText", + "text": {"@type": "formattedText", "text": text}, + }, + } + ) + return self._send_data(data) def toggle_chat_is_marked_as_unread( self, chat_id: int, is_marked_as_unread: bool ) -> AsyncResult: - data = { - "@type": "toggleChatIsMarkedAsUnread", - "chat_id": chat_id, - "is_marked_as_unread": is_marked_as_unread, - } + data = self._parse_data( + { + "@type": "toggleChatIsMarkedAsUnread", + "chat_id": chat_id, + "is_marked_as_unread": is_marked_as_unread, + } + ) + return self._send_data(data) def toggle_chat_is_pinned( self, chat_id: int, is_pinned: bool ) -> AsyncResult: - data = { - "@type": "toggleChatIsPinned", - "chat_id": chat_id, - "is_pinned": is_pinned, - } + data = self._parse_data( + { + "@type": "toggleChatIsPinned", + "chat_id": chat_id, + "is_pinned": is_pinned, + } + ) + return self._send_data(data) def set_chat_nottification_settings( self, chat_id: int, notification_settings: dict ) -> AsyncResult: - data = { - "@type": "setChatNotificationSettings", - "chat_id": chat_id, - "notification_settings": notification_settings, - } + data = self._parse_data( + { + "@type": "setChatNotificationSettings", + "chat_id": chat_id, + "notification_settings": notification_settings, + } + ) + return self._send_data(data) def view_messages( self, chat_id: int, message_ids: list, force_read: bool = True ) -> AsyncResult: - data = { - "@type": "viewMessages", - "chat_id": chat_id, - "message_ids": message_ids, - "force_read": force_read, - } + data = self._parse_data( + { + "@type": "viewMessages", + "chat_id": chat_id, + "message_ids": message_ids, + "force_read": force_read, + } + ) + return self._send_data(data) def open_message_content( self, chat_id: int, message_id: int ) -> AsyncResult: - data = { - "@type": "openMessageContent", - "chat_id": chat_id, - "message_id": message_id, - } + data = self._parse_data( + { + "@type": "openMessageContent", + "chat_id": chat_id, + "message_id": message_id, + } + ) + return self._send_data(data) def forward_messages( @@ -282,120 +345,159 @@ class Tdlib(Telegram): remove_caption: bool = False, options: Dict[str, Any] = {}, ) -> AsyncResult: - data = { - "@type": "forwardMessages", - "chat_id": chat_id, - "from_chat_id": from_chat_id, - "message_ids": message_ids, - "as_album": as_album, - "send_copy": send_copy, - "remove_caption": remove_caption, - "options": options, - } + data = self._parse_data( + { + "@type": "forwardMessages", + "chat_id": chat_id, + "from_chat_id": from_chat_id, + "message_ids": message_ids, + "as_album": as_album, + "send_copy": send_copy, + "remove_caption": remove_caption, + "options": options, + } + ) + return self._send_data(data) def get_basic_group( self, basic_group_id: int, ) -> AsyncResult: - data = { - "@type": "getBasicGroup", - "basic_group_id": basic_group_id, - } + data = self._parse_data( + { + "@type": "getBasicGroup", + "basic_group_id": basic_group_id, + } + ) + return self._send_data(data) def get_basic_group_full_info( self, basic_group_id: int, ) -> AsyncResult: - data = { - "@type": "getBasicGroupFullInfo", - "basic_group_id": basic_group_id, - } + data = self._parse_data( + { + "@type": "getBasicGroupFullInfo", + "basic_group_id": basic_group_id, + } + ) + return self._send_data(data) def get_supergroup( self, supergroup_id: int, ) -> AsyncResult: - data = { - "@type": "getSupergroup", - "supergroup_id": supergroup_id, - } + data = self._parse_data( + { + "@type": "getSupergroup", + "supergroup_id": supergroup_id, + } + ) + return self._send_data(data) def get_supergroup_full_info( self, supergroup_id: int, ) -> AsyncResult: - data = { - "@type": "getSupergroupFullInfo", - "supergroup_id": supergroup_id, - } + data = self._parse_data( + { + "@type": "getSupergroupFullInfo", + "supergroup_id": supergroup_id, + } + ) + return self._send_data(data) def get_secret_chat( self, secret_chat_id: int, ) -> AsyncResult: - data = { - "@type": "getSecretChat", - "secret_chat_id": secret_chat_id, - } + data = self._parse_data( + { + "@type": "getSecretChat", + "secret_chat_id": secret_chat_id, + } + ) + return self._send_data(data) def send_chat_action( self, chat_id: int, action: ChatAction, progress: int = None ) -> AsyncResult: - data = { - "@type": "sendChatAction", - "chat_id": chat_id, - "action": {"@type": action.name, "progress": progress}, - } + data = self._parse_data( + { + "@type": "sendChatAction", + "chat_id": chat_id, + "action": {"@type": action.name, "progress": progress}, + } + ) + return self._send_data(data) def get_contacts(self) -> AsyncResult: - data = { - "@type": "getContacts", - } + data = self._parse_data( + { + "@type": "getContacts", + } + ) + return self._send_data(data) def leave_chat(self, chat_id: int) -> AsyncResult: - data = { - "@type": "leaveChat", - "chat_id": chat_id, - } + data = self._parse_data( + { + "@type": "leaveChat", + "chat_id": chat_id, + } + ) + return self._send_data(data) def join_chat(self, chat_id: int) -> AsyncResult: - data = { - "@type": "joinChat", - "chat_id": chat_id, - } + data = self._parse_data( + { + "@type": "joinChat", + "chat_id": chat_id, + } + ) + return self._send_data(data) def close_secret_chat(self, secret_chat_id: int) -> AsyncResult: - data = { - "@type": "closeSecretChat", - "secret_chat_id": secret_chat_id, - } + data = self._parse_data( + { + "@type": "closeSecretChat", + "secret_chat_id": secret_chat_id, + } + ) + return self._send_data(data) def create_new_secret_chat(self, user_id: int) -> AsyncResult: - data = { - "@type": "createNewSecretChat", - "user_id": user_id, - } + data = self._parse_data( + { + "@type": "createNewSecretChat", + "user_id": user_id, + } + ) + return self._send_data(data) def create_new_basic_group_chat( self, user_ids: List[int], title: str ) -> AsyncResult: - data = { - "@type": "createNewBasicGroupChat", - "user_ids": user_ids, - "title": title, - } + data = self._parse_data( + { + "@type": "createNewBasicGroupChat", + "user_ids": user_ids, + "title": title, + } + ) + return self._send_data(data) def delete_chat_history( @@ -404,26 +506,35 @@ class Tdlib(Telegram): """ revoke: Pass true to try to delete chat history for all users """ - data = { - "@type": "deleteChatHistory", - "chat_id": chat_id, - "remove_from_chat_list": remove_from_chat_list, - "revoke": revoke, - } + data = self._parse_data( + { + "@type": "deleteChatHistory", + "chat_id": chat_id, + "remove_from_chat_list": remove_from_chat_list, + "revoke": revoke, + } + ) + return self._send_data(data) def get_user(self, user_id: int) -> AsyncResult: - data = { - "@type": "getUser", - "user_id": user_id, - } + data = self._parse_data( + { + "@type": "getUser", + "user_id": user_id, + } + ) + return self._send_data(data) def get_user_full_info(self, user_id: int) -> AsyncResult: - data = { - "@type": "getUserFullInfo", - "user_id": user_id, - } + data = self._parse_data( + { + "@type": "getUserFullInfo", + "user_id": user_id, + } + ) + return self._send_data(data) diff --git a/arigram/update_handlers.py b/arigram/update_handlers.py index f16a98f..7d6218b 100644 --- a/arigram/update_handlers.py +++ b/arigram/update_handlers.py @@ -28,8 +28,11 @@ def update_handler( def wrapper(controller: Controller, update: Dict[str, Any]) -> None: try: fun(controller, update) - except Exception: - log.exception("Error happened in handler: %s", update_type) + except Exception as e: + log.exception( + f"Error happened in handler: {update_type}; {e.__class__.__name__}: {e}" + ) + exit() handlers[update_type] = wrapper return wrapper @@ -84,26 +87,12 @@ def update_new_message(controller: Controller, update: Dict[str, Any]) -> None: def update_chat_order(controller: Controller, update: Dict[str, Any]) -> None: current_chat_id = controller.model.current_chat_id chat_id = update["chat_id"] - order = update["order"] + order = update["position"]["order"] if controller.model.chats.update_chat(chat_id, order=order): controller.refresh_current_chat(current_chat_id) -@update_handler("updateChatPosition") -def update_chat_position( - controller: Controller, update: Dict[str, Any] -) -> None: - current_chat_id = controller.model.current_chat_id - chat_id = update["chat_id"] - info = {} - info["order"] = update["position"]["order"] - if "is_pinned" in update: - info["is_pinned"] = update["is_pinned"] - if controller.model.chats.update_chat(chat_id, **info): - controller.refresh_current_chat(current_chat_id) - - @update_handler("updateChatTitle") def update_chat_title(controller: Controller, update: Dict[str, Any]) -> None: chat_id = update["chat_id"] @@ -139,8 +128,8 @@ def update_chat_is_pinned( controller: Controller, update: Dict[str, Any] ) -> None: chat_id = update["chat_id"] - is_pinned = update["is_pinned"] - order = update["order"] + is_pinned = update["position"]["is_pinned"] + order = update["position"]["order"] current_chat_id = controller.model.current_chat_id if controller.model.chats.update_chat( @@ -149,6 +138,22 @@ def update_chat_is_pinned( controller.refresh_current_chat(current_chat_id) +@update_handler("updateChatPosition") +def update_chat_position( + controller: Controller, update: Dict[str, Any] +) -> None: + current_chat_id = controller.model.current_chat_id + chat_id = update["chat_id"] + info = {} + info["order"] = update["position"]["order"] + if update["position"]["is_pinned"]: + info["is_pinned"] = update["position"]["is_pinned"] + update_chat_is_pinned(controller, update) + if controller.model.chats.update_chat(chat_id, **info): + controller.refresh_current_chat(current_chat_id) + controller.render() + + @update_handler("updateChatReadOutbox") def update_chat_read_outbox( controller: Controller, update: Dict[str, Any] @@ -187,7 +192,7 @@ def update_chat_draft_message( chat_id = update["chat_id"] # FIXME: ignoring draft message itself for now because UI can't show it # draft_message = update["draft_message"] - order = update["order"] + order = update["position"]["order"] current_chat_id = controller.model.current_chat_id if controller.model.chats.update_chat(chat_id, order=order): diff --git a/arigram/utils.py b/arigram/utils.py index 8c68a27..2ffd636 100644 --- a/arigram/utils.py +++ b/arigram/utils.py @@ -110,6 +110,8 @@ def get_file_handler(file_path: str) -> str: caps = get_mailcap() handler, view = mailcap.findmatch(caps, mtype, filename=file_path) + + del view if not handler: return config.DEFAULT_OPEN.format(file_path=shlex.quote(file_path)) return handler diff --git a/arigram/views.py b/arigram/views.py index ed07239..e4851e7 100644 --- a/arigram/views.py +++ b/arigram/views.py @@ -137,19 +137,22 @@ class StatusView: self.win.addstr(0, 0, msg.replace("\n", " ")[: self.w]) self._refresh() - def get_input(self, prefix: str = "") -> Optional[str]: + def get_input(self, prefix: str = "input") -> Optional[str]: curses.curs_set(1) buff = "" try: while True: + prompt = f"({prefix}) " + self.win.erase() - line = buff[-(self.w - 1) :] - self.win.addstr(0, 0, f"{prefix}{line}") + line = buff[-(self.w - string_len_dwc(prompt) - 1) :] + self.win.addstr(0, 0, f"{prompt}{line}") key = self.win.get_wch( - 0, min(string_len_dwc(buff + prefix), self.w - 1) + 0, min(string_len_dwc(buff + prompt), self.w - 1) ) + key = ord(key) if key == 10: # return break @@ -179,6 +182,8 @@ class ChatView: self.model = model def resize(self, rows: int, cols: int, width: int) -> None: + del cols + self.h = rows - 1 self.w = width self.win.resize(self.h, self.w) @@ -294,7 +299,7 @@ class ChatView: if self.model.users.is_online(chat["id"]): flags.append("online") - if "is_pinned" in chat and chat["is_pinned"]: + if chat["positions"][0].get("is_pinned"): flags.append("pinned") if chat["notification_settings"]["mute_for"]: diff --git a/pyproject.toml b/pyproject.toml index ab7b8d8..7fae43f 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -12,6 +12,7 @@ repository = "https://github.com/TruncatedDinosour/arigram" [tool.poetry.dependencies] python = "^3.8" python-telegram = "0.14.0" +pyfzf = "^0.2.2" [tool.poetry.dev-dependencies] black = "20.8b1" diff --git a/requirements.txt b/requirements.txt index aa8d230..8fc5356 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,3 +1,3 @@ -python-telegram -pyfzf +python-telegram==0.14.0 +pyfzf>=0.2.2