Start rewrite (yet again)

This will hopefully be the final rewrite.
This commit is contained in:
Joscha 2019-04-06 09:02:09 +00:00
parent 5e108fd31b
commit a5af01f669
19 changed files with 455 additions and 1344 deletions

14
.gitignore vendored
View file

@ -1,2 +1,12 @@
**/__pycache__/ # python stuff
*.cookie */__pycache__/
# venv stuff
bin/
include/
lib/
lib64
pyvenv.cfg
# mypy stuff
.mypy_cache/

26
example.py Normal file
View file

@ -0,0 +1,26 @@
import yyb
class MyClient(yyb.Client):
async def on_join(self, room):
await room.say("Hello!")
async def on_message(self, message):
if message.content == "reply to me"):
reply = await message.reply("reply")
await reply.reply("reply to the reply")
await message.room.say("stuff going on")
elif message.content == "hey, join &test!":
# returns room in phase 3, or throws JoinException
room = await self.join("test")
if room:
room.say("hey, I joined!")
else:
message.reply("didn't work :(")
async def before_part(self, room):
await room.say("Goodbye!")
# Something like this, I guess. It's still missing password fields though.
c = MyClient("my:bot:")
c.run("test", "bots")

View file

@ -1,9 +0,0 @@
[general]
nick = ExampleBot
cookiefile = examplebot.cookie
[rooms]
# Format:
# room
# room=password
test

View file

@ -1,48 +0,0 @@
import asyncio
import configparser
import logging
import yaboli
from yaboli.utils import *
class ExampleBot(yaboli.Bot):
async def on_command_specific(self, room, message, command, nick, argstr):
long_help = (
"I'm an example bot for the yaboli bot library,"
" which can be found at https://github.com/Garmelon/yaboli"
)
if similar(nick, room.session.nick) and not argstr:
await self.botrulez_ping(room, message, command, text="ExamplePong!")
await self.botrulez_help(room, message, command, text=long_help)
await self.botrulez_uptime(room, message, command)
await self.botrulez_kill(room, message, command)
await self.botrulez_restart(room, message, command)
async def on_command_general(self, room, message, command, argstr):
short_help = "Example bot for the yaboli bot library"
if not argstr:
await self.botrulez_ping(room, message, command, text="ExamplePong!")
await self.botrulez_help(room, message, command, text=short_help)
def main(configfile):
logging.basicConfig(level=logging.INFO)
config = configparser.ConfigParser(allow_no_value=True)
config.read(configfile)
nick = config.get("general", "nick")
cookiefile = config.get("general", "cookiefile", fallback=None)
bot = ExampleBot(nick, cookiefile=cookiefile)
for room, password in config.items("rooms"):
if not password:
password = None
bot.join_room(room, password=password)
asyncio.get_event_loop().run_forever()
if __name__ == "__main__":
main("examplebot.conf")

22
info.txt Normal file
View file

@ -0,0 +1,22 @@
Signature of a normal function:
def a(b: int, c: str) -> bool:
pass
a # type: Callable[[int, str], bool]
Signature of an async function:
async def a(b: int, c: str) -> bool:
pass
a # type: Callable[[int, str], Awaitable[bool]]
Enable logging (from the websockets docs):
import logging
logger = logging.getLogger('websockets')
logger.setLevel(logging.INFO)
logger.addHandler(logging.StreamHandler())

3
mypy.ini Normal file
View file

@ -0,0 +1,3 @@
[mypy]
disallow_untyped_defs = True
disallow_incomplete_defs = True

1
requirements.txt Normal file
View file

@ -0,0 +1 @@
websockets==7.0

View file

@ -1,17 +1,21 @@
from .bot import * from typing import List
from .cookiejar import *
from .connection import *
from .database import *
from .exceptions import *
from .room import *
from .utils import *
__all__ = ( __all__: List[str] = []
bot.__all__ +
connection.__all__ + from .client import *
cookiejar.__all__ + __all__ += client.__all__
database.__all__ +
exceptions.__all__ + from .exceptions import *
room.__all__ + __all__ += client.__all__
utils.__all__
) from .message import *
__all__ += exceptions.__all__
from .room import *
__all__ += message.__all__
__all__ += room.__all__
from .user import *
__all__ += user.__all__
from .util import *

View file

