├── res ├── logo │ ├── logo_large.png │ ├── logo_small.png │ └── logo.svg ├── screenshots │ ├── minimal_brown_ss.png │ ├── screenshot_main.png │ └── kingk22-screenshot.png ├── scripts │ └── discline └── settings-skeleton.yaml ├── utils ├── hidecursor.py ├── quicksort.py ├── print_utils │ ├── print_utils.py │ ├── channellist.py │ ├── emojis.py │ ├── serverlist.py │ ├── help.py │ └── userlist.py ├── updates.py ├── token_utils.py ├── settings.py └── globals.py ├── ui ├── line.py ├── ui_utils.py ├── text_manipulation.py ├── ui.py └── ui_curses.py ├── LICENSE ├── input ├── typing_handler.py ├── kbhit.py └── input_handler.py ├── client ├── serverlog.py ├── on_message.py ├── channellog.py └── client.py ├── commands ├── channel_jump.py ├── sendfile.py └── text_emoticons.py ├── .gitignore ├── Discline.py └── README.md /res/logo/logo_large.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mitchweaver/Discline/HEAD/res/logo/logo_large.png -------------------------------------------------------------------------------- /res/logo/logo_small.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mitchweaver/Discline/HEAD/res/logo/logo_small.png -------------------------------------------------------------------------------- /res/screenshots/minimal_brown_ss.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mitchweaver/Discline/HEAD/res/screenshots/minimal_brown_ss.png -------------------------------------------------------------------------------- /res/screenshots/screenshot_main.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mitchweaver/Discline/HEAD/res/screenshots/screenshot_main.png -------------------------------------------------------------------------------- /res/screenshots/kingk22-screenshot.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mitchweaver/Discline/HEAD/res/screenshots/kingk22-screenshot.png -------------------------------------------------------------------------------- /utils/hidecursor.py: -------------------------------------------------------------------------------- 1 | from sys import stdout 2 | 3 | # completely hides the system cursor 4 | async def hide_cursor(): 5 | stdout.write("\033[?25l") 6 | stdout.flush() 7 | -------------------------------------------------------------------------------- /ui/line.py: -------------------------------------------------------------------------------- 1 | class Line(): 2 | 3 | # the text the line contains 4 | text = "" 5 | # how offset from the [left_bar_width + MARGIN] it should be printed 6 | # this is to offset wrapped lines to better line up with the previous 7 | offset = 0 8 | 9 | def __init__(self, text, offset): 10 | self.text = text 11 | self.offset = offset 12 | 13 | def length(self): 14 | return len(self.text) 15 | -------------------------------------------------------------------------------- /utils/quicksort.py: -------------------------------------------------------------------------------- 1 | def quick_sort_channel_logs(channel_logs): 2 | # sort channels to match the server's default chosen positions 3 | if len(channel_logs) <= 1: return channel_logs 4 | else: 5 | return quick_sort_channel_logs([e for e in channel_logs[1:] \ 6 | if e.get_channel().position <= channel_logs[0].get_channel().position]) + \ 7 | [channel_logs[0]] + quick_sort_channel_logs([e for e in channel_logs[1:] \ 8 | if e.get_channel().position > channel_logs[0].get_channel().position]) 9 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | DO WHAT THE FUCK YOU WANT TO PUBLIC LICENSE 2 | Version 2, December 2004 3 | 4 | Copyright (C) 2004 Sam Hocevar 5 | 6 | Everyone is permitted to copy and distribute verbatim or modified 7 | copies of this license document, and changing it is allowed as long 8 | as the name is changed. 9 | 10 | DO WHAT THE FUCK YOU WANT TO PUBLIC LICENSE 11 | TERMS AND CONDITIONS FOR COPYING, DISTRIBUTION AND MODIFICATION 12 | 13 | 0. You just DO WHAT THE FUCK YOU WANT TO. 14 | -------------------------------------------------------------------------------- /res/scripts/discline: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | # 3 | # This is a script to easily launch discline 4 | # from an application launcher or similar. 5 | # 6 | # http://github.com/mitchweaver/Discline 7 | # 8 | # These are just examples of what I use, 9 | # edit them to match your system and needs. 10 | 11 | term='st' 12 | font='MonteCarlo' 13 | shell='/bin/dash' 14 | DISCLINE_DIR="${HOME}/workspace/Discline" 15 | PYTHON_VERSION='3.6' 16 | 17 | # ------------------------------------------------------- 18 | 19 | $term -f $font -e $shell -c "cd $DISCLINE_DIR && \ 20 | python$PYTHON_VERSION Discline.py" & 21 | -------------------------------------------------------------------------------- /utils/print_utils/print_utils.py: -------------------------------------------------------------------------------- 1 | import discord 2 | from utils.globals import gc 3 | 4 | async def print_servers(): 5 | print("Available servers: ") 6 | print_line_break(); 7 | for server in gc.client.servers: 8 | print(server.name) 9 | 10 | async def print_user(): 11 | print('Logged in as: ' + gc.term.green + gc.client.user.name + gc.term.normal) 12 | 13 | async def print_line_break(): 14 | print("-" * int(gc.term.width * 0.45)) 15 | 16 | async def print_channels(server): 17 | print("Available channels:") 18 | print_line_break(); 19 | for channel in server.channels: 20 | print(channel.name) 21 | -------------------------------------------------------------------------------- /input/typing_handler.py: -------------------------------------------------------------------------------- 1 | import asyncio 2 | from utils.settings import settings 3 | from utils.globals import gc 4 | 5 | async def is_typing_handler(): 6 | # user specified setting in settings.py 7 | if not settings["send_is_typing"]: return 8 | 9 | is_typing = False 10 | while True: 11 | # if typing a message, display '... is typing' 12 | if not is_typing: 13 | if len(gc.input_buffer) > 0 and gc.input_buffer[0] is not settings["prefix"]: 14 | await gc.client.send_typing(gc.client.get_current_channel()) 15 | is_typing = True 16 | elif len(gc.input_buffer) == 0 or gc.input_buffer[0] is settings["prefix"]: 17 | is_typing = False 18 | 19 | await asyncio.sleep(0.5) 20 | 21 | -------------------------------------------------------------------------------- /client/serverlog.py: -------------------------------------------------------------------------------- 1 | from discord import Server, Channel 2 | from client.channellog import ChannelLog 3 | 4 | # Simple wrapper class to hold a list of ChannelLogs 5 | class ServerLog(): 6 | 7 | __server = "" 8 | __channel_logs = [] 9 | 10 | def __init__(self, server, channel_log_list): 11 | self.__server = server 12 | self.__channel_logs = list(channel_log_list) 13 | 14 | def get_server(self): 15 | return self.__server 16 | 17 | def get_name(self): 18 | return self.__server.name 19 | 20 | def get_logs(self): 21 | return self.__channel_logs 22 | 23 | def clear_logs(self): 24 | for channel_log in self.__channel_logs: 25 | del channel_log[:] 26 | 27 | # takes list of ChannelLog 28 | def add_logs(self, log_list): 29 | for logs in log_list: 30 | self.__channel_logs.append(logs) 31 | -------------------------------------------------------------------------------- /commands/channel_jump.py: -------------------------------------------------------------------------------- 1 | from utils.globals import gc 2 | from utils.quicksort import quick_sort_channel_logs 3 | from utils.settings import settings 4 | 5 | async def channel_jump(arg): 6 | logs = [] 7 | 8 | num = int(arg[1:]) - 1 9 | 10 | # sub one to allow for "/c0" being the top channel 11 | if settings["arrays_start_at_zero"]: 12 | num -= 1 13 | 14 | # in case someone tries to go to a negative index 15 | if num <= -1: 16 | num = 0 17 | 18 | for slog in gc.server_log_tree: 19 | if slog.get_server() is gc.client.get_current_server(): 20 | for clog in slog.get_logs(): 21 | logs.append(clog) 22 | 23 | logs = quick_sort_channel_logs(logs) 24 | 25 | 26 | if num > len(logs): num = len(logs) - 1 27 | 28 | gc.client.set_current_channel(logs[num].get_name()) 29 | logs[num].unread = False 30 | logs[num].mentioned_in = False 31 | -------------------------------------------------------------------------------- /commands/sendfile.py: -------------------------------------------------------------------------------- 1 | from getpass import getuser 2 | from utils.globals import gc 3 | from ui.ui import set_display 4 | 5 | async def send_file(client, filepath): 6 | 7 | # try to open the file exactly as user inputs it 8 | try: 9 | await client.send_file(client.get_current_channel(), filepath) 10 | except: 11 | # assume the user ommited the prefix of the dir path, 12 | # try to load it starting from user's home directory: 13 | try: 14 | filepath = "/home/" + getuser() + "/" + filepath 15 | await client.send_file(client.get_current_channel(), filepath) 16 | except: 17 | # Either a bad file path, the file was too large, 18 | # or encountered a connection problem during upload 19 | msg = "Error: Bad filepath" 20 | await set_display(gc.term.bold + gc.term.red + gc.term.move(gc.term.height - 1, \ 21 | gc.term.width - len(msg) - 1) + msg) 22 | -------------------------------------------------------------------------------- /client/on_message.py: -------------------------------------------------------------------------------- 1 | from ui.ui import print_screen 2 | from utils.globals import gc 3 | from ui.text_manipulation import calc_mutations 4 | 5 | async def on_incoming_message(msg): 6 | 7 | # TODO: make sure it isn't a private message 8 | 9 | # find the server/channel it belongs to and add it 10 | for server_log in gc.server_log_tree: 11 | if server_log.get_server() == msg.server: 12 | for channel_log in server_log.get_logs(): 13 | if channel_log.get_channel() == msg.channel: 14 | 15 | channel_log.append(await calc_mutations(msg)) 16 | 17 | if channel_log.get_channel() is not gc.client.get_current_channel(): 18 | if msg.server.me.mention in msg.content: 19 | channel_log.mentioned_in = True 20 | else: 21 | channel_log.unread = True 22 | 23 | # redraw the screen 24 | await print_screen() 25 | -------------------------------------------------------------------------------- /input/kbhit.py: -------------------------------------------------------------------------------- 1 | import os 2 | import sys 3 | import termios 4 | import atexit 5 | from select import select 6 | 7 | class KBHit: 8 | 9 | def __init__(self): 10 | self.fd = sys.stdin.fileno() 11 | if self.fd is not None: 12 | self.fd = os.fdopen(os.dup(self.fd)) 13 | 14 | self.new_term = termios.tcgetattr(self.fd) 15 | self.old_term = termios.tcgetattr(self.fd) 16 | 17 | # New terminal setting unbuffered 18 | self.new_term[3] = (self.new_term[3] & ~termios.ICANON & ~termios.ECHO) 19 | termios.tcsetattr(self.fd, termios.TCSAFLUSH, self.new_term) 20 | 21 | # Support normal-terminal reset at exit 22 | atexit.register(self.set_normal_term) 23 | 24 | def set_normal_term(self): 25 | ''' Resets to normal terminal. ''' 26 | termios.tcsetattr(self.fd, termios.TCSAFLUSH, self.old_term) 27 | 28 | async def getch(self): 29 | return self.fd.read(1) 30 | 31 | async def kbhit(self): 32 | ''' Returns if keyboard character was hit ''' 33 | dr,dw,de = select([self.fd], [], [], 0) 34 | return dr != [] 35 | -------------------------------------------------------------------------------- /commands/text_emoticons.py: -------------------------------------------------------------------------------- 1 | async def check_emoticons(client, cmd): 2 | if cmd == "shrug": 3 | try: await client.send_message(client.get_current_channel(), "¯\_(ツ)_/¯") 4 | except: pass 5 | elif cmd == "tableflip": 6 | try: await client.send_message(client.get_current_channel(), "(╯°□°)╯︵ ┻━┻") 7 | except: pass 8 | elif cmd == "unflip": 9 | try: await client.send_message(client.get_current_channel(), "┬──┬ ノ( ゜-゜ノ)") 10 | except: pass 11 | elif cmd == "zoidberg": 12 | try: await client.send_message(client.get_current_channel(), "(/) (°,,°) (/)") 13 | except: pass 14 | elif cmd == "lenny": 15 | try: await client.send_message(client.get_current_channel(), "( ͡° ͜ʖ ͡°)") 16 | except: pass 17 | elif cmd == "lennyx5": 18 | try: await client.send_message(client.get_current_channel(), "( ͡°( ͡° ͜ʖ( ͡° ͜ʖ ͡°)ʖ ͡°) ͡°)") 19 | except: pass 20 | elif cmd == "glasses": 21 | try: await client.send_message(client.get_current_channel(), "(•_•) ( •_•)>⌐■-■ (⌐■_■)") 22 | except: pass 23 | elif cmd == "walking_my_mods": 24 | try: await client.send_message(client.get_current_channel(), "⌐( ͡° ͜ʖ ͡°) ╯╲___卐卐卐卐") 25 | except: pass 26 | 27 | -------------------------------------------------------------------------------- /client/channellog.py: -------------------------------------------------------------------------------- 1 | # Wrapper class to make dealing with logs easier 2 | class ChannelLog(): 3 | 4 | __channel = "" 5 | __logs = [] 6 | unread = False 7 | mentioned_in = False 8 | # the index of where to start printing the messages 9 | __index = 0 10 | 11 | def __init__(self, channel, logs): 12 | self.__channel = channel 13 | self.__logs = list(logs) 14 | 15 | def get_server(self): return self.__channel.server 16 | def get_channel(self): return self.__channel 17 | 18 | def get_logs(self): 19 | return self.__logs 20 | 21 | def get_name(self): 22 | return self.__channel.name 23 | 24 | def get_server_name(self): 25 | return self.__channel.server.name 26 | 27 | def append(self, message): 28 | self.__logs.append(message) 29 | 30 | def index(self, message): 31 | return self.__logs.index(message) 32 | 33 | def insert(self, i, message): 34 | self.__logs.insert(i, message) 35 | 36 | def len(self): 37 | return len(self.__logs) 38 | 39 | def get_index(self): 40 | return self.__index 41 | 42 | def set_index(self, int): 43 | self.__index = int 44 | 45 | def inc_index(self, int): 46 | self.__index += int 47 | 48 | def dec_index(self, int): 49 | self.__index -= int 50 | -------------------------------------------------------------------------------- /utils/print_utils/channellist.py: -------------------------------------------------------------------------------- 1 | from os import system 2 | from discord import ChannelType 3 | from ui.ui import clear_screen, set_display 4 | from utils.globals import gc 5 | 6 | async def print_channellist(): 7 | if len(gc.client.servers) == 0: 8 | set_display(gc.term.red + "Error: You are not in any servers.") 9 | return 10 | 11 | if len(gc.client.get_current_server().channels) == 0: 12 | set_display(gc.term.red + "Error: Does this server not have any channels?" + gc.term.normal) 13 | return 14 | 15 | buffer = [] 16 | for channel in gc.client.get_current_server().channels: 17 | if channel.type == ChannelType.text: 18 | name = channel.name 19 | name = name.replace("'", "") 20 | name = name.replace('"', "") 21 | name = name.replace("`", "") 22 | name = name.replace("$(", "") 23 | buffer.append(name + "\n") 24 | 25 | await clear_screen() 26 | system("echo '" + gc.term.cyan + "Available Channels in " \ 27 | + gc.term.magenta + gc.client.get_current_server_name() + ": \n" \ 28 | + "---------------------------- \n \n" \ 29 | + gc.term.yellow + "".join(buffer) \ 30 | + gc.term.green + "~ \n" \ 31 | + gc.term.green + "~ \n" \ 32 | + gc.term.green + "(press \'q\' to quit this dialog) \n" \ 33 | + "' | less -R") 34 | 35 | -------------------------------------------------------------------------------- /utils/print_utils/emojis.py: -------------------------------------------------------------------------------- 1 | from os import system 2 | from ui.ui import clear_screen, set_display 3 | from utils.globals import gc, get_color 4 | from utils.settings import settings 5 | 6 | async def print_emojilist(): 7 | if len(gc.client.servers) == 0: 8 | set_display(gc.term.red + "Error: You are not in any servers." + gc.term.normal) 9 | return 10 | 11 | server_name = gc.client.get_current_server_name() 12 | server_name = server_name.replace("'", "") 13 | server_name = server_name.replace('"', "") 14 | server_name = server_name.replace("`", "") 15 | server_name = server_name.replace("$(", "") 16 | 17 | emojis = [] 18 | server_emojis = "" 19 | 20 | try: server_emojis = gc.client.get_current_server().emojis 21 | except: pass 22 | 23 | if server_emojis is not None and server_emojis != "": 24 | for emoji in server_emojis: 25 | name = emoji.name 26 | name = name.replace("'", "") 27 | name = name.replace('"', "") 28 | name = name.replace("`", "") 29 | name = name.replace("$(", "") 30 | emojis.append(gc.term.yellow + ":" + name + ":" + "\n") 31 | 32 | await clear_screen() 33 | system("echo '" + gc.term.magenta + "Available Emojis in: " + gc.term.cyan + server_name +"\n" + gc.term.normal \ 34 | + "---------------------------- \n" \ 35 | + "".join(emojis) \ 36 | + gc.term.green + "~ \n" \ 37 | + gc.term.green + "~ \n" \ 38 | + gc.term.green + "(press q to quit this dialog) \n" \ 39 | + "' | less -R") 40 | -------------------------------------------------------------------------------- /utils/updates.py: -------------------------------------------------------------------------------- 1 | def check_for_updates(): 2 | from utils.globals import gc 3 | from os import path 4 | 5 | if not path.exists(".git"): 6 | print(gc.term.red("Error: client not started from repo location! Cancelling...")) 7 | print(gc.term.yellow("You must start the client from its folder to get automatic updates. \n")) 8 | return 9 | 10 | try:# git pull at start as to automatically update to master repo 11 | from subprocess import Popen,PIPE 12 | print(gc.term.green + "Checking for updates..." + gc.term.normal) 13 | process = Popen(["git", "pull", "--force"], stdout=PIPE) 14 | output = process.communicate()[0].decode('utf-8').strip() 15 | 16 | if "Already up to date" not in output: 17 | # print(gc.term.yellow("Updates downloaded! Please restart.")) 18 | print("\n \n") 19 | # This quit() call is breaking the client on MacOS and Linux Mint 20 | # The if statement above is being triggered, even when the output IS 21 | # "Already up to date". Why is this happening? 22 | # quit() 23 | else: 24 | print("Already up to date!" + "\n") 25 | except KeyboardInterrupt: print("Call to cancel update received, skipping.") 26 | except SystemExit: pass 27 | except OSError: # (file not found) 28 | # They must not have git installed, no automatic updates for them! 29 | print(gc.term.red + "Error fetching automatic updates! Do you \ 30 | have git installed?" + gc.term.normal) 31 | except: 32 | print(gc.term.red + "Unkown error occurred during retrieval \ 33 | of updates." + gc.term.normal) 34 | -------------------------------------------------------------------------------- /utils/print_utils/serverlist.py: -------------------------------------------------------------------------------- 1 | from os import system 2 | from ui.ui import clear_screen, set_display 3 | from utils.globals import get_color, gc 4 | from utils.settings import settings 5 | 6 | async def print_serverlist(): 7 | if len(gc.client.servers) == 0: 8 | set_display(gc.term.red + "Error: You are not in any servers." + gc.term.normal) 9 | return 10 | 11 | buffer = [] 12 | for slog in gc.server_log_tree: 13 | name = slog.get_name() 14 | name = name.replace("'", "") 15 | name = name.replace('"', "") 16 | name = name.replace("`", "") 17 | name = name.replace("$(", "") 18 | 19 | if slog.get_server() is gc.client.get_current_server(): 20 | buffer.append(await get_color(settings["current_channel_color"]) + name + gc.term.normal + "\n") 21 | continue 22 | 23 | string = "" 24 | for clog in slog.get_logs(): 25 | if clog.mentioned_in: 26 | string = await get_color(settings["unread_mention_color"]) + name + gc.term.normal + "\n" 27 | break 28 | elif clog.unread: 29 | string = await get_color(settings["unread_channel_color"]) + name + gc.term.normal + "\n" 30 | break 31 | 32 | if string == "": 33 | string = await get_color(settings["text_color"]) + name + gc.term.normal + "\n" 34 | 35 | buffer.append(string) 36 | 37 | await clear_screen() 38 | system("echo '" + gc.term.magenta + "Available Servers: \n" + gc.term.normal \ 39 | + "---------------------------- \n \n" \ 40 | + "".join(buffer) \ 41 | + gc.term.green + "~ \n" \ 42 | + gc.term.green + "~ \n" \ 43 | + gc.term.green + "(press q to quit this dialog) \n" \ 44 | + "' | less -R") 45 | -------------------------------------------------------------------------------- /utils/token_utils.py: -------------------------------------------------------------------------------- 1 | import os 2 | from utils.globals import gc 3 | 4 | def get_token(): 5 | if os.path.exists(os.getenv("HOME") + "/.config/Discline/token"): 6 | token = "" 7 | try: 8 | f = open(os.getenv("HOME") + "/.config/Discline/token", "r") 9 | token = f.read() 10 | f.close() 11 | except: pass 12 | 13 | if token != "": 14 | return token 15 | 16 | from blessings import Terminal 17 | gc.term = Terminal() 18 | print("\n" + gc.term.red("Error reading token.")) 19 | print("\n" + gc.term.yellow("Are you sure you stored your token?")) 20 | print(gc.term.yellow("Use --store-token to store your token.")) 21 | quit() 22 | 23 | def store_token(): 24 | import sys 25 | from blessings import Terminal 26 | 27 | token = "" 28 | try: 29 | token=sys.argv[2] 30 | except IndexError: 31 | print(Terminal().red("Error: You did not specify a token!")) 32 | quit() 33 | 34 | if not os.path.exists(os.getenv("HOME") + "/.config/Discline"): 35 | os.mkdir(os.getenv("HOME") + "/.config/Discline") 36 | 37 | if token is not None and token != "": 38 | # trim off quotes if user added them 39 | token = token.strip('"') 40 | token = token.strip("'") 41 | 42 | # ------- Token format seems to vary, disabling this check for now -------- # 43 | # if token is None or len(token) < 59 or len(token) > 88: 44 | # print(Terminal().red("Error: Bad token. Did you paste it correctly?")) 45 | # quit() 46 | # ------------------------------------------------------------------------- # 47 | 48 | try: 49 | f = open(os.getenv("HOME") + "/.config/Discline/token", "w") 50 | f.write(token) 51 | f.close() 52 | print(Terminal().green("Token stored!")) 53 | except: 54 | print(Terminal().red("Error: Could not write token to file.")) 55 | quit() 56 | -------------------------------------------------------------------------------- /utils/settings.py: -------------------------------------------------------------------------------- 1 | import os 2 | import sys 3 | from yaml import safe_load 4 | from blessings import Terminal 5 | 6 | settings = "" 7 | 8 | def copy_skeleton(): 9 | term = Terminal() 10 | try: 11 | from shutil import copyfile 12 | if not os.path.exists(os.getenv("HOME") + "/.config/Discline"): 13 | os.mkdir(os.getenv("HOME") + "/.config/Discline") 14 | 15 | if os.path.exists(os.getenv("HOME") + "/.config/Discline/config"): 16 | try: 17 | os.remove(os.getenv("HOME") + "/.config/Discline/config") 18 | except: 19 | pass 20 | 21 | copyfile("res/settings-skeleton.yaml", os.getenv("HOME") + "/.config/Discline/config", follow_symlinks=True) 22 | print(term.green("Skeleton copied!" + term.normal)) 23 | print(term.cyan("Your configuration file can be found at ~/.config/Discline")) 24 | 25 | except KeyboardInterrupt: 26 | print("Cancelling...") 27 | quit() 28 | except SystemExit: 29 | quit() 30 | except: 31 | print(term.red("Error creating skeleton file.")) 32 | quit() 33 | 34 | def load_config(path): 35 | global settings 36 | with open(path) as f: 37 | settings = safe_load(f) 38 | 39 | arg = "" 40 | try: 41 | arg = sys.argv[1] 42 | except IndexError: 43 | pass 44 | 45 | if arg == "--store-token" or arg == "--token": 46 | pass 47 | elif arg == "--skeleton" or arg == "--copy-skeleton": 48 | copy_skeleton() 49 | quit() 50 | elif arg == "--config": 51 | try: 52 | load_config(sys.argv[2]) 53 | except IndexError: 54 | print("No path provided?") 55 | quit() 56 | except: 57 | print("Invalid path to config entered.") 58 | quit() 59 | else: 60 | try: 61 | load_config(os.getenv("HOME") + "/.config/Discline/config") 62 | except: 63 | try: 64 | load_config(os.getenv("HOME") + "/.Discline") 65 | except: 66 | print(term.red("ERROR: could not get settings.")) 67 | quit() 68 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | api-ref.html 2 | notes.md 3 | 4 | # more token things just incase someone tries to push 5 | # their branch with their token in here... (smh) 6 | token 7 | login 8 | login.sh 9 | login.txt 10 | discord-token 11 | token.sh 12 | run.sh 13 | start 14 | start.sh 15 | note.txt 16 | notes.txt 17 | token.txt 18 | token 19 | token_login.txt 20 | login.txt 21 | 22 | 23 | # Byte-compiled / optimized / DLL files 24 | __pycache__/ 25 | *.py[cod] 26 | *$py.class 27 | 28 | # C extensions 29 | *.so 30 | 31 | # Distribution / packaging 32 | .Python 33 | env/ 34 | build/ 35 | develop-eggs/ 36 | dist/ 37 | downloads/ 38 | eggs/ 39 | .eggs/ 40 | lib/ 41 | lib64/ 42 | parts/ 43 | sdist/ 44 | var/ 45 | wheels/ 46 | *.egg-info/ 47 | .installed.cfg 48 | *.egg 49 | 50 | # PyInstaller 51 | # Usually these files are written by a python script from a template 52 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 53 | *.manifest 54 | *.spec 55 | 56 | # Installer logs 57 | pip-log.txt 58 | pip-delete-this-directory.txt 59 | 60 | # Unit test / coverage reports 61 | htmlcov/ 62 | .tox/ 63 | .coverage 64 | .coverage.* 65 | .cache 66 | nosetests.xml 67 | coverage.xml 68 | *.cover 69 | .hypothesis/ 70 | 71 | # Translations 72 | *.mo 73 | *.pot 74 | 75 | # Django stuff: 76 | *.log 77 | local_settings.py 78 | 79 | # Flask stuff: 80 | instance/ 81 | .webassets-cache 82 | 83 | # Scrapy stuff: 84 | .scrapy 85 | 86 | # Sphinx documentation 87 | docs/_build/ 88 | 89 | # PyBuilder 90 | target/ 91 | 92 | # Jupyter Notebook 93 | .ipynb_checkpoints 94 | 95 | # pyenv 96 | .python-version 97 | 98 | # celery beat schedule file 99 | celerybeat-schedule 100 | 101 | # SageMath parsed files 102 | *.sage.py 103 | 104 | # dotenv 105 | .env 106 | 107 | # virtualenv 108 | .venv 109 | venv/ 110 | ENV/ 111 | 112 | # Spyder project settings 113 | .spyderproject 114 | .spyproject 115 | 116 | # Rope project settings 117 | .ropeproject 118 | 119 | # mkdocs documentation 120 | /site 121 | 122 | # mypy 123 | .mypy_cache/ 124 | 125 | # curses notes and stuff 126 | log.txt 127 | loading.txt 128 | disTypes/ 129 | 130 | process.md 131 | -------------------------------------------------------------------------------- /ui/ui_utils.py: -------------------------------------------------------------------------------- 1 | from utils.globals import get_color, gc 2 | from utils.settings import settings 3 | 4 | async def get_prompt(): 5 | left = await get_color(settings["prompt_border_color"]) + "[" 6 | right = await get_color(settings["prompt_border_color"]) + "]: " + gc.term.normal 7 | middle = "" 8 | if gc.client.get_prompt() == settings["default_prompt"]: 9 | middle = " " + await get_color(settings["prompt_color"]) + settings["default_prompt"] + " " 10 | else: 11 | middle = await get_color(settings["prompt_hash_color"]) + "#" \ 12 | + await get_color(settings["prompt_color"]) + gc.client.get_prompt() 13 | 14 | return left + middle + right 15 | 16 | 17 | async def get_max_lines(): 18 | num = 0 19 | if settings["show_top_bar"] and settings["show_separators"]: 20 | num = gc.term.height - settings["margin"] * 2 21 | elif settings["show_top_bar"] and not settings["show_separators"]: 22 | num = gc.term.height - settings["margin"] 23 | elif not settings["show_top_bar"] and not settings["show_separators"]: 24 | num = gc.term.height - 1 25 | 26 | return num 27 | 28 | async def get_left_bar_width(): 29 | if not settings["show_left_bar"]: return 0 30 | 31 | left_bar_width = gc.term.width // settings["left_bar_divider"] 32 | if left_bar_width < 8: return 8 33 | else: return left_bar_width 34 | 35 | async def get_role_color(msg): 36 | color = "" 37 | try: 38 | r = msg.author.top_role.name.lower() 39 | for role in settings["custom_roles"]: 40 | if r == role["name"].lower(): 41 | color = await get_color(role["color"]) 42 | 43 | if color is not "": # The user must have already been assigned a custom role 44 | pass 45 | elif settings["normal_user_color"] is not None: 46 | color = await get_color(settings["normal_user_color"]) 47 | else: color = gc.term.green 48 | # if this fails, the user either left or was banned 49 | except: 50 | if settings["normal_user_color"] is not None: 51 | color = await get_color(settings["normal_user_color"]) 52 | else: color = gc.term.green 53 | return color 54 | 55 | -------------------------------------------------------------------------------- /utils/print_utils/help.py: -------------------------------------------------------------------------------- 1 | from os import system 2 | 3 | def print_help(gc): 4 | system("clear") 5 | system("echo '" + gc.term.normal \ 6 | + gc.term.green("Launch Arguments: \n") + gc.term.red \ 7 | + "--------------------------------------------- \n" \ 8 | + get_line(gc, "--copy-skeleton", " --- ", "copies template settings") \ 9 | + gc.term.cyan("This file can be found at ~/.config/Discline/config \n") \ 10 | + "\n" 11 | + get_line(gc, "--store-token", " --- ", "stores your token") \ 12 | + gc.term.cyan("This file can be found at ~/.config/Discline/token \n") \ 13 | + "\n" 14 | + get_line(gc, "--config", " --- ", "specify a specific config path") \ 15 | + "\n" 16 | + gc.term.green("Available Commands: \n") + gc.term.red \ 17 | + "--------------------------------------------- \n" \ 18 | + get_line(gc, "/channel", " - ", "switch to channel - (alias: 'c')") \ 19 | + get_line(gc, "/server", " - ", "switch server - (alias: 's')") \ 20 | + gc.term.cyan + "Note: these commands can now fuzzy-find! \n" \ 21 | + "\n" \ 22 | + get_line(gc, "/servers", " - ", "list available servers") \ 23 | + get_line(gc, "/channels", " - ", "list available channels") \ 24 | + get_line(gc, "/users", " - ", "list servers users") \ 25 | + get_line(gc, "/emojis", " - ", "list servers custom emojis") \ 26 | + "\n" \ 27 | + get_line(gc, "/nick", " - ", "change server nick name") \ 28 | + get_line(gc, "/game", " - ", "change your game status") \ 29 | + get_line(gc, "/file", " - ", "upload a file via path") \ 30 | + get_line(gc, "/status", " - ", "change online presence") \ 31 | + gc.term.cyan + "This can be either 'online', 'offline', 'away', or 'dnd' \n" \ 32 | + gc.term.cyan + "(dnd = do not disturb) \n" \ 33 | + "\n" \ 34 | + get_line(gc, "/cX", " - ", "shorthand to change channel (Ex: /c1)") \ 35 | + gc.term.cyan("This can be configured to start at 0 in your config") \ 36 | + "\n" \ 37 | + "\n" \ 38 | + get_line(gc, "/quit", " - ", "exit cleanly") \ 39 | + "\n \n" \ 40 | + gc.term.magenta + "Note: You can send emojis by using :emojiname: \n" \ 41 | + gc.term.cyan("Nitro emojis do work! Make sure you have \n") \ 42 | + gc.term.cyan("nitro enabled in your config. \n") \ 43 | + "\n" 44 | + gc.term.yellow + "You can scroll up/down in channel logs \n" \ 45 | + gc.term.yellow + "by using PageUp/PageDown. \n" \ 46 | + gc.term.green + "~ \n" \ 47 | + gc.term.green + "~ \n" \ 48 | + gc.term.green + "~ \n" \ 49 | + gc.term.green + "(press q to quit this dialog)" \ 50 | + "' | less -R") 51 | 52 | 53 | 54 | def get_line(gc, command, div, desc): 55 | return gc.term.yellow(command) + gc.term.cyan(div) + gc.term.normal + desc + "\n" 56 | -------------------------------------------------------------------------------- /utils/globals.py: -------------------------------------------------------------------------------- 1 | from sys import exit 2 | from blessings import Terminal 3 | from utils.settings import settings 4 | import sys 5 | 6 | NO_SETTINGS=False 7 | try: 8 | if sys.argv[1] == "--store-token" or sys.argv[1] == "--token": 9 | NO_SETTINGS=True 10 | except IndexError: 11 | pass 12 | 13 | class GlobalsContainer: 14 | def __init__(self): 15 | self.term = Terminal() 16 | self.client = None 17 | self.server_log_tree = [] 18 | self.input_buffer = [] 19 | self.user_input = "" 20 | self.channels_entered = [] 21 | 22 | def initClient(self): 23 | from client.client import Client 24 | if NO_SETTINGS: 25 | messages=100 26 | else: 27 | messages=settings["max_messages"] 28 | self.client = Client(max_messages=messages) 29 | 30 | gc = GlobalsContainer() 31 | 32 | # kills the program and all its elements gracefully 33 | def kill(): 34 | # attempt to cleanly close our loops 35 | import asyncio 36 | try: gc.client.close() 37 | except: pass 38 | try: asyncio.get_event_loop().close() 39 | except: pass 40 | try:# since we're exiting, we can be nice and try to clear the screen 41 | from os import system 42 | system("clear") 43 | except: pass 44 | exit() 45 | 46 | # returns a "Channel" object from the given string 47 | async def string2channel(channel): 48 | for srv in gc.client.servers: 49 | if srv.name == channel.server.name: 50 | for chan in srv.channels: 51 | if chan.name == channel: 52 | return chan 53 | 54 | # returns a "Channellog" object from the given string 55 | async def get_channel_log(channel): 56 | for srvlog in gc.server_log_tree: 57 | if srvlog.get_name().lower() == channel.server.name.lower(): 58 | for chanlog in srvlog.get_logs(): 59 | if chanlog.get_name().lower() == channel.name.lower(): 60 | return chanlog 61 | 62 | # returns a "Channellog" from a given "Channel" 63 | async def chan2log(chan): 64 | for srvlog in gc.server_log_tree: 65 | if srvlog.get_name().lower() == chan.server.name.lower(): 66 | for clog in srvlog.get_logs(): 67 | if clog.get_name().lower() == chan.name.lower(): 68 | return clog 69 | 70 | # returns a "Serverlog" from a given "Server" 71 | async def serv2log(serv): 72 | for srvlog in gc.server_log_tree: 73 | if srvlog.get_name().lower() == serv.name.lower(): 74 | return srvlog 75 | 76 | # takes in a string, returns the appropriate term.color 77 | async def get_color(string): 78 | arg = string.strip().lower() 79 | 80 | if arg == "white": return gc.term.white 81 | if arg == "black": return gc.term.black 82 | if arg == "red": return gc.term.red 83 | if arg == "blue": return gc.term.blue 84 | if arg == "yellow": return gc.term.yellow 85 | if arg == "cyan": return gc.term.cyan 86 | if arg == "magenta": return gc.term.magenta 87 | if arg == "green": return gc.term.green 88 | 89 | if arg == "on_white": return gc.term.on_white 90 | if arg == "on_black": return gc.term.on_black 91 | if arg == "on_red": return gc.term.on_red 92 | if arg == "on_blue": return gc.term.on_blue 93 | if arg == "on_yellow": return gc.term.on_yellow 94 | if arg == "on_cyan": return gc.term.on_cyan 95 | if arg == "on_magenta": return gc.term.on_magenta 96 | if arg == "on_green": return gc.term.on_green 97 | 98 | if arg == "blink_white": return gc.term.blink_white 99 | if arg == "blink_black": return gc.term.blink_black 100 | if arg == "blink_red": return gc.term.blink_red 101 | if arg == "blink_blue": return gc.term.blink_blue 102 | if arg == "blink_yellow": return gc.term.blink_yellow 103 | if arg == "blink_cyan": return gc.term.blink_cyan 104 | if arg == "blink_magenta": return gc.term.blink_magenta 105 | if arg == "blink_green": return gc.term.blink_green 106 | 107 | 108 | # if we're here, someone has one of their settings.yaml 109 | # colors defined wrong. We'll be nice and just return white. 110 | return gc.term.normal + gc.term.white 111 | -------------------------------------------------------------------------------- /res/settings-skeleton.yaml: -------------------------------------------------------------------------------- 1 | --- 2 | # ------------------------------------------------------------------------- 3 | # You can edit these to your preferences. Note: anything silly 4 | # like max_messages=-1 will break the client. Duh. 5 | # ------------------------------------------------------------------------- 6 | 7 | # the default server which will be joined upon startup - CASE SENSITIVE! 8 | default_server: discline 9 | # the default channel which will be joined upon startup - CASE SENSITIVE! 10 | default_channel: test_bed 11 | 12 | # the leading character used for commands 13 | prefix: / 14 | 15 | # whether you have discord "Nitro" -- this enables external emojis 16 | has_nitro: false 17 | 18 | # the default prompt when not in a channel 19 | default_prompt: "~" 20 | 21 | # the default 'playing ' status in discord 22 | default_game: Discline 23 | 24 | # used for various things, your preference 25 | arrays_start_at_zero: false 26 | 27 | # Margins for inside the terminal and between elements. NOTE: must be >= 2 28 | # NOTE: some ratios have weird glitches. Just experiment. 29 | margin: 2 30 | 31 | # the max amount of messages to be downloaded + kept 32 | # NOTE: minimum = 100! This is normally safe to increase. 33 | max_messages: 100 34 | 35 | # the max amount of entries in each channel log to be downloaded + kept 36 | # NOTE: minimum = 100! The larger this is, the slower the client will start. 37 | max_log_entries: 100 38 | 39 | # Whether to send "... is typing" when the input buffer is not blank or '/' 40 | send_is_typing: true 41 | 42 | # Whether to show in-line emojis in messages 43 | show_emojis: true 44 | 45 | # Whether to show, or hide the left channel bar 46 | show_left_bar: true 47 | 48 | # Whether to show, or hide the top bar 49 | show_top_bar: true 50 | 51 | # Whether to show the separator lines 52 | show_separators: true 53 | 54 | # the denominator used to calculate the width of the "left bar" 55 | # NOTE: larger number here, the smaller the bar will be, 56 | # (although there is still a minimum of 8 chars...) 57 | left_bar_divider: 9 58 | 59 | # Determines whether the left bar 'truncates' the channels or 60 | # appends "..." to the end when the screen is too small to display them 61 | truncate_channels: false 62 | 63 | # Whether to number channels in the left bar (ex: "1. general") 64 | number_channels: false 65 | 66 | # the amount of lines to scroll up/down on each trigger 67 | scroll_lines: 3 68 | 69 | # ---------------- COLOR SETTINGS ------------------------------------ # 70 | # Available colors are: "white", "red", "blue", "black" 71 | # "green", "yellow", "cyan", "magenta" 72 | # Or: you can say "on_" to make it the background (ex: 'on_red') 73 | # Or: you can say "blink_" to have it flash (ex: 'blink_blue') 74 | separator_color: white 75 | server_display_color: cyan 76 | prompt_color: white 77 | prompt_hash_color: red 78 | prompt_border_color: magenta 79 | normal_user_color: green 80 | 81 | # messages that contain @you mentions will be this color 82 | mention_color: yellow 83 | 84 | # the "default" text color for messages and other things 85 | text_color: white 86 | 87 | code_block_color: on_black 88 | url_color: cyan 89 | channel_list_color: white 90 | current_channel_color: green 91 | 92 | # colors for the channels in the left bar upon unreads 93 | unread_channel_color: blink_yellow 94 | unread_mention_color: blink_red 95 | 96 | # whether channels should blink when they have unread messages 97 | blink_unreads: true 98 | 99 | # same as above, but for @mentions 100 | blink_mentions: true 101 | 102 | # here you can define your own custom roles - NOTE: text must match exactly! 103 | # These for example could be "helper" or "trusted", whatever roles 104 | # your servers use 105 | custom_roles: 106 | - name: admin 107 | color: magenta 108 | - name: mod 109 | color: blue 110 | - name: bot 111 | color: yellow 112 | 113 | # Channel ignore list - This stops the channel from being loaded. 114 | # Effectively like the "mute" + "hide" feature on the official client, 115 | # However with the added benefit that this means these channels won't 116 | # be stored in RAM. 117 | 118 | # Follow the format as below. 119 | # Note it is TWO spaces, not a tab! 120 | channel_ignore_list: 121 | - server_name: server name 122 | ignores: 123 | - some_channel 124 | - some_other_channel 125 | - server_name: another server name 126 | ignores: 127 | - foo 128 | - bar 129 | 130 | # ignore this unless you know what you're doing 131 | debug: false 132 | -------------------------------------------------------------------------------- /client/client.py: -------------------------------------------------------------------------------- 1 | import discord 2 | from utils.globals import gc 3 | from utils.settings import settings 4 | import ui.text_manipulation as tm 5 | 6 | # inherits from discord.py's Client 7 | class Client(discord.Client): 8 | 9 | # NOTE: These are strings! 10 | __current_server = "" 11 | __current_channel = "" 12 | __prompt = "" 13 | 14 | # discord.Status object 15 | __status = "" 16 | 17 | # discord.Game object 18 | __game = "" 19 | 20 | 21 | # Note: setting only allows for string types 22 | def set_prompt(self, string): 23 | self.__prompt = string.lower() 24 | def set_current_server(self, string): 25 | self.__current_server = string.lower() 26 | def set_current_channel(self, string): 27 | self.__current_channel = string.lower() 28 | self.set_prompt(string) 29 | 30 | def get_prompt(self): return self.__prompt 31 | def get_current_server_name(self): return self.__current_server 32 | def get_current_channel_name(self): return self.__current_channel 33 | 34 | def get_current_server(self): 35 | for server in self.servers: 36 | if server.name.lower() == self.__current_server: 37 | return server 38 | 39 | def get_current_server_log(self): 40 | for slog in gc.server_log_tree: 41 | if slog.get_server() == self.get_current_server(): 42 | return slog 43 | 44 | def get_current_channel(self): 45 | for server in self.servers: 46 | if server.name.lower() == self.__current_server.lower(): 47 | for channel in server.channels: 48 | if channel.type is discord.ChannelType.text: 49 | if channel.name.lower() == self.__current_channel.lower(): 50 | if channel.permissions_for(server.me).read_messages: 51 | return channel 52 | 53 | async def populate_current_channel_log(self): 54 | slog = self.get_current_server_log() 55 | for idx, clog in enumerate(slog.get_logs()): 56 | if clog.get_channel().type is discord.ChannelType.text: 57 | if clog.get_channel().name.lower() == self.__current_channel.lower(): 58 | if clog.get_channel().permissions_for(slog.get_server().me).read_messages: 59 | async for msg in self.logs_from(clog.get_channel(), limit=settings["max_log_entries"]): 60 | clog.insert(0, await tm.calc_mutations(msg)) 61 | 62 | def get_current_channel_log(self): 63 | slog = self.get_current_server_log() 64 | for idx, clog in enumerate(slog.get_logs()): 65 | if clog.get_channel().type is discord.ChannelType.text: 66 | if clog.get_channel().name.lower() == self.__current_channel.lower(): 67 | if clog.get_channel().permissions_for(slog.get_server().me).read_messages: 68 | return clog 69 | 70 | # returns online members in current server 71 | async def get_online(self): 72 | online_count = 0 73 | if not self.get_current_server() == None: 74 | for member in self.get_current_server().members: 75 | if member is None: continue # happens if a member left the server 76 | if member.status is not discord.Status.offline: 77 | online_count +=1 78 | return online_count 79 | 80 | # because the built-in .say is really buggy, just overriding it with my own 81 | async def say(self, string): 82 | await self.send_message(self.get_current_channel(), string) 83 | 84 | async def set_game(self, string): 85 | self.__game = discord.Game(name=string,type=0) 86 | self.__status = discord.Status.online 87 | # Note: the 'afk' kwarg handles how the client receives messages, (rates, etc) 88 | # This is meant to be a "nice" feature, but for us it causes more headache 89 | # than its worth. 90 | if self.__game is not None and self.__game != "": 91 | if self.__status is not None and self.__status != "": 92 | try: await self.change_presence(game=self.__game, status=self.__status, afk=False) 93 | except: pass 94 | else: 95 | try: await self.change_presence(game=self.__game, status=discord.Status.online, afk=False) 96 | except: pass 97 | 98 | async def get_game(self): 99 | return self.__game 100 | 101 | async def set_status(self, string): 102 | if string == "online": 103 | self.__status = discord.Status.online 104 | elif string == "offline": 105 | self.__status = discord.Status.offline 106 | elif string == "idle": 107 | self.__status = discord.Status.idle 108 | elif string == "dnd": 109 | self.__status = discord.Status.dnd 110 | 111 | if self.__game is not None and self.__game != "": 112 | try: await self.change_presence(game=self.__game, status=self.__status, afk=False) 113 | except: pass 114 | else: 115 | try: await self.change_presence(status=self.__status, afk=False) 116 | except: pass 117 | 118 | async def get_status(self): 119 | return self.__status 120 | -------------------------------------------------------------------------------- /utils/print_utils/userlist.py: -------------------------------------------------------------------------------- 1 | from os import system 2 | from discord import Status 3 | from utils.globals import gc 4 | 5 | # On call of the /users command, this will print 6 | # out a nicely sorted, colored list of all users 7 | # connected to the clients current server and pipe 8 | # it to the system pager, (in this case `less`) 9 | 10 | class UserList: 11 | 12 | def __init__(self): 13 | # place to store the names, separted in categories 14 | self.online = [] 15 | self.offline = [] 16 | self.idle = [] 17 | self.dnd = [] 18 | 19 | def add(self, member, tag): 20 | listing = member.name + tag + " \n" 21 | if member.status is Status.online: 22 | self.online.append(listing) 23 | elif member.status is Status.offline: 24 | self.offline.append(listing) 25 | elif member.status is Status.idle: 26 | self.idle.append(listing) 27 | elif member.status is Status.dnd: 28 | self.dnd.append(listing) 29 | 30 | def sort(self): 31 | self.online = sorted(self.online, key=str.lower) 32 | self.offline = sorted(self.offline, key=str.lower) 33 | self.idle = sorted(self.idle, key=str.lower) 34 | self.dnd = sorted(self.dnd, key=str.lower) 35 | 36 | # now they are sorted, we can colorize them 37 | # we couldn't before as the escape codes mess with 38 | # the sorting algorithm 39 | tmp = [] 40 | for name in self.online: 41 | tmp.append(gc.term.green + name) 42 | self.online = list(tmp) 43 | del tmp[:] 44 | 45 | for name in self.idle: 46 | tmp.append(gc.term.yellow + name) 47 | self.idle = list(tmp) 48 | del tmp[:] 49 | 50 | for name in self.dnd: 51 | tmp.append(gc.term.black + name) 52 | self.dnd = list(tmp) 53 | del tmp[:] 54 | 55 | for name in self.offline: 56 | tmp.append(gc.term.red + name) 57 | self.offline = list(tmp) 58 | del tmp[:] 59 | 60 | return "".join(self.online) + "".join(self.offline) \ 61 | + "".join(self.idle) + "".join(self.dnd) 62 | 63 | async def print_userlist(): 64 | if len(gc.client.servers) == 0: 65 | print("Error: You are not in any servers.") 66 | return 67 | 68 | if len(gc.client.get_current_server().channels) == 0: 69 | print("Error: Does this server not have any channels?") 70 | return 71 | 72 | # lists to contain our "Member" objects 73 | nonroles = UserList() 74 | admins = UserList() 75 | mods = UserList() 76 | bots = UserList() 77 | everything_else = UserList() 78 | 79 | for member in gc.client.get_current_server().members: 80 | if member is None: continue # happens if a member left the server 81 | 82 | if member.top_role.name == "admin" or member.top_role.name == "Admin": 83 | admins.add(member, " - (Admin)") 84 | elif member.top_role.name == "mod" or member.top_role.name == "Mod": 85 | mods.add(member, "- (Mod)") 86 | elif member.top_role.name == "bot" or member.top_role.name == "Bot": 87 | bots.add(member, " - (bot)") 88 | elif member.top_role.is_everyone: nonroles.add(member, "") 89 | else: everything_else.add(member, " - " + member.top_role.name) 90 | 91 | 92 | # the final buffer that we're actually going to print 93 | buffer = [] 94 | 95 | if admins is not None: buffer.append(admins.sort()) 96 | if mods is not None: buffer.append(mods.sort()) 97 | 98 | buffer.append("\n" + gc.term.magenta + "---------------------------- \n\n") 99 | 100 | if bots is not None: buffer.append(bots.sort()) 101 | if everything_else is not None: buffer.append(everything_else.sort()) 102 | 103 | buffer.append("\n" + gc.term.magenta + "---------------------------- \n\n") 104 | 105 | if nonroles is not None: buffer.append(nonroles.sort()) 106 | 107 | buffer_copy = [] 108 | for name in buffer: 109 | name = name.replace("'", "") 110 | name = name.replace('"', "") 111 | name = name.replace("`", "") 112 | name = name.replace("$(", "") 113 | buffer_copy.append(name) 114 | 115 | system("echo '" + gc.term.yellow + "Members in " \ 116 | + gc.client.get_current_server().name + ": \n" \ 117 | + gc.term.magenta + "---------------------------- \n \n" \ 118 | + "".join(buffer_copy) \ 119 | + gc.term.green + "~ \n" \ 120 | + gc.term.green + "~ \n" \ 121 | + gc.term.green + "(press \'q\' to quit this dialog) \n" \ 122 | # NOTE: the -R flag here enables color escape codes 123 | + "' | less -R") 124 | 125 | # takes in a member, returns a color based on their status 126 | def get_status_color(member): 127 | if member.status is Status.online: 128 | return gc.term.green 129 | if member.status is Status.idle: # aka "away" 130 | return gc.term.yellow 131 | if member.status is Status.offline: 132 | return gc.term.red 133 | if member.status is Status.dnd: # do not disturb 134 | return gc.term.black 135 | 136 | # if we're still here, something is wrong 137 | return "ERROR: get_status_color() has returned 'None' for " \ 138 | + member.name + "\n" 139 | -------------------------------------------------------------------------------- /ui/text_manipulation.py: -------------------------------------------------------------------------------- 1 | import re 2 | from discord import MessageType 3 | from utils.settings import settings 4 | from utils.globals import gc, get_color 5 | import utils 6 | 7 | async def calc_mutations(msg): 8 | 9 | try: # if the message is a file, extract the discord url from it 10 | json = str(msg.attachments[0]).split("'") 11 | for string in json: 12 | if string is not None and string != "": 13 | if "cdn.discordapp.com/attachments" in string: 14 | msg.content = string 15 | break 16 | except IndexError: pass 17 | 18 | # otherwise it must not have any attachments and its a regular message 19 | text = msg.content 20 | 21 | 22 | # check for in-line code blocks 23 | if text.count("```") > 1: 24 | while("```") in text: 25 | text = await convert_code_block(text) 26 | 27 | msg.content = text 28 | 29 | # TODO: if there are asterics or __'s in the code, then 30 | # this will not stop them from being formatted 31 | # check for in-line code marks 32 | if text.count("`") > 1: 33 | while("`") in text: 34 | text = await convert_code(text) 35 | 36 | msg.content = text 37 | 38 | # check to see if it has any custom-emojis 39 | # These will look like <:emojiname:39432432903201> 40 | # We will recursively trim this into just :emojiname: 41 | if msg.server.emojis is not None and len(msg.server.emojis) > 0: 42 | for emoji in msg.server.emojis: 43 | full_name = "<:" + emoji.name + ":" + emoji.id + ">" 44 | 45 | while full_name in text: 46 | text = await trim_emoji(full_name, emoji.name, text) 47 | 48 | msg.content = text 49 | 50 | # check for boldened font 51 | if text.count("**") > 1: 52 | while("**") in text: 53 | text = await convert_bold(text) 54 | 55 | msg.content = text 56 | 57 | # check for italic font 58 | if text.count("*") > 1: 59 | while("*") in text: 60 | text = await convert_italic(text) 61 | 62 | msg.content = text 63 | 64 | # check for underlined font 65 | if text.count("__") > 1: 66 | while("__") in text: 67 | text = await convert_underline(text) 68 | 69 | msg.content = text 70 | 71 | # check for urls 72 | if "http://" in text or "https://" in text or "www." in text \ 73 | or "ftp://" in text or ".com" in text: 74 | 75 | msg.content = await convert_url(text) 76 | 77 | # check if the message is a "user has pinned..." message 78 | if msg.type == MessageType.pins_add: 79 | msg.content = await convert_pin(msg) 80 | 81 | # else it must be a regular message, nothing else 82 | return msg 83 | 84 | async def convert_pin(msg): 85 | name = "" 86 | if msg.author.nick is not None and msg.author.nick != "": 87 | name = msg.author.nick 88 | else: name = msg.author.name 89 | return "📌 " + str(name) + " has pinned a message to this channel." 90 | 91 | 92 | async def trim_emoji(full_name, short_name, string): 93 | return string.replace(full_name, ":" + short_name + ":") 94 | 95 | async def convert_bold(string): 96 | sections = string.split("**") 97 | left = sections[0] 98 | target = sections[1] 99 | right = "".join(sections[2]) 100 | return gc.term.normal + gc.term.white + left + " " + gc.term.bold(target) + gc.term.normal + \ 101 | gc.term.white + " " + right 102 | 103 | async def convert_italic(string): 104 | sections = string.split("*") 105 | left = sections[0] 106 | target = sections[1] 107 | right = "".join(sections[2]) 108 | return gc.term.normal + gc.term.white + left + " " + gc.term.italic(target) + gc.term.normal + \ 109 | gc.term.white + " " + right 110 | 111 | async def convert_underline(string): 112 | sections = string.split("__") 113 | left = sections[0] 114 | target = sections[1] 115 | right = "".join(sections[2]) 116 | return gc.term.normal + gc.term.white + left + " " + gc.term.underline(target) + gc.term.normal + \ 117 | gc.term.white + " " + right 118 | 119 | async def convert_code(string): 120 | sections = string.split("`") 121 | left = sections[0] 122 | target = sections[1] 123 | right = "".join(sections[2]) 124 | return gc.term.normal + gc.term.white + left + " " + await get_color(settings["code_block_color"]) \ 125 | + target + gc.term.normal \ 126 | + gc.term.white + " " + right 127 | 128 | async def convert_code_block(string): 129 | sections = string.split("```") 130 | left = sections[0] 131 | target = sections[1] 132 | right = "".join(sections[2]) 133 | return gc.term.normal + gc.term.white + left + " " + gc.term.on_black(target) + gc.term.normal + \ 134 | gc.term.white + " " + right 135 | 136 | async def convert_url(string): 137 | formatted_line = [] 138 | entities = [] 139 | if " " in string: 140 | entities = string.split(" ") 141 | else: 142 | entities.append(string) 143 | 144 | for entity in entities: 145 | if "http://" in entity or "https://" in entity or "www." in entity \ 146 | or "ftp://" in entity or ".com" in entity: 147 | entity = await get_color(settings["url_color"]) + gc.term.italic + gc.term.underline + entity + gc.term.normal 148 | formatted_line.append(entity) 149 | 150 | return " ".join(formatted_line) 151 | 152 | 153 | -------------------------------------------------------------------------------- /res/logo/logo.svg: -------------------------------------------------------------------------------- 1 | 2 | 16 | 18 | 19 | 21 | image/svg+xml 22 | 24 | 25 | 26 | 27 | 29 | 49 | 54 | 59 | 60 | -------------------------------------------------------------------------------- /Discline.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | # ------------------------------------------------------- # 3 | # # 4 | # Discline # 5 | # # 6 | # http://github.com/MitchWeaver/Discline # 7 | # # 8 | # Licensed under GNU GPLv3 # 9 | # # 10 | # ------------------------------------------------------- # 11 | 12 | import sys 13 | import asyncio 14 | import os 15 | from discord import ChannelType 16 | from input.input_handler import input_handler, key_input, init_input 17 | from input.typing_handler import is_typing_handler 18 | from ui.ui import print_screen 19 | from ui.text_manipulation import calc_mutations 20 | from utils.print_utils.help import print_help 21 | from utils.print_utils.print_utils import * 22 | from utils.globals import * 23 | from utils.settings import copy_skeleton, settings 24 | from utils.updates import check_for_updates 25 | from utils.token_utils import get_token,store_token 26 | from utils import hidecursor 27 | from client.serverlog import ServerLog 28 | from client.channellog import ChannelLog 29 | from client.on_message import on_incoming_message 30 | from client.client import Client 31 | 32 | # check if using python 3.5+ 33 | # TODO: this still fails if they're using python2 34 | if sys.version_info >= (3, 5): pass 35 | else: 36 | print(gc.term.red + "Sorry, but this requires python 3.5+" + gc.term.normal) 37 | quit() 38 | 39 | init_complete = False 40 | 41 | # Set terminal X11 window title 42 | print('\33]0;Discline\a', end='', flush=True) 43 | 44 | os.system("clear") 45 | 46 | gc.initClient() 47 | 48 | @gc.client.event 49 | async def on_ready(): 50 | await gc.client.wait_until_login() 51 | 52 | # completely hide the system's cursor 53 | await hidecursor.hide_cursor() 54 | 55 | # these values are set in settings.yaml 56 | if settings["default_prompt"] is not None: 57 | gc.client.set_prompt(settings["default_prompt"].lower()) 58 | else: 59 | gc.client.set_prompt('~') 60 | 61 | if settings["default_server"] is not None: 62 | gc.client.set_current_server(settings["default_server"]) 63 | if settings["default_channel"] is not None: 64 | gc.client.set_current_channel(settings["default_channel"].lower()) 65 | gc.client.set_prompt(settings["default_channel"].lower()) 66 | 67 | if settings["default_game"] is not None: 68 | await gc.client.set_game(settings["default_game"]) 69 | 70 | # --------------- INIT SERVERS ----------------------------------------- # 71 | print("Welcome to " + gc.term.cyan + "Discline" + gc.term.normal + "!") 72 | await print_line_break() 73 | await print_user() 74 | await print_line_break() 75 | print("Initializing... \n") 76 | try: 77 | sys.stdout.flush() 78 | except: 79 | pass 80 | 81 | for server in gc.client.servers: 82 | # Null check to check server availability 83 | if server is None: 84 | continue 85 | serv_logs = [] 86 | for channel in server.channels: 87 | # Null checks to test for bugged out channels 88 | if channel is None or channel.type is None: 89 | continue 90 | # Null checks for bugged out members 91 | if server.me is None or server.me.id is None \ 92 | or channel.permissions_for(server.me) is None: 93 | continue 94 | if channel.type == ChannelType.text: 95 | if channel.permissions_for(server.me).read_messages: 96 | try: # try/except in order to 'continue' out of multiple for loops 97 | for serv_key in settings["channel_ignore_list"]: 98 | if serv_key["server_name"].lower() == server.name.lower(): 99 | for name in serv_key["ignores"]: 100 | if channel.name.lower() == name.lower(): 101 | raise Found 102 | serv_logs.append(ChannelLog(channel, [])) 103 | except: 104 | continue 105 | 106 | # add the channellog to the tree 107 | gc.server_log_tree.append(ServerLog(server, serv_logs)) 108 | 109 | if settings["debug"]: 110 | for slog in gc.server_log_tree: 111 | for clog in slog.get_logs(): 112 | print(slog.get_name() + " ---- " + clog.get_name()) 113 | 114 | # start our own coroutines 115 | try: asyncio.get_event_loop().create_task(key_input()) 116 | except SystemExit: pass 117 | except KeyboardInterrupt: pass 118 | try: asyncio.get_event_loop().create_task(input_handler()) 119 | except SystemExit: pass 120 | except KeyboardInterrupt: pass 121 | try: asyncio.get_event_loop().create_task(is_typing_handler()) 122 | except SystemExit: pass 123 | except KeyboardInterrupt: pass 124 | 125 | # Print initial screen 126 | await print_screen() 127 | 128 | global init_complete 129 | init_complete = True 130 | 131 | # called whenever the client receives a message (from anywhere) 132 | @gc.client.event 133 | async def on_message(message): 134 | await gc.client.wait_until_ready() 135 | if init_complete: 136 | await on_incoming_message(message) 137 | 138 | @gc.client.event 139 | async def on_message_edit(msg_old, msg_new): 140 | await gc.client.wait_until_ready() 141 | msg_new.content = msg_new.content + " *(edited)*" 142 | 143 | if init_complete: 144 | await print_screen() 145 | 146 | @gc.client.event 147 | async def on_message_delete(msg): 148 | await gc.client.wait_until_ready() 149 | # TODO: PM's have 'None' as a server -- fix this later 150 | if msg.server is None: return 151 | 152 | try: 153 | for serverlog in gc.server_log_tree: 154 | if serverlog.get_server() == msg.server: 155 | for channellog in serverlog.get_logs(): 156 | if channellog.get_channel()== msg.channel: 157 | channellog.get_logs().remove(msg) 158 | if init_complete: 159 | await print_screen() 160 | return 161 | except: 162 | # if the message cannot be found, an exception will be raised 163 | # this could be #1: if the message was already deleted, 164 | # (happens when multiple calls get excecuted within the same time) 165 | # or the user was banned, (in which case all their msgs disappear) 166 | pass 167 | 168 | 169 | def main(): 170 | # start the client coroutine 171 | TOKEN="" 172 | try: 173 | if sys.argv[1] == "--help" or sys.argv[1] == "-h": 174 | from utils.print_utils.help import print_help 175 | print_help() 176 | quit() 177 | elif sys.argv[1] == "--token" or sys.argv[1] == "--store-token": 178 | store_token() 179 | quit() 180 | elif sys.argv[1] == "--skeleton" or sys.argv[1] == "--copy-skeleton": 181 | # ---- now handled in utils.settings.py ---- # 182 | pass 183 | elif sys.argv[1] == "--config": 184 | # --- now handled in utils.settings.py ---- # 185 | pass 186 | else: 187 | print(gc.term.red("Error: Unknown command.")) 188 | print(gc.term.yellow("See --help for options.")) 189 | quit() 190 | except IndexError: 191 | pass 192 | 193 | check_for_updates() 194 | token = get_token() 195 | init_input() 196 | 197 | print(gc.term.yellow("Starting...")) 198 | 199 | # start the client 200 | try: gc.client.run(token, bot=False) 201 | except KeyboardInterrupt: pass 202 | except SystemExit: pass 203 | 204 | # if we are here, the client's loop was cancelled or errored, or user exited 205 | try: kill() 206 | except: 207 | # if our cleanly-exit kill function failed for whatever reason, 208 | # make sure we at least exit uncleanly 209 | quit() 210 | 211 | if __name__ == "__main__": main() 212 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # ![logo_small.png](res/logo/logo_small.png) Discline 2 | ------------------------------ 3 | 4 | ![screenshot_main.png](res/screenshots/screenshot_main.png) 5 | 6 | 7 | # NOTICE: July 20th, 2019 8 | 9 | AFAIK discline has become non-functional. 10 | 11 | * Python3.7 has changed the syntax of its async libraries. 12 | 13 | * discord.py has also gone through a new release cycle which's syntax is not backwards compatible. 14 | 15 | Discline will require a full rewrite, possibly done in a new language. 16 | 17 | Stay tuned for updates via this readme. 18 | 19 | Thanks to all that have supported the project thus far, 20 | I hope you stick around for whats to come. 21 | 22 | With that said, I'll leave the remainder of the readme intact: 23 | 24 | 25 | ## How to use: 26 | ------------------------- 27 | 28 | 1. Install the dependencies: 29 | 30 | `sudo pip3 install asyncio discord blessings pyyaml` 31 | 32 | 2. Clone the repo 33 | 34 | `git clone https://github.com/MitchWeaver/Discline` 35 | 36 | 3. Find your discord "token" 37 | 38 | * Go to http://discordapp.com/channels/@me 39 | 40 | * Open your browser's developer console. (Normally `F12` or `CTRL-SHIFT-I`) 41 | 42 | * Look for "storage" or "local storage", then find the discord url. 43 | 44 | * Clicking this will show you a list of variables. Look for a line that looks like: 45 | 46 | `"token = 322332r093fwaf032f90323f32f903f23wfa"` 47 | 48 | If you're having troubles, google around, there's a few guides on the net. 49 | 50 | If all else fails, join the dev discord and we'll be glad to help! 51 | 52 | 4. Run `python3 Discline.py --store-token` to store your token 53 | 54 | 5. Run `python3 Discline.py --copy-skeleton` to get a template config 55 | 56 | 6. Edit `~/.config/Discline/config` to your choosing. 57 | 58 | 7. Launch with python3 59 | 60 | `python3 Discline.py` 61 | 62 | *(alternatively if you have python3.6 you can simply use `./Discline.py`)* 63 | 64 | 65 | ### Current Features 66 | -------------------------- 67 | 68 | * /channel to switch channel 69 | * /server to switch server 70 | * /nick to change nickname (per server) 71 | * typing without a leading prefix will submit to current chat 72 | * " is typing..." support 73 | * private channels 74 | * colored output, with user definable colors and custom roles 75 | * Channel logs update when users edit messages 76 | * /channels, /servers, /users to view information 77 | * /game to update the "Now playing: " status 78 | * use /help to see more commands 79 | * unicode emoji displayal support 80 | * sending emojis in messages (unicode *and* custom) 81 | * File uploading via path (ex: /file /path/to/file) 82 | * italic, bold, and underline font support 83 | * inline \`code\` and \`\`\`code\`\`\` block support 84 | * URL detection, highlighting in blue + italics 85 | * automatic updating, fetching the latest master branch's commit 86 | * channel logs blink red upon unread messages 87 | * line scrolling 88 | * discord "Nitro" emojis 89 | * Externalized configs via YAML ~/.config/Discline/config 90 | * @member expansion/mentions 91 | * /status to change online presence 92 | 93 | ### Planned Features 94 | --------------------------- 95 | 96 | * emoji reactions 97 | * comment editing and deletion 98 | * private messaging 99 | * message searching 100 | 101 | ## Dependencies 102 | ------------------------ 103 | 104 | * git (if you want automatic updates) 105 | * [Python 3.5+](https://www.python.org/downloads/) 106 | * [discord.py](https://github.com/Rapptz/discord.py) 107 | * [blessings.py](https://pypi.python.org/pypi/blessings/) 108 | * [PyYAML](https://pypi.python.org/pypi/PyYAML/) 109 | * asyncio 110 | 111 | **To install dependencies**: 112 | 113 | 1. Download Python 3.5/3.6 from the link above 114 | 2. Install `pip3`, normally called `python3-pip` in package managers 115 | 3. Download the dependencies using pip with the following command: 116 | 117 | `sudo pip3 install asyncio discord blessings pyyaml` 118 | 119 | 120 | ### Color Customization 121 | ------------------------ 122 | 123 | Almost all aspects of the client can be colored to 124 | the user's wishes. You can set these colors from within `~/.config/Discline/config` 125 | 126 | Note: These assume that you're using the standard terminal colors. If you 127 | have colors already defined in your ~/.Xresources or similar, this will 128 | be very confusing. 129 | 130 | ## Launching 131 | ------------------------ 132 | Discline uses git for automatic updates, so you must be within the Discline 133 | directory upon starting. Manually you can launch via `python3.6 ./Discline.py`, 134 | however it is advised to create a helper script to do this for you. 135 | 136 | An example script is in the /res/scripts folder, 137 | edit it to suit your system and tastes. 138 | 139 | ### A Note On Emojis 140 | ------------------------- 141 | 142 | Currently *most* of the standard unicode emojis 143 | are displaying. Note your terminal must be able 144 | to render these symbols *and* you must be using a font 145 | set that contains them. Because some of the emojis 146 | that discord uses are non-standard, they may not 147 | display properly. Here is an example of a random 148 | few. 149 | 150 | ![Image](https://images-ext-2.discordapp.net/external/iN52NdGOWqdWOxby88wiEGs8R81j33ndPjgKX8eKUNA/https/0x0.st/soIy.png?width=400&height=32) 151 | 152 | Custom emojis however, are displayed as :emoji_name: 153 | 154 | ### Note On Font Support 155 | ------------------------- 156 | 157 | Like emojis, not all terminals and fonts support 158 | italic/bold/underline and 'background' colors, (which are used for \`code\`). 159 | If these features aren't working for you, odds are you are not using a 160 | supported terminal/font. Experiment with different setups to see what works. 161 | 162 | ![Image](https://0x0.st/sHQ0.png) 163 | 164 | *Letting me know what setups __don't__ work helps a lot!* 165 | 166 | ### Dude this is awesome! How can I support the project? 167 | -------------------------------------------------------- 168 | 169 | Star it! 🌟 170 | 171 | It helps to get it higher in GitHub's search results as well as 172 | making me feel good inside. ;) 173 | 174 | If you'd like to contribute, pull requests are __*always*__ welcome! 175 | 176 | If you would like to get more info on what could be done or to discuss the 177 | project in general, come join the discord server at: https://discord.gg/rBGQMTk 178 | 179 | ### FAQ 180 | ------------------------- 181 | 182 | > Yet another discord cli? 183 | 184 | I didn't like any of the implementations I found around github. Too buggy. 185 | Too bloated. Bad UI. No customization. Some, after discord updates, 186 | no longer functioning at all. 187 | 188 | > Why use a token and not email/password? 189 | 190 | Discord's API __does__ allow for email/pass login, but if you were to have 191 | 2FA, (2 factor authentication), enabled on your account, Discord would 192 | interpret this as a malicious attack against your account and disable it. 193 | 194 | So, because *"Nobody reads the readme"*, I have disabled this. 195 | 196 | > How should I submit a GitHub issue? 197 | 198 | Try to include this format: 199 | 200 | ``` 201 | OS: Linux/Debian 202 | Terminal: urxvt 203 | Font: source code pro 204 | Python Version: 3.6 205 | How to reproduce: xxxxxx 206 | ``` 207 | 208 | > It says my python is out of date even though I have 3.5+ installed? 209 | 210 | Probably because you have multiple versions installed. Try running with 211 | `python3.5` or `python3.6` rather than just "python3" 212 | 213 | > I'm getting weird encoding errors on startup 214 | 215 | You probably don't have UTF-8 set. If you're using Linux, 216 | look up how to do this according to your distro. 217 | 218 | If you're on BSD, add this to your /etc/profile: 219 | 220 | ``` 221 | export LC_CTYPE=en_US.UTF-8 222 | export LESSCHARSET=utf-8 223 | ``` 224 | 225 | and make sure it gets sourced upon opening your terminal. 226 | 227 | ### Misc Screenshots 228 | -------------------------- 229 | 230 | ![Image](res/screenshots/kingk22-screenshot.png) 231 | 232 | ![Image](https://0x0.st/sH5g.png) 233 | 234 | ![Image](https://0x0.st/sHjn.png) 235 | 236 | It can even be configured to hide most elements of the UI in the config: 237 | 238 | ![Image](res/screenshots/minimal_brown_ss.png) 239 | 240 | ### Known Bugs 241 | -------------------------- 242 | 243 | > Line wrapping sometimes doesn't work 244 | 245 | This happens if there is too much formatting / coloring being done to the 246 | message that contains that line. I'm looking for a work around. 247 | 248 | > When I type many lines before hitting send, the UI sometimes bugs out 249 | and/or the separators encroach upon different sections 250 | 251 | Known. Looking for a work around. 252 | 253 | > My bug isn't listed here, how can I voice my problem? 254 | 255 | If you have a specific issue that isn't listed here or in the 256 | wiki, post a github issue with a detailed explanation and I can 257 | try to get it fixed. Join the discord if you want live help. 258 | 259 | ### Token Warning 260 | ------------------------------- 261 | Do *NOT* share your token with anybody, if someone else gets ahold 262 | of your token, they may control your account. If you are someone 263 | who keeps their ~/.config on github, **add your token to your .gitignore**. 264 | Otherwise it will become public. 265 | 266 | 267 | ### License 268 | ------------------------------- 269 | 270 | WTFPL 273 | 274 | ### Legal Disclaimer 275 | -------------------------------- 276 | 277 | Discord hasn't put out any official statement on whether using their 278 | API for 3rd party clients is allowed or not. They *have* said that using 279 | their API to make "self-bots" is against their ToS. By self-bots, it is 280 | my understanding they mean automating non-bot accounts as bots. 281 | My code has no automated functions, or any on_events that provide features 282 | not included in the official client. 283 | 284 | As far as I know, nobody has been banned for using things like this before, 285 | but Discord might one day change their mind. With this said, I take **no** 286 | responsibility if this gets you banned. 287 | -------------------------------------------------------------------------------- /input/input_handler.py: -------------------------------------------------------------------------------- 1 | import asyncio 2 | import discord 3 | from input.kbhit import KBHit 4 | import ui.ui as ui 5 | from utils.globals import gc, kill 6 | from utils.print_utils.help import print_help 7 | from utils.print_utils.userlist import print_userlist 8 | from utils.print_utils.serverlist import print_serverlist 9 | from utils.print_utils.channellist import print_channellist 10 | from utils.print_utils.emojis import print_emojilist 11 | from utils.settings import settings 12 | from commands.text_emoticons import check_emoticons 13 | from commands.sendfile import send_file 14 | from commands.channel_jump import channel_jump 15 | 16 | kb = "" 17 | 18 | def init_input(): 19 | global kb 20 | kb = KBHit() 21 | 22 | async def key_input(): 23 | await gc.client.wait_until_ready() 24 | 25 | global kb 26 | memory = "" 27 | key = "" 28 | while True: 29 | if await kb.kbhit() or memory == "[": 30 | key = await kb.getch() 31 | 32 | ordkey = ord(key) 33 | 34 | if memory == "[": 35 | if key == "6": # page down 36 | gc.client.get_current_channel_log().dec_index(settings["scroll_lines"]) 37 | del gc.input_buffer[-1] 38 | elif key == "5": # page up 39 | gc.client.get_current_channel_log().inc_index(settings["scroll_lines"]) 40 | del gc.input_buffer[-1] 41 | else: 42 | if ordkey == 10 or ordkey == 13: # enter key 43 | gc.user_input = "".join(gc.input_buffer) 44 | del gc.input_buffer[:] 45 | elif ordkey == 127 or ordkey == 8: # backspace 46 | if len(gc.input_buffer) > 0: 47 | del gc.input_buffer[-1] 48 | 49 | elif ordkey >= 32 and ordkey <= 256: # all letters and special characters 50 | if not (ordkey == 126 and (memory == "5" or memory == "6")): # tilde left over from page up/down 51 | gc.input_buffer.append(key) 52 | elif ordkey == 9: 53 | gc.input_buffer.append(" " * 4) # tab key 54 | 55 | memory = key 56 | if key != "[": 57 | await ui.print_screen() 58 | 59 | if key != "[": 60 | await asyncio.sleep(0.015) 61 | elif key == "~": 62 | await asyncio.sleep(0.1) 63 | 64 | async def input_handler(): 65 | await gc.client.wait_until_ready() 66 | 67 | while True: 68 | 69 | # If input is blank, don't do anything 70 | if gc.user_input == '': 71 | await asyncio.sleep(0.05) 72 | continue 73 | 74 | # # check if input is a command 75 | if gc.user_input[0] == settings["prefix"]: 76 | # strip the PREFIX 77 | gc.user_input = gc.user_input[1:] 78 | 79 | # check if contains a space 80 | if ' ' in gc.user_input: 81 | # split into command and argument 82 | command,arg = gc.user_input.split(" ", 1) 83 | 84 | if command == "server" or command == 's': 85 | 86 | server_name = "" 87 | # check if arg is a valid server, then switch 88 | for servlog in gc.server_log_tree: 89 | if servlog.get_name().lower() == arg.lower(): 90 | server_name = servlog.get_name() 91 | break 92 | 93 | # if we didn't find an exact match, assume only partial 94 | # Note if there are multiple servers containing the same 95 | # word, this will only pick the first one. Better than nothing. 96 | if server_name == "": 97 | for servlog in gc.server_log_tree: 98 | if arg.lower() in servlog.get_name().lower(): 99 | server_name = servlog.get_name() 100 | break 101 | 102 | if server_name != "": 103 | gc.client.set_current_server(server_name) 104 | 105 | # discord.py's "server.default_channel" is buggy. 106 | # often times it will return 'none' even when 107 | # there is a default channel. to combat this, 108 | # we can just get it ourselves. 109 | def_chan = "" 110 | 111 | lowest = 999 112 | for chan in servlog.get_server().channels: 113 | if chan.type is discord.ChannelType.text: 114 | if chan.permissions_for(servlog.get_server().me).read_messages: 115 | if chan.position < lowest: 116 | try: 117 | for serv_key in settings["channel_ignore_list"]: 118 | if serv_key["server_name"].lower() == server_name: 119 | for name in serv_key["ignores"]: 120 | if chan.name.lower() == name.lower(): 121 | raise Found 122 | except: 123 | continue 124 | lowest = chan.position 125 | def_chan = chan 126 | 127 | try: 128 | gc.client.set_current_channel(def_chan.name) 129 | # and set the default channel as read 130 | for chanlog in servlog.get_logs(): 131 | if chanlog.get_channel() is def_chan: 132 | chanlog.unread = False 133 | chanlog.mentioned_in = False 134 | break 135 | # TODO: Bug: def_chan is sometimes "" 136 | except: continue 137 | else: 138 | ui.set_display(gc.term.red + "Can't find server" + gc.term.normal) 139 | 140 | 141 | elif command == "channel" or command == 'c': 142 | # check if arg is a valid channel, then switch 143 | for servlog in gc.server_log_tree: 144 | if servlog.get_server() is gc.client.get_current_server(): 145 | final_chanlog = "" 146 | for chanlog in servlog.get_logs(): 147 | if chanlog.get_name().lower() == arg.lower(): 148 | if chanlog.get_channel().type is discord.ChannelType.text: 149 | if chanlog.get_channel().permissions_for(servlog.get_server().me).read_messages: 150 | final_chanlog = chanlog 151 | break 152 | 153 | # if we didn't find an exact match, assume partial 154 | if final_chanlog == "": 155 | for chanlog in servlog.get_logs(): 156 | if chanlog.get_channel().type is discord.ChannelType.text: 157 | if chanlog.get_channel().permissions_for(servlog.get_server().me).read_messages: 158 | if arg.lower() in chanlog.get_name().lower(): 159 | final_chanlog = chanlog 160 | break 161 | 162 | if final_chanlog != "": 163 | gc.client.set_current_channel(final_chanlog.get_name()) 164 | final_chanlog.unread = False 165 | final_chanlog.mentioned_in = False 166 | break 167 | else: 168 | ui.set_display(gc.term.red + "Can't find channel" + gc.term.normal) 169 | 170 | elif command == "nick": 171 | try: 172 | await gc.client.change_nickname(gc.client.get_current_server().me, arg) 173 | except: # you don't have permission to do this here 174 | pass 175 | elif command == "game": 176 | await gc.client.set_game(arg) 177 | elif command == "file": 178 | await send_file(gc.client, arg) 179 | elif command == "status": 180 | status = arg.lower() 181 | if status == "away" or status == "afk": 182 | status = "idle" 183 | elif "disturb" in status: 184 | status = "dnd" 185 | 186 | if status == "online" or status == "offline" \ 187 | or status == "idle" or status == "dnd": 188 | await gc.client.set_status(status) 189 | 190 | # else we must have only a command, no argument 191 | else: 192 | command = gc.user_input 193 | if command == "clear": await ui.clear_screen() 194 | elif command == "quit": kill() 195 | elif command == "exit": kill() 196 | elif command == "help" or command == "h": print_help(gc) 197 | elif command == "servers" or command == "servs": await print_serverlist() 198 | elif command == "channels" or command == "chans": await print_channellist() 199 | elif command == "emojis": await print_emojilist() 200 | elif command == "users" or command == "members": 201 | await ui.clear_screen() 202 | await print_userlist() 203 | elif command[0] == 'c': 204 | try: 205 | if command[1].isdigit(): 206 | await channel_jump(command) 207 | except IndexError: 208 | pass 209 | 210 | await check_emoticons(gc.client, command) 211 | 212 | 213 | # this must not be a command... 214 | else: 215 | # check to see if it has any custom-emojis, written as :emoji: 216 | # we will need to expand them. 217 | # these will look like <:emojiname:39432432903201> 218 | # check if there might be an emoji 219 | if gc.user_input.count(":") >= 2: 220 | 221 | # if user has nitro, loop through *all* emojis 222 | if settings["has_nitro"]: 223 | for emoji in gc.client.get_all_emojis(): 224 | short_name = ':' + emoji.name + ':' 225 | if short_name in gc.user_input: 226 | # find the "full" name of the emoji from the api 227 | full_name = "<:" + emoji.name + ":" + emoji.id + ">" 228 | gc.user_input = gc.user_input.replace(short_name, full_name) 229 | 230 | # else the user can only send from this server 231 | elif gc.client.get_current_server().emojis is not None \ 232 | and len(gc.client.get_current_server().emojis) > 0: 233 | for emoji in gc.client.get_current_server().emojis: 234 | short_name = ':' + emoji.name + ':' 235 | if short_name in gc.user_input: 236 | # find the "full" name of the emoji from the api 237 | full_name = "<:" + emoji.name + ":" + emoji.id + ">" 238 | gc.user_input = gc.user_input.replace(short_name, full_name) 239 | 240 | # if we're here, we've determined its not a command, 241 | # and we've processed all mutations to the input we want 242 | # now we will try to send the message. 243 | text_to_send = gc.user_input 244 | if "@" in gc.user_input: 245 | sections = gc.user_input.lower().strip().split(" ") 246 | sects_copy = [] 247 | for sect in sections: 248 | if "@" in sect: 249 | for member in gc.client.get_current_server().members: 250 | if member is not gc.client.get_current_server().me: 251 | if sect[1:] in member.display_name.lower(): 252 | sect = "<@!" + member.id + ">" 253 | sects_copy.append(sect) 254 | text_to_send = " ".join(sects_copy) 255 | 256 | # sometimes this fails --- this could be due to occasional 257 | # bugs in the api, or there was a connection problem 258 | # So we will try it 3 times, sleeping a bit inbetween 259 | for i in range(0,3): 260 | try: 261 | await gc.client.send_message(gc.client.get_current_channel(), text_to_send) 262 | break 263 | except: 264 | await asyncio.sleep(3) 265 | if i == 2: 266 | ui.set_display(gc.term.blink_red + "error: could not send message") 267 | 268 | # clear our input as we've just sent it 269 | gc.user_input = "" 270 | 271 | # update the screen 272 | await ui.print_screen() 273 | 274 | await asyncio.sleep(0.25) 275 | -------------------------------------------------------------------------------- /ui/ui.py: -------------------------------------------------------------------------------- 1 | import sys 2 | from os import system 3 | from discord import ChannelType 4 | from blessings import Terminal 5 | from ui.line import Line 6 | from ui.ui_utils import * 7 | from utils.globals import gc, get_color 8 | from utils.quicksort import quick_sort_channel_logs 9 | from utils.settings import settings 10 | from utils.print_utils.userlist import print_userlist 11 | 12 | # maximum number of lines that can be on the screen 13 | # is updated every cycle as to allow automatic resizing 14 | MAX_LINES = 0 15 | # buffer to allow for double buffering (stops screen flashing) 16 | screen_buffer = [] 17 | # text that can be set to be displayed for 1 frame 18 | display = "" 19 | display_frames = 0 20 | 21 | async def print_screen(): 22 | # Get ready to redraw the screen 23 | left_bar_width = await get_left_bar_width() 24 | await clear_screen() 25 | 26 | if settings["show_top_bar"]: 27 | await print_top_bar(left_bar_width) 28 | 29 | if gc.server_log_tree is not None: 30 | await print_channel_log(left_bar_width) 31 | 32 | await print_bottom_bar(left_bar_width) 33 | 34 | # Print the buffer containing our message logs 35 | if settings["show_top_bar"]: 36 | if settings["show_separators"]: 37 | with gc.term.location(0, 2): 38 | print("".join(screen_buffer), end="") 39 | else: 40 | with gc.term.location(0, 1): 41 | print("".join(screen_buffer), end="") 42 | 43 | else: 44 | with gc.term.location(0, 0): 45 | print("".join(screen_buffer), end="") 46 | 47 | if settings["show_left_bar"]: 48 | await print_left_bar(left_bar_width) 49 | 50 | global display, display_frames 51 | if display != "": 52 | print(display) 53 | display_frames -= 1 54 | if display_frames <= 0: 55 | display = "" 56 | 57 | async def print_top_bar(left_bar_width): 58 | topic = "" 59 | try: 60 | if gc.client.get_current_channel().topic is not None: 61 | topic = gc.client.get_current_channel().topic 62 | except: 63 | # if there is no channel topic, just print the channel name 64 | try: topic = gc.client.get_current_channel().name 65 | except: pass 66 | 67 | 68 | text_length = gc.term.width - (36 + len(gc.client.get_current_server_name())) 69 | if len(topic) > text_length: 70 | topic = topic[:text_length] 71 | 72 | with gc.term.location(1,0): 73 | print("Server: " + await get_color(settings["server_display_color"]) \ 74 | + gc.client.get_current_server_name() + gc.term.normal, end="") 75 | 76 | with gc.term.location(gc.term.width // 2 - len(topic) // 2, 0): 77 | print(topic, end="") 78 | 79 | online_text = "Users online: " 80 | online_count = str(await gc.client.get_online()) 81 | online_length = len(online_text) + len(online_count) 82 | 83 | with gc.term.location(gc.term.width - online_length - 1, 0): 84 | print(await get_color(settings["server_display_color"]) + online_text \ 85 | + gc.term.normal + online_count, end="") 86 | 87 | if settings["show_separators"]: 88 | divider = await get_color(settings["separator_color"]) \ 89 | + ("─" * gc.term.width) + "\n" + gc.term.normal 90 | 91 | with gc.term.location(0, 1): 92 | print(divider, end="") 93 | 94 | with gc.term.location(left_bar_width, 1): 95 | print(await get_color(settings["separator_color"]) + "┬", end="") 96 | 97 | 98 | async def set_display(string): 99 | global display, display_frames 100 | loc = gc.term.width - 1 - len(string) 101 | escape_chars = "\e" 102 | for escape_chars in string: 103 | loc = loc - 5 104 | display = gc.term.move(gc.term.height - 1, loc) + string 105 | display_frames = 3 106 | 107 | async def print_left_bar(left_bar_width): 108 | start = 0 109 | if settings["show_top_bar"]: 110 | start = 2 111 | 112 | if settings["show_separators"]: 113 | length = 0 114 | length = gc.term.height - settings["margin"] 115 | 116 | sep_color = await get_color(settings["separator_color"]) 117 | for i in range(start, length): 118 | print(gc.term.move(i, left_bar_width) + sep_color + "│" \ 119 | + gc.term.normal, end="") 120 | 121 | # Create a new list so we can preserve the server's channel order 122 | channel_logs = [] 123 | 124 | for servlog in gc.server_log_tree: 125 | if servlog.get_server() is gc.client.get_current_server(): 126 | for chanlog in servlog.get_logs(): 127 | channel_logs.append(chanlog) 128 | break 129 | 130 | channel_logs = quick_sort_channel_logs(channel_logs) 131 | 132 | # buffer to print 133 | buffer = [] 134 | count = 1 135 | 136 | for log in channel_logs: 137 | # don't print categories or voice chats 138 | # TODO: this will break on private messages 139 | if log.get_channel().type != ChannelType.text: continue 140 | text = log.get_name() 141 | length = len(text) 142 | 143 | if settings["number_channels"]: 144 | if count <= 9: length += 1 145 | else: length += 2 146 | 147 | if length > left_bar_width: 148 | if settings["truncate_channels"]: 149 | text = text[0:left_bar_width - 1] 150 | else: 151 | text = text[0:left_bar_width - 4] + "..." 152 | 153 | if log.get_channel() is gc.client.get_current_channel(): 154 | if settings["number_channels"]: 155 | buffer.append(gc.term.normal + str(count) + ". " + gc.term.green + text + gc.term.normal + "\n") 156 | else: 157 | buffer.append(gc.term.green + text + gc.term.normal + "\n") 158 | else: 159 | if log.get_channel() is not channel_logs[0]: 160 | pass 161 | 162 | if log.get_channel() is not gc.client.get_current_channel(): 163 | 164 | if log.unread and settings["blink_unreads"]: 165 | text = await get_color(settings["unread_channel_color"]) + text + gc.term.normal 166 | elif log.mentioned_in and settings["blink_mentions"]: 167 | text = await get_color(settings["unread_mention_color"]) + text + gc.term.normal 168 | 169 | if settings["number_channels"]: 170 | buffer.append(gc.term.normal + str(count) + ". " + text + "\n") 171 | else: 172 | buffer.append(text + "\n") 173 | 174 | count += 1 175 | # should the server have *too many channels!*, stop them 176 | # from spilling over the screen 177 | if count - 1 == gc.term.height - 2 - settings["margin"]: break 178 | 179 | with gc.term.location(0, start): 180 | print("".join(buffer)) 181 | 182 | 183 | async def print_bottom_bar(left_bar_width): 184 | if settings["show_separators"]: 185 | with gc.term.location(0, gc.term.height - 2): 186 | print(await get_color(settings["separator_color"]) + ("─" * gc.term.width) \ 187 | + "\n" + gc.term.normal, end="") 188 | 189 | with gc.term.location(left_bar_width, gc.term.height - 2): 190 | print(await get_color(settings["separator_color"]) + "┴", end="") 191 | 192 | bottom = await get_prompt() 193 | if len(gc.input_buffer) > 0: bottom = bottom + "".join(gc.input_buffer) 194 | with gc.term.location(0, gc.term.height - 1): 195 | print(bottom, end="") 196 | 197 | async def clear_screen(): 198 | # instead of "clearing", we're actually just overwriting 199 | # everything with white space. This mitigates the massive 200 | # screen flashing that goes on with "cls" and "clear" 201 | del screen_buffer[:] 202 | wipe = (" " * (gc.term.width) + "\n") * gc.term.height 203 | print(gc.term.move(0,0) + wipe, end="") 204 | 205 | async def print_channel_log(left_bar_width): 206 | global INDEX 207 | 208 | # If the line would spill over the screen, we need to wrap it 209 | # NOTE: gc.term.width is calculating every time this function is called. 210 | # Meaning that this will automatically resize the screen. 211 | # note: the "1" is the space at the start 212 | MAX_LENGTH = gc.term.width - (left_bar_width + settings["margin"]) - 1 213 | # For wrapped lines, offset them to line up with the previous line 214 | offset = 0 215 | # List to put our *formatted* lines in, once we have OK'd them to print 216 | formatted_lines = [] 217 | 218 | # the max number of lines that can be shown on the screen 219 | MAX_LINES = await get_max_lines() 220 | 221 | for server_log in gc.server_log_tree: 222 | if server_log.get_server() is gc.client.get_current_server(): 223 | for channel_log in server_log.get_logs(): 224 | if channel_log.get_channel() is gc.client.get_current_channel(): 225 | if channel_log.get_channel() not in gc.channels_entered: 226 | await gc.client.populate_current_channel_log() 227 | gc.channels_entered.append(channel_log.get_channel()) 228 | # if the server has a "category" channel named the same 229 | # as a text channel, confusion will occur 230 | # TODO: private messages are not "text" channeltypes 231 | if channel_log.get_channel().type != ChannelType.text: continue 232 | 233 | for msg in channel_log.get_logs(): 234 | # The lines of this unformatted message 235 | msg_lines = [] 236 | 237 | HAS_MENTION = False 238 | if "@" + gc.client.get_current_server().me.display_name in msg.clean_content: 239 | HAS_MENTION = True 240 | 241 | author_name = "" 242 | try: author_name = msg.author.display_name 243 | except: 244 | try: author_name = msg.author.name 245 | except: author_name = "Unknown Author" 246 | 247 | author_name_length = len(author_name) 248 | author_prefix = await get_role_color(msg) + author_name + ": " 249 | 250 | color = "" 251 | if HAS_MENTION: 252 | color = await get_color(settings["mention_color"]) 253 | else: 254 | color = await get_color(settings["text_color"]) 255 | proposed_line = author_prefix + color + msg.clean_content.strip() 256 | 257 | # If our message actually consists of 258 | # of multiple lines separated by new-line 259 | # characters, we need to accomodate for this. 260 | # --- Otherwise: msg_lines will just consist of one line 261 | msg_lines = proposed_line.split("\n") 262 | 263 | for line in msg_lines: 264 | 265 | # strip leading spaces - LEFT ONLY 266 | line = line.lstrip() 267 | 268 | # If our line is greater than our max length, 269 | # that means the author has a long-line comment 270 | # that wasn't using new line chars... 271 | # We must manually wrap it. 272 | 273 | line_length = len(line) 274 | # Loop through, wrapping the lines until it behaves 275 | while line_length > MAX_LENGTH: 276 | 277 | line = line.strip() 278 | 279 | # Take a section out of the line based on our max length 280 | sect = line[:MAX_LENGTH - offset] 281 | 282 | # Make sure we did not cut a word in half 283 | sect = sect[:sect.strip().rfind(' ')] 284 | 285 | # If this section isn't the first line of the comment, 286 | # we should offset it to better distinguish it 287 | offset = 0 288 | if author_prefix not in sect: 289 | if line is not msg_lines[0]: 290 | offset = author_name_length + settings["margin"] 291 | # add in now formatted line! 292 | formatted_lines.append(Line(sect.strip(), offset)) 293 | 294 | # since we just wrapped a line, we need to 295 | # make sure we don't overwrite it next time 296 | 297 | # Split the line between what has been formatted, and 298 | # what still remains needing to be formatted 299 | if len(line) > len(sect): 300 | line = line.split(sect)[1] 301 | 302 | # find the "real" length of the line, by subtracting 303 | # any escape characters it might have. It would 304 | # be wasteful to loop through all of the possibilities 305 | # so instead we will simply subtract the length 306 | # of the shortest for each that it has. 307 | line_length = len(line) 308 | target = "\e" 309 | for target in line: 310 | line_length -= 5 311 | 312 | # Once here, the string was either A: already short enough 313 | # to begin with, or B: made through our while loop and has 314 | # since been chopped down to less than our MAX_LENGTH 315 | if len(line.strip()) > 0: 316 | 317 | offset = 0 318 | if author_prefix not in line: 319 | offset = author_name_length + settings["margin"] 320 | 321 | formatted_lines.append(Line(line.strip(), offset)) 322 | 323 | # where we should start printing from 324 | # clamp the index as not to show whitespace 325 | if channel_log.get_index() < MAX_LINES: 326 | channel_log.set_index(MAX_LINES) 327 | elif channel_log.get_index() > len(formatted_lines): 328 | channel_log.set_index(len(formatted_lines)) 329 | 330 | # ----- Trim out list to print out nicely ----- # 331 | # trims off the front of the list, until our index 332 | del formatted_lines[0:(len(formatted_lines) - channel_log.get_index())] 333 | # retains the amount of lines for our screen, deletes remainder 334 | del formatted_lines[MAX_LINES:] 335 | 336 | # if user does not want the left bar, do not add margin 337 | space = " " 338 | if not settings["show_left_bar"]: 339 | space = "" 340 | 341 | # add to the buffer! 342 | for line in formatted_lines: 343 | screen_buffer.append(space * (left_bar_width + \ 344 | settings["margin"] + line.offset) + line.text + "\n") 345 | 346 | # return as not to loop through all channels unnecessarily 347 | return 348 | -------------------------------------------------------------------------------- /ui/ui_curses.py: -------------------------------------------------------------------------------- 1 | import sys 2 | import time 3 | from os import system 4 | import curses 5 | from curses import ascii as cAscii 6 | from discord import ChannelType 7 | from blessings import Terminal 8 | from ui.line import Line 9 | from ui.ui_utils import * 10 | from utils.globals import * 11 | from utils.quicksort import quick_sort_channel_logs 12 | from utils.settings import settings 13 | from utils.print_utils.userlist import print_userlist 14 | 15 | # maximum number of lines that can be on the screen 16 | # is updated every cycle as to allow automatic resizing 17 | MAX_LINES = 0 18 | # screen 19 | global stdscr 20 | stdscr = None 21 | global windows 22 | windows = [] 23 | # buffer to allow for double buffering (stops screen flashing) 24 | screen_buffer = [] 25 | # text that can be set to be displayed for 1 frame 26 | display = "" 27 | display_frames = 0 28 | 29 | def cursesInit(): 30 | stdscr = curses.initscr() 31 | curses.noecho() 32 | curses.cbreak() 33 | stdscr.keypad(True) 34 | 35 | def cursesDestroy(): 36 | curses.nocbreak() 37 | stdscr.keypad(False) 38 | curses.echo() 39 | curses.endwin() 40 | 41 | def cursesRefresh(): 42 | stdscr.noutrefresh() 43 | for win in windows: 44 | win.noutrefresh() 45 | curses.doupdate() 46 | 47 | async def print_screen(): 48 | stdscr.clear() 49 | stdscr.addstr("Test") 50 | cursesRefresh() 51 | ## Get ready to redraw the screen 52 | #left_bar_width = await get_left_bar_width() 53 | #await clear_screen() 54 | 55 | #if settings["show_top_bar"]: 56 | # await print_top_bar(left_bar_width) 57 | 58 | #if server_log_tree is not None: 59 | # await print_channel_log(left_bar_width) 60 | 61 | #await print_bottom_bar(left_bar_width) 62 | 63 | ## Print the buffer containing our message logs 64 | #if settings["show_top_bar"]: 65 | # if settings["show_separators"]: 66 | # with term.location(0, 2): 67 | # print("".join(screen_buffer), end="") 68 | # else: 69 | # with term.location(0, 1): 70 | # print("".join(screen_buffer), end="") 71 | 72 | #else: 73 | # with term.location(0, 0): 74 | # print("".join(screen_buffer), end="") 75 | 76 | #if settings["show_left_bar"]: 77 | # await print_left_bar(left_bar_width) 78 | 79 | #global display, display_frames 80 | #if display != "": 81 | # print(display) 82 | # display_frames -= 1 83 | # if display_frames <= 0: 84 | # display = "" 85 | 86 | async def print_top_bar(left_bar_width): 87 | topic = "" 88 | try: 89 | if client.get_current_channel().topic is not None: 90 | topic = client.get_current_channel().topic 91 | except: 92 | # if there is no channel topic, just print the channel name 93 | try: topic = client.get_current_channel().name 94 | except: pass 95 | 96 | 97 | text_length = term.width - (36 + len(client.get_current_server_name())) 98 | if len(topic) > text_length: 99 | topic = topic[:text_length] 100 | 101 | with term.location(1,0): 102 | print("Server: " + await get_color(settings["server_display_color"]) \ 103 | + client.get_current_server_name() + term.normal, end="") 104 | 105 | with term.location(term.width // 2 - len(topic) // 2, 0): 106 | print(topic, end="") 107 | 108 | online_text = "Users online: " 109 | online_count = str(await client.get_online()) 110 | online_length = len(online_text) + len(online_count) 111 | 112 | with term.location(term.width - online_length - 1, 0): 113 | print(await get_color(settings["server_display_color"]) + online_text \ 114 | + term.normal + online_count, end="") 115 | 116 | if settings["show_separators"]: 117 | divider = await get_color(settings["separator_color"]) \ 118 | + ("─" * term.width) + "\n" + term.normal 119 | 120 | with term.location(0, 1): 121 | print(divider, end="") 122 | 123 | with term.location(left_bar_width, 1): 124 | print(await get_color(settings["separator_color"]) + "┬", end="") 125 | 126 | 127 | async def set_display(string): 128 | global display, display_frames 129 | loc = term.width - 1 - len(string) 130 | escape_chars = "\e" 131 | for escape_chars in string: 132 | loc = loc - 5 133 | display = term.move(term.height - 1, loc) + string 134 | display_frames = 3 135 | 136 | async def print_left_bar(left_bar_width): 137 | start = 0 138 | if settings["show_top_bar"]: 139 | start = 2 140 | 141 | if settings["show_separators"]: 142 | length = 0 143 | length = term.height - settings["margin"] 144 | 145 | sep_color = await get_color(settings["separator_color"]) 146 | for i in range(start, length): 147 | print(term.move(i, left_bar_width) + sep_color + "│" \ 148 | + term.normal, end="") 149 | 150 | # Create a new list so we can preserve the server's channel order 151 | channel_logs = [] 152 | 153 | for servlog in server_log_tree: 154 | if servlog.get_server() is client.get_current_server(): 155 | for chanlog in servlog.get_logs(): 156 | channel_logs.append(chanlog) 157 | break 158 | 159 | channel_logs = quick_sort_channel_logs(channel_logs) 160 | 161 | # buffer to print 162 | buffer = [] 163 | count = 1 164 | 165 | for log in channel_logs: 166 | # don't print categories or voice chats 167 | # TODO: this will break on private messages 168 | if log.get_channel().type != ChannelType.text: continue 169 | text = log.get_name() 170 | length = len(text) 171 | 172 | if settings["number_channels"]: 173 | if count <= 9: length += 1 174 | else: length += 2 175 | 176 | if length > left_bar_width: 177 | if settings["truncate_channels"]: 178 | text = text[0:left_bar_width - 1] 179 | else: 180 | text = text[0:left_bar_width - 4] + "..." 181 | 182 | if log.get_channel() is client.get_current_channel(): 183 | if settings["number_channels"]: 184 | buffer.append(term.normal + str(count) + ". " + term.green + text + term.normal + "\n") 185 | else: 186 | buffer.append(term.green + text + term.normal + "\n") 187 | else: 188 | if log.get_channel() is not channel_logs[0]: 189 | pass 190 | 191 | if log.get_channel() is not client.get_current_channel(): 192 | 193 | if log.unread and settings["blink_unreads"]: 194 | text = await get_color(settings["unread_channel_color"]) + text + term.normal 195 | elif log.mentioned_in and settings["blink_mentions"]: 196 | text = await get_color(settings["unread_mention_color"]) + text + term.normal 197 | 198 | if settings["number_channels"]: 199 | buffer.append(term.normal + str(count) + ". " + text + "\n") 200 | else: 201 | buffer.append(text + "\n") 202 | 203 | count += 1 204 | # should the server have *too many channels!*, stop them 205 | # from spilling over the screen 206 | if count - 1 == term.height - 2 - settings["margin"]: break 207 | 208 | with term.location(0, start): 209 | print("".join(buffer)) 210 | 211 | 212 | async def print_bottom_bar(left_bar_width): 213 | if settings["show_separators"]: 214 | with term.location(0, term.height - 2): 215 | print(await get_color(settings["separator_color"]) + ("─" * term.width) \ 216 | + "\n" + term.normal, end="") 217 | 218 | with term.location(left_bar_width, term.height - 2): 219 | print(await get_color(settings["separator_color"]) + "┴", end="") 220 | 221 | bottom = await get_prompt() 222 | if len(input_buffer) > 0: bottom = bottom + "".join(input_buffer) 223 | with term.location(0, term.height - 1): 224 | print(bottom, end="") 225 | 226 | async def clear_screen(): 227 | # This is more efficient 228 | cursesRefresh() 229 | ## instead of "clearing", we're actually just overwriting 230 | ## everything with white space. This mitigates the massive 231 | ## screen flashing that goes on with "cls" and "clear" 232 | #del screen_buffer[:] 233 | #wipe = (" " * (term.width) + "\n") * term.height 234 | #print(term.move(0,0) + wipe, end="") 235 | 236 | async def print_channel_log(left_bar_width): 237 | global INDEX 238 | 239 | # If the line would spill over the screen, we need to wrap it 240 | # NOTE: term.width is calculating every time this function is called. 241 | # Meaning that this will automatically resize the screen. 242 | # note: the "1" is the space at the start 243 | MAX_LENGTH = term.width - (left_bar_width + settings["margin"]) - 1 244 | # For wrapped lines, offset them to line up with the previous line 245 | offset = 0 246 | # List to put our *formatted* lines in, once we have OK'd them to print 247 | formatted_lines = [] 248 | 249 | # the max number of lines that can be shown on the screen 250 | MAX_LINES = await get_max_lines() 251 | 252 | for server_log in server_log_tree: 253 | if server_log.get_server() is client.get_current_server(): 254 | for channel_log in server_log.get_logs(): 255 | if channel_log.get_channel() is client.get_current_channel(): 256 | # if the server has a "category" channel named the same 257 | # as a text channel, confusion will occur 258 | # TODO: private messages are not "text" channeltypes 259 | if channel_log.get_channel().type != ChannelType.text: continue 260 | 261 | for msg in channel_log.get_logs(): 262 | # The lines of this unformatted message 263 | msg_lines = [] 264 | 265 | HAS_MENTION = False 266 | if "@" + client.get_current_server().me.display_name in msg.clean_content: 267 | HAS_MENTION = True 268 | 269 | author_name = "" 270 | try: author_name = msg.author.display_name 271 | except: 272 | try: author_name = msg.author.name 273 | except: author_name = "Unknown Author" 274 | 275 | author_name_length = len(author_name) 276 | author_prefix = await get_role_color(msg) + author_name + ": " 277 | 278 | color = "" 279 | if HAS_MENTION: 280 | color = await get_color(settings["mention_color"]) 281 | else: 282 | color = await get_color(settings["text_color"]) 283 | proposed_line = author_prefix + color + msg.clean_content.strip() 284 | 285 | # If our message actually consists of 286 | # of multiple lines separated by new-line 287 | # characters, we need to accomodate for this. 288 | # --- Otherwise: msg_lines will just consist of one line 289 | msg_lines = proposed_line.split("\n") 290 | 291 | for line in msg_lines: 292 | 293 | # strip leading spaces - LEFT ONLY 294 | line = line.lstrip() 295 | 296 | # If our line is greater than our max length, 297 | # that means the author has a long-line comment 298 | # that wasn't using new line chars... 299 | # We must manually wrap it. 300 | 301 | line_length = len(line) 302 | # Loop through, wrapping the lines until it behaves 303 | while line_length > MAX_LENGTH: 304 | 305 | line = line.strip() 306 | 307 | # Take a section out of the line based on our max length 308 | sect = line[:MAX_LENGTH - offset] 309 | 310 | # Make sure we did not cut a word in half 311 | sect = sect[:sect.strip().rfind(' ')] 312 | 313 | # If this section isn't the first line of the comment, 314 | # we should offset it to better distinguish it 315 | offset = 0 316 | if author_prefix not in sect: 317 | if line is not msg_lines[0]: 318 | offset = author_name_length + settings["margin"] 319 | # add in now formatted line! 320 | formatted_lines.append(Line(sect.strip(), offset)) 321 | 322 | # since we just wrapped a line, we need to 323 | # make sure we don't overwrite it next time 324 | 325 | # Split the line between what has been formatted, and 326 | # what still remains needing to be formatted 327 | if len(line) > len(sect): 328 | line = line.split(sect)[1] 329 | 330 | # find the "real" length of the line, by subtracting 331 | # any escape characters it might have. It would 332 | # be wasteful to loop through all of the possibilities 333 | # so instead we will simply subtract the length 334 | # of the shortest for each that it has. 335 | line_length = len(line) 336 | target = "\e" 337 | for target in line: 338 | line_length -= 5 339 | 340 | # Once here, the string was either A: already short enough 341 | # to begin with, or B: made through our while loop and has 342 | # since been chopped down to less than our MAX_LENGTH 343 | if len(line.strip()) > 0: 344 | 345 | offset = 0 346 | if author_prefix not in line: 347 | offset = author_name_length + settings["margin"] 348 | 349 | formatted_lines.append(Line(line.strip(), offset)) 350 | 351 | # where we should start printing from 352 | # clamp the index as not to show whitespace 353 | if channel_log.get_index() < MAX_LINES: 354 | channel_log.set_index(MAX_LINES) 355 | elif channel_log.get_index() > len(formatted_lines): 356 | channel_log.set_index(len(formatted_lines)) 357 | 358 | # ----- Trim out list to print out nicely ----- # 359 | # trims off the front of the list, until our index 360 | del formatted_lines[0:(len(formatted_lines) - channel_log.get_index())] 361 | # retains the amount of lines for our screen, deletes remainder 362 | del formatted_lines[MAX_LINES:] 363 | 364 | # if user does not want the left bar, do not add margin 365 | space = " " 366 | if not settings["show_left_bar"]: 367 | space = "" 368 | 369 | # add to the buffer! 370 | for line in formatted_lines: 371 | screen_buffer.append(space * (left_bar_width + \ 372 | settings["margin"] + line.offset) + line.text + "\n") 373 | 374 | # return as not to loop through all channels unnecessarily 375 | return 376 | --------------------------------------------------------------------------------