first commit

This commit is contained in:
Ari Archer 2021-11-28 15:56:03 +02:00
commit 55eebc496e
Signed by untrusted user who does not match committer: ari
GPG key ID: A50D5B4B599AF8A2
24 changed files with 5055 additions and 0 deletions

46
.github/workflows/main.yml vendored Normal file
View file

@ -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

14
.gitignore vendored Normal file
View file

@ -0,0 +1,14 @@
.mypy_cache/
venv/
__pycache__
.env
dist
*.log*
Makefile
.idea/
.vim/
*monkeytype.sqlite3
.vscode/
build/
MANIFEST
arigram.egg-info/

15
Dockerfile Normal file
View file

@ -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

323
README.md Normal file
View file

@ -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
- `<space>`: select msg and jump one msg down (use for deletion or forwarding)
- `<ctrl+space>`: same as space but jumps one msg up
- `y`: yank (copy) selected msgs with <space> 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
```

26
UNLICENSE Normal file
View file

@ -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 <http://unlicense.org/>

BIN
arigram-screenshot.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 MiB

4
arigram/__init__.py Normal file
View file

@ -0,0 +1,4 @@
"""
Terminal client for telegram
"""
__version__ = "0.1.0"

80
arigram/__main__.py Normal file
View file

@ -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 <q>
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()

53
arigram/colors.py Normal file
View file

@ -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])

104
arigram/config.py Normal file
View file

@ -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")

978
arigram/controllers.py Normal file
View file

@ -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 <space>"""
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+<space>"""
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 <index> 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)
]
)

855
arigram/models.py Normal file
View file

@ -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 "<Unknown>"
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

248
arigram/msg.py Normal file
View file

@ -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")

Binary file not shown.

After

Width:  |  Height:  |  Size: 146 KiB

448
arigram/tdlib.py Normal file
View file

@ -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,
)

333
arigram/update_handlers.py Normal file
View file

@ -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()

322
arigram/utils.py Normal file
View file

@ -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 <enter> 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 <enter> 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)

727
arigram/views.py Normal file
View file

@ -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, <esc>) 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, "<No messages yet>"
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 "<No date>"
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

20
check.sh Executable file
View file

@ -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

78
do Executable file
View file

@ -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

316
poetry.lock generated Normal file
View file

@ -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"},
]

35
pyproject.toml Normal file
View file

@ -0,0 +1,35 @@
[tool.poetry]
name = "arigram"
version = "0.1.0"
description = "A fork of tg -- a hackable telegram TUI client"
authors = ["TruncatedDinosour <truncateddinosour@gmail.com>"]
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

3
requirements.txt Normal file
View file

@ -0,0 +1,3 @@
python-telegram
pyfzf

27
setup.py Normal file
View file

@ -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"],
)