@ -1,271 +0,0 @@
import logging
import re
import time
from .cookiejar import *
from .room import *
from .utils import *
logger = logging.getLogger(__name__)
__all__ = ["Bot", "command", "trigger", "Module", "ModuleBot"]
# Some command stuff
SPECIFIC_RE = re.compile(r"!(\S+)\s+@(\S+)\s*([\S\s]*)")
GENERAL_RE = re.compile(r"!(\S+)\s*([\S\s]*)")
# Decorator magic for commands and triggers.
# I think commands could probably be implemented as some kind of triggers,
# but I'm not gonna do that now because commands are working fine this way.
def command(*commands):
def decorator(func):
async def wrapper(self, room, message, command, *args, **kwargs):
if command in commands:
await func(self, room, message, *args, **kwargs)
return True
else:
return False
return wrapper
return decorator
def trigger(regex, fullmatch=True, flags=0):
def decorator(func):
compiled_regex = re.compile(regex, flags=flags)
async def wrapper(self, room, message, *args, **kwargs):
if fullmatch:
match = compiled_regex.fullmatch(message.content)
else:
match = compiled_regex.match(message.content)
if match is not None:
await func(self, room, message, match, *args, **kwargs)
return True
else:
return False
return wrapper
return decorator
# And now comes the real bot...
class Bot(Inhabitant):
def __init__(self, nick, cookiefile=None):
self.target_nick = nick
self.rooms = {}
self.cookiejar = CookieJar(cookiefile)
# ROOM MANAGEMENT
def join_room(self, roomname, **kwargs):
if roomname in self.rooms:
return
self.rooms[roomname] = Room(self, roomname, self.target_nick, cookiejar=self.cookiejar, **kwargs)
async def part_room(self, roomname):
room = self.rooms.pop(roomname, None)
if room:
await room.exit()
# COMMANDS
async def on_command_specific(self, room, message, command, nick, argstr):
pass
async def on_command_general(self, room, message, command, argstr):
pass
# INHABITED FUNCTIONS
async def on_send(self, room, message):
match = SPECIFIC_RE.fullmatch(message.content)
if match:
command, nick, argstr = match.groups()
await self.on_command_specific(room, message, command, nick, argstr)
match = GENERAL_RE.fullmatch(message.content)
if match:
command, argstr = match.groups()
await self.on_command_general(room, message, command, argstr)
async def on_stopped(self, room):
await self.part_room(room.roomname)
# BOTRULEZ
@command("ping")
async def botrulez_ping(self, room, message, text="Pong!"):
await room.send(text, message.mid)
@command("help")
async def botrulez_help(self, room, message, text="Placeholder help text"):
await room.send(text, message.mid)
@command("uptime")
async def botrulez_uptime(self, room, message):
now = time.time()
startformat = format_time(room.start_time)
deltaformat = format_time_delta(now - room.start_time)
text = f"/me has been up since {startformat} ({deltaformat})"
await room.send(text, message.mid)
@command("kill")
async def botrulez_kill(self, room, message, text="/me dies"):
await room.send(text, message.mid)
await self.part_room(room.roomname)
@command("restart")
async def botrulez_restart(self, room, message, text="/me restarts"):
await room.send(text, message.mid)
await self.part_room(room.roomname)
self.join_room(room.roomname, password=room.password)
# COMMAND PARSING
@staticmethod
def parse_args(text):
"""
Use bash-style single- and double-quotes to include whitespace in arguments.
A backslash always escapes the next character.
Any non-escaped whitespace separates arguments.
Returns a list of arguments.
Deals with unclosed quotes and backslashes without crashing.
"""
escape = False
quote = None
args = []
arg = ""
for character in text:
if escape:
arg += character
escape = False
elif character == "\\":
escape = True
elif quote:
if character == quote:
quote = None
else:
arg += character
elif character in "'\"":
quote = character
elif character.isspace():
if len(arg) > 0:
args.append(arg)
arg = ""
else:
arg += character
#if escape or quote:
#return None # syntax error
if escape:
arg += "\\"
if len(arg) > 0:
args.append(arg)
return args
@staticmethod
def parse_flags(arglist):
flags = ""
args = []
kwargs = {}
for arg in arglist:
# kwargs (--abc, --foo=bar)
if arg[:2] == "--":
arg = arg[2:]
if "=" in arg:
s = arg.split("=", maxsplit=1)
kwargs[s[0]] = s[1]
else:
kwargs[arg] = None
# flags (-x, -rw)
elif arg[:1] == "-":
arg = arg[1:]
flags += arg
# args (normal arguments)
else:
args.append(arg)
return flags, args, kwargs
@staticmethod
def _parse_command(content, specific=None):
if specific:
match = SPECIFIC_RE.fullmatch(content)
if match:
return match.group(1), match.group(3)
else:
match = GENERAL_RE.fullmatch(content)
if match:
return match.group(1), match.group(2)
class Module(Inhabitant):
SHORT_DESCRIPTION = "short module description"
LONG_DESCRIPTION = "long module description"
SHORT_HELP = "short !help"
LONG_HELP = "long !help"
async def on_command_specific(self, room, message, command, nick, argstr, mentioned):
pass
async def on_command_general(self, room, message, command, argstr):
pass
class ModuleBot(Bot):
def __init__(self, module, nick, *args, cookiefile=None, **kwargs):
super().__init__(nick, cookiefile=cookiefile)
self.module = module
async def on_created(self, room):
await self.module.on_created(room)
async def on_connected(self, room, log):
await self.module.on_connected(room, log)
async def on_disconnected(self, room):
await self.module.on_disconnected(room)
async def on_stopped(self, room):
await self.module.on_stopped(room)
async def on_join(self, room, session):
await self.module.on_join(room, session)
async def on_part(self, room, session):
await self.module.on_part(room, session)
async def on_nick(self, room, sid, uid, from_nick, to_nick):
await self.module.on_nick(room, sid, uid, from_nick, to_nick)
async def on_send(self, room, message):
await super().on_send(room, message)
await self.module.on_send(room, message)
async def on_command_specific(self, room, message, command, nick, argstr):
if similar(nick, room.session.nick):
await self.module.on_command_specific(room, message, command, nick, argstr, True)
if not argstr:
await self.botrulez_ping(room, message, command)
await self.botrulez_help(room, message, command, text=self.module.LONG_HELP)
await self.botrulez_uptime(room, message, command)
await self.botrulez_kill(room, message, command)
await self.botrulez_restart(room, message, command)
else:
await self.module.on_command_specific(room, message, command, nick, argstr, False)
async def on_command_general(self, room, message, command, argstr):
await self.module.on_command_general(room, message, command, argstr)
if not argstr:
await self.botrulez_ping(room, message, command)
await self.botrulez_help(room, message, command, text=self.module.SHORT_HELP)

