552 lines
18 KiB
Python
Executable File
552 lines
18 KiB
Python
Executable File
#!/usr/bin/python3
|
|
|
|
import os, sys
|
|
import json
|
|
import shutil
|
|
from uuid import uuid4
|
|
from datetime import datetime, timezone
|
|
import base64
|
|
from hashlib import sha1
|
|
import argparse
|
|
import copy
|
|
|
|
|
|
HOMEDIR = f"/home/{os.getlogin()}"
|
|
HOST = "ctrl-c.club"
|
|
|
|
class IrisError(Exception):
|
|
...
|
|
|
|
class MalformedMessageObjectError(Exception):
|
|
...
|
|
|
|
class IrisNotImplementedError(Exception):
|
|
...
|
|
|
|
|
|
class Messages:
|
|
read = []
|
|
|
|
topics = []
|
|
messages = []
|
|
|
|
topics_latest = []
|
|
messages_latest = []
|
|
|
|
def __init__(self, corpus=None):
|
|
self.__update()
|
|
|
|
def __update(self):
|
|
"""update state of `corpus`, `read`, `topics` and `messages` lists"""
|
|
|
|
corpus = []
|
|
userlist = os.listdir('/home/')
|
|
|
|
for user in userlist:
|
|
try:
|
|
if '.iris.messages' in os.listdir(f'/home/{user}/'):
|
|
with open(f'/home/{user}/.iris.messages', 'r') as messages:
|
|
try:
|
|
userdata = json.loads(messages.read())
|
|
except json.decoder.JSONDecodeError:
|
|
print(f"{user}'s messages file is malformed")
|
|
continue
|
|
|
|
if userdata != []:
|
|
corpus += userdata
|
|
|
|
except (PermissionError, NotADirectoryError):
|
|
pass
|
|
|
|
# sort corpus in ascending order by timestamp
|
|
corpus = sorted(corpus, key=lambda k: k['data']['timestamp'])
|
|
|
|
# get hashes of read messages from the user's `.iris.messages.read`
|
|
with open(f"{HOMEDIR}/.iris.messages.read", "r") as f:
|
|
self.read = json.loads(f.read())
|
|
|
|
# topics and messages lists
|
|
for item in corpus:
|
|
if item["data"]["parent"] == None:
|
|
self.topics.append(item)
|
|
else:
|
|
self.messages.append(item)
|
|
|
|
for i, message in enumerate(self.messages): # message numbers (global, for replies)
|
|
message["mindex"] = i+1
|
|
|
|
self.topics_oldest_version = [item for item in self.topics if item["edit_hash"] == None]
|
|
self.messages_oldest_version = [item for item in self.messages if item["edit_hash"] == None]
|
|
|
|
for i, topic in enumerate(self.topics_oldest_version): # topic numbers
|
|
topic["index"] = i+1
|
|
|
|
|
|
def unread_topics(self):
|
|
"""returns a list of topics with unread messages"""
|
|
output = []
|
|
|
|
for topic in self.topics_oldest_version:
|
|
try:
|
|
topic_editchain = self.__get_editchain(topic["hash"])
|
|
topic_hashes = [msg["hash"] for msg in topic_editchain]
|
|
except MalformedMessageObjectError:
|
|
continue
|
|
|
|
unread_reply_count = 0
|
|
reply_count = 0
|
|
for message in self.messages:
|
|
if message["data"]["parent"] in topic_hashes:
|
|
if message["hash"] not in self.read:
|
|
unread_reply_count += 1
|
|
reply_count += 1
|
|
|
|
if unread_reply_count > 0:
|
|
output.append(topic_editchain[-1])
|
|
output[-1]["index"] = topic["index"] # copying over the index because topics don't have them globally
|
|
output[-1]["unread"] = unread_reply_count # saving unread count in the same manner
|
|
|
|
return output
|
|
|
|
def __get_message_obj_from_hash(self, msghash):
|
|
for msg in self.messages + self.topics:
|
|
if msg["hash"] == msghash:
|
|
return msg
|
|
return None
|
|
|
|
def __get_next_message_version(self, msghash):
|
|
for msg in self.messages + self.topics:
|
|
if msg["edit_hash"] == msghash:
|
|
return msg
|
|
return None
|
|
|
|
def __get_editchain(self, msghash):
|
|
chain = []
|
|
|
|
msg = self.__get_message_obj_from_hash(msghash)
|
|
chain.append(msg)
|
|
|
|
while msg != None:
|
|
msg = self.__get_next_message_version(msghash)
|
|
if msg:
|
|
chain.append(msg)
|
|
if msg["hash"] == msghash:
|
|
raise MalformedMessageObjectError(f"this message object (id: {repr(msghash)}) by user {msg['data']['author'].split('@')[0]} seems to be malformed")
|
|
msghash = msg["hash"]
|
|
else:
|
|
break
|
|
|
|
return chain
|
|
|
|
def replies_to(self, topic_hash):
|
|
replies = []
|
|
|
|
# get hashes for all versions of the topic
|
|
topic_edit_hashes = [msg["hash"] for msg in self.__get_editchain(topic_hash)]
|
|
|
|
# get oldest versions of all replies to all versions of the message
|
|
roots = [msg for msg in self.messages_oldest_version if msg["data"]["parent"] in topic_edit_hashes]
|
|
|
|
# get latest version of each reply and append to replies array
|
|
for msg in roots:
|
|
msghash = msg["hash"]
|
|
|
|
try:
|
|
editchain = self.__get_editchain(msghash)
|
|
except MalformedMessageObjectError:
|
|
continue
|
|
|
|
if not editchain:
|
|
replies.append(msg)
|
|
continue
|
|
replies.append(editchain[-1])
|
|
|
|
# return replies
|
|
return replies
|
|
|
|
def __get_edit_history(self, item):
|
|
# getting edit chain from "outside-in"
|
|
chain = []
|
|
|
|
msg = self.__get_message_obj_from_hash(item)
|
|
chain.append(msg)
|
|
|
|
while msg != None:
|
|
msg = self.__get_message_obj_from_hash(msg["edit_hash"])
|
|
if msg:
|
|
chain.append(msg)
|
|
if msg["hash"] == msg["edit_hash"]:
|
|
raise MalformedMessageObjectError(f"this message object (id: {repr(msghash)}) by user {msg['data']['author'].split('@')[0]} seems to be malformed")
|
|
else:
|
|
break
|
|
|
|
return chain
|
|
|
|
def mark_as_read(self, hashes):
|
|
# TODO, currently broken
|
|
for item in hashes:
|
|
try:
|
|
chain = self.__get_editchain(item)
|
|
|
|
# get hashes from chain
|
|
edit_hashes = [m["hash"] for m in chain]
|
|
|
|
self.read.append(edit_hashes[-1])
|
|
|
|
except MalformedMessageObjectError:
|
|
continue
|
|
|
|
#for h in edit_hashes:
|
|
# if h not in self.read:
|
|
# self.read.append(h)
|
|
|
|
with open(f"{HOMEDIR}/.iris.messages.read", "w") as f:
|
|
f.write(json.dumps(self.read, separators=(',',':')))
|
|
|
|
def mark_all_read(self):
|
|
# TODO, currently broken
|
|
"""mark all unread topics as read"""
|
|
unread_topic_hashes = [m["hash"] for m in self.unread_topics()]
|
|
|
|
all_reply_hashes = []
|
|
for topic in unread_topic_hashes:
|
|
replies = messages.replies_to(topic)
|
|
all_reply_hashes += [obj["hash"] for obj in replies]
|
|
|
|
self.mark_as_read(all_reply_hashes)
|
|
|
|
def get_hash_from_topic_no(self, num):
|
|
"""returns hash of latest version of topic from its edit chain"""
|
|
oldest_hash = self.topics_oldest_version[num - 1]["hash"]
|
|
return self.__get_editchain(oldest_hash)[-1]["hash"]
|
|
|
|
def __write_message(self, default=None) -> str:
|
|
"""launch an editor to write a message (with optional default value) and return message string"""
|
|
|
|
tmp_filename = "irispy_tmp_" + uuid4().hex
|
|
tmp_filepath = f"{HOMEDIR}/{tmp_filename}"
|
|
|
|
# if default value passed in, write it to the file
|
|
if default:
|
|
with open(tmp_filepath, 'w') as f:
|
|
f.write(default)
|
|
else:
|
|
os.system(f"touch {tmp_filepath}")
|
|
|
|
# open $EDITOR or nano to edit the file
|
|
with open(tmp_filepath, 'r+') as f:
|
|
editor = os.environ['EDITOR']
|
|
if editor == None:
|
|
editor = "nano -t"
|
|
os.system(f"{editor} {tmp_filepath}")
|
|
|
|
with open(tmp_filepath, 'r') as f:
|
|
content = f.read()
|
|
|
|
# delete temporary file
|
|
os.system(f"rm {tmp_filepath}")
|
|
|
|
return content
|
|
|
|
def __hash_message_object(self, parent, timestamp, content):
|
|
author = f"{os.getlogin()}@{HOST}"
|
|
|
|
hash_source = json.dumps({
|
|
"author": author,
|
|
"parent": parent,
|
|
"timestamp": timestamp,
|
|
"message": content
|
|
}, separators=(',', ':')
|
|
).encode()
|
|
|
|
message_hash = base64.b64encode(sha1(hash_source).digest())
|
|
|
|
return message_hash.decode('utf-8') + '\n'
|
|
|
|
def create_post(self, parent=None):
|
|
author = f"{os.getlogin()}@{HOST}"
|
|
content = self.__write_message()
|
|
|
|
if len(content) == 0:
|
|
raise IrisError("post cannot be empty")
|
|
|
|
# GMT Timestamp
|
|
timestamp = datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
|
|
|
|
# Message SHA1 hash from schema defined in iris reference implementation
|
|
message_hash = self.__hash_message_object(parent, timestamp, content)
|
|
|
|
# create message object, append it to .iris.messages
|
|
message_obj = {
|
|
"hash": message_hash,
|
|
"edit_hash": None,
|
|
"is_deleted": None,
|
|
"data": {
|
|
"author": author,
|
|
"parent": parent,
|
|
"timestamp": timestamp,
|
|
"message": content
|
|
}
|
|
}
|
|
|
|
# confirmation
|
|
if input("save message? [Y/n] ") in ('N','n'):
|
|
print("canceled")
|
|
sys.exit(0)
|
|
|
|
# updating .iris.messages
|
|
user_messages = []
|
|
with open(f"{HOMEDIR}/.iris.messages", "r") as f:
|
|
user_messages = json.loads(f.read())
|
|
|
|
user_messages.append(message_obj)
|
|
|
|
with open(f"{HOMEDIR}/.iris.messages", "w") as f:
|
|
f.write(json.dumps(user_messages, separators=(',',':')))
|
|
|
|
print("message saved")
|
|
|
|
def edit_message(self, msgid: str):
|
|
# load user's messages
|
|
user_messages = []
|
|
with open(f"{HOMEDIR}/.iris.messages", "r") as f:
|
|
user_messages = json.loads(f.read())
|
|
|
|
# getting latest message / topic hash
|
|
message_hash = msgid
|
|
if type(msgid) is int:
|
|
message_hash = self.get_hash_from_topic_no(msgid)
|
|
elif type(msgid) is str:
|
|
try:
|
|
msg_index = int(msgid.split('M')[1])
|
|
except IndexError:
|
|
raise IrisError("invalid argument format")
|
|
|
|
message_hash = self.messages[msg_index - 1]["hash"]
|
|
else:
|
|
raise IrisError("invalid argument type")
|
|
|
|
# get message object to edit
|
|
old_message = {}
|
|
for msg in user_messages:
|
|
if msg["hash"] == message_hash:
|
|
old_message = msg
|
|
break
|
|
|
|
# raise error if post couldn't be found
|
|
if old_message == {}:
|
|
raise IrisError("couldn't find post in user's messages file")
|
|
|
|
# create new message object
|
|
new_message = copy.deepcopy(old_message)
|
|
|
|
# edit content
|
|
new_message["data"]["message"] = self.__write_message(default=new_message["data"]["message"])
|
|
|
|
# check if no edits
|
|
if new_message["data"]["message"] == old_message["data"]["message"]:
|
|
print("no edits performed")
|
|
sys.exit(0)
|
|
|
|
# re-hash
|
|
new_hash = self.__hash_message_object(
|
|
parent=new_message["data"]["parent"],
|
|
timestamp=new_message["data"]["timestamp"],
|
|
content=new_message["data"]["message"]
|
|
)
|
|
|
|
new_message["hash"] = new_hash
|
|
new_message["edit_hash"] = old_message["hash"]
|
|
|
|
# add new version of message
|
|
user_messages.append(new_message)
|
|
|
|
# confirmation
|
|
if input("save message? [Y/n] ") in ('N','n'):
|
|
print("canceled")
|
|
sys.exit(0)
|
|
|
|
# saving .iris.messages
|
|
with open(f"{HOMEDIR}/.iris.messages", "w") as f:
|
|
f.write(json.dumps(user_messages, separators=(',',':')))
|
|
|
|
print("message saved")
|
|
|
|
|
|
class Display:
|
|
def print_unread_feed(messages):
|
|
COLS = shutil.get_terminal_size((80, 20))[0]
|
|
max_namelen = max([len(msg["data"]["author"].split('@')[0]) for msg in messages])
|
|
tstamp_len = 10
|
|
|
|
max_numlen = 0
|
|
max_numlen = len(str(max([msg["index"] for msg in messages])))
|
|
max_unread_numlen = len(str(max([msg["unread"] for msg in messages])))
|
|
|
|
for msg in messages:
|
|
author = msg["data"]["author"].split('@')[0]
|
|
content_part = msg["data"]["message"].split('\n')[0].replace('\n','').replace('\t',' ')
|
|
tstamp = msg["data"]["timestamp"].split('T')[0]
|
|
topic_no = msg["index"]
|
|
unread_no = msg["unread"]
|
|
|
|
try:
|
|
if msg["is_deleted"]:
|
|
continue
|
|
except KeyError:
|
|
print("<malformed message object>")
|
|
continue
|
|
|
|
print(f"{topic_no}{' '*(max_numlen-len(str(topic_no)))} \x1b[33m{author}{' '*(max_namelen-len(author))}\033[00m [{str(unread_no) + ' '*(max_unread_numlen-len(str(unread_no)))}] \033[96m{tstamp}\033[00m {content_part[:(COLS-max_namelen-tstamp_len-max_numlen-max_unread_numlen-8)]}")
|
|
|
|
def print_as_feed(messages):
|
|
COLS = shutil.get_terminal_size((80, 20))[0]
|
|
max_namelen = max([len(msg["data"]["author"].split('@')[0]) for msg in messages])
|
|
tstamp_len = 10
|
|
|
|
max_numlen = len(str(max([msg["index"] for msg in messages])))
|
|
|
|
for msg in messages:
|
|
author = msg["data"]["author"].split('@')[0]
|
|
content_part = msg["data"]["message"].split('\n')[0].replace('\n','').replace('\t',' ')
|
|
tstamp = msg["data"]["timestamp"].split('T')[0]
|
|
topic_no = msg["index"]
|
|
|
|
try:
|
|
if msg["is_deleted"]:
|
|
#print(f" {topic_no}{' '*(max_numlen-len(str(topic_no)))} \x1b[33m{author}{' '*(max_namelen-len(author))}\033[00m \x1b[37m{tstamp}\033[00m DELETED BY AUTHOR")
|
|
continue
|
|
except KeyError:
|
|
print("<malformed message object>")
|
|
continue
|
|
|
|
print(f"{topic_no}{' '*(max_numlen-len(str(topic_no)))} \x1b[33m{author}{' '*(max_namelen-len(author))}\033[00m \033[96m{tstamp}\033[00m {content_part[:(COLS-max_namelen-tstamp_len-max_numlen-4)]}")
|
|
|
|
def print_message(msg, linecolor=False) -> None:
|
|
COLS = shutil.get_terminal_size((80, 20))[0]
|
|
text = msg["data"]["message"]
|
|
tstamp = msg["data"]["timestamp"].split('T')[0]
|
|
author = msg["data"]["author"].split('@')[0]
|
|
edited = "edited" if msg["edit_hash"] else ""
|
|
message_id = " "
|
|
try:
|
|
if author == os.getlogin():
|
|
message_id = f"[\033[96mM{msg['mindex']}\033[00m]"
|
|
except KeyError:
|
|
pass
|
|
|
|
space = COLS - (len(author) + len(tstamp) + 1) - 3
|
|
|
|
if msg["is_deleted"]:
|
|
space = COLS - (len(author) + len(tstamp) + 1) - 3 - 7
|
|
print(f" \x1b[33m{author}\033[00m \x1b[37m{tstamp}\033[00m {'-'*space} \x1b[33mDELETED\033[00m")
|
|
return
|
|
|
|
if linecolor:
|
|
if edited:
|
|
space = COLS - (len(author) + len(tstamp) + len(edited) + len(message_id) + 3)
|
|
print(f" \x1b[33m{author}\033[00m \x1b[37m{tstamp}\033[00m{message_id}\033[96m{'-'*space}\033[00m \033[96m{edited}\033[00m")
|
|
else:
|
|
print(f" \x1b[33m{author}\033[00m \x1b[37m{tstamp}\033[00m \033[96m{'-'*space}\033[00m")
|
|
|
|
else:
|
|
if edited:
|
|
space = COLS - (len(author) + len(tstamp) + (len(message_id) + 1) + 1)
|
|
print(f" \x1b[33m{author}\033[00m \x1b[37m{tstamp}\033[00m {message_id} {'-'*space} \033[96m{edited}\033[00m")
|
|
else:
|
|
print(f" \x1b[33m{author}\033[00m \x1b[37m{tstamp}\033[00m {'-'*space}")
|
|
|
|
print(text)
|
|
if text[-1] != '\n':
|
|
print()
|
|
|
|
|
|
def view_topic(topic_no, messages):
|
|
COLS = shutil.get_terminal_size((80, 20))[0]
|
|
|
|
topic_no = int(topic_no)
|
|
|
|
requested_topic = messages.topics_oldest_version[int(topic_no) - 1]
|
|
|
|
if requested_topic["is_deleted"] != None:
|
|
print("This topic has been deleted by its author")
|
|
sys.exit(0)
|
|
|
|
replies = messages.replies_to(requested_topic["hash"])
|
|
messages.mark_as_read([x["hash"] for x in replies])
|
|
|
|
latest_topic_object = messages._Messages__get_editchain(requested_topic["hash"])[-1]
|
|
Display.print_message(latest_topic_object, linecolor=True)
|
|
print(f"\033[96m{'='*COLS}\033[00m")
|
|
|
|
for msg in replies:
|
|
Display.print_message(msg)
|
|
|
|
|
|
if __name__ == "__main__":
|
|
|
|
# parsing arguments
|
|
parser = argparse.ArgumentParser()
|
|
|
|
parser.add_argument("-u", "--unread", help="list topics with unread messages", action="store_true")
|
|
parser.add_argument("-t", "--topics", help="list all topics", action="store_true")
|
|
|
|
parser.add_argument("-p", "--post", help="create new topic", action="store_true")
|
|
parser.add_argument("-v", "--view", help="view topic N", metavar='N', nargs=1, type=lambda x: int(x) if int(x) > 0 else False)
|
|
parser.add_argument("-r", "--reply", help="reply to topic N", metavar='N', nargs=1, type=lambda x: int(x) if int(x) > 0 else False)
|
|
parser.add_argument("-e", "--edit", help="edit topic number `X` or message number M`X`", metavar='X', nargs=1, type=lambda x: int(x) if x.isnumeric() else x)
|
|
|
|
parser.add_argument("--mark-all-read", help="mark all topics as read", action="store_true")
|
|
|
|
# default behaviour: print help text
|
|
if not len(sys.argv) > 1:
|
|
parser.print_help()
|
|
sys.exit(0)
|
|
|
|
messages = Messages()
|
|
|
|
args = parser.parse_args()
|
|
|
|
if args.unread:
|
|
topics = messages.unread_topics()
|
|
if len(topics) == 0:
|
|
print("you're all caught up!")
|
|
else:
|
|
Display.print_unread_feed(messages.unread_topics())
|
|
sys.exit(0)
|
|
|
|
if args.topics:
|
|
Display.print_as_feed(messages.topics_oldest_version)
|
|
sys.exit(0)
|
|
|
|
if args.view:
|
|
topic_no = args.view[0]
|
|
if not topic_no:
|
|
raise IrisError("invalid topic number")
|
|
|
|
Display.view_topic(topic_no, messages)
|
|
|
|
sys.exit(0)
|
|
|
|
if args.mark_all_read:
|
|
messages.mark_all_read()
|
|
print("all topics marked read")
|
|
sys.exit(0)
|
|
|
|
if args.post:
|
|
messages.create_post()
|
|
sys.exit(0)
|
|
|
|
if args.reply:
|
|
topic_no = args.reply[0]
|
|
if not topic_no:
|
|
raise IrisError("invalid topic number")
|
|
|
|
messages.create_post(messages.get_hash_from_topic_no(topic_no))
|
|
sys.exit(0)
|
|
|
|
if args.edit:
|
|
messages.edit_message(args.edit[0])
|
|
sys.exit(0)
|
|
|