first commit
This commit is contained in:
commit
55eebc496e
46
.github/workflows/main.yml
vendored
Normal file
46
.github/workflows/main.yml
vendored
Normal 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
14
.gitignore
vendored
Normal 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
15
Dockerfile
Normal 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
323
README.md
Normal 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
26
UNLICENSE
Normal 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
BIN
arigram-screenshot.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 1.1 MiB |
4
arigram/__init__.py
Normal file
4
arigram/__init__.py
Normal file
|
@ -0,0 +1,4 @@
|
||||||
|
"""
|
||||||
|
Terminal client for telegram
|
||||||
|
"""
|
||||||
|
__version__ = "0.1.0"
|
80
arigram/__main__.py
Normal file
80
arigram/__main__.py
Normal 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
53
arigram/colors.py
Normal 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
104
arigram/config.py
Normal 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
978
arigram/controllers.py
Normal 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
855
arigram/models.py
Normal 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
248
arigram/msg.py
Normal 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")
|
BIN
arigram/resources/arigram.png
Normal file
BIN
arigram/resources/arigram.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 146 KiB |
448
arigram/tdlib.py
Normal file
448
arigram/tdlib.py
Normal 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
333
arigram/update_handlers.py
Normal 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
322
arigram/utils.py
Normal 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
727
arigram/views.py
Normal 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
20
check.sh
Executable 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
78
do
Executable 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
316
poetry.lock
generated
Normal 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
35
pyproject.toml
Normal 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
3
requirements.txt
Normal file
|
@ -0,0 +1,3 @@
|
||||||
|
python-telegram
|
||||||
|
pyfzf
|
||||||
|
|
27
setup.py
Normal file
27
setup.py
Normal 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"],
|
||||||
|
)
|
Loading…
Reference in a new issue