23
yaboli/client.py Normal file
View file

@ -0,0 +1,23 @@
from .message import Message
from .room import Room
from .user import User
from typing import List, Optional
__all__ = ["Client"]
class Client:
# Joining and leaving rooms
async def join(self,
room_name: str,
password: str = None,
nick: str = None) -> Room:
pass
async def get(self, room_name: str) -> Optional[Room]:
pass
async def get_all(self, room_name: str) -> List[Room]:
pass

View file

@ -1,229 +0,0 @@
import asyncio
import json
import logging
import socket
import websockets
from .exceptions import *
logger = logging.getLogger(__name__)
__all__ = ["Connection"]
class Connection:
def __init__(self, url, packet_callback, disconnect_callback, stop_callback, cookiejar=None, ping_timeout=10, ping_delay=30, reconnect_attempts=10):
self.url = url
self.packet_callback = packet_callback
self.disconnect_callback = disconnect_callback
self.stop_callback = stop_callback # is called when the connection stops on its own
self.cookiejar = cookiejar
self.ping_timeout = ping_timeout # how long to wait for websocket ping reply
self.ping_delay = ping_delay # how long to wait between pings
self.reconnect_attempts = reconnect_attempts
self._ws = None
self._pid = 0 # successive packet ids
#self._spawned_tasks = set()
self._pending_responses = {}
self._stopped = False
self._pingtask = None
self._runtask = asyncio.ensure_future(self._run())
# ... aaand the connection is started.
async def send(self, ptype, data=None, await_response=True):
if not self._ws:
raise ConnectionClosed
#raise asyncio.CancelledError
pid = str(self._new_pid())
packet = {
"type": ptype,
"id": pid
}
if data:
packet["data"] = data
if await_response:
wait_for = self._wait_for_response(pid)
logging.debug(f"Currently used websocket at self._ws: {self._ws}")
try:
await self._ws.send(json.dumps(packet, separators=(',', ':'))) # minimum size
except websockets.ConnectionClosed:
raise ConnectionClosed()
if await_response:
await wait_for
return wait_for.result()
async def stop(self):
"""
Close websocket connection and wait for running task to stop.
No connection function are to be called after calling stop().
This means that stop() can only be called once.
"""
if not self._stopped:
self._stopped = True
await self.reconnect() # _run() does the cleaning up now.
await self._runtask
async def reconnect(self):
"""
Reconnect to the url.
"""
if self._ws:
await self._ws.close()
async def _connect(self, tries, timeout=10):
"""
Attempt to connect to a room.
If the Connection is already connected, it attempts to reconnect.
Returns True on success, False on failure.
If tries is None, connect retries infinitely.
The delay between connection attempts doubles every attempt (starts with 1s).
"""
# Assumes _disconnect() has already been called in _run()
delay = 1 # seconds
while True:
try:
if self.cookiejar:
cookies = [("Cookie", cookie) for cookie in self.cookiejar.sniff()]
ws = asyncio.ensure_future(
websockets.connect(self.url, max_size=None, extra_headers=cookies)
)
else:
ws = asyncio.ensure_future(
websockets.connect(self.url, max_size=None)
)
self._ws = await asyncio.wait_for(ws, timeout)
except (websockets.InvalidHandshake, socket.gaierror, asyncio.TimeoutError): # not websockets.InvalidURI
logger.warn(f"Connection attempt failed, {tries} tries left.")
self._ws = None
if tries is not None:
tries -= 1
if tries <= 0:
logger.warn(f"{self.url}:Ran out of tries")
return False
await asyncio.sleep(delay)
delay *= 2
else:
if self.cookiejar:
for set_cookie in self._ws.response_headers.get_all("Set-Cookie"):
self.cookiejar.bake(set_cookie)
self.cookiejar.save()
self._pingtask = asyncio.ensure_future(self._ping())
return True
async def _disconnect(self):
"""
Disconnect and clean up all "residue", such as:
- close existing websocket connection
- cancel all pending response futures with a ConnectionClosed exception
- reset package ID counter
- make sure the ping task has finished
"""
asyncio.ensure_future(self.disconnect_callback())
# stop ping task
if self._pingtask:
self._pingtask.cancel()
await self._pingtask
self._pingtask = None
if self._ws:
await self._ws.close()
self._ws = None
self._pid = 0
# clean up pending response futures
for _, future in self._pending_responses.items():
logger.debug(f"Cancelling future with ConnectionClosed: {future}")
future.set_exception(ConnectionClosed("No server response"))
self._pending_responses = {}
async def _run(self):
"""
Listen for packets and deal with them accordingly.
"""
while not self._stopped:
logger.debug(f"{self.url}:Connecting...")
connected = await self._connect(self.reconnect_attempts)
if connected:
logger.debug(f"{self.url}:Connected")
try:
while True:
await self._handle_next_message()
except websockets.ConnectionClosed:
pass
finally:
await self._disconnect() # disconnect and clean up
else:
logger.debug(f"{self.url}:Stopping")
asyncio.ensure_future(self.stop_callback)
self._stopped = True
await self._disconnect()
async def _ping(self):
"""
Periodically ping the server to detect a timeout.
"""
try:
while True:
logger.debug(f"{self.url}:Pinging...")
wait_for_reply = await self._ws.ping()
await asyncio.wait_for(wait_for_reply, self.ping_timeout)
logger.debug(f"{self.url}:Pinged!")
await asyncio.sleep(self.ping_delay)
except asyncio.TimeoutError:
logger.warning(f"{self.url}:Ping timed out")
await self.reconnect()
except (websockets.ConnectionClosed, ConnectionResetError, asyncio.CancelledError):
pass
def _new_pid(self):
self._pid += 1
return self._pid
async def _handle_next_message(self):
response = await self._ws.recv()
packet = json.loads(response)
ptype = packet.get("type")
data = packet.get("data", None)
error = packet.get("error", None)
if packet.get("throttled", False):
throttled = packet.get("throttled_reason")
else:
throttled = None
# Deal with pending responses
pid = packet.get("id", None)
future = self._pending_responses.pop(pid, None)
if future:
future.set_result((ptype, data, error, throttled))
# Pass packet onto room
asyncio.ensure_future(self.packet_callback(ptype, data, error, throttled))
def _wait_for_response(self, pid):
future = asyncio.Future()
self._pending_responses[pid] = future
return future

