Start rewrite
This commit is contained in:
parent
04f4c3a8b6
commit
6b65bef5e0
5 changed files with 354 additions and 776 deletions
|
|
@ -1,30 +1,21 @@
|
||||||
|
# ---------- BEGIN DEV SECTION ----------
|
||||||
import asyncio
|
import asyncio
|
||||||
#asyncio.get_event_loop().set_debug(True) # uncomment for asycio debugging mode
|
|
||||||
|
|
||||||
import logging
|
import logging
|
||||||
|
|
||||||
# general (asyncio) logging level
|
# asyncio debugging
|
||||||
#logging.basicConfig(level=logging.DEBUG)
|
asyncio.get_event_loop().set_debug(True) # uncomment for asycio debugging mode
|
||||||
#logging.basicConfig(level=logging.INFO)
|
logging.getLogger("asyncio").setLevel(logging.DEBUG)
|
||||||
logging.basicConfig(level=logging.WARNING)
|
|
||||||
|
|
||||||
# yaboli logger level
|
# yaboli logger level
|
||||||
logger = logging.getLogger(__name__)
|
logging.getLogger(__name__).setLevel(logging.DEBUG)
|
||||||
#logger.setLevel(logging.DEBUG)
|
# ----------- END DEV SECTION -----------
|
||||||
logger.setLevel(logging.INFO)
|
|
||||||
|
|
||||||
from .bot import *
|
from .cookiejar import *
|
||||||
from .connection import *
|
from .connection import *
|
||||||
from .controller import *
|
from .exceptions import *
|
||||||
from .database import *
|
|
||||||
from .room import *
|
|
||||||
from .utils import *
|
|
||||||
|
|
||||||
__all__ = (
|
__all__ = (
|
||||||
bot.__all__ +
|
|
||||||
connection.__all__ +
|
connection.__all__ +
|
||||||
controller.__all__ +
|
cookiejar.__all__ +
|
||||||
database.__all__ +
|
exceptions.__all__
|
||||||
room.__all__ +
|
|
||||||
utils.__all__
|
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -4,119 +4,37 @@ import logging
|
||||||
import socket
|
import socket
|
||||||
import websockets
|
import websockets
|
||||||
|
|
||||||
|
from .exceptions import ConnectionClosed
|
||||||
|
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
__all__ = ["Connection"]
|
__all__ = ["Connection"]
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
class Connection:
|
class Connection:
|
||||||
def __init__(self, url, packet_hook, cookie=None, ping_timeout=10, ping_delay=30):
|
def __init__(self, url, packet_callback, disconnect_callback, cookiejar=None, ping_timeout=10, ping_delay=30, reconnect_attempts=10):
|
||||||
self.url = url
|
self.url = url
|
||||||
self.packet_hook = packet_hook
|
self.packet_callback = packet_callback
|
||||||
self.cookie = cookie
|
self.disconnect_callback = disconnect_callback
|
||||||
|
self.cookiejar = cookiejar
|
||||||
self.ping_timeout = ping_timeout # how long to wait for websocket ping reply
|
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.ping_delay = ping_delay # how long to wait between pings
|
||||||
|
self.reconnect_attempts = reconnect_attempts
|
||||||
|
|
||||||
self._ws = None
|
self._ws = None
|
||||||
self._pid = 0 # successive packet ids
|
self._pid = 0 # successive packet ids
|
||||||
self._spawned_tasks = set()
|
#self._spawned_tasks = set()
|
||||||
self._pending_responses = {}
|
self._pending_responses = {}
|
||||||
|
|
||||||
self._runtask = None
|
self._stopped = False
|
||||||
self._pingtask = None # pings
|
self._pingtask = None
|
||||||
|
self._runtask = asyncio.create_task(self._run())
|
||||||
async def connect(self, max_tries=10, delay=60):
|
# ... aaand the connection is started.
|
||||||
"""
|
|
||||||
success = await connect(max_tries=10, delay=60)
|
|
||||||
|
|
||||||
Attempt to connect to a room.
|
|
||||||
Returns the task listening for packets, or None if the attempt failed.
|
|
||||||
|
|
||||||
max_tries - maximum number of reconnect attempts before stopping
|
|
||||||
delay - time (in seconds) between reconnect attempts
|
|
||||||
"""
|
|
||||||
|
|
||||||
logger.debug(f"Attempting to connect, max_tries={max_tries}")
|
|
||||||
|
|
||||||
await self.stop()
|
|
||||||
|
|
||||||
logger.debug(f"Stopped previously running things.")
|
|
||||||
|
|
||||||
for tries_left in reversed(range(max_tries)):
|
|
||||||
logger.info(f"Attempting to connect, {tries_left} tries left.")
|
|
||||||
|
|
||||||
try:
|
|
||||||
self._ws = await websockets.connect(self.url, max_size=None)
|
|
||||||
except (websockets.InvalidURI, websockets.InvalidHandshake, socket.gaierror):
|
|
||||||
self._ws = None
|
|
||||||
if tries_left > 0:
|
|
||||||
await asyncio.sleep(delay)
|
|
||||||
else:
|
|
||||||
self._runtask = asyncio.ensure_future(self._run())
|
|
||||||
self._pingtask = asyncio.ensure_future(self._ping())
|
|
||||||
logger.debug(f"Started run and ping tasks")
|
|
||||||
|
|
||||||
return self._runtask
|
|
||||||
|
|
||||||
async def _run(self):
|
|
||||||
"""
|
|
||||||
Listen for packets and deal with them accordingly.
|
|
||||||
"""
|
|
||||||
|
|
||||||
try:
|
|
||||||
while True:
|
|
||||||
await self._handle_next_message()
|
|
||||||
except websockets.ConnectionClosed:
|
|
||||||
pass
|
|
||||||
finally:
|
|
||||||
self._clean_up_futures()
|
|
||||||
self._clean_up_tasks()
|
|
||||||
|
|
||||||
try:
|
|
||||||
await self._ws.close() # just to make sure
|
|
||||||
except:
|
|
||||||
pass # errors are not useful here
|
|
||||||
|
|
||||||
self._pingtask.cancel()
|
|
||||||
await self._pingtask # should stop now that the ws is closed
|
|
||||||
self._ws = None
|
|
||||||
|
|
||||||
async def _ping(self):
|
|
||||||
"""
|
|
||||||
Periodically ping the server to detect a timeout.
|
|
||||||
"""
|
|
||||||
|
|
||||||
while True:
|
|
||||||
try:
|
|
||||||
logger.debug("Pinging...")
|
|
||||||
wait_for_reply = await self._ws.ping()
|
|
||||||
await asyncio.wait_for(wait_for_reply, self.ping_timeout)
|
|
||||||
logger.debug("Pinged!")
|
|
||||||
await asyncio.sleep(self.ping_delay)
|
|
||||||
except asyncio.TimeoutError:
|
|
||||||
logger.warning("Ping timed out.")
|
|
||||||
await self._ws.close()
|
|
||||||
break
|
|
||||||
except (websockets.ConnectionClosed, ConnectionResetError, asyncio.CancelledError):
|
|
||||||
return
|
|
||||||
|
|
||||||
async def stop(self):
|
|
||||||
"""
|
|
||||||
Close websocket connection and wait for running task to stop.
|
|
||||||
"""
|
|
||||||
|
|
||||||
if self._ws:
|
|
||||||
try:
|
|
||||||
await self._ws.close()
|
|
||||||
except:
|
|
||||||
pass # errors not useful here
|
|
||||||
|
|
||||||
if self._runtask:
|
|
||||||
await self._runtask
|
|
||||||
|
|
||||||
async def send(self, ptype, data=None, await_response=True):
|
async def send(self, ptype, data=None, await_response=True):
|
||||||
if not self._ws:
|
if not self._ws:
|
||||||
raise asyncio.CancelledError
|
raise exceptions.ConnectionClosed
|
||||||
|
#raise asyncio.CancelledError
|
||||||
|
|
||||||
pid = str(self._new_pid())
|
pid = str(self._new_pid())
|
||||||
packet = {
|
packet = {
|
||||||
|
|
@ -136,32 +54,126 @@ class Connection:
|
||||||
await wait_for
|
await wait_for
|
||||||
return wait_for.result()
|
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.
|
||||||
|
"""
|
||||||
|
|
||||||
|
self._stopped = True
|
||||||
|
if self._ws:
|
||||||
|
await self._ws.close() # _run() does the cleaning up now.
|
||||||
|
await self._runtask
|
||||||
|
|
||||||
|
async def _connect(self, tries):
|
||||||
|
"""
|
||||||
|
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()]
|
||||||
|
self._ws = await websockets.connect(self.url, max_size=None, extra_headers=cookies)
|
||||||
|
else:
|
||||||
|
self._ws = await websockets.connect(self.url, max_size=None)
|
||||||
|
except (websockets.InvalidHandshake, socket.gaierror): # not websockets.InvalidURI
|
||||||
|
self._ws = None
|
||||||
|
|
||||||
|
if tries is not None:
|
||||||
|
tries -= 1
|
||||||
|
if tries <= 0:
|
||||||
|
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._pingtask = asyncio.create_task(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
|
||||||
|
"""
|
||||||
|
|
||||||
|
# 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(exceptions.ConnectionClosed("No server response"))
|
||||||
|
self._pending_responses = {}
|
||||||
|
|
||||||
|
async def _run(self):
|
||||||
|
"""
|
||||||
|
Listen for packets and deal with them accordingly.
|
||||||
|
"""
|
||||||
|
|
||||||
|
while not self._stopped:
|
||||||
|
self._connect(self.reconnect_attempts)
|
||||||
|
|
||||||
|
try:
|
||||||
|
while True:
|
||||||
|
await self._handle_next_message()
|
||||||
|
except websockets.ConnectionClosed:
|
||||||
|
pass
|
||||||
|
finally:
|
||||||
|
await self._disconnect() # disconnect and clean up
|
||||||
|
|
||||||
|
async def _ping(self):
|
||||||
|
"""
|
||||||
|
Periodically ping the server to detect a timeout.
|
||||||
|
"""
|
||||||
|
|
||||||
|
try:
|
||||||
|
while True:
|
||||||
|
logger.debug("Pinging...")
|
||||||
|
wait_for_reply = await self._ws.ping()
|
||||||
|
await asyncio.wait_for(wait_for_reply, self.ping_timeout)
|
||||||
|
logger.debug("Pinged!")
|
||||||
|
await asyncio.sleep(self.ping_delay)
|
||||||
|
except asyncio.TimeoutError:
|
||||||
|
logger.warning("Ping timed out.")
|
||||||
|
await self._ws.close() # trigger a reconnect attempt
|
||||||
|
except (websockets.ConnectionClosed, ConnectionResetError, asyncio.CancelledError):
|
||||||
|
pass
|
||||||
|
|
||||||
def _new_pid(self):
|
def _new_pid(self):
|
||||||
self._pid += 1
|
self._pid += 1
|
||||||
return self._pid
|
return self._pid
|
||||||
|
|
||||||
async def _handle_next_message(self):
|
async def _handle_next_message(self):
|
||||||
response = await self._ws.recv()
|
response = await self._ws.recv()
|
||||||
task = asyncio.ensure_future(self._handle_json(response))
|
|
||||||
self._track_task(task) # will be cancelled when the connection is closed
|
|
||||||
|
|
||||||
def _clean_up_futures(self):
|
|
||||||
for pid, future in self._pending_responses.items():
|
|
||||||
logger.debug(f"Cancelling future: {future}")
|
|
||||||
future.cancel()
|
|
||||||
self._pending_responses = {}
|
|
||||||
|
|
||||||
def _clean_up_tasks(self):
|
|
||||||
for task in self._spawned_tasks:
|
|
||||||
if not task.done():
|
|
||||||
logger.debug(f"Cancelling task: {task}")
|
|
||||||
task.cancel()
|
|
||||||
else:
|
|
||||||
logger.debug(f"Task already done: {task}")
|
|
||||||
logger.debug(f"Exception: {task.exception()}")
|
|
||||||
self._spawned_tasks = set()
|
|
||||||
|
|
||||||
async def _handle_json(self, text):
|
|
||||||
packet = json.loads(text)
|
packet = json.loads(text)
|
||||||
|
|
||||||
# Deal with pending responses
|
# Deal with pending responses
|
||||||
|
|
@ -170,17 +182,18 @@ class Connection:
|
||||||
if future:
|
if future:
|
||||||
future.set_result(packet)
|
future.set_result(packet)
|
||||||
|
|
||||||
|
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
|
||||||
|
|
||||||
# Pass packet onto room
|
# Pass packet onto room
|
||||||
await self.packet_hook(packet)
|
asyncio.create_task(self.packet_callback(ptype, data, error, throttled))
|
||||||
|
|
||||||
def _track_task(self, task):
|
|
||||||
self._spawned_tasks.add(task)
|
|
||||||
|
|
||||||
# only keep running tasks
|
|
||||||
self._spawned_tasks = {task for task in self._spawned_tasks if not task.done()}
|
|
||||||
|
|
||||||
def _wait_for_response(self, pid):
|
def _wait_for_response(self, pid):
|
||||||
future = asyncio.Future()
|
future = asyncio.Future()
|
||||||
self._pending_responses[pid] = future
|
self._pending_responses[pid] = future
|
||||||
|
|
||||||
return future
|
return future
|
||||||
|
|
|
||||||
65
yaboli/cookiejar.py
Normal file
65
yaboli/cookiejar.py
Normal file
|
|
@ -0,0 +1,65 @@
|
||||||
|
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):
|
||||||
|
self._filename = filename
|
||||||
|
self._cookies = cookies.SimpleCookie()
|
||||||
|
|
||||||
|
with contextlib.suppress(FileNotFoundError):
|
||||||
|
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.
|
||||||
|
"""
|
||||||
|
|
||||||
|
logger.debug(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()
|
||||||
4
yaboli/exceptions.py
Normal file
4
yaboli/exceptions.py
Normal file
|
|
@ -0,0 +1,4 @@
|
||||||
|
__all__ = ["ConnectionClosed"]
|
||||||
|
|
||||||
|
class ConnectionClosed(Exception):
|
||||||
|
pass
|
||||||
687
yaboli/room.py
687
yaboli/room.py
|
|
@ -1,63 +1,23 @@
|
||||||
import asyncio
|
__all__ == ["Room", "Inhabitant"]
|
||||||
import logging
|
|
||||||
from .callbacks import *
|
|
||||||
from .connection import *
|
|
||||||
from .utils import *
|
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
|
||||||
__all__ = ["Room"]
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
class Room:
|
class Room:
|
||||||
"""
|
"""
|
||||||
This class represents a connection to a room. This basically means that one
|
TODO
|
||||||
room instance means one nick on the nick list.
|
|
||||||
|
|
||||||
It's purpose is to provide a higher-level way of interacting with a room to
|
|
||||||
a controller. This includes converting packets received from the server to
|
|
||||||
utility classes where possible, or keeping track of current room state like
|
|
||||||
the client's nick.
|
|
||||||
It does not keep track of the room's messages, as keeping (or not keeping)
|
|
||||||
messages is highly application-dependent. If needed, messages can be kept
|
|
||||||
using the utils.Log class.
|
|
||||||
|
|
||||||
Room implements all commands necessary for creating bots. For now, the
|
|
||||||
human flag should always be False, and the cookie None.
|
|
||||||
It also "attaches" to a controller and calls the corresponding functions
|
|
||||||
when it receives events from the server
|
|
||||||
|
|
||||||
When connection is lost while the room is running, it will attempt to
|
|
||||||
reconnect a few times. Loss of connection is determined by self._conn.
|
|
||||||
"""
|
"""
|
||||||
|
|
||||||
ROOM_FORMAT = "wss://euphoria.io/room/{}/ws"
|
def __init__(self, roomname, inhabitant, password=None, human=False, cookiejar=None):
|
||||||
HUMAN_FORMAT = f"{ROOM_FORMAT}?h=1"
|
# TODO: Connect to room etc.
|
||||||
|
# TODO: Deal with room/connection states of:
|
||||||
|
# disconnected connecting, fast-forwarding, connected
|
||||||
|
|
||||||
def __init__(self, roomname, controller, human=False, cookie=None):
|
self._inhabitant = inhabitant
|
||||||
"""
|
|
||||||
Create a room. To connect to the room and start a run task that listens
|
|
||||||
to packets on the connection, use connect().
|
|
||||||
|
|
||||||
roomname - name of the room to connect to, without a "&" in front
|
|
||||||
controller - the controller which should be notified of events
|
|
||||||
human - currently not implemented, should be False
|
|
||||||
cookie - currently not implemented, should be None
|
|
||||||
"""
|
|
||||||
|
|
||||||
|
# Room info (all fields readonly!)
|
||||||
self.roomname = roomname
|
self.roomname = roomname
|
||||||
self.controller = controller
|
|
||||||
self.human = human
|
|
||||||
self.cookie = cookie
|
|
||||||
|
|
||||||
# Keeps track of sessions, but not messages, since they might be dealt
|
|
||||||
# with differently by different controllers.
|
|
||||||
# If you need to keep track of messages, use utils.Log.
|
|
||||||
self.session = None
|
self.session = None
|
||||||
self.account = None
|
self.account = None
|
||||||
self.listing = Listing()
|
self.listing = None # TODO
|
||||||
|
|
||||||
# Various room information
|
|
||||||
self.account_has_access = None
|
self.account_has_access = None
|
||||||
self.account_email_verified = None
|
self.account_email_verified = None
|
||||||
self.room_is_private = None
|
self.room_is_private = None
|
||||||
|
|
@ -65,573 +25,118 @@ class Room:
|
||||||
self.pm_with_nick = None
|
self.pm_with_nick = None
|
||||||
self.pm_with_user_id = None
|
self.pm_with_user_id = None
|
||||||
|
|
||||||
self._callbacks = Callbacks()
|
#asyncio.create_task(self._run())
|
||||||
self._add_callbacks()
|
|
||||||
|
|
||||||
self._stopping = False
|
async def exit(self):
|
||||||
self._runtask = None
|
pass
|
||||||
|
|
||||||
if human:
|
# ROOM COMMANDS
|
||||||
url = self.HUMAN_FORMAT.format(self.roomname)
|
# 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):
|
||||||
|
pass
|
||||||
|
|
||||||
|
async def log(self, n, before_mid=None):
|
||||||
|
pass
|
||||||
|
|
||||||
|
async def nick(self, nick):
|
||||||
|
pass
|
||||||
|
|
||||||
|
async def pm(self, uid):
|
||||||
|
pass
|
||||||
|
|
||||||
|
async def send(self, content, parent_mid=None):
|
||||||
|
"""
|
||||||
|
Send a message to the room.
|
||||||
|
See http://api.euphoria.io/#send
|
||||||
|
"""
|
||||||
|
|
||||||
|
if parent_mid:
|
||||||
|
data = await self._send_while_connected(
|
||||||
|
"send",
|
||||||
|
content=content,
|
||||||
|
parent=parent_mid
|
||||||
|
)
|
||||||
else:
|
else:
|
||||||
url = self.ROOM_FORMAT.format(self.roomname)
|
data = await self._send_while_connected(
|
||||||
self._conn = Connection(url, self._handle_packet, self.cookie)
|
"send",
|
||||||
|
content=content
|
||||||
|
)
|
||||||
|
|
||||||
async def connect(self, max_tries=10, delay=60):
|
return Message.from_dict(data)
|
||||||
"""
|
|
||||||
runtask = await connect(max_tries, delay)
|
|
||||||
|
|
||||||
Attempts to connect to the room once and returns a task running
|
|
||||||
self._run, if successful, otherwise None. This can be used to detect if
|
|
||||||
a room exists.
|
|
||||||
|
|
||||||
The max_tries and delay parameters are passed on to self._run:
|
|
||||||
max_tries - maximum number of reconnect attempts before stopping
|
|
||||||
delay - time (in seconds) between reconnect attempts
|
|
||||||
"""
|
|
||||||
|
|
||||||
task = await self._conn.connect(max_tries=1)
|
|
||||||
if task:
|
|
||||||
self._runtask = asyncio.ensure_future(self._run(task, max_tries=max_tries, delay=delay))
|
|
||||||
return self._runtask
|
|
||||||
|
|
||||||
async def _run(self, task, max_tries=10, delay=60):
|
|
||||||
"""
|
|
||||||
await _run(max_tries, delay)
|
|
||||||
|
|
||||||
Run and reconnect when the connection is lost or closed, unless
|
|
||||||
self._stopping is set to True.
|
|
||||||
For an explanation of the parameters, see self.connect.
|
|
||||||
"""
|
|
||||||
|
|
||||||
while not self._stopping:
|
|
||||||
if task.done():
|
|
||||||
task = await self._conn.connect(max_tries=max_tries, delay=delay)
|
|
||||||
if not task:
|
|
||||||
return
|
|
||||||
|
|
||||||
await task
|
|
||||||
await self.controller.on_disconnected()
|
|
||||||
|
|
||||||
self.stopping = False
|
|
||||||
|
|
||||||
async def stop(self):
|
|
||||||
"""
|
|
||||||
await stop()
|
|
||||||
|
|
||||||
Close the connection to the room without reconnecting.
|
|
||||||
"""
|
|
||||||
|
|
||||||
self._stopping = True
|
|
||||||
await self._conn.stop()
|
|
||||||
|
|
||||||
if self._runtask:
|
|
||||||
await self._runtask
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
# CATEGORY: SESSION COMMANDS
|
|
||||||
|
|
||||||
async def auth(self, atype, passcode=None):
|
|
||||||
"""
|
|
||||||
success, reason=None = await auth(atype, passcode=None)
|
|
||||||
|
|
||||||
From api.euphoria.io:
|
|
||||||
The auth command attempts to join a private room. It should be sent in
|
|
||||||
response to a bounce-event at the beginning of a session.
|
|
||||||
|
|
||||||
The auth-reply packet reports whether the auth command succeeded.
|
|
||||||
"""
|
|
||||||
|
|
||||||
data = {"type": atype}
|
|
||||||
if passcode:
|
|
||||||
data["passcode"] = passcode
|
|
||||||
|
|
||||||
response = await self._send_packet("auth", data)
|
|
||||||
rdata = response.get("data")
|
|
||||||
|
|
||||||
success = rdata.get("success")
|
|
||||||
reason = rdata.get("reason", None)
|
|
||||||
return success, reason
|
|
||||||
|
|
||||||
async def ping_reply(self, time):
|
|
||||||
"""
|
|
||||||
await ping_reply(time)
|
|
||||||
|
|
||||||
From api.euphoria.io:
|
|
||||||
The ping command initiates a client-to-server ping. The server will
|
|
||||||
send back a ping-reply with the same timestamp as soon as possible.
|
|
||||||
|
|
||||||
ping-reply is a response to a ping command or ping-event.
|
|
||||||
"""
|
|
||||||
|
|
||||||
data = {"time": time}
|
|
||||||
await self._conn.send("ping-reply", data, await_response=False)
|
|
||||||
|
|
||||||
# CATEGORY: CHAT ROOM COMMANDS
|
|
||||||
|
|
||||||
async def get_message(self, message_id):
|
|
||||||
"""
|
|
||||||
message = await get_message(message_id)
|
|
||||||
|
|
||||||
From api.euphoria.io:
|
|
||||||
The get-message command retrieves the full content of a single message
|
|
||||||
in the room.
|
|
||||||
|
|
||||||
get-message-reply returns the message retrieved by get-message.
|
|
||||||
"""
|
|
||||||
|
|
||||||
data = {"id": message_id}
|
|
||||||
|
|
||||||
response = await self._send_packet("get-message", data)
|
|
||||||
rdata = response.get("data")
|
|
||||||
|
|
||||||
message = Message.from_dict(rdata)
|
|
||||||
return message
|
|
||||||
|
|
||||||
async def log(self, n, before=None):
|
|
||||||
"""
|
|
||||||
log, before=None = await log(n, before=None)
|
|
||||||
|
|
||||||
From api.euphoria.io:
|
|
||||||
The log command requests messages from the room’s message log. This can
|
|
||||||
be used to supplement the log provided by snapshot-event (for example,
|
|
||||||
when scrolling back further in history).
|
|
||||||
|
|
||||||
The log-reply packet returns a list of messages from the room’s message
|
|
||||||
"""
|
|
||||||
|
|
||||||
data = {"n": n}
|
|
||||||
if before:
|
|
||||||
data["before"] = before
|
|
||||||
|
|
||||||
response = await self._send_packet("log", data)
|
|
||||||
rdata = response.get("data")
|
|
||||||
|
|
||||||
messages = [Message.from_dict(d) for d in rdata.get("log")]
|
|
||||||
before = rdata.get("before", None)
|
|
||||||
return messages, before
|
|
||||||
|
|
||||||
async def nick(self, name):
|
|
||||||
"""
|
|
||||||
session_id, user_id, from_nick, to_nick = await nick(name)
|
|
||||||
|
|
||||||
From api.euphoria.io:
|
|
||||||
The nick command sets the name you present to the room. This name
|
|
||||||
applies to all messages sent during this session, until the nick
|
|
||||||
command is called again.
|
|
||||||
|
|
||||||
nick-reply confirms the nick command. It returns the session’s former
|
|
||||||
and new names (the server may modify the requested nick).
|
|
||||||
"""
|
|
||||||
|
|
||||||
data = {"name": name}
|
|
||||||
|
|
||||||
response = await self._send_packet("nick", data)
|
|
||||||
rdata = response.get("data")
|
|
||||||
|
|
||||||
session_id = rdata.get("session_id")
|
|
||||||
user_id = rdata.get("id")
|
|
||||||
from_nick = rdata.get("from")
|
|
||||||
to_nick = rdata.get("to")
|
|
||||||
|
|
||||||
# update self.session
|
|
||||||
self.session.nick = to_nick
|
|
||||||
|
|
||||||
return session_id, user_id, from_nick, to_nick
|
|
||||||
|
|
||||||
async def pm_initiate(self, user_id):
|
|
||||||
"""
|
|
||||||
pm_id, to_nick = await pm_initiate(user_id)
|
|
||||||
|
|
||||||
From api.euphoria.io:
|
|
||||||
The pm-initiate command constructs a virtual room for private messaging
|
|
||||||
between the client and the given UserID.
|
|
||||||
|
|
||||||
The pm-initiate-reply provides the PMID for the requested private
|
|
||||||
messaging room.
|
|
||||||
"""
|
|
||||||
|
|
||||||
data = {"user_id": user_id}
|
|
||||||
|
|
||||||
response = await self._send_packet("pm-initiate", data)
|
|
||||||
rdata = response.get("data")
|
|
||||||
|
|
||||||
pm_id = rdata.get("pm_id")
|
|
||||||
to_nick = rdata.get("to_nick")
|
|
||||||
return pm_id, to_nick
|
|
||||||
|
|
||||||
async def send(self, content, parent=None):
|
|
||||||
"""
|
|
||||||
message = await send(content, parent=None)
|
|
||||||
|
|
||||||
From api.euphoria.io:
|
|
||||||
The send command sends a message to a room. The session must be
|
|
||||||
successfully joined with the room. This message will be broadcast to
|
|
||||||
all sessions joined with the room.
|
|
||||||
|
|
||||||
If the room is private, then the message content will be encrypted
|
|
||||||
before it is stored and broadcast to the rest of the room.
|
|
||||||
|
|
||||||
The caller of this command will not receive the corresponding
|
|
||||||
send-event, but will receive the same information in the send-reply.
|
|
||||||
"""
|
|
||||||
|
|
||||||
data = {"content": content}
|
|
||||||
if parent:
|
|
||||||
data["parent"] = parent
|
|
||||||
|
|
||||||
response = await self._send_packet("send", data)
|
|
||||||
rdata = response.get("data")
|
|
||||||
|
|
||||||
message = Message.from_dict(rdata)
|
|
||||||
return message
|
|
||||||
|
|
||||||
async def who(self):
|
async def who(self):
|
||||||
"""
|
pass
|
||||||
sessions = await who()
|
|
||||||
|
|
||||||
From api.euphoria.io:
|
# COMMUNICATION WITH CONNECTION
|
||||||
The who command requests a list of sessions currently joined in the
|
|
||||||
room.
|
|
||||||
|
|
||||||
The who-reply packet lists the sessions currently joined in the room.
|
async def _receive_packet(self, ptype, data, error, throttled):
|
||||||
"""
|
pass # TODO
|
||||||
|
|
||||||
response = await self._send_packet("who")
|
async def _disconnected(self):
|
||||||
rdata = response.get("data")
|
pass # TODO
|
||||||
|
|
||||||
sessions = [Session.from_dict(d) for d in rdata.get("listing")]
|
# SOME USEFUL PUBLIC METHODS
|
||||||
|
|
||||||
# update self.listing
|
@staticmethod
|
||||||
self.listing = Listing()
|
def format_room_url(roomname, private=False, human=False):
|
||||||
for session in sessions:
|
if private:
|
||||||
if not session.sid == self.session.sid:
|
roomname = f"pm:{roomname}"
|
||||||
self.listing.add(session)
|
|
||||||
|
|
||||||
return sessions
|
url = f"wss://euphoria.io/room/{roomname}/ws"
|
||||||
|
|
||||||
# CATEGORY: ACCOUNT COMMANDS
|
if human:
|
||||||
# NYI, and probably never will
|
url = f"{url}?h=1"
|
||||||
|
|
||||||
# CATEGORY: ROOM HOST COMMANDS
|
return url
|
||||||
# NYI, and probably never will
|
|
||||||
|
|
||||||
# CATEGORY: STAFF COMMANDS
|
async def connected(self):
|
||||||
# NYI, and probably never will
|
pass
|
||||||
|
|
||||||
|
# REST OF THE IMPLEMENTATION
|
||||||
|
|
||||||
|
async def _run(self):
|
||||||
|
pass
|
||||||
|
|
||||||
# All the private functions for dealing with stuff
|
async def _send_while_connected(*args, **kwargs):
|
||||||
|
while True:
|
||||||
|
try:
|
||||||
|
await self.connected()
|
||||||
|
if not self._status != Room._CONNECTED: continue # TODO: Figure out a good solution
|
||||||
|
return await self._connection.send(*args, **kwargs)
|
||||||
|
except RoomDisconnected:
|
||||||
|
pass # Just try again
|
||||||
|
|
||||||
def _add_callbacks(self):
|
|
||||||
"""
|
|
||||||
_add_callbacks()
|
|
||||||
|
|
||||||
Adds the functions that handle server events to the callbacks for that
|
class Inhabitant:
|
||||||
event.
|
"""
|
||||||
"""
|
TODO
|
||||||
|
"""
|
||||||
|
|
||||||
self._callbacks.add("bounce-event", self._handle_bounce)
|
# ROOM EVENTS
|
||||||
self._callbacks.add("disconnect-event", self._handle_disconnect)
|
# These functions are called by the room when something happens.
|
||||||
self._callbacks.add("hello-event", self._handle_hello)
|
# They're launched via asyncio.create_task(), so they don't block execution of the room.
|
||||||
self._callbacks.add("join-event", self._handle_join)
|
# Just overwrite the events you need (make sure to keep the arguments the same though).
|
||||||
self._callbacks.add("login-event", self._handle_login)
|
|
||||||
self._callbacks.add("logout-event", self._handle_logout)
|
|
||||||
self._callbacks.add("network-event", self._handle_network)
|
|
||||||
self._callbacks.add("nick-event", self._handle_nick)
|
|
||||||
self._callbacks.add("edit-message-event", self._handle_edit_message)
|
|
||||||
self._callbacks.add("part-event", self._handle_part)
|
|
||||||
self._callbacks.add("ping-event", self._handle_ping)
|
|
||||||
self._callbacks.add("pm-initiate-event", self._handle_pm_initiate)
|
|
||||||
self._callbacks.add("send-event", self._handle_send)
|
|
||||||
self._callbacks.add("snapshot-event", self._handle_snapshot)
|
|
||||||
|
|
||||||
async def _send_packet(self, *args, **kwargs):
|
async def disconnected(self, room):
|
||||||
"""
|
pass
|
||||||
reply_packet = await _send_packet(*args, **kwargs)
|
|
||||||
|
|
||||||
Like self._conn.send, but checks for an error on the packet and raises
|
async def connected(self, room, log):
|
||||||
the corresponding exception.
|
pass
|
||||||
"""
|
|
||||||
|
|
||||||
response = await self._conn.send(*args, **kwargs)
|
async def join(self, room, session):
|
||||||
self._check_for_errors(response)
|
pass
|
||||||
|
|
||||||
return response
|
async def part(self, room, session):
|
||||||
|
pass
|
||||||
|
|
||||||
async def _handle_packet(self, packet):
|
async def nick(self, room, sid, uid, from_nick, to_nick):
|
||||||
"""
|
pass
|
||||||
await _handle_packet(packet)
|
|
||||||
|
|
||||||
Call the correct callbacks to deal with packet.
|
async def send(self, room, message):
|
||||||
|
pass
|
||||||
|
|
||||||
This function catches CancelledErrors and instead displays an info so
|
async def pm(self, room, from_uid, from_nick, from_room, pm_id):
|
||||||
the console doesn't show stack traces when a bot loses connection.
|
pass
|
||||||
"""
|
|
||||||
|
|
||||||
self._check_for_errors(packet)
|
|
||||||
|
|
||||||
ptype = packet.get("type")
|
|
||||||
try:
|
|
||||||
await self._callbacks.call(ptype, packet)
|
|
||||||
except asyncio.CancelledError as e:
|
|
||||||
logger.info(f"&{self.roomname}: Callback of type {ptype!r} cancelled.")
|
|
||||||
#raise # not necessary?
|
|
||||||
|
|
||||||
def _check_for_errors(self, packet):
|
|
||||||
"""
|
|
||||||
_check_for_errors(packet)
|
|
||||||
|
|
||||||
Checks for an error on the packet and raises the corresponding
|
|
||||||
exception.
|
|
||||||
"""
|
|
||||||
|
|
||||||
if packet.get("throttled", False):
|
|
||||||
logger.warn(f"&{self.roomname}: Throttled for reason: {packet.get('throttled_reason', 'no reason')!r}")
|
|
||||||
|
|
||||||
if "error" in packet:
|
|
||||||
raise ResponseError(packet.get("error"))
|
|
||||||
|
|
||||||
async def _handle_bounce(self, packet):
|
|
||||||
"""
|
|
||||||
From api.euphoria.io:
|
|
||||||
A bounce-event indicates that access to a room is denied.
|
|
||||||
"""
|
|
||||||
|
|
||||||
data = packet.get("data")
|
|
||||||
|
|
||||||
await self.controller.on_bounce(
|
|
||||||
reason=data.get("reason", None),
|
|
||||||
auth_options=data.get("auth_options", None),
|
|
||||||
agent_id=data.get("agent_id", None),
|
|
||||||
ip=data.get("ip", None)
|
|
||||||
)
|
|
||||||
|
|
||||||
async def _handle_disconnect(self, packet):
|
|
||||||
"""
|
|
||||||
From api.euphoria.io:
|
|
||||||
A disconnect-event indicates that the session is being closed. The
|
|
||||||
client will subsequently be disconnected.
|
|
||||||
|
|
||||||
If the disconnect reason is “authentication changed”, the client should
|
|
||||||
immediately reconnect.
|
|
||||||
"""
|
|
||||||
|
|
||||||
data = packet.get("data")
|
|
||||||
|
|
||||||
await self.controller.on_disconnect(data.get("reason"))
|
|
||||||
|
|
||||||
async def _handle_hello(self, packet):
|
|
||||||
"""
|
|
||||||
From api.euphoria.io:
|
|
||||||
A hello-event is sent by the server to the client when a session is
|
|
||||||
started. It includes information about the client’s authentication and
|
|
||||||
associated identity.
|
|
||||||
"""
|
|
||||||
|
|
||||||
data = packet.get("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)
|
|
||||||
|
|
||||||
await self.controller.on_hello(
|
|
||||||
data.get("id"),
|
|
||||||
self.session,
|
|
||||||
self.room_is_private,
|
|
||||||
self.version,
|
|
||||||
account=self.account,
|
|
||||||
account_has_access=self.account_has_access,
|
|
||||||
account_email_verified=self.account_email_verified
|
|
||||||
)
|
|
||||||
|
|
||||||
async def _handle_join(self, packet):
|
|
||||||
"""
|
|
||||||
From api.euphoria.io:
|
|
||||||
A join-event indicates a session just joined the room.
|
|
||||||
"""
|
|
||||||
|
|
||||||
data = packet.get("data")
|
|
||||||
session = Session.from_dict(data)
|
|
||||||
|
|
||||||
# update self.listing
|
|
||||||
self.listing.add(session)
|
|
||||||
|
|
||||||
await self.controller.on_join(session)
|
|
||||||
|
|
||||||
async def _handle_login(self, packet):
|
|
||||||
"""
|
|
||||||
From api.euphoria.io:
|
|
||||||
The login-event packet is sent to all sessions of an agent when that
|
|
||||||
agent is logged in (except for the session that issued the login
|
|
||||||
command).
|
|
||||||
"""
|
|
||||||
|
|
||||||
data = packet.get("data")
|
|
||||||
|
|
||||||
await self.controller.on_login(data.get("account_id"))
|
|
||||||
|
|
||||||
async def _handle_logout(self, packet):
|
|
||||||
"""
|
|
||||||
From api.euphoria.io:
|
|
||||||
The logout-event packet is sent to all sessions of an agent when that
|
|
||||||
agent is logged out (except for the session that issued the logout
|
|
||||||
command).
|
|
||||||
"""
|
|
||||||
|
|
||||||
await self.controller.on_logout()
|
|
||||||
|
|
||||||
async def _handle_network(self, packet):
|
|
||||||
"""
|
|
||||||
From api.euphoria.io:
|
|
||||||
A network-event indicates some server-side event that impacts the
|
|
||||||
presence of sessions in a room.
|
|
||||||
|
|
||||||
If the network event type is partition, then this should be treated as
|
|
||||||
a part-event for all sessions connected to the same server id/era
|
|
||||||
combo.
|
|
||||||
"""
|
|
||||||
|
|
||||||
data = packet.get("data")
|
|
||||||
server_id = data.get("server_id")
|
|
||||||
server_era = data.get("server_era")
|
|
||||||
|
|
||||||
# update self.listing
|
|
||||||
self.listing.remove_combo(server_id, server_era)
|
|
||||||
|
|
||||||
await self.controller.on_network(server_id, server_era)
|
|
||||||
|
|
||||||
async def _handle_nick(self, packet):
|
|
||||||
"""
|
|
||||||
From api.euphoria.io:
|
|
||||||
nick-event announces a nick change by another session in the room.
|
|
||||||
"""
|
|
||||||
|
|
||||||
data = packet.get("data")
|
|
||||||
session_id = data.get("session_id")
|
|
||||||
to_nick = data.get("to")
|
|
||||||
|
|
||||||
# update self.listing
|
|
||||||
session = self.listing.by_sid(session_id)
|
|
||||||
if session:
|
|
||||||
session.nick = to_nick
|
|
||||||
|
|
||||||
await self.controller.on_nick(
|
|
||||||
session_id,
|
|
||||||
data.get("id"),
|
|
||||||
data.get("from"),
|
|
||||||
to_nick
|
|
||||||
)
|
|
||||||
|
|
||||||
async def _handle_edit_message(self, packet):
|
|
||||||
"""
|
|
||||||
From api.euphoria.io:
|
|
||||||
An edit-message-event indicates that a message in the room has been
|
|
||||||
modified or deleted. If the client offers a user interface and the
|
|
||||||
indicated message is currently displayed, it should update its display
|
|
||||||
accordingly.
|
|
||||||
|
|
||||||
The event packet includes a snapshot of the message post-edit.
|
|
||||||
"""
|
|
||||||
|
|
||||||
data = packet.get("data")
|
|
||||||
message = Message.from_dict(data)
|
|
||||||
|
|
||||||
await self.controller.on_edit_message(
|
|
||||||
data.get("edit_id"),
|
|
||||||
message
|
|
||||||
)
|
|
||||||
|
|
||||||
async def _handle_part(self, packet):
|
|
||||||
"""
|
|
||||||
From api.euphoria.io:
|
|
||||||
A part-event indicates a session just disconnected from the room.
|
|
||||||
"""
|
|
||||||
|
|
||||||
data = packet.get("data")
|
|
||||||
session = Session.from_dict(data)
|
|
||||||
|
|
||||||
# update self.listing
|
|
||||||
self.listing.remove(session.session_id)
|
|
||||||
|
|
||||||
await self.controller.on_part(session)
|
|
||||||
|
|
||||||
async def _handle_ping(self, packet):
|
|
||||||
"""
|
|
||||||
From api.euphoria.io:
|
|
||||||
A ping-event represents a server-to-client ping. The client should send
|
|
||||||
back a ping-reply with the same value for the time field as soon as
|
|
||||||
possible (or risk disconnection).
|
|
||||||
"""
|
|
||||||
|
|
||||||
data = packet.get("data")
|
|
||||||
|
|
||||||
await self.controller.on_ping(
|
|
||||||
data.get("time"),
|
|
||||||
data.get("next")
|
|
||||||
)
|
|
||||||
|
|
||||||
async def _handle_pm_initiate(self, packet):
|
|
||||||
"""
|
|
||||||
From api.euphoria.io:
|
|
||||||
The pm-initiate-event informs the client that another user wants to
|
|
||||||
chat with them privately.
|
|
||||||
"""
|
|
||||||
|
|
||||||
data = packet.get("data")
|
|
||||||
|
|
||||||
await self.controller.on_pm_initiate(
|
|
||||||
data.get("from"),
|
|
||||||
data.get("from_nick"),
|
|
||||||
data.get("from_room"),
|
|
||||||
data.get("pm_id")
|
|
||||||
)
|
|
||||||
|
|
||||||
async def _handle_send(self, packet):
|
|
||||||
"""
|
|
||||||
From api.euphoria.io:
|
|
||||||
A send-event indicates a message received by the room from another
|
|
||||||
session.
|
|
||||||
"""
|
|
||||||
|
|
||||||
data = packet.get("data")
|
|
||||||
message = Message.from_dict(data)
|
|
||||||
|
|
||||||
await self.controller.on_send(message)
|
|
||||||
|
|
||||||
async def _handle_snapshot(self, packet):
|
|
||||||
"""
|
|
||||||
From api.euphoria.io:
|
|
||||||
A snapshot-event indicates that a session has successfully joined a
|
|
||||||
room. It also offers a snapshot of the room’s state and recent history.
|
|
||||||
"""
|
|
||||||
|
|
||||||
data = packet.get("data")
|
|
||||||
|
|
||||||
sessions = [Session.from_dict(d) for d in data.get("listing")]
|
|
||||||
messages = [Message.from_dict(d) for d in data.get("log")]
|
|
||||||
|
|
||||||
# update self.listing
|
|
||||||
for session in sessions:
|
|
||||||
self.listing.add(session)
|
|
||||||
|
|
||||||
self.session.nick = data.get("nick", None)
|
|
||||||
|
|
||||||
self.pm_with_nick = data.get("pm_with_nick", None),
|
|
||||||
self.pm_with_user_id = data.get("pm_with_user_id", None)
|
|
||||||
|
|
||||||
await self.controller.on_connected()
|
|
||||||
|
|
||||||
await self.controller.on_snapshot(
|
|
||||||
data.get("identity"),
|
|
||||||
data.get("session_id"),
|
|
||||||
self.version,
|
|
||||||
sessions, # listing
|
|
||||||
messages, # log
|
|
||||||
nick=self.session.nick,
|
|
||||||
pm_with_nick=self.pm_with_nick,
|
|
||||||
pm_with_user_id=self.pm_with_user_id
|
|
||||||
)
|
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue