|
|
@ -106,6 +106,8 @@ class Client: |
|
|
|
} |
|
|
|
self._closed = False |
|
|
|
|
|
|
|
# internals |
|
|
|
|
|
|
|
def _resolve_mentions(self, content, mentions): |
|
|
|
if isinstance(mentions, list): |
|
|
|
return [user.id for user in mentions] |
|
|
@ -141,7 +143,6 @@ class Client: |
|
|
|
else: |
|
|
|
raise InvalidArgument('Destination must be Channel, PrivateChannel, User, or Object') |
|
|
|
|
|
|
|
# Compatibility shim |
|
|
|
def __getattr__(self, name): |
|
|
|
if name in ('user', 'email', 'servers', 'private_channels', 'messages'): |
|
|
|
return getattr(self.connection, name) |
|
|
@ -149,7 +150,6 @@ class Client: |
|
|
|
msg = "'{}' object has no attribute '{}'" |
|
|
|
raise AttributeError(msg.format(self.__class__, name)) |
|
|
|
|
|
|
|
# Compatibility shim |
|
|
|
def __setattr__(self, name, value): |
|
|
|
if name in ('user', 'email', 'servers', 'private_channels', |
|
|
|
'messages'): |
|
|
@ -157,16 +157,6 @@ class Client: |
|
|
|
else: |
|
|
|
object.__setattr__(self, name, value) |
|
|
|
|
|
|
|
@property |
|
|
|
def is_logged_in(self): |
|
|
|
"""bool: Indicates if the client has logged in successfully.""" |
|
|
|
return self._is_logged_in |
|
|
|
|
|
|
|
@property |
|
|
|
def is_closed(self): |
|
|
|
"""bool: Indicates if the websocket connection is closed.""" |
|
|
|
return self._closed |
|
|
|
|
|
|
|
@asyncio.coroutine |
|
|
|
def _get_gateway(self): |
|
|
|
resp = yield from self.session.get(endpoints.GATEWAY, headers=self.headers) |
|
|
@ -193,6 +183,103 @@ class Client: |
|
|
|
if hasattr(self, method): |
|
|
|
utils.create_task(self._run_event(method, *args, **kwargs), loop=self.loop) |
|
|
|
|
|
|
|
@asyncio.coroutine |
|
|
|
def keep_alive_handler(self, interval): |
|
|
|
while not self._closed: |
|
|
|
payload = { |
|
|
|
'op': 1, |
|
|
|
'd': int(time.time()) |
|
|
|
} |
|
|
|
|
|
|
|
msg = 'Keeping websocket alive with timestamp {}' |
|
|
|
log.debug(msg.format(payload['d'])) |
|
|
|
yield from self.ws.send(to_json(payload)) |
|
|
|
yield from asyncio.sleep(interval) |
|
|
|
|
|
|
|
@asyncio.coroutine |
|
|
|
def on_error(self, event_method, *args, **kwargs): |
|
|
|
"""|coro| |
|
|
|
|
|
|
|
The default error handler provided by the client. |
|
|
|
|
|
|
|
By default this prints to ``sys.stderr`` however it could be |
|
|
|
overridden to have a different implementation. |
|
|
|
Check :func:`discord.on_error` for more details. |
|
|
|
""" |
|
|
|
print('Ignoring exception in {}'.format(event_method), file=sys.stderr) |
|
|
|
traceback.print_exc() |
|
|
|
|
|
|
|
def received_message(self, msg): |
|
|
|
log.debug('WebSocket Event: {}'.format(msg)) |
|
|
|
self.dispatch('socket_response', msg) |
|
|
|
|
|
|
|
op = msg.get('op') |
|
|
|
data = msg.get('d') |
|
|
|
|
|
|
|
if op != 0: |
|
|
|
log.info('Unhandled op {}'.format(op)) |
|
|
|
return |
|
|
|
|
|
|
|
event = msg.get('t') |
|
|
|
|
|
|
|
if event == 'READY': |
|
|
|
interval = data['heartbeat_interval'] / 1000.0 |
|
|
|
self.keep_alive = utils.create_task(self.keep_alive_handler(interval), loop=self.loop) |
|
|
|
|
|
|
|
if event in ('READY', 'MESSAGE_CREATE', 'MESSAGE_DELETE', |
|
|
|
'MESSAGE_UPDATE', 'PRESENCE_UPDATE', 'USER_UPDATE', |
|
|
|
'CHANNEL_DELETE', 'CHANNEL_UPDATE', 'CHANNEL_CREATE', |
|
|
|
'GUILD_MEMBER_ADD', 'GUILD_MEMBER_REMOVE', |
|
|
|
'GUILD_MEMBER_UPDATE', 'GUILD_CREATE', 'GUILD_DELETE', |
|
|
|
'GUILD_ROLE_CREATE', 'GUILD_ROLE_DELETE', 'TYPING_START', |
|
|
|
'GUILD_ROLE_UPDATE', 'VOICE_STATE_UPDATE'): |
|
|
|
parser = 'parse_' + event.lower() |
|
|
|
if hasattr(self.connection, parser): |
|
|
|
getattr(self.connection, parser)(data) |
|
|
|
else: |
|
|
|
log.info("Unhandled event {}".format(event)) |
|
|
|
|
|
|
|
@asyncio.coroutine |
|
|
|
def _make_websocket(self): |
|
|
|
if not self.is_logged_in: |
|
|
|
raise ClientException('You must be logged in to connect') |
|
|
|
|
|
|
|
self.gateway = yield from self._get_gateway() |
|
|
|
self.ws = yield from websockets.connect(self.gateway) |
|
|
|
self.ws.max_size = None |
|
|
|
log.info('Created websocket connected to {0.gateway}'.format(self)) |
|
|
|
payload = { |
|
|
|
'op': 2, |
|
|
|
'd': { |
|
|
|
'token': self.token, |
|
|
|
'properties': { |
|
|
|
'$os': sys.platform, |
|
|
|
'$browser': 'discord.py', |
|
|
|
'$device': 'discord.py', |
|
|
|
'$referrer': '', |
|
|
|
'$referring_domain': '' |
|
|
|
}, |
|
|
|
'v': 3 |
|
|
|
} |
|
|
|
} |
|
|
|
|
|
|
|
yield from self.ws.send(to_json(payload)) |
|
|
|
log.info('sent the initial payload to create the websocket') |
|
|
|
|
|
|
|
# properties |
|
|
|
|
|
|
|
@property |
|
|
|
def is_logged_in(self): |
|
|
|
"""bool: Indicates if the client has logged in successfully.""" |
|
|
|
return self._is_logged_in |
|
|
|
|
|
|
|
@property |
|
|
|
def is_closed(self): |
|
|
|
"""bool: Indicates if the websocket connection is closed.""" |
|
|
|
return self._closed |
|
|
|
|
|
|
|
# helpers/getters |
|
|
|
|
|
|
|
def get_channel(self, id): |
|
|
|
"""Returns a :class:`Channel` or :class:`PrivateChannel` with the following ID. If not found, returns None.""" |
|
|
|
return self.connection.get_channel(id) |
|
|
@ -231,18 +318,7 @@ class Client: |
|
|
|
for member in server.members: |
|
|
|
yield member |
|
|
|
|
|
|
|
@asyncio.coroutine |
|
|
|
def close(self): |
|
|
|
"""Closes the websocket connection. |
|
|
|
|
|
|
|
To reconnect the websocket connection, :meth:`connect` must be used. |
|
|
|
""" |
|
|
|
if self._closed: |
|
|
|
return |
|
|
|
|
|
|
|
yield from self.ws.close() |
|
|
|
self.keep_alive.cancel() |
|
|
|
self._closed = True |
|
|
|
# login state management |
|
|
|
|
|
|
|
@asyncio.coroutine |
|
|
|
def login(self, email, password): |
|
|
@ -289,87 +365,14 @@ class Client: |
|
|
|
self._is_logged_in = True |
|
|
|
|
|
|
|
@asyncio.coroutine |
|
|
|
def keep_alive_handler(self, interval): |
|
|
|
while not self._closed: |
|
|
|
payload = { |
|
|
|
'op': 1, |
|
|
|
'd': int(time.time()) |
|
|
|
} |
|
|
|
|
|
|
|
msg = 'Keeping websocket alive with timestamp {}' |
|
|
|
log.debug(msg.format(payload['d'])) |
|
|
|
yield from self.ws.send(to_json(payload)) |
|
|
|
yield from asyncio.sleep(interval) |
|
|
|
|
|
|
|
@asyncio.coroutine |
|
|
|
def on_error(self, event_method, *args, **kwargs): |
|
|
|
def logout(self): |
|
|
|
"""|coro| |
|
|
|
|
|
|
|
The default error handler provided by the client. |
|
|
|
|
|
|
|
By default this prints to ``sys.stderr`` however it could be |
|
|
|
overridden to have a different implementation. |
|
|
|
Check :func:`discord.on_error` for more details. |
|
|
|
""" |
|
|
|
print('Ignoring exception in {}'.format(event_method), file=sys.stderr) |
|
|
|
traceback.print_exc() |
|
|
|
|
|
|
|
def received_message(self, msg): |
|
|
|
log.debug('WebSocket Event: {}'.format(msg)) |
|
|
|
self.dispatch('socket_response', msg) |
|
|
|
|
|
|
|
op = msg.get('op') |
|
|
|
data = msg.get('d') |
|
|
|
|
|
|
|
if op != 0: |
|
|
|
log.info('Unhandled op {}'.format(op)) |
|
|
|
return |
|
|
|
|
|
|
|
event = msg.get('t') |
|
|
|
|
|
|
|
if event == 'READY': |
|
|
|
interval = data['heartbeat_interval'] / 1000.0 |
|
|
|
self.keep_alive = utils.create_task(self.keep_alive_handler(interval), loop=self.loop) |
|
|
|
|
|
|
|
if event in ('READY', 'MESSAGE_CREATE', 'MESSAGE_DELETE', |
|
|
|
'MESSAGE_UPDATE', 'PRESENCE_UPDATE', 'USER_UPDATE', |
|
|
|
'CHANNEL_DELETE', 'CHANNEL_UPDATE', 'CHANNEL_CREATE', |
|
|
|
'GUILD_MEMBER_ADD', 'GUILD_MEMBER_REMOVE', |
|
|
|
'GUILD_MEMBER_UPDATE', 'GUILD_CREATE', 'GUILD_DELETE', |
|
|
|
'GUILD_ROLE_CREATE', 'GUILD_ROLE_DELETE', 'TYPING_START', |
|
|
|
'GUILD_ROLE_UPDATE', 'VOICE_STATE_UPDATE'): |
|
|
|
parser = 'parse_' + event.lower() |
|
|
|
if hasattr(self.connection, parser): |
|
|
|
getattr(self.connection, parser)(data) |
|
|
|
else: |
|
|
|
log.info("Unhandled event {}".format(event)) |
|
|
|
|
|
|
|
@asyncio.coroutine |
|
|
|
def _make_websocket(self): |
|
|
|
if not self.is_logged_in: |
|
|
|
raise ClientException('You must be logged in to connect') |
|
|
|
|
|
|
|
self.gateway = yield from self._get_gateway() |
|
|
|
self.ws = yield from websockets.connect(self.gateway) |
|
|
|
self.ws.max_size = None |
|
|
|
log.info('Created websocket connected to {0.gateway}'.format(self)) |
|
|
|
payload = { |
|
|
|
'op': 2, |
|
|
|
'd': { |
|
|
|
'token': self.token, |
|
|
|
'properties': { |
|
|
|
'$os': sys.platform, |
|
|
|
'$browser': 'discord.py', |
|
|
|
'$device': 'discord.py', |
|
|
|
'$referrer': '', |
|
|
|
'$referring_domain': '' |
|
|
|
}, |
|
|
|
'v': 3 |
|
|
|
} |
|
|
|
} |
|
|
|
|
|
|
|
yield from self.ws.send(to_json(payload)) |
|
|
|
log.info('sent the initial payload to create the websocket') |
|
|
|
Logs out of Discord and closes all connections.""" |
|
|
|
response = yield from self.session.post(endpoints.LOGOUT, headers=self.headers) |
|
|
|
yield from self.close() |
|
|
|
self._is_logged_in = False |
|
|
|
log.debug(request_logging_format.format(method='POST', response=response)) |
|
|
|
|
|
|
|
@asyncio.coroutine |
|
|
|
def connect(self): |
|
|
@ -396,6 +399,21 @@ class Client: |
|
|
|
|
|
|
|
self.received_message(json.loads(msg)) |
|
|
|
|
|
|
|
@asyncio.coroutine |
|
|
|
def close(self): |
|
|
|
"""Closes the websocket connection. |
|
|
|
|
|
|
|
To reconnect the websocket connection, :meth:`connect` must be used. |
|
|
|
""" |
|
|
|
if self._closed: |
|
|
|
return |
|
|
|
|
|
|
|
yield from self.ws.close() |
|
|
|
self.keep_alive.cancel() |
|
|
|
self._closed = True |
|
|
|
|
|
|
|
# event registration |
|
|
|
|
|
|
|
def event(self, coro): |
|
|
|
"""A decorator that registers an event to listen to. |
|
|
|
|
|
|
@ -435,6 +453,8 @@ class Client: |
|
|
|
|
|
|
|
return self.event(coro) |
|
|
|
|
|
|
|
# Message sending/management |
|
|
|
|
|
|
|
@asyncio.coroutine |
|
|
|
def start_private_message(self, user): |
|
|
|
"""|coro| |
|
|
@ -674,7 +694,7 @@ class Client: |
|
|
|
yield from utils._verify_successful_response(response) |
|
|
|
|
|
|
|
@asyncio.coroutine |
|
|
|
def edit_message(self, message, new_content, mentions=True): |
|
|
|
def edit_message(self, message, new_content, *, mentions=True): |
|
|
|
"""|coro| |
|
|
|
|
|
|
|
Edits a :class:`Message` with the new message content. |
|
|
@ -717,16 +737,6 @@ class Client: |
|
|
|
log.debug(request_success_log.format(response=response, json=payload, data=data)) |
|
|
|
return Message(channel=channel, **data) |
|
|
|
|
|
|
|
@asyncio.coroutine |
|
|
|
def logout(self): |
|
|
|
"""|coro| |
|
|
|
|
|
|
|
Logs out of Discord and closes all connections.""" |
|
|
|
response = yield from self.session.post(endpoints.LOGOUT, headers=self.headers) |
|
|
|
yield from self.close() |
|
|
|
self._is_logged_in = False |
|
|
|
log.debug(request_logging_format.format(method='POST', response=response)) |
|
|
|
|
|
|
|
@asyncio.coroutine |
|
|
|
def logs_from(self, channel, limit=100, *, before=None, after=None): |
|
|
|
"""|coro| |
|
|
@ -789,3 +799,244 @@ class Client: |
|
|
|
yield from utils._verify_successful_response(response) |
|
|
|
messages = yield from response.json() |
|
|
|
return generator_wrapper(messages) |
|
|
|
|
|
|
|
# Member management |
|
|
|
|
|
|
|
@asyncio.coroutine |
|
|
|
def kick(self, member): |
|
|
|
"""|coro| |
|
|
|
|
|
|
|
Kicks a :class:`Member` from the server they belong to. |
|
|
|
|
|
|
|
Warning |
|
|
|
-------- |
|
|
|
This function kicks the :class:`Member` based on the server it |
|
|
|
belongs to, which is accessed via :attr:`Member.server`. So you |
|
|
|
must have the proper permissions in that server. |
|
|
|
|
|
|
|
Parameters |
|
|
|
----------- |
|
|
|
member : :class:`Member` |
|
|
|
The member to kick from their server. |
|
|
|
|
|
|
|
Raises |
|
|
|
------- |
|
|
|
Forbidden |
|
|
|
You do not have the proper permissions to kick. |
|
|
|
HTTPException |
|
|
|
Kicking failed. |
|
|
|
""" |
|
|
|
|
|
|
|
url = '{0}/{1.server.id}/members/{1.id}'.format(endpoints.SERVERS, member) |
|
|
|
response = yield from self.session.delete(url, headers=self.headers) |
|
|
|
log.debug(request_logging_format.format(method='DELETE', response=response)) |
|
|
|
yield from utils._verify_successful_response(response) |
|
|
|
|
|
|
|
@asyncio.coroutine |
|
|
|
def ban(self, member): |
|
|
|
"""|coro| |
|
|
|
|
|
|
|
Bans a :class:`Member` from the server they belong to. |
|
|
|
|
|
|
|
Warning |
|
|
|
-------- |
|
|
|
This function bans the :class:`Member` based on the server it |
|
|
|
belongs to, which is accessed via :attr:`Member.server`. So you |
|
|
|
must have the proper permissions in that server. |
|
|
|
|
|
|
|
Parameters |
|
|
|
----------- |
|
|
|
member : :class:`Member` |
|
|
|
The member to ban from their server. |
|
|
|
|
|
|
|
Raises |
|
|
|
------- |
|
|
|
Forbidden |
|
|
|
You do not have the proper permissions to ban. |
|
|
|
HTTPException |
|
|
|
Banning failed. |
|
|
|
""" |
|
|
|
|
|
|
|
url = '{0}/{1.server.id}/bans/{1.id}'.format(endpoints.SERVERS, member) |
|
|
|
response = yield from self.session.put(url, headers=self.headers) |
|
|
|
log.debug(request_logging_format.format(method='PUT', response=response)) |
|
|
|
yield from utils._verify_successful_response(response) |
|
|
|
|
|
|
|
@asyncio.coroutine |
|
|
|
def unban(self, member): |
|
|
|
"""|coro| |
|
|
|
|
|
|
|
Unbans a :class:`Member` from the server they belong to. |
|
|
|
|
|
|
|
Warning |
|
|
|
-------- |
|
|
|
This function unbans the :class:`Member` based on the server it |
|
|
|
belongs to, which is accessed via :attr:`Member.server`. So you |
|
|
|
must have the proper permissions in that server. |
|
|
|
|
|
|
|
Parameters |
|
|
|
----------- |
|
|
|
member : :class:`Member` |
|
|
|
The member to unban from their server. |
|
|
|
|
|
|
|
Raises |
|
|
|
------- |
|
|
|
Forbidden |
|
|
|
You do not have the proper permissions to unban. |
|
|
|
HTTPException |
|
|
|
Unbanning failed. |
|
|
|
""" |
|
|
|
|
|
|
|
url = '{0}/{1.server.id}/bans/{1.id}'.format(endpoints.SERVERS, member) |
|
|
|
response = yield from self.session.delete(url, headers=self.headers) |
|
|
|
log.debug(request_logging_format.format(method='DELETE', response=response)) |
|
|
|
yield from utils._verify_successful_response(response) |
|
|
|
|
|
|
|
@asyncio.coroutine |
|
|
|
def server_voice_state(self, member, *, mute=False, deafen=False): |
|
|
|
"""|coro| |
|
|
|
|
|
|
|
Server mutes or deafens a specific :class:`Member`. |
|
|
|
|
|
|
|
Warning |
|
|
|
-------- |
|
|
|
This function mutes or un-deafens the :class:`Member` based on the |
|
|
|
server it belongs to, which is accessed via :attr:`Member.server`. |
|
|
|
So you must have the proper permissions in that server. |
|
|
|
|
|
|
|
Parameters |
|
|
|
----------- |
|
|
|
member : :class:`Member` |
|
|
|
The member to unban from their server. |
|
|
|
mute : bool |
|
|
|
Indicates if the member should be server muted or un-muted. |
|
|
|
deafen : bool |
|
|
|
Indicates if the member should be server deafened or un-deafened. |
|
|
|
|
|
|
|
Raises |
|
|
|
------- |
|
|
|
Forbidden |
|
|
|
You do not have the proper permissions to deafen or mute. |
|
|
|
HTTPException |
|
|
|
The operation failed. |
|
|
|
""" |
|
|
|
|
|
|
|
url = '{0}/{1.server.id}/members/{1.id}'.format(endpoints.SERVERS, member) |
|
|
|
payload = { |
|
|
|
'mute': mute, |
|
|
|
'deaf': deafen |
|
|
|
} |
|
|
|
|
|
|
|
response = yield from self.session.patch(url, headers=self.headers, data=to_json(payload)) |
|
|
|
log.debug(request_logging_format.format(method='PATCH', response=response)) |
|
|
|
yield from utils._verify_successful_response(response) |
|
|
|
|
|
|
|
@asyncio.coroutine |
|
|
|
def edit_profile(self, password, **fields): |
|
|
|
"""|coro| |
|
|
|
|
|
|
|
Edits the current profile of the client. |
|
|
|
|
|
|
|
All fields except ``password`` are optional. |
|
|
|
|
|
|
|
Note |
|
|
|
----- |
|
|
|
To upload an avatar, a *bytes-like object* must be passed in that |
|
|
|
represents the image being uploaded. If this is done through a file |
|
|
|
then the file must be opened via ``open('some_filename', 'rb')`` and |
|
|
|
the *bytes-like object* is given through the use of ``fp.read()``. |
|
|
|
|
|
|
|
The only image formats supported for uploading is JPEG and PNG. |
|
|
|
|
|
|
|
Parameters |
|
|
|
----------- |
|
|
|
password : str |
|
|
|
The current password for the client's account. |
|
|
|
new_password : str |
|
|
|
The new password you wish to change to. |
|
|
|
email : str |
|
|
|
The new email you wish to change to. |
|
|
|
username :str |
|
|
|
The new username you wish to change to. |
|
|
|
avatar : bytes |
|
|
|
A *bytes-like object* representing the image to upload. |
|
|
|
|
|
|
|
Raises |
|
|
|
------ |
|
|
|
HTTPException |
|
|
|
Editing your profile failed. |
|
|
|
""" |
|
|
|
|
|
|
|
avatar_bytes = fields.get('avatar') |
|
|
|
avatar = None |
|
|
|
if avatar_bytes is not None: |
|
|
|
fmt = 'data:{mime};base64,{data}' |
|
|
|
mime = utils._get_mime_type_for_image(avatar_bytes) |
|
|
|
b64 = b64encode(avatar_bytes).decode('ascii') |
|
|
|
avatar = fmt.format(mime=mime, data=b64) |
|
|
|
|
|
|
|
payload = { |
|
|
|
'password': password, |
|
|
|
'new_password': fields.get('new_password'), |
|
|
|
'email': fields.get('email', self.email), |
|
|
|
'username': fields.get('username', self.user.name), |
|
|
|
'avatar': avatar |
|
|
|
} |
|
|
|
|
|
|
|
url = '{0}/@me'.format(endpoints.USERS) |
|
|
|
r = yield from self.session.patch(url, headers=self.headers, data=to_json(payload)) |
|
|
|
log.debug(request_logging_format.format(method='PATCH', response=r)) |
|
|
|
yield from utils._verify_successful_response(r) |
|
|
|
|
|
|
|
data = yield from r.json() |
|
|
|
log.debug(request_success_log.format(response=r, json=payload, data=data)) |
|
|
|
self.token = data['token'] |
|
|
|
self.email = data['email'] |
|
|
|
self.headers['authorization'] = self.token |
|
|
|
|
|
|
|
@asyncio.coroutine |
|
|
|
def change_status(self, game_id=None, idle=False): |
|
|
|
"""|coro| |
|
|
|
|
|
|
|
Changes the client's status. |
|
|
|
|
|
|
|
The game_id parameter is a numeric ID (not a string) that represents |
|
|
|
a game being played currently. The list of game_id to actual games changes |
|
|
|
constantly and would thus be out of date pretty quickly. An old version of |
|
|
|
the game_id database can be seen `here`_ to help you get started. |
|
|
|
|
|
|
|
The idle parameter is a boolean parameter that indicates whether the |
|
|
|
client should go idle or not. |
|
|
|
|
|
|
|
.. _here: https://gist.github.com/Rapptz/a82b82381b70a60c281b |
|
|
|
|
|
|
|
Parameters |
|
|
|
---------- |
|
|
|
game_id : Optional[int] |
|
|
|
The game ID being played. None if no game is being played. |
|
|
|
idle : bool |
|
|
|
Indicates if the client should go idle. |
|
|
|
|
|
|
|
Raises |
|
|
|
------ |
|
|
|
InvalidArgument |
|
|
|
If the ``game_id`` parameter is convertible integer or None. |
|
|
|
""" |
|
|
|
|
|
|
|
idle_since = None if idle == False else int(time.time() * 1000) |
|
|
|
try: |
|
|
|
game_id = None if game_id is None else int(game_id) |
|
|
|
except: |
|
|
|
raise InvalidArgument('game_id must be convertible to an integer or None') |
|
|
|
|
|
|
|
payload = { |
|
|
|
'op': 3, |
|
|
|
'd': { |
|
|
|
'game_id': game_id, |
|
|
|
'idle_since': idle_since |
|
|
|
} |
|
|
|
} |
|
|
|
|
|
|
|
sent = to_json(payload) |
|
|
|
log.debug('Sending "{}" to change status'.format(sent)) |
|
|
|
yield from self.ws.send(sent) |
|
|
|