View file

@ -1,74 +0,0 @@
import contextlib
import http.cookies as cookies
import logging
logger = logging.getLogger(__name__)
__all__ = ["CookieJar"]
class CookieJar:
"""
Keeps your cookies in a file.
"""
def __init__(self, filename=None):
self._filename = filename
self._cookies = cookies.SimpleCookie()
if not self._filename:
logger.warning("Could not load cookies, no filename given.")
return
with contextlib.suppress(FileNotFoundError):
logger.info(f"Loading cookies from {self._filename!r}")
with open(self._filename, "r") as f:
for line in f:
self._cookies.load(line)
def sniff(self):
"""
Returns a list of Cookie headers containing all current cookies.
"""
return [morsel.OutputString(attrs=[]) for morsel in self._cookies.values()]
def bake(self, cookie_string):
"""
Parse cookie and add it to the jar.
Does not automatically save to the cookie file.
Example cookie: "a=bcd; Path=/; Expires=Wed, 24 Jul 2019 14:57:52 GMT; HttpOnly; Secure"
"""
logger.debug(f"Baking cookie: {cookie_string!r}")
self._cookies.load(cookie_string)
def save(self):
"""
Saves all current cookies to the cookie jar file.
"""
if not self._filename:
logger.warning("Could not save cookies, no filename given.")
return
logger.info(f"Saving cookies to {self._filename!r}")
with open(self._filename, "w") as f:
for morsel in self._cookies.values():
cookie_string = morsel.OutputString()
#f.write(f"{cookie_string}\n")
f.write(cookie_string)
f.write("\n")
def monster(self):
"""
Removes all cookies from the cookie jar.
Does not automatically save to the cookie file.
"""
logger.debug("OMNOMNOM, cookies are all gone!")
self._cookies = cookies.SimpleCookie()

View file

@ -1,38 +0,0 @@
import asyncio
import logging
import sqlite3
from .utils import *
logger = logging.getLogger(__name__)
__all__ = ["Database", "operation"]
def operation(func):
async def wrapper(self, *args, **kwargs):
async with self as db:
while True:
try:
return await asyncify(func, self, db, *args, **kwargs)
except sqlite3.OperationalError as e:
logger.warn(f"Operational error encountered: {e}")
await asyncio.sleep(5)
return wrapper
class Database:
def __init__(self, database):
self._connection = sqlite3.connect(database, check_same_thread=False)
self._lock = asyncio.Lock()
self.initialize(self._connection)
def initialize(self, db):
pass
async def __aenter__(self, *args, **kwargs):
await self._lock.__aenter__(*args, **kwargs)
return self._connection
async def __aexit__(self, *args, **kwargs):
return await self._lock.__aexit__(*args, **kwargs)

View file

@ -1,13 +1,51 @@
__all__ = ["ConnectionClosed"] __all__ = ["EuphException", "JoinException", "CouldNotConnectException",
"CouldNotAuthenticateException", "RoomClosedException",
"RateLimitException", "NotLoggedInException", "UnauthorizedException"]
class ConnectionClosed(Exception): class EuphException(Exception):
pass pass
class RoomException(Exception): # Joining a room
pass
class AuthenticationRequired(RoomException): class JoinException(EuphException):
pass """
An exception that happened while joining a room.
"""
pass
class RoomClosed(RoomException): class CouldNotConnectException(JoinException):
pass """
Could not establish a websocket connection to euphoria.
"""
pass
class CouldNotAuthenticateException(JoinException):
"""
The password is either incorrect or not set, even though authentication is
required.
"""
pass
# Doing stuff in a room
class RoomClosedException(EuphException):
"""
The room has been closed already.
This means that phase 4 (see the docstring of Room) has been initiated or
completed.
"""
pass
# exception for having no username?
# Maybe these will become real exceptions one day?
class RateLimitException(EuphException):
pass
class NotLoggedInException(EuphException):
pass
class UnauthorizedException(EuphException):
pass

108
yaboli/message.py Normal file
View file

