commit 5cfec13d6f0c137636264c84048cb3c8f59ecbbe Author: Joscha Date: Fri May 6 11:12:03 2016 +0200 Initial commit diff --git a/__init__.py b/__init__.py new file mode 100644 index 0000000..06a26de --- /dev/null +++ b/__init__.py @@ -0,0 +1,3 @@ +from .connection import Connection +from .session import Session +from .message import Message diff --git a/connection.py b/connection.py new file mode 100644 index 0000000..0913c59 --- /dev/null +++ b/connection.py @@ -0,0 +1,235 @@ +import json +import time +import threading +import websocket +from websocket import WebSocketException as WSException + + +ROOM_FORMAT = "wss://euphoria.io/room/{}/ws" + + +class Connection(): + """ + Stays connected to a room in its own thread. + Callback functions are called when a packet is received. + """ + + def __init__(self, room): + """ + room - name of the room to connect to + """ + + self.room = room + + self.stopping = False + + self.ws = None + self.send_id = 0 + self.callbacks = {} + self.id_callbacks = {} + + def connect(self, tries=-1, delay=10): + """ + _connect(tries, delay) -> bool + + tries - maximum number of retries + -1 -> retry indefinitely + + Returns True on success, False on failure. + + Connect to the room. + """ + + while tries != 0: + try: + self.ws = websocket.create_connection( + ROOM_FORMAT.format(self.room), + enable_multithread=True + ) + return True + except WSException: + if tries > 0: + tries -= 1 + if tries != 0: + time.sleep(delay) + return False + + def disconnect(self): + """ + disconnect() -> None + + Reconnect to the room. + WARNING: To completely disconnect, use stop(). + """ + + if self.ws: + self.ws.close() + self.ws = None + + def launch(self): + """ + launch() -> Thread + + Connect to the room and spawn a new thread running run. + """ + + if self.connect(tries=1): + self.thread = threading.Thread(target=self.run, name=self.room) + self.thread.start() + return self.thread + else: + self.stop() + + def run(self): + """ + run() -> None + + Receive messages. + """ + + while not self.stopping: + try: + self.handle_json(self.ws.recv()) + except (WSException, OSError, ValueError): + if not self.stopping: + self.disconnect() + self.connect() + + def stop(self): + """ + stop() -> None + + Close the connection to the room. + """ + + self.stopping = True + self.disconnect() + + def join(self): + """ + join() -> None + + Join the thread spawned by launch. + """ + + if self.thread: + self.thread.join() + + def add_callback(self, ptype, callback, *args, **kwargs): + """ + add_callback(ptype, callback) -> None + + Add a function to be called when a packet of type ptype is received. + """ + + if not ptype in self.callbacks: + self.callbacks[ptype] = [] + + callback_info = { + "callback": callback, + "args": args, + "kwargs": kwargs + } + + self.callbacks[ptype].append(callback_info) + + def add_id_callback(self, pid, callback, *args, **kwargs): + """ + add_id_callback(pid, callback) -> None + + Add a function to be called when a packet with id pid is received. + """ + + if not pid in self.id_callbacks: + self.id_callbacks[pid] = [] + + callback_info = { + "callback": callback, + "args": args, + "kwargs": kwargs + } + + self.id_callbacks[pid].append(callback_info) + + def call_callback(self, event, *args): + """ + call_callback(event) -> None + + Call all callbacks subscribed to the event with *args. + """ + + if event in self.callbacks: + for c_info in self.callbacks[event]: + c = c_info["callback"] + args = c_info["args"] + args + kwargs = c_info["kwargs"] + c(*args, **kwargs) + + def call_id_callback(self, pid, *args): + """ + call_callback(pid) -> None + + Call all callbacks subscribed to the pid with *args. + """ + + if pid in self.id_callbacks: + for c_info in self.id_callbacks.pop(pid): + c = c_info["callback"] + args = c_info["args"] + args + kwargs = c_info["kwargs"] + c(*args, **kwargs) + + def handle_json(self, data): + """ + handle_json(data) -> None + + Handle incoming 'raw' data. + """ + + packet = json.loads(data) + self.handle_packet(packet) + + def handle_packet(self, packet): + """ + handle_packet(ptype, data) -> None + + Handle incoming packets + """ + + if "data" in packet: + data = packet["data"] + else: + data = None + + self.call_callback(packet["type"], data) + + if "id" in packet: + self.call_id_callback(packet["id"], data) + + def send_json(self, data): + """ + send_json(data) -> None + + Send 'raw' json. + """ + + if self.ws: + try: + self.ws.send(json.dumps(data)) + except WSException: + self.disconnect() + + def send_packet(self, ptype, **kwargs): + """ + send_packet(ptype, **kwargs) -> None + + Send a formatted packet. + """ + + packet = { + "type": ptype, + "data": kwargs or None, + "id": str(self.send_id) + } + self.send_id += 1 + self.send_json(packet) diff --git a/message.py b/message.py new file mode 100644 index 0000000..ae3db19 --- /dev/null +++ b/message.py @@ -0,0 +1,87 @@ +import time + +from . import session + +class Message(): + """ + This class keeps track of message details. + """ + + def __init__(self, message): + """ + message - A euphoria message: http://api.euphoria.io/#message + """ + + self.message = message + self.session = session.Session(message["sender"]) + + def id(self): + """ + id() -> str + + The message's unique id. + """ + + return self.message["id"] + + def parent(self): + """ + parent() -> str + + The message's parent's unique id. + """ + + if "parent" in self.message: + return self.message["parent"] + + def content(self): + """ + content() -> str + + The message's content. + """ + + return self.message["content"] + + def sender(self): + """ + sender() -> Session + + The sender of the message. + """ + + return self.session + + def time(self): + """ + time() -> int + + Unix epoch timestamp of when the message was posted. + """ + + return self.message["time"] + + def time_formatted(self, date=False): + """ + time_formatted(date=False) -> str + + date - include date in format + + Time in a readable format: + With date: YYYY-MM-DD HH:MM:SS + Without date: HH:MM:SS + """ + + if date: + return time.strftime("%Y-%m-%d %H:%M:%S", time.gmtime(self.time())) + else: + return time.strftime("%H:%M:%S", time.gmtime(self.time())) + + def deleted(self): + """ + deleted() -> bool + + Is this message deleted? + """ + + return True if "deleted" in self.message and self.message["deleted"] else False diff --git a/messages.py b/messages.py new file mode 100644 index 0000000..1b61b07 --- /dev/null +++ b/messages.py @@ -0,0 +1,79 @@ +from . import message + +class Messages(): + """ + Message storage class which preserves thread hierarchy. + """ + + def __init__(self): + self.by_id = {} + self.by_parent = {} + + def add_raw(self, raw_message): + """ + add_raw(raw_message) -> None + + Create a message from raw data and add it. + """ + + mes = message.Message(raw_message) + + self.add(mes) + + def add(self, mes): + """ + add(message) -> None + + Add a message to the structure. + """ + + self.remove(mes.id()) + + self.by_id[mes.id()] = mes + + if mes.parent(): + if not mes.parent() in self.by_parent: + self.by_parent[parent] = [] + self.by_parent[mes.parent()].append(mes) + + def remove(self, mes): + """ + remove(message) -> None + + Remove a message from the structure. + """ + + if mes.id() in self.by_id: + self.by_id.pop(mes.id()) + + if mes.parent() and mes in self.get_children(mes.parent()): + self.by_parent[mes.parent()].remove(mes) + + def get(self, mid): + """ + get(message_id) -> Message + + Returns the message with the given id, if found. + """ + + if mid in self.by_id: + return self.by_id[mid] + + def get_parent(self, mes): + """ + get_parent(message) -> str + + Returns the message's parent. + Returns None if no parent was found. + """ + + return self.get(mes.parent()) + + def get_children(self, mes): + """ + get_children(message) -> list + + Returns a list of children of the given message. + """ + + return self.by_parent[mes.id()] diff --git a/room.py b/room.py new file mode 100644 index 0000000..17b69b4 --- /dev/null +++ b/room.py @@ -0,0 +1,9 @@ +import connection + +class Room(): + """ + TODO + """ + + def __init__(self): + self.messages = [] \ No newline at end of file diff --git a/session.py b/session.py new file mode 100644 index 0000000..b8f9f73 --- /dev/null +++ b/session.py @@ -0,0 +1,118 @@ +class Session(): + """ + This class keeps track of session details. + """ + + def __init__(self, session): + """ + session - a euphoria SessionView: http://api.euphoria.io/#sessionview + """ + + self.session = session + + def session_type(self): + """ + session_type() -> str + + The session's type (bot, account, agent). + """ + + return self.user_id().split(":")[0] + + def user_id(self): + """ + user_id() -> str + + The user's id. + """ + + return self.session["id"] + + def session_id(self): + """ + session_id() -> str + + Returns the session's id. + """ + + return self.session["session_id"] + + def name(self): + """ + name() -> str + + The user's name. + """ + + return self.session["name"] + + def mentionable(self): + """ + mentionable() -> str + + Converts the name to a mentionable format. + """ + + return "".join(c for c in self.name() if not c in ",.!?;&<'\"" and not c.isspace()) + + def listable(self, width): + """ + listable(width): -> prefixes, name + + Prefixes and name which together are characters long or shorter. + """ + + prefixes = "" + if self.session_type() == "account": + prefixes += "*" + if self.is_manager(): + prefixes += "m" + if self.is_staff(): + prefixes += "s" + + name = self.name() + if len(prefixes + name) > width: + name = name[:width - len(prefixes) - 1] + "…" + + return prefixes, name + + def server_id(self): + """ + server_id() -> server_id + + The session's server id. + """ + + return self.session["server_id"] + + def server_era(self): + """ + server_era() -> server_era + + The session's server era. + """ + + return self.session["server_era"] + + def is_staff(self): + """ + is_staff() -> staff + + Is a user staff? + """ + if "is_staff" in self.session: + return self.session["is_staff"] + else: + return False + + def is_manager(self): + """ + is_manager() -> manager + + Is a user manager? + """ + if "is_manager" in self.session: + return self.session["is_manager"] + else: + return False + \ No newline at end of file diff --git a/sessions.py b/sessions.py new file mode 100644 index 0000000..5bb6c57 --- /dev/null +++ b/sessions.py @@ -0,0 +1,104 @@ +from . import session + +class Sessions(): + """ + Keeps track of sessions. + """ + + def __init__(self): + """ + TODO + """ + self.sessions = {} + + def add_raw(self, raw_session): + """ + add_raw(raw_session) -> None + + Create a session from raw data and add it. + """ + + ses = session.Session(raw_session) + + self.sessions[ses.session_id()] = ses + + def add(self, ses): + """ + add(session) -> None + + Add a session. + """ + + self.sessions[ses.session_id()] = ses + + def remove(self, ses): + """ + remove(session_id) -> None + + Remove a session. + """ + + if ses.session_id() in self.sessions: + self.sessions.pop(ses.session_id()) + + def remove_on_network_partition(self, server_id, server_era): + """ + remove_on_network_partition(server_id, server_era) -> None + + Removes all sessions matching the server_id/server_era combo. + http://api.euphoria.io/#network-event + """ + + for ses in self.sessions: + if ses.server_id() == server_id and ses.server_era() == server_era: + self.remove(ses) + + def get_people(self): + """ + get_people() -> list + + Returns a list of all non-bot and non-lurker sessions. + """ + + # not a list comprehension because that would span several lines too + people = [] + for ses in self.sessions: + if ses.session_type() in ["agent", "account"] and ses.name(): + people.append(ses) + return people + + def get_accounts(self): + """ + get_accounts() -> list + + Returns a list of all logged-in sessions. + """ + + return [ses for ses in self.sessions if ses.session_type() == "account" and ses.name()] + + def get_agents(self): + """ + get_agents() -> list + + Returns a list of all sessions who are not signed into an account and not bots or lurkers. + """ + + return [ses for ses in self.sessions if ses.session_type() == "agent" and ses.name()] + + def get_bots(self): + """ + get_bots() -> list + + Returns a list of all bot sessions. + """ + + return [ses for ses in self.sessions if ses.session_type() == "bot" and ses.name()] + + def get_lurkers(self): + """ + get_lurkers() -> list + + Returns a list of all lurker sessions. + """ + + return [ses for ses in self.sessions if not ses.name()]