Start rewrite (yet again)
This will hopefully be the final rewrite.
This commit is contained in:
parent
5e108fd31b
commit
a5af01f669
19 changed files with 455 additions and 1344 deletions
14
.gitignore
vendored
14
.gitignore
vendored
|
|
@ -1,2 +1,12 @@
|
|||
**/__pycache__/
|
||||
*.cookie
|
||||
# python stuff
|
||||
*/__pycache__/
|
||||
|
||||
# venv stuff
|
||||
bin/
|
||||
include/
|
||||
lib/
|
||||
lib64
|
||||
pyvenv.cfg
|
||||
|
||||
# mypy stuff
|
||||
.mypy_cache/
|
||||
|
|
|
|||
26
example.py
Normal file
26
example.py
Normal 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")
|
||||
|
|
@ -1,9 +0,0 @@
|
|||
[general]
|
||||
nick = ExampleBot
|
||||
cookiefile = examplebot.cookie
|
||||
|
||||
[rooms]
|
||||
# Format:
|
||||
# room
|
||||
# room=password
|
||||
test
|
||||
|
|
@ -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
22
info.txt
Normal 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
3
mypy.ini
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
[mypy]
|
||||
disallow_untyped_defs = True
|
||||
disallow_incomplete_defs = True
|
||||
1
requirements.txt
Normal file
1
requirements.txt
Normal file
|
|
@ -0,0 +1 @@
|
|||
websockets==7.0
|
||||
|
|
@ -1,17 +1,21 @@
|
|||
from .bot import *
|
||||
from .cookiejar import *
|
||||
from .connection import *
|
||||
from .database import *
|
||||
from .exceptions import *
|
||||
from .room import *
|
||||
from .utils import *
|
||||
from typing import List
|
||||
|
||||
__all__ = (
|
||||
bot.__all__ +
|
||||
connection.__all__ +
|
||||
cookiejar.__all__ +
|
||||
database.__all__ +
|
||||
exceptions.__all__ +
|
||||
room.__all__ +
|
||||
utils.__all__
|
||||
)
|
||||
__all__: List[str] = []
|
||||
|
||||
from .client import *
|
||||
__all__ += client.__all__
|
||||
|
||||
from .exceptions import *
|
||||
__all__ += client.__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 *
|
||||
|
|
|
|||
271
yaboli/bot.py
271
yaboli/bot.py
|
|
@ -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
23
yaboli/client.py
Normal 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
|
||||
|
|
@ -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
|
||||
|
|
@ -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()
|
||||
|
|
@ -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)
|
||||
|
|
@ -1,13 +1,51 @@
|
|||
__all__ = ["ConnectionClosed"]
|
||||
__all__ = ["EuphException", "JoinException", "CouldNotConnectException",
|
||||
"CouldNotAuthenticateException", "RoomClosedException",
|
||||
"RateLimitException", "NotLoggedInException", "UnauthorizedException"]
|
||||
|
||||
class ConnectionClosed(Exception):
|
||||
class EuphException(Exception):
|
||||
pass
|
||||
|
||||
class RoomException(Exception):
|
||||
# Joining a room
|
||||
|
||||
class JoinException(EuphException):
|
||||
"""
|
||||
An exception that happened while joining a room.
|
||||
"""
|
||||
pass
|
||||
|
||||
class AuthenticationRequired(RoomException):
|
||||
class CouldNotConnectException(JoinException):
|
||||
"""
|
||||
Could not establish a websocket connection to euphoria.
|
||||
"""
|
||||
pass
|
||||
|
||||
class RoomClosed(RoomException):
|
||||
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
108
yaboli/message.py
Normal 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
|
||||
504
yaboli/room.py
504
yaboli/room.py
|
|
@ -1,443 +1,107 @@
|
|||
import asyncio
|
||||
import logging
|
||||
import time
|
||||
|
||||
from .connection 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", "Inhabitant"]
|
||||
|
||||
__all__ = ["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.
|
||||
|
||||
A Room can only be used once in the sense that after it has been closed,
|
||||
any further actions will result in a RoomClosedException. If you need to
|
||||
manually reconnect, instead just create a new Room object.
|
||||
|
||||
|
||||
|
||||
Life cycle of a Room
|
||||
|
||||
1. create a new Room and register callbacks
|
||||
2. await join()
|
||||
3. do room-related stuff
|
||||
4. await part()
|
||||
|
||||
|
||||
|
||||
IN PHASE 1, a password and a starting nick can be set. The password and
|
||||
current nick are used when first connecting to the room, or when
|
||||
reconnecting to the room after connection was lost.
|
||||
|
||||
Usually, event callbacks are also registered during this phase.
|
||||
|
||||
|
||||
|
||||
IN PHASE 2, the Room creates the initial connection to euphoria and
|
||||
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:
|
||||
|
||||
1. the room is now in phase 3, in which case join() returns None
|
||||
2. the room could not be joined, in which case one of the JoinExceptions is
|
||||
returned
|
||||
|
||||
|
||||
|
||||
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.
|
||||
"""
|
||||
|
||||
CONNECTED = 1
|
||||
DISCONNECTED = 2
|
||||
CLOSED = 3
|
||||
FORWARDING = 4
|
||||
# Phase 1
|
||||
|
||||
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:
|
||||
def __init__(self,
|
||||
room_name: str,
|
||||
nick: str = None,
|
||||
password: str = None):
|
||||
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")
|
||||
self.closed = False
|
||||
|
||||
await self._inhabitant.on_pm(self, from_uid, from_nick, from_room, pm_id)
|
||||
# Phase 2
|
||||
|
||||
async def _event_send(self, data):
|
||||
message = Message.from_dict(data)
|
||||
# Phase 3
|
||||
|
||||
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)
|
||||
def _ensure_open(self) -> None:
|
||||
if self.closed:
|
||||
raise RoomClosedException()
|
||||
|
||||
# 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")
|
||||
for message in reversed(old_messages):
|
||||
self._last_known_mid = message.mid
|
||||
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
|
||||
# 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):
|
||||
async def _ensure_joined(self) -> None:
|
||||
pass
|
||||
|
||||
async def on_connected(self, room, log):
|
||||
async def _ensure(self) -> None:
|
||||
self._ensure_open()
|
||||
await self._ensure_joined()
|
||||
|
||||
# Phase 4
|
||||
|
||||
# Other stuff
|
||||
|
||||
@property
|
||||
def name(self) -> str:
|
||||
pass
|
||||
|
||||
async def on_disconnected(self, room):
|
||||
async def say(self,
|
||||
text: str,
|
||||
parent_id: Optional[str] = None
|
||||
) -> LiveMessage:
|
||||
pass
|
||||
|
||||
async def on_stopped(self, room):
|
||||
@property
|
||||
def users(self) -> List[LiveUser]:
|
||||
pass
|
||||
|
||||
async def on_join(self, room, session):
|
||||
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):
|
||||
pass
|
||||
|
||||
async def on_forward(self, room, message):
|
||||
await self.on_send(room, message)
|
||||
|
||||
async def on_edit(self, room, message):
|
||||
pass
|
||||
|
||||
async def on_pm(self, room, from_uid, from_nick, from_room, pm_id):
|
||||
pass
|
||||
# retrieving messages
|
||||
|
|
|
|||
91
yaboli/user.py
Normal file
91
yaboli/user.py
Normal 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
15
yaboli/util.py
Normal 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
|
||||
225
yaboli/utils.py
225
yaboli/utils.py
|
|
@ -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)
|
||||
)
|
||||
Loading…
Add table
Add a link
Reference in a new issue