@ -0,0 +1,108 @@
from .user import User, LiveUser
from typing import TYPE_CHECKING, Optional
import datetime
if TYPE_CHECKING:
from .client import Client
from .room import Room
__all__ = ["Message", "LiveMessage"]
# "Offline" message
class Message:
def __init__(self,
room_name: str,
id_: str,
parent_id: Optional[str],
timestamp: int,
sender: User,
content: str,
deleted: bool,
truncated: bool):
self._room_name = room_name
self._id = id_
self._parent_id = parent_id
self._timestamp = timestamp
self._sender = sender
self._content = content
self._deleted = deleted
self._truncated = truncated
@property
def room_name(self) -> str:
return self._room_name
@property
def id(self) -> str:
return self._id
@property
def parent_id(self) -> Optional[str]:
return self._parent_id
@property
def time(self) -> datetime.datetime:
return datetime.datetime.fromtimestamp(self.timestamp)
@property
def timestamp(self) -> int:
return self._timestamp
@property
def sender(self) -> User:
return self._sender
@property
def content(self) -> str:
return self._content
@property
def deleted(self) -> bool:
return self._deleted
@property
def truncated(self) -> bool:
return self._truncated
# "Online" message
# has a few nice functions
class LiveMessage(Message):
def __init__(self,
client: 'Client',
room: 'Room',
id_: str,
parent_id: Optional[str],
timestamp: int,
sender: LiveUser,
content: str,
deleted: bool,
truncated: bool):
self._client = client
super().__init__(room.name, id_, parent_id, timestamp, sender, content,
deleted, truncated)
self._room = room
# The typechecker can't use self._sender directly, because it has type
# User.
#
# TODO Find a way to satisfy the type checker without having this
# duplicate around, if possible?
self._livesender = sender
@property
def room(self) -> 'Room':
return self._room
@property
def sender(self) -> LiveUser:
return self._livesender
async def reply(self, text: str) -> None:
pass
# TODO add some sort of permission guard that checks the room
# UnauthorizedException
async def delete(self,
deleted: bool = True
) -> None:
pass

View file

