Compare commits

..

110 commits

Author SHA1 Message Date
eba398e5d3 Bump version to 1.2.0 2022-08-21 14:26:04 +02:00
37c4ba703a Switch to pyproject.toml style setuptools config 2022-08-21 14:25:04 +02:00
74caea4e92 Update websockets dependency 2022-08-21 14:10:50 +02:00
1d25b596bb Bump version to 1.1.5 2020-01-26 22:50:20 +00:00
455d2af251 Use IOError to catch more exceptions 2019-11-30 16:30:52 +00:00
1b9860ba1e Bump version to 1.1.4 2019-06-21 07:23:49 +00:00
66b56a450e Fix room firing incorrect event 2019-06-21 07:21:50 +00:00
2215e75c34 Add config file to example 2019-04-20 19:31:11 +00:00
7024686ff2 Update example gitignore to latest version 2019-04-20 19:01:25 +00:00
1c409601db Update echobot to latest yaboli version 2019-04-20 18:55:47 +00:00
74a8adfa58 Fix imports 2019-04-19 11:09:08 +00:00
6a15e1a948 Add docstrings to Bot functions 2019-04-19 11:08:19 +00:00
eb9cc4f9bd Make KILL_REPLY and RESTART_REPLY optional 2019-04-19 10:52:39 +00:00
ca56de710c Fix changelog formatting 2019-04-19 10:04:12 +00:00
83af4ff9e8 Bump version to 1.1.3 2019-04-19 10:01:50 +00:00
de4ba53de8 Fix config file not reloading on bot restart 2019-04-19 09:57:09 +00:00
d9f25a04fb Time out when creating the ws connections 2019-04-14 22:25:42 +00:00
e53ce42e99 Bump version to 1.1.2 2019-04-14 20:03:29 +00:00
1297cf201b Fix room authentication 2019-04-14 19:56:14 +00:00
838c364066 Bump version to 1.1.1 2019-04-14 19:28:40 +00:00
c579adca9a Re-add database 2019-04-14 19:27:16 +00:00
7e74499f81 Bump version to 1.1.0 2019-04-14 16:12:33 +00:00
7780cb92de Update the docs 2019-04-14 16:10:40 +00:00
86472afb3f Pass along ConfigParsers instead of file names 2019-04-14 16:07:37 +00:00
2f7502723b Bump version to 1.0.0 2019-04-13 22:09:58 +00:00
7e56de60da Clean up changelog and readme 2019-04-13 21:49:00 +00:00
24128a460a Add fancy argument parsing 2019-04-13 21:47:27 +00:00
135640ca44 Log in/out and pm 2019-04-13 20:31:46 +00:00
0d58f61652 Clean up readme 2019-04-13 15:36:02 +00:00
e09e2d215f Add cookie support 2019-04-13 15:32:58 +00:00
7b7ddaa0d1 Save bot config file 2019-04-13 00:30:18 +00:00
ac70f45229 Add some basic documentation 2019-04-13 00:22:42 +00:00
b726d8f9f0 Add !restart command to botrulez 2019-04-12 23:14:48 +00:00
b7579b5b78 Bump version to 0.2.0 2019-04-12 21:04:51 +00:00
8f576b1147 Update installation instructions 2019-04-12 20:49:30 +00:00
6741d36009 Clean up 2019-04-12 20:40:47 +00:00
5586020d1e Change config file format 2019-04-12 20:40:27 +00:00
f40fb2d45d Add on_connected to client 2019-04-12 20:14:22 +00:00
f46ca47a28 Add ALIASES variable to Bot 2019-04-12 20:05:36 +00:00
1d772e7215 Set version number update reminder 2019-04-12 19:31:42 +00:00
1d66b3a518 Clean up 2019-04-12 19:20:13 +00:00
903ba4973b Use setuptools 2019-04-12 18:13:15 +00:00
62e5adc878 Add logging and bot starting utils 2019-04-12 13:28:40 +00:00
8cd2c8d125 Use config files for bots 2019-04-12 12:53:34 +00:00
a78f57db7a Untruncate LiveMessages 2019-04-12 12:12:04 +00:00
3255ea770e Implement !kill 2019-04-12 12:08:26 +00:00
a0f7c8e84a Implement !uptime 2019-04-12 11:37:36 +00:00
14b4e74c7e Add README 2019-04-12 00:28:51 +00:00
2bf512d8dc Rename argstr to raw 2019-04-12 00:28:08 +00:00
8dd94b6ac8 Clean up 2019-04-11 23:51:28 +00:00
9cb38c4ca8 Add modules 2019-04-11 23:35:44 +00:00
40cb7917c8 Fix escaping 2019-04-11 23:33:06 +00:00
147ea92102 Improve help formatting 2019-04-11 23:32:44 +00:00
b7831cfeb2 Fix method signature 2019-04-11 23:28:28 +00:00
ee726b93ff Check if a command has arguments 2019-04-11 23:27:19 +00:00
788b116d83 Clean up 2019-04-11 21:44:05 +00:00
27b3f4b29e Add Bot 2019-04-11 21:33:17 +00:00
82a292b5d8 Fix room not knowing its own nick initially 2019-04-11 21:32:20 +00:00
72d10f5c43 Implement command system 2019-04-11 21:32:01 +00:00
4cd422493b Update own session on who command 2019-04-10 17:07:30 +00:00
ed2bd2a2c6 Add some logging to Room 2019-04-10 14:56:24 +00:00
c3fa1bf396 Add Client
Some functionality is yet to be tested.
2019-04-10 14:07:22 +00:00
17d4fb216e Don't try to set an empty nick 2019-04-10 09:01:08 +00:00
97f05272ca Implement Message related classes 2019-04-10 08:56:01 +00:00
06af0e7faa Fix minor crashes 2019-04-10 08:07:26 +00:00
9d0c588685 Reset ping check on ping-events only 2019-04-10 08:05:53 +00:00
bf736430a6 Implement Session-related classes 2019-04-10 08:05:39 +00:00
40edcdc791 Implement Room
First, the Room itself.
2019-04-10 08:03:36 +00:00
20f635a7ae Clean up pinging 2019-04-08 10:54:16 +00:00
b437731c7f Improve pinging
It actually resets the ping check now. Also, I'm now using the event
callbacks for sending the ping-reply, which only seems appropriate.
2019-04-07 19:46:30 +00:00
8c34a450c1 Clean up 2019-04-07 19:05:17 +00:00
2a9cd03c47 Send and receive packets 2019-04-07 18:11:16 +00:00
2de2cbf92c Document stuff
Also, use the correct exceptions when interacting with _ws
2019-04-07 15:38:51 +00:00
c60526a34d Add some event calls and event loop 2019-04-07 12:20:31 +00:00
a24e4aa18a Add debug logging 2019-04-06 23:44:42 +00:00
500d91a14c Make mypy happy 2019-04-06 22:24:29 +00:00
47a8014b4c Fix disconnect() bug
I just forgot to set the _state to _DISCONNECTING while disconnecting.
2019-04-06 19:04:19 +00:00
0d9161fd1e Switch to asyncio.Condition 2019-04-06 18:07:33 +00:00
f6f7cc5aa6 Clean up 2019-04-06 17:17:43 +00:00
23425090cc Implement most of Connection 2019-04-06 17:17:10 +00:00
325af11fea Add Connection concept 2019-04-06 10:22:23 +00:00
a5af01f669 Start rewrite (yet again)
This will hopefully be the final rewrite.
2019-04-06 09:02:09 +00:00
Joscha
5e108fd31b Add Module and ModuleBot 2018-09-12 21:18:33 +00:00
Joscha
2c1a1996a1 Remove debugging logging 2018-08-19 20:24:54 +00:00
Joscha
85eb820fa6 Keep trailing backslash in arg parsing 2018-08-13 16:34:17 +00:00
Joscha
e60d0ba81e Add PersonalAccountView 2018-08-13 13:27:56 +00:00
Joscha
6ed513efd9 Log failure to connect 2018-08-08 17:01:27 +00:00
Joscha
ceaf8748e9 Fix bots vanishing after trying to reconnect 2018-08-08 16:55:59 +00:00
Joscha
fa579ec231 Add license 2018-08-06 14:05:28 +00:00
Joscha
0881d25103 Fix joining rooms with passwords 2018-08-05 11:54:25 +00:00
Joscha
aee21f359c Restructure command parsing 2018-08-04 10:46:42 +00:00
Joscha
ac04e7fd30 Notify inhabitant of edits 2018-08-04 09:12:55 +00:00
Joscha
6b1348236d Retry transaction until it works 2018-08-03 19:49:01 +00:00
Joscha
1fee49d0e4 Fix various things
- Fix some typos in Message and SessionView
- Database operations can now access self
- Fix mention (missing comma) and rename mention_reduce to normalize
2018-08-03 18:13:32 +00:00
Joscha
5c254f4e70 Use configparser in examplebot 2018-08-02 20:30:45 +00:00
Joscha
78bb6b935f Set some Connection parameters in join_room 2018-08-01 21:44:46 +00:00
Joscha
d55d05826f Notice when connection runs out of retries 2018-08-01 21:41:08 +00:00
Joscha
df72a3d9cf get_message returns None when no message was found 2018-07-31 15:19:11 +00:00
Joscha
bb2dadb862 Add database module 2018-07-31 15:18:57 +00:00
Joscha
339d3ca516 Rename event functions 2018-07-29 15:26:45 +00:00
Joscha
46cd20ac74 Log more stuff 2018-07-29 12:20:40 +00:00
Joscha
31f4e23aba Enable debugging in examplebot 2018-07-29 11:43:26 +00:00
Joscha
55798a5b88 Implement rewind/fast-forward system 2018-07-27 22:49:24 +00:00
Joscha
7e28c6e3dd Deal with closed ws connection while sending 2018-07-27 19:22:04 +00:00
Joscha
d9761008f6 Change botrulez arguments and update examplebot 2018-07-27 19:09:43 +00:00
Joscha
943537b57a Add bot's own session to listing 2018-07-27 15:12:23 +00:00
Joscha
f8d3f68ed9 Clean up 2018-07-27 12:21:57 +00:00
Joscha
bd75d0ebba Add new inhabitant callback 2018-07-27 12:21:33 +00:00
Joscha
6174fa6ff1 Add yaboli.trigger decorator 2018-07-27 10:29:55 +00:00
Joscha
e39f41e183 Remove debugging stuff 2018-07-26 22:31:50 +00:00
28 changed files with 3338 additions and 1003 deletions

6
.gitignore vendored
View file

@ -1,2 +1,4 @@
yaboli/__pycache__/ __pycache__/
*.db *.egg-info/
/.mypy_cache/
/.venv/

60
CHANGELOG.md Normal file
View file

@ -0,0 +1,60 @@
# Changelog
## Next version
## 1.2.0 (2022-08-21)
- update websockets dependency
- switch to pyproject.toml style setuptools config
## 1.1.5 (2020-01-26)
- more stability (I think)
## 1.1.4 (2019-06-21)
- add docstrings to `Bot`
- change `KILL_REPLY` and `RESTART_REPLY` to be optional in `Bot`
- fix imports
- fix room firing incorrect event
- update echobot example to newest version
- update example gitignore to newest version
## 1.1.3 (2019-04-19)
- add timeout for creating ws connections
- fix config file not reloading when restarting bots
## 1.1.2 (2019-04-14)
- fix room authentication
- resolve to test yaboli more thoroughly before publishing a new version
## 1.1.1 (2019-04-14)
- add database class for easier sqlite3 access
## 1.1.0 (2019-04-14)
- change how config files are passed along
- change module system to support config file changes
## 1.0.0 (2019-04-13)
- add fancy argument parsing
- add login and logout command to room
- add pm command to room
- add cookie support
- add !restart to botrulez
- add Bot config file saving
- fix the Room not setting its nick correctly upon reconnecting
## 0.2.0 (2019-04-12)
- add `ALIASES` variable to `Bot`
- add `on_connected` function to `Client`
- change config file format
## 0.1.0 (2019-04-12)
- use setuptools

View file

@ -1,21 +0,0 @@
import asyncio
import yaboli
class ExampleBot(yaboli.Bot):
async def send(self, room, message):
ping = "ExamplePong!"
short_help = "Example bot for the yaboli bot library"
long_help = "I'm an example bot for the yaboli bot library, which can be found at https://github.com/Garmelon/yaboli"
await self.botrulez_ping_general(room, message, ping_text=ping)
await self.botrulez_ping_specific(room, message, ping_text=ping)
await self.botrulez_help_general(room, message, help_text=short_help)
await self.botrulez_help_specific(room, message, help_text=long_help)
await self.botrulez_uptime(room, message)
await self.botrulez_kill(room, message)
await self.botrulez_restart(room, message)
forward = send # should work without modifications for most bots
bot = ExampleBot("ExampleBot", "examplebot_cookies", rooms=["test", "welcome"])
asyncio.get_event_loop().run_forever()

21
LICENSE Normal file
View file

@ -0,0 +1,21 @@
MIT License
Copyright (c) 2018 - 2019 Garmelon
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

85
README.md Normal file
View file

@ -0,0 +1,85 @@
# Yaboli
Yaboli (**Y**et **A**nother **Bo**t **Li**brary) is a Python library for
creating bots for [euphoria.io](https://euphoria.io).
- [Documentation](docs/index.md)
- [Changelog](CHANGELOG.md)
## Installation
Ensure that you have at least Python 3.7 installed.
To install yaboli or update your installation to the latest version, run:
```
$ pip install git+https://github.com/Garmelon/yaboli@v1.2.0
```
The use of [venv](https://docs.python.org/3/library/venv.html) is recommended.
## Example echo bot
A simple echo bot that conforms to the
[botrulez](https://github.com/jedevc/botrulez) can be written like so:
```python
class EchoBot(yaboli.Bot):
HELP_GENERAL = "/me echoes back what you said"
HELP_SPECIFIC = [
"This bot only has one command:",
"!echo <text> reply with exactly <text>",
]
def __init__(self, config_file):
super().__init__(config_file)
self.register_botrulez(kill=True)
self.register_general("echo", self.cmd_echo)
async def cmd_echo(self, room, message, args):
await message.reply(args.raw)
```
The bot's nick, cookie file and default rooms are specified in a config file,
like so:
```ini
[general]
nick = EchoBot
cookie_file = bot.cookie
[rooms]
test
```
The help command from the botrulez uses the `HELP_GENERAL` and `HELP_SPECIFIC`
fields.
In the `__init__` function, the bot's commands are registered. The required
botrulez commands (!ping, !help, !uptime) are enabled by default. Other
commands like !kill need to be enabled explicitly.
In the `cmd_echo` function, the echo command is implemented. In this case, the
bot replies to the message containing the command with the raw argument string,
i. e. the text between the end of the "!echo" and the end of the whole message.
The full version of this echobot can be found [in the
examples](examples/echo/).
## TODOs
- [ ] document yaboli (markdown files in a "docs" folder?)
- [ ] document new classes (docstrings, maybe comments)
- [ ] write examples
- [ ] make yaboli package play nice with mypy
- [x] implement !uptime for proper botrulez conformity
- [x] implement !kill
- [x] untruncate LiveMessage-s
- [x] config file support for bots, used by default
- [x] make it easier to enable log messages
- [x] make it easier to run bots
- [x] package in a distutils-compatible way (users should be able to install
yaboli using `pip install git+https://github.com/Garmelon/yaboli`)
- [x] implement !restart
- [x] write project readme
- [x] cookie support
- [x] fancy argument parsing

13
docs/bot_setup.md Normal file
View file

@ -0,0 +1,13 @@
# Setting up and running a bot
## Installing yaboli
TODO
## Configuring the bot
TODO
## Running the bot
TODO

89
docs/index.md Normal file
View file

@ -0,0 +1,89 @@
# Index for yaboli docs
- [Setting up and running a bot](bot_setup.md)
- Classes
- [Bot](bot.md)
## Getting started
First, read the [overview](#library-structure-overview) below.
To set up your project, follow the [setup guide](bot_setup.md).
To get a feel for how bots are structured, have a look at the example bots or
read through the docstrings in the `Bot` class.
## Library structure overview
### Message, Session
A `Message` represents a single message. It contains all the fields [specified
in the API](http://api.euphoria.io/#message), in addition to a few utility
functions.
Similar to a `Message`, a `Session` represents a [session
view](http://api.euphoria.io/#sessionview) and also contains almost all the
fields specified in the API, in addition to a few utility functions.
`Message`s and `Session`s also both contain the name of the room they
originated from.
### Room
A `Room` represents a single connection to a room on euphoria. It tries to keep
connected and reconnects if it loses connection. When connecting and
reconnecting, it automatically authenticates and sets a nick.
In addition, a `Room` also keeps track of its own session and the sessions of
all other people and bots connected to the room. It doesn't remember any
messages though, since no "correct" solution to do that exists and the method
depends on the design of the bot using the `Room` (keeping the last few
messages in memory, storing messages in a database etc.).
### LiveMessage, LiveSession
`LiveMessage`s and `LiveSession`s function the same as `Message`s and
`Session`s, with the difference that they contain the `Room` object they
originated from, instead of just a room name. This allows them to also include
a few convenience functions, like `Message.reply`.
Usually, `Room`s and `Client`s (and thus `Bot`s) will pass `LiveMessage`s and
`LiveSession`s instead of their `Message` and `Session` counterparts.
### Client
A `Client` may be connected to a few rooms on euphoria and thus manages a few
`Room` objects. It has functions for joining and leaving rooms on euphoria, and
it can also be connected to the same room multiple times (resulting in multiple
`Room` objects).
The `Client` has a few `on_<event>` functions (e. g. `on_message`, `on_join`)
that are triggered by events in any of the `Room` objects it manages. This
allows a `Client` to react to various things happening in its `Room`s.
### Bot
A `Bot` is a client that:
- is configured using a config file
- reacts to commands using a command system
- implements most commands specified in the
[botrulez](https://github.com/jedevc/botrulez)
The config file includes the bot's default nick, initial rooms and bot-specific
configuration. Upon starting a `Bot`, it joins the rooms specified in the
config, setting its nick to the default nick.
The command system can react to general and specific commands as specified in
the botrulez, and can parse command arguments with or without bash-style string
escaping, and with or without unix-like syntax (flags and optional arguments).
### Module, ModuleBot
A `Module` is a `Bot` that can also be used as a module in a `ModuleBot`. This
is like combining multiple bots into a single bot.
The most notable differences are the new `DESCRIPTION` and `standalone` fields.
The `DESCRIPTION` field contains a short description of the module, whereas the
`standalone` field answers the question whether the `Module` is being run as
standalone bot or part of a `ModuleBot`.

5
examples/echo/.gitignore vendored Normal file
View file

@ -0,0 +1,5 @@
# These files are ignored because they may contain sensitive information you
# wouldn't want in your repo. If you need to have a config file in your repo,
# store a bot.conf.default with default settings.
*.conf
*.cookie

View file

@ -0,0 +1,6 @@
[general]
nick = EchoBot
cookie_file = bot.cookie
[rooms]
test

23
examples/echo/echobot.py Normal file
View file

@ -0,0 +1,23 @@
import yaboli
class EchoBot(yaboli.Bot):
HELP_GENERAL = "/me echoes back what you said"
HELP_SPECIFIC = [
"This bot only has one command:",
"!echo <text> reply with exactly <text>",
]
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self.register_botrulez(kill=True)
self.register_general("echo", self.cmd_echo)
async def cmd_echo(self, room, message, args):
text = args.raw.strip() # ignoring leading and trailing whitespace
await message.reply(text)
if __name__ == "__main__":
yaboli.enable_logging()
yaboli.run(EchoBot)

View file

@ -0,0 +1,17 @@
# python stuff
__pycache__/
# venv stuff
bin/
include/
lib/
lib64
pyvenv.cfg
# bot stuff
#
# These files are ignored because they may contain sensitive information you
# wouldn't want in your repo. If you need to have a config file in your repo,
# store a bot.conf.default with default settings.
*.conf
*.cookie

4
mypy.ini Normal file
View file

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

30
pyproject.toml Normal file
View file

@ -0,0 +1,30 @@
[build-system]
requires = ["setuptools"]
build-backend = "setuptools.build_meta"
[project]
name = "yaboli"
version = "1.2.0"
dependencies = [
"websockets >=10.3, <11"
]
# When updating the version, also:
# - update the README.md installation instructions
# - update the changelog
# - set a tag to the update commit
# Meanings of version numbers
#
# Format: a.b.c
#
# a - increased when: major change such as a rewrite
# b - increased when: changes breaking backwards compatibility
# c - increased when: minor changes preserving backwards compatibility
#
# To specify version requirements for yaboli, the following format is
# recommended if you need version a.b.c:
#
# yaboli >=a.b.c, <a.b+1.c
#
# "b+1" is the version number of b increased by 1, not "+1" appended to b.

View file

@ -1,27 +1,83 @@
# ---------- BEGIN DEV SECTION ----------
import asyncio import asyncio
import configparser
import logging import logging
from typing import Callable, Dict
# asyncio debugging
asyncio.get_event_loop().set_debug(True) # uncomment for asycio debugging mode
logging.getLogger("asyncio").setLevel(logging.DEBUG)
# yaboli logger level
logging.getLogger(__name__).setLevel(logging.DEBUG)
# ----------- END DEV SECTION -----------
from .bot import * from .bot import *
from .cookiejar import * from .client import *
from .command import *
from .connection import * from .connection import *
from .database import *
from .events import *
from .exceptions import * from .exceptions import *
from .message import *
from .module import *
from .room import * from .room import *
from .utils import * from .session import *
from .util import *
__all__ = ( __all__ = ["STYLE", "FORMAT", "DATE_FORMAT", "FORMATTER", "enable_logging",
bot.__all__ + "run", "run_modulebot"]
connection.__all__ +
cookiejar.__all__ + __all__ += bot.__all__
exceptions.__all__ + __all__ += client.__all__
room.__all__ + __all__ += command.__all__
utils.__all__ __all__ += connection.__all__
__all__ += database.__all__
__all__ += events.__all__
__all__ += exceptions.__all__
__all__ += message.__all__
__all__ += module.__all__
__all__ += room.__all__
__all__ += session.__all__
__all__ += util.__all__
STYLE = "{"
FORMAT = "{asctime} [{levelname:<7}] <{name}>: {message}"
DATE_FORMAT = "%F %T"
FORMATTER = logging.Formatter(
fmt=FORMAT,
datefmt=DATE_FORMAT,
style=STYLE
) )
def enable_logging(name: str = "yaboli", level: int = logging.INFO) -> None:
handler = logging.StreamHandler()
handler.setFormatter(FORMATTER)
logger = logging.getLogger(name)
logger.setLevel(level)
logger.addHandler(handler)
def run(
bot_constructor: BotConstructor,
config_file: str = "bot.conf",
) -> None:
async def _run() -> None:
while True:
# Load the config file
config = configparser.ConfigParser(allow_no_value=True)
config.read(config_file)
bot = bot_constructor(config, config_file)
await bot.run()
asyncio.run(_run())
def run_modulebot(
modulebot_constructor: ModuleBotConstructor,
module_constructors: Dict[str, ModuleConstructor],
config_file: str = "bot.conf",
) -> None:
async def _run() -> None:
while True:
# Load the config file
config = configparser.ConfigParser(allow_no_value=True)
config.read(config_file)
modulebot = modulebot_constructor(config, config_file,
module_constructors)
await modulebot.run()
asyncio.run(_run())

View file

@ -1,176 +1,377 @@
import configparser
import datetime
import logging import logging
import re from typing import Callable, List, Optional
import time
from .cookiejar import *
from .room import *
from .utils import *
from .client import Client
from .command import *
from .message import LiveMessage, Message
from .room import Room
from .util import *
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
__all__ = ["Bot", "command"]
__all__ = ["Bot", "BotConstructor"]
# Some command stuff class Bot(Client):
"""
A Bot is a Client that responds to commands and uses a config file to
automatically set its nick and join rooms.
SPECIFIC_RE = re.compile(r"!(\S+)\s+@(\S+)\s*([\S\s]*)") The config file is loaded as a ConfigParser by the run() or run_modulebot()
GENERAL_RE = re.compile(r"!(\S+)\s*([\S\s]*)") functions and has the following structure:
def command(commandname, specific=True, noargs=False): A "general" section which contains:
def decorator(func): - nick - the default nick of the bot (set to the empty string if you don't
async def wrapper(self, room, message, *args, **kwargs): want to set a nick)
if specific: - cookie_file (optional) - the file the cookie should be saved in
result = self._parse_command(message.content, specific=room.session.nick)
else:
result = self._parse_command(message.content)
if result is None: return
cmd, argstr = result
if cmd != commandname: return
if noargs:
if argstr: return
return await func(self, room, message, *args, **kwargs)
else:
return await func(self, room, message, argstr, *args, **kwargs)
return wrapper
return decorator
A "rooms" section which contains a list of rooms that the bot should
automatically join. This section is optional if you overwrite started().
The room list should have the format "roomname" or "roomname = password".
# And now comes the real bot... A bot has the following attributes:
- ALIASES - list of alternate nicks the bot responds to (see
process_commands())
- PING_REPLY - used by cmd_ping()
- HELP_GENERAL - used by cmd_help_general()
- HELP_SPECIFIC - used by cmd_help_specific()
- KILL_REPLY - used by cmd_kill()
- RESTART_REPLY - used by cmd_restart()
- GENERAL_SECTION - the name of the "general" section in the config file
(see above) (default: "general")
- ROOMS_SECTION - the name of the "rooms" section in the config file (see
above) (default: "rooms")
"""
class Bot(Inhabitant): ALIASES: List[str] = []
def __init__(self, nick, cookiefile=None):
self.target_nick = nick
self.rooms = {}
self.cookiejar = CookieJar(cookiefile)
# ROOM MANAGEMENT PING_REPLY: str = "Pong!"
HELP_GENERAL: Optional[str] = None
HELP_SPECIFIC: Optional[List[str]] = None
KILL_REPLY: Optional[str] = "/me dies"
RESTART_REPLY: Optional[str] = "/me restarts"
def join_room(self, roomname, password=None): GENERAL_SECTION = "general"
if roomname in self.rooms: ROOMS_SECTION = "rooms"
return
self.rooms[roomname] = Room(self, roomname, self.target_nick, password=password, cookiejar=self.cookiejar) def __init__(self,
config: configparser.ConfigParser,
config_file: str,
) -> None:
self.config = config
self.config_file = config_file
async def part_room(self, roomname): nick = self.config[self.GENERAL_SECTION].get("nick")
room = self.rooms.pop(roomname, None) if nick is None:
if room: logger.warn(("'nick' not set in config file. Defaulting to empty"
await room.exit() " nick"))
nick = ""
# BOTRULEZ cookie_file = self.config[self.GENERAL_SECTION].get("cookie_file")
if cookie_file is None:
logger.warn(("'cookie_file' not set in config file. Using no cookie"
" file."))
@command("ping", specific=False, noargs=True) super().__init__(nick, cookie_file=cookie_file)
async def botrulez_ping_general(self, room, message, ping_text="Pong!"):
await room.send(ping_text, message.mid)
@command("ping", specific=True, noargs=True) self._commands: List[Command] = []
async def botrulez_ping_specific(self, room, message, ping_text="Pong!"):
await room.send(ping_text, message.mid)
@command("help", specific=False, noargs=True) self.start_time = datetime.datetime.now()
async def botrulez_help_general(self, room, message, help_text="Placeholder help text"):
await room.send(help_text, message.mid)
@command("help", specific=True, noargs=True) def save_config(self) -> None:
async def botrulez_help_specific(self, room, message, help_text="Placeholder help text"): """
await room.send(help_text, message.mid) Save the current state of self.config to the file passed in __init__ as
the config_file parameter.
@command("uptime", specific=True, noargs=True) Usually, this is the file that self.config was loaded from (if you use
async def botrulez_uptime(self, room, message): run or run_modulebot).
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", specific=True, noargs=True) with open(self.config_file, "w") as f:
async def botrulez_kill(self, room, message, kill_text="/me dies"): self.config.write(f)
await room.send(kill_text, message.mid)
await self.part_room(room.roomname)
@command("restart", specific=True, noargs=True) async def started(self) -> None:
async def botrulez_restart(self, room, message, restart_text="/me restarts"): """
await room.send(restart_text, message.mid) This Client function is overwritten in order to join all the rooms
await self.part_room(room.roomname) listed in the "rooms" section of self.config.
self.join_room(room.roomname, password=room.password)
# COMMAND PARSING If you need to overwrite this function but want to keep the auto-join
functionality, make sure to await super().started().
"""
@staticmethod for room, password in self.config[self.ROOMS_SECTION].items():
def parse_args(text): if password is None:
""" await self.join(room)
Use bash-style single- and double-quotes to include whitespace in arguments. else:
A backslash always escapes the next character. await self.join(room, password=password)
Any non-escaped whitespace separates arguments.
Returns a list of arguments. # Registering commands
Deals with unclosed quotes and backslashes without crashing.
"""
escape = False def register(self, command: Command) -> None:
quote = None """
args = [] Register a Command (from the yaboli.command submodule).
arg = ""
for character in text: Usually, you don't have to call this function yourself.
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: self._commands.append(command)
#return None # syntax error
if len(arg) > 0: def register_general(self,
args.append(arg) name: str,
cmdfunc: GeneralCommandFunction,
args: bool = True
) -> None:
"""
Register a function as general bot command (i. e. no @mention of the
bot nick after the !command). This function will be called by
process_commands() when the bot encounters a matching command.
return args name - the name of the command (If you want your command to be !hello,
the name is "hello".)
@staticmethod cmdfunc - the function that is called with the Room, LiveMessage and
def parse_flags(arglist): ArgumentData when the bot encounters a matching command
flags = ""
args = []
kwargs = {}
for arg in arglist: args - whether the command may have arguments (If set to False, the
# kwargs (--abc, --foo=bar) ArgumentData's has_args() function must also return False for the
if arg[:2] == "--": command function to be called. If set to True, all ArgumentData is
arg = arg[2:] valid.)
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 command = GeneralCommand(name, cmdfunc, args)
self.register(command)
@staticmethod def register_specific(self,
def _parse_command(content, specific=None): name: str,
if specific is not None: cmdfunc: SpecificCommandFunction,
match = SPECIFIC_RE.fullmatch(content) args: bool = True
if match and similar(match.group(2), specific): ) -> None:
return match.group(1), match.group(3) """
else: Register a function as specific bot command (i. e. @mention of the bot
match = GENERAL_RE.fullmatch(content) nick after the !command is required). This function will be called by
if match: process_commands() when the bot encounters a matching command.
return match.group(1), match.group(2)
name - the name of the command (see register_general() for an
explanation)
cmdfunc - the function that is called with the Room, LiveMessage and
SpecificArgumentData when the bot encounters a matching command
args - whether the command may have arguments (see register_general()
for an explanation)
"""
command = SpecificCommand(name, cmdfunc, args)
self.register(command)
# Processing commands
async def process_commands(self,
room: Room,
message: LiveMessage,
aliases: List[str] = []
) -> None:
"""
If the message contains a command, call all matching command functions
that were previously registered.
This function is usually called by the overwritten on_send() function.
"""
nicks = [room.session.nick] + aliases
data = CommandData.from_string(message.content)
if data is not None:
logger.debug(f"Processing command from {message.content!r}")
for command in self._commands:
await command.run(room, message, nicks, data)
async def on_send(self, room: Room, message: LiveMessage) -> None:
"""
This Client function is overwritten in order to automatically call
process_commands() with self.ALIASES.
If you need to overwrite this function, make sure to await
process_commands() with self.ALIASES somewhere in your function, or
await super().on_send().
"""
await self.process_commands(room, message, aliases=self.ALIASES)
# Help util
def format_help(self, room: Room, lines: List[str]) -> str:
"""
Format a list of strings into a string, replacing certain placeholders
with the actual values.
This function uses the str.format() function to replace the following:
- {nick} - the bot's current nick
- {mention} - the bot's current nick, run through mention()
- {atmention} - the bot's current nick, run through atmention()
"""
text = "\n".join(lines)
params = {
"nick": room.session.nick,
"mention": room.session.mention,
"atmention": room.session.atmention,
}
return text.format(**params)
# Botrulez
def register_botrulez(self,
ping: bool = True,
help_: bool = True,
uptime: bool = True,
kill: bool = False,
restart: bool = False,
) -> None:
"""
Register the commands necessary for the bot to conform to the botrulez
(https://github.com/jedevc/botrulez). Also includes a few optional
botrulez commands that are disabled by default.
- ping - register general and specific cmd_ping()
- help_ - register cmd_help_general() and cmd_help_specific()
- uptime - register specific cmd_uptime
- kill - register specific cmd_kill (disabled by default)
- uptime - register specific cmd_uptime (disabled by default)
All commands are registered with args=False.
If you want to implement your own versions of these commands, it is
recommended that you set the respective argument to False in your call
to register_botrulez(), overwrite the existing command functions or
create your own, and then register them manually.
For help, that might look something like this, if you've written a
custom specific help that takes extra arguments but are using the
botrulez general help:
self.register_botrulez(help_=False)
self.register_general("help", self.cmd_help_general, args=False)
self.register_specific("help", self.cmd_help_custom)
In case you're asking, the help_ parameter has an underscore at the end
so it doesn't overlap the help() function.
"""
if ping:
self.register_general("ping", self.cmd_ping, args=False)
self.register_specific("ping", self.cmd_ping, args=False)
if help_:
if self.HELP_GENERAL is None and self.HELP_SPECIFIC is None:
logger.warn(("HELP_GENERAL and HELP_SPECIFIC are None, but the"
" help command is enabled"))
self.register_general("help", self.cmd_help_general, args=False)
self.register_specific("help", self.cmd_help_specific, args=False)
if uptime:
self.register_specific("uptime", self.cmd_uptime, args=False)
if kill:
self.register_specific("kill", self.cmd_kill, args=False)
if restart:
self.register_specific("restart", self.cmd_restart, args=False)
async def cmd_ping(self,
room: Room,
message: LiveMessage,
args: ArgumentData
) -> None:
"""
Reply with self.PING_REPLY.
"""
await message.reply(self.PING_REPLY)
async def cmd_help_general(self,
room: Room,
message: LiveMessage,
args: ArgumentData
) -> None:
"""
Reply with self.HELP_GENERAL, if it is not None. Uses format_help().
"""
if self.HELP_GENERAL is not None:
await message.reply(self.format_help(room, [self.HELP_GENERAL]))
async def cmd_help_specific(self,
room: Room,
message: LiveMessage,
args: SpecificArgumentData
) -> None:
"""
Reply with self.HELP_SPECIFIC, if it is not None. Uses format_help().
"""
if self.HELP_SPECIFIC is not None:
await message.reply(self.format_help(room, self.HELP_SPECIFIC))
async def cmd_uptime(self,
room: Room,
message: LiveMessage,
args: SpecificArgumentData
) -> None:
"""
Reply with the bot's uptime in the format specified by the botrulez.
This uses the time that the Bot was first started, not the time the
respective Room was created. A !restart (see register_botrulez()) will
reset the bot uptime, but leaving and re-joining a room or losing
connection won't.
"""
time = format_time(self.start_time)
delta = format_delta(datetime.datetime.now() - self.start_time)
text = f"/me has been up since {time} UTC ({delta})"
await message.reply(text)
async def cmd_kill(self,
room: Room,
message: LiveMessage,
args: SpecificArgumentData
) -> None:
"""
Remove the bot from this room.
If self.KILL_REPLY is not None, replies with that before leaving the
room.
"""
logger.info(f"Killed in &{room.name} by {message.sender.atmention}")
if self.KILL_REPLY is not None:
await message.reply(self.KILL_REPLY)
await self.part(room)
async def cmd_restart(self,
room: Room,
message: LiveMessage,
args: SpecificArgumentData
) -> None:
"""
Restart the whole Bot.
This is done by stopping the Bot, since the run() or run_modulebot()
functions start the Bot in a while True loop.
If self.RESTART_REPLY is not None, replies with that before restarting.
"""
logger.info(f"Restarted in &{room.name} by {message.sender.atmention}")
if self.RESTART_REPLY is not None:
await message.reply(self.RESTART_REPLY)
await self.stop()
BotConstructor = Callable[[configparser.ConfigParser, str], Bot]

171
yaboli/client.py Normal file
View file

@ -0,0 +1,171 @@
import asyncio
import functools
import logging
from typing import Dict, List, Optional, Union
from .message import LiveMessage
from .room import Room
from .session import LiveSession
logger = logging.getLogger(__name__)
__all__ = ["Client"]
class Client:
def __init__(self,
default_nick: str,
cookie_file: Optional[str] = None,
) -> None:
self._default_nick = default_nick
self._cookie_file = cookie_file
self._rooms: Dict[str, List[Room]] = {}
self._stop = asyncio.Event()
async def run(self) -> None:
await self.started()
await self._stop.wait()
async def stop(self) -> None:
await self.stopping()
tasks = []
for rooms in self._rooms.values():
for room in rooms:
tasks.append(asyncio.create_task(self.part(room)))
for task in tasks:
await task
self._stop.set()
# Managing rooms
def get(self, room_name: str) -> Optional[Room]:
rooms = self._rooms.get(room_name)
if rooms: # None or [] are False-y
return rooms[0]
else:
return None
def get_all(self, room_name: str) -> List[Room]:
return self._rooms.get(room_name, [])
async def join(self,
room_name: str,
password: Optional[str] = None,
nick: Optional[str] = None,
cookie_file: Union[str, bool] = True,
) -> Optional[Room]:
"""
cookie_file is the name of the file to store the cookies in. If it is
True, the client default is used. If it is False, no cookie file name
will be used.
"""
logger.info(f"Joining &{room_name}")
if nick is None:
nick = self._default_nick
this_cookie_file: Optional[str]
if isinstance(cookie_file, str): # This way, mypy doesn't complain
this_cookie_file = cookie_file
elif cookie_file:
this_cookie_file = self._cookie_file
else:
this_cookie_file = None
room = Room(room_name, password=password, target_nick=nick,
cookie_file=this_cookie_file)
room.register_event("connected",
functools.partial(self.on_connected, room))
room.register_event("snapshot",
functools.partial(self.on_snapshot, room))
room.register_event("send",
functools.partial(self.on_send, room))
room.register_event("join",
functools.partial(self.on_join, room))
room.register_event("part",
functools.partial(self.on_part, room))
room.register_event("nick",
functools.partial(self.on_nick, room))
room.register_event("edit",
functools.partial(self.on_edit, room))
room.register_event("pm",
functools.partial(self.on_pm, room))
room.register_event("disconnect",
functools.partial(self.on_disconnect, room))
if await room.connect():
rooms = self._rooms.get(room_name, [])
rooms.append(room)
self._rooms[room_name] = rooms
return room
else:
logger.warn(f"Could not join &{room.name}")
return None
async def part(self, room: Room) -> None:
logger.info(f"Leaving &{room.name}")
rooms = self._rooms.get(room.name, [])
rooms = [r for r in rooms if r is not room]
self._rooms[room.name] = rooms
await room.disconnect()
# Management stuff - overwrite these functions
async def started(self) -> None:
pass
async def stopping(self) -> None:
pass
# Event stuff - overwrite these functions
async def on_connected(self, room: Room) -> None:
pass
async def on_snapshot(self, room: Room, messages: List[LiveMessage]) -> None:
pass
async def on_send(self, room: Room, message: LiveMessage) -> None:
pass
async def on_join(self, room: Room, user: LiveSession) -> None:
pass
async def on_part(self, room: Room, user: LiveSession) -> None:
pass
async def on_nick(self,
room: Room,
user: LiveSession,
from_nick: str,
to_nick: str
) -> None:
pass
async def on_edit(self, room: Room, message: LiveMessage) -> None:
pass
async def on_login(self, room: Room, account_id: str) -> None:
pass
async def on_logout(self, room: Room) -> None:
pass
async def on_pm(self,
room: Room,
from_id: str,
from_nick: str,
from_room: str,
pm_id: str
) -> None:
pass
async def on_disconnect(self, room: Room, reason: str) -> None:
pass

384
yaboli/command.py Normal file
View file

@ -0,0 +1,384 @@
import abc
import re
from typing import (Awaitable, Callable, Dict, List, NamedTuple, Optional,
Pattern, Tuple)
from .message import LiveMessage
from .room import Room
from .util import similar
# Different ways of parsing commands:
#
# - raw string
#
# - split into arguments by whitespace
# - parsed into positional, optional, flags
#
# - The above two with or without bash-style escaping
#
# All of the above can be done with any argstr, even with an empty one.
__all__ = ["FancyArgs", "ArgumentData", "SpecificArgumentData", "CommandData",
"Command", "GeneralCommandFunction", "GeneralCommand",
"SpecificCommandFunction", "SpecificCommand"]
class FancyArgs(NamedTuple):
"""
The fancy argument parser supports arguments of the following formats:
FLAGS:
These are one or more characters preceded by a single dash. Examples:
-a, -fghf, -vv
The fancy argument parser counts how often each character (also called
flag) appears. Each flag that appears once or more gets an entry in the
"flags" dict of the form: flags[flag] = amount
Exception: A single dash ("-") is interpreted as a positional argument.
OPTIONAL:
These are arguments of the form --<name> or --<name>=<value>, where <name>
is the name of the optional argument and <value> is its (optional) value.
Due to this syntax, the <name> may not include any "=" signs.
The optional arguments are collected in a dict of the form:
optional[name] = value or None
If the optional argument included a "=" after the name, but no further
characters, its value is the empty string. If it didn't include a "=" after
the name, its value is None.
If more than one optional argument appears with the same name, the last
argument's value is kept and all previous values discarded.
POSITIONAL:
Positional arguments are all arguments that don't start with "-" or "--".
They are compiled in a list and ordered in the same order they appeared in
after the command.
RAW:
At any time, a single "--" argument may be inserted. This separates the
positional and optional arguments and the flags from the raw arguments. All
arguments after the "--" are interpreted as raw arguments, even flags,
optional arguments and further "--"s.
For example, consider the following arguments:
ab -cd -c --ef=g --h i -- j --klm -nop -- qr
positional: ["ab", "i"]
optional: {"ef": "g", "h": None}
flags: {"c": 2, "d": 1}
raw: ["j", "--klm", "-nop", "--", "qr"]
"""
positional: List[str]
optional: Dict[str, Optional[str]]
flags: Dict[str, int]
raw: List[str]
class ArgumentData:
def __init__(self, raw: str) -> None:
self._raw = raw
self._basic: Optional[List[str]] = None
self._basic_escaped: Optional[List[str]] = None
self._fancy: Optional[FancyArgs] = None
self._fancy_escaped: Optional[FancyArgs] = None
def _split_escaped(self, text: str) -> List[str]:
"""
Splits the string into individual arguments, while allowing
bash-inspired quoting/escaping.
A single backslash escapes the immediately following character.
Double quotes allow backslash escapes, but escape all other characters.
Single quotes escape all characters.
The remaining string is split at all unescaped while space characters
(using str.isspace), similar to str.split without any arguments.
"""
words: List[str] = []
word: List[str] = []
backslash = False
quotes: Optional[str] = None
for char in text:
if backslash:
backslash = False
word.append(char)
elif quotes is not None:
if quotes == "\"" and char == "\\":
backslash = True
elif char == quotes:
quotes = None
else:
word.append(char)
elif char == "\\":
backslash = True
elif char in ["\"", "'"]:
quotes = char
elif char.isspace():
if word:
words.append("".join(word))
word = []
else:
word.append(char)
# ignoring any left-over backslashes or open quotes at the end
if word:
words.append("".join(word))
return words
def _split(self, text: str, escaped: bool) -> List[str]:
if escaped:
return self._split_escaped(text)
else:
return text.split()
def _parse_fancy(self, args: List[str]) -> FancyArgs:
positional: List[str] = []
optional: Dict[str, Optional[str]] = {}
flags: Dict[str, int] = {}
raw: List[str] = []
is_raw = False
for arg in args:
# raw arguments
if is_raw:
raw.append(arg)
# raw arguments separator
elif arg == "--":
is_raw = True
# optional arguments
elif arg[:2] == "--":
split = arg[2:].split("=", maxsplit=1)
name = split[0]
value = split[1] if len(split) == 2 else None
optional[name] = value
# the "-" exception
elif arg == "-":
positional.append(arg)
# flags
elif arg[:1] == "-":
for char in arg[1:]:
flags[char] = flags.get(char, 0) + 1
# positional arguments
else:
positional.append(arg)
return FancyArgs(positional, optional, flags, raw)
@property
def raw(self) -> str:
return self._raw
def basic(self, escaped: bool = True) -> List[str]:
if escaped:
if self._basic_escaped is None:
self._basic_escaped = self._split(self._raw, escaped)
return self._basic_escaped
else:
if self._basic is None:
self._basic = self._split(self._raw, escaped)
return self._basic
def fancy(self, escaped: bool = True) -> FancyArgs:
if escaped:
if self._fancy_escaped is None:
basic = self._split(self._raw, escaped)
self._fancy_escaped = self._parse_fancy(basic)
return self._fancy_escaped
else:
if self._fancy is None:
basic = self._split(self._raw, escaped)
self._fancy = self._parse_fancy(basic)
return self._fancy
def has_args(self) -> bool:
return bool(self.basic()) # The list of arguments is empty
class SpecificArgumentData(ArgumentData):
def __init__(self, nick: str, raw: str) -> None:
super().__init__(raw)
self._nick = nick
@property
def nick(self) -> str:
return self._nick
class CommandData:
_NAME_RE = re.compile(r"^!(\S+)")
_MENTION_RE = re.compile(r"^\s+@(\S+)")
def __init__(self,
name: str,
general: ArgumentData,
specific: Optional[SpecificArgumentData]
) -> None:
self._name = name
self._general = general
self._specific = specific
@property
def name(self) -> str:
return self._name
@property
def general(self) -> ArgumentData:
return self._general
@property
def specific(self) -> Optional[SpecificArgumentData]:
return self._specific
@staticmethod
def _take(pattern: Pattern, text: str) -> Optional[Tuple[str, str]]:
"""
Returns the pattern's first group and the rest of the string that
didn't get matched by the pattern.
Anchoring the pattern to the beginning of the string is the
responsibility of the pattern writer.
"""
match = pattern.match(text)
if not match:
return None
group = match.group(1)
rest = text[match.end():]
return group, rest
@classmethod
def from_string(cls, string: str) -> "Optional[CommandData]":
# If it looks like it should work in the euphoria UI, it should work.
# Since euphoria strips whitespace chars from the beginning and end of
# messages, we do too.
string = string.strip()
name_part = cls._take(cls._NAME_RE, string)
if name_part is None: return None
name, name_rest = name_part
general = ArgumentData(name_rest)
specific: Optional[SpecificArgumentData]
mention_part = cls._take(cls._MENTION_RE, name_rest)
if mention_part is None:
specific = None
else:
mention, rest = mention_part
specific = SpecificArgumentData(mention, rest)
return cls(name, general, specific)
class Command(abc.ABC):
def __init__(self, name: str) -> None:
self._name = name
async def run(self,
room: Room,
message: LiveMessage,
nicks: List[str],
data: CommandData,
) -> None:
if data.name == self._name:
await self._run(room, message, nicks, data)
@abc.abstractmethod
async def _run(self,
room: Room,
message: LiveMessage,
nicks: List[str],
data: CommandData,
) -> None:
pass
# General command
GeneralCommandFunction = Callable[[Room, LiveMessage, ArgumentData],
Awaitable[None]]
class GeneralCommand(Command):
def __init__(self,
name: str,
cmdfunc: GeneralCommandFunction,
args: bool
) -> None:
super().__init__(name)
self._cmdfunc = cmdfunc
self._args = args
async def _run(self,
room: Room,
message: LiveMessage,
nicks: List[str],
data: CommandData,
) -> None:
# Do we have arguments if we shouldn't?
if not self._args and data.general.has_args():
return
await self._cmdfunc(room, message, data.general)
# Specific command
SpecificCommandFunction = Callable[[Room, LiveMessage, SpecificArgumentData],
Awaitable[None]]
class SpecificCommand(Command):
def __init__(self,
name: str,
cmdfunc: SpecificCommandFunction,
args: bool
) -> None:
super().__init__(name)
self._cmdfunc = cmdfunc
self._args = args
async def _run(self,
room: Room,
message: LiveMessage,
nicks: List[str],
data: CommandData,
) -> None:
# Is this a specific command?
if data.specific is None:
return
# Are we being mentioned?
for nick in nicks:
if similar(nick, data.specific.nick):
break
else:
return # Yay, a rare occurrence of this structure!
# Do we have arguments if we shouldn't?
if not self._args and data.specific.has_args():
return
await self._cmdfunc(room, message, data.specific)

View file

@ -2,208 +2,570 @@ import asyncio
import json import json
import logging import logging
import socket import socket
from typing import Any, Awaitable, Callable, Dict, Optional
import websockets import websockets
from .cookiejar import CookieJar
from .events import Events
from .exceptions import * from .exceptions import *
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
__all__ = ["Connection"] __all__ = ["Connection"]
# This class could probably be cleaned up by introducing one or two well-placed
# Locks something for the next rewrite :P
class Connection: class Connection:
def __init__(self, url, packet_callback, disconnect_callback, cookiejar=None, ping_timeout=10, ping_delay=30, reconnect_attempts=10): """
self.url = url The Connection handles the lower-level stuff required when connecting to
self.packet_callback = packet_callback euphoria, such as:
self.disconnect_callback = disconnect_callback
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 - Creating a websocket connection
self._pid = 0 # successive packet ids - Encoding and decoding packets (json)
#self._spawned_tasks = set() - Waiting for the server's asynchronous replies to packets
self._pending_responses = {} - Keeping the connection alive (ping, ping-reply packets)
- Reconnecting (timeout while connecting, no pings received in some time)
self._stopped = False It doesn't respond to any events other than the ping-event and is otherwise
self._pingtask = None "dumb".
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: Life cycle of a Connection:
wait_for = self._wait_for_response(pid)
logging.debug(f"Currently used websocket at self._ws: {self._ws}") 1. create connection and register event callbacks
await self._ws.send(json.dumps(packet, separators=(',', ':'))) # minimum size 2. call connect()
3. send and receive packets, reconnecting automatically when connection is
lost
4. call disconnect(), then go to 2.
if await_response:
await wait_for
return wait_for.result()
async def stop(self): IN PHASE 1, parameters such as the url the Connection should connect to are
""" set. Usually, event callbacks are also registered in this phase.
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 IN PHASE 2, the Connection attempts to connect to the url set in phase 1.
await self.reconnect() # _run() does the cleaning up now. If successfully connected, it fires a "connected" event.
await self._runtask
async def reconnect(self):
"""
Reconnect to the url.
"""
if self._ws: IN PHASE 3, the Connection listenes for packets from the server and fires
await self._ws.close() the corresponding events. Packets can be sent using the Connection.
async def _connect(self, tries): If the Connection has to reconnect for some reason, it first fires a
""" "reconnecting" event. Then it tries to reconnect until it has established a
Attempt to connect to a room. connection to euphoria again. After the connection is reestablished, it
If the Connection is already connected, it attempts to reconnect. fires a "reconnected" event.
Returns True on success, False on failure.
If tries is None, connect retries infinitely. IN PHASE 4, the Connection fires a "disconnecting" event and then closes
The delay between connection attempts doubles every attempt (starts with 1s). the connection to euphoria. This event is the last event that is fired
""" until connect() is called again.
# 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: Events:
tries -= 1
if tries <= 0:
return False
await asyncio.sleep(delay) - "connected" : No arguments
delay *= 2 - "reconnecting" : No arguments
else: - "reconnected" : No arguments
if self.cookiejar: - "disconnecting" : No arguments
for set_cookie in self._ws.response_headers.get_all("Set-Cookie"): - "<euph event name>": the packet, parsed as JSON
self.cookiejar.bake(set_cookie)
self.cookiejar.save()
self._pingtask = asyncio.ensure_future(self._ping()) Events ending with "-ing" ("reconnecting", "disconnecting") are fired at
the beginning of the process they represent. Events ending with "-ed"
("connected", "reconnected") are fired after the process they represent has
finished.
return True Examples for the last category of events include "message-event",
"part-event" and "ping".
"""
async def _disconnect(self): # Timeout for waiting for the ws connection to be established
""" CONNECT_TIMEOUT = 10 # seconds
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()) # Maximum duration between euphoria's ping messages. Euphoria usually sends
# ping messages every 20 to 30 seconds.
PING_TIMEOUT = 40 # seconds
# stop ping task # The delay between reconnect attempts.
if self._pingtask: RECONNECT_DELAY = 40 # seconds
self._pingtask.cancel()
await self._pingtask
self._pingtask = None
if self._ws: # States the Connection may be in
await self._ws.close() _NOT_RUNNING = "not running"
self._ws = None _CONNECTING = "connecting"
_RUNNING = "running"
_RECONNECTING = "reconnecting"
_DISCONNECTING = "disconnecting"
self._pid = 0 # Initialising
# clean up pending response futures def __init__(self, url: str, cookie_file: Optional[str] = None) -> None:
for _, future in self._pending_responses.items(): self._url = url
logger.debug(f"Cancelling future with ConnectionClosed: {future}") self._cookie_jar = CookieJar(cookie_file)
future.set_exception(ConnectionClosed("No server response"))
self._pending_responses = {}
async def _run(self): self._events = Events()
""" self._packet_id = 0
Listen for packets and deal with them accordingly.
"""
while not self._stopped: # This is the current status of the connection. It can be set to one of
await self._connect(self.reconnect_attempts) # _NOT_RUNNING, _CONNECTING, _RUNNING, _RECONNECTING, or
# _DISCONNECTING.
#
# Always be careful to set any state-dependent variables.
self._state = self._NOT_RUNNING
self._connected_condition = asyncio.Condition()
self._disconnected_condition = asyncio.Condition()
try: self._event_loop: Optional[asyncio.Task[None]] = None
while True:
await self._handle_next_message()
except websockets.ConnectionClosed:
pass
finally:
await self._disconnect() # disconnect and clean up
async def _ping(self): # These must always be (re)set together. If one of them is None, all
""" # must be None.
Periodically ping the server to detect a timeout. self._ws = None
""" self._awaiting_replies: Optional[Dict[str, asyncio.Future[Any]]] = None
self._ping_check: Optional[asyncio.Task[None]] = None
try: self.register_event("ping-event", self._ping_pong)
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.reconnect()
except (websockets.ConnectionClosed, ConnectionResetError, asyncio.CancelledError):
pass
def _new_pid(self): def register_event(self,
self._pid += 1 event: str,
return self._pid callback: Callable[..., Awaitable[None]]
) -> None:
"""
Register an event callback.
async def _handle_next_message(self): For an overview of the possible events, see the Connection docstring.
response = await self._ws.recv() """
packet = json.loads(response)
ptype = packet.get("type") self._events.register(event, callback)
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 # Connecting and disconnecting
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 async def _disconnect(self) -> None:
asyncio.ensure_future(self.packet_callback(ptype, data, error, throttled)) """
Disconnect _ws and clean up _ws, _awaiting_replies and _ping_check.
def _wait_for_response(self, pid): Important: The caller must ensure that this function is called in valid
future = asyncio.Future() circumstances and not called twice at the same time. _disconnect() does
self._pending_responses[pid] = future not check or manipulate _state.
return future """
if self._ws is not None:
logger.debug("Closing ws connection")
await self._ws.close()
# Checking self._ws again since during the above await, another
# disconnect call could have finished cleaning up.
if self._ws is None:
# This indicates that _ws, _awaiting_replies and _ping_check are
# cleaned up
logger.debug("Ws connection already cleaned up")
return
logger.debug("Cancelling futures waiting for replies")
for future in self._awaiting_replies.values():
future.set_exception(ConnectionClosedException())
logger.debug("Cancelling ping check task")
self._ping_check.cancel()
logger.debug("Cleaning up variables")
self._ws = None
self._awaiting_replies = None
self._ping_check = None
async def _connect(self) -> bool:
"""
Attempts once to create a ws connection.
Important: The caller must ensure that this function is called in valid
circumstances and not called twice at the same time. _connect() does
not check or manipulate _state, nor does it perform cleanup on
_awaiting_replies or _ping_check.
"""
try:
logger.debug(f"Creating ws connection to {self._url!r}")
ws = await asyncio.wait_for(
websockets.connect(self._url,
extra_headers=self._cookie_jar.get_cookies_as_headers()),
self.CONNECT_TIMEOUT
)
logger.debug(f"Established ws connection to {self._url!r}")
self._ws = ws
self._awaiting_replies = {}
logger.debug("Starting ping check")
self._ping_check = asyncio.create_task(
self._disconnect_in(self.PING_TIMEOUT))
# Put received cookies into cookie jar
for set_cookie in ws.response_headers.get_all("Set-Cookie"):
self._cookie_jar.add_cookie(set_cookie)
self._cookie_jar.save()
return True
except (websockets.InvalidHandshake, websockets.InvalidStatusCode,
OSError, asyncio.TimeoutError):
logger.debug("Connection failed")
return False
async def _disconnect_in(self, delay: int) -> None:
await asyncio.sleep(delay)
logger.debug(f"Disconnect timeout of {delay}s elapsed, disconnecting...")
# Starting the _disconnect function in another task because otherwise,
# its own CancelledError would inhibit _disconnect() from completing
# the disconnect.
#
# We don't need to check the state because _disconnect_in only runs
# while the _state is _RUNNING.
asyncio.create_task(self._disconnect())
async def _reconnect(self) -> bool:
"""
This function should only be called from the event loop while the
_state is _RUNNING.
"""
if self._state != self._RUNNING:
raise IncorrectStateException("This should never happen")
logger.debug("Reconnecting...")
self._events.fire("reconnecting")
self._state = self._RECONNECTING
await self._disconnect()
success = await self._connect()
self._state = self._RUNNING
self._events.fire("reconnected")
logger.debug("Sending connected notification")
async with self._connected_condition:
self._connected_condition.notify_all()
logger.debug("Reconnected" if success else "Reconnection failed")
return success
async def connect(self) -> bool:
"""
Attempt to create a connection to the Connection's url.
Returns True if the Connection could connect to the url and is now
running. Returns False if the Connection could not connect to the url
and is not running.
Exceptions:
This function must be called while the connection is not running,
otherwise an IncorrectStateException will be thrown. To stop a
Connection, use disconnect().
"""
# Special exception message for _CONNECTING.
if self._state == self._CONNECTING:
raise IncorrectStateException(("connect() may not be called"
" multiple times."))
if self._state != self._NOT_RUNNING:
raise IncorrectStateException(("disconnect() must complete before"
" connect() may be called again."))
logger.debug("Connecting...")
# Now we're sure we're in the _NOT_RUNNING state, we can set our state.
# Important: No await-ing has occurred between checking the state and
# setting it.
self._state = self._CONNECTING
success = await self._connect()
if success:
logger.debug("Starting event loop")
self._event_loop = asyncio.create_task(self._run())
self._state = self._RUNNING
self._events.fire("connected")
else:
self._state = self._NOT_RUNNING
logger.debug("Sending connected notification")
async with self._connected_condition:
self._connected_condition.notify_all()
logger.debug("Connected" if success else "Connection failed")
return success
async def disconnect(self) -> None:
"""
Close and stop the Connection, if it is currently (re-)connecting or
running. Does nothing if the Connection is not running.
This function returns once the Connection has stopped running.
"""
# Possible states left: _NOT_RUNNING, _CONNECTING, _RUNNING,
# _RECONNECTING, _DISCONNECTING
# Waiting until the current connection attempt is finished. Using a
# while loop since the event loop might have started to reconnect again
# while the await is still waiting.
while self._state in [self._CONNECTING, self._RECONNECTING]:
# After _CONNECTING, the state can either be _NOT_RUNNING or
# _RUNNING. After _RECONNECTING, the state must be _RUNNING.
async with self._connected_condition:
await self._connected_condition.wait()
# Possible states left: _NOT_RUNNING, _RUNNING, _DISCONNECTING
if self._state == self._NOT_RUNNING:
# No need to do anything since we're already disconnected
logger.debug("Already disconnected")
return
# Possible states left: _RUNNING, _DISCONNECTING
if self._state == self._DISCONNECTING:
# Wait until the disconnecting currently going on is complete. This
# is to prevent the disconnect() function from ever returning
# without the disconnecting process being finished.
logger.debug("Already disconnecting, waiting for it to finish...")
async with self._disconnected_condition:
await self._disconnected_condition.wait()
logger.debug("Disconnected, finished waiting")
return
# Possible states left: _RUNNING
# By principle of exclusion, the only state left is _RUNNING. Doing an
# explicit check though, just to make sure.
if self._state != self._RUNNING:
raise IncorrectStateException("This should never happen.")
logger.debug("Disconnecting...")
self._events.fire("disconnecting")
# Now we're sure we're in the _RUNNING state, we can set our state.
# Important: No await-ing has occurred between checking the state and
# setting it.
self._state = self._DISCONNECTING
await self._disconnect()
# We know that _event_loop is not None, but this is to keep mypy happy.
logger.debug("Waiting for event loop")
if self._event_loop is not None:
await self._event_loop
self._event_loop = None
self._state = self._NOT_RUNNING
# Notify all other disconnect()s waiting
logger.debug("Sending disconnected notification")
async with self._disconnected_condition:
self._disconnected_condition.notify_all()
logger.debug("Disconnected")
async def reconnect(self) -> None:
"""
Forces the Connection to reconnect.
This function may return before the reconnect process is finished.
Exceptions:
This function must be called while the connection is (re-)connecting or
running, otherwise an IncorrectStateException will be thrown.
"""
if self._state in [self._CONNECTING, self._RECONNECTING]:
logger.debug("Already (re-)connecting, waiting for it to finish...")
async with self._connected_condition:
await self._connected_condition.wait()
logger.debug("(Re-)connected, finished waiting")
return
if self._state != self._RUNNING:
raise IncorrectStateException(("reconnect() may not be called while"
" the connection is not running."))
# Disconnecting via task because otherwise, the _connected_condition
# might fire before we start waiting for it.
#
# The event loop will reconenct after the ws connection has been
# disconnected.
logger.debug("Disconnecting and letting the event loop reconnect")
await self._disconnect()
# Running
async def _run(self) -> None:
"""
The main loop that runs during phase 3
"""
while True:
# The "Exiting event loop" checks are a bit ugly. They're in place
# so that the event loop exits on its own at predefined positions
# instead of randomly getting thrown a CancelledError.
#
# Now that I think about it, the whole function looks kinda ugly.
# Maybe one day (yeah, right), I'll clean this up. I want to get it
# working first though.
if self._state != self._RUNNING:
logger.debug("Exiting event loop")
return
if self._ws is not None:
try:
logger.debug("Receiving ws packets")
async for packet in self._ws:
logger.debug(f"Received packet {packet}")
packet_data = json.loads(packet)
self._process_packet(packet_data)
except websockets.ConnectionClosed:
logger.debug("Stopped receiving ws packets")
else:
logger.debug("No ws connection found")
if self._state != self._RUNNING:
logger.debug("Exiting event loop")
return
logger.debug("Attempting to reconnect")
while not await self._reconnect():
logger.debug("Reconnect attempt not successful")
if self._state != self._RUNNING:
logger.debug("Exiting event loop")
return
logger.debug(f"Sleeping for {self.RECONNECT_DELAY}s and retrying")
await asyncio.sleep(self.RECONNECT_DELAY)
def _process_packet(self, packet: Any) -> None:
# This function assumes that the packet is formed correctly according
# to http://api.euphoria.io/#packets.
# First, notify whoever's waiting for this packet
packet_id = packet.get("id")
if packet_id is not None and self._awaiting_replies is not None:
future = self._awaiting_replies.get(packet_id)
if future is not None:
del self._awaiting_replies[packet_id]
future.set_result(packet)
# Then, send the corresponding event
packet_type = packet["type"]
self._events.fire(packet_type, packet)
# Finally, reset the ping check
if packet_type == "ping-event":
logger.debug("Resetting ping check")
if self._ping_check is not None:
self._ping_check.cancel()
self._ping_check = asyncio.create_task(
self._disconnect_in(self.PING_TIMEOUT))
async def _do_if_possible(self, coroutine: Awaitable[None]) -> None:
"""
Try to run a coroutine, ignoring any IncorrectStateExceptions.
"""
try:
await coroutine
except IncorrectStateException:
pass
async def _send_if_possible(self, packet_type: str, data: Any,) -> None:
"""
This function tries to send a packet without awaiting the reply.
It ignores IncorrectStateExceptions, meaning that if it is called while
in the wrong state, nothing will happen.
"""
try:
await self.send(packet_type, data, await_reply=False)
except IncorrectStateException:
logger.debug("Could not send (disconnecting or already disconnected)")
async def _ping_pong(self, packet: Any) -> None:
"""
Implements http://api.euphoria.io/#ping and is called as "ping-event"
callback.
"""
logger.debug("Pong!")
await self._do_if_possible(self.send(
"ping-reply",
{"time": packet["data"]["time"]},
await_reply=False
))
async def send(self,
packet_type: str,
data: Any,
await_reply: bool = True
) -> Any:
"""
Send a packet of type packet_type to the server.
The object passed as data will make up the packet's "data" section and
must be json-serializable.
This function will return the complete json-deserialized reply package,
unless await_reply is set to False, in which case it will immediately
return None.
Exceptions:
This function must be called while the Connection is (re-)connecting or
running, otherwise an IncorrectStateException will be thrown.
If the connection closes unexpectedly while sending the packet or
waiting for the reply, a ConnectionClosedException will be thrown.
"""
while self._state in [self._CONNECTING, self._RECONNECTING]:
async with self._connected_condition:
await self._connected_condition.wait()
if self._state != self._RUNNING:
raise IncorrectStateException(("send() must be called while the"
" Connection is running"))
# We're now definitely in the _RUNNING state
# Since we're in the _RUNNING state, _ws and _awaiting_replies are not
# None. This check is to satisfy mypy.
if self._ws is None or self._awaiting_replies is None:
raise IncorrectStateException("This should never happen")
packet_id = str(self._packet_id)
self._packet_id += 1
# Doing this before the await below since we know that
# _awaiting_replies is not None while the _state is _RUNNING.
if await_reply:
response: asyncio.Future[Any] = asyncio.Future()
self._awaiting_replies[packet_id] = response
text = json.dumps({"id": packet_id, "type": packet_type, "data": data})
logger.debug(f"Sending packet {text}")
try:
await self._ws.send(text)
except websockets.ConnectionClosed:
raise ConnectionClosedException() # as promised in the docstring
if await_reply:
await response
# If the response Future was completed with a
# ConnectionClosedException via set_exception(), response.result()
# will re-raise that exception.
return response.result()
else:
return None

View file

@ -1,73 +1,77 @@
import contextlib import contextlib
import http.cookies as cookies import http.cookies as cookies
import logging import logging
from typing import List, Optional, Tuple
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
__all__ = ["CookieJar"] __all__ = ["CookieJar"]
class CookieJar: class CookieJar:
""" """
Keeps your cookies in a file. Keeps your cookies in a file.
"""
def __init__(self, filename=None): CookieJar doesn't attempt to discard old cookies, but that doesn't appear
self._filename = filename to be necessary for keeping euphoria session cookies.
self._cookies = cookies.SimpleCookie() """
if not self._filename: def __init__(self, filename: Optional[str] = None) -> None:
logger.info("Could not load cookies, no filename given.") self._filename = filename
return self._cookies = cookies.SimpleCookie()
with contextlib.suppress(FileNotFoundError): if not self._filename:
with open(self._filename, "r") as f: logger.warning("Could not load cookies, no filename given.")
for line in f: return
self._cookies.load(line)
def sniff(self): with contextlib.suppress(FileNotFoundError):
""" logger.info(f"Loading cookies from {self._filename!r}")
Returns a list of Cookie headers containing all current cookies. with open(self._filename, "r") as f:
""" for line in f:
self._cookies.load(line)
return [morsel.OutputString(attrs=[]) for morsel in self._cookies.values()] def get_cookies(self) -> List[str]:
return [morsel.OutputString(attrs=[])
for morsel in self._cookies.values()]
def bake(self, cookie_string): def get_cookies_as_headers(self) -> List[Tuple[str, str]]:
""" """
Parse cookie and add it to the jar. Return all stored cookies as tuples in a list. The first tuple entry is
Does not automatically save to the cookie file. always "Cookie".
"""
Example cookie: "a=bcd; Path=/; Expires=Wed, 24 Jul 2019 14:57:52 GMT; HttpOnly; Secure" return [("Cookie", cookie) for cookie in self.get_cookies()]
"""
logger.debug(f"Baking cookie: {cookie_string!r}") def add_cookie(self, cookie: str) -> None:
"""
Parse cookie and add it to the jar.
self._cookies.load(cookie_string) Example cookie: "a=bcd; Path=/; Expires=Wed, 24 Jul 2019 14:57:52 GMT;
HttpOnly; Secure"
"""
def save(self): logger.debug(f"Adding cookie {cookie!r}")
""" self._cookies.load(cookie)
Saves all current cookies to the cookie jar file.
"""
if not self._filename: def save(self) -> None:
logger.info("Could not save cookies, no filename given.") """
return Saves all current cookies to the cookie jar file.
"""
logger.debug(f"Saving cookies to {self._filename!r}") if not self._filename:
logger.warning("Could not save cookies, no filename given.")
return
with open(self._filename, "w") as f: logger.info(f"Saving cookies to {self._filename!r}")
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): with open(self._filename, "w") as f:
""" for morsel in self._cookies.values():
Removes all cookies from the cookie jar. cookie_string = morsel.OutputString()
Does not automatically save to the cookie file. f.write(f"{cookie_string}\n")
"""
logger.debug("OMNOMNOM, cookies are all gone!") def clear(self) -> None:
"""
Removes all cookies from the cookie jar.
"""
self._cookies = cookies.SimpleCookie() logger.debug("OMNOMNOM, cookies are all gone!")
self._cookies = cookies.SimpleCookie()

View file

@ -1,87 +1,40 @@
import asyncio import asyncio
from functools import wraps import logging
import sqlite3 import sqlite3
import threading from typing import Any, Awaitable, Callable, TypeVar
__all__ = ["Database"] from .util import asyncify
logger = logging.getLogger(__name__)
__all__ = ["Database", "operation"]
def shielded(afunc): T = TypeVar('T')
#@wraps(afunc)
async def wrapper(*args, **kwargs):
return await asyncio.shield(afunc(*args, **kwargs))
return wrapper
class PooledConnection: def operation(func: Callable[..., T]) -> Callable[..., Awaitable[T]]:
def __init__(self, pool): async def wrapper(self: Any, *args: Any, **kwargs: Any) -> T:
self._pool = pool async with self as db:
while True:
self.connection = None try:
return await asyncify(func, self, db, *args, **kwargs)
async def open(self): except sqlite3.OperationalError as e:
self.connection = await self._pool._request() logger.warn(f"Operational error encountered: {e}")
await asyncio.sleep(5)
async def close(self): return wrapper
conn = self.connection
self.connection = None
await self._pool._return(conn)
async def __aenter__(self):
await self.open()
return self
async def __aexit__(self, exc_type, exc, tb):
await self.close()
class Pool:
def __init__(self, filename, size=10):
self.filename = filename
self.size = size
self._available_connections = asyncio.Queue()
for i in range(size):
conn = sqlite3.connect(self.filename, check_same_thread=False)
self._available_connections.put_nowait(conn)
def connection(self):
return PooledConnection(self)
async def _request(self):
return await self._available_connections.get()
async def _return(self, conn):
await self._available_connections.put(conn)
class Database: class Database:
def __init__(self, filename, pool_size=10, event_loop=None): def __init__(self, database: str) -> None:
self._filename = filename self._connection = sqlite3.connect(database, check_same_thread=False)
self._pool = Pool(filename, size=pool_size) self._lock = asyncio.Lock()
self._loop = event_loop or asyncio.get_event_loop()
self.initialize(self._connection)
def operation(func):
@wraps(func) def initialize(self, db: Any) -> None:
@shielded pass
async def wrapper(self, *args, **kwargs):
async with self._pool.connection() as conn: async def __aenter__(self) -> Any:
return await self._run_in_thread(func, conn.connection, *args, **kwargs) await self._lock.__aenter__()
return wrapper return self._connection
@staticmethod async def __aexit__(self, *args: Any, **kwargs: Any) -> Any:
def _target_function(loop, future, func, *args, **kwargs): return await self._lock.__aexit__(*args, **kwargs)
result = None
try:
result = func(*args, **kwargs)
finally:
loop.call_soon_threadsafe(future.set_result, result)
async def _run_in_thread(self, func, *args, **kwargs):
finished = asyncio.Future()
target_args = (self._loop, finished, func, *args)
thread = threading.Thread(target=self._target_function, args=target_args, kwargs=kwargs)
thread.start()
await finished
return finished.result()

25
yaboli/events.py Normal file
View file

@ -0,0 +1,25 @@
import asyncio
import logging
from typing import Any, Awaitable, Callable, Dict, List
logger = logging.getLogger(__name__)
__all__ = ["Events"]
class Events:
def __init__(self) -> None:
self._callbacks: Dict[str, List[Callable[..., Awaitable[None]]]] = {}
def register(self,
event: str,
callback: Callable[..., Awaitable[None]]
) -> None:
callback_list = self._callbacks.get(event, [])
callback_list.append(callback)
self._callbacks[event] = callback_list
logger.debug(f"Registered callback for event {event!r}")
def fire(self, event: str, *args: Any, **kwargs: Any) -> None:
logger.debug(f"Calling callbacks for event {event!r}")
for callback in self._callbacks.get(event, []):
asyncio.create_task(callback(*args, **kwargs))

View file

@ -1,13 +1,67 @@
__all__ = ["ConnectionClosed"] __all__ = [
"EuphException",
# Connection exceptions
"IncorrectStateException",
"ConnectionClosedException",
# Joining a room
"JoinException",
"CouldNotConnectException",
"CouldNotAuthenticateException",
# Doing stuff in a room
"RoomNotConnectedException",
"EuphError",
]
class ConnectionClosed(Exception): class EuphException(Exception):
pass pass
class RoomException(Exception): # Connection exceptions
pass
class AuthenticationRequired(RoomException): class IncorrectStateException(EuphException):
pass """
A Connection function was called while the Connection was in the incorrect
state.
"""
pass
class RoomClosed(RoomException): class ConnectionClosedException(EuphException):
pass """
The connection was closed unexpectedly.
"""
pass
# Joining a room
class JoinException(EuphException):
"""
An exception that happened while joining a room.
"""
pass
class CouldNotConnectException(JoinException):
"""
Could not establish a websocket connection to euphoria.
"""
pass
class CouldNotAuthenticateException(JoinException):
"""
The password is either incorrect or not set, even though authentication is
required.
"""
pass
# Doing stuff in a room
class RoomNotConnectedException(EuphException):
"""
Either the Room's connect() function has not been called or it has not
completed successfully.
"""
pass
class EuphError(EuphException):
"""
The euphoria server has sent back an "error" field in its response.
"""
pass

173
yaboli/message.py Normal file
View file

@ -0,0 +1,173 @@
import datetime
from typing import TYPE_CHECKING, Any, List, Optional
from .session import LiveSession, Session
if TYPE_CHECKING:
from .room import Room
__all__ = ["Message", "LiveMessage"]
class Message:
def __init__(self,
room_name: str,
message_id: str,
parent_id: Optional[str],
previous_edit_id: Optional[str],
timestamp: int,
sender: Session,
content: str,
encryption_key_id: Optional[str],
edited_timestamp: Optional[int],
deleted_timestamp: Optional[int],
truncated: bool
) -> None:
self._room_name = room_name
self._message_id = message_id
self._parent_id = parent_id
self._previous_edit_id = previous_edit_id
self._timestamp = timestamp
self._sender = sender
self._content = content
self._encryption_key_id = encryption_key_id
self._edited_timestamp = edited_timestamp
self._deleted_timestamp = deleted_timestamp
self._truncated = truncated
@classmethod
def from_data(cls, room_name: str, data: Any) -> "Message":
message_id = data["id"]
parent_id = data.get("parent")
previous_edit_id = data.get("previous_edit_id")
timestamp = data["time"]
sender = Session.from_data(room_name, data["sender"])
content = data["content"]
encryption_key_id = data.get("encryption_key_id")
edited_timestamp = data.get("edited")
deleted_timestamp = data.get("deleted")
truncated = data.get("truncated", False)
return cls(room_name, message_id, parent_id, previous_edit_id,
timestamp, sender, content, encryption_key_id,
edited_timestamp, deleted_timestamp, truncated)
# Attributes
@property
def room_name(self) -> str:
return self._room_name
@property
def message_id(self) -> str:
return self._message_id
@property
def parent_id(self) -> Optional[str]:
return self._parent_id
@property
def previous_edit_id(self) -> Optional[str]:
return self._previous_edit_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) -> Session:
return self._sender
@property
def content(self) -> str:
return self._content
@property
def encryption_key_id(self) -> Optional[str]:
return self._encryption_key_id
@property
def edited_time(self) -> Optional[datetime.datetime]:
if self.edited_timestamp is not None:
return datetime.datetime.fromtimestamp(self.edited_timestamp)
else:
return None
@property
def edited_timestamp(self) -> Optional[int]:
return self._edited_timestamp
@property
def deleted_time(self) -> Optional[datetime.datetime]:
if self.deleted_timestamp is not None:
return datetime.datetime.fromtimestamp(self.deleted_timestamp)
else:
return None
@property
def deleted_timestamp(self) -> Optional[int]:
return self._deleted_timestamp
@property
def truncated(self) -> bool:
return self._truncated
class LiveMessage(Message):
def __init__(self,
room: "Room",
message_id: str,
parent_id: Optional[str],
previous_edit_id: Optional[str],
timestamp: int,
sender: LiveSession,
content: str,
encryption_key_id: Optional[str],
edited_timestamp: Optional[int],
deleted_timestamp: Optional[int],
truncated: bool
) -> None:
super().__init__(room.name, message_id, parent_id, previous_edit_id,
timestamp, sender, content, encryption_key_id,
edited_timestamp, deleted_timestamp, truncated)
self._room = room
self._live_sender = sender
@classmethod
def from_data(cls, # type: ignore
room: "Room",
data: Any
) -> "LiveMessage":
return cls.from_message(room, Message.from_data(room.name, data))
@classmethod
def from_message(cls, room: "Room", message: Message) -> "LiveMessage":
live_sender = LiveSession.from_session(room, message.sender)
return cls(room, message.message_id, message.parent_id,
message.previous_edit_id, message.timestamp, live_sender,
message.content, message.encryption_key_id,
message.edited_timestamp, message.deleted_timestamp,
message.truncated)
# Attributes
@property
def room(self) -> "Room":
return self._room
@property
def sender(self) -> LiveSession:
return self._live_sender
# Live stuff
async def reply(self, content: str) -> "LiveMessage":
return await self.room.send(content, parent_id=self.message_id)
async def get(self) -> "LiveMessage":
return await self.room.get(self.message_id)
async def before(self, amount: int) -> List["LiveMessage"]:
return await self.room.log(amount, before_id=self.message_id)

214
yaboli/module.py Normal file
View file

@ -0,0 +1,214 @@
import configparser
import logging
from typing import Callable, Dict, List, Optional
from .bot import Bot
from .command import *
from .message import LiveMessage
from .room import Room
from .session import LiveSession
from .util import *
logger = logging.getLogger(__name__)
__all__ = ["Module", "ModuleConstructor", "ModuleBot", "ModuleBotConstructor"]
class Module(Bot):
DESCRIPTION: Optional[str] = None
def __init__(self,
config: configparser.ConfigParser,
config_file: str,
standalone: bool = True,
) -> None:
super().__init__(config, config_file)
self.standalone = standalone
ModuleConstructor = Callable[[configparser.ConfigParser, str, bool], Module]
class ModuleBot(Bot):
HELP_PRE: Optional[List[str]] = [
"This bot contains the following modules:"
]
HELP_POST: Optional[List[str]] = [
"",
"For module-specific help, try \"!help {atmention} <module>\".",
]
MODULE_HELP_LIMIT = 5
MODULES_SECTION = "modules"
def __init__(self,
config: configparser.ConfigParser,
config_file: str,
module_constructors: Dict[str, ModuleConstructor],
) -> None:
super().__init__(config, config_file)
self.module_constructors = module_constructors
self.modules: Dict[str, Module] = {}
# Load initial modules
for module_name in self.config[self.MODULES_SECTION]:
module_constructor = self.module_constructors.get(module_name)
if module_constructor is None:
logger.warn(f"Module {module_name} not found")
continue
# standalone is set to False
module = module_constructor(self.config, self.config_file, False)
self.load_module(module_name, module)
def load_module(self, name: str, module: Module) -> None:
if name in self.modules:
logger.warn(f"Module {name!r} is already registered, overwriting...")
self.modules[name] = module
def unload_module(self, name: str) -> None:
if name in self.modules:
del self.modules[name]
# Better help messages
def compile_module_overview(self) -> List[str]:
lines = []
if self.HELP_PRE is not None:
lines.extend(self.HELP_PRE)
any_modules = False
modules_without_desc: List[str] = []
for module_name in sorted(self.modules):
any_modules = True
module = self.modules[module_name]
if module.DESCRIPTION is None:
modules_without_desc.append(module_name)
else:
line = f"\t{module_name}{module.DESCRIPTION}"
lines.append(line)
if modules_without_desc:
lines.append("\t" + ", ".join(modules_without_desc))
if not any_modules:
lines.append("No modules loaded.")
if self.HELP_POST is not None:
lines.extend(self.HELP_POST)
return lines
def compile_module_help(self, module_name: str) -> List[str]:
module = self.modules.get(module_name)
if module is None:
return [f"Module {module_name!r} not found."]
elif module.HELP_SPECIFIC is None:
return [f"Module {module_name!r} has no detailed help message."]
return module.HELP_SPECIFIC
async def cmd_modules_help(self,
room: Room,
message: LiveMessage,
args: SpecificArgumentData
) -> None:
if args.has_args():
if len(args.basic()) > self.MODULE_HELP_LIMIT:
limit = self.MODULE_HELP_LIMIT
text = f"A maximum of {limit} module{plural(limit)} is allowed."
await message.reply(text)
else:
for module_name in args.basic():
help_lines = self.compile_module_help(module_name)
await message.reply(self.format_help(room, help_lines))
else:
help_lines = self.compile_module_overview()
await message.reply(self.format_help(room, help_lines))
# Sending along all kinds of events
async def on_connected(self, room: Room) -> None:
await super().on_connected(room)
for module in self.modules.values():
await module.on_connected(room)
async def on_snapshot(self, room: Room, messages: List[LiveMessage]) -> None:
await super().on_snapshot(room, messages)
for module in self.modules.values():
await module.on_snapshot(room, messages)
async def on_send(self, room: Room, message: LiveMessage) -> None:
await super().on_send(room, message)
for module in self.modules.values():
await module.on_send(room, message)
async def on_join(self, room: Room, user: LiveSession) -> None:
await super().on_join(room, user)
for module in self.modules.values():
await module.on_join(room, user)
async def on_part(self, room: Room, user: LiveSession) -> None:
await super().on_part(room, user)
for module in self.modules.values():
await module.on_part(room, user)
async def on_nick(self,
room: Room,
user: LiveSession,
from_nick: str,
to_nick: str
) -> None:
await super().on_nick(room, user, from_nick, to_nick)
for module in self.modules.values():
await module.on_nick(room, user, from_nick, to_nick)
async def on_edit(self, room: Room, message: LiveMessage) -> None:
await super().on_edit(room, message)
for module in self.modules.values():
await module.on_edit(room, message)
async def on_login(self, room: Room, account_id: str) -> None:
await super().on_login(room, account_id)
for module in self.modules.values():
await module.on_login(room, account_id)
async def on_logout(self, room: Room) -> None:
await super().on_logout(room)
for module in self.modules.values():
await module.on_logout(room)
async def on_pm(self,
room: Room,
from_id: str,
from_nick: str,
from_room: str,
pm_id: str
) -> None:
await super().on_pm(room, from_id, from_nick, from_room, pm_id)
for module in self.modules.values():
await module.on_pm(room, from_id, from_nick, from_room, pm_id)
async def on_disconnect(self, room: Room, reason: str) -> None:
await super().on_disconnect(room, reason)
for module in self.modules.values():
await module.on_disconnect(room, reason)
ModuleBotConstructor = Callable[
[configparser.ConfigParser, str, Dict[str, ModuleConstructor]],
Bot
]

View file

@ -1,352 +1,562 @@
import asyncio import asyncio
import logging import logging
import time from typing import Any, Awaitable, Callable, List, Optional, Tuple, TypeVar
from .connection import * from .connection import Connection
from .events import Events
from .exceptions import * from .exceptions import *
from .utils import * from .message import LiveMessage
from .session import Account, LiveSession, LiveSessionListing
from .util import atmention
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
__all__ = ["Room", "Inhabitant"]
__all__ = ["Room"]
T = TypeVar("T")
class Room: class Room:
""" """
TODO Events and parameters:
"""
"connected" - fired after the Room has authenticated, joined and set its
CONNECTED = 1 nick, meaning that now, messages can be sent
DISCONNECTED = 2 no parameters
CLOSED = 3
"snapshot" - snapshot of the room's messages at the time of joining
def __init__(self, inhabitant, roomname, nick, password=None, human=False, cookiejar=None): messages: List[LiveMessage]
# TODO: Connect to room etc.
# TODO: Deal with room/connection states of: "send" - another room member has sent a message
# disconnected connecting, fast-forwarding, connected message: LiveMessage
# Room info (all fields readonly!) "join" - somebody has joined the room
self.target_nick = nick user: LiveSession
self.roomname = roomname
self.password = password "part" - somebody has left the room
self.human = human user: LiveSession
self.session = None "nick" - another room member has changed their nick
self.account = None user: LiveSession
self.listing = Listing() from: str
to: str
self.start_time = time.time()
"edit" - a message in the room has been modified or deleted
self.account_has_access = None message: LiveMessage
self.account_email_verified = None
self.room_is_private = None "login" - this session has been logged in from another session
self.version = None # the version of the code being run and served by the server account_id: str
self.pm_with_nick = None
self.pm_with_user_id = None "logout" - this session has been logged out from another session
no parameters
self._inhabitant = inhabitant
self._status = Room.DISCONNECTED "pm" - another session initiated a pm with you
self._connected_future = asyncio.Future() from: str - the id of the user inviting the client to chat privately
from_nick: str - the nick of the inviting user
# TODO: Allow for all parameters of Connection() to be specified in Room(). from_room: str - the room where the invitation was sent from
self._connection = Connection( pm_id: str - the private chat can be accessed at /room/pm:PMID
self.format_room_url(self.roomname, human=self.human),
self._receive_packet, "disconect" - corresponds to http://api.euphoria.io/#disconnect-event (if
self._disconnected, the reason is "authentication changed", the room automatically reconnects)
cookiejar reason: str - the reason for disconnection
) """
async def exit(self): URL_FORMAT = "wss://euphoria.io/room/{}/ws"
self._status = Room.CLOSED
await self._connection.stop() def __init__(self,
name: str,
# ROOM COMMANDS password: Optional[str] = None,
# These always return a response from the server. target_nick: str = "",
# If the connection is lost while one of these commands is called, url_format: str = URL_FORMAT,
# the command will retry once the bot has reconnected. cookie_file: Optional[str] = None,
) -> None:
async def get_message(self, mid): self._name = name
if self._status == Room.CLOSED: self._password = password
raise RoomClosed() self._target_nick = target_nick
self._url_format = url_format
ptype, data, error, throttled = await self._send_while_connected(
"get-message", self._session: Optional[LiveSession] = None
id=mid self._account: Optional[Account] = None
) self._private: Optional[bool] = None
self._version: Optional[str] = None
return Message.from_dict(data) self._users: Optional[LiveSessionListing] = None
self._pm_with_nick: Optional[str] = None
async def log(self, n, before_mid=None): self._pm_with_user_id: Optional[str] = None
if self._status == Room.CLOSED: self._server_version: Optional[str] = None
raise RoomClosed()
# Connected management
if before_mid: self._url = self._url_format.format(self._name)
ptype, data, error, throttled = await self._send_while_connected( self._connection = Connection(self._url, cookie_file=cookie_file)
"log", self._events = Events()
n=n,
before=before_mid self._connected = asyncio.Event()
) self._connected_successfully = False
else: self._hello_received = False
ptype, data, error, throttled = await self._send_while_connected( self._snapshot_received = False
"log",
n=n self._connection.register_event("reconnecting", self._on_reconnecting)
) self._connection.register_event("hello-event", self._on_hello_event)
self._connection.register_event("snapshot-event", self._on_snapshot_event)
return [Message.from_dict(d) for d in data.get("log")] self._connection.register_event("bounce-event", self._on_bounce_event)
async def nick(self, nick): self._connection.register_event("disconnect-event", self._on_disconnect_event)
if self._status == Room.CLOSED: self._connection.register_event("join-event", self._on_join_event)
raise RoomClosed() self._connection.register_event("login-event", self._on_login_event)
self._connection.register_event("logout-event", self._on_logout_event)
self.target_nick = nick self._connection.register_event("network-event", self._on_network_event)
ptype, data, error, throttled = await self._send_while_connected( self._connection.register_event("nick-event", self._on_nick_event)
"nick", self._connection.register_event("edit-message-event", self._on_edit_message_event)
name=nick self._connection.register_event("part-event", self._on_part_event)
) self._connection.register_event("pm-initiate-event", self._on_pm_initiate_event)
self._connection.register_event("send-event", self._on_send_event)
sid = data.get("session_id")
uid = data.get("id") def register_event(self,
from_nick = data.get("from") event: str,
to_nick = data.get("to") callback: Callable[..., Awaitable[None]]
) -> None:
self.session.nick = to_nick """
return sid, uid, from_nick, to_nick Register an event callback.
async def pm(self, uid): For an overview of the possible events, see the Room docstring.
if self._status == Room.CLOSED: """
raise RoomClosed()
self._events.register(event, callback)
ptype, data, error, throttled = await self._send_while_connected(
"pm-initiate", # Connecting, reconnecting and disconnecting
user_id=uid
) async def _try_set_connected(self) -> None:
packets_received = self._hello_received and self._snapshot_received
# Just ignoring non-authenticated errors if packets_received and not self._connected.is_set():
pm_id = data.get("pm_id") await self._set_nick_if_necessary()
to_nick = data.get("to_nick") self._set_connected()
return pm_id, to_nick
async def _set_nick_if_necessary(self) -> None:
async def send(self, content, parent_mid=None): nick_needs_updating = (self._session is None
if parent_mid: or self._target_nick != self._session.nick)
ptype, data, error, throttled = await self._send_while_connected(
"send", if self._target_nick and nick_needs_updating:
content=content, await self._nick(self._target_nick)
parent=parent_mid
) def _set_connected(self) -> None:
else: self._connected_successfully = True
ptype, data, error, throttled = await self._send_while_connected( self._connected.set()
"send",
content=content def _set_connected_failed(self) -> None:
) if not self._connected.is_set():
self._connected_successfully = False
return Message.from_dict(data) self._connected.set()
async def who(self): def _set_connected_reset(self) -> None:
ptype, data, error, throttled = await self._send_while_connected("who") self._connected.clear()
self.listing = Listing.from_dict(data.get("listing")) self._connected_successfully = False
self._hello_received = False
# COMMUNICATION WITH CONNECTION self._snapshot_received = False
async def _disconnected(self): async def _on_reconnecting(self) -> None:
# While disconnected, keep the last known session info, listing etc. self._set_connected_reset()
# All of this is instead reset when the hello/snapshot events are received.
self.status = Room.DISCONNECTED async def _on_hello_event(self, packet: Any) -> None:
self._connected_future = asyncio.Future() data = packet["data"]
await self._inhabitant.disconnected(self) self._session = LiveSession.from_data(self, data["session"])
self._private = data["room_is_private"]
async def _receive_packet(self, ptype, data, error, throttled): self._version = data["version"]
# Ignoring errors and throttling for now
functions = { if "account" in data:
"bounce-event": self._event_bounce, self._account = Account.from_data(data)
#"disconnect-event": self._event_disconnect, # Not important, can ignore
"hello-event": self._event_hello, self._hello_received = True
"join-event": self._event_join, await self._try_set_connected()
#"login-event": self._event_login,
#"logout-event": self._event_logout, async def _on_snapshot_event(self, packet: Any) -> None:
"network-event": self._event_network, data = packet["data"]
"nick-event": self._event_nick,
#"edit-message-event": self._event_edit_message, self._server_version = data["version"]
"part-event": self._event_part, self._users = LiveSessionListing.from_data(self, data["listing"])
"ping-event": self._event_ping, self._pm_with_nick = data.get("pm_with_nick")
"pm-initiate-event": self._event_pm_initiate, self._pm_with_user_id = data.get("pm_with_user_id")
"send-event": self._event_send,
"snapshot-event": self._event_snapshot, # Update session nick
} nick = data.get("nick")
if nick is not None and self._session is not None:
function = functions.get(ptype) self._session = self.session.with_nick(nick)
if function:
await function(data)
async def _event_bounce(self, data):
if self.password is not None:
try:
data = {"type": passcode, "passcode": self.password}
response = await self._connection.send("auth", data=data)
rdata = response.get("data")
success = rdata.get("success")
if not success:
reason = rdata.get("reason")
raise AuthenticationRequired(f"Could not join &{self.roomname}: {reason}")
except ConnectionClosed:
pass
else:
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)
async def _event_join(self, data):
session = Session.from_dict(data)
self.listing.add(session)
await self._inhabitant.join(self, session)
async def _event_network(self, data):
server_id = data.get("server_id")
server_era = data.get("server_era")
sessions = self.listing.remove_combo(server_id, server_era)
for session in sessions:
await self._inhabitant.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.nick(self, sid, uid, from_nick, to_nick)
async def _event_part(self, data):
session = Session.from_dict(data)
self.listing.remove(session.sid)
await self._inhabitant.part(self, session)
async def _event_ping(self, data):
try:
new_data = {"time": data.get("time")}
await self._connection.send( "ping-reply", data=new_data, await_response=False)
except ConnectionClosed:
pass
async def _event_pm_initiate(self, data):
from_uid = data.get("from")
from_nick = data.get("from_nick")
from_room = data.get("from_room")
pm_id = data.get("pm_id")
await self._inhabitant.pm(self, from_uid, from_nick, from_room, pm_id)
async def _event_send(self, data):
message = Message.from_dict(data)
await self._inhabitant.send(self, message)
# TODO: Figure out a way to bring fast-forwarding into this
async def _event_snapshot(self, data):
# Update listing
self.listing = Listing()
sessions = [Session.from_dict(d) for d in data.get("listing")]
for session in sessions:
self.listing.add(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:
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!
self.status = Room.CONNECTED
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.
logger.debug("Letting inhabitant know")
log = [Message.from_dict(m) for m in data.get("log")]
await self._inhabitant.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 # Send "snapshot" event
def format_room_url(roomname, private=False, human=False): messages = [LiveMessage.from_data(self, msg_data)
if private: for msg_data in data["log"]]
roomname = f"pm:{roomname}" self._events.fire("snapshot", messages)
url = f"wss://euphoria.io/room/{roomname}/ws" self._snapshot_received = True
await self._try_set_connected()
if human: async def _on_bounce_event(self, packet: Any) -> None:
url = f"{url}?h=1" data = packet["data"]
return url # Can we even authenticate? (Assuming that passcode authentication is
# available if no authentication options are given: Euphoria doesn't
# (always) send authentication options, even when passcode
# authentication works.)
if not "passcode" in data.get("auth_options", ["passcode"]):
self._set_connected_failed()
return
async def connected(self): # If so, do we have a password?
await self._connected_future if self._password is None:
self._set_connected_failed()
return
# REST OF THE IMPLEMENTATION reply = await self._connection.send(
"auth",
{"type": "passcode", "passcode": self._password}
)
async def _send_while_connected(self, *args, **kwargs): if not reply["data"]["success"]:
while True: self._set_connected_failed()
if self._status == Room.CLOSED:
raise RoomClosed()
try: async def connect(self) -> bool:
await self.connected() """
return await self._connection.send(*args, data=kwargs) Attempt to connect to the room and start handling events.
except ConnectionClosed:
pass # just try again
This function returns once the Room is fully connected, i. e.
authenticated, using the correct nick and able to post messages.
"""
class Inhabitant: if not await self._connection.connect():
""" return False
TODO
"""
# ROOM EVENTS await self._connected.wait()
# These functions are called by the room when something happens. if not self._connected_successfully:
# They're launched via asyncio.ensure_future(), so they don't block execution of the room. return False
# Just overwrite the events you need (make sure to keep the arguments the same though).
async def disconnected(self, room): self._events.fire("connected")
pass return True
async def connected(self, room, log): async def disconnect(self) -> None:
pass """
Disconnect from the room and stop the Room.
async def join(self, room, session): This function has the potential to mess things up, and it has not yet
pass been tested thoroughly. Use at your own risk, especially if you want to
call connect() after calling disconnect().
"""
async def part(self, room, session): self._set_connected_reset()
pass await self._connection.disconnect()
async def nick(self, room, sid, uid, from_nick, to_nick): # Other events
pass
async def send(self, room, message): async def _on_disconnect_event(self, packet: Any) -> None:
pass reason = packet["data"]["reason"]
async def fast_forward(self, room, message): if reason == "authentication changed":
pass await self._connection.reconnect()
async def pm(self, room, from_uid, from_nick, from_room, pm_id): self._events.fire("disconnect", reason)
pass
async def _on_join_event(self, packet: Any) -> None:
data = packet["data"]
session = LiveSession.from_data(self, data)
self._users = self.users.with_join(session)
logger.info(f"&{self.name}: {session.atmention} joined")
self._events.fire("join", session)
async def _on_login_event(self, packet: Any) -> None:
"""
Just reconnect, see
https://github.com/euphoria-io/heim/blob/master/client/lib/stores/chat.js#L275-L276
"""
data = packet["data"]
account_id = data["account_id"]
self._events.fire("login", account_id)
logger.info(f"&{self.name}: Got logged in to {account_id}, reconnecting")
await self._connection.reconnect()
async def _on_logout_event(self, packet: Any) -> None:
"""
Just reconnect, see
https://github.com/euphoria-io/heim/blob/master/client/lib/stores/chat.js#L275-L276
"""
self._events.fire("logout")
logger.info(f"&{self.name}: Got logged out, reconnecting")
await self._connection.reconnect()
async def _on_network_event(self, packet: Any) -> None:
data = packet["data"]
if data["type"] == "partition":
server_id = data["server_id"]
server_era = data["server_era"]
users = self.users
for user in self.users:
if user.server_id == server_id and user.server_era == server_era:
users = users.with_part(user)
logger.info(f"&{self.name}: {user.atmention} left")
self._events.fire("part", user)
self._users = users
async def _on_nick_event(self, packet: Any) -> None:
data = packet["data"]
session_id = data["session_id"]
nick_from = data["from"]
nick_to = data["to"]
session = self.users.get(session_id)
if session is not None:
self._users = self.users.with_nick(session, nick_to)
else:
await self.who() # recalibrating self._users
logger.info(f"&{self.name}: {atmention(nick_from)} is now called {atmention(nick_to)}")
self._events.fire("nick", session, nick_from, nick_to)
async def _on_edit_message_event(self, packet: Any) -> None:
data = packet["data"]
message = LiveMessage.from_data(self, data)
self._events.fire("edit", message)
async def _on_part_event(self, packet: Any) -> None:
data = packet["data"]
session = LiveSession.from_data(self, data)
self._users = self.users.with_part(session)
logger.info(f"&{self.name}: {session.atmention} left")
self._events.fire("part", session)
async def _on_pm_initiate_event(self, packet: Any) -> None:
data = packet["data"]
from_id = data["from"]
from_nick = data["from_nick"]
from_room = data["from_room"]
pm_id = data["pm_id"]
self._events.fire("pm", from_id, from_nick, from_room, pm_id)
async def _on_send_event(self, packet: Any) -> None:
data = packet["data"]
message = LiveMessage.from_data(self, data)
self._events.fire("send", message)
# Attributes, ordered the same as in __init__
def _wrap_optional(self, x: Optional[T]) -> T:
if x is None:
raise RoomNotConnectedException()
return x
@property
def name(self) -> str:
return self._name
@property
def password(self) -> Optional[str]:
return self._password
@property
def target_nick(self) -> str:
return self._target_nick
@property
def url_format(self) -> str:
return self._url_format
@property
def session(self) -> LiveSession:
return self._wrap_optional(self._session)
@property
def account(self) -> Account:
return self._wrap_optional(self._account)
@property
def private(self) -> bool:
return self._wrap_optional(self._private)
@property
def version(self) -> str:
return self._wrap_optional(self._version)
@property
def users(self) -> LiveSessionListing:
return self._wrap_optional(self._users)
@property
def pm_with_nick(self) -> str:
return self._wrap_optional(self._pm_with_nick)
@property
def pm_with_user_id(self) -> str:
return self._wrap_optional(self._pm_with_user_id)
@property
def url(self) -> str:
return self._url
# Functionality
def _extract_data(self, packet: Any) -> Any:
error = packet.get("error")
if error is not None:
raise EuphError(error)
return packet["data"]
async def _ensure_connected(self) -> None:
await self._connected.wait()
if not self._connected_successfully:
raise RoomNotConnectedException()
async def send(self,
content: str,
parent_id: Optional[str] = None
) -> LiveMessage:
await self._ensure_connected()
data = {"content": content}
if parent_id is not None:
data["parent"] = parent_id
reply = await self._connection.send("send", data)
data = self._extract_data(reply)
return LiveMessage.from_data(self, data)
async def _nick(self, nick: str) -> str:
"""
This function implements all of the nick-setting logic except waiting
for the room to actually connect. This is because connect() actually
uses this function to set the desired nick before the room is
connected.
"""
logger.debug(f"Setting nick to {nick!r}")
self._target_nick = nick
reply = await self._connection.send("nick", {"name": nick})
data = self._extract_data(reply)
new_nick = data["to"]
self._target_nick = new_nick
if self._session is not None:
self._session = self._session.with_nick(new_nick)
logger.debug(f"Set nick to {new_nick!r}")
return new_nick
async def nick(self, nick: str) -> str:
await self._ensure_connected()
return await self._nick(nick)
async def get(self, message_id: str) -> LiveMessage:
await self._ensure_connected()
reply = await self._connection.send("get-message", {"id": message_id})
data = self._extract_data(reply)
return LiveMessage.from_data(self, data)
async def log(self,
amount: int,
before_id: Optional[str] = None
) -> List[LiveMessage]:
await self._ensure_connected()
data: Any = {"n": amount}
if before_id is not None:
data["before"] = before_id
reply = await self._connection.send("log", data)
data = self._extract_data(reply)
messages = [LiveMessage.from_data(self, msg_data)
for msg_data in data["log"]]
return messages
async def who(self) -> LiveSessionListing:
await self._ensure_connected()
reply = await self._connection.send("who", {})
data = self._extract_data(reply)
users = LiveSessionListing.from_data(self, data["listing"])
# Assumes that self._session is set (we're connected)
session = users.get(self.session.session_id)
if session is not None:
self._session = session
self._users = users.with_part(self._session)
else:
self._users = users
return self._users
async def login(self, email: str, password: str) -> Tuple[bool, str]:
"""
Since euphoria appears to only support email authentication, this way
of logging in is hardcoded here.
Returns whether the login was successful. If it was, the second
parameter is the account id. If it wasn't, the second parameter is the
reason why the login failed.
"""
data: Any = {
"namespace": "email",
"id": email,
"password": password,
}
reply = await self._connection.send("login", data)
data = self._extract_data(reply)
success: bool = data["success"]
account_id_or_reason = data.get("account_id") or data["reason"]
if success:
logger.info(f"&{self.name}: Logged in as {account_id_or_reason}")
else:
logger.info(f"&{self.name}: Failed to log in with {email} because {account_id_or_reason}")
await self._connection.reconnect()
return success, account_id_or_reason
async def logout(self) -> None:
await self._connection.send("logout", {})
logger.info(f"&{self.name}: Logged out")
await self._connection.reconnect()
async def pm(self, user_id: str) -> Tuple[str, str]:
"""
Returns the pm_id of the pm and the nick of the person being pinged.
"""
data = {"user_id": user_id}
reply = await self._connection.send("pm-initiate", data)
data = self._extract_data(reply)
pm_id = data["pm_id"]
to_nick = data["to_nick"]
return pm_id, to_nick

324
yaboli/session.py Normal file
View file

@ -0,0 +1,324 @@
import re
from typing import (TYPE_CHECKING, Any, Dict, Iterable, Iterator, List,
Optional, Tuple)
from .util import mention, normalize
if TYPE_CHECKING:
from .room import Room
__all__ = ["Account", "Session", "LiveSession", "LiveSessionListing"]
class Account:
"""
This class represents a http://api.euphoria.io/#personalaccountview, with a
few added fields stolen from the hello-event (see
http://api.euphoria.io/#hello-event).
"""
def __init__(self,
account_id: str,
name: str,
email: str,
has_access: Optional[bool],
email_verified: Optional[bool]
) -> None:
self._account_id = account_id
self._name = name
self._email = email
self._has_access = has_access
self._email_verified = email_verified
@classmethod
def from_data(cls, data: Any) -> "Account":
"""
The data parameter must be the "data" part of a hello-event.
If, in the future, a PersonalAccountView appears in other places, this
function might have to be changed.
"""
view = data["account"]
account_id = view["id"]
name = view["name"]
email = view["email"]
has_access = data.get("account_has_access")
email_verified = data.get("account_email_verified")
return cls(account_id, name, email, has_access, email_verified)
# Attributes
@property
def account_id(self) -> str:
return self._account_id
@property
def name(self) -> str:
return self._name
@property
def email(self) -> str:
return self._email
@property
def has_access(self) -> Optional[bool]:
return self._has_access
@property
def email_verified(self) -> Optional[bool]:
return self._email_verified
class Session:
_ID_SPLIT_RE = re.compile(r"(agent|account|bot):(.*)")
def __init__(self,
room_name: str,
user_id: str,
nick: str,
server_id: str,
server_era: str,
session_id: str,
is_staff: bool,
is_manager: bool,
client_address: Optional[str]
) -> None:
self._room_name = room_name
self._user_id = user_id
self._id_type: Optional[str]
match = self._ID_SPLIT_RE.fullmatch(self._user_id)
if match is not None:
self._id_type = match.group(1)
else:
self._id_type = None
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
def _copy(self) -> "Session":
return Session(self.room_name, self.user_id, self.nick, self.server_id,
self.server_era, self.session_id, self.is_staff,
self.is_manager, self.client_address)
@classmethod
def from_data(cls, room_name: str, data: Any) -> "Session":
user_id = data["id"]
nick = data["name"]
server_id = data["server_id"]
server_era = data["server_era"]
session_id = data["session_id"]
is_staff = data.get("is_staff", False)
is_manager = data.get("is_manager", False)
client_address = data.get("client_address")
return cls(room_name, user_id, nick, server_id, server_era, session_id,
is_staff, is_manager, client_address)
def with_nick(self, nick: str) -> "Session":
copy = self._copy()
copy._nick = nick
return copy
# Attributes
@property
def room_name(self) -> str:
return self._room_name
@property
def user_id(self) -> str:
return self._user_id
@property
def nick(self) -> str:
return self._nick
@property
def server_id(self) -> str:
return self._server_id
@property
def server_era(self) -> str:
return self._server_era
@property
def session_id(self) -> str:
return self._session_id
@property
def is_staff(self) -> bool:
return self._is_staff
@property
def is_manager(self) -> bool:
return self._is_manager
@property
def client_address(self) -> Optional[str]:
return self._client_address
@property
def mention(self) -> str:
return mention(self.nick, ping=False)
@property
def atmention(self) -> str:
return mention(self.nick, ping=True)
@property
def normalize(self) -> str:
return normalize(self.nick)
@property
def is_person(self) -> bool:
return self._id_type is None or self._id_type in ["agent", "account"]
@property
def is_agent(self) -> bool:
return self._id_type == "agent"
@property
def is_account(self) -> bool:
return self._id_type == "account"
@property
def is_bot(self) -> bool:
return self._id_type == "bot"
class LiveSession(Session):
def __init__(self,
room: "Room",
user_id: str,
nick: str,
server_id: str,
server_era: str,
session_id: str,
is_staff: bool,
is_manager: bool,
client_address: Optional[str]
) -> None:
super().__init__(room.name, user_id, nick, server_id, server_era,
session_id, is_staff, is_manager, client_address)
self._room = room
def _copy(self) -> "LiveSession":
return self.from_session(self._room, self)
# Ignoring the type discrepancy since it is more convenient this way
@classmethod
def from_data(cls, # type: ignore
room: "Room",
data: Any
) -> "LiveSession":
return cls.from_session(room, Session.from_data(room.name, data))
@classmethod
def from_session(cls, room: "Room", session: Session) -> "LiveSession":
return cls(room, session.user_id, session.nick, session.server_id,
session.server_era, session.session_id, session.is_staff,
session.is_manager, session.client_address)
def with_nick(self, nick: str) -> "LiveSession":
copy = self._copy()
copy._nick = nick
return copy
# Attributes
@property
def room(self) -> "Room":
return self._room
# Live stuff
async def pm(self) -> Tuple[str, str]:
"""
See Room.pm
"""
return await self.room.pm(self.user_id)
class LiveSessionListing:
def __init__(self, room: "Room", sessions: Iterable[LiveSession]) -> None:
self._room = room
# just to make sure it doesn't get changed on us
self._sessions: Dict[str, LiveSession] = {session.session_id: session
for session in sessions}
def __iter__(self) -> Iterator[LiveSession]:
return self._sessions.values().__iter__()
def _copy(self) -> "LiveSessionListing":
return LiveSessionListing(self.room, self)
@classmethod
def from_data(cls,
room: "Room",
data: Any,
exclude_id: Optional[str] = None
) -> "LiveSessionListing":
sessions = [LiveSession.from_data(room, subdata) for subdata in data]
if exclude_id:
sessions = [session for session in sessions
if session.session_id != exclude_id]
return cls(room, sessions)
def get(self, session_id: str) -> Optional[LiveSession]:
return self._sessions.get(session_id)
def with_join(self, session: LiveSession) -> "LiveSessionListing":
copy = self._copy()
copy._sessions[session.session_id] = session
return copy
def with_part(self, session: LiveSession) -> "LiveSessionListing":
copy = self._copy()
if session.session_id in copy._sessions:
del copy._sessions[session.session_id]
return copy
def with_nick(self,
session: LiveSession,
new_nick: str
) -> "LiveSessionListing":
copy = self._copy()
copy._sessions[session.session_id] = session.with_nick(new_nick)
return copy
# Attributes
@property
def room(self) -> "Room":
return self._room
@property
def all(self) -> List[LiveSession]:
return list(self._sessions.values())
@property
def people(self) -> List[LiveSession]:
return [session for session in self if session.is_person]
@property
def accounts(self) -> List[LiveSession]:
return [session for session in self if session.is_account]
@property
def agents(self) -> List[LiveSession]:
return [session for session in self if session.is_agent]
@property
def bots(self) -> List[LiveSession]:
return [session for session in self if session.is_bot]

73
yaboli/util.py Normal file
View file

@ -0,0 +1,73 @@
import asyncio
import datetime
import functools
import re
from typing import Any, Callable
__all__ = ["asyncify", "mention", "atmention", "normalize", "similar",
"plural", "format_time", "format_delta"]
async def asyncify(func: Callable[..., Any], *args: Any, **kwargs: Any) -> Any:
func_with_args = functools.partial(func, *args, **kwargs)
loop = asyncio.get_running_loop()
return await loop.run_in_executor(None, func_with_args)
# Name/nick related functions
def mention(nick: str, ping: bool = False) -> str:
mentioned = re.sub(r"""[,.!?;&<'"\s]""", "", nick)
return "@" + mentioned if ping else mentioned
def atmention(nick: str) -> str:
return mention(nick, ping=True)
def normalize(nick: str) -> str:
return mention(nick, ping=False).lower()
def similar(nick_a: str, nick_b: str) -> bool:
return normalize(nick_a) == normalize(nick_b)
# Other formatting
def plural(
number: int,
if_plural: str = "s",
if_singular: str = ""
) -> str:
if number in [1, -1]:
return if_singular
else:
return if_plural
def format_time(time: datetime.datetime) -> str:
return time.strftime("%F %T")
def format_delta(delta: datetime.timedelta) -> str:
seconds = int(delta.total_seconds())
negative = seconds < 0
seconds = abs(seconds)
days = seconds // (60 * 60 * 24)
seconds -= days * (60 * 60 * 24)
hours = seconds // (60 * 60)
seconds -= hours * (60 * 60)
minutes = seconds // 60
seconds -= minutes * 60
text: str
if days > 0:
text = f"{days}d {hours}h {minutes}m {seconds}s"
elif hours > 0:
text = f"{hours}h {minutes}m {seconds}s"
elif minutes > 0:
text = f"{minutes}m {seconds}s"
else:
text = f"{seconds}s"
if negative:
text = "- " + text
return text

View file

@ -1,203 +0,0 @@
import asyncio
import logging
import time
logger = logging.getLogger(__name__)
__all__ = [
"parallel",
"mention", "mention_reduced", "similar",
"format_time", "format_time_delta",
"Session", "Listing", "Message",
]
# alias for parallel message sending
parallel = asyncio.ensure_future
def mention(nick):
return "".join(c for c in nick if c not in ".!?;&<'\"" and not c.isspace())
def mention_reduced(nick):
return mention(nick).lower()
def similar(nick1, nick2):
return mention_reduced(nick1) == mention_reduced(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_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_address = real_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_address", None)
)
@property
def client_type(self):
# account, agent or bot
return self.user_id.split(":")[0]
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=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 = encryption_key
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", None),
d.get("edited", None),
d.get("deleted", None),
d.get("truncated", None)
)