commit 55eebc496ef83351ce43cec07575f0da0f8460c1 Author: Ari Archer Date: Sun Nov 28 15:56:03 2021 +0200 first commit diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml new file mode 100644 index 0000000..5e106c2 --- /dev/null +++ b/.github/workflows/main.yml @@ -0,0 +1,46 @@ +name: CI + +on: + push: + branches: [ main ] + pull_request: + branches: [ main ] + +jobs: + + build: + + runs-on: ubuntu-latest + strategy: + matrix: + python-version: [3.8] + + steps: + + - uses: actions/checkout@v2 + + - name: Set up Python ${{ matrix.python-version }} + uses: actions/setup-python@v1 + with: + python-version: ${{ matrix.python-version }} + + - name: Install tools + run: | + pip install poetry && poetry config virtualenvs.create false && poetry install --no-interaction --no-ansi + + - name: Check formatting and run linters + run: | + sh check.sh + + #- name: build and deploy master image to ghcr.io + # # if: ${{ github.ref == 'refs/heads/master' }} + # env: + # PKG_TOKEN: ${{ secrets.PKG_TOKEN }} + # USERNAME: ${{ github.actor }} + # run: | + # git_hash=$(git rev-parse --short "$GITHUB_SHA") + # git_branch="$(echo ${GITHUB_REF} | cut -d'/' -f3)" + # echo ${PKG_TOKEN} | docker login ghcr.io -u ${USERNAME} --password-stdin + # docker build -t ghcr.io/${USERNAME}/tg:${git_branch}-${git_hash} -t ghcr.io/${USERNAME}/tg:latest . + # docker push ghcr.io/${USERNAME}/tg:${git_branch}-${git_hash} + # docker push ghcr.io/${USERNAME}/tg:latest diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..510a54d --- /dev/null +++ b/.gitignore @@ -0,0 +1,14 @@ +.mypy_cache/ +venv/ +__pycache__ +.env +dist +*.log* +Makefile +.idea/ +.vim/ +*monkeytype.sqlite3 +.vscode/ +build/ +MANIFEST +arigram.egg-info/ diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..a77af11 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,15 @@ +FROM python:3.9.4-slim-buster + +WORKDIR /app + +ENV PYTHONPATH=/app + +RUN pip3 install --disable-pip-version-check --no-cache-dir poetry + +COPY poetry.lock pyproject.toml /app/ + +RUN poetry config virtualenvs.create false && poetry install --no-interaction --no-ansi --no-dev --no-root + +COPY . /app + +CMD python3 -m arigram diff --git a/README.md b/README.md new file mode 100644 index 0000000..5c7301c --- /dev/null +++ b/README.md @@ -0,0 +1,323 @@ +# Arigram + +A fork of [tg](https://github.com/paul-nameless/tg) -- a hackable telegram TUI client + +![tg screenshot](tg-screenshot.png) + + +## Features + +- [X] view mediafiles: 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] auto download files +- [X] toggle chats: pin/unpin, mark as read/unread, mute/unmute +- [X] message history +- [X] list contacts +- [X] show user status +- [X] secret chats +- [x] automation +- [x] better default file picker +- [x] custom keybindings +- [ ] stickers (sticker keyboard) +- [ ] search +- [ ] bots (bot keyboard) + + +## Requirements + +To use tg, you'll need to have the following installed: + +- [Python >= 3.8](https://www.python.org/downloads/) + + +## Optional dependencies + +- [terminal-notifier](https://github.com/julienXX/terminal-notifier) - for Mac (used by default). You can change it to [dunst](https://github.com/dunst-project/dunst) for Linux or any other notifications program (see `NOTIFY_CMD` in configuration) +- [ffmpeg](https://ffmpeg.org/) - to record voice msgs and upload videos. +- [tdlib](https://tdlib.github.io/td/build.html?language=Python) - in case of incompatibility with built in package. + For example, macOS: + ```sh + brew install tdlib + ``` + and then set in config `TDLIB_PATH` +- `urlview` to choose urls when there is multiple in message, use `URL_VIEW` in config file to use another app (it should accept urls in stdin) +- to open `stickers` and `animated` ones (thumbnail preview) you need to set in mailcap appropriate handler and have app which will open `webp` file: + ```ini + image/webp; mpv %s + ``` +- [ranger](https://github.com/ranger/ranger), [nnn](https://github.com/jarun/nnn), [fzf](https://github.com/junegunn/fzf) - can be used to choose file when sending, customizable with `FILE_PICKER_CMD` +- [fzf](https://github.com/junegunn/fzf) - to create groups and secret chats (used for single and multiple user selection) + +## Installation + +### Homebrew + +```sh +brew tap TruncatedDinosour/dino-bar +brew install arigram +``` + +### From sources + +This option is recommended: + +```sh +git clone https://github.com/TruncatedDinosour/arigram.git +cd git +pip install --upgrade --user -r requirements.txt +pip install . +``` + +And add this to `~/.bashrc` or whatever POSIX complient shell you use: + +```sh +export PATH="${PATH|}:${HOME}/.local/bin" +``` + +To Launch it + +```sh +arigram +``` + +## Configuration + +Config file should be stored at `~/.config/arigram/config.py`. This is simple python file. + +### Simple config: + +```python +# should start with + (plus) and contain country code +PHONE = "[phone number in international format]" +``` + +### Advanced configuration: + +All configurable variables can be found [here](https://github.com/paul-nameless/tg/blob/master/tg/config.py) + +```python +import os +from arigram.controllers import msg_handler +from plyer import notification +from arigram import config as tg_config +from simpleaudio import WaveObject +from threading import Thread + + +class Custom: + def _play_wav(self, wave_path: str) -> Thread: + def player() -> None: + wave_obj = WaveObject.from_wave_file(wave_path) + play_obj = wave_obj.play() + play_obj.wait_done() + + sound = Thread(target=player) + sound.setDaemon(True) + + return sound + + def _notify(self, title: str, message: str) -> Thread: + def notifier() -> None: + notification.notify( + app_name=f"arigram {arigram.__version__}", title=title, message=message + ) + + notif = Thread(target=notifier) + notif.setDaemon(True) + + return notif + + def notify(self, *args, **kwargs) -> None: + del args + + self._notify(str(kwargs.get("title")), str(kwargs.get("msg"))).start() + self._play_wav(f"{tg_config.CONFIG_DIR}resources/notification.wav").start() + + +# You can write anything you want here, file will be executed at start time +# You can keep you sensitive information in password managers or gpg +# encrypted files for example +def get_pass(key): + # retrieves key from password store + return os.popen("pass show {} | head -n 1".format(key)).read().strip() + +# Custom methods (doesn't need to be named "custom") +c = Custom() + + +PHONE = get_pass("i/telegram-phone") +# encrypt you local tdlib database with the key +ENC_KEY = get_pass("i/telegram-enc-key") + +# log level for debugging, info by default +LOG_LEVEL = "DEBUG" +# path where logs will be stored (all.log and error.log) +LOG_PATH = os.path.expanduser("~/.local/share/tg/") + +# If you have problems with tdlib shipped with the client, you can install and +# use your own, for example: +TDLIB_PATH = "/usr/local/Cellar/tdlib/1.6.0/lib/libtdjson.dylib" + +# A callback to notify a user, +# Arguments get passed in kwargs +NOTIFY_FUNCTION = c.notify + +# You can use your own voice recording cmd but it's better to use default one. +# The voice note must be encoded with the Opus codec, and stored inside an OGG +# container. Voice notes can have only a single audio channel. +VOICE_RECORD_CMD = "ffmpeg -f avfoundation -i ':0' -c:a libopus -b:a 32k {file_path}" + +# You can customize chat and msg flags however you want. +# By default words will be used for readability, but you can make +# it as simple as one letter flags like in mutt or add emojies +CHAT_FLAGS = { + "online": "●", + "pinned": "P", + "muted": "M", + # chat is marked as unread + "unread": "U", + # last msg haven't been seen by recipient + "unseen": "✓", + "secret": "🔒", + "seen": "✓✓", # leave empty if you don't want to see it +} +MSG_FLAGS = { + "selected": "*", + "forwarded": "F", + "new": "N", + "unseen": "U", + "edited": "E", + "pending": "...", + "failed": "💩", + "seen": "✓✓", # leave empty if you don't want to see it +} + +# use this app to open url when there are multiple +URL_VIEW = 'urlview' + +# Specifies range of colors to use for drawing users with +# different colors +# this one uses base 16 colors which should look good by default +USERS_COLORS = tuple(range(2, 16)) + +# to use 256 colors, set range appropriately +# though 233 looks better, because last colors are black and gray +# USERS_COLORS = tuple(range(233)) + +# to make one color for all users +# USERS_COLORS = (4,) + +# cleanup cache +# Values: N days, None (never) +KEEP_MEDIA = 7 + +FILE_PICKER_CMD = "ranger --choosefile={file_path}" +# FILE_PICKER_CMD = "nnn -p {file_path}" + +MAILCAP_FILE = os.path.expanduser("~/.config/mailcap") + +DOWNLOAD_DIR = os.path.expanduser("~/Downloads/") # copy file to this dir + +def send_hello(ctrl, *args) -> None: + # ctrl = the current Controller class instance + 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}} + +# What to add before file picker (while using fzf (default)) +EXTRA_FILE_CHOOSER_PATHS = ["..", "/", "~"] +``` + +### Mailcap file + +Mailcap file is used for deciding how to open telegram files (docs, pics, voice notes, etc.). Path to the file can be overriden with `MAILCAP_FILE` in config file. + +Example: `~/.mailcap` + +```ini +# media +video/*; mpv "%s" +audio/ogg; mpv --speed=1.33 "%s" +audio/mpeg; mpv --no-video "%s" +image/*; qview "%s" + +# text +text/html; w3m "%s" +text/html; open -a Firefox "%s" +text/plain; less "%s" + +# fallback to vim +text/*; vim "%s" +``` + + +## Keybindings + +vi like keybindings are used in the project. Can be used commands like `4j` - 4 lines down. + +For navigation arrow keys also can be used. + +### Chats: + +- `j,k`: move up/down +- `J,K`: move 10 chats up/down +- `g`: go to top chat +- `l`: open msgs of the chat +- `m`: mute/unmute current chat +- `p`: pin/unpin current chat +- `u`: mark read/unread +- `r`: read current chat +- `c`: show list of contacts +- `dd`: delete chat or remove history +- `ng`: create new group chat +- `ns`: create new secret chat +- `/`: search in chats +- `?`: show help + +## Msgs: + +- `j,k`: move up/down +- `J,K`: move 10 msgs up/down +- `G`: move to the last msg (at the bottom) +- `D`: download file +- `l`: if video, pics or audio then open app specified in mailcap file, for example: + ```ini + # Images + image/png; qView "%s" + audio/*; mpv "%s" + ``` + if text, open in `less` (to view multiline msgs) +- `e`: edit current msg +- ``: select msg and jump one msg down (use for deletion or forwarding) +- ``: same as space but jumps one msg up +- `y`: yank (copy) selected msgs with to internal buffer (for forwarding) and copy current msg text or path to file to clipboard +- `p`: forward (paste) yanked (copied) msgs to current chat +- `dd`: delete msg for everybody (multiple messages will be deleted if selected) +- `i or a`: insert mode, type new message +- `I or A`: open vim to write long msg and send +- `v`: record and send voice message +- `r,R`: reply to a current msg +- `sv`: send video +- `sa`: send audio +- `sp`: send picture +- `sd`: send document +- `o`: open url present in message (if multiple urls, `urlview` will be opened) +- `]`: next chat +- `[`: prev chat +- `u`: show user info (username, bio, phone, etc.) +- `c`: show chat info (e.g. secret chat encryption key, chat id, state, etc.) +- `?`: show help +- `!`: open msg with custom cmd + +## Publish + +Run script to automatically increase version and release + +```sh +./do release +``` diff --git a/UNLICENSE b/UNLICENSE new file mode 100644 index 0000000..51129ad --- /dev/null +++ b/UNLICENSE @@ -0,0 +1,26 @@ +UNLICENSE + +This is free and unencumbered software released into the public domain. + +Anyone is free to copy, modify, publish, use, compile, sell, or +distribute this software, either in source code form or as a compiled +binary, for any purpose, commercial or non-commercial, and by any +means. + +In jurisdictions that recognize copyright laws, the author or authors +of this software dedicate any and all copyright interest in the +software to the public domain. We make this dedication for the benefit +of the public at large and to the detriment of our heirs and +successors. We intend this dedication to be an overt act of +relinquishment in perpetuity of all present and future rights to this +software under copyright law. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. +IN NO EVENT SHALL THE AUTHORS BE LIABLE FOR ANY CLAIM, DAMAGES OR +OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, +ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +OTHER DEALINGS IN THE SOFTWARE. + +For more information, please refer to diff --git a/arigram-screenshot.png b/arigram-screenshot.png new file mode 100644 index 0000000..39437fa Binary files /dev/null and b/arigram-screenshot.png differ diff --git a/arigram/__init__.py b/arigram/__init__.py new file mode 100644 index 0000000..3059c56 --- /dev/null +++ b/arigram/__init__.py @@ -0,0 +1,4 @@ +""" +Terminal client for telegram +""" +__version__ = "0.1.0" diff --git a/arigram/__main__.py b/arigram/__main__.py new file mode 100644 index 0000000..d49cf52 --- /dev/null +++ b/arigram/__main__.py @@ -0,0 +1,80 @@ +import logging.handlers +import signal +import threading +from curses import window, wrapper +from functools import partial +from types import FrameType + +from arigram import config, update_handlers, utils +from arigram.controllers import Controller +from arigram.models import Model +from arigram.tdlib import Tdlib +from arigram.views import ChatView, MsgView, StatusView, View + +log = logging.getLogger(__name__) + + +def run(tg: Tdlib, stdscr: window) -> None: + + # handle ctrl+c, to avoid interrupting arigram when subprocess is called + def interrupt_signal_handler(sig: int, frame: FrameType) -> None: + # TODO: draw on status pane: to quite press + del sig, frame + log.info("Interrupt signal is handled and ignored on purpose.") + + signal.signal(signal.SIGINT, interrupt_signal_handler) + + model = Model(tg) + status_view = StatusView(stdscr) + msg_view = MsgView(stdscr, model) + chat_view = ChatView(stdscr, model) + view = View(stdscr, chat_view, msg_view, status_view) + controller = Controller(model, view, tg) + + # hanlde resize of terminal correctly + signal.signal(signal.SIGWINCH, controller.resize_handler) + + for msg_type, handler in update_handlers.handlers.items(): + tg.add_update_handler(msg_type, partial(handler, controller)) + + thread = threading.Thread(target=controller.run) + thread.daemon = True + thread.start() + + controller.load_custom_keybindings() + controller.draw() + + +def parse_args() -> None: + import sys + + if len(sys.argv) > 1 and sys.argv[1] in ("-v", "--version"): + import arigram + + print("Terminal Telegram client") + print("Version:", arigram.__version__) + exit(0) + + +def main() -> None: + parse_args() + utils.cleanup_cache() + tg = Tdlib( + api_id=config.API_ID, + api_hash=config.API_HASH, + phone=config.PHONE, + database_encryption_key=config.ENC_KEY, + files_directory=config.FILES_DIR, + tdlib_verbosity=config.TDLIB_VERBOSITY, + library_path=config.TDLIB_PATH, + ) + tg.login() + + utils.setup_log() + utils.set_shorter_esc_delay() + + wrapper(partial(run, tg)) + + +if __name__ == "__main__": + main() diff --git a/arigram/colors.py b/arigram/colors.py new file mode 100644 index 0000000..2adb508 --- /dev/null +++ b/arigram/colors.py @@ -0,0 +1,53 @@ +import curses + +DEFAULT_FG = curses.COLOR_WHITE +DEFAULT_BG = curses.COLOR_BLACK +COLOR_PAIRS = {(10, 10): 0} +COLOR_PAIRS = {} + +# colors +black = curses.COLOR_BLACK +blue = curses.COLOR_BLUE +cyan = curses.COLOR_CYAN +green = curses.COLOR_GREEN +magenta = curses.COLOR_MAGENTA +red = curses.COLOR_RED +white = curses.COLOR_WHITE +yellow = curses.COLOR_YELLOW +default = -1 + +# modes +normal = curses.A_NORMAL +bold = curses.A_BOLD +blink = curses.A_BLINK +reverse = curses.A_REVERSE +underline = curses.A_UNDERLINE +invisible = curses.A_INVIS +dim = curses.A_DIM + + +def get_color(fg: int, bg: int) -> int: + """Returns the curses color pair for the given fg/bg combination.""" + + key = (fg, bg) + if key not in COLOR_PAIRS: + size = len(COLOR_PAIRS) + try: + curses.init_pair(size, fg, bg) + except curses.error: + # If curses.use_default_colors() failed during the initialization + # of curses, then using -1 as fg or bg will fail as well, which + # we need to handle with fallback-defaults: + if fg == -1: # -1 is the "default" color + fg = DEFAULT_FG + if bg == -1: # -1 is the "default" color + bg = DEFAULT_BG + + try: + curses.init_pair(size, fg, bg) + except curses.error: + # If this fails too, colors are probably not supported + pass + COLOR_PAIRS[key] = size + + return curses.color_pair(COLOR_PAIRS[key]) diff --git a/arigram/config.py b/arigram/config.py new file mode 100644 index 0000000..0250efe --- /dev/null +++ b/arigram/config.py @@ -0,0 +1,104 @@ +""" +Every parameter (except for CONFIG_FILE) can be +overwritten by external config file +""" +import os +import platform +import runpy +from typing import Any, Dict, Optional + +_os_name = platform.system() +_darwin = "Darwin" +_linux = "Linux" + + +CONFIG_DIR = os.path.expanduser("~/.config/arigram/") +CONFIG_FILE = os.path.join(CONFIG_DIR, "config.py") +FILES_DIR = os.path.expanduser("~/.cache/arigram/") +MAILCAP_FILE: Optional[str] = None + +LOG_LEVEL = "INFO" +LOG_PATH = os.path.expanduser("~/.local/share/arigram/") + +API_ID = "559815" +API_HASH = "fd121358f59d764c57c55871aa0807ca" + +PHONE = None +ENC_KEY = "" + +TDLIB_PATH = None +TDLIB_VERBOSITY = 0 + +MAX_DOWNLOAD_SIZE = "10MB" + +# TODO: check platform +NOTIFY_FUNCTION = None + +VIEW_TEXT_CMD = "less" +FZF = "fzf" + +if _os_name == _linux: + # for more info see https://trac.ffmpeg.org/wiki/Capture/ALSA + VOICE_RECORD_CMD = ( + "ffmpeg -f alsa -i hw:0 -c:a libopus -b:a 32k {file_path}" + ) +else: + VOICE_RECORD_CMD = ( + "ffmpeg -f avfoundation -i ':0' -c:a libopus -b:a 32k {file_path}" + ) + +# TODO: use mailcap instead of editor +LONG_MSG_CMD = "vim + -c 'startinsert' {file_path}" +EDITOR = os.environ.get("EDITOR", "vi") + +if _os_name == _linux: + DEFAULT_OPEN = "xdg-open {file_path}" +else: + DEFAULT_OPEN = "open {file_path}" + +if _os_name == _linux: + if os.environ.get("WAYLAND_DISPLAY"): + COPY_CMD = "wl-copy" + else: + COPY_CMD = "xclip -selection c" +else: + COPY_CMD = "pbcopy" + +CHAT_FLAGS: Dict[str, str] = {} + +MSG_FLAGS: Dict[str, str] = {} + +ICON_PATH = os.path.join(os.path.dirname(__file__), "resources", "arigram.png") + +URL_VIEW = "urlview" + +USERS_COLORS = tuple(range(2, 16)) + +KEEP_MEDIA = 7 + +FILE_PICKER_CMD = None + +DOWNLOAD_DIR = os.path.expanduser("~/Downloads/") + +EXTRA_FILE_CHOOSER_PATHS = ["..", "/", "~"] + +CUSTOM_KEYBINDS: Dict[str, Dict[str, Any]] = {} + +if os.path.isfile(CONFIG_FILE): + config_params = runpy.run_path(CONFIG_FILE) + for param, value in config_params.items(): + if param.isupper(): + globals()[param] = value +else: + os.makedirs(CONFIG_DIR, exist_ok=True) + + if not PHONE: + print( + "Enter your phone number in international format (including country code)" + ) + PHONE = input("phone> ") + if not PHONE.startswith("+"): + PHONE = "+" + PHONE + + with open(CONFIG_FILE, "w") as f: + f.write(f"PHONE = '{PHONE}'\n") diff --git a/arigram/controllers.py b/arigram/controllers.py new file mode 100644 index 0000000..6db942c --- /dev/null +++ b/arigram/controllers.py @@ -0,0 +1,978 @@ +import logging +import os +import random +import shlex +from datetime import datetime +from functools import partial, wraps +from queue import Queue +from tempfile import NamedTemporaryFile +from typing import Any, Callable, Dict, List, Optional + +import pyfzf +from telegram.utils import AsyncResult + +from arigram import config +from arigram.models import Model +from arigram.msg import MsgProxy +from arigram.tdlib import ChatAction, ChatType, Tdlib, get_chat_type +from arigram.utils import ( + get_duration, + get_mime, + get_video_resolution, + get_waveform, + is_no, + is_yes, + notify, + suspend, +) +from arigram.views import View + +log = logging.getLogger(__name__) + +# start scrolling to next page when number of the msgs left is less than value. +# note, that setting high values could lead to situations when long msgs will +# be removed from the display in order to achive scroll threshold. this could +# cause blan areas on the msg display screen +MSGS_LEFT_SCROLL_THRESHOLD = 2 +REPLY_MSG_PREFIX = "# >" +HandlerType = Callable[[Any], Optional[str]] + +chat_handler: Dict[str, HandlerType] = {} +msg_handler: Dict[str, HandlerType] = {} + + +def bind( + binding: Dict[str, HandlerType], + keys: List[str], + repeat_factor: bool = False, +) -> Callable: + """bind handlers to given keys""" + + def decorator(fun: Callable) -> HandlerType: + @wraps(fun) + def wrapper(*args: Any, **kwargs: Any) -> Any: + return fun(*args, **kwargs) + + @wraps(fun) + def _no_repeat_factor(self: "Controller", _: bool) -> Optional[str]: + return fun(self) + + for key in keys: + assert ( + key not in binding + ), f"Key {key} already binded to {binding[key]}" + binding[key] = fun if repeat_factor else _no_repeat_factor # type: ignore + + return wrapper + + return decorator + + +class Controller: + def __init__(self, model: Model, view: View, tg: Tdlib) -> None: + self.model = model + self.view = view + self.queue: Queue = Queue() + self.is_running = True + self.tg = tg + self.chat_size = 0.5 + + @bind(msg_handler, ["c"]) + def show_chat_info(self) -> None: + """Show chat information""" + chat = self.model.chats.chats[self.model.current_chat] + info = self.model.get_chat_info(chat) + + with suspend(self.view) as s: + s.run_with_input( + config.VIEW_TEXT_CMD, + "\n".join(f"{k}: {v}" for k, v in info.items() if v), + ) + + @bind(msg_handler, ["u"]) + def show_user_info(self) -> None: + """Show user profile""" + msg = MsgProxy(self.model.current_msg) + user_id = msg.sender_id + info = self.model.get_user_info(user_id) + + with suspend(self.view) as s: + s.run_with_input( + config.VIEW_TEXT_CMD, + "\n".join(f"{k}: {v}" for k, v in info.items() if v), + ) + + @bind(msg_handler, ["O"]) + def save_file_in_folder(self) -> None: + chat_id = self.model.chats.id_by_index(self.model.current_chat) + if not chat_id: + return + msg_ids = self.model.selected[chat_id] + if not msg_ids: + msg = self.model.current_msg + msg_ids = [msg["id"]] + else: + self.discard_selected_msgs() + if self.model.copy_files(chat_id, msg_ids, config.DOWNLOAD_DIR): + self.present_info(f"Copied files to {config.DOWNLOAD_DIR}") + + @bind(msg_handler, ["o"]) + def open_url(self) -> None: + msg = MsgProxy(self.model.current_msg) + if not msg.is_text: + return self.present_error("Does not contain urls") + text = msg["content"]["text"]["text"] + urls = [] + for entity in msg["content"]["text"]["entities"]: + _type = entity["type"]["@type"] + if _type == "textEntityTypeUrl": + offset = entity["offset"] + length = entity["length"] + url = text[offset : offset + length] + elif _type == "textEntityTypeTextUrl": + url = entity["type"]["url"] + else: + continue + urls.append(url) + if not urls: + return self.present_error("No url to open") + if len(urls) == 1: + with suspend(self.view) as s: + s.call( + config.DEFAULT_OPEN.format(file_path=shlex.quote(urls[0])) + ) + return + with suspend(self.view) as s: + s.run_with_input(config.URL_VIEW, "\n".join(urls)) + + @staticmethod + def format_help(bindings: Dict[str, HandlerType]) -> str: + return "\n".join( + f"{key}\t{fun.__name__}\t{fun.__doc__ or ''}" + for key, fun in sorted(bindings.items()) + ) + + @bind(chat_handler, ["?"]) + def show_chat_help(self) -> None: + _help = self.format_help(chat_handler) + with suspend(self.view) as s: + s.run_with_input(config.VIEW_TEXT_CMD, _help) + + @bind(msg_handler, ["?"]) + def show_msg_help(self) -> None: + _help = self.format_help(msg_handler) + with suspend(self.view) as s: + s.run_with_input(config.VIEW_TEXT_CMD, _help) + + @bind(chat_handler, ["bp"]) + @bind(msg_handler, ["bp"]) + def breakpoint(self) -> None: + with suspend(self.view): + breakpoint() + + @bind(chat_handler, ["q"]) + @bind(msg_handler, ["q"]) + def quit(self) -> str: + return "QUIT" + + @bind(msg_handler, ["h", "^D"]) + def back(self) -> str: + return "BACK" + + @bind(msg_handler, ["m"]) + def jump_to_reply_msg(self) -> None: + chat_id = self.model.chats.id_by_index(self.model.current_chat) + if not chat_id: + return + msg = MsgProxy(self.model.current_msg) + if not msg.reply_msg_id: + return self.present_error("This msg does not reply") + if not self.model.msgs.jump_to_msg_by_id(chat_id, msg.reply_msg_id): + return self.present_error( + "Can't jump to reply msg: it's not preloaded or deleted" + ) + return self.render_msgs() + + @bind(msg_handler, ["p"]) + def forward_msgs(self) -> None: + """Paste yanked msgs""" + if not self.model.forward_msgs(): + self.present_error("Can't forward msg(s)") + return + self.present_info("Forwarded msg(s)") + + @bind(msg_handler, ["y"]) + def yank_msgs(self) -> None: + """Copy msgs to clipboard and internal buffer to forward""" + chat_id = self.model.chats.id_by_index(self.model.current_chat) + if not chat_id: + return + msg_ids = self.model.selected[chat_id] + if not msg_ids: + msg = self.model.current_msg + msg_ids = [msg["id"]] + self.model.copied_msgs = (chat_id, msg_ids) + self.discard_selected_msgs() + self.model.copy_msgs_text() + self.present_info(f"Copied {len(msg_ids)} msg(s)") + + def _toggle_select_msg(self) -> None: + chat_id = self.model.chats.id_by_index(self.model.current_chat) + if not chat_id: + return + msg = MsgProxy(self.model.current_msg) + + if msg.msg_id in self.model.selected[chat_id]: + self.model.selected[chat_id].remove(msg.msg_id) + else: + self.model.selected[chat_id].append(msg.msg_id) + + @bind(msg_handler, [" "]) + def toggle_select_msg_down(self) -> None: + """Select and jump to next msg with """ + self._toggle_select_msg() + self.model.next_msg() + self.render_msgs() + + @bind(msg_handler, ["^@"]) + def toggle_select_msg_up(self) -> None: + """Select and jump to previous msg with ctrl+""" + self._toggle_select_msg() + self.model.prev_msg() + self.render_msgs() + + @bind(msg_handler, ["^G", "^["]) + def discard_selected_msgs(self) -> None: + chat_id = self.model.chats.id_by_index(self.model.current_chat) + if not chat_id: + return + self.model.selected[chat_id] = [] + self.render_msgs() + self.present_info("Discarded selected messages") + + @bind(msg_handler, ["G"]) + def bottom_msg(self) -> None: + if self.model.jump_bottom(): + self.render_msgs() + + @bind(msg_handler, ["j", "^B", "^N"], repeat_factor=True) + def next_msg(self, repeat_factor: int = 1) -> None: + if self.model.next_msg(repeat_factor): + self.render_msgs() + + @bind(msg_handler, ["J"]) + def jump_10_msgs_down(self) -> None: + self.next_msg(10) + + @bind(msg_handler, ["k", "^C", "^P"], repeat_factor=True) + def prev_msg(self, repeat_factor: int = 1) -> Optional[bool]: + if self.model.prev_msg(repeat_factor): + self.render_msgs() + return False + + @bind(msg_handler, ["K"]) + def jump_10_msgs_up(self) -> None: + self.prev_msg(10) + + @bind(msg_handler, ["r"]) + def reply_message(self) -> None: + if not self.can_send_msg(): + self.present_info("Can't send msg in this chat") + return + chat_id = self.model.current_chat_id + if chat_id is None: + return + reply_to_msg = self.model.current_msg_id + if msg := self.view.status.get_input(): + self.model.view_all_msgs() + self.tg.reply_message(chat_id, reply_to_msg, msg) + self.present_info("Message reply sent") + else: + self.present_info("Message reply wasn't sent") + + @bind(msg_handler, ["R"]) + def reply_with_long_message(self) -> None: + if not self.can_send_msg(): + self.present_info("Can't send msg in this chat") + return + chat_id = self.model.current_chat_id + if chat_id is None: + return + reply_to_msg = self.model.current_msg_id + msg = MsgProxy(self.model.current_msg) + with NamedTemporaryFile("w+", suffix=".md") as f, suspend( + self.view + ) as s: + f.write(insert_replied_msg(msg)) + f.seek(0) + s.call(config.LONG_MSG_CMD.format(file_path=shlex.quote(f.name))) + with open(f.name) as f: + if replied_msg := strip_replied_msg(f.read().strip()): + self.model.view_all_msgs() + self.tg.reply_message(chat_id, reply_to_msg, replied_msg) + self.present_info("Message sent") + else: + self.present_info("Message wasn't sent") + + @bind(msg_handler, ["a", "i"]) + def write_short_msg(self) -> None: + chat_id = self.model.chats.id_by_index(self.model.current_chat) + if not self.can_send_msg() or chat_id is None: + 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(): + self.model.send_message(text=msg) + self.present_info("Message sent") + else: + self.tg.send_chat_action(chat_id, ChatAction.chatActionCancel) + self.present_info("Message wasn't sent") + + @bind(msg_handler, ["A", "I"]) + def write_long_msg(self) -> None: + chat_id = self.model.chats.id_by_index(self.model.current_chat) + if not self.can_send_msg() or chat_id is None: + self.present_info("Can't send msg in this chat") + return + with NamedTemporaryFile("r+", suffix=".txt") as f, suspend( + self.view + ) as s: + self.tg.send_chat_action(chat_id, ChatAction.chatActionTyping) + s.call(config.LONG_MSG_CMD.format(file_path=shlex.quote(f.name))) + with open(f.name) as f: + if msg := f.read().strip(): + self.model.send_message(text=msg) + self.present_info("Message sent") + else: + self.tg.send_chat_action( + chat_id, ChatAction.chatActionCancel + ) + self.present_info("Message wasn't sent") + + @bind(msg_handler, ["dd"]) + def delete_msgs(self) -> None: + is_deleted = self.model.delete_msgs() + self.discard_selected_msgs() + if not is_deleted: + return self.present_error("Can't delete msg(s)") + self.present_info("Message deleted") + + def _recursive_fzf_file_chooser(self) -> str: + fzf = pyfzf.FzfPrompt() + files = config.EXTRA_FILE_CHOOSER_PATHS + os.listdir(".") + file_path = fzf.prompt(files)[0] + + full_file_path = os.path.abspath(os.path.expanduser(file_path)) + + if os.path.isdir(full_file_path): + os.chdir(full_file_path) + return self._recursive_fzf_file_chooser() + + return full_file_path + + @bind(msg_handler, ["S"]) + def choose_and_send_file(self) -> None: + """Call file picker and send chosen file based on mimetype""" + chat_id = self.model.chats.id_by_index(self.model.current_chat) + file_path = None + if not chat_id: + return self.present_error("No chat selected") + try: + with NamedTemporaryFile("w") as f, suspend(self.view) as s: + if config.FILE_PICKER_CMD is None: + file_path = self._recursive_fzf_file_chooser() + else: + s.call(config.FILE_PICKER_CMD.format(file_path=f.name)) # type: ignore + + with open(f.name) as f: + file_path = f.read().strip() + + with suspend(self.view) as s: + resp = self.view.status.get_input( + f"Review <{file_path}>? [Y/n]: " + ) + + if resp is None: + return self.present_info("Uploading cancelled") + if is_yes(resp): + with suspend(self.view) as sv: + sv.open_file(file_path, None) + + resp_up = self.view.status.get_input( + f"Upload <{file_path}>? [y/N]: " + ) + + if resp_up is None or is_no(resp_up): + return self.present_info("Uploading cancelled") + except FileNotFoundError: + pass + if not file_path or not os.path.isfile(file_path): + return self.present_error("No file was selected") + mime_map = { + "animation": self.tg.send_animation, + "image": self.tg.send_photo, + "audio": self.tg.send_audio, + "video": self._send_video, + } + mime = get_mime(file_path) + if mime in ("image", "video", "animation"): + resp = self.view.status.get_input( + f"Upload <{file_path}> compressed? [Y/n]: " + ) + self.render_status() + if resp is None: + return self.present_info("Uploading cancelled") + if not is_yes(resp): + mime = "" + + fun = mime_map.get(mime, self.tg.send_doc) + fun(file_path, chat_id) + + @bind(msg_handler, ["sd"]) + def send_document(self) -> None: + """Enter file path and send uncompressed""" + self.send_file(self.tg.send_doc) + + @bind(msg_handler, ["sp"]) + def send_picture(self) -> None: + """Enter file path and send compressed image""" + self.send_file(self.tg.send_photo) + + @bind(msg_handler, ["sa"]) + def send_audio(self) -> None: + """Enter file path and send as audio""" + self.send_file(self.tg.send_audio) + + @bind(msg_handler, ["sn"]) + def send_animation(self) -> None: + """Enter file path and send as animation""" + self.send_file(self.tg.send_animation) + + @bind(msg_handler, ["sv"]) + def send_video(self) -> None: + """Enter file path and send compressed video""" + file_path = self.view.status.get_input() + if not file_path or not os.path.isfile(file_path): + return + chat_id = self.model.chats.id_by_index(self.model.current_chat) + if not chat_id: + return + self._send_video(file_path, chat_id) + + def _send_video(self, file_path: str, chat_id: int) -> None: + width, height = get_video_resolution(file_path) + duration = get_duration(file_path) + self.tg.send_video(file_path, chat_id, width, height, duration) + + def send_file( + self, + send_file_fun: Callable[[str, int], AsyncResult], + ) -> None: + _input = self.view.status.get_input() + if _input is None: + return + file_path = os.path.expanduser(_input) + if not file_path or not os.path.isfile(file_path): + return self.present_info("Given path to file does not exist") + + if chat_id := self.model.chats.id_by_index(self.model.current_chat): + send_file_fun(file_path, chat_id) + self.present_info("File sent") + + @bind(msg_handler, ["v"]) + def record_voice(self) -> None: + file_path = f"/tmp/voice-{datetime.now()}.oga" + with suspend(self.view) as s: + s.call( + config.VOICE_RECORD_CMD.format( + file_path=shlex.quote(file_path) + ) + ) + resp = self.view.status.get_input( + 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") + + if not os.path.isfile(file_path): + return self.present_info(f"Can't load recording file {file_path}") + + chat_id = self.model.chats.id_by_index(self.model.current_chat) + if not chat_id: + return + duration = get_duration(file_path) + waveform = get_waveform(file_path) + self.tg.send_voice(file_path, chat_id, duration, waveform) + self.present_info(f"Sent voice msg: {file_path}") + + @bind(msg_handler, ["D"]) + def download_current_file(self) -> None: + msg = MsgProxy(self.model.current_msg) + log.debug("Downloading msg: %s", msg.msg) + file_id = msg.file_id + if not file_id: + self.present_info("File can't be downloaded") + return + self.download(file_id, msg["chat_id"], msg["id"]) + self.present_info("File started downloading") + + def download(self, file_id: int, chat_id: int, msg_id: int) -> None: + log.info("Downloading file: file_id=%s", file_id) + self.model.downloads[file_id] = (chat_id, msg_id) + self.tg.download_file(file_id=file_id) + log.info("Downloaded: file_id=%s", file_id) + + def can_send_msg(self) -> bool: + chat = self.model.chats.chats[self.model.current_chat] + return chat["permissions"]["can_send_messages"] + + def _open_msg(self, msg: MsgProxy, cmd: str = None) -> None: + if msg.is_text: + with NamedTemporaryFile("w", suffix=".txt") as f: + f.write(msg.text_content) + f.flush() + with suspend(self.view) as s: + s.open_file(f.name, cmd) + return + + path = msg.local_path + if not path: + self.present_info("File should be downloaded first") + return + chat_id = self.model.chats.id_by_index(self.model.current_chat) + if not chat_id: + return + self.tg.open_message_content(chat_id, msg.msg_id) + with suspend(self.view) as s: + s.open_file(path, cmd) + + @bind(msg_handler, ["!"]) + 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() + if not cmd: + return + if "%s" not in cmd: + return self.present_error( + "command should contain <%s> which will be replaced by file path" + ) + return self._open_msg(msg, cmd) + + @bind(msg_handler, ["l", "^J"]) + def open_current_msg(self) -> None: + """Open msg or file with cmd in mailcap""" + msg = MsgProxy(self.model.current_msg) + self._open_msg(msg) + + @bind(msg_handler, ["e"]) + def edit_msg(self) -> None: + msg = MsgProxy(self.model.current_msg) + log.info("Editing msg: %s", msg.msg) + if not self.model.is_me(msg.sender_id): + return self.present_error("You can edit only your messages!") + if not msg.is_text: + return self.present_error("You can edit text messages only!") + if not msg.can_be_edited: + return self.present_error("Meessage can't be edited!") + + with NamedTemporaryFile("r+", suffix=".txt") as f, suspend( + self.view + ) as s: + f.write(msg.text_content) + f.flush() + s.call(f"{config.EDITOR} {f.name}") + with open(f.name) as f: + if text := f.read().strip(): + self.model.edit_message(text=text) + self.present_info("Message edited") + + def _get_user_ids(self, is_multiple: bool = False) -> List[int]: + users = self.model.users.get_users() + _, cols = self.view.stdscr.getmaxyx() + limit = min( + int(cols / 2), + max(len(user.name) for user in users), + ) + users_out = "\n".join( + f"{user.id}\t{user.name:<{limit}} | {user.status}" + for user in sorted(users, key=lambda user: user.order) + ) + cmd = config.FZF + " -n 2" + if is_multiple: + cmd += " -m" + + with NamedTemporaryFile("r+") as tmp, suspend(self.view) as s: + s.run_with_input(f"{cmd} > {tmp.name}", users_out) + with open(tmp.name) as f: + return [int(line.split()[0]) for line in f.readlines()] + + @bind(chat_handler, ["ns"]) + def new_secret(self) -> None: + """Create new secret chat""" + user_ids = self._get_user_ids() + if not user_ids: + return + self.tg.create_new_secret_chat(user_ids[0]) + + @bind(chat_handler, ["ng"]) + def new_group(self) -> None: + """Create new group""" + user_ids = self._get_user_ids(is_multiple=True) + if not user_ids: + return + title = self.view.status.get_input("Group name: ") + if title is None: + return self.present_info("Cancelling creating group") + if not title: + return self.present_error("Group name should not be empty") + + self.tg.create_new_basic_group_chat(user_ids, title) + + @bind(chat_handler, ["dd"]) + def delete_chat(self) -> None: + """Leave group/channel or delete private/secret chats""" + + chat = self.model.chats.chats[self.model.current_chat] + chat_type = get_chat_type(chat) + if chat_type in ( + ChatType.chatTypeSupergroup, + ChatType.chatTypeBasicGroup, + ChatType.channel, + ): + resp = self.view.status.get_input( + "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") + self.tg.leave_chat(chat["id"]) + self.tg.delete_chat_history( + chat["id"], remove_from_chat_list=True, revoke=False + ) + return + + resp = self.view.status.get_input( + "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]") + if resp is None: + return self.present_info("Not deleting chat") + self.render_status() + is_revoke = is_no(resp) + + self.tg.delete_chat_history( + chat["id"], remove_from_chat_list=True, revoke=is_revoke + ) + if chat_type == ChatType.chatTypeSecret: + self.tg.close_secret_chat(chat["type"]["secret_chat_id"]) + + self.present_info("Chat was deleted") + + @bind(chat_handler, ["n"]) + def next_found_chat(self) -> None: + """Go to next found chat""" + if self.model.set_current_chat_by_id( + self.model.chats.next_found_chat() + ): + self.render() + + @bind(chat_handler, ["N"]) + def prev_found_chat(self) -> None: + """Go to previous found chat""" + if self.model.set_current_chat_by_id( + self.model.chats.next_found_chat(True) + ): + self.render() + + @bind(chat_handler, ["/"]) + def search_contacts(self) -> None: + """Search contacts and set jumps to it if found""" + msg = self.view.status.get_input("/") + if not msg: + return self.present_info("Search discarded") + + rv = self.tg.search_contacts(msg) + chat_ids = rv.update["chat_ids"] + if not chat_ids: + return self.present_info("Chat not found") + + chat_id = chat_ids[0] + if chat_id not in self.model.chats.chat_ids: + self.present_info("Chat not loaded") + return + + self.model.chats.found_chats = chat_ids + + if self.model.set_current_chat_by_id(chat_id): + self.render() + + @bind(chat_handler, ["c"]) + def view_contacts(self) -> None: + self._get_user_ids() + + @bind(chat_handler, ["l", "^J", "^E"]) + def handle_msgs(self) -> Optional[str]: + rc = self.handle(msg_handler, 0.2) + if rc == "QUIT": + return rc + self.chat_size = 0.5 + self.resize() + + @bind(chat_handler, ["g"]) + def top_chat(self) -> None: + if self.model.first_chat(): + self.render() + + @bind(chat_handler, ["j", "^B", "^N"], repeat_factor=True) + @bind(msg_handler, ["]"]) + def next_chat(self, repeat_factor: int = 1) -> None: + if self.model.next_chat(repeat_factor): + self.render() + + @bind(chat_handler, ["k", "^C", "^P"], repeat_factor=True) + @bind(msg_handler, ["["]) + def prev_chat(self, repeat_factor: int = 1) -> None: + if self.model.prev_chat(repeat_factor): + self.render() + + @bind(chat_handler, ["J"]) + def jump_10_chats_down(self) -> None: + self.next_chat(10) + + @bind(chat_handler, ["K"]) + def jump_10_chats_up(self) -> None: + self.prev_chat(10) + + @bind(chat_handler, ["u"]) + def toggle_unread(self) -> None: + chat = self.model.chats.chats[self.model.current_chat] + chat_id = chat["id"] + toggle = not chat["is_marked_as_unread"] + self.tg.toggle_chat_is_marked_as_unread(chat_id, toggle) + self.render() + + @bind(chat_handler, ["r"]) + def read_msgs(self) -> None: + self.model.view_all_msgs() + self.render() + + @bind(chat_handler, ["m"]) + def toggle_mute(self) -> None: + # TODO: if it's msg to yourself, do not change its + # notification setting, because we can't by documentation, + # instead write about it in status + chat = self.model.chats.chats[self.model.current_chat] + chat_id = chat["id"] + if self.model.is_me(chat_id): + self.present_error("You can't mute Saved Messages") + return + notification_settings = chat["notification_settings"] + if notification_settings["mute_for"]: + notification_settings["mute_for"] = 0 + else: + notification_settings["mute_for"] = 2147483647 + self.tg.set_chat_nottification_settings(chat_id, notification_settings) + self.render() + + @bind(chat_handler, ["p"]) + def toggle_pin(self) -> None: + chat = self.model.chats.chats[self.model.current_chat] + chat_id = chat["id"] + toggle = not chat["is_pinned"] + self.tg.toggle_chat_is_pinned(chat_id, toggle) + self.render() + + def run(self) -> None: + try: + self.handle(chat_handler, 0.5) + self.queue.put(self.close) + except Exception: + log.exception("Error happened in main loop") + + def close(self) -> None: + self.is_running = False + + def handle(self, handlers: Dict[str, HandlerType], size: float) -> str: + self.chat_size = size + self.resize() + + while True: + try: + repeat_factor, keys = self.view.get_keys() + fun = handlers.get(keys, lambda *_: None) + res = fun(self, repeat_factor) # type: ignore + if res == "QUIT": + return res + elif res == "BACK": + return res + except Exception: + log.exception("Error happend in key handle loop") + + def resize_handler(self, signum: int, frame: Any) -> None: + del signum, frame + self.view.resize_handler() + self.resize() + + def resize(self) -> None: + self.queue.put(self._resize) + + def _resize(self) -> None: + rows, cols = self.view.stdscr.getmaxyx() + # If we didn't clear the screen before doing this, + # the original window contents would remain on the screen + # and we would see the window text twice. + self.view.stdscr.erase() + self.view.stdscr.noutrefresh() + + chat_width = round(cols * self.chat_size) + msg_width = cols - chat_width + self.view.chats.resize(rows, cols, chat_width) + self.view.msgs.resize(rows, cols, msg_width) + self.view.status.resize(rows, cols) + self._render() + + def draw(self) -> None: + while self.is_running: + try: + log.info("Queue size: %d", self.queue.qsize()) + fun = self.queue.get() + fun() + except Exception: + log.exception("Error happened in draw loop") + + def present_error(self, msg: str) -> None: + return self.update_status("Error", msg) + + def present_info(self, msg: str) -> None: + return self.update_status("Info", msg) + + def update_status(self, level: str, msg: str) -> None: + self.queue.put(partial(self._update_status, level, msg)) + + def _update_status(self, level: str, msg: str) -> None: + self.view.status.draw(f"{level}: {msg}") + + def render(self) -> None: + self.queue.put(self._render) + + def _render(self) -> None: + self._render_chats() + self._render_msgs() + + def render_status(self) -> None: + self.view.status.draw() + + def render_chats(self) -> None: + self.queue.put(self._render_chats) + + def _render_chats(self) -> None: + page_size = self.view.chats.h - 1 + chats = self.model.get_chats( + self.model.current_chat, page_size, MSGS_LEFT_SCROLL_THRESHOLD + ) + selected_chat = min( + self.model.current_chat, page_size - MSGS_LEFT_SCROLL_THRESHOLD + ) + self.view.chats.draw(selected_chat, chats, self.model.chats.title) + + def render_msgs(self) -> None: + self.queue.put(self._render_msgs) + + def _render_msgs(self) -> None: + current_msg_idx = self.model.get_current_chat_msg_idx() + if current_msg_idx is None: + return + msgs = self.model.fetch_msgs( + current_position=current_msg_idx, + page_size=self.view.msgs.h - 1, + msgs_left_scroll_threshold=MSGS_LEFT_SCROLL_THRESHOLD, + ) + chat = self.model.chats.chats[self.model.current_chat] + self.view.msgs.draw( + current_msg_idx, msgs, MSGS_LEFT_SCROLL_THRESHOLD, chat + ) + + def notify_for_message(self, chat_id: int, msg: MsgProxy) -> None: + # do not notify, if muted + # TODO: optimize + for chat in self.model.chats.chats: + if chat_id == chat["id"]: + break + else: + # chat not found, do not notify + return + + # TODO: handle cases when all chats muted on global level + if chat["notification_settings"]["mute_for"]: + 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']}" + + if text := msg.text_content if msg.is_text else msg.content_type: + notify(text, title=name) + + def refresh_current_chat(self, current_chat_id: Optional[int]) -> None: + if current_chat_id is None: + return + # TODO: we can create for chats, it's faster than sqlite anyway + # though need to make sure that creatinng index is atomic operation + # requires locks for read, until index and chats will be the same + for i, chat in enumerate(self.model.chats.chats): + if chat["id"] == current_chat_id: + self.model.current_chat = i + break + self.render() + + def load_custom_keybindings(self) -> None: + for binding, binding_info in config.CUSTOM_KEYBINDS.items(): + function_name = ( + f"handle_{binding}_{random.randint(0, 999_000_000_000_000)}" + ) + + function = binding_info["func"] + handler = binding_info["handler"] + + @bind(handler, [binding]) + def handle(*args) -> None: # type: ignore + try: + function(self, *args) + except Exception as e: + self.present_error( + f"{function_name}(): {e.__class__.__name__}: {e}" + ) + + setattr( + Controller, + function_name, + handle, + ) + + +def insert_replied_msg(msg: MsgProxy) -> str: + text = msg.text_content if msg.is_text else msg.content_type + if not text: + return "" + return ( + "\n".join([f"{REPLY_MSG_PREFIX} {line}" for line in text.split("\n")]) + # adding line with whitespace so text editor could start editing from last line + + "\n " + ) + + +def strip_replied_msg(msg: str) -> str: + return "\n".join( + [ + line + for line in msg.split("\n") + if not line.startswith(REPLY_MSG_PREFIX) + ] + ) diff --git a/arigram/models.py b/arigram/models.py new file mode 100644 index 0000000..7b6a040 --- /dev/null +++ b/arigram/models.py @@ -0,0 +1,855 @@ +import base64 +import logging +import shutil +import sys +import time +from collections import defaultdict, namedtuple +from typing import Any, Dict, List, Optional, Set, Tuple + +from arigram.msg import MsgProxy +from arigram.tdlib import ( + ChatAction, + ChatType, + SecretChatState, + Tdlib, + UserStatus, + UserType, + get_chat_type, +) +from arigram.utils import copy_to_clipboard, pretty_ts + +log = logging.getLogger(__name__) + + +class Model: + def __init__(self, tg: Tdlib) -> None: + self.tg = tg + self.chats = ChatModel(tg) + self.msgs = MsgModel(tg) + self.users = UserModel(tg) + self.current_chat = 0 + self.downloads: Dict[int, Tuple[int, int]] = {} + self.selected: Dict[int, List[int]] = defaultdict(list) + self.copied_msgs: Tuple[int, List[int]] = (0, []) + + def get_me(self) -> Dict[str, Any]: + return self.users.get_me() + + def is_me(self, user_id: int) -> bool: + return self.get_me()["id"] == user_id + + @property + def current_chat_id(self) -> Optional[int]: + return self.chats.id_by_index(self.current_chat) + + def get_current_chat_msg_idx(self) -> Optional[int]: + chat_id = self.chats.id_by_index(self.current_chat) + if chat_id is None: + return None + return self.msgs.current_msgs[chat_id] + + def fetch_msgs( + self, + current_position: int = 0, + page_size: int = 10, + msgs_left_scroll_threshold: int = 10, + ) -> List[Tuple[int, Dict[str, Any]]]: + chat_id = self.chats.id_by_index(self.current_chat) + if chat_id is None: + return [] + msgs_left = page_size - 1 - current_position + offset = max(msgs_left_scroll_threshold - msgs_left, 0) + limit = offset + page_size + + return self.msgs.fetch_msgs(chat_id, offset=offset, limit=limit) + + @property + def current_msg(self) -> Dict[str, Any]: + chat_id = self.chats.id_by_index(self.current_chat) + if chat_id is None: + return {} + current_msg = self.msgs.current_msgs[chat_id] + log.info("current-msg: %s", current_msg) + msg_id = self.msgs.msg_ids[chat_id][current_msg] + return self.msgs.msgs[chat_id][msg_id] + + @property + def current_msg_id(self) -> int: + return self.current_msg["id"] + + def jump_bottom(self) -> bool: + res = False + if chat_id := self.chats.id_by_index(self.current_chat): + res = self.msgs.jump_bottom(chat_id) + self.view_current_msg() + return res + + def set_current_chat_by_id(self, chat_id: int) -> bool: + idx = next( + iter( + i + for i, chat in enumerate(self.chats.chats) + if chat["id"] == chat_id + ) + ) + return self.set_current_chat(idx) + + def set_current_chat(self, chat_idx: int) -> bool: + if 0 < chat_idx < len(self.chats.chats): + self.current_chat = chat_idx + return True + return False + + def next_chat(self, step: int = 1) -> bool: + new_idx = self.current_chat + step + if new_idx < len(self.chats.chats): + self.current_chat = new_idx + return True + return False + + def prev_chat(self, step: int = 1) -> bool: + if self.current_chat == 0: + return False + self.current_chat = max(0, self.current_chat - step) + return True + + def first_chat(self) -> bool: + if self.current_chat != 0: + self.current_chat = 0 + return True + return False + + def view_current_msg(self) -> None: + msg = MsgProxy(self.current_msg) + msg_id = msg["id"] + if chat_id := self.chats.id_by_index(self.current_chat): + self.tg.view_messages(chat_id, [msg_id]) + + def view_all_msgs(self) -> None: + chat = self.chats.chats[self.current_chat] + chat_id = chat["id"] + msg_id = chat["last_message"]["id"] + self.tg.view_messages(chat_id, [msg_id]) + + def next_msg(self, step: int = 1) -> bool: + chat_id = self.chats.id_by_index(self.current_chat) + if not chat_id: + return False + is_next = self.msgs.next_msg(chat_id, step) + self.view_current_msg() + return is_next + + def prev_msg(self, step: int = 1) -> bool: + chat_id = self.chats.id_by_index(self.current_chat) + if not chat_id: + return False + is_prev = self.msgs.prev_msg(chat_id, step) + if is_prev: + self.view_current_msg() + return is_prev + + def has_prev_message(self, step: int = 2) -> bool: + chat_id = self.chats.id_by_index(self.current_chat) + if chat_id is None: + return False + return len(self.msgs.msg_ids[chat_id]) > step + + def get_chats( + self, + current_position: int = 0, + page_size: int = 10, + msgs_left_scroll_threshold: int = 10, + ) -> List[Dict[str, Any]]: + chats_left = page_size - current_position + offset = max(msgs_left_scroll_threshold - chats_left, 0) + limit = offset + page_size + return self.chats.fetch_chats(offset=offset, limit=limit) + + def send_message(self, text: str) -> bool: + chat_id = self.chats.id_by_index(self.current_chat) + if chat_id is None: + return False + # order is matter: this should be before send_message + # otherwise it will view message that was sent + self.view_all_msgs() + self.msgs.send_message(chat_id, text) + return True + + def edit_message(self, text: str) -> bool: + if chat_id := self.chats.id_by_index(self.current_chat): + return self.msgs.edit_message(chat_id, self.current_msg_id, text) + return False + + def can_be_deleted(self, chat_id: int, msg: Dict[str, Any]) -> bool: + c_id = msg["sender"].get("chat_id") or msg["sender"].get("user_id") + if chat_id == c_id: + return msg["can_be_deleted_only_for_self"] + return msg["can_be_deleted_for_all_users"] + + def delete_msgs(self) -> bool: + chat_id = self.chats.id_by_index(self.current_chat) + if not chat_id: + return False + msg_ids = self.selected[chat_id] + if msg_ids: + message_ids = msg_ids + for msg_id in message_ids: + msg = self.msgs.get_message(chat_id, msg_id) + if not msg or not self.can_be_deleted(chat_id, msg): + return False + else: + selected_msg = self.msgs.current_msgs[chat_id] + msg_id = self.msgs.msg_ids[chat_id][selected_msg] + msg = self.msgs.msgs[chat_id][msg_id] + if not self.can_be_deleted(chat_id, msg): + return False + message_ids = [msg["id"]] + + log.info(f"Deleting msg from the chat {chat_id}: {message_ids}") + self.tg.delete_messages(chat_id, message_ids, revoke=True) + return True + + def forward_msgs(self) -> bool: + chat_id = self.chats.id_by_index(self.current_chat) + if not chat_id: + return False + from_chat_id, msg_ids = self.copied_msgs + if not msg_ids: + return False + for msg_id in msg_ids: + msg = self.msgs.get_message(from_chat_id, msg_id) + if not msg or not msg["can_be_forwarded"]: + return False + + self.tg.forward_messages(chat_id, from_chat_id, msg_ids) + self.copied_msgs = (0, []) + return True + + def copy_msgs_text(self) -> bool: + """Copies current msg text or path to file if it's file""" + buffer = [] + + from_chat_id, msg_ids = self.copied_msgs + if not msg_ids: + return False + for msg_id in msg_ids: + _msg = self.msgs.get_message(from_chat_id, msg_id) + if not _msg: + return False + msg = MsgProxy(_msg) + if msg.file_id and msg.local_path: + buffer.append(msg.local_path) + elif msg.is_text: + buffer.append(msg.text_content) + copy_to_clipboard("\n".join(buffer)) + return True + + def copy_files( + self, chat_id: int, msg_ids: List[int], dest_dir: str + ) -> bool: + is_copied = False + for msg_id in msg_ids: + _msg = self.msgs.get_message(chat_id, msg_id) + if not _msg: + return False + msg = MsgProxy(_msg) + if msg.file_id and msg.local_path: + file_path = msg.local_path + shutil.copy2(file_path, dest_dir) + is_copied = True + return is_copied + + def get_private_chat_info(self, chat: Dict[str, Any]) -> Dict[str, Any]: + user_id = chat["id"] + user = self.users.get_user(user_id) + user_info = self.users.get_user_full_info(user_id) + status = self.users.get_status(user_id) + return { + chat["title"]: status, + "Username": user.get("username", ""), + "Phone": user.get("phone_number", ""), + "Bio": user_info.get("bio", ""), + } + + def get_basic_group_info(self, chat: Dict[str, Any]) -> Dict[str, Any]: + group_id = chat["type"]["basic_group_id"] + result = self.tg.get_basic_group_full_info(group_id) + result.wait() + chat_info = result.update + basic_info = self.tg.get_basic_group(group_id) + basic_info.wait() + basic_info = basic_info.update + return { + chat["title"]: f"{basic_info['member_count']} members", + "Info": chat_info["description"], + "Share link": chat_info["invite_link"], + } + + def get_supergroup_info(self, chat: Dict[str, Any]) -> Dict[str, Any]: + result = self.tg.get_supergroup_full_info( + chat["type"]["supergroup_id"] + ) + result.wait() + chat_info = result.update + return { + chat["title"]: f"{chat_info['member_count']} members", + "Info": chat_info["description"], + "Share link": chat_info["invite_link"], + } + + def get_channel_info(self, chat: Dict[str, Any]) -> Dict[str, Any]: + result = self.tg.get_supergroup_full_info( + chat["type"]["supergroup_id"] + ) + result.wait() + chat_info = result.update + return { + chat["title"]: "subscribers", + "Info": chat_info["description"], + "Share link": chat_info["invite_link"], + } + + def get_secret_chat_info(self, chat: Dict[str, Any]) -> Dict[str, Any]: + result = self.tg.get_secret_chat(chat["type"]["secret_chat_id"]) + result.wait() + chat_info = result.update + enc_key = base64.b64decode(chat_info["key_hash"])[:32].hex() + hex_key = " ".join( + [enc_key[i : i + 2] for i in range(0, len(enc_key), 2)] + ) + + state = "Unknown" + try: + state = SecretChatState[chat_info["state"]["@type"]].value + except KeyError: + pass + + user_id = chat_info["user_id"] + user_info = self.get_user_info(user_id) + + return {**user_info, "State": state, "Encryption Key": hex_key} + + def get_chat_info(self, chat: Dict[str, Any]) -> Dict[str, Any]: + chat_type = get_chat_type(chat) + if chat_type is None: + return {} + + handlers = { + ChatType.chatTypePrivate: self.get_private_chat_info, + ChatType.chatTypeBasicGroup: self.get_basic_group_info, + ChatType.chatTypeSupergroup: self.get_supergroup_info, + ChatType.channel: self.get_channel_info, + ChatType.chatTypeSecret: self.get_secret_chat_info, + } + + info = handlers.get(chat_type, lambda _: dict())(chat) + + info.update({"Type": chat_type.value, "Chat Id": chat["id"]}) + return info + + def get_user_info(self, user_id: int) -> Dict[str, Any]: + name = self.users.get_user_label(user_id) + status = self.users.get_status(user_id) + user = self.users.get_user(user_id) + user_info = self.users.get_user_full_info(user_id) + user_type = "Unknown" + try: + user_type = UserType[user["type"]["@type"]].value + except KeyError: + pass + info = { + name: status, + "Username": user.get("username", ""), + "Bio": user_info.get("bio", ""), + "Phone": user.get("phone_number", ""), + "User Id": user_id, + "Type": user_type, + } + return info + + +class ChatModel: + def __init__(self, tg: Tdlib) -> None: + self.tg = tg + self.chats: List[Dict[str, Any]] = [] + self.inactive_chats: Dict[int, Dict[str, Any]] = {} + self.chat_ids: Set[int] = set() + self.have_full_chat_list = False + self.title: str = "Chats" + self.found_chats: List[int] = [] + self.found_chat_idx: int = 0 + + def next_found_chat(self, backwards: bool = False) -> int: + new_idx = self.found_chat_idx + (-1 if backwards else 1) + new_idx %= len(self.found_chats) + + self.found_chat_idx = new_idx + + return self.found_chats[new_idx] + + def id_by_index(self, index: int) -> Optional[int]: + if index >= len(self.chats): + return None + return self.chats[index]["id"] + + def fetch_chats( + self, offset: int = 0, limit: int = 10 + ) -> List[Dict[str, Any]]: + if offset + limit > len(self.chats): + self._load_next_chats() + + return self.chats[offset:limit] + + def _load_next_chats(self) -> None: + """ + based on + https://github.com/tdlib/td/issues/56#issuecomment-364221408 + """ + if self.have_full_chat_list: + return None + offset_order = 2 ** 63 - 1 + offset_chat_id = 0 + if len(self.chats): + offset_chat_id = self.chats[-1]["id"] + offset_order = self.chats[-1]["order"] + result = self.tg.get_chats( + offset_chat_id=offset_chat_id, offset_order=offset_order + ) + + result.wait() + if result.error: + log.error(f"get chat ids error: {result.error_info}") + return None + + chat_ids = result.update["chat_ids"] + if not chat_ids: + self.have_full_chat_list = True + return + + for chat_id in chat_ids: + chat = self.fetch_chat(chat_id) + self.add_chat(chat) + + def fetch_chat(self, chat_id: int) -> Dict[str, Any]: + result = self.tg.get_chat(chat_id) + result.wait() + + if result.error: + log.error(f"get chat error: {result.error_info}") + return {} + return result.update + + def add_chat(self, chat: Dict[str, Any]) -> None: + chat_id = chat["id"] + if chat_id in self.chat_ids: + return + + if len(chat["positions"]) > 0: + chat["order"] = chat["positions"][0]["order"] + else: + chat["order"] = 0 # str(sys.maxsize) + + if int(chat["order"]) == 0: + self.inactive_chats[chat_id] = chat + return + self.chat_ids.add(chat_id) + self.chats.append(chat) + self._sort_chats() + + def _sort_chats(self) -> None: + self.chats = sorted( + self.chats, + # recommended chat order, for more info see + # https://core.telegram.org/tdlib/getting-started#getting-the-lists-of-chats + key=lambda it: (it["order"], it["id"]), + reverse=True, + ) + + def update_chat(self, chat_id: int, **updates: Dict[str, Any]) -> bool: + for i, chat in enumerate(self.chats): + if chat["id"] != chat_id: + continue + chat.update(updates) + if int(chat["order"]) == 0: + self.inactive_chats[chat_id] = chat + self.chat_ids.discard(chat_id) + self.chats = [ + _chat for _chat in self.chats if _chat["id"] != chat_id + ] + log.info(f"Removing chat '{chat['title']}'") + else: + self._sort_chats() + log.info(f"Updated chat with keys {list(updates)}") + return True + + if _chat := self.inactive_chats.get(chat_id): + _chat.update(updates) + if int(_chat["order"]) != 0: + del self.inactive_chats[chat_id] + self.add_chat(_chat) + log.info(f"Marked chat '{_chat['title']}' as active") + return True + return False + + log.warning(f"Can't find chat {chat_id} in existing chats") + return False + + +class MsgModel: + def __init__(self, tg: Tdlib) -> None: + self.tg = tg + self.msgs: Dict[int, Dict[int, Dict]] = defaultdict(dict) + self.current_msgs: Dict[int, int] = defaultdict(int) + self.not_found: Set[int] = set() + self.msg_ids: Dict[int, List[int]] = defaultdict(list) + + def jump_to_msg_by_id(self, chat_id: int, msg_id: int) -> bool: + if index := self.msg_ids[chat_id].index(msg_id): + self.current_msgs[chat_id] = index + return True + return False + + def next_msg(self, chat_id: int, step: int = 1) -> bool: + current_msg = self.current_msgs[chat_id] + if current_msg == 0: + return False + self.current_msgs[chat_id] = max(0, current_msg - step) + return True + + def jump_bottom(self, chat_id: int) -> bool: + if self.current_msgs[chat_id] == 0: + return False + self.current_msgs[chat_id] = 0 + return True + + def prev_msg(self, chat_id: int, step: int = 1) -> bool: + new_idx = self.current_msgs[chat_id] + step + if new_idx < len(self.msg_ids[chat_id]): + self.current_msgs[chat_id] = new_idx + return True + return False + + def get_message(self, chat_id: int, msg_id: int) -> Optional[Dict]: + if msg_id in self.not_found: + return None + if msg := self.msgs[chat_id].get(msg_id): + return msg + result = self.tg.get_message(chat_id, msg_id) + result.wait() + if result.error: + self.not_found.add(msg_id) + return None + return result.update + + def remove_messages(self, chat_id: int, msg_ids: List[int]) -> None: + log.info(f"removing msg {msg_ids=}") + for msg_id in msg_ids: + try: + self.msg_ids[chat_id].remove(msg_id) + except ValueError: + pass + self.msgs[chat_id].pop(msg_id, None) + + def add_message(self, chat_id: int, msg: Dict[str, Any]) -> None: + log.info(f"adding {msg=}") + msg_id = msg["id"] + ids = self.msg_ids[chat_id] + self.msgs[chat_id][msg_id] = msg + ids.insert(0, msg_id) + if len(ids) >= 2 and msg_id < ids[1]: + self.msg_ids[chat_id].sort(reverse=True) + + def update_msg_content_opened(self, chat_id: int, msg_id: int) -> None: + msg = self.msgs[chat_id].get(msg_id) + if not msg: + return + msg_proxy = MsgProxy(msg) + if msg_proxy.content_type == "voice": + msg_proxy.is_listened = True + elif msg_proxy.content_type == "recording": + msg_proxy.is_viewed = True + # TODO: start the TTL timer for self-destructing messages + # that is the last case to implement + # https://core.telegram.org/tdlib/docs/classtd_1_1td__api_1_1update_message_content_opened.html + + def update_msg( + self, chat_id: int, msg_id: int, **fields: Dict[str, Any] + ) -> None: + msg = self.msgs[chat_id].get(msg_id) + if not msg: + return + msg.update(fields) + + def _fetch_msgs_until_limit( + self, chat_id: int, offset: int = 0, limit: int = 10 + ) -> List[Dict[str, Any]]: + if self.msgs[chat_id]: + result = self.tg.get_chat_history( + chat_id, + from_message_id=self.msg_ids[chat_id][-1], + limit=len(self.msg_ids[chat_id]) + limit, + ) + else: + result = self.tg.get_chat_history( + chat_id, + offset=len(self.msg_ids[chat_id]), + limit=len(self.msg_ids[chat_id]) + limit, + ) + result.wait() + if not result or not result.update["messages"]: + return [] + + messages = result.update["messages"] + + # tdlib could doesn't guarantee number of messages, so we need to + # send another request on demand + # see https://github.com/tdlib/td/issues/168 + for i in range(3): + if len(messages) >= limit + offset: + break + result = self.tg.get_chat_history( + chat_id, + from_message_id=messages[-1]["id"], + limit=len(self.msg_ids[chat_id]) + limit, + ) + result.wait() + messages += result.update["messages"] + + return messages + + def fetch_msgs( + self, chat_id: int, offset: int = 0, limit: int = 10 + ) -> List[Tuple[int, Dict[str, Any]]]: + if offset + limit > len(self.msg_ids[chat_id]): + msgs = self._fetch_msgs_until_limit( + chat_id, offset, offset + limit + ) + for msg in msgs: + self.add_message(chat_id, msg) + + return [ + (i, self.msgs[chat_id][msg_id]) + for i, msg_id in enumerate( + self.msg_ids[chat_id][offset : offset + limit] + ) + ] + + def edit_message(self, chat_id: int, message_id: int, text: str) -> bool: + log.info("Editing msg") + result = self.tg.edit_message_text(chat_id, message_id, text) + + result.wait() + if result.error: + log.info(f"send message error: {result.error_info}") + return False + else: + log.info(f"message has been sent: {result.update}") + return True + + def send_message(self, chat_id: int, text: str) -> None: + result = self.tg.send_message(chat_id, text) + result.wait() + if result.error: + log.info(f"send message error: {result.error_info}") + else: + log.info(f"message has been sent: {result.update}") + + +User = namedtuple("User", ["id", "name", "status", "order"]) + + +class UserModel: + + types = { + "userTypeUnknown": "unknown", + "userTypeBot": "bot", + "userTypeDeleted": "deleted", + "userTypeRegular": "regular", + } + + def __init__(self, tg: Tdlib) -> None: + self.tg = tg + self.me: Dict[str, Any] = {} + self.users: Dict[int, Dict] = {} + self.groups: Dict[int, Dict] = {} + self.supergroups: Dict[int, Dict] = {} + self.actions: Dict[int, Dict] = {} + self.not_found: Set[int] = set() + self.contacts: Dict[str, Any] = {} + + def get_me(self) -> Dict[str, Any]: + if self.me: + return self.me + result = self.tg.get_me() + result.wait() + if result.error: + log.error(f"get myself error: {result.error_info}") + return {} + self.me = result.update + return self.me + + def get_user_action( + self, chat_id: int + ) -> Tuple[Optional[int], Optional[str]]: + action = self.actions.get(chat_id) + if action is None: + return None, None + action_type = action["action"]["@type"] + user_id = action["user_id"] + try: + return user_id, ChatAction[action_type].value + except KeyError: + log.error(f"ChatAction type {action_type} not implemented") + return None, None + + def set_status(self, user_id: int, status: Dict[str, Any]) -> None: + if user_id not in self.users: + self.get_user(user_id) + self.users[user_id]["status"] = status + + def get_status(self, user_id: int) -> str: + if user_id not in self.users: + return "" + if self.is_bot(user_id): + return "bot" + user_status = self.users[user_id]["status"] + + try: + status = UserStatus[user_status["@type"]] + except KeyError: + log.error(f"UserStatus type {user_status} not implemented") + return "" + + if status == UserStatus.userStatusEmpty: + return "" + elif status == UserStatus.userStatusOnline: + expires = user_status["expires"] + if expires < time.time(): + return "" + return status.value + elif status == UserStatus.userStatusOffline: + was_online = user_status["was_online"] + ago = pretty_ts(was_online) + return f"last seen {ago}" + return f"last seen {status.value}" + + def get_user_status_order(self, user_id: int) -> int: + if user_id not in self.users: + return sys.maxsize + user_status = self.users[user_id]["status"] + + try: + status = UserStatus[user_status["@type"]] + except KeyError: + log.error(f"UserStatus type {user_status} not implemented") + return sys.maxsize + if status == UserStatus.userStatusOnline: + return 0 + elif status == UserStatus.userStatusOffline: + was_online = user_status["was_online"] + return time.time() - was_online + order = { + UserStatus.userStatusRecently: 1, + UserStatus.userStatusLastWeek: 2, + UserStatus.userStatusLastMonth: 3, + } + return order.get(status, sys.maxsize) + + def is_bot(self, user_id: int) -> bool: + user = self.get_user(user_id) + if user and user["type"]["@type"] == "userTypeBot": + return True + return False + + def is_online(self, user_id: int) -> bool: + user = self.get_user(user_id) + if ( + user + and user["type"]["@type"] != "userTypeBot" + and user["status"]["@type"] == "userStatusOnline" + and user["status"]["expires"] > time.time() + ): + return True + return False + + def get_user_full_info(self, user_id: int) -> Dict[str, Any]: + user = self.get_user(user_id) + if not user: + return user + if user.get("full_info"): + return user["full_info"] + + result = self.tg.get_user_full_info(user_id) + result.wait() + if result.error: + log.warning(f"get user full info error: {result.error_info}") + return {} + user["full_info"] = result.update + return result.update + + def get_user(self, user_id: int) -> Dict[str, Any]: + if user_id in self.not_found: + return {} + if user_id in self.users: + return self.users[user_id] + result = self.tg.get_user(user_id) + result.wait() + if result.error: + log.warning(f"get user error: {result.error_info}") + self.not_found.add(user_id) + return {} + self.users[user_id] = result.update + return result.update + + def get_group_info(self, group_id: int) -> Optional[Dict[str, Any]]: + if group_id in self.groups: + return self.groups[group_id] + self.tg.get_basic_group(group_id) + return None + + def get_supergroup_info( + self, supergroup_id: int + ) -> Optional[Dict[str, Any]]: + if supergroup_id in self.supergroups: + return self.supergroups[supergroup_id] + self.tg.get_supergroup(supergroup_id) + return None + + def get_contacts(self) -> Optional[Dict[str, Any]]: + if self.contacts: + return self.contacts + + result = self.tg.get_contacts() + result.wait() + + if result.error: + log.error("get contacts error: %s", result.error_info) + return None + self.contacts = result.update + return self.contacts + + def get_user_label(self, user_id: int) -> str: + if user_id == 0: + return "" + user = self.get_user(user_id) + if user.get("first_name") and user.get("last_name"): + return f'{user["first_name"]} {user["last_name"]}'[:20] + + if user.get("first_name"): + return f'{user["first_name"]}'[:20] + + if user.get("username"): + return "@" + user["username"] + return "" + + def get_users(self) -> List[User]: + contacts = self.get_contacts() + if contacts is None: + return [] + users = [] + for user_id in contacts["user_ids"]: + user_name = self.get_user_label(user_id) + status = self.get_status(user_id) + order = self.get_user_status_order(user_id) + users.append(User(user_id, user_name, status, order)) + return users diff --git a/arigram/msg.py b/arigram/msg.py new file mode 100644 index 0000000..9fa2450 --- /dev/null +++ b/arigram/msg.py @@ -0,0 +1,248 @@ +import logging +from datetime import datetime +from typing import Any, Dict, List, Optional + +from arigram import utils + +log = logging.getLogger(__name__) + + +class MsgProxy: + + fields_mapping = { + "messageDocument": ("document", "document"), + "messageVoiceNote": ("voice_note", "voice"), + "messageText": ("text", "text"), + "messagePhoto": ("photo", "sizes", -1, "photo"), + "messageAudio": ("audio", "audio"), + "messageVideo": ("video", "video"), + "messageVideoNote": ("video_note", "video"), + "messageSticker": ("sticker", "thumbnail", "photo"), + "messagePoll": (), + "messageAnimation": ("animation", "animation"), + } + + types = { + "messageDocument": "document", + "messageVoiceNote": "voice", + "messageText": "text", + "messagePhoto": "photo", + "messageAudio": "audio", + "messageVideo": "video", + "messageVideoNote": "recording", + "messageSticker": "sticker", + "messagePoll": "poll", + "messageAnimation": "animation", + } + + @classmethod + def get_doc(cls, msg: Dict[str, Any], deep: int = 10) -> Dict[str, Any]: + doc = msg["content"] + _type = doc["@type"] + fields = cls.fields_mapping.get(_type) + if fields is None: + log.error("msg type not supported: %s", _type) + return {} + for field in fields[:deep]: + if isinstance(field, int): + doc = doc[field] + else: + doc = doc.get(field) + if "file" in doc: + return doc["file"] + if doc is None: + return {} + return doc + + def __init__(self, msg: Dict[str, Any]) -> None: + self.msg = msg + + def __getitem__(self, key: str) -> Any: + return self.msg[key] + + def __setitem__(self, key: str, value: Any) -> None: + self.msg[key] = value + + @property + def type(self) -> Optional[str]: + return self.msg.get("@type") + + @property + def date(self) -> datetime: + return datetime.fromtimestamp(self.msg["date"]) + + @property + def is_message(self) -> bool: + return self.type == "message" + + @property + def content_type(self) -> Optional[str]: + return self.types.get(self.msg["content"]["@type"]) + + @property + def size(self) -> Optional[int]: + doc = self.get_doc(self.msg) + return doc.get("size") + + @property + def human_size(self) -> Optional[str]: + if self.size: + return utils.humanize_size(self.size) + + @property + def duration(self) -> Optional[str]: + if self.content_type not in ("audio", "voice", "video", "recording"): + return None + doc = self.get_doc(self.msg, deep=1) + return utils.humanize_duration(doc["duration"]) + + @property + def file_name(self) -> Optional[str]: + if self.content_type not in ("audio", "document", "video"): + return None + doc = self.get_doc(self.msg, deep=1) + return doc["file_name"] + + @property + def file_id(self) -> Optional[int]: + if self.content_type not in ( + "audio", + "document", + "photo", + "video", + "recording", + "sticker", + "voice", + "animation", + ): + return None + doc = self.get_doc(self.msg) + return doc["id"] + + @property + def local_path(self) -> Optional[str]: + if self.content_type is None: + return None + doc = self.get_doc(self.msg) + return doc["local"]["path"] + + @property + def local(self) -> Dict: + doc = self.get_doc(self.msg) + return doc.get("local", {}) + + @local.setter + def local(self, value: Dict) -> None: + if self.msg["content"]["@type"] is None: + return + doc = self.get_doc(self.msg) + doc["local"] = value + + @property + def is_text(self) -> bool: + return self.msg["content"]["@type"] == "messageText" + + @property + def is_poll(self) -> bool: + return self.msg["content"]["@type"] == "messagePoll" + + @property + def poll_question(self) -> str: + assert self.is_poll + return self.msg["content"]["poll"]["question"] + + @property + def poll_options(self) -> List[Dict]: + assert self.is_poll + return self.msg["content"]["poll"]["options"] + + @property + def is_closed_poll(self) -> Optional[bool]: + if not self.is_poll: + return None + return self.msg["content"]["poll"]["is_closed"] + + @property + def text_content(self) -> str: + return self.msg["content"]["text"]["text"] + + @property + def is_downloaded(self) -> bool: + doc = self.get_doc(self.msg) + return doc["local"]["is_downloading_completed"] + + @property + def is_listened(self) -> Optional[bool]: + if self.content_type != "voice": + return None + return self.msg["content"]["is_listened"] + + @is_listened.setter + def is_listened(self, value: bool) -> None: + if self.content_type == "voice": + self.msg["content"]["is_listened"] = value + + @property + def is_viewed(self) -> Optional[bool]: + if self.content_type != "recording": + return None + return self.msg["content"]["is_viewed"] + + @is_viewed.setter + def is_viewed(self, value: bool) -> None: + if self.content_type == "recording": + self.msg["content"]["is_viewed"] = value + + @property + def msg_id(self) -> int: + return self.msg["id"] + + @property + def can_be_edited(self) -> bool: + return self.msg["can_be_edited"] + + @property + def reply_msg_id(self) -> Optional[int]: + return self.msg.get("reply_to_message_id") + + @property + def reply_markup(self) -> Optional[Dict[str, Any]]: + return self.msg.get("reply_markup") + + @property + def reply_markup_rows(self) -> List[List[Dict[str, Any]]]: + assert self.reply_markup + return self.reply_markup.get("rows", []) + + @property + def chat_id(self) -> int: + return self.msg["chat_id"] + + @property + def sender_id(self) -> int: + return self.msg["sender"].get("user_id") or self.msg["sender"].get( + "chat_id" + ) + + @property + def forward(self) -> Optional[Dict[str, Any]]: + return self.msg.get("forward_info") + + @property + def caption(self) -> Optional[str]: + caption = self.msg["content"].get("caption") + if not caption: + return None + return caption["text"] + + @property + def sticker_emoji(self) -> Optional[str]: + if self.content_type != "sticker": + return None + return self.msg["content"].get("sticker", {}).get("emoji") + + @property + def is_animated(self) -> Optional[bool]: + if self.content_type != "sticker": + return None + return self.msg["content"].get("sticker", {}).get("is_animated") diff --git a/arigram/resources/arigram.png b/arigram/resources/arigram.png new file mode 100644 index 0000000..d5f247f Binary files /dev/null and b/arigram/resources/arigram.png differ diff --git a/arigram/tdlib.py b/arigram/tdlib.py new file mode 100644 index 0000000..17b1023 --- /dev/null +++ b/arigram/tdlib.py @@ -0,0 +1,448 @@ +from enum import Enum +from typing import Any, Dict, List, Optional, Union + +from telegram.client import AsyncResult, Telegram + + +class ChatAction(Enum): + chatActionTyping = "typing" + chatActionCancel = "cancel" + chatActionRecordingVideo = "recording video" + chatActionUploadingVideo = "uploading video" + chatActionRecordingVoiceNote = "recording voice" + chatActionUploadingVoiceNote = "uploading voice" + chatActionUploadingPhoto = "uploading photo" + chatActionUploadingDocument = "uploading document" + chatActionChoosingLocation = "choosing location" + chatActionChoosingContact = "choosing contact" + chatActionStartPlayingGame = "start playing game" + chatActionRecordingVideoNote = "recording video" + chatActionUploadingVideoNote = "uploading video" + + +class ChatType(Enum): + chatTypePrivate = "private" + chatTypeBasicGroup = "group" + chatTypeSupergroup = "supergroup" + channel = "channel" + chatTypeSecret = "secret" + + +class UserStatus(Enum): + userStatusEmpty = "" + userStatusOnline = "online" + userStatusOffline = "offline" + userStatusRecently = "recently" + userStatusLastWeek = "last week" + userStatusLastMonth = "last month" + + +class UserType(Enum): + userTypeRegular = "" + userTypeDeleted = "deleted" + userTypeBot = "bot" + userTypeUnknown = "unknownn" + + +class TextParseModeInput(Enum): + textParseModeMarkdown = "markdown" + textParseModeHTML = "html" + + +class SecretChatState(Enum): + secretChatStatePending = "pending" + secretChatStateReady = "ready" + secretChatStateClosed = "closed" + + +class Tdlib(Telegram): + def parse_text_entities( + self, + text: str, + parse_mode: TextParseModeInput = TextParseModeInput.textParseModeMarkdown, + version: int = 2, + ) -> AsyncResult: + """Offline synchronous method which returns parsed entities""" + data = { + "@type": "parseTextEntities", + "text": text, + "parse_mode": {"@type": parse_mode.name, "version": version}, + } + + return self._send_data(data) + + def send_message(self, chat_id: int, msg: str) -> AsyncResult: + text = {"@type": "formattedText", "text": msg} + + result = self.parse_text_entities(msg) + result.wait() + if not result.error: + text = result.update + + data = { + "@type": "sendMessage", + "chat_id": chat_id, + "input_message_content": { + "@type": "inputMessageText", + "text": text, + }, + } + + return self._send_data(data) + + def download_file( + self, + file_id: int, + priority: int = 16, + offset: int = 0, + limit: int = 0, + synchronous: bool = False, + ) -> None: + 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}, + }, + } + + return self._send_data(data) + + def search_contacts(self, target: str, limit: int = 10) -> AsyncResult: + 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}, + }, + } + 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}, + }, + } + 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}, + }, + } + 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}, + }, + } + return self._send_data(data) + + def send_video( + self, + file_path: str, + chat_id: int, + width: int, + 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}, + }, + } + 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}, + }, + } + 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}, + }, + } + 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, + } + 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, + } + 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, + } + 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, + } + 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, + } + return self._send_data(data) + + def forward_messages( + self, + chat_id: int, + from_chat_id: int, + message_ids: List[int], + as_album: bool = False, + send_copy: bool = False, + 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, + } + return self._send_data(data) + + def get_basic_group( + self, + basic_group_id: int, + ) -> AsyncResult: + 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, + } + return self._send_data(data) + + def get_supergroup( + self, + supergroup_id: int, + ) -> AsyncResult: + 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, + } + return self._send_data(data) + + def get_secret_chat( + self, + secret_chat_id: int, + ) -> AsyncResult: + 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}, + } + return self._send_data(data) + + def get_contacts(self) -> AsyncResult: + data = { + "@type": "getContacts", + } + return self._send_data(data) + + def leave_chat(self, chat_id: int) -> AsyncResult: + 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, + } + return self._send_data(data) + + def close_secret_chat(self, secret_chat_id: int) -> AsyncResult: + 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, + } + 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, + } + return self._send_data(data) + + def delete_chat_history( + self, chat_id: int, remove_from_chat_list: bool, revoke: bool = False + ) -> AsyncResult: + """ + 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, + } + return self._send_data(data) + + def get_user(self, user_id: int) -> AsyncResult: + 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, + } + return self._send_data(data) + + +def get_chat_type(chat: Dict[str, Any]) -> Optional[ChatType]: + try: + chat_type = ChatType[chat["type"]["@type"]] + if ( + chat_type == ChatType.chatTypeSupergroup + and chat["type"]["is_channel"] + ): + chat_type = ChatType.channel + return chat_type + except KeyError: + pass + return None + + +def is_group(chat_type: Union[str, ChatType]) -> bool: + return chat_type in ( + ChatType.chatTypeSupergroup, + ChatType.chatTypeBasicGroup, + ) diff --git a/arigram/update_handlers.py b/arigram/update_handlers.py new file mode 100644 index 0000000..f16a98f --- /dev/null +++ b/arigram/update_handlers.py @@ -0,0 +1,333 @@ +import logging +from functools import wraps +from typing import Any, Callable, Dict + +from arigram import config, utils +from arigram.controllers import Controller +from arigram.msg import MsgProxy + +log = logging.getLogger(__name__) + +UpdateHandler = Callable[[Controller, Dict[str, Any]], None] + +handlers: Dict[str, UpdateHandler] = {} + +max_download_size: int = utils.parse_size(config.MAX_DOWNLOAD_SIZE) + + +def update_handler( + update_type: str, +) -> Callable[[UpdateHandler], UpdateHandler]: + def decorator(fun: UpdateHandler) -> UpdateHandler: + global handlers + assert ( + update_type not in handlers + ), f"Update type <{update_type}> already has handler: {handlers[update_type]}" + + @wraps(fun) + def wrapper(controller: Controller, update: Dict[str, Any]) -> None: + try: + fun(controller, update) + except Exception: + log.exception("Error happened in handler: %s", update_type) + + handlers[update_type] = wrapper + return wrapper + + return decorator + + +@update_handler("updateMessageContent") +def update_message_content( + controller: Controller, update: Dict[str, Any] +) -> None: + chat_id = update["chat_id"] + message_id = update["message_id"] + controller.model.msgs.update_msg( + chat_id, message_id, content=update["new_content"] + ) + + current_chat_id = controller.model.current_chat_id + if current_chat_id == chat_id: + controller.render_msgs() + + +@update_handler("updateMessageEdited") +def update_message_edited( + controller: Controller, update: Dict[str, Any] +) -> None: + chat_id = update["chat_id"] + message_id = update["message_id"] + edit_date = update["edit_date"] + controller.model.msgs.update_msg(chat_id, message_id, edit_date=edit_date) + + current_chat_id = controller.model.current_chat_id + if current_chat_id == chat_id: + controller.render_msgs() + + +@update_handler("updateNewMessage") +def update_new_message(controller: Controller, update: Dict[str, Any]) -> None: + msg = MsgProxy(update["message"]) + controller.model.msgs.add_message(msg.chat_id, msg.msg) + current_chat_id = controller.model.current_chat_id + if current_chat_id == msg.chat_id: + controller.render_msgs() + if msg.file_id and msg.size and msg.size <= max_download_size: + controller.download(msg.file_id, msg.chat_id, msg["id"]) + + controller.notify_for_message(msg.chat_id, msg) + + +# outdated +@update_handler("updateChatOrder") +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"] + + 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"] + title = update["title"] + + current_chat_id = controller.model.current_chat_id + if controller.model.chats.update_chat(chat_id, title=title): + controller.refresh_current_chat(current_chat_id) + + +@update_handler("updateChatIsMarkedAsUnread") +def update_chat_is_marked_as_unread( + controller: Controller, update: Dict[str, Any] +) -> None: + chat_id = update["chat_id"] + is_marked_as_unread = update["is_marked_as_unread"] + + current_chat_id = controller.model.current_chat_id + if controller.model.chats.update_chat( + chat_id, is_marked_as_unread=is_marked_as_unread + ): + controller.refresh_current_chat(current_chat_id) + + +@update_handler("updateNewChat") +def update_new_chat(controller: Controller, update: Dict[str, Any]) -> None: + chat = update["chat"] + controller.model.chats.add_chat(chat) + + +@update_handler("updateChatIsPinned") +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"] + + current_chat_id = controller.model.current_chat_id + if controller.model.chats.update_chat( + chat_id, is_pinned=is_pinned, order=order + ): + controller.refresh_current_chat(current_chat_id) + + +@update_handler("updateChatReadOutbox") +def update_chat_read_outbox( + controller: Controller, update: Dict[str, Any] +) -> None: + chat_id = update["chat_id"] + last_read_outbox_message_id = update["last_read_outbox_message_id"] + + current_chat_id = controller.model.current_chat_id + if controller.model.chats.update_chat( + chat_id, last_read_outbox_message_id=last_read_outbox_message_id + ): + controller.refresh_current_chat(current_chat_id) + + +@update_handler("updateChatReadInbox") +def update_chat_read_inbox( + controller: Controller, update: Dict[str, Any] +) -> None: + chat_id = update["chat_id"] + last_read_inbox_message_id = update["last_read_inbox_message_id"] + unread_count = update["unread_count"] + + current_chat_id = controller.model.current_chat_id + if controller.model.chats.update_chat( + chat_id, + last_read_inbox_message_id=last_read_inbox_message_id, + unread_count=unread_count, + ): + controller.refresh_current_chat(current_chat_id) + + +@update_handler("updateChatDraftMessage") +def update_chat_draft_message( + controller: Controller, update: Dict[str, Any] +) -> None: + 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"] + + current_chat_id = controller.model.current_chat_id + if controller.model.chats.update_chat(chat_id, order=order): + controller.refresh_current_chat(current_chat_id) + + +@update_handler("updateChatLastMessage") +def update_chat_last_message( + controller: Controller, update: Dict[str, Any] +) -> None: + chat_id = update["chat_id"] + last_message = update.get("last_message") + if not last_message: + # according to documentation it can be null + log.warning("last_message is null: %s", update) + return + + info = {} + info["last_message"] = last_message + if len(update["positions"]) > 0: + info["order"] = update["positions"][0]["order"] + + current_chat_id = controller.model.current_chat_id + if controller.model.chats.update_chat(chat_id, **info): + controller.refresh_current_chat(current_chat_id) + + +@update_handler("updateChatNotificationSettings") +def update_chat_notification_settings( + controller: Controller, update: Dict[str, Any] +) -> None: + chat_id = update["chat_id"] + notification_settings = update["notification_settings"] + if controller.model.chats.update_chat( + chat_id, notification_settings=notification_settings + ): + controller.render() + + +@update_handler("updateMessageSendSucceeded") +def update_message_send_succeeded( + controller: Controller, update: Dict[str, Any] +) -> None: + chat_id = update["message"]["chat_id"] + msg_id = update["old_message_id"] + controller.model.msgs.add_message(chat_id, update["message"]) + controller.model.msgs.remove_messages(chat_id, [msg_id]) + + current_chat_id = controller.model.current_chat_id + if current_chat_id == chat_id: + controller.render_msgs() + + +@update_handler("updateFile") +def update_file(controller: Controller, update: Dict[str, Any]) -> None: + file_id = update["file"]["id"] + local = update["file"]["local"] + chat_id, msg_id = controller.model.downloads.get(file_id, (None, None)) + if chat_id is None or msg_id is None: + log.warning( + "Can't find information about file with file_id=%s", file_id + ) + return + msg = controller.model.msgs.msgs[chat_id].get(msg_id) + if not msg: + return + proxy = MsgProxy(msg) + proxy.local = local + controller.render_msgs() + if proxy.is_downloaded: + controller.model.downloads.pop(file_id) + + +@update_handler("updateMessageContentOpened") +def update_message_content_opened( + controller: Controller, update: Dict[str, Any] +) -> None: + chat_id = update["chat_id"] + message_id = update["message_id"] + controller.model.msgs.update_msg_content_opened(chat_id, message_id) + controller.render_msgs() + + +@update_handler("updateDeleteMessages") +def update_delete_messages( + controller: Controller, update: Dict[str, Any] +) -> None: + if not update["is_permanent"]: + log.debug("Ignoring deletiong becuase not permanent: %s", update) + return + chat_id = update["chat_id"] + msg_ids = update["message_ids"] + controller.model.msgs.remove_messages(chat_id, msg_ids) + controller.render_msgs() + + +@update_handler("updateConnectionState") +def update_connection_state( + controller: Controller, update: Dict[str, Any] +) -> None: + state = update["state"]["@type"] + states = { + "connectionStateWaitingForNetwork": "Waiting for network...", + "connectionStateConnectingToProxy": "Connecting to proxy...", + "connectionStateConnecting": "Connecting...", + "connectionStateUpdating": "Updating...", + # state exists, but when it's "Ready" we want to show "Chats" + # "connectionStateReady": "Ready", + } + controller.model.chats.title = states.get(state, "Chats") + controller.render_chats() + + +@update_handler("updateUserStatus") +def update_user_status(controller: Controller, update: Dict[str, Any]) -> None: + controller.model.users.set_status(update["user_id"], update["status"]) + controller.render() + + +@update_handler("updateBasicGroup") +def update_basic_group(controller: Controller, update: Dict[str, Any]) -> None: + basic_group = update["basic_group"] + controller.model.users.groups[basic_group["id"]] = basic_group + controller.render_msgs() + + +@update_handler("updateSupergroup") +def update_supergroup(controller: Controller, update: Dict[str, Any]) -> None: + supergroup = update["supergroup"] + controller.model.users.supergroups[supergroup["id"]] = supergroup + controller.render_msgs() + + +@update_handler("updateUserChatAction") +def update_user_chat_action( + controller: Controller, update: Dict[str, Any] +) -> None: + chat_id = update["chat_id"] + if update["action"]["@type"] == "chatActionCancel": + controller.model.users.actions.pop(chat_id, None) + else: + controller.model.users.actions[chat_id] = update + controller.render() diff --git a/arigram/utils.py b/arigram/utils.py new file mode 100644 index 0000000..108ee2c --- /dev/null +++ b/arigram/utils.py @@ -0,0 +1,322 @@ +import base64 +import curses +import hashlib +import logging +import mailcap +import math +import mimetypes +import os +import random +import shlex +import struct +import subprocess +import sys +import unicodedata +from datetime import datetime +from functools import lru_cache +from logging.handlers import RotatingFileHandler +from subprocess import CompletedProcess +from types import TracebackType +from typing import Any, Dict, Optional, Tuple, Type + +from arigram import config + +log = logging.getLogger(__name__) +units = {"B": 1, "KB": 10 ** 3, "MB": 10 ** 6, "GB": 10 ** 9, "TB": 10 ** 12} + + +class LogWriter: + def __init__(self, level: Any) -> None: + self.level = level + + def write(self, message: str) -> None: + if message != "\n": + self.level.log(self.level, message) + + def flush(self) -> None: + pass + + +def setup_log() -> None: + os.makedirs(config.LOG_PATH, exist_ok=True) + + handlers = [] + + for level, filename in ( + (config.LOG_LEVEL, "all.log"), + (logging.ERROR, "error.log"), + ): + handler = RotatingFileHandler( + os.path.join(config.LOG_PATH, filename), + maxBytes=parse_size("32MB"), + backupCount=1, + ) + handler.setLevel(level) # type: ignore + handlers.append(handler) + + logging.basicConfig( + format="%(levelname)s [%(asctime)s] %(filename)s:%(lineno)s - %(funcName)s | %(message)s", + handlers=handlers, + ) + logging.getLogger().setLevel(config.LOG_LEVEL) + sys.stderr = LogWriter(log.error) # type: ignore + logging.captureWarnings(True) + + +def get_mime(file_path: str) -> str: + mtype, _ = mimetypes.guess_type(file_path) + if not mtype: + return "" + if mtype == "image/gif": + return "animation" + return mtype.split("/")[0] + + +def get_mailcap() -> Dict: + if config.MAILCAP_FILE: + with open(config.MAILCAP_FILE) as f: + return mailcap.readmailcapfile(f) # type: ignore + return mailcap.getcaps() + + +def get_file_handler(file_path: str) -> str: + mtype, _ = mimetypes.guess_type(file_path) + if not mtype: + return config.DEFAULT_OPEN.format(file_path=shlex.quote(file_path)) + + caps = get_mailcap() + handler, view = mailcap.findmatch(caps, mtype, filename=file_path) + if not handler: + return config.DEFAULT_OPEN.format(file_path=shlex.quote(file_path)) + return handler + + +def parse_size(size: str) -> int: + if size[-2].isalpha(): + number, unit = size[:-2], size[-2:] + else: + number, unit = size[:-1], size[-1:] + return int(float(number) * units[unit]) + + +def humanize_size( + num: int, + suffix: str = "B", + suffixes: Tuple[str, ...] = ( + "", + "K", + "M", + "G", + "T", + "P", + "E", + "Z", + ), +) -> str: + magnitude = int(math.floor(math.log(num, 1024))) + val = num / math.pow(1024, magnitude) + if magnitude > 7: + return "{:.1f}{}{}".format(val, "Yi", suffix) + return "{:3.1f}{}{}".format(val, suffixes[magnitude], suffix) + + +def humanize_duration(seconds: int) -> str: + dt = datetime.utcfromtimestamp(seconds) + fmt = "%-M:%S" + if seconds >= 3600: + fmt = "%-H:%M:%S" + return dt.strftime(fmt) + + +def num(value: str, default: Optional[int] = None) -> Optional[int]: + try: + return int(value) + except ValueError: + return default + + +def is_yes(resp: str) -> bool: + return not resp or resp.strip().lower() == "y" + + +def is_no(resp: str) -> bool: + return not resp or resp.strip().lower() == "n" + + +def get_duration(file_path: str) -> int: + cmd = f"ffprobe -v error -i '{file_path}' -show_format" + stdout = subprocess.check_output(shlex.split(cmd)).decode().splitlines() + line = next((line for line in stdout if "duration" in line), None) + if line: + _, duration = line.split("=") + log.info("duration: %s", duration) + return int(float(duration)) + return 0 + + +def get_video_resolution(file_path: str) -> Tuple[int, int]: + cmd = f"ffprobe -v error -show_entries stream=width,height -of default=noprint_wrappers=1 '{file_path}'" + lines = subprocess.check_output(shlex.split(cmd)).decode().splitlines() + info = {line.split("=")[0]: line.split("=")[1] for line in lines} + return int(str(info.get("width"))), int(str(info.get("height"))) + + +def get_waveform(file_path: str) -> str: + # stub for now + del file_path + waveform = (random.randint(0, 255) for _ in range(100)) + packed = struct.pack("100B", *waveform) + return base64.b64encode(packed).decode() + + +safe_map = str.maketrans({"'": "", "`": "", '"': ""}) + + +def notify( + msg: str, + subtitle: str = "", + title: str = "tg", + function: Any = config.NOTIFY_FUNCTION, +) -> None: + if not function: + return + + function( + icon_path=shlex.quote(config.ICON_PATH), + title=shlex.quote(title), + subtitle=shlex.quote(subtitle.translate(safe_map)), + msg=shlex.quote(msg.translate(safe_map)), + ) + + +def string_len_dwc(string: str) -> int: + """Returns string len including count for double width characters""" + return sum(1 + (unicodedata.east_asian_width(c) in "WF") for c in string) + + +def truncate_to_len(string: str, width: int) -> str: + real_len = string_len_dwc(string) + if real_len <= width: + return string + + cur_len = 0 + out_string = "" + + for char in string: + cur_len += 2 if unicodedata.east_asian_width(char) in "WF" else 1 + out_string += char + if cur_len >= width: + break + return out_string + + +def copy_to_clipboard(text: str) -> None: + subprocess.run( + config.COPY_CMD, universal_newlines=True, input=text, shell=True + ) + + +class suspend: + # FIXME: can't explicitly set type "View" due to circular import + def __init__(self, view: Any) -> None: + self.view = view + + def call(self, cmd: str) -> CompletedProcess: + return subprocess.run(cmd, shell=True) + + def run_with_input(self, cmd: str, text: str) -> None: + proc = subprocess.run( + cmd, universal_newlines=True, input=text, shell=True + ) + if proc.returncode: + input(f"Command <{cmd}> failed: press to continue") + + def open_file(self, file_path: str, cmd: str = None) -> None: + if cmd: + cmd = cmd % shlex.quote(file_path) + else: + cmd = get_file_handler(file_path) + + proc = self.call(cmd) + if proc.returncode: + input(f"Command <{cmd}> failed: press to continue") + + def __enter__(self) -> "suspend": + for view in (self.view.chats, self.view.msgs, self.view.status): + view._refresh = view.win.noutrefresh + self.view.resize_handler = self.view.resize_stub + curses.echo() + curses.nocbreak() + self.view.stdscr.keypad(False) + curses.curs_set(1) + curses.endwin() + return self + + def __exit__( + self, + exc_type: Optional[Type[BaseException]], + exc_val: Optional[BaseException], + tb: Optional[TracebackType], + ) -> None: + del exc_type, exc_val, tb + for view in (self.view.chats, self.view.msgs, self.view.status): + view._refresh = view.win.refresh + self.view.resize_handler = self.view.resize + curses.noecho() + curses.cbreak() + self.view.stdscr.keypad(True) + curses.curs_set(0) + curses.doupdate() + + +def set_shorter_esc_delay(delay: int = 25) -> None: + os.environ.setdefault("ESCDELAY", str(delay)) + + +def pretty_ts(ts: int) -> str: + now = datetime.utcnow() + diff = now - datetime.utcfromtimestamp(ts) + second_diff = diff.seconds + day_diff = diff.days + + if day_diff < 0: + return "" + + if day_diff == 0: + if second_diff < 10: + return "just now" + if second_diff < 60: + return f"{second_diff} seconds ago" + if second_diff < 120: + return "a minute ago" + if second_diff < 3600: + return f"{int(second_diff / 60)} minutes ago" + if second_diff < 7200: + return "an hour ago" + if second_diff < 86400: + return f"{int(second_diff / 3600)} hours ago" + if day_diff == 1: + return "yesterday" + if day_diff < 7: + return f"{day_diff} days ago" + if day_diff < 31: + return f"{int(day_diff / 7)} weeks ago" + if day_diff < 365: + return f"{int(day_diff / 30)} months ago" + return f"{int(day_diff / 365)} years ago" + + +@lru_cache(maxsize=256) +def get_color_by_str(user: str) -> int: + index = int(hashlib.sha1(user.encode()).hexdigest(), 16) % len( + config.USERS_COLORS + ) + return config.USERS_COLORS[index] + + +def cleanup_cache() -> None: + if not config.KEEP_MEDIA: + return + files_path = os.path.join(config.FILES_DIR, "files") + cmd = f"find {files_path} -type f -mtime +{config.KEEP_MEDIA} -delete" + subprocess.Popen(cmd, shell=True) diff --git a/arigram/views.py b/arigram/views.py new file mode 100644 index 0000000..2c3119d --- /dev/null +++ b/arigram/views.py @@ -0,0 +1,727 @@ +import curses +import logging +from datetime import datetime +from typing import Any, Dict, List, Optional, Tuple, Union, cast + +from _curses import window + +from arigram import config +from arigram.colors import ( + bold, + cyan, + get_color, + magenta, + reverse, + white, + yellow, +) +from arigram.models import Model, UserModel +from arigram.msg import MsgProxy +from arigram.tdlib import ChatType, get_chat_type, is_group +from arigram.utils import ( + get_color_by_str, + num, + string_len_dwc, + truncate_to_len, +) + +log = logging.getLogger(__name__) + +MAX_KEYBINDING_LENGTH = 5 +MULTICHAR_KEYBINDINGS = ( + "dd", + "sd", + "sp", + "sa", + "sv", + "sn", + "ns", + "ng", + "bp", +) + + +class Win: + """Proxy for win object to log error and continue working""" + + def __init__(self, win: window): + self.win = win + + def addstr(self, y: int, x: int, _str: str, attr: int = 0) -> None: + try: + return self.win.addstr(y, x, _str, attr) + except Exception: + log.exception(f"Error drawing: {y=}, {x=}, {_str=}, {attr=}") + + def __getattribute__(self, name: str) -> Any: + if name in ("win", "addstr"): + return object.__getattribute__(self, name) + return self.win.__getattribute__(name) + + +class View: + def __init__( + self, + stdscr: window, + chat_view: "ChatView", + msg_view: "MsgView", + status_view: "StatusView", + ) -> None: + curses.noecho() + curses.cbreak() + stdscr.keypad(True) + curses.curs_set(0) + + curses.start_color() + curses.use_default_colors() + # init white color first to initialize colors correctly + get_color(white, -1) + + self.stdscr = stdscr + self.chats = chat_view + self.msgs = msg_view + self.status = status_view + self.max_read = 2048 + self.resize_handler = self.resize + + def resize_stub(self) -> None: + pass + + def resize(self) -> None: + curses.endwin() + self.stdscr.refresh() + + def get_keys(self) -> Tuple[int, str]: + keys = repeat_factor = "" + + for _ in range(MAX_KEYBINDING_LENGTH): + ch = self.stdscr.getch() + log.info("raw ch without unctrl: %s", ch) + try: + key = curses.unctrl(ch).decode() + except Exception: + log.warning("cant uncrtl: %s", ch) + break + if key.isdigit(): + repeat_factor += key + continue + keys += key + # if match found or there are not any shortcut matches at all + if all( + p == keys or not p.startswith(keys) + for p in MULTICHAR_KEYBINDINGS + ): + break + + return cast(int, num(repeat_factor, default=1)), keys or "UNKNOWN" + + +class StatusView: + def __init__(self, stdscr: window) -> None: + self.h = 1 + self.w = curses.COLS + self.y = curses.LINES - 1 + self.x = 0 + self.stdscr = stdscr + self.win = Win(stdscr.subwin(self.h, self.w, self.y, self.x)) + self._refresh = self.win.refresh + + def resize(self, rows: int, cols: int) -> None: + self.w = cols - 1 + self.y = rows - 1 + self.win.resize(self.h, self.w) + self.win.mvwin(self.y, self.x) + + def draw(self, msg: str = "") -> None: + self.win.clear() + self.win.addstr(0, 0, msg.replace("\n", " ")[: self.w]) + self._refresh() + + def get_input(self, prefix: str = "") -> Optional[str]: + curses.curs_set(1) + buff = "" + + try: + while True: + self.win.erase() + line = buff[-(self.w - 1) :] + self.win.addstr(0, 0, f"{prefix}{line}") + + key = self.win.get_wch( + 0, min(string_len_dwc(buff + prefix), self.w - 1) + ) + key = ord(key) + if key == 10: # return + break + elif key == 127 or key == 8: # del + if buff: + buff = buff[:-1] + elif key in (7, 27): # (^G, ) cancel + return None + elif chr(key).isprintable(): + buff += chr(key) + finally: + self.win.clear() + curses.curs_set(0) + curses.cbreak() + curses.noecho() + + return buff + + +class ChatView: + def __init__(self, stdscr: window, model: Model) -> None: + self.stdscr = stdscr + self.h = 0 + self.w = 0 + self.win = Win(stdscr.subwin(self.h, self.w, 0, 0)) + self._refresh = self.win.refresh + self.model = model + + def resize(self, rows: int, cols: int, width: int) -> None: + self.h = rows - 1 + self.w = width + self.win.resize(self.h, self.w) + + def _msg_color(self, is_selected: bool = False) -> int: + color = get_color(white, -1) + if is_selected: + return color | reverse + return color + + def _unread_color(self, is_selected: bool = False) -> int: + color = get_color(magenta, -1) + if is_selected: + return color | reverse + return color + + def _chat_attributes( + self, is_selected: bool, title: str, user: Optional[str] + ) -> Tuple[int, ...]: + attrs = ( + get_color(cyan, -1), + get_color(get_color_by_str(title), -1), + get_color(get_color_by_str(user or ""), -1), + self._msg_color(is_selected), + ) + if is_selected: + return tuple(attr | reverse for attr in attrs) + return attrs + + def draw( + self, current: int, chats: List[Dict[str, Any]], title: str = "Chats" + ) -> None: + self.win.erase() + line = curses.ACS_VLINE + width = self.w - 1 + + self.win.vline(0, width, line, self.h) + self.win.addstr( + 0, 0, title.center(width)[:width], get_color(cyan, -1) | bold + ) + + for i, chat in enumerate(chats, 1): + is_selected = i == current + 1 + date = get_date(chat) + title = chat["title"] + offset = 0 + + last_msg_sender, last_msg = self._get_last_msg_data(chat) + sender_label = f" {last_msg_sender}" if last_msg_sender else "" + flags = self._get_flags(chat) + flags_len = string_len_dwc(flags) + + if flags: + self.win.addstr( + i, + max(0, width - flags_len), + truncate_to_len(flags, width)[-width:], + # flags[-width:], + self._unread_color(is_selected), + ) + + for attr, elem in zip( + self._chat_attributes(is_selected, title, last_msg_sender), + [f"{date} ", title, sender_label, f" {last_msg}"], + ): + if not elem: + continue + item = truncate_to_len( + elem, max(0, width - offset - flags_len) + ) + + if len(item) > 1: + self.win.addstr(i, offset, item, attr) + offset += string_len_dwc(elem) + + self._refresh() + + def _get_last_msg_data( + self, chat: Dict[str, Any] + ) -> Tuple[Optional[str], Optional[str]]: + user, last_msg = get_last_msg(chat, self.model.users) + last_msg = last_msg.replace("\n", " ") + if user: + last_msg_sender = self.model.users.get_user_label(user) + chat_type = get_chat_type(chat) + if chat_type and is_group(chat_type): + return last_msg_sender, last_msg + + return None, last_msg + + def _get_flags(self, chat: Dict[str, Any]) -> str: + flags = [] + + msg = chat.get("last_message") + if ( + msg + and self.model.is_me(msg["sender"].get("user_id")) + and msg["id"] > chat["last_read_outbox_message_id"] + and not self.model.is_me(chat["id"]) + ): + # last msg haven't been seen by recipient + flags.append("unseen") + elif ( + msg + and self.model.is_me(msg["sender"].get("user_id")) + and msg["id"] <= chat["last_read_outbox_message_id"] + ): + flags.append("seen") + + if action_label := _get_action_label(self.model.users, chat): + flags.append(action_label) + + if self.model.users.is_online(chat["id"]): + flags.append("online") + + if "is_pinned" in chat and chat["is_pinned"]: + flags.append("pinned") + + if chat["notification_settings"]["mute_for"]: + flags.append("muted") + + if chat["is_marked_as_unread"]: + flags.append("unread") + elif chat["unread_count"]: + flags.append(str(chat["unread_count"])) + + if get_chat_type(chat) == ChatType.chatTypeSecret: + flags.append("secret") + + label = " ".join(config.CHAT_FLAGS.get(flag, flag) for flag in flags) + if label: + return f" {label}" + return label + + +class MsgView: + def __init__( + self, + stdscr: window, + model: Model, + ) -> None: + self.model = model + self.stdscr = stdscr + self.h = 0 + self.w = 0 + self.x = 0 + self.win = Win(self.stdscr.subwin(self.h, self.w, 0, self.x)) + self._refresh = self.win.refresh + self.states = { + "messageSendingStateFailed": "failed", + "messageSendingStatePending": "pending", + } + + def resize(self, rows: int, cols: int, width: int) -> None: + self.h = rows - 1 + self.w = width + self.x = cols - self.w + self.win.resize(self.h, self.w) + self.win.mvwin(0, self.x) + + def _get_flags(self, msg_proxy: MsgProxy) -> str: + flags = [] + chat = self.model.chats.chats[self.model.current_chat] + + if msg_proxy.msg_id in self.model.selected[chat["id"]]: + flags.append("selected") + + if msg_proxy.forward is not None: + flags.append("forwarded") + + if ( + not self.model.is_me(msg_proxy.sender_id) + and msg_proxy.msg_id > chat["last_read_inbox_message_id"] + ): + flags.append("new") + elif ( + self.model.is_me(msg_proxy.sender_id) + and msg_proxy.msg_id > chat["last_read_outbox_message_id"] + ): + if not self.model.is_me(chat["id"]): + flags.append("unseen") + elif ( + self.model.is_me(msg_proxy.sender_id) + and msg_proxy.msg_id <= chat["last_read_outbox_message_id"] + ): + flags.append("seen") + if state := msg_proxy.msg.get("sending_state"): + log.info("state: %s", state) + state_type = state["@type"] + flags.append(self.states.get(state_type, state_type)) + if msg_proxy.msg["edit_date"]: + flags.append("edited") + + if not flags: + return "" + return " ".join(config.MSG_FLAGS.get(flag, flag) for flag in flags) + + def _format_reply_msg( + self, chat_id: int, msg: str, reply_to: int, width_limit: int + ) -> str: + _msg = self.model.msgs.get_message(chat_id, reply_to) + if not _msg: + return msg + reply_msg = MsgProxy(_msg) + if reply_msg_content := self._parse_msg(reply_msg): + reply_sender = self.model.users.get_user_label(reply_msg.sender_id) + sender_name = f" {reply_sender}:" if reply_sender else "" + reply_line = f">{sender_name} {reply_msg_content}" + if len(reply_line) >= width_limit: + reply_line = f"{reply_line[:width_limit - 4]}..." + msg = f"{reply_line}\n{msg}" + return msg + + @staticmethod + def _format_url(msg_proxy: MsgProxy) -> str: + if not msg_proxy.is_text or "web_page" not in msg_proxy.msg["content"]: + return "" + web = msg_proxy.msg["content"]["web_page"] + page_type = web["type"] + if page_type == "photo": + return f"\n | photo: {web['url']}" + name = web["site_name"] + title = web["title"] + description = web["description"]["text"].replace("\n", "") + url = f"\n | {name}: {title}" + if description: + url += f"\n | {description}" + return url + + def _format_msg(self, msg_proxy: MsgProxy, width_limit: int) -> str: + msg = self._parse_msg(msg_proxy) + if caption := msg_proxy.caption: + msg += "\n" + caption.replace("\n", " ") + msg += self._format_url(msg_proxy) + if reply_to := msg_proxy.reply_msg_id: + msg = self._format_reply_msg( + msg_proxy.chat_id, msg, reply_to, width_limit + ) + if reply_markup := self._format_reply_markup(msg_proxy): + msg += reply_markup + + return msg + + @staticmethod + def _format_reply_markup(msg_proxy: MsgProxy) -> str: + msg = "" + reply_markup = msg_proxy.reply_markup + if not reply_markup: + return msg + for row in msg_proxy.reply_markup_rows: + msg += "\n" + for item in row: + text = item.get("text") + if not text: + continue + _type = item.get("type", {}) + if _type.get("@type") == "inlineKeyboardButtonTypeUrl": + if url := _type.get("url"): + text = f"{text} ({url})" + msg += f"| {text} " + msg += "|" + return msg + + def _collect_msgs_to_draw( + self, + current_msg_idx: int, + msgs: List[Tuple[int, Dict[str, Any]]], + min_msg_padding: int, + ) -> List[Tuple[Tuple[str, ...], bool, int]]: + """ + Tries to collect list of messages that will satisfy `min_msg_padding` + theshold. Long messages could prevent other messages from displaying on + the screen. In order to prevent scenario when *selected* message moved + out from the visible area of the screen by some long messages, this + function will remove message one by one from the start until selected + message could be visible on the screen. + """ + selected_item_idx: Optional[int] = None + collected_items: List[Tuple[Tuple[str, ...], bool, int]] = [] + for ignore_before in range(len(msgs)): + if selected_item_idx is not None: + break + collected_items = [] + line_num = self.h + for msg_idx, msg_item in msgs[ignore_before:]: + is_selected_msg = current_msg_idx == msg_idx + msg_proxy = MsgProxy(msg_item) + dt = msg_proxy.date.strftime("%H:%M:%S") + user_id_item = msg_proxy.sender_id + + user_id = self.model.users.get_user_label(user_id_item) + flags = self._get_flags(msg_proxy) + if user_id and flags: + # if not channel add space between name and flags + flags = f" {flags}" + label_elements = f" {dt} ", user_id, flags + label_len = sum(string_len_dwc(e) for e in label_elements) + + msg = self._format_msg( + msg_proxy, width_limit=self.w - label_len - 1 + ) + elements = *label_elements, f" {msg}" + needed_lines = 0 + for i, msg_line in enumerate(msg.split("\n")): + # count wide character utf-8 symbols that take > 1 bytes to + # print it causes invalid offset + line_len = string_len_dwc(msg_line) + + # first line cotains msg lable, e.g user name, date + if i == 0: + line_len += label_len + + needed_lines += (line_len // self.w) + 1 + + line_num -= needed_lines + if line_num < 0: + tail_lines = needed_lines + line_num - 1 + # try preview long message that did fit in the screen + if tail_lines > 0 and not is_selected_msg: + limit = self.w * tail_lines + tail_chatacters = len(msg) - limit - 3 + elements = ( + "", + "", + "", + f" ...{msg[tail_chatacters:]}", + ) + collected_items.append((elements, is_selected_msg, 0)) + break + collected_items.append((elements, is_selected_msg, line_num)) + if is_selected_msg: + selected_item_idx = len(collected_items) - 1 + if ( + # ignore first and last msg + selected_item_idx not in (0, len(msgs) - 1, None) + and selected_item_idx is not None + and len(collected_items) - 1 - selected_item_idx + < min_msg_padding + ): + selected_item_idx = None + + return collected_items + + def draw( + self, + current_msg_idx: int, + msgs: List[Tuple[int, Dict[str, Any]]], + min_msg_padding: int, + chat: Dict[str, Any], + ) -> None: + self.win.erase() + msgs_to_draw = self._collect_msgs_to_draw( + current_msg_idx, msgs, min_msg_padding + ) + + if not msgs_to_draw: + log.error("Can't collect message for drawing!") + + for elements, selected, line_num in msgs_to_draw: + column = 0 + user = elements[1] + for attr, elem in zip( + self._msg_attributes(selected, user), elements + ): + if not elem: + continue + lines = (column + string_len_dwc(elem)) // self.w + last_line = self.h == line_num + lines + # work around agaist curses behaviour, when you cant write + # char to the lower right coner of the window + # see https://stackoverflow.com/questions/21594778/how-to-fill-to-lower-right-corner-in-python-curses/27517397#27517397 + if last_line: + start, stop = 0, self.w - column + for i in range(lines): + # insstr does not wraps long strings + self.win.insstr( + line_num + i, + column if not i else 0, + elem[start:stop], + attr, + ) + start, stop = stop, stop + self.w + else: + self.win.addstr(line_num, column, elem, attr) + column += string_len_dwc(elem) + + self.win.addstr( + 0, 0, self._msg_title(chat), get_color(cyan, -1) | bold + ) + + self._refresh() + + def _msg_title(self, chat: Dict[str, Any]) -> str: + chat_type = get_chat_type(chat) + status = "" + + if action_label := _get_action_label(self.model.users, chat): + status = action_label + elif chat_type == ChatType.chatTypePrivate: + status = self.model.users.get_status(chat["id"]) + elif chat_type == ChatType.chatTypeBasicGroup: + if group := self.model.users.get_group_info( + chat["type"]["basic_group_id"] + ): + status = f"{group['member_count']} members" + elif chat_type == ChatType.chatTypeSupergroup: + if supergroup := self.model.users.get_supergroup_info( + chat["type"]["supergroup_id"] + ): + status = f"{supergroup['member_count']} members" + elif chat_type == ChatType.channel: + if supergroup := self.model.users.get_supergroup_info( + chat["type"]["supergroup_id"] + ): + status = f"{supergroup['member_count']} subscribers" + + return f"{chat['title']}: {status}".center(self.w)[: self.w] + + def _msg_attributes(self, is_selected: bool, user: str) -> Tuple[int, ...]: + attrs = ( + get_color(cyan, -1), + get_color(get_color_by_str(user), -1), + get_color(yellow, -1), + get_color(white, -1), + ) + + if is_selected: + return tuple(attr | reverse for attr in attrs) + return attrs + + def _parse_msg(self, msg: MsgProxy) -> str: + if msg.is_message: + return parse_content(msg, self.model.users) + log.debug("Unknown message type: %s", msg) + return "unknown msg type: " + str(msg["content"]) + + +def get_last_msg( + chat: Dict[str, Any], users: UserModel +) -> Tuple[Optional[int], str]: + last_msg = chat.get("last_message") + if not last_msg: + return None, "" + return ( + last_msg["sender"].get("user_id"), + parse_content(MsgProxy(last_msg), users), + ) + + +def get_date(chat: Dict[str, Any]) -> str: + last_msg = chat.get("last_message") + if not last_msg: + return "" + dt = datetime.fromtimestamp(last_msg["date"]) + date_fmt = "%d %b %y" + if datetime.today().date() == dt.date(): + date_fmt = "%H:%M" + elif datetime.today().year == dt.year: + date_fmt = "%d %b" + return dt.strftime(date_fmt) + + +def parse_content(msg: MsgProxy, users: UserModel) -> str: + if msg.is_text: + return msg.text_content.replace("\n", " ") + + content = msg["content"] + _type = content["@type"] + + if _type == "messageBasicGroupChatCreate": + return f"[created the group \"{content['title']}\"]" + if _type == "messageChatAddMembers": + user_ids = content["member_user_ids"] + if user_ids[0] == msg.sender_id: + return "[joined the group]" + users_name = ", ".join( + users.get_user_label(user_id) for user_id in user_ids + ) + return f"[added {users_name}]" + if _type == "messageChatDeleteMember": + user_id = content["user_id"] + if user_id == msg.sender_id: + return "[left the group]" + user_name = users.get_user_label(user_id) + return f"[removed {user_name}]" + if _type == "messageChatChangeTitle": + return f"[changed the group name to \"{content['title']}\"]" + + if not msg.content_type: + # not implemented + return f"[{_type}]" + + content_text = "" + if msg.is_poll: + content_text = f"\n {msg.poll_question}" + for option in msg.poll_options: + content_text += f"\n * {option['voter_count']} ({option['vote_percentage']}%) | {option['text']}" + + fields = dict( + name=msg.file_name, + download=get_download(msg.local, msg.size), + size=msg.human_size, + duration=msg.duration, + listened=format_bool(msg.is_listened), + viewed=format_bool(msg.is_viewed), + animated=msg.is_animated, + emoji=msg.sticker_emoji, + closed=msg.is_closed_poll, + ) + info = ", ".join(f"{k}={v}" for k, v in fields.items() if v is not None) + + return f"[{msg.content_type}: {info}]{content_text}" + + +def format_bool(value: Optional[bool]) -> Optional[str]: + if value is None: + return None + return "yes" if value else "no" + + +def get_download( + local: Dict[str, Union[str, bool, int]], size: Optional[int] +) -> Optional[str]: + if not size: + return None + elif local["is_downloading_completed"]: + return "yes" + elif local["is_downloading_active"]: + d = int(local["downloaded_size"]) + percent = int(d * 100 / size) + return f"{percent}%" + return "no" + + +def _get_action_label(users: UserModel, chat: Dict[str, Any]) -> Optional[str]: + actioner, action = users.get_user_action(chat["id"]) + if actioner and action: + label = f"{action}..." + chat_type = get_chat_type(chat) + if chat_type and is_group(chat_type): + user_label = users.get_user_label(actioner) + label = f"{user_label} {label}" + + return label + + return None diff --git a/check.sh b/check.sh new file mode 100755 index 0000000..455c500 --- /dev/null +++ b/check.sh @@ -0,0 +1,20 @@ +#!/bin/sh + +set -ex + +echo Checking and formatting with black... +black --check . + +echo Python type checking... +mypy arigram --warn-redundant-casts --warn-unused-ignores \ + --no-warn-no-return --warn-unreachable --strict-equality \ + --ignore-missing-imports --warn-unused-configs \ + --disallow-untyped-calls --disallow-untyped-defs \ + --disallow-incomplete-defs --check-untyped-defs \ + --disallow-untyped-decorators + +echo Checking import sorting... +isort -c arigram/*.py + +echo Checking unused imports... +flake8 --select=F401 diff --git a/do b/do new file mode 100755 index 0000000..af4cfc8 --- /dev/null +++ b/do @@ -0,0 +1,78 @@ +#!/bin/bash + +set -e + +SRC=$(dirname $0) + +cd $SRC + +ARG=${1:-""} + + +case $ARG in + build) + python3 -m pip install --upgrade setuptools wheel + python3 setup.py sdist bdist_wheel + python3 -m pip install --upgrade twine + python3 -m twine upload --repository testpypi dist/* + ;; + + review) + gh pr create -f + ;; + + release) + CURRENT_VERSION=$(cat arigram/__init__.py | grep version | cut -d '"' -f 2) + echo Current version $CURRENT_VERSION + + NEW_VERSION=$(echo $CURRENT_VERSION | awk -F. '{print $1 "." $2+1 "." $3}') + echo New version $NEW_VERSION + sed -i '' "s|$CURRENT_VERSION|$NEW_VERSION|g" arigram/__init__.py + poetry version $NEW_VERSION + + git add -u arigram/__init__.py pyproject.toml + git commit -m "Release v$NEW_VERSION" + git tag v$NEW_VERSION + + poetry build + poetry publish -u $(pass show i/pypi | grep username | cut -d ' ' -f 2 | tr -d '\n') -p $(pass show i/pypi | head -n 1 | tr -d '\n') + git log --pretty=format:"%cn: %s" v$CURRENT_VERSION...v$NEW_VERSION | grep -v -e "Merge" | grep -v "Release"| awk '!x[$0]++' > changelog.md + git push origin master --tags + gh release create v$NEW_VERSION -F changelog.md + rm changelog.md + ;; + + release-brew) + CURRENT_VERSION=$(cat arigram/__init__.py | grep version | cut -d '"' -f 2) + echo Current version $CURRENT_VERSION + + URL="https://github.com/TruncatedDinosour/arigram/archive/refs/tags/v$CURRENT_VERSION.tar.gz" + echo $URL + wget $URL -O /tmp/arigram.tar.gz + HASH=$(sha256sum /tmp/arigram.tar.gz | cut -d ' ' -f 1) + rm /tmp/arigram.tar.gz + + cd /opt/homebrew/Library/Taps/TruncatedDinosour/dino-bar + sed -i '' "6s|.*| url \"https://github.com/TruncatedDinosour/arigram/archive/refs/tags/v$CURRENT_VERSION.tar.gz\"|" arigram.rb + sed -i '' "7s|.*| sha256 \"$HASH\"|" arigram.rb + + brew audit --new arigram + brew uninstall arigram || true + brew install arigram + brew test arigram + + git add -u arigram.rb + git commit -m "Release arigram.rb v$CURRENT_VERSION" + git push origin master + ;; + + check) + black . + isort arigran/*.py + sh check.sh + ;; + + *) + python3 -m arigram + ;; +esac diff --git a/poetry.lock b/poetry.lock new file mode 100644 index 0000000..4dd8e5c --- /dev/null +++ b/poetry.lock @@ -0,0 +1,316 @@ +[[package]] +name = "appdirs" +version = "1.4.4" +description = "A small Python module for determining appropriate platform-specific dirs, e.g. a \"user data dir\"." +category = "dev" +optional = false +python-versions = "*" + +[[package]] +name = "black" +version = "20.8b1" +description = "The uncompromising code formatter." +category = "dev" +optional = false +python-versions = ">=3.6" + +[package.dependencies] +appdirs = "*" +click = ">=7.1.2" +mypy-extensions = ">=0.4.3" +pathspec = ">=0.6,<1" +regex = ">=2020.1.8" +toml = ">=0.10.1" +typed-ast = ">=1.4.0" +typing-extensions = ">=3.7.4" + +[package.extras] +colorama = ["colorama (>=0.4.3)"] +d = ["aiohttp (>=3.3.2)", "aiohttp-cors"] + +[[package]] +name = "click" +version = "7.1.2" +description = "Composable command line interface toolkit" +category = "dev" +optional = false +python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*" + +[[package]] +name = "flake8" +version = "3.8.4" +description = "the modular source code checker: pep8 pyflakes and co" +category = "dev" +optional = false +python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,>=2.7" + +[package.dependencies] +mccabe = ">=0.6.0,<0.7.0" +pycodestyle = ">=2.6.0a1,<2.7.0" +pyflakes = ">=2.2.0,<2.3.0" + +[[package]] +name = "isort" +version = "5.6.2" +description = "A Python utility / library to sort Python imports." +category = "dev" +optional = false +python-versions = ">=3.6,<4.0" + +[package.extras] +pipfile_deprecated_finder = ["pipreqs", "requirementslib"] +requirements_deprecated_finder = ["pipreqs", "pip-api"] +colors = ["colorama (>=0.4.3,<0.5.0)"] + +[[package]] +name = "mccabe" +version = "0.6.1" +description = "McCabe checker, plugin for flake8" +category = "dev" +optional = false +python-versions = "*" + +[[package]] +name = "mypy" +version = "0.812" +description = "Optional static typing for Python" +category = "dev" +optional = false +python-versions = ">=3.5" + +[package.dependencies] +mypy-extensions = ">=0.4.3,<0.5.0" +typed-ast = ">=1.4.0,<1.5.0" +typing-extensions = ">=3.7.4" + +[package.extras] +dmypy = ["psutil (>=4.0)"] + +[[package]] +name = "mypy-extensions" +version = "0.4.3" +description = "Experimental type system extensions for programs checked with the mypy typechecker." +category = "dev" +optional = false +python-versions = "*" + +[[package]] +name = "pathspec" +version = "0.8.1" +description = "Utility library for gitignore style pattern matching of file paths." +category = "dev" +optional = false +python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*" + +[[package]] +name = "pycodestyle" +version = "2.6.0" +description = "Python style guide checker" +category = "dev" +optional = false +python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" + +[[package]] +name = "pyflakes" +version = "2.2.0" +description = "passive checker of Python programs" +category = "dev" +optional = false +python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" + +[[package]] +name = "python-telegram" +version = "0.14.0" +description = "Python library to help you build your own Telegram clients" +category = "main" +optional = false +python-versions = "*" + +[[package]] +name = "regex" +version = "2021.4.4" +description = "Alternative regular expression module, to replace re." +category = "dev" +optional = false +python-versions = "*" + +[[package]] +name = "toml" +version = "0.10.2" +description = "Python Library for Tom's Obvious, Minimal Language" +category = "dev" +optional = false +python-versions = ">=2.6, !=3.0.*, !=3.1.*, !=3.2.*" + +[[package]] +name = "typed-ast" +version = "1.4.3" +description = "a fork of Python 2 and 3 ast modules with type comment support" +category = "dev" +optional = false +python-versions = "*" + +[[package]] +name = "typing-extensions" +version = "3.7.4.3" +description = "Backported and Experimental Type Hints for Python 3.5+" +category = "dev" +optional = false +python-versions = "*" + +[metadata] +lock-version = "1.1" +python-versions = "^3.8" +content-hash = "1cd8d27a7f199ab602a89036a158650be0e6bba6abd8d3d28af730c5e61b8e58" + +[metadata.files] +appdirs = [ + {file = "appdirs-1.4.4-py2.py3-none-any.whl", hash = "sha256:a841dacd6b99318a741b166adb07e19ee71a274450e68237b4650ca1055ab128"}, + {file = "appdirs-1.4.4.tar.gz", hash = "sha256:7d5d0167b2b1ba821647616af46a749d1c653740dd0d2415100fe26e27afdf41"}, +] +black = [ + {file = "black-20.8b1.tar.gz", hash = "sha256:1c02557aa099101b9d21496f8a914e9ed2222ef70336404eeeac8edba836fbea"}, +] +click = [ + {file = "click-7.1.2-py2.py3-none-any.whl", hash = "sha256:dacca89f4bfadd5de3d7489b7c8a566eee0d3676333fbb50030263894c38c0dc"}, + {file = "click-7.1.2.tar.gz", hash = "sha256:d2b5255c7c6349bc1bd1e59e08cd12acbbd63ce649f2588755783aa94dfb6b1a"}, +] +flake8 = [ + {file = "flake8-3.8.4-py2.py3-none-any.whl", hash = "sha256:749dbbd6bfd0cf1318af27bf97a14e28e5ff548ef8e5b1566ccfb25a11e7c839"}, + {file = "flake8-3.8.4.tar.gz", hash = "sha256:aadae8761ec651813c24be05c6f7b4680857ef6afaae4651a4eccaef97ce6c3b"}, +] +isort = [ + {file = "isort-5.6.2-py3-none-any.whl", hash = "sha256:a30c567b88d7b73e448b0f30526eaf2f943f0627809e4f34b9c3271918d96c3e"}, + {file = "isort-5.6.2.tar.gz", hash = "sha256:c2cfe5b621f62932677004f96f93c4b128dc457d957b0531f204641fe8adc8a6"}, +] +mccabe = [ + {file = "mccabe-0.6.1-py2.py3-none-any.whl", hash = "sha256:ab8a6258860da4b6677da4bd2fe5dc2c659cff31b3ee4f7f5d64e79735b80d42"}, + {file = "mccabe-0.6.1.tar.gz", hash = "sha256:dd8d182285a0fe56bace7f45b5e7d1a6ebcbf524e8f3bd87eb0f125271b8831f"}, +] +mypy = [ + {file = "mypy-0.812-cp35-cp35m-macosx_10_9_x86_64.whl", hash = "sha256:a26f8ec704e5a7423c8824d425086705e381b4f1dfdef6e3a1edab7ba174ec49"}, + {file = "mypy-0.812-cp35-cp35m-manylinux1_x86_64.whl", hash = "sha256:28fb5479c494b1bab244620685e2eb3c3f988d71fd5d64cc753195e8ed53df7c"}, + {file = "mypy-0.812-cp35-cp35m-manylinux2010_x86_64.whl", hash = "sha256:9743c91088d396c1a5a3c9978354b61b0382b4e3c440ce83cf77994a43e8c521"}, + {file = "mypy-0.812-cp35-cp35m-win_amd64.whl", hash = "sha256:d7da2e1d5f558c37d6e8c1246f1aec1e7349e4913d8fb3cb289a35de573fe2eb"}, + {file = "mypy-0.812-cp36-cp36m-macosx_10_9_x86_64.whl", hash = "sha256:4eec37370483331d13514c3f55f446fc5248d6373e7029a29ecb7b7494851e7a"}, + {file = "mypy-0.812-cp36-cp36m-manylinux1_x86_64.whl", hash = "sha256:d65cc1df038ef55a99e617431f0553cd77763869eebdf9042403e16089fe746c"}, + {file = "mypy-0.812-cp36-cp36m-manylinux2010_x86_64.whl", hash = "sha256:61a3d5b97955422964be6b3baf05ff2ce7f26f52c85dd88db11d5e03e146a3a6"}, + {file = "mypy-0.812-cp36-cp36m-win_amd64.whl", hash = "sha256:25adde9b862f8f9aac9d2d11971f226bd4c8fbaa89fb76bdadb267ef22d10064"}, + {file = "mypy-0.812-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:552a815579aa1e995f39fd05dde6cd378e191b063f031f2acfe73ce9fb7f9e56"}, + {file = "mypy-0.812-cp37-cp37m-manylinux1_x86_64.whl", hash = "sha256:499c798053cdebcaa916eef8cd733e5584b5909f789de856b482cd7d069bdad8"}, + {file = "mypy-0.812-cp37-cp37m-manylinux2010_x86_64.whl", hash = "sha256:5873888fff1c7cf5b71efbe80e0e73153fe9212fafdf8e44adfe4c20ec9f82d7"}, + {file = "mypy-0.812-cp37-cp37m-win_amd64.whl", hash = "sha256:9f94aac67a2045ec719ffe6111df543bac7874cee01f41928f6969756e030564"}, + {file = "mypy-0.812-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:d23e0ea196702d918b60c8288561e722bf437d82cb7ef2edcd98cfa38905d506"}, + {file = "mypy-0.812-cp38-cp38-manylinux1_x86_64.whl", hash = "sha256:674e822aa665b9fd75130c6c5f5ed9564a38c6cea6a6432ce47eafb68ee578c5"}, + {file = "mypy-0.812-cp38-cp38-manylinux2010_x86_64.whl", hash = "sha256:abf7e0c3cf117c44d9285cc6128856106183938c68fd4944763003decdcfeb66"}, + {file = "mypy-0.812-cp38-cp38-win_amd64.whl", hash = "sha256:0d0a87c0e7e3a9becdfbe936c981d32e5ee0ccda3e0f07e1ef2c3d1a817cf73e"}, + {file = "mypy-0.812-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:7ce3175801d0ae5fdfa79b4f0cfed08807af4d075b402b7e294e6aa72af9aa2a"}, + {file = "mypy-0.812-cp39-cp39-manylinux1_x86_64.whl", hash = "sha256:b09669bcda124e83708f34a94606e01b614fa71931d356c1f1a5297ba11f110a"}, + {file = "mypy-0.812-cp39-cp39-manylinux2010_x86_64.whl", hash = "sha256:33f159443db0829d16f0a8d83d94df3109bb6dd801975fe86bacb9bf71628e97"}, + {file = "mypy-0.812-cp39-cp39-win_amd64.whl", hash = "sha256:3f2aca7f68580dc2508289c729bd49ee929a436208d2b2b6aab15745a70a57df"}, + {file = "mypy-0.812-py3-none-any.whl", hash = "sha256:2f9b3407c58347a452fc0736861593e105139b905cca7d097e413453a1d650b4"}, + {file = "mypy-0.812.tar.gz", hash = "sha256:cd07039aa5df222037005b08fbbfd69b3ab0b0bd7a07d7906de75ae52c4e3119"}, +] +mypy-extensions = [ + {file = "mypy_extensions-0.4.3-py2.py3-none-any.whl", hash = "sha256:090fedd75945a69ae91ce1303b5824f428daf5a028d2f6ab8a299250a846f15d"}, + {file = "mypy_extensions-0.4.3.tar.gz", hash = "sha256:2d82818f5bb3e369420cb3c4060a7970edba416647068eb4c5343488a6c604a8"}, +] +pathspec = [ + {file = "pathspec-0.8.1-py2.py3-none-any.whl", hash = "sha256:aa0cb481c4041bf52ffa7b0d8fa6cd3e88a2ca4879c533c9153882ee2556790d"}, + {file = "pathspec-0.8.1.tar.gz", hash = "sha256:86379d6b86d75816baba717e64b1a3a3469deb93bb76d613c9ce79edc5cb68fd"}, +] +pycodestyle = [ + {file = "pycodestyle-2.6.0-py2.py3-none-any.whl", hash = "sha256:2295e7b2f6b5bd100585ebcb1f616591b652db8a741695b3d8f5d28bdc934367"}, + {file = "pycodestyle-2.6.0.tar.gz", hash = "sha256:c58a7d2815e0e8d7972bf1803331fb0152f867bd89adf8a01dfd55085434192e"}, +] +pyflakes = [ + {file = "pyflakes-2.2.0-py2.py3-none-any.whl", hash = "sha256:0d94e0e05a19e57a99444b6ddcf9a6eb2e5c68d3ca1e98e90707af8152c90a92"}, + {file = "pyflakes-2.2.0.tar.gz", hash = "sha256:35b2d75ee967ea93b55750aa9edbbf72813e06a66ba54438df2cfac9e3c27fc8"}, +] +python-telegram = [ + {file = "python-telegram-0.14.0.tar.gz", hash = "sha256:d3b08469ed06f6599ac4acfe89c697f3c99b55dec476c475c237f6decf238376"}, +] +regex = [ + {file = "regex-2021.4.4-cp36-cp36m-macosx_10_9_x86_64.whl", hash = "sha256:619d71c59a78b84d7f18891fe914446d07edd48dc8328c8e149cbe0929b4e000"}, + {file = "regex-2021.4.4-cp36-cp36m-manylinux1_i686.whl", hash = "sha256:47bf5bf60cf04d72bf6055ae5927a0bd9016096bf3d742fa50d9bf9f45aa0711"}, + {file = "regex-2021.4.4-cp36-cp36m-manylinux1_x86_64.whl", hash = "sha256:281d2fd05555079448537fe108d79eb031b403dac622621c78944c235f3fcf11"}, + {file = "regex-2021.4.4-cp36-cp36m-manylinux2010_i686.whl", hash = "sha256:bd28bc2e3a772acbb07787c6308e00d9626ff89e3bfcdebe87fa5afbfdedf968"}, + {file = "regex-2021.4.4-cp36-cp36m-manylinux2010_x86_64.whl", hash = "sha256:7c2a1af393fcc09e898beba5dd59196edaa3116191cc7257f9224beaed3e1aa0"}, + {file = "regex-2021.4.4-cp36-cp36m-manylinux2014_aarch64.whl", hash = "sha256:c38c71df845e2aabb7fb0b920d11a1b5ac8526005e533a8920aea97efb8ec6a4"}, + {file = "regex-2021.4.4-cp36-cp36m-manylinux2014_i686.whl", hash = "sha256:96fcd1888ab4d03adfc9303a7b3c0bd78c5412b2bfbe76db5b56d9eae004907a"}, + {file = "regex-2021.4.4-cp36-cp36m-manylinux2014_x86_64.whl", hash = "sha256:ade17eb5d643b7fead300a1641e9f45401c98eee23763e9ed66a43f92f20b4a7"}, + {file = "regex-2021.4.4-cp36-cp36m-win32.whl", hash = "sha256:e8e5b509d5c2ff12f8418006d5a90e9436766133b564db0abaec92fd27fcee29"}, + {file = "regex-2021.4.4-cp36-cp36m-win_amd64.whl", hash = "sha256:11d773d75fa650cd36f68d7ca936e3c7afaae41b863b8c387a22aaa78d3c5c79"}, + {file = "regex-2021.4.4-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:d3029c340cfbb3ac0a71798100ccc13b97dddf373a4ae56b6a72cf70dfd53bc8"}, + {file = "regex-2021.4.4-cp37-cp37m-manylinux1_i686.whl", hash = "sha256:18c071c3eb09c30a264879f0d310d37fe5d3a3111662438889ae2eb6fc570c31"}, + {file = "regex-2021.4.4-cp37-cp37m-manylinux1_x86_64.whl", hash = "sha256:4c557a7b470908b1712fe27fb1ef20772b78079808c87d20a90d051660b1d69a"}, + {file = "regex-2021.4.4-cp37-cp37m-manylinux2010_i686.whl", hash = "sha256:01afaf2ec48e196ba91b37451aa353cb7eda77efe518e481707e0515025f0cd5"}, + {file = "regex-2021.4.4-cp37-cp37m-manylinux2010_x86_64.whl", hash = "sha256:3a9cd17e6e5c7eb328517969e0cb0c3d31fd329298dd0c04af99ebf42e904f82"}, + {file = "regex-2021.4.4-cp37-cp37m-manylinux2014_aarch64.whl", hash = "sha256:90f11ff637fe8798933fb29f5ae1148c978cccb0452005bf4c69e13db951e765"}, + {file = "regex-2021.4.4-cp37-cp37m-manylinux2014_i686.whl", hash = "sha256:919859aa909429fb5aa9cf8807f6045592c85ef56fdd30a9a3747e513db2536e"}, + {file = "regex-2021.4.4-cp37-cp37m-manylinux2014_x86_64.whl", hash = "sha256:339456e7d8c06dd36a22e451d58ef72cef293112b559010db3d054d5560ef439"}, + {file = "regex-2021.4.4-cp37-cp37m-win32.whl", hash = "sha256:67bdb9702427ceddc6ef3dc382455e90f785af4c13d495f9626861763ee13f9d"}, + {file = "regex-2021.4.4-cp37-cp37m-win_amd64.whl", hash = "sha256:32e65442138b7b76dd8173ffa2cf67356b7bc1768851dded39a7a13bf9223da3"}, + {file = "regex-2021.4.4-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:1e1c20e29358165242928c2de1482fb2cf4ea54a6a6dea2bd7a0e0d8ee321500"}, + {file = "regex-2021.4.4-cp38-cp38-manylinux1_i686.whl", hash = "sha256:314d66636c494ed9c148a42731b3834496cc9a2c4251b1661e40936814542b14"}, + {file = "regex-2021.4.4-cp38-cp38-manylinux1_x86_64.whl", hash = "sha256:6d1b01031dedf2503631d0903cb563743f397ccaf6607a5e3b19a3d76fc10480"}, + {file = "regex-2021.4.4-cp38-cp38-manylinux2010_i686.whl", hash = "sha256:741a9647fcf2e45f3a1cf0e24f5e17febf3efe8d4ba1281dcc3aa0459ef424dc"}, + {file = "regex-2021.4.4-cp38-cp38-manylinux2010_x86_64.whl", hash = "sha256:4c46e22a0933dd783467cf32b3516299fb98cfebd895817d685130cc50cd1093"}, + {file = "regex-2021.4.4-cp38-cp38-manylinux2014_aarch64.whl", hash = "sha256:e512d8ef5ad7b898cdb2d8ee1cb09a8339e4f8be706d27eaa180c2f177248a10"}, + {file = "regex-2021.4.4-cp38-cp38-manylinux2014_i686.whl", hash = "sha256:980d7be47c84979d9136328d882f67ec5e50008681d94ecc8afa8a65ed1f4a6f"}, + {file = "regex-2021.4.4-cp38-cp38-manylinux2014_x86_64.whl", hash = "sha256:ce15b6d103daff8e9fee13cf7f0add05245a05d866e73926c358e871221eae87"}, + {file = "regex-2021.4.4-cp38-cp38-win32.whl", hash = "sha256:a91aa8619b23b79bcbeb37abe286f2f408d2f2d6f29a17237afda55bb54e7aac"}, + {file = "regex-2021.4.4-cp38-cp38-win_amd64.whl", hash = "sha256:c0502c0fadef0d23b128605d69b58edb2c681c25d44574fc673b0e52dce71ee2"}, + {file = "regex-2021.4.4-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:598585c9f0af8374c28edd609eb291b5726d7cbce16be6a8b95aa074d252ee17"}, + {file = "regex-2021.4.4-cp39-cp39-manylinux1_i686.whl", hash = "sha256:ee54ff27bf0afaf4c3b3a62bcd016c12c3fdb4ec4f413391a90bd38bc3624605"}, + {file = "regex-2021.4.4-cp39-cp39-manylinux1_x86_64.whl", hash = "sha256:7d9884d86dd4dd489e981d94a65cd30d6f07203d90e98f6f657f05170f6324c9"}, + {file = "regex-2021.4.4-cp39-cp39-manylinux2010_i686.whl", hash = "sha256:bf5824bfac591ddb2c1f0a5f4ab72da28994548c708d2191e3b87dd207eb3ad7"}, + {file = "regex-2021.4.4-cp39-cp39-manylinux2010_x86_64.whl", hash = "sha256:563085e55b0d4fb8f746f6a335893bda5c2cef43b2f0258fe1020ab1dd874df8"}, + {file = "regex-2021.4.4-cp39-cp39-manylinux2014_aarch64.whl", hash = "sha256:b9c3db21af35e3b3c05764461b262d6f05bbca08a71a7849fd79d47ba7bc33ed"}, + {file = "regex-2021.4.4-cp39-cp39-manylinux2014_i686.whl", hash = "sha256:3916d08be28a1149fb97f7728fca1f7c15d309a9f9682d89d79db75d5e52091c"}, + {file = "regex-2021.4.4-cp39-cp39-manylinux2014_x86_64.whl", hash = "sha256:fd45ff9293d9274c5008a2054ecef86a9bfe819a67c7be1afb65e69b405b3042"}, + {file = "regex-2021.4.4-cp39-cp39-win32.whl", hash = "sha256:fa4537fb4a98fe8fde99626e4681cc644bdcf2a795038533f9f711513a862ae6"}, + {file = "regex-2021.4.4-cp39-cp39-win_amd64.whl", hash = "sha256:97f29f57d5b84e73fbaf99ab3e26134e6687348e95ef6b48cfd2c06807005a07"}, + {file = "regex-2021.4.4.tar.gz", hash = "sha256:52ba3d3f9b942c49d7e4bc105bb28551c44065f139a65062ab7912bef10c9afb"}, +] +toml = [ + {file = "toml-0.10.2-py2.py3-none-any.whl", hash = "sha256:806143ae5bfb6a3c6e736a764057db0e6a0e05e338b5630894a5f779cabb4f9b"}, + {file = "toml-0.10.2.tar.gz", hash = "sha256:b3bda1d108d5dd99f4a20d24d9c348e91c4db7ab1b749200bded2f839ccbe68f"}, +] +typed-ast = [ + {file = "typed_ast-1.4.3-cp35-cp35m-manylinux1_i686.whl", hash = "sha256:2068531575a125b87a41802130fa7e29f26c09a2833fea68d9a40cf33902eba6"}, + {file = "typed_ast-1.4.3-cp35-cp35m-manylinux1_x86_64.whl", hash = "sha256:c907f561b1e83e93fad565bac5ba9c22d96a54e7ea0267c708bffe863cbe4075"}, + {file = "typed_ast-1.4.3-cp35-cp35m-manylinux2014_aarch64.whl", hash = "sha256:1b3ead4a96c9101bef08f9f7d1217c096f31667617b58de957f690c92378b528"}, + {file = "typed_ast-1.4.3-cp35-cp35m-win32.whl", hash = "sha256:dde816ca9dac1d9c01dd504ea5967821606f02e510438120091b84e852367428"}, + {file = "typed_ast-1.4.3-cp35-cp35m-win_amd64.whl", hash = "sha256:777a26c84bea6cd934422ac2e3b78863a37017618b6e5c08f92ef69853e765d3"}, + {file = "typed_ast-1.4.3-cp36-cp36m-macosx_10_9_x86_64.whl", hash = "sha256:f8afcf15cc511ada719a88e013cec87c11aff7b91f019295eb4530f96fe5ef2f"}, + {file = "typed_ast-1.4.3-cp36-cp36m-manylinux1_i686.whl", hash = "sha256:52b1eb8c83f178ab787f3a4283f68258525f8d70f778a2f6dd54d3b5e5fb4341"}, + {file = "typed_ast-1.4.3-cp36-cp36m-manylinux1_x86_64.whl", hash = "sha256:01ae5f73431d21eead5015997ab41afa53aa1fbe252f9da060be5dad2c730ace"}, + {file = "typed_ast-1.4.3-cp36-cp36m-manylinux2014_aarch64.whl", hash = "sha256:c190f0899e9f9f8b6b7863debfb739abcb21a5c054f911ca3596d12b8a4c4c7f"}, + {file = "typed_ast-1.4.3-cp36-cp36m-win32.whl", hash = "sha256:398e44cd480f4d2b7ee8d98385ca104e35c81525dd98c519acff1b79bdaac363"}, + {file = "typed_ast-1.4.3-cp36-cp36m-win_amd64.whl", hash = "sha256:bff6ad71c81b3bba8fa35f0f1921fb24ff4476235a6e94a26ada2e54370e6da7"}, + {file = "typed_ast-1.4.3-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:0fb71b8c643187d7492c1f8352f2c15b4c4af3f6338f21681d3681b3dc31a266"}, + {file = "typed_ast-1.4.3-cp37-cp37m-manylinux1_i686.whl", hash = "sha256:760ad187b1041a154f0e4d0f6aae3e40fdb51d6de16e5c99aedadd9246450e9e"}, + {file = "typed_ast-1.4.3-cp37-cp37m-manylinux1_x86_64.whl", hash = "sha256:5feca99c17af94057417d744607b82dd0a664fd5e4ca98061480fd8b14b18d04"}, + {file = "typed_ast-1.4.3-cp37-cp37m-manylinux2014_aarch64.whl", hash = "sha256:95431a26309a21874005845c21118c83991c63ea800dd44843e42a916aec5899"}, + {file = "typed_ast-1.4.3-cp37-cp37m-win32.whl", hash = "sha256:aee0c1256be6c07bd3e1263ff920c325b59849dc95392a05f258bb9b259cf39c"}, + {file = "typed_ast-1.4.3-cp37-cp37m-win_amd64.whl", hash = "sha256:9ad2c92ec681e02baf81fdfa056fe0d818645efa9af1f1cd5fd6f1bd2bdfd805"}, + {file = "typed_ast-1.4.3-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:b36b4f3920103a25e1d5d024d155c504080959582b928e91cb608a65c3a49e1a"}, + {file = "typed_ast-1.4.3-cp38-cp38-manylinux1_i686.whl", hash = "sha256:067a74454df670dcaa4e59349a2e5c81e567d8d65458d480a5b3dfecec08c5ff"}, + {file = "typed_ast-1.4.3-cp38-cp38-manylinux1_x86_64.whl", hash = "sha256:7538e495704e2ccda9b234b82423a4038f324f3a10c43bc088a1636180f11a41"}, + {file = "typed_ast-1.4.3-cp38-cp38-manylinux2014_aarch64.whl", hash = "sha256:af3d4a73793725138d6b334d9d247ce7e5f084d96284ed23f22ee626a7b88e39"}, + {file = "typed_ast-1.4.3-cp38-cp38-win32.whl", hash = "sha256:f2362f3cb0f3172c42938946dbc5b7843c2a28aec307c49100c8b38764eb6927"}, + {file = "typed_ast-1.4.3-cp38-cp38-win_amd64.whl", hash = "sha256:dd4a21253f42b8d2b48410cb31fe501d32f8b9fbeb1f55063ad102fe9c425e40"}, + {file = "typed_ast-1.4.3-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:f328adcfebed9f11301eaedfa48e15bdece9b519fb27e6a8c01aa52a17ec31b3"}, + {file = "typed_ast-1.4.3-cp39-cp39-manylinux1_i686.whl", hash = "sha256:2c726c276d09fc5c414693a2de063f521052d9ea7c240ce553316f70656c84d4"}, + {file = "typed_ast-1.4.3-cp39-cp39-manylinux1_x86_64.whl", hash = "sha256:cae53c389825d3b46fb37538441f75d6aecc4174f615d048321b716df2757fb0"}, + {file = "typed_ast-1.4.3-cp39-cp39-manylinux2014_aarch64.whl", hash = "sha256:b9574c6f03f685070d859e75c7f9eeca02d6933273b5e69572e5ff9d5e3931c3"}, + {file = "typed_ast-1.4.3-cp39-cp39-win32.whl", hash = "sha256:209596a4ec71d990d71d5e0d312ac935d86930e6eecff6ccc7007fe54d703808"}, + {file = "typed_ast-1.4.3-cp39-cp39-win_amd64.whl", hash = "sha256:9c6d1a54552b5330bc657b7ef0eae25d00ba7ffe85d9ea8ae6540d2197a3788c"}, + {file = "typed_ast-1.4.3.tar.gz", hash = "sha256:fb1bbeac803adea29cedd70781399c99138358c26d05fcbd23c13016b7f5ec65"}, +] +typing-extensions = [ + {file = "typing_extensions-3.7.4.3-py2-none-any.whl", hash = "sha256:dafc7639cde7f1b6e1acc0f457842a83e722ccca8eef5270af2d74792619a89f"}, + {file = "typing_extensions-3.7.4.3-py3-none-any.whl", hash = "sha256:7cb407020f00f7bfc3cb3e7881628838e69d8f3fcab2f64742a5e76b2f841918"}, + {file = "typing_extensions-3.7.4.3.tar.gz", hash = "sha256:99d4073b617d30288f569d3f13d2bd7548c3a7e4c8de87db09a9d29bb3a4a60c"}, +] diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..ab7b8d8 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,35 @@ +[tool.poetry] +name = "arigram" +version = "0.1.0" +description = "A fork of tg -- a hackable telegram TUI client" +authors = ["TruncatedDinosour "] +license = "Unlicense" +packages = [{ include = "arigram"}] +readme = "README.md" +homepage = "https://github.com/TruncatedDinosour/arigram" +repository = "https://github.com/TruncatedDinosour/arigram" + +[tool.poetry.dependencies] +python = "^3.8" +python-telegram = "0.14.0" + +[tool.poetry.dev-dependencies] +black = "20.8b1" +flake8 = "3.8.4" +isort = "5.6.2" +mypy = "0.812" + +[tool.poetry.scripts] +arigram = "arigram.__main__:main" + +[build-system] +requires = ["poetry-core>=1.0.0"] +build-backend = "poetry.core.masonry.api" + +[tool.black] +line-length = 79 + +[tool.isort] +line_length = 79 +multi_line_output = 3 +include_trailing_comma = true diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..aa8d230 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,3 @@ +python-telegram +pyfzf + diff --git a/setup.py b/setup.py new file mode 100644 index 0000000..fd3e7d2 --- /dev/null +++ b/setup.py @@ -0,0 +1,27 @@ +from setuptools import setup + +import arigram + +with open("README.md", "r") as fh: + readme = fh.read() + + +setup( + long_description=readme, + long_description_content_type="text/markdown", + name="arigram", + version=arigram.__version__, + description="A fork of tg -- a hackable telegram TUI client", + url="https://github.com/TruncatedDinosour/arigram", + author="TruncatedDinosour", + author_email="truncateddinosour@gmail.com", + classifiers=[ + "Programming Language :: Python :: 3", + "Programming Language :: Python :: 3.8", + "Programming Language :: Python :: 3.9", + ], + packages=["arigram"], + entry_points={"console_scripts": ["arigram = arigram.__main__:main"]}, + python_requires=">=3.8", + install_requires=["python-telegram==0.14.0", "pyfzf==0.2.2"], +)