@ -1,443 +1,107 @@
import asyncio
import logging
import time
from .connection import *
from .exceptions import * from .exceptions import *
from .utils import * from .message import LiveMessage
from .user import LiveUser
from typing import List, Optional
logger = logging.getLogger(__name__) __all__ = ["Room"]
__all__ = ["Room", "Inhabitant"]
class Room: class Room:
""" """
TODO A Room represents one connection to a room on euphoria, i. e. what other
""" implementations might consider a "client". This means that each Room has
its own session (User) and nick.
CONNECTED = 1
DISCONNECTED = 2
CLOSED = 3
FORWARDING = 4
def __init__(self, inhabitant, roomname, nick, password=None, human=False, cookiejar=None, **kwargs):
# TODO: Connect to room etc.
# TODO: Deal with room/connection states of:
# disconnected connecting, fast-forwarding, connected
# Room info (all fields readonly!)
self.target_nick = nick
self.roomname = roomname
self.password = password
self.human = human
self.session = None
self.account = None
self.listing = Listing()
self.start_time = time.time()
self.account_has_access = None
self.account_email_verified = None
self.room_is_private = None
self.version = None # the version of the code being run and served by the server
self.pm_with_nick = None
self.pm_with_user_id = None
self._inhabitant = inhabitant
self._status = Room.DISCONNECTED
self._connected_future = asyncio.Future()
self._last_known_mid = None
self._forwarding = None # task that downloads messages and fowards
self._forward_new = [] # new messages received while downloading old messages
# TODO: Allow for all parameters of Connection() to be specified in Room().
self._connection = Connection(
self.format_room_url(self.roomname, human=self.human),
self._receive_packet,
self._disconnected,
self._stopped,
cookiejar,
**kwargs
)
asyncio.ensure_future(self._inhabitant.on_created(self))
async def exit(self):
self._status = Room.CLOSED
await self._connection.stop()
# ROOM COMMANDS
# These always return a response from the server.
# If the connection is lost while one of these commands is called,
# the command will retry once the bot has reconnected.
async def get_message(self, mid):
if self._status == Room.CLOSED:
raise RoomClosed()
ptype, data, error, throttled = await self._send_while_connected(
"get-message",
id=mid
)
if data:
return Message.from_dict(data)
# else: message does not exist
# The log returned is sorted from old to new
async def log(self, n, before=None):
if self._status == Room.CLOSED:
raise RoomClosed()
if before:
ptype, data, error, throttled = await self._send_while_connected(
"log",
n=n,
before=before
)
else:
ptype, data, error, throttled = await self._send_while_connected(
"log",
n=n
)
return [Message.from_dict(d) for d in data.get("log")]
async def nick(self, nick):
if self._status == Room.CLOSED:
raise RoomClosed()
self.target_nick = nick
ptype, data, error, throttled = await self._send_while_connected(
"nick",
name=nick
)
sid = data.get("session_id")
uid = data.get("id")
from_nick = data.get("from")
to_nick = data.get("to")
self.session.nick = to_nick
return sid, uid, from_nick, to_nick
async def pm(self, uid):
if self._status == Room.CLOSED:
raise RoomClosed()
ptype, data, error, throttled = await self._send_while_connected(
"pm-initiate",
user_id=uid
)
# Just ignoring non-authenticated errors
pm_id = data.get("pm_id")
to_nick = data.get("to_nick")
return pm_id, to_nick
async def send(self, content, parent=None):
if parent:
ptype, data, error, throttled = await self._send_while_connected(
"send",
content=content,
parent=parent
)
else:
ptype, data, error, throttled = await self._send_while_connected(
"send",
content=content
)
message = Message.from_dict(data)
self._last_known_mid = message.mid
return message
async def who(self):
ptype, data, error, throttled = await self._send_while_connected("who")
self.listing = Listing.from_dict(data.get("listing"))
self.listing.add(self.session)
# COMMUNICATION WITH CONNECTION
async def _disconnected(self):
# While disconnected, keep the last known session info, listing etc.
# All of this is instead reset when the hello/snapshot events are received.
logger.warn(f"&{self.roomname}:Lost connection.")
self.status = Room.DISCONNECTED
self._connected_future = asyncio.Future()
if self._forwarding is not None:
self._forwarding.cancel()
await self._inhabitant.on_disconnected(self)
async def _stopped(self):
await self._inhabitant.on_stopped(self)
async def _receive_packet(self, ptype, data, error, throttled):
# Ignoring errors and throttling for now
functions = {
"bounce-event": self._event_bounce,
#"disconnect-event": self._event_disconnect, # Not important, can ignore
"hello-event": self._event_hello,
"join-event": self._event_join,
#"login-event": self._event_login,
#"logout-event": self._event_logout,
"network-event": self._event_network,
"nick-event": self._event_nick,
"edit-message-event": self._event_edit_message,
"part-event": self._event_part,
"ping-event": self._event_ping,
"pm-initiate-event": self._event_pm_initiate,
"send-event": self._event_send,
"snapshot-event": self._event_snapshot,
}
function = functions.get(ptype)
if function:
await function(data)
async def _event_bounce(self, data):
logger.info(f"&{self.roomname}:Received bounce-event")
if self.password is not None:
try:
data = {"type": "passcode", "passcode": self.password}
ptype, rdata, error, throttled = await self._connection.send("auth", data=data)
success = rdata.get("success")
if not success:
reason = rdata.get("reason")
logger.warn(f"&{self.roomname}:Authentication failed: {reason}")
raise AuthenticationRequired(f"Could not join &{self.roomname}:{reason}")
else:
logger.info(f"&{self.roomname}:Authentication successful")
except ConnectionClosed:
pass
else:
logger.warn(f"&{self.roomname}:Could not authenticate: Password unknown")
raise AuthenticationRequired(f"&{self.roomname} is password locked but no password was given")
async def _event_hello(self, data):
self.session = Session.from_dict(data.get("session"))
self.room_is_private = data.get("room_is_private")
self.version = data.get("version")
self.account = data.get("account", None)
self.account_has_access = data.get("account_has_access", None)
self.account_email_verified = data.get("account_email_verified", None)
self.listing.add(self.session)
async def _event_join(self, data):
session = Session.from_dict(data)
self.listing.add(session)
await self._inhabitant.on_join(self, session)
async def _event_network(self, data):
server_id = data.get("server_id")
server_era = data.get("server_era")
logger.debug(f"&{self.roomname}:Received network-event: server_id: {server_id!r}, server_era: {server_era!r}")
sessions = self.listing.remove_combo(server_id, server_era)
for session in sessions:
asyncio.ensure_future(self._inhabitant.on_part(self, session))
async def _event_nick(self, data):
sid = data.get("session_id")
uid = data.get("user_id")
from_nick = data.get("from")
to_nick = data.get("to")
session = self.listing.by_sid(sid)
if session:
session.nick = to_nick
await self._inhabitant.on_nick(self, sid, uid, from_nick, to_nick)
async def _event_edit_message(self, data):
message = Message.from_dict(data)
await self._inhabitant.on_edit(self, message)
async def _event_part(self, data):
session = Session.from_dict(data)
self.listing.remove(session.sid)
await self._inhabitant.on_part(self, session)
async def _event_ping(self, data):
try:
new_data = {"time": data.get("time")}
await self._connection.send( "ping-reply", data=new_data, await_response=False)
except ConnectionClosed:
pass
async def _event_pm_initiate(self, data):
from_uid = data.get("from")
from_nick = data.get("from_nick")
from_room = data.get("from_room")
pm_id = data.get("pm_id")
await self._inhabitant.on_pm(self, from_uid, from_nick, from_room, pm_id)
async def _event_send(self, data):
message = Message.from_dict(data)
if self._status == Room.FORWARDING:
logger.info(f"&{self.roomname}:Received new message while forwarding, adding to queue")
self._forward_new.append(message)
else:
self._last_known_mid = message.mid
await self._inhabitant.on_send(self, message)
# TODO: Figure out a way to bring fast-forwarding into this
async def _event_snapshot(self, data):
logger.debug(f"&{self.roomname}:Received snapshot-event, gained access to the room")
log = [Message.from_dict(m) for m in data.get("log")]
sessions = [Session.from_dict(d) for d in data.get("listing")]
# Update listing
self.listing = Listing()
for session in sessions:
self.listing.add(session)
self.listing.add(self.session)
# Update room info
self.pm_with_nick = data.get("pm_with_nick", None),
self.pm_with_user_id = data.get("pm_with_user_id", None)
self.session.nick = data.get("nick", None)
# Make sure a room is not CONNECTED without a nick
if self.target_nick and self.target_nick != self.session.nick:
logger.info(f"&{self.roomname}:Current nick doesn't match target nick {self.target_nick!r}, changing nick")
try:
_, nick_data, _, _ = await self._connection.send("nick", data={"name": self.target_nick})
self.session.nick = nick_data.get("to")
except ConnectionClosed:
return # Aww, we've lost connection again
# Now, we're finally connected again!
if self._last_known_mid is None:
logger.info(f"&{self.roomname}:Fully connected")
self._status = Room.CONNECTED
if log: # log goes from old to new
self._last_known_mid = log[-1].mid
else:
logger.info(f"&{self.roomname}:Not fully connected yet, starting message rewinding")
self._status = Room.FORWARDING
self._forward_new = []
if self._forwarding is not None:
self._forwarding.cancel()
self._forwarding = asyncio.ensure_future(self._forward(log))
if not self._connected_future.done(): # Should never be done already, I think
self._connected_future.set_result(None)
# Let's let the inhabitant know.
await self._inhabitant.on_connected(self, log)
# TODO: Figure out a way to bring fast-forwarding into this
# Should probably happen where this comment is
# SOME USEFUL PUBLIC METHODS
@staticmethod
def format_room_url(roomname, private=False, human=False):
if private:
roomname = f"pm:{roomname}"
url = f"wss://euphoria.io/room/{roomname}/ws"
if human:
url = f"{url}?h=1"
return url
async def connected(self):
await self._connected_future
# REST OF THE IMPLEMENTATION
async def _forward(self, log):
old_messages = []
while True:
found_last_known = True
for message in reversed(log):
if message.mid <= self._last_known_mid:
break
old_messages.append(message)
else:
found_last_known = False
if found_last_known:
break
log = await self.log(100, before=log[0].mid)
logger.info(f"&{self.roomname}:Reached last known message, forwarding through messages") A Room can only be used once in the sense that after it has been closed,
for message in reversed(old_messages): any further actions will result in a RoomClosedException. If you need to
self._last_known_mid = message.mid manually reconnect, instead just create a new Room object.
asyncio.ensure_future(self._inhabitant.on_forward(self, message))
for message in self._forward_new:
self._last_known_mid = message.mid
asyncio.ensure_future(self._inhabitant.on_forward(self, message))
logger.info(f"&{self.roomname}:Forwarding complete, fully connected")
self._forward_new = []
self._status = Room.CONNECTED
async def _send_while_connected(self, *args, **kwargs):
while True:
if self._status == Room.CLOSED:
raise RoomClosed()
try:
await self.connected()
return await self._connection.send(*args, data=kwargs)
except ConnectionClosed:
pass # just try again
class Inhabitant:
"""
TODO
"""
# ROOM EVENTS Life cycle of a Room
# These functions are called by the room when something happens.
# They're launched via asyncio.ensure_future(), so they don't block execution of the room.
# Just overwrite the events you need (make sure to keep the arguments the same though).
async def on_created(self, room): 1. create a new Room and register callbacks
pass 2. await join()
3. do room-related stuff
4. await part()
async def on_connected(self, room, log):
pass
async def on_disconnected(self, room):
pass
async def on_stopped(self, room): IN PHASE 1, a password and a starting nick can be set. The password and
pass current nick are used when first connecting to the room, or when
reconnecting to the room after connection was lost.
async def on_join(self, room, session): Usually, event callbacks are also registered during this phase.
pass
async def on_part(self, room, session):
pass
async def on_nick(self, room, sid, uid, from_nick, to_nick):
pass
async def on_send(self, room, message): IN PHASE 2, the Room creates the initial connection to euphoria and
pass performs initialisations (i. e. authentication or setting the nick) where
necessary. It also starts the Room's main event loop. The join() function
returns once one of the following cases has occurred:
async def on_forward(self, room, message): 1. the room is now in phase 3, in which case join() returns None
await self.on_send(room, message) 2. the room could not be joined, in which case one of the JoinExceptions is
returned
async def on_edit(self, room, message):
pass
async def on_pm(self, room, from_uid, from_nick, from_room, pm_id):
pass IN PHASE 3, the usual room-related functions like say() or nick() are
available. The Room's event loop is running.
The room will automatically reconnect if it loses connection to euphoria.
The usual room-related functions will block until the room has successfully
reconnected.
IN PHASE 4, the Room is disconnected and the event loop stopped. During and
after completion of this phase, the Room is considered closed. Any further
attempts to re-join or call room action functions will result in a
RoomClosedException.
"""
# Phase 1
def __init__(self,
room_name: str,
nick: str = None,
password: str = None):
pass
self.closed = False
# Phase 2
# Phase 3
def _ensure_open(self) -> None:
if self.closed:
raise RoomClosedException()
async def _ensure_joined(self) -> None:
pass
async def _ensure(self) -> None:
self._ensure_open()
await self._ensure_joined()
# Phase 4
# Other stuff
@property
def name(self) -> str:
pass
async def say(self,
text: str,
parent_id: Optional[str] = None
) -> LiveMessage:
pass
@property
def users(self) -> List[LiveUser]:
pass
# retrieving messages

