Initial Commit
This commit is contained in:
commit
e4aa416bfb
6
.gitignore
vendored
Normal file
6
.gitignore
vendored
Normal file
@ -0,0 +1,6 @@
|
|||||||
|
.idea/**/*
|
||||||
|
*.swp
|
||||||
|
*.session
|
||||||
|
*/**/__pycache__/
|
||||||
|
__pycache__/*
|
||||||
|
*.session-journal
|
17
README.md
Normal file
17
README.md
Normal file
@ -0,0 +1,17 @@
|
|||||||
|
# TTTC, the telegram client
|
||||||
|
|
||||||
|
TTTC is an unofficial [Telegram](https://telegram.org/) commandline client.
|
||||||
|
It aims to provide a user experience similar to that of [VIM](https://www.vim.org/).
|
||||||
|
|
||||||
|
|
||||||
|
## Requirements
|
||||||
|
TTTC uses the `curses` and `telethon` python libraries. Curses is usually shipped with
|
||||||
|
your python installation, but `telethon` can easily be installed via `pip`.
|
||||||
|
|
||||||
|
In order to use TTTC, you will need your own Telegram `api_id` and `api_hash`.
|
||||||
|
You can read more about how to get them [here](https://core.telegram.org/api/obtaining_api_id).
|
||||||
|
|
||||||
|
Once you obtained your own api key and hash, you need to set them as your environment variables
|
||||||
|
`TTTC_API_ID` and `TTTC_API_HASH`, respectively.
|
||||||
|
|
||||||
|
The client can be run with `python3 tttc.py`.
|
80
authview.py
Normal file
80
authview.py
Normal file
@ -0,0 +1,80 @@
|
|||||||
|
from asyncio import Condition
|
||||||
|
import telethon
|
||||||
|
import resources
|
||||||
|
import curses
|
||||||
|
from tttcutils import debug, show_stacktrace
|
||||||
|
|
||||||
|
def bla(scr):
|
||||||
|
lines = resources.tttc_logo
|
||||||
|
tttw,ttth = len(lines[4]), len(lines)
|
||||||
|
w, h = curses.COLS, curses.LINES
|
||||||
|
yoff = h//2 - ttth//2
|
||||||
|
xoff = w//2 - tttw//2
|
||||||
|
for a in range(len(lines)):
|
||||||
|
scr.addstr(yoff + a, xoff, lines[a])
|
||||||
|
scr.refresh()
|
||||||
|
|
||||||
|
class AuthView():
|
||||||
|
def __init__(self, client, stdscr):
|
||||||
|
self.stdscr = stdscr
|
||||||
|
self.client = client
|
||||||
|
self.inputevent = Condition()
|
||||||
|
self.inputs = ""
|
||||||
|
self.w, self.h = curses.COLS, curses.LINES
|
||||||
|
self.fin = False
|
||||||
|
|
||||||
|
|
||||||
|
async def textinput(self):
|
||||||
|
self.stdscr.addstr("\n> ")
|
||||||
|
self.stdscr.refresh()
|
||||||
|
self.inputs = ""
|
||||||
|
with await self.inputevent:
|
||||||
|
await self.inputevent.wait()
|
||||||
|
out = self.inputs
|
||||||
|
self.inputs = ""
|
||||||
|
self.stdscr.addstr("\n")
|
||||||
|
self.stdscr.refresh()
|
||||||
|
return out
|
||||||
|
|
||||||
|
async def run(self):
|
||||||
|
await self.client.connect()
|
||||||
|
self.stdscr.addstr("connected")
|
||||||
|
self.auth = await self.client.is_user_authorized()
|
||||||
|
bla(self.stdscr)
|
||||||
|
if not self.auth:
|
||||||
|
while True:
|
||||||
|
self.stdscr.addstr("Please enter your phone number: ")
|
||||||
|
self.stdscr.refresh()
|
||||||
|
self.phone = await self.textinput()
|
||||||
|
try:
|
||||||
|
response = await self.client.send_code_request(self.phone)
|
||||||
|
if not response.phone_registered:
|
||||||
|
self.stdscr.addstr("This phone number is not registered in telegram. ")
|
||||||
|
self.stdscr.refresh()
|
||||||
|
else:
|
||||||
|
break
|
||||||
|
except telethon.errors.rpcerrorlist.FloodWaitError as err:
|
||||||
|
self.stdscr.addstr(f"The telegram servers blocked you for too many retries ({err.seconds}s remaining). ")
|
||||||
|
self.stdscr.refresh()
|
||||||
|
except Exception as e:
|
||||||
|
self.stdscr.addstr("Incorrect phone number. ")
|
||||||
|
self.stdscr.refresh()
|
||||||
|
self.stdscr.addstr("auth with code now.")
|
||||||
|
self.stdscr.refresh()
|
||||||
|
self.code = await self.textinput()
|
||||||
|
await self.client.sign_in(self.phone, self.code)
|
||||||
|
self.stdscr.addstr(f"auth successful. ")
|
||||||
|
self.stdscr.refresh()
|
||||||
|
|
||||||
|
async def handle_key(self, key):
|
||||||
|
if key == "RETURN":
|
||||||
|
with await self.inputevent:
|
||||||
|
self.inputevent.notify()
|
||||||
|
elif key == "BACKSPACE":
|
||||||
|
self.inputs = self.inputs[0:-1]
|
||||||
|
else:
|
||||||
|
self.inputs += key
|
||||||
|
self.stdscr.addstr(20, 50, self.inputs)
|
||||||
|
self.stdscr.clrtoeol()
|
||||||
|
self.stdscr.refresh()
|
||||||
|
|
167
commandline.py
Normal file
167
commandline.py
Normal file
@ -0,0 +1,167 @@
|
|||||||
|
import argparse
|
||||||
|
import sys
|
||||||
|
import commandline
|
||||||
|
import re
|
||||||
|
import curses
|
||||||
|
from telethon import sync, TelegramClient
|
||||||
|
import os
|
||||||
|
import tttcutils
|
||||||
|
|
||||||
|
|
||||||
|
def handle():
|
||||||
|
parser = argparse.ArgumentParser(description="Run with no arguments to start interactive mode")
|
||||||
|
parser.add_argument("--verbose", "-v", action="store_true", help="Be verbose")
|
||||||
|
parser.add_argument("--colortest", action="store_true", help="Test the used curses color pallet")
|
||||||
|
|
||||||
|
contacts = parser.add_argument_group("contacts")
|
||||||
|
contacts.add_argument("--list", "-l", action="store_true", help="List available dialogs with chat ids")
|
||||||
|
contacts.add_argument("--startswith", "-s", metavar="S",
|
||||||
|
help="Search for a contact starting with S (case sensitive). Can be used with messaging options if result is unique.")
|
||||||
|
contacts.add_argument("--contains", "-c", metavar="C",
|
||||||
|
help="Search for a contact containing S (case sensitive). Can be used with messaging options if result is unique.")
|
||||||
|
contacts.add_argument("--matches", "-x", metavar="M",
|
||||||
|
help="Search for a contact matching regular expression M. Can be used with messaging options if result is unique.")
|
||||||
|
|
||||||
|
messaging = parser.add_argument_group("messaging")
|
||||||
|
messaging.add_argument("--me", action="store_true", help="Send the message to yourself")
|
||||||
|
messaging.add_argument("--target", "-t", metavar="chatid", type=int, help="Send the message to the given chat")
|
||||||
|
messaging.add_argument("--message", "--msg", "-m", help="Provide a message.")
|
||||||
|
messaging.add_argument("--stdin", "-i", action="store_true", help="Read a message from stdin.")
|
||||||
|
parsed = parser.parse_args()
|
||||||
|
global debug
|
||||||
|
if parsed.verbose:
|
||||||
|
def debug(*args, **kwargs):
|
||||||
|
print(*args, **kwargs)
|
||||||
|
else:
|
||||||
|
def debug(*args, **kwargs):
|
||||||
|
pass
|
||||||
|
|
||||||
|
interactive = len(sys.argv) == 1
|
||||||
|
if interactive:
|
||||||
|
return False
|
||||||
|
elif parsed.colortest:
|
||||||
|
with ColorSample() as c:
|
||||||
|
c.show()
|
||||||
|
return True
|
||||||
|
else:
|
||||||
|
api_id, api_hash = tttcutils.assert_environment()
|
||||||
|
client = TelegramClient("tttc", api_id, api_hash)
|
||||||
|
debug("Connecting...", file=sys.stderr)
|
||||||
|
client.connect()
|
||||||
|
debug("Connected.", file=sys.stderr)
|
||||||
|
if not client.is_user_authorized():
|
||||||
|
print("Please use the interactive client first to authorize and create a tttc.session file. Aborting.", file=sys.stderr)
|
||||||
|
exit(1)
|
||||||
|
debug("Client is authorized.", file=sys.stderr)
|
||||||
|
if parsed.startswith or parsed.list or parsed.matches or parsed.contains:
|
||||||
|
global chats
|
||||||
|
debug("Fetching chats...", file=sys.stderr)
|
||||||
|
chats = client.get_dialogs()
|
||||||
|
|
||||||
|
filtered = chats if parsed.list else filter_chats((parsed.startswith, parsed.contains, parsed.matches))
|
||||||
|
unique = None
|
||||||
|
if filtered:
|
||||||
|
if len(filtered) == 0:
|
||||||
|
print("No matching chats found.")
|
||||||
|
elif len(filtered) > 1:
|
||||||
|
for result in reversed(filtered):
|
||||||
|
print(str(result.id).rjust(16) + " "*4 + result.name)
|
||||||
|
else:
|
||||||
|
if not (parsed.message or parsed.stdin):
|
||||||
|
for result in reversed(filtered):
|
||||||
|
print(str(result.id).rjust(16) + " "*4 + result.name)
|
||||||
|
exit()
|
||||||
|
unique = filtered[0].id
|
||||||
|
if not (parsed.message or parsed.stdin):
|
||||||
|
exit() # we are done here
|
||||||
|
|
||||||
|
recipient = unique or parsed.target
|
||||||
|
if not recipient and parsed.me:
|
||||||
|
recipient = client.get_me().id
|
||||||
|
if not recipient:
|
||||||
|
if len(filtered) > 1:
|
||||||
|
print("Recipient ambiguous. Aborting.")
|
||||||
|
else:
|
||||||
|
print("No recipient provided.")
|
||||||
|
exit()
|
||||||
|
#try:
|
||||||
|
# pass
|
||||||
|
# #recipient = client.get_input_entity(recipient)
|
||||||
|
# #client.get_entity(recipient)
|
||||||
|
#except ValueError:
|
||||||
|
# print("Illegal entity id. Aborting.", file=sys.stderr)
|
||||||
|
# exit(1)
|
||||||
|
|
||||||
|
if parsed.message or parsed.stdin:
|
||||||
|
send_message(client, recipient, message=parsed.message)
|
||||||
|
return True
|
||||||
|
|
||||||
|
def send_message(client, chat_id, message):
|
||||||
|
#print(f"call to {client} {chat_id} {message}")
|
||||||
|
try:
|
||||||
|
recipient = client.get_input_entity(chat_id)
|
||||||
|
except ValueError:
|
||||||
|
print("Could not find the entity for this entity id. Aborting.", file=sys.stderr)
|
||||||
|
exit(1)
|
||||||
|
debug("Chat exists. Sending message.", file=sys.stderr)
|
||||||
|
if message is None:
|
||||||
|
debug("No message specified. Reading from stdin.", file=sys.stderr)
|
||||||
|
message = "".join([line for line in sys.stdin])
|
||||||
|
if message.strip() == "":
|
||||||
|
print("The message must not be empty.", file=sys.stderr)
|
||||||
|
exit()
|
||||||
|
client.send_message(chat_id, message)
|
||||||
|
|
||||||
|
def filter_chats(filt):
|
||||||
|
if filt == (None, None, None):
|
||||||
|
return None
|
||||||
|
starts, contains, matches = filt
|
||||||
|
if starts:
|
||||||
|
return [ chat for chat in chats if chat.name.startswith(starts) ]
|
||||||
|
elif contains:
|
||||||
|
return [ chat for chat in chats if contains in chat.name ]
|
||||||
|
else:
|
||||||
|
reg = re.compile(matches)
|
||||||
|
return [ chat for chat in chats if reg.match(chat.name) ]
|
||||||
|
|
||||||
|
class ColorSample:
|
||||||
|
def __init__(self):
|
||||||
|
pass
|
||||||
|
|
||||||
|
def __enter__(self):
|
||||||
|
self.stdscr = curses.initscr()
|
||||||
|
curses.start_color()
|
||||||
|
curses.use_default_colors()
|
||||||
|
curses.noecho()
|
||||||
|
curses.cbreak()
|
||||||
|
self.stdscr.keypad(1)
|
||||||
|
self.stdscr.refresh()
|
||||||
|
return self
|
||||||
|
|
||||||
|
def __exit__(self, *args):
|
||||||
|
curses.nocbreak()
|
||||||
|
self.stdscr.keypad(0)
|
||||||
|
curses.echo()
|
||||||
|
curses.endwin()
|
||||||
|
return False
|
||||||
|
|
||||||
|
def show(self):
|
||||||
|
self.stdscr.addstr("These are all the colors your terminal supports (and their codes):\n")
|
||||||
|
for i in range(curses.COLORS):
|
||||||
|
curses.init_pair(i, i, -1);
|
||||||
|
self.stdscr.addstr(f" {i} ", curses.color_pair(i))
|
||||||
|
self.stdscr.addstr(f" {i} ", curses.A_STANDOUT | curses.color_pair(i))
|
||||||
|
self.stdscr.addstr("\n\n")
|
||||||
|
self.stdscr.addstr("Press any key to continue.")
|
||||||
|
self.stdscr.getch()
|
||||||
|
self.stdscr.refresh()
|
||||||
|
self.stdscr.clear()
|
||||||
|
self.stdscr.addstr("TTTC uses these colors. Refer to the previous page for their color codes. You can adjust them in the config:\n", curses.color_pair(0))
|
||||||
|
from config import colors
|
||||||
|
for (i, (k, v)) in enumerate(colors.colors.items()):
|
||||||
|
f, b = v
|
||||||
|
curses.init_pair(i+1, f, b);
|
||||||
|
self.stdscr.addstr(f"{k.ljust(25)} {v}\n", curses.color_pair(i+1))
|
||||||
|
self.stdscr.refresh()
|
||||||
|
self.stdscr.refresh()
|
||||||
|
self.stdscr.getch()
|
0
config/__init__.py
Normal file
0
config/__init__.py
Normal file
29
config/colors.py
Normal file
29
config/colors.py
Normal file
@ -0,0 +1,29 @@
|
|||||||
|
import curses
|
||||||
|
|
||||||
|
colors_256 = {
|
||||||
|
"default": (255, -1),
|
||||||
|
"default_highlight": (255, 8),
|
||||||
|
"primary": (14, -1),
|
||||||
|
"secondary": (10, -1),
|
||||||
|
"ternary": (11, -1),
|
||||||
|
"standout": (0, 3),
|
||||||
|
"error": (9, -1),
|
||||||
|
"accent": (237, -1)
|
||||||
|
}
|
||||||
|
colors_8 = {
|
||||||
|
"default": (7, -1),
|
||||||
|
"default_highlight": (0, 7),
|
||||||
|
"primary": (6, -1),
|
||||||
|
"secondary": (2, -1),
|
||||||
|
"ternary": (5, -1),
|
||||||
|
"standout": (7, 3),
|
||||||
|
"error": (1, -1),
|
||||||
|
"accent": (7, -1)
|
||||||
|
}
|
||||||
|
colors = colors_256 if curses.COLORS >= 256 else colors_8
|
||||||
|
def get_colors():
|
||||||
|
for (i, (k, v)) in enumerate(colors.items()):
|
||||||
|
f, b = v
|
||||||
|
curses.init_pair(i+1, f, b);
|
||||||
|
return { k: curses.color_pair(i+1) for (i, (k,v)) in enumerate(colors.items()) }
|
||||||
|
|
69
config/tttcrc.py
Normal file
69
config/tttcrc.py
Normal file
@ -0,0 +1,69 @@
|
|||||||
|
# Colors
|
||||||
|
# How much space should the contacts area use?
|
||||||
|
split_ratio = 0.3
|
||||||
|
# set to None for transparency
|
||||||
|
background = None
|
||||||
|
primary = curses.COLOR_CYAN
|
||||||
|
highlight = curses.COLOR_RED
|
||||||
|
|
||||||
|
# General
|
||||||
|
bootscreen_duration = 0.4
|
||||||
|
contacts_scroll_offset = 0
|
||||||
|
messages_scroll_offset = 0
|
||||||
|
|
||||||
|
## Contacts
|
||||||
|
pinned_group = True
|
||||||
|
pinned_symbol = ""
|
||||||
|
# indicate the amount of unread messages next to the contacts name
|
||||||
|
new_messages = True
|
||||||
|
|
||||||
|
# characters to indicate chat type
|
||||||
|
symbol_read = "✔"
|
||||||
|
symbol_channel = "C"
|
||||||
|
symbol_group = "G"
|
||||||
|
symbol_supergroup = "S"
|
||||||
|
symbol_private = "P"
|
||||||
|
|
||||||
|
time_today = "%I:%M %p"
|
||||||
|
# 6 days to prevent confusion with last weeks <this weekday>
|
||||||
|
# ie if today is monday "Mon" would refer to last weeks monday instead of today. change to 7*86400 if you're fine with that
|
||||||
|
time_lastweek = "%a"
|
||||||
|
lastweek = 6*86400
|
||||||
|
# anything that's not withing lastweek is considered longtimeago
|
||||||
|
time_longtimeago = "%d.%m.%y"
|
||||||
|
|
||||||
|
## Messages
|
||||||
|
message_edited = "edited"
|
||||||
|
message_forwarded = "<author> via <from>"
|
||||||
|
# when author and from match
|
||||||
|
message_forwarded_self = "via <author>"
|
||||||
|
|
||||||
|
# Keys
|
||||||
|
tttc_quit = "q"
|
||||||
|
vimline_open = ":"
|
||||||
|
contacts_search = "/"
|
||||||
|
contacts_top = "gg"
|
||||||
|
contacts_bottom = "G"
|
||||||
|
contacts_next = "c"
|
||||||
|
contacts_prev = "C"
|
||||||
|
|
||||||
|
# mind the escape sequence
|
||||||
|
messages_search = "\\"
|
||||||
|
messages_compose = "m"
|
||||||
|
# use xdg-open to open file/image
|
||||||
|
messages_visual_link = "l"
|
||||||
|
messages_visual_open = "o"
|
||||||
|
messages_visual_reply = "r"
|
||||||
|
messages_visual_next = "n"
|
||||||
|
messages_visual_prev = "N"
|
||||||
|
messages_visual_toggle_select = "space"
|
||||||
|
messages_visual_forward = "w"
|
||||||
|
|
||||||
|
compose_send = "y"
|
||||||
|
compose_edit = "e"
|
||||||
|
# add a file to the message, the message itself then becomes the caption
|
||||||
|
compose_file = "f"
|
||||||
|
# add image to the message, the message itself then becomes the caption
|
||||||
|
compose_image = "i"
|
||||||
|
# after having composed a message you can still select it as a reply to another message
|
||||||
|
compose_select_reply ="r"
|
335
drawtool.py
Normal file
335
drawtool.py
Normal file
@ -0,0 +1,335 @@
|
|||||||
|
import curses
|
||||||
|
import math
|
||||||
|
from telethon.utils import get_display_name
|
||||||
|
import emojis
|
||||||
|
import os
|
||||||
|
import datetime
|
||||||
|
import time
|
||||||
|
import config
|
||||||
|
import textwrap
|
||||||
|
from tttcutils import show_stacktrace, debug
|
||||||
|
|
||||||
|
class Drawtool():
|
||||||
|
def __init__(self, main_view):
|
||||||
|
self.client = main_view.client
|
||||||
|
self.main_view = main_view
|
||||||
|
self.stdscr = main_view.stdscr
|
||||||
|
|
||||||
|
self.chat_ratio = 0.3
|
||||||
|
|
||||||
|
self.H, self.W = self.stdscr.getmaxyx()
|
||||||
|
self.recompute_dimensions()
|
||||||
|
|
||||||
|
self.chat_rows = 5
|
||||||
|
self.chat_offset_fraction = 0.3
|
||||||
|
self.single_chat_fraction = 0.3
|
||||||
|
self.dialog_fraction = 0.25
|
||||||
|
self.show_indices = False
|
||||||
|
|
||||||
|
def recompute_dimensions(self):
|
||||||
|
self.min_input_lines = int(0.1 * self.H)
|
||||||
|
self.max_input_lines = int(0.3 * self.H)
|
||||||
|
try:
|
||||||
|
self.input_lines = min(self.max_input_lines, max(len(self._get_input_lines(self.main_view.inputs, width = self.W - 4)), self.min_input_lines))
|
||||||
|
except:
|
||||||
|
show_stacktrace()
|
||||||
|
|
||||||
|
self.chats_height = self.H - self.input_lines - 3
|
||||||
|
self.chats_width = int(self.W * self.chat_ratio)
|
||||||
|
self.chats_num = self.chats_height // 3
|
||||||
|
|
||||||
|
async def resize(self):
|
||||||
|
self.H, self.W = self.stdscr.getmaxyx()
|
||||||
|
self.recompute_dimensions()
|
||||||
|
await self.redraw()
|
||||||
|
|
||||||
|
def _get_input_lines(self, s, width = 50):
|
||||||
|
# in order to preserve user made linebreaks:
|
||||||
|
wrapper = textwrap.TextWrapper()
|
||||||
|
wrapper.width = width
|
||||||
|
wrapper.replace_whitespace = False
|
||||||
|
wrapper.drop_whitespace = False
|
||||||
|
|
||||||
|
lines = s.split("\n")
|
||||||
|
newlines = []
|
||||||
|
for line in lines:
|
||||||
|
if line:
|
||||||
|
newlines += wrapper.wrap(line)
|
||||||
|
else:
|
||||||
|
newlines += [""]
|
||||||
|
return newlines
|
||||||
|
#return textwrap.wrap(s, width = width)
|
||||||
|
|
||||||
|
def _get_cursor_position(self, s, width = 50):
|
||||||
|
lines = self._get_input_lines(s, width = width)[-self.input_lines:]
|
||||||
|
if not lines:
|
||||||
|
return (0, 0)
|
||||||
|
x = len(lines[-1])
|
||||||
|
y = len(lines) - 1
|
||||||
|
return y, x
|
||||||
|
|
||||||
|
async def redraw(self):
|
||||||
|
self.recompute_dimensions()
|
||||||
|
|
||||||
|
self.stdscr.clear()
|
||||||
|
self.draw_chats()
|
||||||
|
await self.draw_messages()
|
||||||
|
if self.main_view.mode == "search":
|
||||||
|
if self.main_view.search_result == []:
|
||||||
|
self.stdscr.addstr(self.H - 1, 0, "/" + self.main_view.search_box, self.main_view.colors["error"])
|
||||||
|
else:
|
||||||
|
self.stdscr.addstr(self.H - 1, 0, "/" + self.main_view.search_box)
|
||||||
|
elif self.main_view.mode == "vimmode":
|
||||||
|
self.stdscr.addstr(self.H - 1, 0, ":" + self.main_view.vimline_box)
|
||||||
|
else:
|
||||||
|
self.stdscr.addstr(self.H - 1, 0, "--" + self.main_view.mode.upper() + "--")
|
||||||
|
self.stdscr.addstr(self.H - 1, int(self.W * 2/3), self.main_view.command_box[:8])
|
||||||
|
|
||||||
|
for index, line in enumerate(self._get_input_lines(self.main_view.inputs, width = self.W - 4)[-self.input_lines:]):
|
||||||
|
self.stdscr.addstr(self.H - self.input_lines - 2 + index, 2, f"{line}")
|
||||||
|
|
||||||
|
if self.main_view.mode == "insert":
|
||||||
|
curses.curs_set(1)
|
||||||
|
y, x = self._get_cursor_position(self.main_view.inputs, width = self.W - 4)
|
||||||
|
self.stdscr.move(self.H - self.input_lines - 2 + y, 2 + x)
|
||||||
|
elif self.main_view.mode == "search":
|
||||||
|
curses.curs_set(1)
|
||||||
|
self.stdscr.move(self.H - 1, 1 + len(self.main_view.search_box))
|
||||||
|
elif self.main_view.mode == "vimmode":
|
||||||
|
curses.curs_set(1)
|
||||||
|
self.stdscr.move(self.H - 1, 1 + len(self.main_view.vimline_box))
|
||||||
|
else:
|
||||||
|
curses.curs_set(0)
|
||||||
|
self.stdscr.refresh()
|
||||||
|
|
||||||
|
def format(self, text, attributes = None, width = None, alignment = "left", inner_alignment = "left", truncation = "..."):
|
||||||
|
if attributes == None:
|
||||||
|
attributes = self.main_view.colors["default"]
|
||||||
|
if width == None:
|
||||||
|
width = len(text)
|
||||||
|
return {
|
||||||
|
"text": text,
|
||||||
|
"attributes": attributes,
|
||||||
|
"width": width,
|
||||||
|
"alignment": alignment,
|
||||||
|
"inner_alignment": inner_alignment,
|
||||||
|
"truncation": truncation
|
||||||
|
}
|
||||||
|
|
||||||
|
def _datestring(self, date):
|
||||||
|
now = datetime.datetime.now().astimezone()
|
||||||
|
today = datetime.date.today()
|
||||||
|
if (now - date).total_seconds() < 20*3600:
|
||||||
|
out = date.strftime(f"%I:%M %p")
|
||||||
|
return out
|
||||||
|
if (now - date).total_seconds() < 6*86400:
|
||||||
|
return date.strftime("%A")
|
||||||
|
return date.strftime("%d.%m.%y")
|
||||||
|
|
||||||
|
def draw_frame(self, yoff, xoff, h, w, chars = "││──┌┐└┘", attributes = 0):
|
||||||
|
for i in range(h):
|
||||||
|
self.stdscr.addstr(yoff + i, xoff, chars[0], attributes)
|
||||||
|
for i in range(h):
|
||||||
|
self.stdscr.addstr(yoff + i, xoff + w, chars[1], attributes)
|
||||||
|
for i in range(w):
|
||||||
|
self.stdscr.addstr(yoff, xoff + i, chars[2], attributes)
|
||||||
|
for i in range(w):
|
||||||
|
self.stdscr.addstr(yoff + h, xoff + i, chars[3], attributes)
|
||||||
|
self.stdscr.addstr(yoff, xoff, chars[4], attributes)
|
||||||
|
self.stdscr.addstr(yoff, xoff + w, chars[5], attributes)
|
||||||
|
self.stdscr.addstr(yoff + h, xoff, chars[6], attributes)
|
||||||
|
self.stdscr.addstr(yoff + h, xoff + w, chars[7], attributes)
|
||||||
|
|
||||||
|
def draw_chats(self):
|
||||||
|
selected_chat_index = self.main_view.selected_chat - self.main_view.selected_chat_offset
|
||||||
|
offset = self.main_view.selected_chat_offset
|
||||||
|
try:
|
||||||
|
self.draw_frame(0,0, self.chats_height , self.chats_width)
|
||||||
|
index = 0
|
||||||
|
y = 1
|
||||||
|
for index in range(self.chats_num):
|
||||||
|
dialog = self.main_view.dialogs[index + offset]
|
||||||
|
message = dialog["messages"][0] if "messages" in dialog else dialog["dialog"].message
|
||||||
|
message_string = message.text if message.text else "[Non-text object]"
|
||||||
|
if self.main_view.text_emojis:
|
||||||
|
message_string = emojis.decode(message_string)
|
||||||
|
chat_name = get_display_name(dialog["dialog"].entity)
|
||||||
|
from_string = get_display_name(message.sender)
|
||||||
|
unread = dialog["unread_count"]
|
||||||
|
unread_string = f"({unread} new)" if unread else ""
|
||||||
|
date = dialog["dialog"].date
|
||||||
|
date = date.astimezone()
|
||||||
|
date_string = self._datestring(date)
|
||||||
|
pinned = "* " if dialog["dialog"].pinned else " "
|
||||||
|
selected = selected_chat_index == index
|
||||||
|
|
||||||
|
self.draw_text(
|
||||||
|
[
|
||||||
|
self.format("o" if dialog["online"] else " ", attributes = self.main_view.colors["secondary"]),
|
||||||
|
self.format(chat_name, attributes = self.main_view.colors["primary"] | curses.A_STANDOUT if selected else curses.A_BOLD, width = int(0.5 * self.chats_width)),
|
||||||
|
self.format(f" {str(index)} " if self.show_indices else "", attributes = self.main_view.colors["standout"]),
|
||||||
|
self.format(unread_string, attributes = self.main_view.colors["error"], alignment = "right"),
|
||||||
|
self.format(date_string, alignment = "right", attributes = self.main_view.colors["primary"]),
|
||||||
|
],
|
||||||
|
y, 2, maxwidth = self.chats_width - 2)
|
||||||
|
self.draw_text(
|
||||||
|
[
|
||||||
|
self.format(f"{from_string}:"),
|
||||||
|
self.format(message_string, width = self.chats_width - len(f"{from_string}: ") - 3)
|
||||||
|
],
|
||||||
|
y + 1, 2, maxwidth = self.chats_width - 2)
|
||||||
|
y += 3
|
||||||
|
index += 1
|
||||||
|
except Exception:
|
||||||
|
show_stacktrace()
|
||||||
|
|
||||||
|
def draw_text(self, format_dicts, y_off = 0, x_off = 0, screen = None, maxwidth = None, separator = " "):
|
||||||
|
if maxwidth == None:
|
||||||
|
maxwidth = sum(format_dict["width"] for format_dict in format_dicts)
|
||||||
|
if screen == None:
|
||||||
|
screen = self.stdscr
|
||||||
|
left = [ x for x in format_dicts if x["alignment"] == "left" ]
|
||||||
|
right = list(reversed([ x for x in format_dicts if x["alignment"] == "right" ]))
|
||||||
|
center = [ x for x in format_dicts if x["alignment"] == "center" ]
|
||||||
|
entries = [ (x, "left") for x in left ] + [ (x, "right") for x in right ] + [ (x, "center") for x in center ]
|
||||||
|
x_left = 0
|
||||||
|
x_right = maxwidth -1
|
||||||
|
for (format_dict, alignment) in entries:
|
||||||
|
text = format_dict["text"]
|
||||||
|
text = text.replace("\n", "")
|
||||||
|
attributes = format_dict["attributes"]
|
||||||
|
width = format_dict["width"]
|
||||||
|
inner_alignment = format_dict["inner_alignment"]
|
||||||
|
truncation = format_dict["truncation"]
|
||||||
|
# TODO: make this split preferrably at spaces and not show linebreaks
|
||||||
|
display_text = text.split("\n")[0]
|
||||||
|
if len(display_text) > width:
|
||||||
|
if truncation:
|
||||||
|
# TODO: make this split preferrably at spaces and not show linebreaks
|
||||||
|
display_text = display_text[:width - len(truncation)] + truncation
|
||||||
|
else:
|
||||||
|
display_text = display_text[:width]
|
||||||
|
rljust = " " * (width - len(display_text))
|
||||||
|
|
||||||
|
if alignment == "left":
|
||||||
|
if inner_alignment == "left":
|
||||||
|
screen.addstr(y_off, x_off + x_left, display_text, attributes)
|
||||||
|
x_left += len(display_text)
|
||||||
|
if rljust:
|
||||||
|
screen.addstr(y_off, x_off + x_left, rljust)
|
||||||
|
x_left += len(rljust)
|
||||||
|
elif inner_alignment == "right":
|
||||||
|
if rljust:
|
||||||
|
screen.addstr(y_off, x_off + x_left, rljust)
|
||||||
|
x_left += len(rljust)
|
||||||
|
screen.addstr(y_off, x_off + x_left, display_text, attributes)
|
||||||
|
x_left += len(display_text)
|
||||||
|
if left and format_dict != left[-1]:
|
||||||
|
screen.addstr(y_off, x_off + x_left, separator)
|
||||||
|
x_left += len(separator)
|
||||||
|
elif alignment == "right":
|
||||||
|
if inner_alignment == "left":
|
||||||
|
x_right -= len(text)
|
||||||
|
self.stdscr.addstr(y_off, x_off + x_right, text, attributes)
|
||||||
|
x_right -= len(rljust)
|
||||||
|
self.stdscr.addstr(y_off, x_off + x_right, rljust)
|
||||||
|
elif inner_alignment == "right":
|
||||||
|
x_right -= len(rljust)
|
||||||
|
self.stdscr.addstr(y_off, x_off + x_right, rljust)
|
||||||
|
x_right -= len(text)
|
||||||
|
self.stdscr.addstr(y_off, x_off + x_right, text, attributes)
|
||||||
|
if right and format_dict != right[-1]:
|
||||||
|
x_right -= len(separator)
|
||||||
|
self.stdscr.addstr(y_off, x_off + x_right, separator)
|
||||||
|
elif alignment == "center":
|
||||||
|
self.stdscr.addstr(y_off, maxwidth // 2 - len(display_text) // 2, display_text, attributes)
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
def draw_message(self, main_view, message, chat_idx):
|
||||||
|
maxtextwidth = int(self.single_chat_fraction * self.W) - 2
|
||||||
|
lines = []
|
||||||
|
if message.text:
|
||||||
|
message_lines = message.text.split("\n")
|
||||||
|
for message_line in message_lines:
|
||||||
|
if main_view.text_emojis:
|
||||||
|
message_line = emojis.decode(message_line)
|
||||||
|
if message_line == "":
|
||||||
|
lines += [""]
|
||||||
|
else:
|
||||||
|
lines += [
|
||||||
|
message_line[maxtextwidth * i: maxtextwidth*i+maxtextwidth]
|
||||||
|
for i in range(int(math.ceil(len(message_line)/maxtextwidth)))
|
||||||
|
]
|
||||||
|
if message.media:
|
||||||
|
media_type = message.media.to_dict()["_"]
|
||||||
|
if media_type == "MessageMediaPhoto":
|
||||||
|
media_type = "Photo"
|
||||||
|
elif media_type == "MessageMediaDocument":
|
||||||
|
atts = message.media.document.attributes
|
||||||
|
filename = [ x for x in atts if x.to_dict()["_"] == "DocumentAttributeFilename" ]
|
||||||
|
if filename:
|
||||||
|
filename = filename[0].to_dict()["file_name"]
|
||||||
|
media_type = f"Document ({filename})"
|
||||||
|
else:
|
||||||
|
media_type = f"Document ({message.media.document.mime_type})"
|
||||||
|
lines += [ f"[{media_type}]" ]
|
||||||
|
|
||||||
|
reply = ""
|
||||||
|
if message.is_reply:
|
||||||
|
reply_id = message.reply_to_msg_id
|
||||||
|
reply = " r?? "
|
||||||
|
for idx2, message2 in enumerate(main_view.dialogs[main_view.selected_chat]["messages"]):
|
||||||
|
if message2.id == reply_id:
|
||||||
|
reply = f"r{idx2:02d}"
|
||||||
|
break
|
||||||
|
|
||||||
|
from_message = message
|
||||||
|
from_user = "You" if message.out else get_display_name(from_message.sender)
|
||||||
|
via_user = f" via {get_display_name(from_message.forward.sender)}" if message.forward else ""
|
||||||
|
user_string = f"{from_user}{via_user} "
|
||||||
|
out = []
|
||||||
|
if message.out:
|
||||||
|
out.append(f"{chat_idx} {user_string}{self._datestring(message.date.astimezone())}".rjust(maxtextwidth))
|
||||||
|
for idx, text in enumerate(lines):
|
||||||
|
out.append(text.rjust(maxtextwidth - 4))
|
||||||
|
#out.append(f"{chat_idx} {message.date.hour}:{message.date.minute:02d}".rjust(maxtextwidth) + ".")
|
||||||
|
if message.is_reply:
|
||||||
|
out.append(reply)
|
||||||
|
else:
|
||||||
|
out.append(f"{chat_idx} {user_string}{self._datestring(message.date.astimezone())}")
|
||||||
|
for idx, text in enumerate(lines):
|
||||||
|
out.append(" " + text)
|
||||||
|
if message.is_reply:
|
||||||
|
out.append(reply)
|
||||||
|
return (out, message)
|
||||||
|
|
||||||
|
async def load_messages(self, chat_index):
|
||||||
|
main_view = self.main_view
|
||||||
|
index = chat_index
|
||||||
|
if not "messages" in main_view.dialogs[index]:
|
||||||
|
temp = await main_view.client.get_messages(main_view.dialogs[index]["dialog"], 50)
|
||||||
|
main_view.dialogs[index]["messages"] = [ message for message in temp ]
|
||||||
|
|
||||||
|
async def draw_messages(self, offset = 0):
|
||||||
|
main_view = self.main_view
|
||||||
|
await self.load_messages(main_view.selected_chat)
|
||||||
|
messages = main_view.dialogs[main_view.selected_chat]["messages"]
|
||||||
|
max_rows = self.H - self.input_lines - 3 - 1
|
||||||
|
lines = []
|
||||||
|
chat_count = 0
|
||||||
|
while len(lines) < max_rows + offset and chat_count < len(messages):
|
||||||
|
text, message = self.draw_message(main_view, messages[chat_count], chat_count)
|
||||||
|
for line in reversed(text):
|
||||||
|
lines.append((line, message))
|
||||||
|
lines.append(("",message))
|
||||||
|
chat_count += 1
|
||||||
|
|
||||||
|
for i in range(min(len(lines)-offset, max_rows)):
|
||||||
|
text, message = lines[i + offset]
|
||||||
|
if message.out:
|
||||||
|
self.stdscr.addstr(max_rows - i, int(self.W * (1-self.single_chat_fraction) - 4) + 2, text)
|
||||||
|
else:
|
||||||
|
self.stdscr.addstr(max_rows - i, int(self.W * self.chat_offset_fraction) + 2, text)
|
||||||
|
self.draw_frame(0, self.chats_width + 1, self.chats_height, self.W - self.chats_width - 2)
|
13
functest.py
Normal file
13
functest.py
Normal file
@ -0,0 +1,13 @@
|
|||||||
|
def f():
|
||||||
|
print("very secret variable leaked")
|
||||||
|
|
||||||
|
def g():
|
||||||
|
#h = lambda : (print("hi"), print("hello"))
|
||||||
|
yn(f, "leak secret? ")
|
||||||
|
|
||||||
|
def yn(task, question):
|
||||||
|
x = input(question)
|
||||||
|
if x == "y":
|
||||||
|
task()
|
||||||
|
|
||||||
|
g()
|
14
interactive.py
Normal file
14
interactive.py
Normal file
@ -0,0 +1,14 @@
|
|||||||
|
from telethon import sync, TelegramClient
|
||||||
|
import os
|
||||||
|
|
||||||
|
if not ("TTTC_API_ID" in os.environ or "TTTC_API_HASH" in os.environ):
|
||||||
|
print("Please set your environment variables \"TTTC_API_ID\" and \"TTTC_API_HASH\" accordingly.")
|
||||||
|
print("Please consult https://core.telegram.org/api/obtaining_api_id on how to get your own API id and hash.")
|
||||||
|
quit(1)
|
||||||
|
api_id = os.environ["TTTC_API_ID"]
|
||||||
|
api_hash = os.environ["TTTC_API_HASH"]
|
||||||
|
|
||||||
|
client = TelegramClient("tttc", api_id, api_hash)
|
||||||
|
client.connect()
|
||||||
|
chats = client.get_dialogs()
|
||||||
|
print("client: TelegramClient chats: [Dialog]")
|
451
mainview.py
Normal file
451
mainview.py
Normal file
@ -0,0 +1,451 @@
|
|||||||
|
from asyncio import Condition
|
||||||
|
import telethon
|
||||||
|
from telethon import events
|
||||||
|
import resources
|
||||||
|
import os
|
||||||
|
import curses
|
||||||
|
from subprocess import call
|
||||||
|
import drawtool
|
||||||
|
import emojis
|
||||||
|
import shlex
|
||||||
|
import sqlite3
|
||||||
|
from telethon.utils import get_display_name
|
||||||
|
import datetime
|
||||||
|
import re
|
||||||
|
from tttcutils import debug, show_stacktrace
|
||||||
|
import subprocess
|
||||||
|
|
||||||
|
|
||||||
|
class MainView():
|
||||||
|
def __init__(self, client, stdscr):
|
||||||
|
self.stdscr = stdscr
|
||||||
|
self.client = client
|
||||||
|
self.inputevent = Condition()
|
||||||
|
self.client.add_event_handler(self.on_message, events.NewMessage)
|
||||||
|
self.client.add_event_handler(self.on_user_update, events.UserUpdate)
|
||||||
|
# TODO
|
||||||
|
# self.client.add_event_handler(self.on_read, events.MessageRead)
|
||||||
|
self.text_emojis = True
|
||||||
|
|
||||||
|
self.inputs = ""
|
||||||
|
self.inputs_cursor = 0
|
||||||
|
|
||||||
|
self.drawtool = drawtool.Drawtool(self)
|
||||||
|
self.fin = False
|
||||||
|
from config import colors as colorconfig
|
||||||
|
self.colors = colorconfig.get_colors()
|
||||||
|
self.ready = False
|
||||||
|
|
||||||
|
self.search_result = None
|
||||||
|
self.search_index = None
|
||||||
|
self.search_box = ""
|
||||||
|
self.vimline_box = ""
|
||||||
|
self.command_box = ""
|
||||||
|
|
||||||
|
# index corresponds to the index in self.dialogs
|
||||||
|
self.selected_chat = 0
|
||||||
|
# index offset
|
||||||
|
self.selected_chat_offset = 0
|
||||||
|
|
||||||
|
self.selected_message = None
|
||||||
|
|
||||||
|
self.mode = "normal"
|
||||||
|
|
||||||
|
async def quit(self):
|
||||||
|
self.fin = True
|
||||||
|
with await self.inputevent:
|
||||||
|
self.inputevent.notify()
|
||||||
|
|
||||||
|
async def on_user_update(self, event):
|
||||||
|
user_id = event.user_id
|
||||||
|
if event.online != None:
|
||||||
|
for dialog in self.dialogs:
|
||||||
|
if event.online == True:
|
||||||
|
dialog["online_until"] = event.until
|
||||||
|
elif dialog["online_until"]:
|
||||||
|
now = datetime.datetime.now().astimezone()
|
||||||
|
until = dialog["online_until"].astimezone()
|
||||||
|
if (now - until).seconds > 0:
|
||||||
|
dialog["online_until"] = None
|
||||||
|
dialog["online"] = False
|
||||||
|
if dialog["dialog"].entity.id == user_id:
|
||||||
|
dialog["online"] = event.online
|
||||||
|
|
||||||
|
async def on_message(self, event):
|
||||||
|
# move chats with news up
|
||||||
|
for idx, dialog in enumerate(self.dialogs):
|
||||||
|
if dialog["dialog"].id == event.chat_id:
|
||||||
|
# stuff to do upon arriving messages
|
||||||
|
newmessage = await self.client.get_messages(dialog["dialog"], 1)
|
||||||
|
dialog["messages"].insert(0, newmessage[0])
|
||||||
|
if not event.out:
|
||||||
|
dialog["unread_count"] += 1
|
||||||
|
front = self.dialogs.pop(idx)
|
||||||
|
self.dialogs = [front] + self.dialogs
|
||||||
|
break
|
||||||
|
# auto adjust relative replys to match shifted message offsets
|
||||||
|
if event.chat_id == self.dialogs[self.selected_chat]["dialog"].id:
|
||||||
|
if self.inputs.startswith("r"):
|
||||||
|
num = self.inputs[1:].split()[0]
|
||||||
|
try:
|
||||||
|
# num = int(s[1:].split()[0])
|
||||||
|
number = int(num)
|
||||||
|
msg = self.inputs.replace("r" + num, "r" + str(number+1))
|
||||||
|
self.inputs = msg
|
||||||
|
except:
|
||||||
|
pass
|
||||||
|
# dont switch the dialoge upon arriving messages
|
||||||
|
if idx == self.selected_chat:
|
||||||
|
self.selected_chat = 0
|
||||||
|
elif idx > self.selected_chat:
|
||||||
|
self.selected_chat += 1
|
||||||
|
elif idx < self.selected_chat:
|
||||||
|
pass
|
||||||
|
await self.drawtool.redraw()
|
||||||
|
|
||||||
|
async def textinput(self):
|
||||||
|
self.inputs = ""
|
||||||
|
with await self.inputevent:
|
||||||
|
await self.inputevent.wait()
|
||||||
|
if self.fin:
|
||||||
|
return ""
|
||||||
|
out = self.inputs
|
||||||
|
self.inputs = ""
|
||||||
|
return out
|
||||||
|
|
||||||
|
async def run(self):
|
||||||
|
try:
|
||||||
|
chats = await self.client.get_dialogs()
|
||||||
|
except sqlite3.OperationalError:
|
||||||
|
self.stdscr.addstr("Database is locked. Cannot connect with this session. Aborting")
|
||||||
|
self.stdscr.refresh()
|
||||||
|
await self.textinput()
|
||||||
|
await self.quit()
|
||||||
|
self.dialogs = [
|
||||||
|
{
|
||||||
|
"dialog": dialog,
|
||||||
|
"unread_count": dialog.unread_count,
|
||||||
|
"online": dialog.entity.status.to_dict()["_"] == "UserStatusOnline" if hasattr(dialog.entity, "status") and dialog.entity.status else None,
|
||||||
|
"online_until": None,
|
||||||
|
# "last_seen": dialog.entity.status.to_dict()["was_online"] if online == False else None,
|
||||||
|
} for dialog in chats ]
|
||||||
|
await self.drawtool.redraw()
|
||||||
|
self.ready = True
|
||||||
|
while True:
|
||||||
|
s = await self.textinput()
|
||||||
|
if self.fin:
|
||||||
|
return
|
||||||
|
if s.startswith("r"):
|
||||||
|
try:
|
||||||
|
num = int(s[1:].split()[0])
|
||||||
|
except:
|
||||||
|
continue
|
||||||
|
s = s.replace("r" + str(num) + " ", "")
|
||||||
|
reply_msg = self.dialogs[self.selected_chat]["messages"][num]
|
||||||
|
s = emojis.encode(s)
|
||||||
|
reply = await reply_msg.reply(s)
|
||||||
|
await self.on_message(reply)
|
||||||
|
elif s.startswith("media"):
|
||||||
|
try:
|
||||||
|
num = int(s[5:].split()[0])
|
||||||
|
except:
|
||||||
|
continue
|
||||||
|
message = self.dialogs[self.selected_chat]["messages"][num]
|
||||||
|
if message.media:
|
||||||
|
os.makedirs("/tmp/tttc/", exist_ok=True)
|
||||||
|
path = await self.client.download_media(message.media, "/tmp/tttc/")
|
||||||
|
# TODO mute calls
|
||||||
|
if message.media.photo:
|
||||||
|
sizes = message.media.photo.sizes
|
||||||
|
w, h = sizes[0].w, sizes[0].h
|
||||||
|
# w, h
|
||||||
|
basesize = 1500
|
||||||
|
w3m_command=f"0;1;0;0;{basesize};{int(basesize*h/w)};;;;;{path}\n4;\n3;"
|
||||||
|
W3MIMGDISPLAY="/usr/lib/w3m/w3mimgdisplay"
|
||||||
|
os.system(f"echo -e '{w3m_command}' | {W3MIMGDISPLAY} & disown")
|
||||||
|
await self.textinput()
|
||||||
|
else:
|
||||||
|
subprocess.call(["xdg-open", "{shlex.quote(path)}"], stdout = subprocess.DEVNULL, stderr = subprocess.DEVNULL)
|
||||||
|
#os.system(f"(xdg-open {shlex.quote(path)} 2>&1 > /dev/null) & disown")
|
||||||
|
else:
|
||||||
|
s = emojis.encode(s)
|
||||||
|
outgoing_message = await self.dialogs[self.selected_chat]["dialog"].send_message(s)
|
||||||
|
await self.on_message(outgoing_message)
|
||||||
|
await self.drawtool.redraw()
|
||||||
|
|
||||||
|
def select_next_chat(self):
|
||||||
|
# if wrapping not allowed:
|
||||||
|
# self.selected_chat = min(self.selected_chat + 1, len(self.dialogs) - 1)
|
||||||
|
self.selected_chat = (self.selected_chat + 1) % (len(self.dialogs))
|
||||||
|
self.center_selected_chat()
|
||||||
|
|
||||||
|
def select_prev_chat(self):
|
||||||
|
# if wrapping not allowed:
|
||||||
|
# self.selected_chat = max(self.selected_chat - 1, 0)
|
||||||
|
self.selected_chat = (self.selected_chat - 1) % (len(self.dialogs))
|
||||||
|
self.center_selected_chat()
|
||||||
|
|
||||||
|
def center_selected_chat(self):
|
||||||
|
if self.selected_chat < self.drawtool.chats_num // 2:
|
||||||
|
self.selected_chat_offset = 0
|
||||||
|
elif self.selected_chat > len(self.dialogs) - self.drawtool.chats_num // 2:
|
||||||
|
self.selected_chat_offset = len(self.dialogs) - self.drawtool.chats_num
|
||||||
|
else:
|
||||||
|
self.selected_chat_offset = self.selected_chat - self.drawtool.chats_num // 2
|
||||||
|
|
||||||
|
def select_chat(self, index):
|
||||||
|
if index < -1 or index >= len(self.dialogs):
|
||||||
|
return
|
||||||
|
if index == -1:
|
||||||
|
index = len(self.dialogs) - 1
|
||||||
|
while index < self.selected_chat:
|
||||||
|
self.select_prev_chat()
|
||||||
|
else:
|
||||||
|
while index > self.selected_chat:
|
||||||
|
self.select_next_chat()
|
||||||
|
|
||||||
|
|
||||||
|
def is_subsequence(self, xs, ys):
|
||||||
|
xs = list(xs)
|
||||||
|
for y in ys:
|
||||||
|
if xs and xs[0] == y:
|
||||||
|
xs.pop(0)
|
||||||
|
return not xs
|
||||||
|
|
||||||
|
def search_chats(self, query = None):
|
||||||
|
if query is None:
|
||||||
|
query = self.search_box
|
||||||
|
if query is None:
|
||||||
|
return # we dont search for ""
|
||||||
|
filter_function = self.is_subsequence
|
||||||
|
filter_function = lambda x, y: x in y
|
||||||
|
self.search_result = [ idx for (idx, dialog) in enumerate(self.dialogs)
|
||||||
|
if filter_function(query.lower(), get_display_name(dialog["dialog"].entity).lower())]
|
||||||
|
self.search_index = -1
|
||||||
|
|
||||||
|
def search_next(self):
|
||||||
|
if not self.search_result:
|
||||||
|
return
|
||||||
|
if self.search_index == -1:
|
||||||
|
import bisect
|
||||||
|
self.search_index = bisect.bisect_left(self.search_result, self.selected_chat)
|
||||||
|
self.select_chat(self.search_result[self.search_index % len(self.search_result)])
|
||||||
|
self.center_selected_chat()
|
||||||
|
return
|
||||||
|
self.search_index = (self.search_index + 1) % len(self.search_result)
|
||||||
|
index = self.search_result[self.search_index]
|
||||||
|
self.select_chat(index)
|
||||||
|
self.center_selected_chat()
|
||||||
|
|
||||||
|
def search_prev(self):
|
||||||
|
if not self.search_result:
|
||||||
|
return
|
||||||
|
if self.search_index == -1:
|
||||||
|
import bisect
|
||||||
|
self.search_index = bisect.bisect_right(self.search_result, self.selected_chat)
|
||||||
|
self.select_chat(self.search_result[self.search_index])
|
||||||
|
self.center_selected_chat()
|
||||||
|
return
|
||||||
|
self.search_index = (self.search_index - 1) % len(self.search_result)
|
||||||
|
self.select_chat(self.search_result[self.search_index % len(self.search_result)])
|
||||||
|
self.center_selected_chat()
|
||||||
|
|
||||||
|
async def call_command(self):
|
||||||
|
command = self.vimline_box
|
||||||
|
if command == "q":
|
||||||
|
await self.quit()
|
||||||
|
elif command == "pfd":
|
||||||
|
m = ""
|
||||||
|
for i in range(len(self.inputs)):
|
||||||
|
m += self.inputs[i].lower() if i%2==0 else self.inputs[i].lower().swapcase()
|
||||||
|
self.inputs = m
|
||||||
|
|
||||||
|
async def send_message(self):
|
||||||
|
if not self.inputs:
|
||||||
|
return
|
||||||
|
s = self.inputs
|
||||||
|
s = emojis.encode(s)
|
||||||
|
outgoing_message = await self.dialogs[self.selected_chat]["dialog"].send_message(s)
|
||||||
|
await self.on_message(outgoing_message)
|
||||||
|
await self.mark_read()
|
||||||
|
self.center_selected_chat()
|
||||||
|
self.inputs = ""
|
||||||
|
|
||||||
|
async def mark_read(self):
|
||||||
|
chat = self.dialogs[self.selected_chat]
|
||||||
|
dialog = chat["dialog"]
|
||||||
|
lastmessage = chat["messages"][0]
|
||||||
|
await self.client.send_read_acknowledge(dialog, lastmessage)
|
||||||
|
self.dialogs[self.selected_chat]["unread_count"] = 0
|
||||||
|
|
||||||
|
async def show_media(self, num = None):
|
||||||
|
if not num:
|
||||||
|
return
|
||||||
|
message = self.dialogs[self.selected_chat]["messages"][num]
|
||||||
|
if message.media:
|
||||||
|
os.makedirs("/tmp/tttc/", exist_ok=True)
|
||||||
|
# TODO test if file exists, ask for confirmation to replace or download again
|
||||||
|
path = await self.client.download_media(message.media, "/tmp/tttc/")
|
||||||
|
if hasattr(message.media, "photo") and False:
|
||||||
|
sizes = message.media.photo.sizes
|
||||||
|
w, h = sizes[0].w, sizes[0].h
|
||||||
|
# w, h
|
||||||
|
basesize = 300
|
||||||
|
w3m_command=f"0;1;0;0;{basesize};{int(basesize*h/w)};;;;;{path}\n4;\n3;"
|
||||||
|
W3MIMGDISPLAY="/usr/lib/w3m/w3mimgdisplay"
|
||||||
|
echo_sp = subprocess.Popen(["echo", "-e", f"{w3m_command}"], stdout = subprocess.PIPE)
|
||||||
|
w3m_sp = subprocess.Popen([f"{W3MIMGDISPLAY}"], stdin = echo_sp.stdout)
|
||||||
|
else:
|
||||||
|
subprocess.Popen(["xdg-open", f"{path}"], stdout = subprocess.DEVNULL, stderr = subprocess.DEVNULL)
|
||||||
|
|
||||||
|
async def handle_key(self, key):
|
||||||
|
if not self.ready:
|
||||||
|
return
|
||||||
|
if key == "RESIZE":
|
||||||
|
await self.drawtool.resize()
|
||||||
|
return
|
||||||
|
if self.mode == "search":
|
||||||
|
if key == "ESCAPE" or key == "RETURN":
|
||||||
|
self.mode = "normal"
|
||||||
|
elif key == "BACKSPACE":
|
||||||
|
if self.search_box == "":
|
||||||
|
self.mode = "normal"
|
||||||
|
else:
|
||||||
|
self.search_box = self.search_box[0:-1]
|
||||||
|
self.search_chats()
|
||||||
|
self.search_next()
|
||||||
|
else:
|
||||||
|
self.search_box += key
|
||||||
|
self.search_chats()
|
||||||
|
self.search_next()
|
||||||
|
elif self.mode == "vimmode":
|
||||||
|
if key == "ESCAPE":
|
||||||
|
self.mode = "normal"
|
||||||
|
elif key == "RETURN":
|
||||||
|
await self.call_command()
|
||||||
|
self.vimline_box = ""
|
||||||
|
self.mode = "normal"
|
||||||
|
elif key == "BACKSPACE":
|
||||||
|
if self.vimline_box == "":
|
||||||
|
self.mode = "normal"
|
||||||
|
else:
|
||||||
|
self.vimline_box = self.vimline_box[0:-1]
|
||||||
|
else:
|
||||||
|
self.vimline_box += key
|
||||||
|
elif self.mode == "normal":
|
||||||
|
num = None
|
||||||
|
try:
|
||||||
|
num = int(key)
|
||||||
|
except:
|
||||||
|
pass
|
||||||
|
if num is not None:
|
||||||
|
self.command_box += str(num)
|
||||||
|
await self.drawtool.redraw()
|
||||||
|
return
|
||||||
|
elif key == ":":
|
||||||
|
self.mode = "vimmode"
|
||||||
|
self.vimline_box = ""
|
||||||
|
elif key == "RETURN" or key == "y":
|
||||||
|
await self.send_message()
|
||||||
|
elif key == "q":
|
||||||
|
await self.quit()
|
||||||
|
#elif key == "D":
|
||||||
|
# for i in range(10):
|
||||||
|
# self.select_prev_chat()
|
||||||
|
#elif key == "d":
|
||||||
|
# for i in range(10):
|
||||||
|
# self.select_next_chat()
|
||||||
|
elif key == "C":
|
||||||
|
self.select_prev_chat()
|
||||||
|
elif key == "c":
|
||||||
|
self.select_next_chat()
|
||||||
|
elif key == "e":
|
||||||
|
self.text_emojis ^= True
|
||||||
|
elif key == "R":
|
||||||
|
await self.mark_read()
|
||||||
|
elif key == "d":
|
||||||
|
if self.command_box:
|
||||||
|
try:
|
||||||
|
n = int(self.command_box)
|
||||||
|
except:
|
||||||
|
return
|
||||||
|
if n >= len(self.dialogs[self.selected_chat]["messages"]):
|
||||||
|
#TODO: alert user
|
||||||
|
return
|
||||||
|
to_delete = self.dialogs[self.selected_chat]["messages"][n]
|
||||||
|
await to_delete.delete()
|
||||||
|
self.dialogs[self.selected_chat]["messages"].pop(n)
|
||||||
|
self.command_box = ""
|
||||||
|
await self.drawtool.redraw()
|
||||||
|
elif key == "r":
|
||||||
|
if self.command_box:
|
||||||
|
try:
|
||||||
|
n = int(self.command_box)
|
||||||
|
except:
|
||||||
|
return
|
||||||
|
reply_to = self.dialogs[self.selected_chat]["messages"][n]
|
||||||
|
s = emojis.encode(self.inputs)
|
||||||
|
reply = await reply_to.reply(s)
|
||||||
|
await self.on_message(reply)
|
||||||
|
self.command_box = ""
|
||||||
|
self.inputs = ""
|
||||||
|
elif key == "m":
|
||||||
|
if self.command_box:
|
||||||
|
try:
|
||||||
|
n = int(self.command_box)
|
||||||
|
except:
|
||||||
|
return
|
||||||
|
self.command_box = ""
|
||||||
|
await self.show_media(n)
|
||||||
|
elif key == "M":
|
||||||
|
self.center_selected_chat()
|
||||||
|
elif key == "HOME" or key == "g":
|
||||||
|
self.select_chat(0)
|
||||||
|
elif key == "END" or key == "G":
|
||||||
|
self.select_chat(-1)
|
||||||
|
elif key == "i":
|
||||||
|
self.mode = "insert"
|
||||||
|
elif key == "n":
|
||||||
|
self.search_next()
|
||||||
|
elif key == "N":
|
||||||
|
self.search_prev()
|
||||||
|
elif key == "/":
|
||||||
|
self.mode = "search"
|
||||||
|
self.search_box = ""
|
||||||
|
elif key == " ":
|
||||||
|
self.drawtool.show_indices ^= True
|
||||||
|
elif self.mode == "insert":
|
||||||
|
if key == "ESCAPE":
|
||||||
|
self.mode = "normal"
|
||||||
|
elif key == "LEFT":
|
||||||
|
self.insert_move_left()
|
||||||
|
elif key == "RIGHT":
|
||||||
|
self.insert_move_right()
|
||||||
|
elif key == "BACKSPACE":
|
||||||
|
self.inputs = self.inputs[0:-1]
|
||||||
|
elif key == "RETURN":
|
||||||
|
self.inputs += "\n"
|
||||||
|
else:
|
||||||
|
self.inputs += key
|
||||||
|
self.command_box = ""
|
||||||
|
await self.drawtool.redraw()
|
||||||
|
|
||||||
|
def insert_move_left(self):
|
||||||
|
self.inputs_cursor = max(0, self.cursor - 1)
|
||||||
|
|
||||||
|
def insert_move_right(self):
|
||||||
|
self.inputs_cursor = min(len(self.inputs), self.cursor + 1)
|
||||||
|
|
||||||
|
async def handle_key_old(self, key):
|
||||||
|
if key == "RETURN":
|
||||||
|
with await self.inputevent:
|
||||||
|
self.inputevent.notify()
|
||||||
|
elif key == "":
|
||||||
|
chat = self.dialogs[self.selected_chat]["dialog"]
|
||||||
|
last_message = self.dialogs[self.selected_chat]["messages"][0]
|
||||||
|
await self.client.send_read_acknowledge(chat, max_id=last_message.id)
|
||||||
|
self.dialogs[self.selected_chat]["unread_count"] = 0
|
||||||
|
else:
|
||||||
|
self.inputs += key
|
||||||
|
self.drawtool.redraw()
|
||||||
|
|
45
resources.py
Normal file
45
resources.py
Normal file
@ -0,0 +1,45 @@
|
|||||||
|
key_mapping = {
|
||||||
|
"\n": "RETURN",
|
||||||
|
"\x1b":"ESCAPE",
|
||||||
|
"\t":"TAB",
|
||||||
|
343: "NUM_RETURN",
|
||||||
|
263: "BACKSPACE",
|
||||||
|
"": "BACKSPACE",
|
||||||
|
330: "DEL",
|
||||||
|
331: "INSERT",
|
||||||
|
262: "HOME",
|
||||||
|
360: "END",
|
||||||
|
338: "PGDOWN",
|
||||||
|
339: "PGUP",
|
||||||
|
410: "RESIZE",
|
||||||
|
258: "DOWN",
|
||||||
|
259: "UP",
|
||||||
|
260: "LEFT",
|
||||||
|
261: "RIGHT"
|
||||||
|
}
|
||||||
|
|
||||||
|
tttc_logo= \
|
||||||
|
""" ____.
|
||||||
|
____.----' ,##/ _ _ _
|
||||||
|
____.----' ,##" / | |_ | |_ | |_ ___
|
||||||
|
_----' ,###" / | __| | __| | __| / __|
|
||||||
|
-_ .#####" / | |_ | |_ | |_ | (__
|
||||||
|
'-._ .#######" / \__| \__| \__| \___|
|
||||||
|
'-..#######" /
|
||||||
|
\#####" /
|
||||||
|
\##/, /
|
||||||
|
\/ '-, /
|
||||||
|
'-, /
|
||||||
|
' """.split("\n")
|
||||||
|
|
||||||
|
auth_text = {
|
||||||
|
0: ["", "Please enter your full phone number:", " %phone%^"],
|
||||||
|
1: ["An error occured trying to sign you in.", "Please enter your full phone number:", " %phone%^"],
|
||||||
|
2: ["", "Sending authentification code..."],
|
||||||
|
3: ["", "You have been sent a message with", "an activation code. Please provide said code:", " %code%^"],
|
||||||
|
4: ["Incorrect code.", "You have been sent a message with", "an activation code. Please provide said code:", " %code%^"],
|
||||||
|
5: ["", "Signing in..."],
|
||||||
|
6: ["", "Two factor authentification is enabled.", "Please provide your password: %pass%^"],
|
||||||
|
7: ["Incorrect password.", "Two factor authentification is enabled.", "Please provide your password: %pass%^"],
|
||||||
|
8: ["", "Signing in..."]
|
||||||
|
}
|
76
tttc.py
Executable file
76
tttc.py
Executable file
@ -0,0 +1,76 @@
|
|||||||
|
#!/bin/python
|
||||||
|
|
||||||
|
from authview import AuthView
|
||||||
|
from functools import partial
|
||||||
|
from mainview import MainView
|
||||||
|
from queue import Queue
|
||||||
|
from telethon import TelegramClient
|
||||||
|
from telethon import events
|
||||||
|
from time import sleep
|
||||||
|
import argparse
|
||||||
|
import asyncio
|
||||||
|
import commandline
|
||||||
|
import concurrent
|
||||||
|
import curses
|
||||||
|
import resources
|
||||||
|
import sys
|
||||||
|
import os
|
||||||
|
from tttcutils import debug, show_stacktrace
|
||||||
|
import tttcutils
|
||||||
|
|
||||||
|
class Display:
|
||||||
|
def __init__(self, loop):
|
||||||
|
self.loop = loop
|
||||||
|
api_id, api_hash = tttcutils.assert_environment()
|
||||||
|
self.client = TelegramClient("tttc", api_id, api_hash, loop=self.loop)
|
||||||
|
|
||||||
|
|
||||||
|
def __enter__(self):
|
||||||
|
self.stdscr = curses.initscr()
|
||||||
|
curses.start_color()
|
||||||
|
curses.use_default_colors()
|
||||||
|
for i in range(curses.COLORS):
|
||||||
|
curses.init_pair(i, i, -1);
|
||||||
|
curses.noecho()
|
||||||
|
curses.cbreak()
|
||||||
|
self.stdscr.keypad(1)
|
||||||
|
self.stdscr.refresh()
|
||||||
|
return self
|
||||||
|
|
||||||
|
def __exit__(self, *args):
|
||||||
|
curses.nocbreak()
|
||||||
|
self.stdscr.keypad(0)
|
||||||
|
curses.echo()
|
||||||
|
curses.endwin()
|
||||||
|
|
||||||
|
async def main(self):
|
||||||
|
tasks = [
|
||||||
|
self.run(),
|
||||||
|
self.get_ch()
|
||||||
|
]
|
||||||
|
await asyncio.wait(tasks)
|
||||||
|
|
||||||
|
|
||||||
|
async def run(self):
|
||||||
|
self.view = AuthView(self.client, self.stdscr)
|
||||||
|
await self.view.run()
|
||||||
|
self.view = MainView(self.client, self.stdscr)
|
||||||
|
await self.view.run()
|
||||||
|
|
||||||
|
async def get_ch(self):
|
||||||
|
while True:
|
||||||
|
pool = concurrent.futures.ThreadPoolExecutor(max_workers=1)
|
||||||
|
a = await self.loop.run_in_executor(pool, self.stdscr.get_wch)
|
||||||
|
out = resources.key_mapping.get(a, str(a))
|
||||||
|
if self.view:
|
||||||
|
await self.view.handle_key(out)
|
||||||
|
if self.view.fin:
|
||||||
|
return
|
||||||
|
|
||||||
|
|
||||||
|
if commandline.handle():
|
||||||
|
exit()
|
||||||
|
elif __name__ == '__main__':
|
||||||
|
loop = asyncio.get_event_loop()
|
||||||
|
with Display(loop) as display:
|
||||||
|
loop.run_until_complete(display.main())
|
19
tttcutils.py
Normal file
19
tttcutils.py
Normal file
@ -0,0 +1,19 @@
|
|||||||
|
import os
|
||||||
|
import shlex
|
||||||
|
import traceback
|
||||||
|
|
||||||
|
def debug(x):
|
||||||
|
with open("/tmp/tttc.log", "a") as f:
|
||||||
|
f.write(str(x) + "\n")
|
||||||
|
os.system(f"notify-send {shlex.quote(str(x))}")
|
||||||
|
|
||||||
|
def show_stacktrace():
|
||||||
|
a = traceback.format_exc()
|
||||||
|
os.system(f"notify-send {shlex.quote(a)}")
|
||||||
|
|
||||||
|
def assert_environment():
|
||||||
|
if not ("TTTC_API_ID" in os.environ or "TTTC_API_HASH" in os.environ):
|
||||||
|
print("Please set your environment variables \"TTTC_API_ID\" and \"TTTC_API_HASH\" accordingly.")
|
||||||
|
print("Please consult https://core.telegram.org/api/obtaining_api_id on how to get your own API id and hash.")
|
||||||
|
exit(1)
|
||||||
|
return os.environ["TTTC_API_ID"], os.environ["TTTC_API_HASH"]
|
60
vimbindings.md
Normal file
60
vimbindings.md
Normal file
@ -0,0 +1,60 @@
|
|||||||
|
A a append/append
|
||||||
|
B b back/BACK
|
||||||
|
C c change
|
||||||
|
D d delete
|
||||||
|
E e end/END
|
||||||
|
F f find/Find
|
||||||
|
G g go/GO
|
||||||
|
H h LEFT
|
||||||
|
I i INSERT
|
||||||
|
J j DOWN
|
||||||
|
K k UP
|
||||||
|
L l RIGHT
|
||||||
|
M m
|
||||||
|
N n
|
||||||
|
O o insert line
|
||||||
|
P p put
|
||||||
|
Q q quit
|
||||||
|
R r nReply, vReply/replace
|
||||||
|
S s
|
||||||
|
T t till
|
||||||
|
U u
|
||||||
|
V v visual mode
|
||||||
|
v - text visual
|
||||||
|
V - message visual
|
||||||
|
vV - text line visual
|
||||||
|
W w word
|
||||||
|
X x del
|
||||||
|
Y y yank
|
||||||
|
Z z
|
||||||
|
0 $ goto 0 $
|
||||||
|
? help
|
||||||
|
|
||||||
|
RETURN send??
|
||||||
|
|
||||||
|
^A
|
||||||
|
^B
|
||||||
|
^C kill
|
||||||
|
^D
|
||||||
|
^E
|
||||||
|
^F
|
||||||
|
^G
|
||||||
|
^H
|
||||||
|
^I
|
||||||
|
^J
|
||||||
|
^K
|
||||||
|
^L
|
||||||
|
^M
|
||||||
|
^N
|
||||||
|
^O
|
||||||
|
^P
|
||||||
|
^Q
|
||||||
|
^R
|
||||||
|
^S
|
||||||
|
^T
|
||||||
|
^U
|
||||||
|
^V
|
||||||
|
^W
|
||||||
|
^X
|
||||||
|
^Y
|
||||||
|
^Z
|
Loading…
Reference in New Issue
Block a user