Compare commits
159 commits
| Author | SHA1 | Date | |
|---|---|---|---|
| eba398e5d3 | |||
| 37c4ba703a | |||
| 74caea4e92 | |||
| 1d25b596bb | |||
| 455d2af251 | |||
| 1b9860ba1e | |||
| 66b56a450e | |||
| 2215e75c34 | |||
| 7024686ff2 | |||
| 1c409601db | |||
| 74a8adfa58 | |||
| 6a15e1a948 | |||
| eb9cc4f9bd | |||
| ca56de710c | |||
| 83af4ff9e8 | |||
| de4ba53de8 | |||
| d9f25a04fb | |||
| e53ce42e99 | |||
| 1297cf201b | |||
| 838c364066 | |||
| c579adca9a | |||
| 7e74499f81 | |||
| 7780cb92de | |||
| 86472afb3f | |||
| 2f7502723b | |||
| 7e56de60da | |||
| 24128a460a | |||
| 135640ca44 | |||
| 0d58f61652 | |||
| e09e2d215f | |||
| 7b7ddaa0d1 | |||
| ac70f45229 | |||
| b726d8f9f0 | |||
| b7579b5b78 | |||
| 8f576b1147 | |||
| 6741d36009 | |||
| 5586020d1e | |||
| f40fb2d45d | |||
| f46ca47a28 | |||
| 1d772e7215 | |||
| 1d66b3a518 | |||
| 903ba4973b | |||
| 62e5adc878 | |||
| 8cd2c8d125 | |||
| a78f57db7a | |||
| 3255ea770e | |||
| a0f7c8e84a | |||
| 14b4e74c7e | |||
| 2bf512d8dc | |||
| 8dd94b6ac8 | |||
| 9cb38c4ca8 | |||
| 40cb7917c8 | |||
| 147ea92102 | |||
| b7831cfeb2 | |||
| ee726b93ff | |||
| 788b116d83 | |||
| 27b3f4b29e | |||
| 82a292b5d8 | |||
| 72d10f5c43 | |||
| 4cd422493b | |||
| ed2bd2a2c6 | |||
| c3fa1bf396 | |||
| 17d4fb216e | |||
| 97f05272ca | |||
| 06af0e7faa | |||
| 9d0c588685 | |||
| bf736430a6 | |||
| 40edcdc791 | |||
| 20f635a7ae | |||
| b437731c7f | |||
| 8c34a450c1 | |||
| 2a9cd03c47 | |||
| 2de2cbf92c | |||
| c60526a34d | |||
| a24e4aa18a | |||
| 500d91a14c | |||
| 47a8014b4c | |||
| 0d9161fd1e | |||
| f6f7cc5aa6 | |||
| 23425090cc | |||
| 325af11fea | |||
| a5af01f669 | |||
|
|
5e108fd31b | ||
|
|
2c1a1996a1 | ||
|
|
85eb820fa6 | ||
|
|
e60d0ba81e | ||
|
|
6ed513efd9 | ||
|
|
ceaf8748e9 | ||
|
|
fa579ec231 | ||
|
|
0881d25103 | ||
|
|
aee21f359c | ||
|
|
ac04e7fd30 | ||
|
|
6b1348236d | ||
|
|
1fee49d0e4 | ||
|
|
5c254f4e70 | ||
|
|
78bb6b935f | ||
|
|
d55d05826f | ||
|
|
df72a3d9cf | ||
|
|
bb2dadb862 | ||
|
|
339d3ca516 | ||
|
|
46cd20ac74 | ||
|
|
31f4e23aba | ||
|
|
55798a5b88 | ||
|
|
7e28c6e3dd | ||
|
|
d9761008f6 | ||
|
|
943537b57a | ||
|
|
f8d3f68ed9 | ||
|
|
bd75d0ebba | ||
|
|
6174fa6ff1 | ||
|
|
e39f41e183 | ||
|
|
5ee578258e | ||
|
|
b5827df2f1 | ||
|
|
fb7079a7a9 | ||
|
|
1bb38fc836 | ||
|
|
3eade77cf1 | ||
|
|
3051b15095 | ||
|
|
a971b7e064 | ||
|
|
1f5fc58e06 | ||
|
|
6b65bef5e0 | ||
|
|
04f4c3a8b6 | ||
|
|
c728ff331a | ||
|
|
5952c4c6bd | ||
|
|
156e9088ef | ||
|
|
16e9910729 | ||
|
|
be48e67b3a | ||
|
|
ba9c5d38cc | ||
|
|
31b3f715e0 | ||
|
|
6628b27ec3 | ||
|
|
405a9b81a5 | ||
|
|
da84c6685e | ||
|
|
5b0f078f7a | ||
|
|
ccaaf6be3f | ||
|
|
b917fed126 | ||
|
|
023d154d37 | ||
|
|
2e7b364307 | ||
|
|
db07cdf17b | ||
|
|
41d7e5b0dc | ||
|
|
9c4f5e4372 | ||
|
|
7cfdc0f13b | ||
|
|
85bcdad916 | ||
|
|
a6d4a0779f | ||
|
|
828bb978c2 | ||
|
|
ea1f68fb84 | ||
|
|
80b8a997fe | ||
|
|
d783abf014 | ||
|
|
3945c6ae45 | ||
|
|
3d12b070e8 | ||
|
|
4deb1870ee | ||
|
|
053573e3cb | ||
|
|
b8bb75a897 | ||
|
|
1c3b9d0a20 | ||
|
|
34e1ae4b8f | ||
|
|
676f9d395b | ||
|
|
a199af40d9 | ||
|
|
6cc8094e0d | ||
|
|
dfad3241fb | ||
|
|
04364c6b3f | ||
|
|
d0ad542b72 | ||
|
|
97b98c29f7 |
31 changed files with 3480 additions and 2234 deletions
6
.gitignore
vendored
6
.gitignore
vendored
|
|
@ -1,2 +1,4 @@
|
|||
yaboli/__pycache__/
|
||||
*.txt
|
||||
__pycache__/
|
||||
*.egg-info/
|
||||
/.mypy_cache/
|
||||
/.venv/
|
||||
|
|
|
|||
60
CHANGELOG.md
Normal file
60
CHANGELOG.md
Normal 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
|
||||
21
LICENSE
Normal file
21
LICENSE
Normal 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
85
README.md
Normal 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
13
docs/bot_setup.md
Normal 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
89
docs/index.md
Normal 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
5
examples/echo/.gitignore
vendored
Normal 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
|
||||
6
examples/echo/bot.conf.default
Normal file
6
examples/echo/bot.conf.default
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
[general]
|
||||
nick = EchoBot
|
||||
cookie_file = bot.cookie
|
||||
|
||||
[rooms]
|
||||
test
|
||||
23
examples/echo/echobot.py
Normal file
23
examples/echo/echobot.py
Normal 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)
|
||||
17
examples/gitignore_with_venv
Normal file
17
examples/gitignore_with_venv
Normal 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
4
mypy.ini
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
[mypy]
|
||||
disallow_untyped_defs = True
|
||||
disallow_incomplete_defs = True
|
||||
no_implicit_optional = True
|
||||
30
pyproject.toml
Normal file
30
pyproject.toml
Normal 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.
|
||||
|
|
@ -1,11 +1,83 @@
|
|||
import asyncio
|
||||
import configparser
|
||||
import logging
|
||||
logging.basicConfig(
|
||||
level=logging.DEBUG,
|
||||
format="[{levelname: <7}] in {threadName: <17} <{name}>: {message}",
|
||||
style="{"
|
||||
from typing import Callable, Dict
|
||||
|
||||
from .bot import *
|
||||
from .client import *
|
||||
from .command import *
|
||||
from .connection import *
|
||||
from .database import *
|
||||
from .events import *
|
||||
from .exceptions import *
|
||||
from .message import *
|
||||
from .module import *
|
||||
from .room import *
|
||||
from .session import *
|
||||
from .util import *
|
||||
|
||||
__all__ = ["STYLE", "FORMAT", "DATE_FORMAT", "FORMATTER", "enable_logging",
|
||||
"run", "run_modulebot"]
|
||||
|
||||
__all__ += bot.__all__
|
||||
__all__ += client.__all__
|
||||
__all__ += command.__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
|
||||
)
|
||||
|
||||
from .basic_types import Message, SessionView
|
||||
from .callbacks import Callbacks
|
||||
from .connection import Connection
|
||||
from .session import Session
|
||||
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())
|
||||
|
|
|
|||
|
|
@ -1,163 +0,0 @@
|
|||
import time
|
||||
|
||||
class SessionView():
|
||||
"""
|
||||
This class keeps track of session details.
|
||||
http://api.euphoria.io/#sessionview
|
||||
"""
|
||||
|
||||
def __init__(self, id, name, server_id, server_era, session_id, is_staff=None, is_manager=None):
|
||||
"""
|
||||
id - agent/account id
|
||||
name - name of the client when the SessionView was captured
|
||||
server_id - id of the server
|
||||
server_era - era of the server
|
||||
session_id - session id (unique across euphoria)
|
||||
is_staff - client is staff
|
||||
is_manager - client is manager
|
||||
"""
|
||||
|
||||
self.id = id
|
||||
self.name = name
|
||||
self.server_id = server_id
|
||||
self.server_era = server_era
|
||||
self.session_id = session_id
|
||||
self.staff = is_staff
|
||||
self.manager = is_manager
|
||||
|
||||
@classmethod
|
||||
def from_data(cls, data):
|
||||
"""
|
||||
Creates and returns a session created from the data.
|
||||
|
||||
data - a euphoria SessionView
|
||||
"""
|
||||
|
||||
view = cls(None, None, None, None, None)
|
||||
view.read_data(data)
|
||||
return view
|
||||
|
||||
def read_data(self, data):
|
||||
if "id" in data: self.id = data.get("id")
|
||||
if "name" in data: self.name = data.get("name")
|
||||
if "server_id" in data: self.server_id = data.get("server_id")
|
||||
if "server_era" in data: self.server_era = data.get("server_era")
|
||||
if "session_id" in data: self.session_id = data.get("session_id")
|
||||
if "is_staff" in data: self.is_staff = data.get("is_staff")
|
||||
if "is_manager" in data: self.is_manager = data.get("is_manager")
|
||||
|
||||
def session_type(self):
|
||||
"""
|
||||
session_type() -> str
|
||||
|
||||
The session's type (bot, account, agent).
|
||||
"""
|
||||
|
||||
return self.id.split(":")[0] if ":" in self.id else None
|
||||
|
||||
class Message():
|
||||
"""
|
||||
This class represents a single euphoria message.
|
||||
http://api.euphoria.io/#message
|
||||
"""
|
||||
|
||||
def __init__(self, id, time, sender, content, parent=None, edited=None, previous_edit_id=None,
|
||||
deleted=None, truncated=None, encryption_key_id=None):
|
||||
"""
|
||||
id - message id
|
||||
time - time the message was sent (epoch)
|
||||
sender - SessionView of the sender
|
||||
content - content of the message
|
||||
parent - id of the parent message, or None
|
||||
edited - time of last edit (epoch)
|
||||
previous_edit_id - edit id of the most recent edit of this message
|
||||
deleted - time of deletion (epoch)
|
||||
truncated - message was truncated
|
||||
encryption_key_id - id of the key that encrypts the message in storage
|
||||
"""
|
||||
|
||||
self.id = id
|
||||
self.time = time
|
||||
self.sender = sender
|
||||
self.content = content
|
||||
self.parent = parent
|
||||
self.edited = edited
|
||||
self.previous_edit_id = previous_edit_id
|
||||
self.deleted = deleted
|
||||
self.truncated = truncated
|
||||
self.encryption_key_id = encryption_key_id
|
||||
|
||||
@classmethod
|
||||
def from_data(self, data):
|
||||
"""
|
||||
Creates and returns a message created from the data.
|
||||
NOTE: This also creates a session object using the data in "sender".
|
||||
|
||||
data - a euphoria message: http://api.euphoria.io/#message
|
||||
"""
|
||||
|
||||
sender = SessionView.from_data(data.get("sender"))
|
||||
|
||||
return self(
|
||||
data.get("id"),
|
||||
data.get("time"),
|
||||
sender,
|
||||
data.get("content"),
|
||||
parent=data.get("parent"),
|
||||
edited=data.get("edited"),
|
||||
deleted=data.get("deleted"),
|
||||
truncated=data.get("truncated"),
|
||||
previous_edit_id=data.get("previous_edit_id"),
|
||||
encryption_key_id=data.get("encryption_key_id")
|
||||
)
|
||||
|
||||
def time_formatted(self, date=True):
|
||||
"""
|
||||
time_formatted(date=True) -> str
|
||||
|
||||
date - include date in format
|
||||
|
||||
Time in a readable format:
|
||||
With date: YYYY-MM-DD HH:MM:SS
|
||||
Without date: HH:MM:SS
|
||||
"""
|
||||
|
||||
if date:
|
||||
return time.strftime("%Y-%m-%d %H:%M:%S", time.gmtime(self.time))
|
||||
else:
|
||||
return time.strftime("%H:%M:%S", time.gmtime(self.time))
|
||||
|
||||
def formatted(self, show_time=False, date=True, insert_string=None, repeat_insert_string=True):
|
||||
"""
|
||||
formatted() -> strftime
|
||||
|
||||
The message contents in the following format (does not end on a newline):
|
||||
<time><insert_string>[<sender name>] message content
|
||||
<insert_string> more message on a new line
|
||||
|
||||
If repeat_insert_string is False, the insert_string will only appear
|
||||
on the first line.
|
||||
|
||||
If show_time is False, the time will not appear in the first line of
|
||||
the formatted message.
|
||||
The date option works like it does in Message.time_formatted().
|
||||
"""
|
||||
|
||||
msgtime = self.time_formatted(date) if show_time else ""
|
||||
if insert_string is None:
|
||||
insert_string = " " if show_time else ""
|
||||
lines = self.content.split("\n")
|
||||
|
||||
# first line
|
||||
msg = "{}{}[{}] {}\n".format(msgtime, insert_string, self.sender.name, lines[0])
|
||||
|
||||
# all other lines
|
||||
for line in lines[1:]:
|
||||
msg += "{}{} {} {}\n".format(
|
||||
" "*len(msgtime),
|
||||
insert_string if repeat_insert_string else " "*len(insert_string),
|
||||
" "*len(self.sender.name),
|
||||
line
|
||||
)
|
||||
|
||||
return msg[:-1] # remove trailing newline
|
||||
973
yaboli/bot.py
973
yaboli/bot.py
|
|
@ -1,600 +1,377 @@
|
|||
import time
|
||||
import configparser
|
||||
import datetime
|
||||
import logging
|
||||
from typing import Callable, List, Optional
|
||||
|
||||
from . import callbacks
|
||||
from . import exceptions
|
||||
from . import room
|
||||
from .client import Client
|
||||
from .command import *
|
||||
from .message import LiveMessage, Message
|
||||
from .room import Room
|
||||
from .util import *
|
||||
|
||||
class Bot():
|
||||
"""
|
||||
Empty bot class that can be built upon.
|
||||
Takes care of extended botrulez.
|
||||
"""
|
||||
|
||||
def __init__(self, roomname, nick="yaboli", password=None, manager=None,
|
||||
created_in=None, created_by=None):
|
||||
"""
|
||||
roomname - name of the room to connect to
|
||||
nick - nick to assume, None -> no nick
|
||||
password - room password (in case the room is private)
|
||||
created_in - room the bot was created in
|
||||
created_by - nick of the person the bot was created by
|
||||
"""
|
||||
|
||||
self.start_time = time.time()
|
||||
|
||||
self.created_by = created_by
|
||||
self.created_in = created_in
|
||||
|
||||
self.manager = manager
|
||||
|
||||
# modify/customize this in your __init__() function (or anywhere else you want, for that matter)
|
||||
self.bot_description = ("This bot complies with the botrulez™ (https://github.com/jedevc/botrulez),\n"
|
||||
"plus a few extra commands.")
|
||||
|
||||
self.helptexts = {}
|
||||
self.detailed_helptexts = {}
|
||||
|
||||
self.room = room.Room(roomname, nick=nick, password=password)
|
||||
self.room.add_callback("message", self.on_message)
|
||||
|
||||
self.commands = callbacks.Callbacks()
|
||||
self.bot_specific_commands = []
|
||||
|
||||
self.add_command("clone", self.clone_command, "Clone this bot to another room.", # possibly add option to set nick?
|
||||
("!clone @bot [ <room> [ --pw=<password> ] ]\n"
|
||||
"<room> : the name of the room to clone the bot to\n"
|
||||
"--pw : the room's password\n\n"
|
||||
"Clone this bot to the room specified.\n"
|
||||
"If the target room is passworded, you can use the --pw option to set\n"
|
||||
"a password for the bot to use.\n"
|
||||
"If no room is specified, this will use the current room and password."),
|
||||
bot_specific=False)
|
||||
|
||||
self.add_command("help", self.help_command, "Show help information about the bot.",
|
||||
("!help @bot [ -s | -c | <command> ]\n"
|
||||
"-s : general syntax help\n"
|
||||
"-c : only list the commands\n"
|
||||
"<command> : any command from !help @bot -c\n\n"
|
||||
"Shows detailed help for a command if you specify a command name.\n"
|
||||
"Shows a list of commands and short description if no arguments are given."))
|
||||
|
||||
self.add_command("kill", self.kill_command, "Kill (stop) the bot.",
|
||||
("!kill @bot [ -r ]\n"
|
||||
"-r : restart the bot (will change the id)\n\n"
|
||||
"The bot disconnects from the room and stops."))
|
||||
|
||||
self.add_command("ping", self.ping_command, "Replies 'Pong!'.",
|
||||
("!ping @bot\n\n"
|
||||
"This command was originally used to help distinguish bots from\n"
|
||||
"people. Since the Great UI Change, this is no longer necessary as\n"
|
||||
"bots and people are displayed separately."))
|
||||
|
||||
self.add_command("restart", self.restart_command, "Restart the bot (shorthand for !kill @bot -r).",
|
||||
("!restart @bot\n\n"
|
||||
"Restart the bot.\n"
|
||||
"Short for !kill @bot -r"))
|
||||
|
||||
self.add_command("send", self.send_command, "Send the bot to another room.",
|
||||
("!send @bot <room> [ --pw=<password> ]\n"
|
||||
"--pw : the room's password\n\n"
|
||||
"Sends this bot to the room specified. If the target room is passworded,\n"
|
||||
"you can use the --pw option to set a password for the bot to use."))
|
||||
|
||||
self.add_command("uptime", self.uptime_command, "Show bot uptime since last (re-)start.",
|
||||
("!uptime @bot [ -i s]\n"
|
||||
"-i : show more detailed information\n\n"
|
||||
"Shows the bot's uptime since the last start or restart.\n"
|
||||
"Shows additional information (i.e. id) if the -i flag is set."))
|
||||
|
||||
|
||||
self.add_command("show", self.show_command, detailed_helptext="You've found a hidden command! :)")
|
||||
|
||||
self.room.launch()
|
||||
|
||||
def stop(self):
|
||||
"""
|
||||
stop() -> None
|
||||
|
||||
Kill this bot.
|
||||
"""
|
||||
|
||||
self.room.stop()
|
||||
|
||||
def add_command(self, command, function, helptext=None, detailed_helptext=None,
|
||||
bot_specific=True):
|
||||
"""
|
||||
add_command(command, function, helptext, detailed_helptext, bot_specific) -> None
|
||||
|
||||
Subscribe a function to a command and add a help text.
|
||||
If no help text is provided, the command won't be displayed by the !help command.
|
||||
This overwrites any previously added command.
|
||||
|
||||
You can "hide" commands by specifying only the detailed helptext,
|
||||
or no helptext at all.
|
||||
|
||||
If the command is not bot specific, no id has to be specified if there are multiple bots
|
||||
with the same nick in a room.
|
||||
"""
|
||||
|
||||
command = command.lower()
|
||||
|
||||
self.commands.remove(command)
|
||||
self.commands.add(command, function)
|
||||
|
||||
if helptext and not command in self.helptexts:
|
||||
self.helptexts[command] = helptext
|
||||
elif not helptext and command in self.helptexts:
|
||||
self.helptexts.pop(command)
|
||||
|
||||
if detailed_helptext and not command in self.detailed_helptexts:
|
||||
self.detailed_helptexts[command] = detailed_helptext
|
||||
elif not detailed_helptext and command in self.detailed_helptexts:
|
||||
self.detailed_helptexts.pop(command)
|
||||
|
||||
if bot_specific and not command in self.bot_specific_commands:
|
||||
self.bot_specific_commands.append(command)
|
||||
elif not bot_specific and command in self.bot_specific_commands:
|
||||
self.bot_specific_commands.remove(command)
|
||||
|
||||
def call_command(self, message):
|
||||
"""
|
||||
call_command(message) -> None
|
||||
|
||||
Calls all functions subscribed to the command with the arguments supplied in the message.
|
||||
Deals with the situation that multiple bots of the same type and nick are in the same room.
|
||||
"""
|
||||
|
||||
try:
|
||||
command, bot_id, nick, arguments, flags, options = self.parse(message.content)
|
||||
except exceptions.ParseMessageException:
|
||||
return
|
||||
else:
|
||||
command = command.lower()
|
||||
nick = self.room.mentionable(nick).lower()
|
||||
|
||||
if not self.commands.exists(command):
|
||||
return
|
||||
|
||||
if not nick == self.mentionable().lower():
|
||||
return
|
||||
|
||||
if bot_id is not None: # id specified
|
||||
if self.manager.get(bot_id) == self:
|
||||
self.commands.call(command, message, arguments, flags, options)
|
||||
else:
|
||||
return
|
||||
|
||||
else: # no id specified
|
||||
bots = self.manager.get_similar(self.roomname(), nick)
|
||||
if self.manager.get_id(self) == min(bots): # only one bot should act
|
||||
# either the bot is unique or the command is not bot-specific
|
||||
if not command in self.bot_specific_commands or len(bots) == 1:
|
||||
self.commands.call(command, message, arguments, flags, options)
|
||||
|
||||
else: # user has to select a bot
|
||||
msg = ("There are multiple bots with that nick in this room. To select one,\n"
|
||||
"please specify its id (from the list below) as follows:\n"
|
||||
"!{} <id> @{} [your arguments...]\n").format(command, nick)
|
||||
|
||||
for bot_id in sorted(bots):
|
||||
bot = bots[bot_id]
|
||||
msg += "\n{} - @{} ({})".format(bot_id, bot.mentionable(), bot.creation_info())
|
||||
|
||||
self.room.send_message(msg, parent=message.id)
|
||||
|
||||
def roomname(self):
|
||||
"""
|
||||
roomname() -> roomname
|
||||
|
||||
The room the bot is connected to.
|
||||
"""
|
||||
|
||||
return self.room.room
|
||||
|
||||
def password(self):
|
||||
"""
|
||||
password() -> password
|
||||
|
||||
The current room's password.
|
||||
"""
|
||||
|
||||
return self.room.password
|
||||
|
||||
def nick(self):
|
||||
"""
|
||||
nick() -> nick
|
||||
|
||||
The bot's full nick.
|
||||
"""
|
||||
|
||||
return self.room.nick
|
||||
|
||||
def mentionable(self):
|
||||
"""
|
||||
mentionable() -> nick
|
||||
|
||||
The bot's nick in a mentionable format.
|
||||
"""
|
||||
|
||||
return self.room.mentionable()
|
||||
|
||||
def creation_info(self):
|
||||
"""
|
||||
creation_info() -> str
|
||||
|
||||
Formatted info about the bot's creation
|
||||
"""
|
||||
|
||||
info = "created {}".format(self.format_date())
|
||||
|
||||
if self.created_by:
|
||||
info += " by @{}".format(self.room.mentionable(self.created_by))
|
||||
|
||||
if self.created_in:
|
||||
info += " in &{}".format(self.created_in)
|
||||
|
||||
return info
|
||||
|
||||
def format_date(self, seconds=None):
|
||||
"""
|
||||
format_date(seconds) -> str
|
||||
|
||||
Format a time in epoch format to the format specified in self.date_format.
|
||||
Defaults to self.start_time.
|
||||
"""
|
||||
|
||||
if seconds is None:
|
||||
seconds = self.start_time
|
||||
|
||||
return time.strftime("%Y-%m-%d %H:%M:%S", time.gmtime(seconds))
|
||||
|
||||
def format_delta(self, delta=None):
|
||||
"""
|
||||
format_delta(delta) -> str
|
||||
|
||||
Format a difference in seconds to the following format:
|
||||
[- ][<days>d ][<hours>h ][<minutes>m ]<seconds>s
|
||||
Defaults to the current uptime if no delta is specified.
|
||||
"""
|
||||
|
||||
if not delta:
|
||||
delta = time.time() - self.start_time
|
||||
|
||||
delta = int(delta)
|
||||
uptime = ""
|
||||
|
||||
if delta < 0:
|
||||
uptime += "- "
|
||||
delta = -delta
|
||||
|
||||
if delta >= 24*60*60:
|
||||
uptime +="{}d ".format(delta//(24*60*60))
|
||||
delta %= 24*60*60
|
||||
|
||||
if delta >= 60*60:
|
||||
uptime += "{}h ".format(delta//(60*60))
|
||||
delta %= 60*60
|
||||
|
||||
if delta >= 60:
|
||||
uptime += "{}m ".format(delta//60)
|
||||
delta %= 60
|
||||
|
||||
uptime += "{}s".format(delta)
|
||||
|
||||
return uptime
|
||||
|
||||
def parse_command(self, message):
|
||||
"""
|
||||
parse_command(message_content) -> command, bot_id, nick, argpart
|
||||
|
||||
Parse the "!command[ bot_id] @botname[ argpart]" part of a command.
|
||||
"""
|
||||
|
||||
# command name (!command)
|
||||
split = message.split(maxsplit=1)
|
||||
|
||||
if len(split) < 2:
|
||||
raise exceptions.ParseMessageException("Not enough arguments")
|
||||
elif split[0][:1] != "!":
|
||||
raise exceptions.ParseMessageException("Not a command")
|
||||
|
||||
command = split[0][1:]
|
||||
message = split[1]
|
||||
split = message.split(maxsplit=1)
|
||||
|
||||
# bot id
|
||||
try:
|
||||
bot_id = int(split[0])
|
||||
except ValueError:
|
||||
bot_id = None
|
||||
else:
|
||||
if len(split) <= 1:
|
||||
raise exceptions.ParseMessageException("No bot nick")
|
||||
|
||||
message = split[1]
|
||||
split = message.split(maxsplit=1)
|
||||
|
||||
# bot nick (@mention)
|
||||
if split[0][:1] != "@":
|
||||
raise exceptions.ParseMessageException("No bot nick")
|
||||
|
||||
nick = split[0][1:]
|
||||
|
||||
# arguments to the command
|
||||
if len(split) > 1:
|
||||
argpart = split[1]
|
||||
else:
|
||||
argpart = None
|
||||
|
||||
return command, bot_id, nick, argpart
|
||||
|
||||
def parse_arguments(self, argstr):
|
||||
"""
|
||||
parse_arguments(argstr) -> arguments, flags, options
|
||||
|
||||
Parse the argument part of a command.
|
||||
"""
|
||||
|
||||
argstr += " " # so the last argument will also be captured
|
||||
|
||||
escaping = False
|
||||
quot_marks = None
|
||||
type_signs = 0
|
||||
option = None
|
||||
word = ""
|
||||
|
||||
arguments = []
|
||||
flags = ""
|
||||
options = {}
|
||||
|
||||
for char in argstr:
|
||||
|
||||
# backslash-escaping
|
||||
if escaping:
|
||||
word += char
|
||||
escaping = False
|
||||
elif char == "\\":
|
||||
escaping = True
|
||||
|
||||
# quotation mark escaped strings
|
||||
elif quot_marks:
|
||||
if char == quot_marks:
|
||||
quot_marks = None
|
||||
else:
|
||||
word += char
|
||||
elif char in ['"', "'"]:
|
||||
quot_marks = char
|
||||
|
||||
# type signs
|
||||
elif char == "-":
|
||||
if type_signs < 2 and not word:
|
||||
type_signs += 1
|
||||
else:
|
||||
word += char
|
||||
|
||||
# "=" in options
|
||||
elif char == "=" and type_signs == 2 and word and not option:
|
||||
option = word
|
||||
word = ""
|
||||
|
||||
# space - evaluate information collected so far
|
||||
elif char == " ":
|
||||
if word:
|
||||
if type_signs == 0: # argument
|
||||
arguments.append(word)
|
||||
elif type_signs == 1: # flag(s)
|
||||
for flag in word:
|
||||
if not flag in flags:
|
||||
flags += flag
|
||||
elif type_signs == 2: # option
|
||||
if option:
|
||||
options[option] = word
|
||||
else:
|
||||
options[word] = True
|
||||
|
||||
# reset all state variables
|
||||
escaping = False
|
||||
quot_marks = None
|
||||
type_signs = 0
|
||||
option = None
|
||||
word = ""
|
||||
|
||||
# all other chars and situations
|
||||
else:
|
||||
word += char
|
||||
|
||||
return arguments, flags, options
|
||||
|
||||
def parse(self, message):
|
||||
"""
|
||||
parse(message_content) -> bool
|
||||
|
||||
Parse a message.
|
||||
"""
|
||||
|
||||
command, bot_id, nick, argpart = self.parse_command(message)
|
||||
|
||||
if argpart:
|
||||
arguments, flags, options = self.parse_arguments(argpart)
|
||||
else:
|
||||
arguments = []
|
||||
flags = ""
|
||||
options = {}
|
||||
|
||||
return command, bot_id, nick, arguments, flags, options
|
||||
|
||||
# ----- HANDLING OF EVENTS -----
|
||||
|
||||
def on_message(self, message):
|
||||
"""
|
||||
on_message(message) -> None
|
||||
|
||||
Gets called when a message is received (see __init__).
|
||||
If you want to add a command to your bot, consider using add_command instead of overwriting
|
||||
this function.
|
||||
"""
|
||||
|
||||
self.call_command(message)
|
||||
|
||||
# ----- COMMANDS -----
|
||||
|
||||
def clone_command(self, message, arguments, flags, options):
|
||||
"""
|
||||
clone_command(message, *arguments, flags, options) -> None
|
||||
|
||||
Create a new bot.
|
||||
"""
|
||||
|
||||
if not arguments:
|
||||
room = self.roomname()
|
||||
password = self.room.password
|
||||
else:
|
||||
room = arguments[0]
|
||||
|
||||
if room[:1] == "&":
|
||||
room = room[1:]
|
||||
|
||||
if "pw" in options and options["pw"] is not True:
|
||||
password = options["pw"]
|
||||
else:
|
||||
password = None
|
||||
|
||||
try:
|
||||
bot = self.manager.create(room, password=password, created_in=self.roomname(),
|
||||
created_by=message.sender.name)
|
||||
except exceptions.CreateBotException:
|
||||
self.room.send_message("Bot could not be cloned.", parent=message.id)
|
||||
else:
|
||||
self.room.send_message("Cloned @{} to &{}.".format(bot.mentionable(), room),
|
||||
parent=message.id)
|
||||
|
||||
def help_command(self, message, arguments, flags, options):
|
||||
"""
|
||||
help_command(message, *arguments, flags, options) -> None
|
||||
|
||||
Show help about the bot.
|
||||
"""
|
||||
|
||||
if arguments: # detailed help for one command
|
||||
command = arguments[0]
|
||||
if command[:1] == "!":
|
||||
command = command[1:]
|
||||
|
||||
if command in self.detailed_helptexts:
|
||||
msg = "Detailed help for !{}:\n".format(command)
|
||||
msg += self.detailed_helptexts[command]
|
||||
else:
|
||||
msg = "No detailed help text found for !{}.".format(command)
|
||||
if command in self.helptexts:
|
||||
msg += "\n\n" + self.helptexts[command]
|
||||
|
||||
elif "s" in flags: # detailed syntax help
|
||||
msg = ("SYNTAX HELP PLACEHOLDER")
|
||||
|
||||
else: # just list all commands
|
||||
msg = ""
|
||||
|
||||
if not "c" in flags:
|
||||
msg += self.bot_description + "\n\n"
|
||||
|
||||
msg += "This bot supports the following commands:"
|
||||
for command in sorted(self.helptexts):
|
||||
helptext = self.helptexts[command]
|
||||
msg += "\n!{} - {}".format(command, helptext)
|
||||
|
||||
if not "c" in flags:
|
||||
msg += ("\n\nFor help on the command syntax, try: !help @{0} -s\n"
|
||||
"For detailed help on a command, try: !help @{0} <command>\n"
|
||||
"(Hint: Most commands have extra functionality, which is listed in their detailed help.)")
|
||||
msg = msg.format(self.mentionable())
|
||||
|
||||
self.room.send_message(msg, parent=message.id)
|
||||
|
||||
def kill_command(self, message, arguments, flags, options):
|
||||
"""
|
||||
kill_command(message, *arguments, flags, options) -> None
|
||||
|
||||
stop the bot.
|
||||
"""
|
||||
|
||||
if "r" in flags:
|
||||
bot = self.manager.create(self.roomname())
|
||||
bot.created_by = self.created_by
|
||||
bot.created_in = self.created_in
|
||||
|
||||
self.room.send_message("/me exits.", message.id)
|
||||
|
||||
self.manager.remove(self.manager.get_id(self))
|
||||
|
||||
def ping_command(self, message, arguments, flags, options):
|
||||
"""
|
||||
ping_command(message, *arguments, flags, options) -> None
|
||||
|
||||
Send a "Pong!" reply on a !ping command.
|
||||
"""
|
||||
|
||||
self.room.send_message("Pong!", parent=message.id)
|
||||
|
||||
def restart_command(self, message, arguments, flags, options):
|
||||
"""
|
||||
restart_command(message, *arguments, flags, options) -> None
|
||||
|
||||
Restart the bot (shorthand for !kill @bot -r).
|
||||
"""
|
||||
|
||||
self.commands.call("kill", message, [], "r", {})
|
||||
|
||||
def send_command(self, message, arguments, flags, options):
|
||||
"""
|
||||
_command(message, *arguments, flags, options) -> None
|
||||
|
||||
Send this bot to another room.
|
||||
"""
|
||||
|
||||
if not arguments:
|
||||
return
|
||||
else:
|
||||
room = arguments[0]
|
||||
|
||||
if room[:1] == "&":
|
||||
room = room[1:]
|
||||
|
||||
if "pw" in options and options["pw"] is not True:
|
||||
password = options["pw"]
|
||||
else:
|
||||
password = None
|
||||
|
||||
self.room.send_message("/me moves to &{}.".format(room), parent=message.id)
|
||||
|
||||
self.room.change(room, password=password)
|
||||
self.room.launch()
|
||||
|
||||
def show_command(self, message, arguments, flags, options):
|
||||
"""
|
||||
show_command(message, arguments, flags, options) -> None
|
||||
|
||||
Show arguments, flags and options.
|
||||
"""
|
||||
|
||||
msg = "arguments: {}\nflags: {}\noptions: {}".format(arguments, repr(flags), options)
|
||||
self.room.send_message(msg, parent=message.id)
|
||||
|
||||
def uptime_command(self, message, arguments, flags, options):
|
||||
"""
|
||||
uptime_command(message, arguments, flags, options) -> None
|
||||
|
||||
Show uptime and other info.
|
||||
"""
|
||||
|
||||
stime = self.format_date()
|
||||
utime = self.format_delta()
|
||||
|
||||
if "i" in flags:
|
||||
msg = "uptime: {} ({})".format(stime, utime)
|
||||
msg += "\nid: {}".format(self.manager.get_id(self))
|
||||
msg += "\n{}".format(self.creation_info())
|
||||
|
||||
else:
|
||||
msg = "/me is up since {} ({}).".format(stime, utime)
|
||||
|
||||
self.room.send_message(msg, message.id)
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
__all__ = ["Bot", "BotConstructor"]
|
||||
|
||||
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.
|
||||
|
||||
The config file is loaded as a ConfigParser by the run() or run_modulebot()
|
||||
functions and has the following structure:
|
||||
|
||||
A "general" section which contains:
|
||||
- nick - the default nick of the bot (set to the empty string if you don't
|
||||
want to set a nick)
|
||||
- cookie_file (optional) - the file the cookie should be saved in
|
||||
|
||||
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".
|
||||
|
||||
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")
|
||||
"""
|
||||
|
||||
ALIASES: List[str] = []
|
||||
|
||||
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"
|
||||
|
||||
GENERAL_SECTION = "general"
|
||||
ROOMS_SECTION = "rooms"
|
||||
|
||||
def __init__(self,
|
||||
config: configparser.ConfigParser,
|
||||
config_file: str,
|
||||
) -> None:
|
||||
self.config = config
|
||||
self.config_file = config_file
|
||||
|
||||
nick = self.config[self.GENERAL_SECTION].get("nick")
|
||||
if nick is None:
|
||||
logger.warn(("'nick' not set in config file. Defaulting to empty"
|
||||
" nick"))
|
||||
nick = ""
|
||||
|
||||
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."))
|
||||
|
||||
super().__init__(nick, cookie_file=cookie_file)
|
||||
|
||||
self._commands: List[Command] = []
|
||||
|
||||
self.start_time = datetime.datetime.now()
|
||||
|
||||
def save_config(self) -> None:
|
||||
"""
|
||||
Save the current state of self.config to the file passed in __init__ as
|
||||
the config_file parameter.
|
||||
|
||||
Usually, this is the file that self.config was loaded from (if you use
|
||||
run or run_modulebot).
|
||||
"""
|
||||
|
||||
with open(self.config_file, "w") as f:
|
||||
self.config.write(f)
|
||||
|
||||
async def started(self) -> None:
|
||||
"""
|
||||
This Client function is overwritten in order to join all the rooms
|
||||
listed in the "rooms" section of self.config.
|
||||
|
||||
If you need to overwrite this function but want to keep the auto-join
|
||||
functionality, make sure to await super().started().
|
||||
"""
|
||||
|
||||
for room, password in self.config[self.ROOMS_SECTION].items():
|
||||
if password is None:
|
||||
await self.join(room)
|
||||
else:
|
||||
await self.join(room, password=password)
|
||||
|
||||
# Registering commands
|
||||
|
||||
def register(self, command: Command) -> None:
|
||||
"""
|
||||
Register a Command (from the yaboli.command submodule).
|
||||
|
||||
Usually, you don't have to call this function yourself.
|
||||
"""
|
||||
|
||||
self._commands.append(command)
|
||||
|
||||
def register_general(self,
|
||||
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.
|
||||
|
||||
name - the name of the command (If you want your command to be !hello,
|
||||
the name is "hello".)
|
||||
|
||||
cmdfunc - the function that is called with the Room, LiveMessage and
|
||||
ArgumentData when the bot encounters a matching command
|
||||
|
||||
args - whether the command may have arguments (If set to False, the
|
||||
ArgumentData's has_args() function must also return False for the
|
||||
command function to be called. If set to True, all ArgumentData is
|
||||
valid.)
|
||||
"""
|
||||
|
||||
command = GeneralCommand(name, cmdfunc, args)
|
||||
self.register(command)
|
||||
|
||||
def register_specific(self,
|
||||
name: str,
|
||||
cmdfunc: SpecificCommandFunction,
|
||||
args: bool = True
|
||||
) -> None:
|
||||
"""
|
||||
Register a function as specific bot command (i. e. @mention of the bot
|
||||
nick after the !command is required). This function will be called by
|
||||
process_commands() when the bot encounters a matching command.
|
||||
|
||||
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]
|
||||
|
|
|
|||
|
|
@ -1,149 +0,0 @@
|
|||
import json
|
||||
|
||||
from . import bot
|
||||
from . import exceptions
|
||||
|
||||
class BotManager():
|
||||
"""
|
||||
Keep track of multiple bots in different rooms.
|
||||
"""
|
||||
|
||||
def __init__(self, bot_class, default_nick="yaboli", max_bots=100,
|
||||
bots_file="bots.json", data_file="data.json"):
|
||||
"""
|
||||
bot_class - class to create instances of
|
||||
default_nick - default nick for all bots to assume when no nick is specified
|
||||
max_bots - maximum number of bots allowed to exist simultaneously
|
||||
None or 0 - no limit
|
||||
bots_file - file the bot backups are saved to
|
||||
None - no bot backups
|
||||
data_file - file the bot data is saved to
|
||||
- None - bot data isn't saved
|
||||
"""
|
||||
|
||||
self.bot_class = bot_class
|
||||
self.max_bots = max_bots
|
||||
self.default_nick = default_nick
|
||||
|
||||
self.bots_file = bots_file
|
||||
self.data_file = data_file
|
||||
|
||||
self._bots = {}
|
||||
self._bot_id = 0
|
||||
self._bot_data = {}
|
||||
|
||||
self._load_bots()
|
||||
|
||||
def create(self, room, password=None, nick=None, created_in=None, created_by=None):
|
||||
"""
|
||||
create(room, password, nick) -> bot
|
||||
|
||||
Create a new bot in room.
|
||||
"""
|
||||
|
||||
if nick is None:
|
||||
nick = self.default_nick
|
||||
|
||||
if self.max_bots and len(self._bots) >= self.max_bots:
|
||||
raise exceptions.CreateBotException("max_bots limit hit")
|
||||
else:
|
||||
bot = self.bot_class(room, nick=nick, password=password, manager=self,
|
||||
created_in=created_in, created_by=created_by)
|
||||
self._bots[self._bot_id] = bot
|
||||
self._bot_id += 1
|
||||
|
||||
self._save_bots()
|
||||
|
||||
return bot
|
||||
|
||||
def remove(self, bot_id):
|
||||
"""
|
||||
remove(bot_id) -> None
|
||||
|
||||
Kill a bot and remove it from the list of bots.
|
||||
"""
|
||||
|
||||
if bot_id in self._bots:
|
||||
self._bots[bot_id].stop()
|
||||
self._bots.pop(bot_id)
|
||||
|
||||
self._save_bots()
|
||||
|
||||
def get(self, bot_id):
|
||||
"""
|
||||
get(self, bot_id) -> bot
|
||||
|
||||
Return bot with that id, if found.
|
||||
"""
|
||||
|
||||
if bot_id in self._bots:
|
||||
return self._bots[bot_id]
|
||||
|
||||
def get_id(self, bot):
|
||||
"""
|
||||
get_id(bot) -> bot_id
|
||||
|
||||
Return the bot id, if the bot is known.
|
||||
"""
|
||||
|
||||
for bot_id, own_bot in self._bots.items():
|
||||
if bot == own_bot:
|
||||
return bot_id
|
||||
|
||||
def get_similar(self, room, nick):
|
||||
"""
|
||||
get_by_room(room, nick) -> dict
|
||||
|
||||
Collect all bots that are connected to the room and have that nick.
|
||||
"""
|
||||
|
||||
return {bot_id: bot for bot_id, bot in self._bots.items()
|
||||
if bot.roomname() == room and bot.mentionable().lower() == nick.lower()}
|
||||
|
||||
def _load_bots(self):
|
||||
"""
|
||||
_load_bots() -> None
|
||||
|
||||
Load and create bots from self.bots_file.
|
||||
"""
|
||||
|
||||
if not self.bots_file:
|
||||
return
|
||||
|
||||
try:
|
||||
with open(self.bots_file) as f:
|
||||
bots = json.load(f)
|
||||
except FileNotFoundError:
|
||||
pass
|
||||
else:
|
||||
for bot_info in bots:
|
||||
bot = self.create(bot_info["room"], password=bot_info["password"],
|
||||
nick=bot_info["nick"])
|
||||
bot.created_in = bot_info["created_in"]
|
||||
bot.created_by = bot_info["created_by"]
|
||||
|
||||
def _save_bots(self):
|
||||
"""
|
||||
_save_bots() -> None
|
||||
|
||||
Save all current bots to self.bots_file.
|
||||
"""
|
||||
|
||||
if not self.bots_file:
|
||||
return
|
||||
|
||||
bots = []
|
||||
|
||||
for bot_id, bot in self._bots.items():
|
||||
bot_info = {}
|
||||
|
||||
bot_info["room"] = bot.roomname()
|
||||
bot_info["password"] = bot.password()
|
||||
bot_info["nick"] = bot.nick()
|
||||
bot_info["created_in"] = bot.created_in
|
||||
bot_info["created_by"] = bot.created_by
|
||||
|
||||
bots.append(bot_info)
|
||||
|
||||
with open(self.bots_file, "w") as f:
|
||||
json.dump(bots, f)
|
||||
|
|
@ -1,62 +0,0 @@
|
|||
class Callbacks():
|
||||
"""
|
||||
Manage callbacks
|
||||
"""
|
||||
|
||||
def __init__(self):
|
||||
self._callbacks = {}
|
||||
|
||||
def add(self, event, callback, *args, **kwargs):
|
||||
"""
|
||||
add(event, callback, *args, **kwargs) -> None
|
||||
|
||||
Add a function to be called on event.
|
||||
The function will be called with *args and **kwargs.
|
||||
Certain arguments might be added, depending on the event.
|
||||
"""
|
||||
|
||||
if not event in self._callbacks:
|
||||
self._callbacks[event] = []
|
||||
|
||||
callback_info = {
|
||||
"callback": callback,
|
||||
"args": args,
|
||||
"kwargs": kwargs
|
||||
}
|
||||
|
||||
self._callbacks[event].append(callback_info)
|
||||
|
||||
def remove(self, event):
|
||||
"""
|
||||
remove(event) -> None
|
||||
|
||||
Remove all callbacks attached to that event.
|
||||
"""
|
||||
|
||||
if event in self._callbacks:
|
||||
del self._callbacks[event]
|
||||
|
||||
def call(self, event, *args):
|
||||
"""
|
||||
call(event) -> None
|
||||
|
||||
Call all callbacks subscribed to the event with *args and the arguments specified when the
|
||||
callback was added.
|
||||
"""
|
||||
|
||||
if event in self._callbacks:
|
||||
for c_info in self._callbacks[event]:
|
||||
c = c_info["callback"]
|
||||
args = c_info["args"] + args
|
||||
kwargs = c_info["kwargs"]
|
||||
|
||||
c(*args, **kwargs)
|
||||
|
||||
def exists(self, event):
|
||||
"""
|
||||
exists(event) -> bool
|
||||
|
||||
Are any functions subscribed to this event?
|
||||
"""
|
||||
|
||||
return event in self._callbacks
|
||||
171
yaboli/client.py
Normal file
171
yaboli/client.py
Normal 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
384
yaboli/command.py
Normal 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)
|
||||
|
|
@ -1,279 +1,571 @@
|
|||
import asyncio
|
||||
import json
|
||||
import logging
|
||||
import socket
|
||||
import ssl
|
||||
import time
|
||||
import threading
|
||||
import websocket
|
||||
from websocket import WebSocketException as WSException
|
||||
from typing import Any, Awaitable, Callable, Dict, Optional
|
||||
|
||||
from .callbacks import Callbacks
|
||||
import websockets
|
||||
|
||||
SSLOPT = {"ca_certs": ssl.get_default_verify_paths().cafile}
|
||||
#SSLOPT = {"cert_reqs": ssl.CERT_NONE}
|
||||
ROOM_FORMAT = "wss://euphoria.io/room/{}/ws"
|
||||
from .cookiejar import CookieJar
|
||||
from .events import Events
|
||||
from .exceptions import *
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
#logger.setLevel(logging.INFO)
|
||||
|
||||
class Connection():
|
||||
"""
|
||||
Stays connected to a room in its own thread.
|
||||
Callback functions are called when a packet is received.
|
||||
|
||||
Callbacks:
|
||||
- all the message types from api.euphoria.io
|
||||
These pass the packet data and errors (if any) as arguments to the called functions.
|
||||
The other callbacks don't pass any special arguments.
|
||||
- "connect"
|
||||
- "disconnect"
|
||||
- "stop"
|
||||
"""
|
||||
|
||||
def __init__(self, room, url_format=ROOM_FORMAT, tries=10, delay=30):
|
||||
"""
|
||||
url_format - url the bot will connect to, where the room name is represented by {}
|
||||
tries - how often to try to reconnect when connection is lost (-1 - try forever)
|
||||
delay - time (in seconds) to wait between tries
|
||||
"""
|
||||
|
||||
self.room = room
|
||||
self.tries = tries
|
||||
self.delay = delay
|
||||
self.url_format = url_format
|
||||
|
||||
self.start_time = None
|
||||
|
||||
self._stopping = True
|
||||
|
||||
self._ws = None
|
||||
self._thread = None
|
||||
self._send_id = 0
|
||||
self._callbacks = Callbacks()
|
||||
self._id_callbacks = Callbacks()
|
||||
self._lock = threading.RLock()
|
||||
|
||||
def __enter__(self):
|
||||
self._lock.acquire()
|
||||
return self
|
||||
|
||||
def __exit__(self, exc_type, exc_value, traceback):
|
||||
self._lock.release()
|
||||
|
||||
def _connect(self, tries=10, delay=30):
|
||||
"""
|
||||
_connect(tries, delay) -> bool
|
||||
|
||||
delay - delay between retries (in seconds)
|
||||
tries - maximum number of retries
|
||||
-1 -> retry indefinitely
|
||||
|
||||
Returns True on success, False on failure.
|
||||
|
||||
Connect to the room.
|
||||
"""
|
||||
|
||||
while tries != 0:
|
||||
try:
|
||||
url = self.url_format.format(self.room)
|
||||
logger.info("Connecting to url: {!r} ({} {} left)".format(
|
||||
url,
|
||||
tries-1 if tries > 0 else "infinite",
|
||||
"tries" if (tries-1) != 1 else "try" # proper english :D
|
||||
))
|
||||
self._ws = websocket.create_connection(
|
||||
url,
|
||||
enable_multithread=True,
|
||||
sslopt=SSLOPT
|
||||
)
|
||||
|
||||
except (WSException, socket.gaierror, TimeoutError):
|
||||
if tries > 0:
|
||||
tries -= 1
|
||||
if tries != 0:
|
||||
logger.info("Connection failed. Retrying in {} seconds.".format(delay))
|
||||
time.sleep(delay)
|
||||
else:
|
||||
logger.info("No more tries, stopping.")
|
||||
self.stop()
|
||||
|
||||
else:
|
||||
logger.debug("Connected")
|
||||
self._callbacks.call("connect")
|
||||
return True
|
||||
|
||||
return False
|
||||
|
||||
def _run(self):
|
||||
"""
|
||||
_run() -> None
|
||||
|
||||
Receive messages.
|
||||
"""
|
||||
|
||||
logger.debug("Running")
|
||||
|
||||
while not self._stopping:
|
||||
try:
|
||||
j = self._ws.recv()
|
||||
self._handle_json(j)
|
||||
except (WSException, ConnectionResetError):
|
||||
if not self._stopping:
|
||||
self.disconnect()
|
||||
self._connect(self.tries, self.delay)
|
||||
|
||||
logger.debug("Finished running")
|
||||
self._thread = None
|
||||
|
||||
def _handle_json(self, data):
|
||||
"""
|
||||
_handle_json(data) -> None
|
||||
|
||||
Handle incoming 'raw' data.
|
||||
"""
|
||||
|
||||
packet = json.loads(data)
|
||||
self._handle_packet(packet)
|
||||
|
||||
def _handle_packet(self, packet):
|
||||
"""
|
||||
_handle_packet(ptype, data) -> None
|
||||
|
||||
Handle incoming packets
|
||||
"""
|
||||
|
||||
ptype = packet.get("type")
|
||||
logger.debug("Handling packet of type {}.".format(ptype))
|
||||
|
||||
data = packet.get("data")
|
||||
if "error" in packet:
|
||||
logger.debug("Error in packet: {!r}".format(error))
|
||||
|
||||
if "id" in packet:
|
||||
self._id_callbacks.call(packet["id"], data, packet)
|
||||
self._id_callbacks.remove(packet["id"])
|
||||
|
||||
self._callbacks.call(packet["type"], data, packet)
|
||||
|
||||
def _send_json(self, data):
|
||||
"""
|
||||
_send_json(data) -> None
|
||||
|
||||
Send 'raw' json.
|
||||
"""
|
||||
|
||||
if self._ws:
|
||||
try:
|
||||
self._ws.send(json.dumps(data))
|
||||
except WSException:
|
||||
self.disconnect()
|
||||
|
||||
def launch(self):
|
||||
"""
|
||||
launch() -> bool
|
||||
|
||||
Connect to the room and spawn a new thread.
|
||||
Returns True if connecting was successful and a new thread was spawned.
|
||||
"""
|
||||
|
||||
if self._connect(1):
|
||||
self.start_time = time.time()
|
||||
self._stopping = False
|
||||
|
||||
self._thread = threading.Thread(
|
||||
target=self._run,
|
||||
name="{}-{}".format(int(self.start_time), self.room)
|
||||
)
|
||||
|
||||
logger.debug("Launching new thread: {}".format(self._thread.name))
|
||||
self._thread.start()
|
||||
return True
|
||||
else:
|
||||
return False
|
||||
|
||||
def disconnect(self):
|
||||
"""
|
||||
disconnect() -> None
|
||||
|
||||
Disconnect from the room.
|
||||
This will cause the connection to reconnect.
|
||||
To completely disconnect, use stop().
|
||||
"""
|
||||
|
||||
if self._ws:
|
||||
logger.debug("Closing connection!")
|
||||
self._ws.abort()
|
||||
self._ws.close()
|
||||
self._ws = None
|
||||
|
||||
logger.debug("Disconnected")
|
||||
self._id_callbacks = Callbacks() # we don't need the old id callbacks any more
|
||||
self._callbacks.call("disconnect")
|
||||
|
||||
def stop(self):
|
||||
"""
|
||||
stop() -> None
|
||||
|
||||
Close the connection to the room.
|
||||
Joins the thread launched by self.launch().
|
||||
"""
|
||||
|
||||
logger.debug("Stopping")
|
||||
self._stopping = True
|
||||
self.disconnect()
|
||||
|
||||
self._callbacks.call("stop")
|
||||
|
||||
if self._thread and self._thread != threading.current_thread():
|
||||
self._thread.join()
|
||||
|
||||
def next_id(self):
|
||||
"""
|
||||
next_id() -> id
|
||||
|
||||
Returns the id that will be used for the next package.
|
||||
"""
|
||||
|
||||
return str(self._send_id)
|
||||
|
||||
def subscribe(self, ptype, callback, *args, **kwargs):
|
||||
"""
|
||||
subscribe(ptype, callback, *args, **kwargs) -> None
|
||||
|
||||
Add a function to be called when a packet of type ptype is received.
|
||||
"""
|
||||
|
||||
self._callbacks.add(ptype, callback, *args, **kwargs)
|
||||
|
||||
def subscribe_to_id(self, pid, callback, *args, **kwargs):
|
||||
"""
|
||||
subscribe_to_id(pid, callback, *args, **kwargs) -> None
|
||||
|
||||
Add a function to be called when a packet with id pid is received.
|
||||
"""
|
||||
|
||||
self._id_callbacks.add(pid, callback, *args, **kwargs)
|
||||
|
||||
def subscribe_to_next(self, callback, *args, **kwargs):
|
||||
"""
|
||||
subscribe_to_next(callback, *args, **kwargs) -> None
|
||||
|
||||
Add a function to be called when the answer to the next message sent is received.
|
||||
"""
|
||||
|
||||
self._id_callbacks.add(self.next_id(), callback, *args, **kwargs)
|
||||
|
||||
def send_packet(self, ptype, **kwargs):
|
||||
"""
|
||||
send_packet(ptype, **kwargs) -> None
|
||||
|
||||
Send a formatted packet.
|
||||
"""
|
||||
|
||||
packet = {
|
||||
"type": ptype,
|
||||
"data": kwargs or None,
|
||||
"id": str(self._send_id)
|
||||
}
|
||||
self._send_id += 1
|
||||
self._send_json(packet)
|
||||
__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:
|
||||
"""
|
||||
The Connection handles the lower-level stuff required when connecting to
|
||||
euphoria, such as:
|
||||
|
||||
- Creating a websocket connection
|
||||
- Encoding and decoding packets (json)
|
||||
- Waiting for the server's asynchronous replies to packets
|
||||
- Keeping the connection alive (ping, ping-reply packets)
|
||||
- Reconnecting (timeout while connecting, no pings received in some time)
|
||||
|
||||
It doesn't respond to any events other than the ping-event and is otherwise
|
||||
"dumb".
|
||||
|
||||
|
||||
|
||||
Life cycle of a Connection:
|
||||
|
||||
1. create connection and register event callbacks
|
||||
2. call connect()
|
||||
3. send and receive packets, reconnecting automatically when connection is
|
||||
lost
|
||||
4. call disconnect(), then go to 2.
|
||||
|
||||
|
||||
IN PHASE 1, parameters such as the url the Connection should connect to are
|
||||
set. Usually, event callbacks are also registered in this phase.
|
||||
|
||||
|
||||
IN PHASE 2, the Connection attempts to connect to the url set in phase 1.
|
||||
If successfully connected, it fires a "connected" event.
|
||||
|
||||
|
||||
IN PHASE 3, the Connection listenes for packets from the server and fires
|
||||
the corresponding events. Packets can be sent using the Connection.
|
||||
|
||||
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
|
||||
connection to euphoria again. After the connection is reestablished, it
|
||||
fires a "reconnected" event.
|
||||
|
||||
|
||||
IN PHASE 4, the Connection fires a "disconnecting" event and then closes
|
||||
the connection to euphoria. This event is the last event that is fired
|
||||
until connect() is called again.
|
||||
|
||||
|
||||
|
||||
Events:
|
||||
|
||||
- "connected" : No arguments
|
||||
- "reconnecting" : No arguments
|
||||
- "reconnected" : No arguments
|
||||
- "disconnecting" : No arguments
|
||||
- "<euph event name>": the packet, parsed as JSON
|
||||
|
||||
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.
|
||||
|
||||
Examples for the last category of events include "message-event",
|
||||
"part-event" and "ping".
|
||||
"""
|
||||
|
||||
# Timeout for waiting for the ws connection to be established
|
||||
CONNECT_TIMEOUT = 10 # seconds
|
||||
|
||||
# Maximum duration between euphoria's ping messages. Euphoria usually sends
|
||||
# ping messages every 20 to 30 seconds.
|
||||
PING_TIMEOUT = 40 # seconds
|
||||
|
||||
# The delay between reconnect attempts.
|
||||
RECONNECT_DELAY = 40 # seconds
|
||||
|
||||
# States the Connection may be in
|
||||
_NOT_RUNNING = "not running"
|
||||
_CONNECTING = "connecting"
|
||||
_RUNNING = "running"
|
||||
_RECONNECTING = "reconnecting"
|
||||
_DISCONNECTING = "disconnecting"
|
||||
|
||||
# Initialising
|
||||
|
||||
def __init__(self, url: str, cookie_file: Optional[str] = None) -> None:
|
||||
self._url = url
|
||||
self._cookie_jar = CookieJar(cookie_file)
|
||||
|
||||
self._events = Events()
|
||||
self._packet_id = 0
|
||||
|
||||
# This is the current status of the connection. It can be set to one of
|
||||
# _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()
|
||||
|
||||
self._event_loop: Optional[asyncio.Task[None]] = None
|
||||
|
||||
# These must always be (re)set together. If one of them is None, all
|
||||
# must be None.
|
||||
self._ws = None
|
||||
self._awaiting_replies: Optional[Dict[str, asyncio.Future[Any]]] = None
|
||||
self._ping_check: Optional[asyncio.Task[None]] = None
|
||||
|
||||
self.register_event("ping-event", self._ping_pong)
|
||||
|
||||
def register_event(self,
|
||||
event: str,
|
||||
callback: Callable[..., Awaitable[None]]
|
||||
) -> None:
|
||||
"""
|
||||
Register an event callback.
|
||||
|
||||
For an overview of the possible events, see the Connection docstring.
|
||||
"""
|
||||
|
||||
self._events.register(event, callback)
|
||||
|
||||
# Connecting and disconnecting
|
||||
|
||||
async def _disconnect(self) -> None:
|
||||
"""
|
||||
Disconnect _ws and clean up _ws, _awaiting_replies and _ping_check.
|
||||
|
||||
Important: The caller must ensure that this function is called in valid
|
||||
circumstances and not called twice at the same time. _disconnect() does
|
||||
not check or manipulate _state.
|
||||
"""
|
||||
|
||||
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
|
||||
|
|
|
|||
77
yaboli/cookiejar.py
Normal file
77
yaboli/cookiejar.py
Normal file
|
|
@ -0,0 +1,77 @@
|
|||
import contextlib
|
||||
import http.cookies as cookies
|
||||
import logging
|
||||
from typing import List, Optional, Tuple
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
__all__ = ["CookieJar"]
|
||||
|
||||
class CookieJar:
|
||||
"""
|
||||
Keeps your cookies in a file.
|
||||
|
||||
CookieJar doesn't attempt to discard old cookies, but that doesn't appear
|
||||
to be necessary for keeping euphoria session cookies.
|
||||
"""
|
||||
|
||||
def __init__(self, filename: Optional[str] = None) -> None:
|
||||
self._filename = filename
|
||||
self._cookies = cookies.SimpleCookie()
|
||||
|
||||
if not self._filename:
|
||||
logger.warning("Could not load cookies, no filename given.")
|
||||
return
|
||||
|
||||
with contextlib.suppress(FileNotFoundError):
|
||||
logger.info(f"Loading cookies from {self._filename!r}")
|
||||
with open(self._filename, "r") as f:
|
||||
for line in f:
|
||||
self._cookies.load(line)
|
||||
|
||||
def get_cookies(self) -> List[str]:
|
||||
return [morsel.OutputString(attrs=[])
|
||||
for morsel in self._cookies.values()]
|
||||
|
||||
def get_cookies_as_headers(self) -> List[Tuple[str, str]]:
|
||||
"""
|
||||
Return all stored cookies as tuples in a list. The first tuple entry is
|
||||
always "Cookie".
|
||||
"""
|
||||
|
||||
return [("Cookie", cookie) for cookie in self.get_cookies()]
|
||||
|
||||
def add_cookie(self, cookie: str) -> None:
|
||||
"""
|
||||
Parse cookie and add it to the jar.
|
||||
|
||||
Example cookie: "a=bcd; Path=/; Expires=Wed, 24 Jul 2019 14:57:52 GMT;
|
||||
HttpOnly; Secure"
|
||||
"""
|
||||
|
||||
logger.debug(f"Adding cookie {cookie!r}")
|
||||
self._cookies.load(cookie)
|
||||
|
||||
def save(self) -> None:
|
||||
"""
|
||||
Saves all current cookies to the cookie jar file.
|
||||
"""
|
||||
|
||||
if not self._filename:
|
||||
logger.warning("Could not save cookies, no filename given.")
|
||||
return
|
||||
|
||||
logger.info(f"Saving cookies to {self._filename!r}")
|
||||
|
||||
with open(self._filename, "w") as f:
|
||||
for morsel in self._cookies.values():
|
||||
cookie_string = morsel.OutputString()
|
||||
f.write(f"{cookie_string}\n")
|
||||
|
||||
def clear(self) -> None:
|
||||
"""
|
||||
Removes all cookies from the cookie jar.
|
||||
"""
|
||||
|
||||
logger.debug("OMNOMNOM, cookies are all gone!")
|
||||
self._cookies = cookies.SimpleCookie()
|
||||
40
yaboli/database.py
Normal file
40
yaboli/database.py
Normal file
|
|
@ -0,0 +1,40 @@
|
|||
import asyncio
|
||||
import logging
|
||||
import sqlite3
|
||||
from typing import Any, Awaitable, Callable, TypeVar
|
||||
|
||||
from .util import asyncify
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
__all__ = ["Database", "operation"]
|
||||
|
||||
T = TypeVar('T')
|
||||
|
||||
def operation(func: Callable[..., T]) -> Callable[..., Awaitable[T]]:
|
||||
async def wrapper(self: Any, *args: Any, **kwargs: Any) -> T:
|
||||
async with self as db:
|
||||
while True:
|
||||
try:
|
||||
return await asyncify(func, self, db, *args, **kwargs)
|
||||
except sqlite3.OperationalError as e:
|
||||
logger.warn(f"Operational error encountered: {e}")
|
||||
await asyncio.sleep(5)
|
||||
return wrapper
|
||||
|
||||
class Database:
|
||||
def __init__(self, database: str) -> None:
|
||||
self._connection = sqlite3.connect(database, check_same_thread=False)
|
||||
self._lock = asyncio.Lock()
|
||||
|
||||
self.initialize(self._connection)
|
||||
|
||||
def initialize(self, db: Any) -> None:
|
||||
pass
|
||||
|
||||
async def __aenter__(self) -> Any:
|
||||
await self._lock.__aenter__()
|
||||
return self._connection
|
||||
|
||||
async def __aexit__(self, *args: Any, **kwargs: Any) -> Any:
|
||||
return await self._lock.__aexit__(*args, **kwargs)
|
||||
25
yaboli/events.py
Normal file
25
yaboli/events.py
Normal 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))
|
||||
67
yaboli/exceptions.py
Normal file
67
yaboli/exceptions.py
Normal file
|
|
@ -0,0 +1,67 @@
|
|||
__all__ = [
|
||||
"EuphException",
|
||||
# Connection exceptions
|
||||
"IncorrectStateException",
|
||||
"ConnectionClosedException",
|
||||
# Joining a room
|
||||
"JoinException",
|
||||
"CouldNotConnectException",
|
||||
"CouldNotAuthenticateException",
|
||||
# Doing stuff in a room
|
||||
"RoomNotConnectedException",
|
||||
"EuphError",
|
||||
]
|
||||
|
||||
class EuphException(Exception):
|
||||
pass
|
||||
|
||||
# Connection exceptions
|
||||
|
||||
class IncorrectStateException(EuphException):
|
||||
"""
|
||||
A Connection function was called while the Connection was in the incorrect
|
||||
state.
|
||||
"""
|
||||
pass
|
||||
|
||||
class ConnectionClosedException(EuphException):
|
||||
"""
|
||||
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
173
yaboli/message.py
Normal 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)
|
||||
|
|
@ -1,4 +0,0 @@
|
|||
import sqlite3
|
||||
|
||||
class MessageDB():
|
||||
pass
|
||||
214
yaboli/module.py
Normal file
214
yaboli/module.py
Normal 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
|
||||
]
|
||||
1175
yaboli/room.py
1175
yaboli/room.py
File diff suppressed because it is too large
Load diff
|
|
@ -1,344 +1,324 @@
|
|||
import logging
|
||||
import threading
|
||||
import re
|
||||
from typing import (TYPE_CHECKING, Any, Dict, Iterable, Iterator, List,
|
||||
Optional, Tuple)
|
||||
|
||||
from .callbacks import Callbacks
|
||||
from .connection import Connection
|
||||
from .basic_types import Message, SessionView, mention
|
||||
from .util import mention, normalize
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
if TYPE_CHECKING:
|
||||
from .room import Room
|
||||
|
||||
class Session():
|
||||
"""
|
||||
Deals with the things arising from being connected to a room, such as:
|
||||
- playing ping pong
|
||||
- having a name (usually)
|
||||
- seeing other clients
|
||||
- sending and receiving messages
|
||||
|
||||
event (args) | meaning
|
||||
--------------------|-------------------------------------------------
|
||||
join (bool) | joining the room was successful/not successful
|
||||
| Callbacks for this event are cleared whenever it is called.
|
||||
enter | can view the room
|
||||
ready | can view the room and post messages (has a nick)
|
||||
sessions-update | self.sessions has changed
|
||||
own-session-update | your own message has changed
|
||||
message (msg) | a message has been received (no own messages)
|
||||
own-message (msg) | a message that you have sent
|
||||
|
||||
"""
|
||||
|
||||
def __init__(self, room, password=None, name=None, timeout=10):
|
||||
self.password = password
|
||||
self.real_name = name
|
||||
|
||||
self._room_accessible = False
|
||||
self._room_accessible_event = threading.Event()
|
||||
self._room_accessible_timeout = threading.Timer(timeout, self.stop)
|
||||
|
||||
self._connection = Connection(room)
|
||||
self._connection.subscribe("disconnect", self._reset_variables)
|
||||
# and now the packet types
|
||||
self._connection.subscribe("bounce-event", self._handle_bounce_event)
|
||||
self._connection.subscribe("disconnect-event", self._handle_disconnect_event)
|
||||
self._connection.subscribe("hello-event", self._handle_hello_event)
|
||||
self._connection.subscribe("join-event", self._handle_join_event)
|
||||
self._connection.subscribe("logout-event", self._handle_logout_event)
|
||||
self._connection.subscribe("network-event", self._handle_network_event)
|
||||
self._connection.subscribe("nick-event", self._handle_nick_event)
|
||||
self._connection.subscribe("edit-message-event", self._handle_edit_message_event)
|
||||
self._connection.subscribe("part-event", self._handle_part_event)
|
||||
self._connection.subscribe("ping-event", self._handle_ping_event)
|
||||
self._connection.subscribe("pm-initiate-event", self._handle_pm_initiate_event)
|
||||
self._connection.subscribe("send-event", self._handle_send_event)
|
||||
self._connection.subscribe("snapshot-event", self._handle_snapshot_event)
|
||||
|
||||
self._callbacks = Callbacks()
|
||||
self.subscribe("enter", self._on_enter)
|
||||
|
||||
#self._hello_event_completed = False
|
||||
#self._snapshot_event_completed = False
|
||||
#self._ready = False
|
||||
#self.my_session = SessionView(None, None, None, None, None)
|
||||
#self.sessions = {} # sessions in the room
|
||||
#self.room_is_private = None
|
||||
#self.server_version = None
|
||||
|
||||
self._reset_variables()
|
||||
|
||||
def _reset_variables(self):
|
||||
logger.debug("Resetting room-related variables")
|
||||
self._room_accessible = False
|
||||
|
||||
self.my_session = SessionView(None, None, None, None, None)
|
||||
self.sessions = {}
|
||||
|
||||
self._hello_event_completed = False
|
||||
self._snapshot_event_completed = False
|
||||
self._ready = False
|
||||
|
||||
self.room_is_private = None
|
||||
self.server_version = None
|
||||
|
||||
def _set_name(self, new_name):
|
||||
with self._connection as conn:
|
||||
logger.debug("Setting name to {!r}".format(new_name))
|
||||
conn.subscribe_to_next(self._handle_nick_reply)
|
||||
conn.send_packet("nick", name=new_name)
|
||||
|
||||
def _on_enter(self):
|
||||
logger.info("Connected and authenticated.")
|
||||
self._room_accessible_timeout.cancel()
|
||||
self._room_accessible = True
|
||||
self._room_accessible_event.set()
|
||||
self._room_accessible_event.clear()
|
||||
|
||||
if self.real_name:
|
||||
self._set_name(self.real_name)
|
||||
|
||||
def launch(self, timeout=10):
|
||||
logger.info("Launching session &{}.".format(room))
|
||||
|
||||
self._room_accessible_timeout.start()
|
||||
|
||||
if self._connection.launch(room):
|
||||
logger.debug("Connection established. Waiting for correct events")
|
||||
self._room_accessible_event.wait()
|
||||
return self._room_accessible
|
||||
else:
|
||||
logger.warn("Could not connect to room url.")
|
||||
return False
|
||||
|
||||
def launch(self):
|
||||
return self._connection.launch()
|
||||
|
||||
def stop(self):
|
||||
logger.info("Stopping")
|
||||
self._room_accessible_timeout.cancel()
|
||||
self._room_accessible = False
|
||||
self._room_accessible_event.set()
|
||||
self._room_accessible_event.clear()
|
||||
|
||||
with self._connection as conn:
|
||||
conn.stop()
|
||||
|
||||
def subscribe(self, event, callback, *args, **kwargs):
|
||||
logger.debug("Adding callback {} to {}".format(callback, event))
|
||||
self._callbacks.add(event, callback, *args, **kwargs)
|
||||
|
||||
def send(self, content, parent=None):
|
||||
if self._ready:
|
||||
self._connection.send_packet("send", content=content, parent=parent)
|
||||
logger.debug("Message sent.")
|
||||
else:
|
||||
logger.warn("Attempted to send message while not ready.")
|
||||
|
||||
@property
|
||||
def name(self):
|
||||
return self.my_session.name
|
||||
|
||||
@name.setter
|
||||
def name(self, new_name):
|
||||
self.real_name = new_name
|
||||
|
||||
if not self._ready:
|
||||
self._set_name(new_name)
|
||||
|
||||
@property
|
||||
def room(self):
|
||||
return self._connection.room
|
||||
|
||||
@property
|
||||
def start_time(self):
|
||||
return self._connection.start_time
|
||||
|
||||
def refresh_sessions(self):
|
||||
logger.debug("Refreshing sessions")
|
||||
self._connection.send_packet("who")
|
||||
|
||||
def _set_sessions_from_listing(self, listing):
|
||||
self.sessions = {}
|
||||
|
||||
for item in listing:
|
||||
view = SessionView.from_data(item)
|
||||
self.sessions[view.session_id] = view
|
||||
|
||||
self._callbacks.call("sessions-update")
|
||||
|
||||
def _revert_to_revious_room(self):
|
||||
self._callbacks.call("join", False)
|
||||
|
||||
if self._prev_room:
|
||||
self.password = self._prev_password
|
||||
self.room = self._prev_room # shouldn't do this
|
||||
|
||||
self._prev_room = None
|
||||
self._prev_password = None
|
||||
else:
|
||||
self.stop()
|
||||
|
||||
def _handle_bounce_event(self, data, packet):
|
||||
if data.get("reason") == "authentication required":
|
||||
if self.password:
|
||||
with self._connection as conn:
|
||||
conn.subscribe_to_next(self._handle_auth_reply)
|
||||
conn.send_packet("auth", type="passcode", passcode=self.password)
|
||||
else:
|
||||
logger.warn("Could not access &{}: No password.".format(self._connection.room))
|
||||
self.stop()
|
||||
|
||||
def _handle_disconnect_event(self, data, packet):
|
||||
self._connection.disconnect() # should reconnect
|
||||
|
||||
def _handle_hello_event(self, data, packet):
|
||||
self.my_session.read_data(data.get("session"))
|
||||
self._callbacks.call("own-session-update")
|
||||
|
||||
self.room_is_private = data.get("room_is_private")
|
||||
self.server_version = data.get("version")
|
||||
|
||||
self._hello_event_completed = True
|
||||
if self._snapshot_event_completed:
|
||||
self._callbacks.call("enter")
|
||||
|
||||
def _handle_join_event(self, data, packet):
|
||||
view = SessionView.from_data(data)
|
||||
self.sessions[view.session_id] = view
|
||||
|
||||
if view.name:
|
||||
logger.debug("@{} joined the room.".format(mention(view.name)))
|
||||
else:
|
||||
logger.debug("Someone joined the room.")
|
||||
|
||||
self._callbacks.call("sessions-update")
|
||||
|
||||
def _handle_logout_event(self, data, packet):
|
||||
# no idea why this should happen to the bot
|
||||
# just reconnect, in case it does happen
|
||||
self._connection.disconnect()
|
||||
|
||||
def _handle_network_event(self, data, packet):
|
||||
if data.get("type") == "partition":
|
||||
prev_len = len(self.sessions)
|
||||
|
||||
# only remove views matching the server_id/server_era combo
|
||||
self.sessions = {
|
||||
sid: view for sid, view in self.sessions.items()
|
||||
if view.server_id != data.get("server_id")
|
||||
or view.server_era != data.get("server_era")
|
||||
}
|
||||
|
||||
if len(sessions) != prev_len:
|
||||
logger.info("Some people left after a network event.")
|
||||
else:
|
||||
logger.info("No people left after a network event.")
|
||||
|
||||
self._callbacks.call("sessions-update")
|
||||
|
||||
def _handle_nick_event(self, data, packet):
|
||||
session_id = data.get("session_id")
|
||||
|
||||
if session_id not in self.sessions:
|
||||
logger.warn("SessionView not found: Refreshing sessions.")
|
||||
self.refresh_sessions()
|
||||
else:
|
||||
self.sessions[session_id].name = data.get("to")
|
||||
|
||||
if data.get("from"):
|
||||
logger.debug("@{} changed their name to @{}.".format(
|
||||
mention(data.get("from")),
|
||||
mention(data.get("to"))
|
||||
))
|
||||
else:
|
||||
logger.debug("Someone changed their name to @{}.".format(
|
||||
mention(data.get("to"))
|
||||
))
|
||||
|
||||
self._callbacks.call("sessions-update")
|
||||
|
||||
def _handle_edit_message_event(self, data, packet):
|
||||
# TODO: implement
|
||||
pass
|
||||
|
||||
def _handle_part_event(self, data, packet):
|
||||
view = SessionView.from_data(data)
|
||||
if view.session_id not in self.sessions:
|
||||
logger.warn("SessionView not found: Refreshing sessions.")
|
||||
self.refresh_sessions()
|
||||
else:
|
||||
del self.sessions[view.session_id]
|
||||
|
||||
if view.name:
|
||||
logger.debug("@{} left the room.".format(mention(view.name)))
|
||||
else:
|
||||
logger.debug("Someone left the room.")
|
||||
|
||||
self._callbacks.call("sessions-update")
|
||||
|
||||
def _handle_ping_event(self, data, packet):
|
||||
with self._connection as conn:
|
||||
conn.send_packet("ping-reply", time=data.get("time"))
|
||||
|
||||
def _handle_pm_initiate_event(self, data, error):
|
||||
pass # placeholder, maybe implemented in the future
|
||||
|
||||
def _handle_send_event(self, data, error):
|
||||
# TODO: implement
|
||||
msg = Message.from_data(data)
|
||||
self._callbacks.call("message", msg)
|
||||
|
||||
def _handle_snapshot_event(self, data, packet):
|
||||
# deal with connected sessions
|
||||
self._set_sessions_from_listing(data.get("listing"))
|
||||
|
||||
# deal with messages
|
||||
# TODO: implement
|
||||
|
||||
# deal with other info
|
||||
self.server_version = data.get("version")
|
||||
if "nick" in data:
|
||||
self.my_session.name = data.get("nick")
|
||||
self._callbacks.call("own-session-update")
|
||||
|
||||
self._snapshot_event_completed = True
|
||||
if self._hello_event_completed:
|
||||
self._callbacks.call("enter")
|
||||
|
||||
|
||||
def _handle_auth_reply(self, data, packet):
|
||||
if not data.get("success"):
|
||||
logger.warn("Could not authenticate, reason: {!r}".format(data.get("reason")))
|
||||
self.stop()
|
||||
else:
|
||||
logger.debug("Authetication complete, password was correct.")
|
||||
|
||||
def _handle_get_message_reply(self, data, packet):
|
||||
# TODO: implement
|
||||
pass
|
||||
|
||||
def _handle_log_event(self, data, packet):
|
||||
# TODO: implement
|
||||
pass
|
||||
|
||||
def _handle_nick_reply(self, data, packet):
|
||||
first_name = not self.name
|
||||
|
||||
if data.get("from"):
|
||||
logger.info("Changed name from {!r} to {!r}.".format(data.get("from"), data.get("to")))
|
||||
else:
|
||||
logger.info("Changed name to {!r}.".format(data.get("to")))
|
||||
|
||||
self.my_session.name = data.get("to")
|
||||
self._callbacks.call("own-session-update")
|
||||
|
||||
if first_name:
|
||||
self._ready = True
|
||||
self._callbacks.call("ready")
|
||||
|
||||
def _handle_send_reply(self, data, packet):
|
||||
# TODO: implement
|
||||
msg = Message.from_data(data)
|
||||
self._callbacks.call("own-message", msg)
|
||||
|
||||
def _handle_who_reply(self, data, packet):
|
||||
self._set_sessions_from_listing(data.get("listing"))
|
||||
__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
73
yaboli/util.py
Normal 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
|
||||
|
|
@ -1,21 +0,0 @@
|
|||
def mention(name):
|
||||
"""
|
||||
mention(name) -> name
|
||||
|
||||
Removes all whitespace and some special characters from the name,
|
||||
such that the resulting name, if prepended with a "@", will mention the user.
|
||||
"""
|
||||
|
||||
return "".join(c for c in name if not c in ".!?;&<'\"" and not c.isspace())
|
||||
|
||||
def reduce_name(name):
|
||||
"""
|
||||
reduce_name(name) -> name
|
||||
|
||||
Reduces a name to a form which can be compared with other such forms.
|
||||
If two such forms are equal, they are both mentioned by the same @mentions,
|
||||
and should be considered identical when used to identify users.
|
||||
"""
|
||||
|
||||
#TODO: implement
|
||||
pass
|
||||
Loading…
Add table
Add a link
Reference in a new issue