91
yaboli/user.py Normal file
View file

@ -0,0 +1,91 @@
from .util import mention, atmention
from typing import TYPE_CHECKING
if TYPE_CHECKING:
from .client import Client
from .room import Room
__all__ = ["User", "LiveUser"]
class User:
def __init__(self,
room_name: str,
id_: str,
name: str,
is_staff: bool,
is_manager: bool):
self._room_name = room_name
self._id = id_
self._name = name
self._is_staff = is_staff
self._is_manager = is_manager
@property
def room_name(self) -> str:
return self._room_name
@property
def id(self) -> str:
return self._id
@property
def name(self) -> str:
# no name = empty str
return self._name
@property
def is_staff(self) -> bool:
return self._is_staff
@property
def is_manager(self) -> bool:
return self._is_manager
@property
def is_account(self) -> bool:
pass
@property
def is_agent(self) -> bool:
# TODO should catch all old ids too
pass
@property
def is_bot(self) -> bool:
pass
# TODO possibly add other fields
# Properties here? Yeah sure, why not?
@property
def mention(self) -> str:
return mention(self.name)
@property
def atmention(self) -> str:
return atmention(self.name)
class LiveUser(User):
def __init__(self,
client: 'Client',
room: 'Room',
id_: str,
name: str,
is_staff: bool,
is_manager: bool):
super().__init__(room.name, id_, name, is_staff, is_manager)
self._room = room
@property
def room(self) -> 'Room':
return self._room
# NotLoggedInException
async def pm(self) -> 'Room':
pass
# kick
# ban
# ip_ban

