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