15
yaboli/util.py Normal file
View file

@ -0,0 +1,15 @@
__all__ = ["mention", "atmention", "normalize", "compare"]
# Name/nick related functions
def mention(name: str) -> str:
pass
def atmention(name: str) -> str:
pass
def normalize(name: str) -> str:
pass
def compare(name_a: str, name_b: str) -> bool:
pass

View file

@ -1,225 +0,0 @@
import asyncio
import logging
import re
import time
import functools
logger = logging.getLogger(__name__)
__all__ = [
"parallel", "asyncify",
"mention", "normalize", "similar",
"format_time", "format_time_delta",
"Session", "PersonalAccountView", "Listing", "Message",
]
# alias for parallel message sending
parallel = asyncio.ensure_future
async def asyncify(func, *args, **kwargs):
func_with_args = functools.partial(func, *args, **kwargs)
return await asyncio.get_event_loop().run_in_executor(None, func_with_args)
def mention(nick, ping=True):
nick = re.sub(r"""[,.!?;&<'"\s]""", "", nick)
return "@" + nick if ping else nick
def normalize(nick):
return mention(nick, ping=False).lower()
def similar(nick1, nick2):
return normalize(nick1) == normalize(nick2)
def format_time(timestamp):
return time.strftime(
"%Y-%m-%d %H:%M:%S UTC",
time.gmtime(timestamp)
)
def format_time_delta(delta):
if delta < 0:
result = "-"
else:
result = ""
delta = int(delta)
second = 1
minute = second*60
hour = minute*60
day = hour*24
if delta >= day:
result += f"{delta//day}d "
delta = delta%day
if delta >= hour:
result += f"{delta//hour}h "
delta = delta%hour
if delta >= minute:
result += f"{delta//minute}m "
delta = delta%minute
result += f"{delta}s"
return result
class Session:
def __init__(self, user_id, nick, server_id, server_era, session_id, is_staff=None,
is_manager=None, client_address=None, real_client_address=None):
self.user_id = user_id
self.nick = nick
self.server_id = server_id
self.server_era = server_era
self.session_id = session_id
self.is_staff = is_staff
self.is_manager = is_manager
self.client_address = client_address
self.real_client_address = real_client_address
@property
def uid(self):
return self.user_id
@uid.setter
def uid(self, new_uid):
self.user_id = new_uid
@property
def sid(self):
return self.session_id
@sid.setter
def sid(self, new_sid):
self.session_id = new_sid
@classmethod
def from_dict(cls, d):
return cls(
d.get("id"),
d.get("name"),
d.get("server_id"),
d.get("server_era"),
d.get("session_id"),
d.get("is_staff", None),
d.get("is_manager", None),
d.get("client_address", None),
d.get("real_client_address", None)
)
@property
def client_type(self):
# account, agent or bot
return self.user_id.split(":")[0]
class PersonalAccountView:
def __init__(self, account_id, name, email):
self.account_id = account_id
self.name = name
self.email = email
@property
def aid(self):
return self.account_id
@aid.setter
def aid(self, new_aid):
self.account_id = new_aid
class Listing:
def __init__(self):
self._sessions = {}
def __len__(self):
return len(self._sessions)
def add(self, session):
self._sessions[session.session_id] = session
def remove(self, session_id):
self._sessions.pop(session_id)
def remove_combo(self, server_id, server_era):
removed = [ses for ses in self._sessions.items()
if ses.server_id == server_id and ses.server_era == server_era]
self._sessions = {i: ses for i, ses in self._sessions.items()
if ses.server_id != server_id and ses.server_era != server_era}
return removed
def by_sid(self, session_id):
return self._sessions.get(session_id);
def by_uid(self, user_id):
return [ses for ses in self._sessions if ses.user_id == user_id]
def get(self, types=["agent", "account", "bot"], lurker=None):
sessions = []
for uid, ses in self._sessions.items():
if ses.client_type not in types:
continue
is_lurker = not ses.nick # "" or None
if lurker is None or lurker == is_lurker:
sessions.append(ses)
return sessions
@classmethod
def from_dict(cls, d):
listing = cls()
for session in d:
listing.add(Session.from_dict(session))
return listing
#def get_people(self):
#return self.get(types=["agent", "account"])
#def get_accounts(self):
#return self.get(types=["account"])
#def get_agents(self):
#return self.get(types=["agent"])
#def get_bots(self):
#return self.get(types=["bot"])
class Message():
def __init__(self, message_id, time, sender, content, parent=None, previous_edit_id=None,
encryption_key_id=None, edited=None, deleted=None, truncated=None):
self.message_id = message_id
self.time = time
self.sender = sender
self.content = content
self.parent = parent
self.previous_edit_id = previous_edit_id
self.encryption_key_id = encryption_key_id
self.edited = edited
self.deleted = deleted
self.truncated = truncated
@property
def mid(self):
return self.message_id
@mid.setter
def mid(self, new_mid):
self.message_id = new_mid
@classmethod
def from_dict(cls, d):
return cls(
d.get("id"),
d.get("time"),
Session.from_dict(d.get("sender")),
d.get("content"),
d.get("parent", None),
d.get("previous_edit_id", None),
d.get("encryption_key_id", None),
d.get("edited", None),
d.get("deleted", None),
d.get("truncated", None)
)