Compare commits
No commits in common. "master" and "rewrite-4" have entirely different histories.
28 changed files with 1072 additions and 3407 deletions
6
.gitignore
vendored
6
.gitignore
vendored
|
|
@ -1,4 +1,2 @@
|
||||||
__pycache__/
|
yaboli/__pycache__/
|
||||||
*.egg-info/
|
*.db
|
||||||
/.mypy_cache/
|
|
||||||
/.venv/
|
|
||||||
|
|
|
||||||
60
CHANGELOG.md
60
CHANGELOG.md
|
|
@ -1,60 +0,0 @@
|
||||||
# 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
ExampleBot.py
Normal file
21
ExampleBot.py
Normal file
|
|
@ -0,0 +1,21 @@
|
||||||
|
import asyncio
|
||||||
|
import yaboli
|
||||||
|
|
||||||
|
class ExampleBot(yaboli.Bot):
|
||||||
|
async def send(self, room, message):
|
||||||
|
ping = "ExamplePong!"
|
||||||
|
short_help = "Example bot for the yaboli bot library"
|
||||||
|
long_help = "I'm an example bot for the yaboli bot library, which can be found at https://github.com/Garmelon/yaboli"
|
||||||
|
|
||||||
|
await self.botrulez_ping_general(room, message, ping_text=ping)
|
||||||
|
await self.botrulez_ping_specific(room, message, ping_text=ping)
|
||||||
|
await self.botrulez_help_general(room, message, help_text=short_help)
|
||||||
|
await self.botrulez_help_specific(room, message, help_text=long_help)
|
||||||
|
await self.botrulez_uptime(room, message)
|
||||||
|
await self.botrulez_kill(room, message)
|
||||||
|
await self.botrulez_restart(room, message)
|
||||||
|
|
||||||
|
forward = send # should work without modifications for most bots
|
||||||
|
|
||||||
|
bot = ExampleBot("ExampleBot", "examplebot_cookies", rooms=["test", "welcome"])
|
||||||
|
asyncio.get_event_loop().run_forever()
|
||||||
21
LICENSE
21
LICENSE
|
|
@ -1,21 +0,0 @@
|
||||||
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
85
README.md
|
|
@ -1,85 +0,0 @@
|
||||||
# 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
|
|
||||||
|
|
@ -1,13 +0,0 @@
|
||||||
# Setting up and running a bot
|
|
||||||
|
|
||||||
## Installing yaboli
|
|
||||||
|
|
||||||
TODO
|
|
||||||
|
|
||||||
## Configuring the bot
|
|
||||||
|
|
||||||
TODO
|
|
||||||
|
|
||||||
## Running the bot
|
|
||||||
|
|
||||||
TODO
|
|
||||||
|
|
@ -1,89 +0,0 @@
|
||||||
# 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
5
examples/echo/.gitignore
vendored
|
|
@ -1,5 +0,0 @@
|
||||||
# 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
|
|
||||||
|
|
@ -1,6 +0,0 @@
|
||||||
[general]
|
|
||||||
nick = EchoBot
|
|
||||||
cookie_file = bot.cookie
|
|
||||||
|
|
||||||
[rooms]
|
|
||||||
test
|
|
||||||
|
|
@ -1,23 +0,0 @@
|
||||||
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)
|
|
||||||
|
|
@ -1,17 +0,0 @@
|
||||||
# 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
4
mypy.ini
|
|
@ -1,4 +0,0 @@
|
||||||
[mypy]
|
|
||||||
disallow_untyped_defs = True
|
|
||||||
disallow_incomplete_defs = True
|
|
||||||
no_implicit_optional = True
|
|
||||||
|
|
@ -1,30 +0,0 @@
|
||||||
[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,83 +1,27 @@
|
||||||
|
# ---------- BEGIN DEV SECTION ----------
|
||||||
import asyncio
|
import asyncio
|
||||||
import configparser
|
|
||||||
import logging
|
import logging
|
||||||
from typing import Callable, Dict
|
|
||||||
|
# asyncio debugging
|
||||||
|
asyncio.get_event_loop().set_debug(True) # uncomment for asycio debugging mode
|
||||||
|
logging.getLogger("asyncio").setLevel(logging.DEBUG)
|
||||||
|
|
||||||
|
# yaboli logger level
|
||||||
|
logging.getLogger(__name__).setLevel(logging.DEBUG)
|
||||||
|
# ----------- END DEV SECTION -----------
|
||||||
|
|
||||||
from .bot import *
|
from .bot import *
|
||||||
from .client import *
|
from .cookiejar import *
|
||||||
from .command import *
|
|
||||||
from .connection import *
|
from .connection import *
|
||||||
from .database import *
|
|
||||||
from .events import *
|
|
||||||
from .exceptions import *
|
from .exceptions import *
|
||||||
from .message import *
|
|
||||||
from .module import *
|
|
||||||
from .room import *
|
from .room import *
|
||||||
from .session import *
|
from .utils import *
|
||||||
from .util import *
|
|
||||||
|
|
||||||
__all__ = ["STYLE", "FORMAT", "DATE_FORMAT", "FORMATTER", "enable_logging",
|
__all__ = (
|
||||||
"run", "run_modulebot"]
|
bot.__all__ +
|
||||||
|
connection.__all__ +
|
||||||
__all__ += bot.__all__
|
cookiejar.__all__ +
|
||||||
__all__ += client.__all__
|
exceptions.__all__ +
|
||||||
__all__ += command.__all__
|
room.__all__ +
|
||||||
__all__ += connection.__all__
|
utils.__all__
|
||||||
__all__ += database.__all__
|
|
||||||
__all__ += events.__all__
|
|
||||||
__all__ += exceptions.__all__
|
|
||||||
__all__ += message.__all__
|
|
||||||
__all__ += module.__all__
|
|
||||||
__all__ += room.__all__
|
|
||||||
__all__ += session.__all__
|
|
||||||
__all__ += util.__all__
|
|
||||||
|
|
||||||
STYLE = "{"
|
|
||||||
FORMAT = "{asctime} [{levelname:<7}] <{name}>: {message}"
|
|
||||||
DATE_FORMAT = "%F %T"
|
|
||||||
|
|
||||||
FORMATTER = logging.Formatter(
|
|
||||||
fmt=FORMAT,
|
|
||||||
datefmt=DATE_FORMAT,
|
|
||||||
style=STYLE
|
|
||||||
)
|
)
|
||||||
|
|
||||||
def enable_logging(name: str = "yaboli", level: int = logging.INFO) -> None:
|
|
||||||
handler = logging.StreamHandler()
|
|
||||||
handler.setFormatter(FORMATTER)
|
|
||||||
|
|
||||||
logger = logging.getLogger(name)
|
|
||||||
logger.setLevel(level)
|
|
||||||
logger.addHandler(handler)
|
|
||||||
|
|
||||||
def run(
|
|
||||||
bot_constructor: BotConstructor,
|
|
||||||
config_file: str = "bot.conf",
|
|
||||||
) -> None:
|
|
||||||
async def _run() -> None:
|
|
||||||
while True:
|
|
||||||
# Load the config file
|
|
||||||
config = configparser.ConfigParser(allow_no_value=True)
|
|
||||||
config.read(config_file)
|
|
||||||
|
|
||||||
bot = bot_constructor(config, config_file)
|
|
||||||
await bot.run()
|
|
||||||
|
|
||||||
asyncio.run(_run())
|
|
||||||
|
|
||||||
def run_modulebot(
|
|
||||||
modulebot_constructor: ModuleBotConstructor,
|
|
||||||
module_constructors: Dict[str, ModuleConstructor],
|
|
||||||
config_file: str = "bot.conf",
|
|
||||||
) -> None:
|
|
||||||
async def _run() -> None:
|
|
||||||
while True:
|
|
||||||
# Load the config file
|
|
||||||
config = configparser.ConfigParser(allow_no_value=True)
|
|
||||||
config.read(config_file)
|
|
||||||
|
|
||||||
modulebot = modulebot_constructor(config, config_file,
|
|
||||||
module_constructors)
|
|
||||||
await modulebot.run()
|
|
||||||
|
|
||||||
asyncio.run(_run())
|
|
||||||
|
|
|
||||||
545
yaboli/bot.py
545
yaboli/bot.py
|
|
@ -1,377 +1,176 @@
|
||||||
import configparser
|
|
||||||
import datetime
|
|
||||||
import logging
|
import logging
|
||||||
from typing import Callable, List, Optional
|
import re
|
||||||
|
import time
|
||||||
|
|
||||||
|
from .cookiejar import *
|
||||||
|
from .room import *
|
||||||
|
from .utils import *
|
||||||
|
|
||||||
from .client import Client
|
|
||||||
from .command import *
|
|
||||||
from .message import LiveMessage, Message
|
|
||||||
from .room import Room
|
|
||||||
from .util import *
|
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
__all__ = ["Bot", "command"]
|
||||||
__all__ = ["Bot", "BotConstructor"]
|
|
||||||
|
|
||||||
class Bot(Client):
|
# Some command stuff
|
||||||
"""
|
|
||||||
A Bot is a Client that responds to commands and uses a config file to
|
SPECIFIC_RE = re.compile(r"!(\S+)\s+@(\S+)\s*([\S\s]*)")
|
||||||
automatically set its nick and join rooms.
|
GENERAL_RE = re.compile(r"!(\S+)\s*([\S\s]*)")
|
||||||
|
|
||||||
The config file is loaded as a ConfigParser by the run() or run_modulebot()
|
def command(commandname, specific=True, noargs=False):
|
||||||
functions and has the following structure:
|
def decorator(func):
|
||||||
|
async def wrapper(self, room, message, *args, **kwargs):
|
||||||
A "general" section which contains:
|
if specific:
|
||||||
- nick - the default nick of the bot (set to the empty string if you don't
|
result = self._parse_command(message.content, specific=room.session.nick)
|
||||||
want to set a nick)
|
else:
|
||||||
- cookie_file (optional) - the file the cookie should be saved in
|
result = self._parse_command(message.content)
|
||||||
|
if result is None: return
|
||||||
A "rooms" section which contains a list of rooms that the bot should
|
cmd, argstr = result
|
||||||
automatically join. This section is optional if you overwrite started().
|
if cmd != commandname: return
|
||||||
The room list should have the format "roomname" or "roomname = password".
|
if noargs:
|
||||||
|
if argstr: return
|
||||||
A bot has the following attributes:
|
return await func(self, room, message, *args, **kwargs)
|
||||||
- ALIASES - list of alternate nicks the bot responds to (see
|
else:
|
||||||
process_commands())
|
return await func(self, room, message, argstr, *args, **kwargs)
|
||||||
- PING_REPLY - used by cmd_ping()
|
return wrapper
|
||||||
- HELP_GENERAL - used by cmd_help_general()
|
return decorator
|
||||||
- HELP_SPECIFIC - used by cmd_help_specific()
|
|
||||||
- KILL_REPLY - used by cmd_kill()
|
|
||||||
- RESTART_REPLY - used by cmd_restart()
|
# And now comes the real bot...
|
||||||
- GENERAL_SECTION - the name of the "general" section in the config file
|
|
||||||
(see above) (default: "general")
|
class Bot(Inhabitant):
|
||||||
- ROOMS_SECTION - the name of the "rooms" section in the config file (see
|
def __init__(self, nick, cookiefile=None):
|
||||||
above) (default: "rooms")
|
self.target_nick = nick
|
||||||
"""
|
self.rooms = {}
|
||||||
|
self.cookiejar = CookieJar(cookiefile)
|
||||||
ALIASES: List[str] = []
|
|
||||||
|
# ROOM MANAGEMENT
|
||||||
PING_REPLY: str = "Pong!"
|
|
||||||
HELP_GENERAL: Optional[str] = None
|
def join_room(self, roomname, password=None):
|
||||||
HELP_SPECIFIC: Optional[List[str]] = None
|
if roomname in self.rooms:
|
||||||
KILL_REPLY: Optional[str] = "/me dies"
|
return
|
||||||
RESTART_REPLY: Optional[str] = "/me restarts"
|
|
||||||
|
self.rooms[roomname] = Room(self, roomname, self.target_nick, password=password, cookiejar=self.cookiejar)
|
||||||
GENERAL_SECTION = "general"
|
|
||||||
ROOMS_SECTION = "rooms"
|
async def part_room(self, roomname):
|
||||||
|
room = self.rooms.pop(roomname, None)
|
||||||
def __init__(self,
|
if room:
|
||||||
config: configparser.ConfigParser,
|
await room.exit()
|
||||||
config_file: str,
|
|
||||||
) -> None:
|
# BOTRULEZ
|
||||||
self.config = config
|
|
||||||
self.config_file = config_file
|
@command("ping", specific=False, noargs=True)
|
||||||
|
async def botrulez_ping_general(self, room, message, ping_text="Pong!"):
|
||||||
nick = self.config[self.GENERAL_SECTION].get("nick")
|
await room.send(ping_text, message.mid)
|
||||||
if nick is None:
|
|
||||||
logger.warn(("'nick' not set in config file. Defaulting to empty"
|
@command("ping", specific=True, noargs=True)
|
||||||
" nick"))
|
async def botrulez_ping_specific(self, room, message, ping_text="Pong!"):
|
||||||
nick = ""
|
await room.send(ping_text, message.mid)
|
||||||
|
|
||||||
cookie_file = self.config[self.GENERAL_SECTION].get("cookie_file")
|
@command("help", specific=False, noargs=True)
|
||||||
if cookie_file is None:
|
async def botrulez_help_general(self, room, message, help_text="Placeholder help text"):
|
||||||
logger.warn(("'cookie_file' not set in config file. Using no cookie"
|
await room.send(help_text, message.mid)
|
||||||
" file."))
|
|
||||||
|
@command("help", specific=True, noargs=True)
|
||||||
super().__init__(nick, cookie_file=cookie_file)
|
async def botrulez_help_specific(self, room, message, help_text="Placeholder help text"):
|
||||||
|
await room.send(help_text, message.mid)
|
||||||
self._commands: List[Command] = []
|
|
||||||
|
@command("uptime", specific=True, noargs=True)
|
||||||
self.start_time = datetime.datetime.now()
|
async def botrulez_uptime(self, room, message):
|
||||||
|
now = time.time()
|
||||||
def save_config(self) -> None:
|
startformat = format_time(room.start_time)
|
||||||
"""
|
deltaformat = format_time_delta(now - room.start_time)
|
||||||
Save the current state of self.config to the file passed in __init__ as
|
text = f"/me has been up since {startformat} ({deltaformat})"
|
||||||
the config_file parameter.
|
await room.send(text, message.mid)
|
||||||
|
|
||||||
Usually, this is the file that self.config was loaded from (if you use
|
@command("kill", specific=True, noargs=True)
|
||||||
run or run_modulebot).
|
async def botrulez_kill(self, room, message, kill_text="/me dies"):
|
||||||
"""
|
await room.send(kill_text, message.mid)
|
||||||
|
await self.part_room(room.roomname)
|
||||||
with open(self.config_file, "w") as f:
|
|
||||||
self.config.write(f)
|
@command("restart", specific=True, noargs=True)
|
||||||
|
async def botrulez_restart(self, room, message, restart_text="/me restarts"):
|
||||||
async def started(self) -> None:
|
await room.send(restart_text, message.mid)
|
||||||
"""
|
await self.part_room(room.roomname)
|
||||||
This Client function is overwritten in order to join all the rooms
|
self.join_room(room.roomname, password=room.password)
|
||||||
listed in the "rooms" section of self.config.
|
|
||||||
|
# COMMAND PARSING
|
||||||
If you need to overwrite this function but want to keep the auto-join
|
|
||||||
functionality, make sure to await super().started().
|
@staticmethod
|
||||||
"""
|
def parse_args(text):
|
||||||
|
"""
|
||||||
for room, password in self.config[self.ROOMS_SECTION].items():
|
Use bash-style single- and double-quotes to include whitespace in arguments.
|
||||||
if password is None:
|
A backslash always escapes the next character.
|
||||||
await self.join(room)
|
Any non-escaped whitespace separates arguments.
|
||||||
else:
|
|
||||||
await self.join(room, password=password)
|
Returns a list of arguments.
|
||||||
|
Deals with unclosed quotes and backslashes without crashing.
|
||||||
# Registering commands
|
"""
|
||||||
|
|
||||||
def register(self, command: Command) -> None:
|
escape = False
|
||||||
"""
|
quote = None
|
||||||
Register a Command (from the yaboli.command submodule).
|
args = []
|
||||||
|
arg = ""
|
||||||
Usually, you don't have to call this function yourself.
|
|
||||||
"""
|
for character in text:
|
||||||
|
if escape:
|
||||||
self._commands.append(command)
|
arg += character
|
||||||
|
escape = False
|
||||||
def register_general(self,
|
elif character == "\\":
|
||||||
name: str,
|
escape = True
|
||||||
cmdfunc: GeneralCommandFunction,
|
elif quote:
|
||||||
args: bool = True
|
if character == quote:
|
||||||
) -> None:
|
quote = None
|
||||||
"""
|
else:
|
||||||
Register a function as general bot command (i. e. no @mention of the
|
arg += character
|
||||||
bot nick after the !command). This function will be called by
|
elif character in "'\"":
|
||||||
process_commands() when the bot encounters a matching command.
|
quote = character
|
||||||
|
elif character.isspace():
|
||||||
name - the name of the command (If you want your command to be !hello,
|
if len(arg) > 0:
|
||||||
the name is "hello".)
|
args.append(arg)
|
||||||
|
arg = ""
|
||||||
cmdfunc - the function that is called with the Room, LiveMessage and
|
else:
|
||||||
ArgumentData when the bot encounters a matching command
|
arg += character
|
||||||
|
|
||||||
args - whether the command may have arguments (If set to False, the
|
#if escape or quote:
|
||||||
ArgumentData's has_args() function must also return False for the
|
#return None # syntax error
|
||||||
command function to be called. If set to True, all ArgumentData is
|
|
||||||
valid.)
|
if len(arg) > 0:
|
||||||
"""
|
args.append(arg)
|
||||||
|
|
||||||
command = GeneralCommand(name, cmdfunc, args)
|
return args
|
||||||
self.register(command)
|
|
||||||
|
@staticmethod
|
||||||
def register_specific(self,
|
def parse_flags(arglist):
|
||||||
name: str,
|
flags = ""
|
||||||
cmdfunc: SpecificCommandFunction,
|
args = []
|
||||||
args: bool = True
|
kwargs = {}
|
||||||
) -> None:
|
|
||||||
"""
|
for arg in arglist:
|
||||||
Register a function as specific bot command (i. e. @mention of the bot
|
# kwargs (--abc, --foo=bar)
|
||||||
nick after the !command is required). This function will be called by
|
if arg[:2] == "--":
|
||||||
process_commands() when the bot encounters a matching command.
|
arg = arg[2:]
|
||||||
|
if "=" in arg:
|
||||||
name - the name of the command (see register_general() for an
|
s = arg.split("=", maxsplit=1)
|
||||||
explanation)
|
kwargs[s[0]] = s[1]
|
||||||
|
else:
|
||||||
cmdfunc - the function that is called with the Room, LiveMessage and
|
kwargs[arg] = None
|
||||||
SpecificArgumentData when the bot encounters a matching command
|
# flags (-x, -rw)
|
||||||
|
elif arg[:1] == "-":
|
||||||
args - whether the command may have arguments (see register_general()
|
arg = arg[1:]
|
||||||
for an explanation)
|
flags += arg
|
||||||
"""
|
# args (normal arguments)
|
||||||
|
else:
|
||||||
command = SpecificCommand(name, cmdfunc, args)
|
args.append(arg)
|
||||||
self.register(command)
|
|
||||||
|
return flags, args, kwargs
|
||||||
# Processing commands
|
|
||||||
|
@staticmethod
|
||||||
async def process_commands(self,
|
def _parse_command(content, specific=None):
|
||||||
room: Room,
|
if specific is not None:
|
||||||
message: LiveMessage,
|
match = SPECIFIC_RE.fullmatch(content)
|
||||||
aliases: List[str] = []
|
if match and similar(match.group(2), specific):
|
||||||
) -> None:
|
return match.group(1), match.group(3)
|
||||||
"""
|
else:
|
||||||
If the message contains a command, call all matching command functions
|
match = GENERAL_RE.fullmatch(content)
|
||||||
that were previously registered.
|
if match:
|
||||||
|
return match.group(1), match.group(2)
|
||||||
This function is usually called by the overwritten on_send() function.
|
|
||||||
"""
|
|
||||||
|
|
||||||
nicks = [room.session.nick] + aliases
|
|
||||||
data = CommandData.from_string(message.content)
|
|
||||||
|
|
||||||
if data is not None:
|
|
||||||
logger.debug(f"Processing command from {message.content!r}")
|
|
||||||
for command in self._commands:
|
|
||||||
await command.run(room, message, nicks, data)
|
|
||||||
|
|
||||||
async def on_send(self, room: Room, message: LiveMessage) -> None:
|
|
||||||
"""
|
|
||||||
This Client function is overwritten in order to automatically call
|
|
||||||
process_commands() with self.ALIASES.
|
|
||||||
|
|
||||||
If you need to overwrite this function, make sure to await
|
|
||||||
process_commands() with self.ALIASES somewhere in your function, or
|
|
||||||
await super().on_send().
|
|
||||||
"""
|
|
||||||
|
|
||||||
await self.process_commands(room, message, aliases=self.ALIASES)
|
|
||||||
|
|
||||||
# Help util
|
|
||||||
|
|
||||||
def format_help(self, room: Room, lines: List[str]) -> str:
|
|
||||||
"""
|
|
||||||
Format a list of strings into a string, replacing certain placeholders
|
|
||||||
with the actual values.
|
|
||||||
|
|
||||||
This function uses the str.format() function to replace the following:
|
|
||||||
|
|
||||||
- {nick} - the bot's current nick
|
|
||||||
- {mention} - the bot's current nick, run through mention()
|
|
||||||
- {atmention} - the bot's current nick, run through atmention()
|
|
||||||
"""
|
|
||||||
|
|
||||||
text = "\n".join(lines)
|
|
||||||
params = {
|
|
||||||
"nick": room.session.nick,
|
|
||||||
"mention": room.session.mention,
|
|
||||||
"atmention": room.session.atmention,
|
|
||||||
}
|
|
||||||
return text.format(**params)
|
|
||||||
|
|
||||||
# Botrulez
|
|
||||||
|
|
||||||
def register_botrulez(self,
|
|
||||||
ping: bool = True,
|
|
||||||
help_: bool = True,
|
|
||||||
uptime: bool = True,
|
|
||||||
kill: bool = False,
|
|
||||||
restart: bool = False,
|
|
||||||
) -> None:
|
|
||||||
"""
|
|
||||||
Register the commands necessary for the bot to conform to the botrulez
|
|
||||||
(https://github.com/jedevc/botrulez). Also includes a few optional
|
|
||||||
botrulez commands that are disabled by default.
|
|
||||||
|
|
||||||
- ping - register general and specific cmd_ping()
|
|
||||||
- help_ - register cmd_help_general() and cmd_help_specific()
|
|
||||||
- uptime - register specific cmd_uptime
|
|
||||||
- kill - register specific cmd_kill (disabled by default)
|
|
||||||
- uptime - register specific cmd_uptime (disabled by default)
|
|
||||||
|
|
||||||
All commands are registered with args=False.
|
|
||||||
|
|
||||||
If you want to implement your own versions of these commands, it is
|
|
||||||
recommended that you set the respective argument to False in your call
|
|
||||||
to register_botrulez(), overwrite the existing command functions or
|
|
||||||
create your own, and then register them manually.
|
|
||||||
|
|
||||||
For help, that might look something like this, if you've written a
|
|
||||||
custom specific help that takes extra arguments but are using the
|
|
||||||
botrulez general help:
|
|
||||||
|
|
||||||
self.register_botrulez(help_=False)
|
|
||||||
self.register_general("help", self.cmd_help_general, args=False)
|
|
||||||
self.register_specific("help", self.cmd_help_custom)
|
|
||||||
|
|
||||||
In case you're asking, the help_ parameter has an underscore at the end
|
|
||||||
so it doesn't overlap the help() function.
|
|
||||||
"""
|
|
||||||
|
|
||||||
if ping:
|
|
||||||
self.register_general("ping", self.cmd_ping, args=False)
|
|
||||||
self.register_specific("ping", self.cmd_ping, args=False)
|
|
||||||
|
|
||||||
if help_:
|
|
||||||
if self.HELP_GENERAL is None and self.HELP_SPECIFIC is None:
|
|
||||||
logger.warn(("HELP_GENERAL and HELP_SPECIFIC are None, but the"
|
|
||||||
" help command is enabled"))
|
|
||||||
self.register_general("help", self.cmd_help_general, args=False)
|
|
||||||
self.register_specific("help", self.cmd_help_specific, args=False)
|
|
||||||
|
|
||||||
if uptime:
|
|
||||||
self.register_specific("uptime", self.cmd_uptime, args=False)
|
|
||||||
|
|
||||||
if kill:
|
|
||||||
self.register_specific("kill", self.cmd_kill, args=False)
|
|
||||||
|
|
||||||
if restart:
|
|
||||||
self.register_specific("restart", self.cmd_restart, args=False)
|
|
||||||
|
|
||||||
async def cmd_ping(self,
|
|
||||||
room: Room,
|
|
||||||
message: LiveMessage,
|
|
||||||
args: ArgumentData
|
|
||||||
) -> None:
|
|
||||||
"""
|
|
||||||
Reply with self.PING_REPLY.
|
|
||||||
"""
|
|
||||||
|
|
||||||
await message.reply(self.PING_REPLY)
|
|
||||||
|
|
||||||
async def cmd_help_general(self,
|
|
||||||
room: Room,
|
|
||||||
message: LiveMessage,
|
|
||||||
args: ArgumentData
|
|
||||||
) -> None:
|
|
||||||
"""
|
|
||||||
Reply with self.HELP_GENERAL, if it is not None. Uses format_help().
|
|
||||||
"""
|
|
||||||
|
|
||||||
if self.HELP_GENERAL is not None:
|
|
||||||
await message.reply(self.format_help(room, [self.HELP_GENERAL]))
|
|
||||||
|
|
||||||
async def cmd_help_specific(self,
|
|
||||||
room: Room,
|
|
||||||
message: LiveMessage,
|
|
||||||
args: SpecificArgumentData
|
|
||||||
) -> None:
|
|
||||||
"""
|
|
||||||
Reply with self.HELP_SPECIFIC, if it is not None. Uses format_help().
|
|
||||||
"""
|
|
||||||
|
|
||||||
if self.HELP_SPECIFIC is not None:
|
|
||||||
await message.reply(self.format_help(room, self.HELP_SPECIFIC))
|
|
||||||
|
|
||||||
async def cmd_uptime(self,
|
|
||||||
room: Room,
|
|
||||||
message: LiveMessage,
|
|
||||||
args: SpecificArgumentData
|
|
||||||
) -> None:
|
|
||||||
"""
|
|
||||||
Reply with the bot's uptime in the format specified by the botrulez.
|
|
||||||
|
|
||||||
This uses the time that the Bot was first started, not the time the
|
|
||||||
respective Room was created. A !restart (see register_botrulez()) will
|
|
||||||
reset the bot uptime, but leaving and re-joining a room or losing
|
|
||||||
connection won't.
|
|
||||||
"""
|
|
||||||
|
|
||||||
time = format_time(self.start_time)
|
|
||||||
delta = format_delta(datetime.datetime.now() - self.start_time)
|
|
||||||
text = f"/me has been up since {time} UTC ({delta})"
|
|
||||||
await message.reply(text)
|
|
||||||
|
|
||||||
async def cmd_kill(self,
|
|
||||||
room: Room,
|
|
||||||
message: LiveMessage,
|
|
||||||
args: SpecificArgumentData
|
|
||||||
) -> None:
|
|
||||||
"""
|
|
||||||
Remove the bot from this room.
|
|
||||||
|
|
||||||
If self.KILL_REPLY is not None, replies with that before leaving the
|
|
||||||
room.
|
|
||||||
"""
|
|
||||||
|
|
||||||
logger.info(f"Killed in &{room.name} by {message.sender.atmention}")
|
|
||||||
|
|
||||||
if self.KILL_REPLY is not None:
|
|
||||||
await message.reply(self.KILL_REPLY)
|
|
||||||
|
|
||||||
await self.part(room)
|
|
||||||
|
|
||||||
async def cmd_restart(self,
|
|
||||||
room: Room,
|
|
||||||
message: LiveMessage,
|
|
||||||
args: SpecificArgumentData
|
|
||||||
) -> None:
|
|
||||||
"""
|
|
||||||
Restart the whole Bot.
|
|
||||||
|
|
||||||
This is done by stopping the Bot, since the run() or run_modulebot()
|
|
||||||
functions start the Bot in a while True loop.
|
|
||||||
|
|
||||||
If self.RESTART_REPLY is not None, replies with that before restarting.
|
|
||||||
"""
|
|
||||||
|
|
||||||
logger.info(f"Restarted in &{room.name} by {message.sender.atmention}")
|
|
||||||
|
|
||||||
if self.RESTART_REPLY is not None:
|
|
||||||
await message.reply(self.RESTART_REPLY)
|
|
||||||
|
|
||||||
await self.stop()
|
|
||||||
|
|
||||||
BotConstructor = Callable[[configparser.ConfigParser, str], Bot]
|
|
||||||
|
|
|
||||||
171
yaboli/client.py
171
yaboli/client.py
|
|
@ -1,171 +0,0 @@
|
||||||
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
|
|
||||||
|
|
@ -1,384 +0,0 @@
|
||||||
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)
|
|
||||||
|
|
@ -2,570 +2,208 @@ import asyncio
|
||||||
import json
|
import json
|
||||||
import logging
|
import logging
|
||||||
import socket
|
import socket
|
||||||
from typing import Any, Awaitable, Callable, Dict, Optional
|
|
||||||
|
|
||||||
import websockets
|
import websockets
|
||||||
|
|
||||||
from .cookiejar import CookieJar
|
|
||||||
from .events import Events
|
|
||||||
from .exceptions import *
|
from .exceptions import *
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
__all__ = ["Connection"]
|
__all__ = ["Connection"]
|
||||||
|
|
||||||
# This class could probably be cleaned up by introducing one or two well-placed
|
|
||||||
# Locks – something for the next rewrite :P
|
|
||||||
|
|
||||||
class Connection:
|
class Connection:
|
||||||
"""
|
def __init__(self, url, packet_callback, disconnect_callback, cookiejar=None, ping_timeout=10, ping_delay=30, reconnect_attempts=10):
|
||||||
The Connection handles the lower-level stuff required when connecting to
|
self.url = url
|
||||||
euphoria, such as:
|
self.packet_callback = packet_callback
|
||||||
|
self.disconnect_callback = disconnect_callback
|
||||||
- Creating a websocket connection
|
self.cookiejar = cookiejar
|
||||||
- Encoding and decoding packets (json)
|
self.ping_timeout = ping_timeout # how long to wait for websocket ping reply
|
||||||
- Waiting for the server's asynchronous replies to packets
|
self.ping_delay = ping_delay # how long to wait between pings
|
||||||
- Keeping the connection alive (ping, ping-reply packets)
|
self.reconnect_attempts = reconnect_attempts
|
||||||
- Reconnecting (timeout while connecting, no pings received in some time)
|
|
||||||
|
self._ws = None
|
||||||
It doesn't respond to any events other than the ping-event and is otherwise
|
self._pid = 0 # successive packet ids
|
||||||
"dumb".
|
#self._spawned_tasks = set()
|
||||||
|
self._pending_responses = {}
|
||||||
|
|
||||||
|
self._stopped = False
|
||||||
Life cycle of a Connection:
|
self._pingtask = None
|
||||||
|
self._runtask = asyncio.ensure_future(self._run())
|
||||||
1. create connection and register event callbacks
|
# ... aaand the connection is started.
|
||||||
2. call connect()
|
|
||||||
3. send and receive packets, reconnecting automatically when connection is
|
async def send(self, ptype, data=None, await_response=True):
|
||||||
lost
|
if not self._ws:
|
||||||
4. call disconnect(), then go to 2.
|
raise ConnectionClosed
|
||||||
|
#raise asyncio.CancelledError
|
||||||
|
|
||||||
IN PHASE 1, parameters such as the url the Connection should connect to are
|
pid = str(self._new_pid())
|
||||||
set. Usually, event callbacks are also registered in this phase.
|
packet = {
|
||||||
|
"type": ptype,
|
||||||
|
"id": pid
|
||||||
IN PHASE 2, the Connection attempts to connect to the url set in phase 1.
|
}
|
||||||
If successfully connected, it fires a "connected" event.
|
if data:
|
||||||
|
packet["data"] = data
|
||||||
|
|
||||||
IN PHASE 3, the Connection listenes for packets from the server and fires
|
if await_response:
|
||||||
the corresponding events. Packets can be sent using the Connection.
|
wait_for = self._wait_for_response(pid)
|
||||||
|
|
||||||
If the Connection has to reconnect for some reason, it first fires a
|
logging.debug(f"Currently used websocket at self._ws: {self._ws}")
|
||||||
"reconnecting" event. Then it tries to reconnect until it has established a
|
await self._ws.send(json.dumps(packet, separators=(',', ':'))) # minimum size
|
||||||
connection to euphoria again. After the connection is reestablished, it
|
|
||||||
fires a "reconnected" event.
|
if await_response:
|
||||||
|
await wait_for
|
||||||
|
return wait_for.result()
|
||||||
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
|
async def stop(self):
|
||||||
until connect() is called again.
|
"""
|
||||||
|
Close websocket connection and wait for running task to stop.
|
||||||
|
|
||||||
|
No connection function are to be called after calling stop().
|
||||||
Events:
|
This means that stop() can only be called once.
|
||||||
|
"""
|
||||||
- "connected" : No arguments
|
|
||||||
- "reconnecting" : No arguments
|
self._stopped = True
|
||||||
- "reconnected" : No arguments
|
await self.reconnect() # _run() does the cleaning up now.
|
||||||
- "disconnecting" : No arguments
|
await self._runtask
|
||||||
- "<euph event name>": the packet, parsed as JSON
|
|
||||||
|
async def reconnect(self):
|
||||||
Events ending with "-ing" ("reconnecting", "disconnecting") are fired at
|
"""
|
||||||
the beginning of the process they represent. Events ending with "-ed"
|
Reconnect to the url.
|
||||||
("connected", "reconnected") are fired after the process they represent has
|
"""
|
||||||
finished.
|
|
||||||
|
if self._ws:
|
||||||
Examples for the last category of events include "message-event",
|
await self._ws.close()
|
||||||
"part-event" and "ping".
|
|
||||||
"""
|
async def _connect(self, tries):
|
||||||
|
"""
|
||||||
# Timeout for waiting for the ws connection to be established
|
Attempt to connect to a room.
|
||||||
CONNECT_TIMEOUT = 10 # seconds
|
If the Connection is already connected, it attempts to reconnect.
|
||||||
|
|
||||||
# Maximum duration between euphoria's ping messages. Euphoria usually sends
|
Returns True on success, False on failure.
|
||||||
# ping messages every 20 to 30 seconds.
|
|
||||||
PING_TIMEOUT = 40 # seconds
|
If tries is None, connect retries infinitely.
|
||||||
|
The delay between connection attempts doubles every attempt (starts with 1s).
|
||||||
# The delay between reconnect attempts.
|
"""
|
||||||
RECONNECT_DELAY = 40 # seconds
|
|
||||||
|
# Assumes _disconnect() has already been called in _run()
|
||||||
# States the Connection may be in
|
|
||||||
_NOT_RUNNING = "not running"
|
delay = 1 # seconds
|
||||||
_CONNECTING = "connecting"
|
while True:
|
||||||
_RUNNING = "running"
|
try:
|
||||||
_RECONNECTING = "reconnecting"
|
if self.cookiejar:
|
||||||
_DISCONNECTING = "disconnecting"
|
cookies = [("Cookie", cookie) for cookie in self.cookiejar.sniff()]
|
||||||
|
self._ws = await websockets.connect(self.url, max_size=None, extra_headers=cookies)
|
||||||
# Initialising
|
else:
|
||||||
|
self._ws = await websockets.connect(self.url, max_size=None)
|
||||||
def __init__(self, url: str, cookie_file: Optional[str] = None) -> None:
|
except (websockets.InvalidHandshake, socket.gaierror): # not websockets.InvalidURI
|
||||||
self._url = url
|
self._ws = None
|
||||||
self._cookie_jar = CookieJar(cookie_file)
|
|
||||||
|
if tries is not None:
|
||||||
self._events = Events()
|
tries -= 1
|
||||||
self._packet_id = 0
|
if tries <= 0:
|
||||||
|
return False
|
||||||
# This is the current status of the connection. It can be set to one of
|
|
||||||
# _NOT_RUNNING, _CONNECTING, _RUNNING, _RECONNECTING, or
|
await asyncio.sleep(delay)
|
||||||
# _DISCONNECTING.
|
delay *= 2
|
||||||
#
|
else:
|
||||||
# Always be careful to set any state-dependent variables.
|
if self.cookiejar:
|
||||||
self._state = self._NOT_RUNNING
|
for set_cookie in self._ws.response_headers.get_all("Set-Cookie"):
|
||||||
self._connected_condition = asyncio.Condition()
|
self.cookiejar.bake(set_cookie)
|
||||||
self._disconnected_condition = asyncio.Condition()
|
self.cookiejar.save()
|
||||||
|
|
||||||
self._event_loop: Optional[asyncio.Task[None]] = None
|
self._pingtask = asyncio.ensure_future(self._ping())
|
||||||
|
|
||||||
# These must always be (re)set together. If one of them is None, all
|
return True
|
||||||
# must be None.
|
|
||||||
self._ws = None
|
async def _disconnect(self):
|
||||||
self._awaiting_replies: Optional[Dict[str, asyncio.Future[Any]]] = None
|
"""
|
||||||
self._ping_check: Optional[asyncio.Task[None]] = None
|
Disconnect and clean up all "residue", such as:
|
||||||
|
- close existing websocket connection
|
||||||
self.register_event("ping-event", self._ping_pong)
|
- cancel all pending response futures with a ConnectionClosed exception
|
||||||
|
- reset package ID counter
|
||||||
def register_event(self,
|
- make sure the ping task has finished
|
||||||
event: str,
|
"""
|
||||||
callback: Callable[..., Awaitable[None]]
|
|
||||||
) -> None:
|
asyncio.ensure_future(self.disconnect_callback())
|
||||||
"""
|
|
||||||
Register an event callback.
|
# stop ping task
|
||||||
|
if self._pingtask:
|
||||||
For an overview of the possible events, see the Connection docstring.
|
self._pingtask.cancel()
|
||||||
"""
|
await self._pingtask
|
||||||
|
self._pingtask = None
|
||||||
self._events.register(event, callback)
|
|
||||||
|
if self._ws:
|
||||||
# Connecting and disconnecting
|
await self._ws.close()
|
||||||
|
self._ws = None
|
||||||
async def _disconnect(self) -> None:
|
|
||||||
"""
|
self._pid = 0
|
||||||
Disconnect _ws and clean up _ws, _awaiting_replies and _ping_check.
|
|
||||||
|
# clean up pending response futures
|
||||||
Important: The caller must ensure that this function is called in valid
|
for _, future in self._pending_responses.items():
|
||||||
circumstances and not called twice at the same time. _disconnect() does
|
logger.debug(f"Cancelling future with ConnectionClosed: {future}")
|
||||||
not check or manipulate _state.
|
future.set_exception(ConnectionClosed("No server response"))
|
||||||
"""
|
self._pending_responses = {}
|
||||||
|
|
||||||
if self._ws is not None:
|
async def _run(self):
|
||||||
logger.debug("Closing ws connection")
|
"""
|
||||||
await self._ws.close()
|
Listen for packets and deal with them accordingly.
|
||||||
|
"""
|
||||||
# Checking self._ws again since during the above await, another
|
|
||||||
# disconnect call could have finished cleaning up.
|
while not self._stopped:
|
||||||
if self._ws is None:
|
await self._connect(self.reconnect_attempts)
|
||||||
# This indicates that _ws, _awaiting_replies and _ping_check are
|
|
||||||
# cleaned up
|
try:
|
||||||
logger.debug("Ws connection already cleaned up")
|
while True:
|
||||||
return
|
await self._handle_next_message()
|
||||||
|
except websockets.ConnectionClosed:
|
||||||
logger.debug("Cancelling futures waiting for replies")
|
pass
|
||||||
for future in self._awaiting_replies.values():
|
finally:
|
||||||
future.set_exception(ConnectionClosedException())
|
await self._disconnect() # disconnect and clean up
|
||||||
|
|
||||||
logger.debug("Cancelling ping check task")
|
async def _ping(self):
|
||||||
self._ping_check.cancel()
|
"""
|
||||||
|
Periodically ping the server to detect a timeout.
|
||||||
logger.debug("Cleaning up variables")
|
"""
|
||||||
self._ws = None
|
|
||||||
self._awaiting_replies = None
|
try:
|
||||||
self._ping_check = None
|
while True:
|
||||||
|
logger.debug("Pinging...")
|
||||||
async def _connect(self) -> bool:
|
wait_for_reply = await self._ws.ping()
|
||||||
"""
|
await asyncio.wait_for(wait_for_reply, self.ping_timeout)
|
||||||
Attempts once to create a ws connection.
|
logger.debug("Pinged!")
|
||||||
|
await asyncio.sleep(self.ping_delay)
|
||||||
Important: The caller must ensure that this function is called in valid
|
except asyncio.TimeoutError:
|
||||||
circumstances and not called twice at the same time. _connect() does
|
logger.warning("Ping timed out.")
|
||||||
not check or manipulate _state, nor does it perform cleanup on
|
await self.reconnect()
|
||||||
_awaiting_replies or _ping_check.
|
except (websockets.ConnectionClosed, ConnectionResetError, asyncio.CancelledError):
|
||||||
"""
|
pass
|
||||||
|
|
||||||
try:
|
def _new_pid(self):
|
||||||
logger.debug(f"Creating ws connection to {self._url!r}")
|
self._pid += 1
|
||||||
ws = await asyncio.wait_for(
|
return self._pid
|
||||||
websockets.connect(self._url,
|
|
||||||
extra_headers=self._cookie_jar.get_cookies_as_headers()),
|
async def _handle_next_message(self):
|
||||||
self.CONNECT_TIMEOUT
|
response = await self._ws.recv()
|
||||||
)
|
packet = json.loads(response)
|
||||||
logger.debug(f"Established ws connection to {self._url!r}")
|
|
||||||
|
ptype = packet.get("type")
|
||||||
self._ws = ws
|
data = packet.get("data", None)
|
||||||
self._awaiting_replies = {}
|
error = packet.get("error", None)
|
||||||
logger.debug("Starting ping check")
|
if packet.get("throttled", False):
|
||||||
self._ping_check = asyncio.create_task(
|
throttled = packet.get("throttled_reason")
|
||||||
self._disconnect_in(self.PING_TIMEOUT))
|
else:
|
||||||
|
throttled = None
|
||||||
# Put received cookies into cookie jar
|
|
||||||
for set_cookie in ws.response_headers.get_all("Set-Cookie"):
|
# Deal with pending responses
|
||||||
self._cookie_jar.add_cookie(set_cookie)
|
pid = packet.get("id", None)
|
||||||
self._cookie_jar.save()
|
future = self._pending_responses.pop(pid, None)
|
||||||
|
if future:
|
||||||
return True
|
future.set_result((ptype, data, error, throttled))
|
||||||
|
|
||||||
except (websockets.InvalidHandshake, websockets.InvalidStatusCode,
|
# Pass packet onto room
|
||||||
OSError, asyncio.TimeoutError):
|
asyncio.ensure_future(self.packet_callback(ptype, data, error, throttled))
|
||||||
logger.debug("Connection failed")
|
|
||||||
return False
|
def _wait_for_response(self, pid):
|
||||||
|
future = asyncio.Future()
|
||||||
async def _disconnect_in(self, delay: int) -> None:
|
self._pending_responses[pid] = future
|
||||||
await asyncio.sleep(delay)
|
return future
|
||||||
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
|
|
||||||
|
|
|
||||||
|
|
@ -1,77 +1,73 @@
|
||||||
import contextlib
|
import contextlib
|
||||||
import http.cookies as cookies
|
import http.cookies as cookies
|
||||||
import logging
|
import logging
|
||||||
from typing import List, Optional, Tuple
|
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
__all__ = ["CookieJar"]
|
__all__ = ["CookieJar"]
|
||||||
|
|
||||||
|
|
||||||
class CookieJar:
|
class CookieJar:
|
||||||
"""
|
"""
|
||||||
Keeps your cookies in a file.
|
Keeps your cookies in a file.
|
||||||
|
"""
|
||||||
|
|
||||||
CookieJar doesn't attempt to discard old cookies, but that doesn't appear
|
def __init__(self, filename=None):
|
||||||
to be necessary for keeping euphoria session cookies.
|
self._filename = filename
|
||||||
"""
|
self._cookies = cookies.SimpleCookie()
|
||||||
|
|
||||||
def __init__(self, filename: Optional[str] = None) -> None:
|
if not self._filename:
|
||||||
self._filename = filename
|
logger.info("Could not load cookies, no filename given.")
|
||||||
self._cookies = cookies.SimpleCookie()
|
return
|
||||||
|
|
||||||
if not self._filename:
|
with contextlib.suppress(FileNotFoundError):
|
||||||
logger.warning("Could not load cookies, no filename given.")
|
with open(self._filename, "r") as f:
|
||||||
return
|
for line in f:
|
||||||
|
self._cookies.load(line)
|
||||||
|
|
||||||
with contextlib.suppress(FileNotFoundError):
|
def sniff(self):
|
||||||
logger.info(f"Loading cookies from {self._filename!r}")
|
"""
|
||||||
with open(self._filename, "r") as f:
|
Returns a list of Cookie headers containing all current cookies.
|
||||||
for line in f:
|
"""
|
||||||
self._cookies.load(line)
|
|
||||||
|
|
||||||
def get_cookies(self) -> List[str]:
|
return [morsel.OutputString(attrs=[]) for morsel in self._cookies.values()]
|
||||||
return [morsel.OutputString(attrs=[])
|
|
||||||
for morsel in self._cookies.values()]
|
|
||||||
|
|
||||||
def get_cookies_as_headers(self) -> List[Tuple[str, str]]:
|
def bake(self, cookie_string):
|
||||||
"""
|
"""
|
||||||
Return all stored cookies as tuples in a list. The first tuple entry is
|
Parse cookie and add it to the jar.
|
||||||
always "Cookie".
|
Does not automatically save to the cookie file.
|
||||||
"""
|
|
||||||
|
|
||||||
return [("Cookie", cookie) for cookie in self.get_cookies()]
|
Example cookie: "a=bcd; Path=/; Expires=Wed, 24 Jul 2019 14:57:52 GMT; HttpOnly; Secure"
|
||||||
|
"""
|
||||||
|
|
||||||
def add_cookie(self, cookie: str) -> None:
|
logger.debug(f"Baking cookie: {cookie_string!r}")
|
||||||
"""
|
|
||||||
Parse cookie and add it to the jar.
|
|
||||||
|
|
||||||
Example cookie: "a=bcd; Path=/; Expires=Wed, 24 Jul 2019 14:57:52 GMT;
|
self._cookies.load(cookie_string)
|
||||||
HttpOnly; Secure"
|
|
||||||
"""
|
|
||||||
|
|
||||||
logger.debug(f"Adding cookie {cookie!r}")
|
def save(self):
|
||||||
self._cookies.load(cookie)
|
"""
|
||||||
|
Saves all current cookies to the cookie jar file.
|
||||||
|
"""
|
||||||
|
|
||||||
def save(self) -> None:
|
if not self._filename:
|
||||||
"""
|
logger.info("Could not save cookies, no filename given.")
|
||||||
Saves all current cookies to the cookie jar file.
|
return
|
||||||
"""
|
|
||||||
|
|
||||||
if not self._filename:
|
logger.debug(f"Saving cookies to {self._filename!r}")
|
||||||
logger.warning("Could not save cookies, no filename given.")
|
|
||||||
return
|
|
||||||
|
|
||||||
logger.info(f"Saving cookies to {self._filename!r}")
|
with open(self._filename, "w") as f:
|
||||||
|
for morsel in self._cookies.values():
|
||||||
|
cookie_string = morsel.OutputString()
|
||||||
|
#f.write(f"{cookie_string}\n")
|
||||||
|
f.write(cookie_string)
|
||||||
|
f.write("\n")
|
||||||
|
|
||||||
with open(self._filename, "w") as f:
|
def monster(self):
|
||||||
for morsel in self._cookies.values():
|
"""
|
||||||
cookie_string = morsel.OutputString()
|
Removes all cookies from the cookie jar.
|
||||||
f.write(f"{cookie_string}\n")
|
Does not automatically save to the cookie file.
|
||||||
|
"""
|
||||||
|
|
||||||
def clear(self) -> None:
|
logger.debug("OMNOMNOM, cookies are all gone!")
|
||||||
"""
|
|
||||||
Removes all cookies from the cookie jar.
|
|
||||||
"""
|
|
||||||
|
|
||||||
logger.debug("OMNOMNOM, cookies are all gone!")
|
self._cookies = cookies.SimpleCookie()
|
||||||
self._cookies = cookies.SimpleCookie()
|
|
||||||
|
|
|
||||||
|
|
@ -1,40 +1,87 @@
|
||||||
import asyncio
|
import asyncio
|
||||||
import logging
|
from functools import wraps
|
||||||
import sqlite3
|
import sqlite3
|
||||||
from typing import Any, Awaitable, Callable, TypeVar
|
import threading
|
||||||
|
|
||||||
from .util import asyncify
|
__all__ = ["Database"]
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
|
||||||
|
|
||||||
__all__ = ["Database", "operation"]
|
|
||||||
|
|
||||||
T = TypeVar('T')
|
def shielded(afunc):
|
||||||
|
#@wraps(afunc)
|
||||||
|
async def wrapper(*args, **kwargs):
|
||||||
|
return await asyncio.shield(afunc(*args, **kwargs))
|
||||||
|
return wrapper
|
||||||
|
|
||||||
def operation(func: Callable[..., T]) -> Callable[..., Awaitable[T]]:
|
class PooledConnection:
|
||||||
async def wrapper(self: Any, *args: Any, **kwargs: Any) -> T:
|
def __init__(self, pool):
|
||||||
async with self as db:
|
self._pool = pool
|
||||||
while True:
|
|
||||||
try:
|
self.connection = None
|
||||||
return await asyncify(func, self, db, *args, **kwargs)
|
|
||||||
except sqlite3.OperationalError as e:
|
async def open(self):
|
||||||
logger.warn(f"Operational error encountered: {e}")
|
self.connection = await self._pool._request()
|
||||||
await asyncio.sleep(5)
|
|
||||||
return wrapper
|
async def close(self):
|
||||||
|
conn = self.connection
|
||||||
|
self.connection = None
|
||||||
|
await self._pool._return(conn)
|
||||||
|
|
||||||
|
async def __aenter__(self):
|
||||||
|
await self.open()
|
||||||
|
return self
|
||||||
|
|
||||||
|
async def __aexit__(self, exc_type, exc, tb):
|
||||||
|
await self.close()
|
||||||
|
|
||||||
|
class Pool:
|
||||||
|
def __init__(self, filename, size=10):
|
||||||
|
self.filename = filename
|
||||||
|
self.size = size
|
||||||
|
|
||||||
|
self._available_connections = asyncio.Queue()
|
||||||
|
|
||||||
|
for i in range(size):
|
||||||
|
conn = sqlite3.connect(self.filename, check_same_thread=False)
|
||||||
|
self._available_connections.put_nowait(conn)
|
||||||
|
|
||||||
|
def connection(self):
|
||||||
|
return PooledConnection(self)
|
||||||
|
|
||||||
|
async def _request(self):
|
||||||
|
return await self._available_connections.get()
|
||||||
|
|
||||||
|
async def _return(self, conn):
|
||||||
|
await self._available_connections.put(conn)
|
||||||
|
|
||||||
class Database:
|
class Database:
|
||||||
def __init__(self, database: str) -> None:
|
def __init__(self, filename, pool_size=10, event_loop=None):
|
||||||
self._connection = sqlite3.connect(database, check_same_thread=False)
|
self._filename = filename
|
||||||
self._lock = asyncio.Lock()
|
self._pool = Pool(filename, size=pool_size)
|
||||||
|
self._loop = event_loop or asyncio.get_event_loop()
|
||||||
self.initialize(self._connection)
|
|
||||||
|
def operation(func):
|
||||||
def initialize(self, db: Any) -> None:
|
@wraps(func)
|
||||||
pass
|
@shielded
|
||||||
|
async def wrapper(self, *args, **kwargs):
|
||||||
async def __aenter__(self) -> Any:
|
async with self._pool.connection() as conn:
|
||||||
await self._lock.__aenter__()
|
return await self._run_in_thread(func, conn.connection, *args, **kwargs)
|
||||||
return self._connection
|
return wrapper
|
||||||
|
|
||||||
async def __aexit__(self, *args: Any, **kwargs: Any) -> Any:
|
@staticmethod
|
||||||
return await self._lock.__aexit__(*args, **kwargs)
|
def _target_function(loop, future, func, *args, **kwargs):
|
||||||
|
result = None
|
||||||
|
try:
|
||||||
|
result = func(*args, **kwargs)
|
||||||
|
finally:
|
||||||
|
loop.call_soon_threadsafe(future.set_result, result)
|
||||||
|
|
||||||
|
async def _run_in_thread(self, func, *args, **kwargs):
|
||||||
|
finished = asyncio.Future()
|
||||||
|
target_args = (self._loop, finished, func, *args)
|
||||||
|
|
||||||
|
thread = threading.Thread(target=self._target_function, args=target_args, kwargs=kwargs)
|
||||||
|
thread.start()
|
||||||
|
|
||||||
|
await finished
|
||||||
|
return finished.result()
|
||||||
|
|
|
||||||
|
|
@ -1,25 +0,0 @@
|
||||||
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))
|
|
||||||
|
|
@ -1,67 +1,13 @@
|
||||||
__all__ = [
|
__all__ = ["ConnectionClosed"]
|
||||||
"EuphException",
|
|
||||||
# Connection exceptions
|
|
||||||
"IncorrectStateException",
|
|
||||||
"ConnectionClosedException",
|
|
||||||
# Joining a room
|
|
||||||
"JoinException",
|
|
||||||
"CouldNotConnectException",
|
|
||||||
"CouldNotAuthenticateException",
|
|
||||||
# Doing stuff in a room
|
|
||||||
"RoomNotConnectedException",
|
|
||||||
"EuphError",
|
|
||||||
]
|
|
||||||
|
|
||||||
class EuphException(Exception):
|
class ConnectionClosed(Exception):
|
||||||
pass
|
pass
|
||||||
|
|
||||||
# Connection exceptions
|
class RoomException(Exception):
|
||||||
|
pass
|
||||||
|
|
||||||
class IncorrectStateException(EuphException):
|
class AuthenticationRequired(RoomException):
|
||||||
"""
|
pass
|
||||||
A Connection function was called while the Connection was in the incorrect
|
|
||||||
state.
|
|
||||||
"""
|
|
||||||
pass
|
|
||||||
|
|
||||||
class ConnectionClosedException(EuphException):
|
class RoomClosed(RoomException):
|
||||||
"""
|
pass
|
||||||
The connection was closed unexpectedly.
|
|
||||||
"""
|
|
||||||
pass
|
|
||||||
|
|
||||||
# Joining a room
|
|
||||||
|
|
||||||
class JoinException(EuphException):
|
|
||||||
"""
|
|
||||||
An exception that happened while joining a room.
|
|
||||||
"""
|
|
||||||
pass
|
|
||||||
|
|
||||||
class CouldNotConnectException(JoinException):
|
|
||||||
"""
|
|
||||||
Could not establish a websocket connection to euphoria.
|
|
||||||
"""
|
|
||||||
pass
|
|
||||||
|
|
||||||
class CouldNotAuthenticateException(JoinException):
|
|
||||||
"""
|
|
||||||
The password is either incorrect or not set, even though authentication is
|
|
||||||
required.
|
|
||||||
"""
|
|
||||||
pass
|
|
||||||
|
|
||||||
# Doing stuff in a room
|
|
||||||
|
|
||||||
class RoomNotConnectedException(EuphException):
|
|
||||||
"""
|
|
||||||
Either the Room's connect() function has not been called or it has not
|
|
||||||
completed successfully.
|
|
||||||
"""
|
|
||||||
pass
|
|
||||||
|
|
||||||
class EuphError(EuphException):
|
|
||||||
"""
|
|
||||||
The euphoria server has sent back an "error" field in its response.
|
|
||||||
"""
|
|
||||||
pass
|
|
||||||
|
|
|
||||||
|
|
@ -1,173 +0,0 @@
|
||||||
import datetime
|
|
||||||
from typing import TYPE_CHECKING, Any, List, Optional
|
|
||||||
|
|
||||||
from .session import LiveSession, Session
|
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
|
||||||
from .room import Room
|
|
||||||
|
|
||||||
__all__ = ["Message", "LiveMessage"]
|
|
||||||
|
|
||||||
class Message:
|
|
||||||
def __init__(self,
|
|
||||||
room_name: str,
|
|
||||||
message_id: str,
|
|
||||||
parent_id: Optional[str],
|
|
||||||
previous_edit_id: Optional[str],
|
|
||||||
timestamp: int,
|
|
||||||
sender: Session,
|
|
||||||
content: str,
|
|
||||||
encryption_key_id: Optional[str],
|
|
||||||
edited_timestamp: Optional[int],
|
|
||||||
deleted_timestamp: Optional[int],
|
|
||||||
truncated: bool
|
|
||||||
) -> None:
|
|
||||||
self._room_name = room_name
|
|
||||||
self._message_id = message_id
|
|
||||||
self._parent_id = parent_id
|
|
||||||
self._previous_edit_id = previous_edit_id
|
|
||||||
self._timestamp = timestamp
|
|
||||||
self._sender = sender
|
|
||||||
self._content = content
|
|
||||||
self._encryption_key_id = encryption_key_id
|
|
||||||
self._edited_timestamp = edited_timestamp
|
|
||||||
self._deleted_timestamp = deleted_timestamp
|
|
||||||
self._truncated = truncated
|
|
||||||
|
|
||||||
@classmethod
|
|
||||||
def from_data(cls, room_name: str, data: Any) -> "Message":
|
|
||||||
message_id = data["id"]
|
|
||||||
parent_id = data.get("parent")
|
|
||||||
previous_edit_id = data.get("previous_edit_id")
|
|
||||||
timestamp = data["time"]
|
|
||||||
sender = Session.from_data(room_name, data["sender"])
|
|
||||||
content = data["content"]
|
|
||||||
encryption_key_id = data.get("encryption_key_id")
|
|
||||||
edited_timestamp = data.get("edited")
|
|
||||||
deleted_timestamp = data.get("deleted")
|
|
||||||
truncated = data.get("truncated", False)
|
|
||||||
|
|
||||||
return cls(room_name, message_id, parent_id, previous_edit_id,
|
|
||||||
timestamp, sender, content, encryption_key_id,
|
|
||||||
edited_timestamp, deleted_timestamp, truncated)
|
|
||||||
|
|
||||||
# Attributes
|
|
||||||
|
|
||||||
@property
|
|
||||||
def room_name(self) -> str:
|
|
||||||
return self._room_name
|
|
||||||
|
|
||||||
@property
|
|
||||||
def message_id(self) -> str:
|
|
||||||
return self._message_id
|
|
||||||
|
|
||||||
@property
|
|
||||||
def parent_id(self) -> Optional[str]:
|
|
||||||
return self._parent_id
|
|
||||||
|
|
||||||
@property
|
|
||||||
def previous_edit_id(self) -> Optional[str]:
|
|
||||||
return self._previous_edit_id
|
|
||||||
|
|
||||||
@property
|
|
||||||
def time(self) -> datetime.datetime:
|
|
||||||
return datetime.datetime.fromtimestamp(self.timestamp)
|
|
||||||
|
|
||||||
@property
|
|
||||||
def timestamp(self) -> int:
|
|
||||||
return self._timestamp
|
|
||||||
|
|
||||||
@property
|
|
||||||
def sender(self) -> Session:
|
|
||||||
return self._sender
|
|
||||||
|
|
||||||
@property
|
|
||||||
def content(self) -> str:
|
|
||||||
return self._content
|
|
||||||
|
|
||||||
@property
|
|
||||||
def encryption_key_id(self) -> Optional[str]:
|
|
||||||
return self._encryption_key_id
|
|
||||||
|
|
||||||
@property
|
|
||||||
def edited_time(self) -> Optional[datetime.datetime]:
|
|
||||||
if self.edited_timestamp is not None:
|
|
||||||
return datetime.datetime.fromtimestamp(self.edited_timestamp)
|
|
||||||
else:
|
|
||||||
return None
|
|
||||||
|
|
||||||
@property
|
|
||||||
def edited_timestamp(self) -> Optional[int]:
|
|
||||||
return self._edited_timestamp
|
|
||||||
|
|
||||||
@property
|
|
||||||
def deleted_time(self) -> Optional[datetime.datetime]:
|
|
||||||
if self.deleted_timestamp is not None:
|
|
||||||
return datetime.datetime.fromtimestamp(self.deleted_timestamp)
|
|
||||||
else:
|
|
||||||
return None
|
|
||||||
|
|
||||||
@property
|
|
||||||
def deleted_timestamp(self) -> Optional[int]:
|
|
||||||
return self._deleted_timestamp
|
|
||||||
|
|
||||||
@property
|
|
||||||
def truncated(self) -> bool:
|
|
||||||
return self._truncated
|
|
||||||
|
|
||||||
class LiveMessage(Message):
|
|
||||||
def __init__(self,
|
|
||||||
room: "Room",
|
|
||||||
message_id: str,
|
|
||||||
parent_id: Optional[str],
|
|
||||||
previous_edit_id: Optional[str],
|
|
||||||
timestamp: int,
|
|
||||||
sender: LiveSession,
|
|
||||||
content: str,
|
|
||||||
encryption_key_id: Optional[str],
|
|
||||||
edited_timestamp: Optional[int],
|
|
||||||
deleted_timestamp: Optional[int],
|
|
||||||
truncated: bool
|
|
||||||
) -> None:
|
|
||||||
super().__init__(room.name, message_id, parent_id, previous_edit_id,
|
|
||||||
timestamp, sender, content, encryption_key_id,
|
|
||||||
edited_timestamp, deleted_timestamp, truncated)
|
|
||||||
self._room = room
|
|
||||||
self._live_sender = sender
|
|
||||||
|
|
||||||
@classmethod
|
|
||||||
def from_data(cls, # type: ignore
|
|
||||||
room: "Room",
|
|
||||||
data: Any
|
|
||||||
) -> "LiveMessage":
|
|
||||||
return cls.from_message(room, Message.from_data(room.name, data))
|
|
||||||
|
|
||||||
@classmethod
|
|
||||||
def from_message(cls, room: "Room", message: Message) -> "LiveMessage":
|
|
||||||
live_sender = LiveSession.from_session(room, message.sender)
|
|
||||||
return cls(room, message.message_id, message.parent_id,
|
|
||||||
message.previous_edit_id, message.timestamp, live_sender,
|
|
||||||
message.content, message.encryption_key_id,
|
|
||||||
message.edited_timestamp, message.deleted_timestamp,
|
|
||||||
message.truncated)
|
|
||||||
|
|
||||||
# Attributes
|
|
||||||
|
|
||||||
@property
|
|
||||||
def room(self) -> "Room":
|
|
||||||
return self._room
|
|
||||||
|
|
||||||
@property
|
|
||||||
def sender(self) -> LiveSession:
|
|
||||||
return self._live_sender
|
|
||||||
|
|
||||||
# Live stuff
|
|
||||||
|
|
||||||
async def reply(self, content: str) -> "LiveMessage":
|
|
||||||
return await self.room.send(content, parent_id=self.message_id)
|
|
||||||
|
|
||||||
async def get(self) -> "LiveMessage":
|
|
||||||
return await self.room.get(self.message_id)
|
|
||||||
|
|
||||||
async def before(self, amount: int) -> List["LiveMessage"]:
|
|
||||||
return await self.room.log(amount, before_id=self.message_id)
|
|
||||||
214
yaboli/module.py
214
yaboli/module.py
|
|
@ -1,214 +0,0 @@
|
||||||
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
|
|
||||||
]
|
|
||||||
858
yaboli/room.py
858
yaboli/room.py
|
|
@ -1,562 +1,352 @@
|
||||||
import asyncio
|
import asyncio
|
||||||
import logging
|
import logging
|
||||||
from typing import Any, Awaitable, Callable, List, Optional, Tuple, TypeVar
|
import time
|
||||||
|
|
||||||
from .connection import Connection
|
from .connection import *
|
||||||
from .events import Events
|
|
||||||
from .exceptions import *
|
from .exceptions import *
|
||||||
from .message import LiveMessage
|
from .utils import *
|
||||||
from .session import Account, LiveSession, LiveSessionListing
|
|
||||||
from .util import atmention
|
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
__all__ = ["Room", "Inhabitant"]
|
||||||
|
|
||||||
__all__ = ["Room"]
|
|
||||||
|
|
||||||
T = TypeVar("T")
|
|
||||||
|
|
||||||
class Room:
|
class Room:
|
||||||
"""
|
"""
|
||||||
Events and parameters:
|
TODO
|
||||||
|
"""
|
||||||
"connected" - fired after the Room has authenticated, joined and set its
|
|
||||||
nick, meaning that now, messages can be sent
|
CONNECTED = 1
|
||||||
no parameters
|
DISCONNECTED = 2
|
||||||
|
CLOSED = 3
|
||||||
"snapshot" - snapshot of the room's messages at the time of joining
|
|
||||||
messages: List[LiveMessage]
|
def __init__(self, inhabitant, roomname, nick, password=None, human=False, cookiejar=None):
|
||||||
|
# TODO: Connect to room etc.
|
||||||
"send" - another room member has sent a message
|
# TODO: Deal with room/connection states of:
|
||||||
message: LiveMessage
|
# disconnected connecting, fast-forwarding, connected
|
||||||
|
|
||||||
"join" - somebody has joined the room
|
# Room info (all fields readonly!)
|
||||||
user: LiveSession
|
self.target_nick = nick
|
||||||
|
self.roomname = roomname
|
||||||
"part" - somebody has left the room
|
self.password = password
|
||||||
user: LiveSession
|
self.human = human
|
||||||
|
|
||||||
"nick" - another room member has changed their nick
|
self.session = None
|
||||||
user: LiveSession
|
self.account = None
|
||||||
from: str
|
self.listing = Listing()
|
||||||
to: str
|
|
||||||
|
self.start_time = time.time()
|
||||||
"edit" - a message in the room has been modified or deleted
|
|
||||||
message: LiveMessage
|
self.account_has_access = None
|
||||||
|
self.account_email_verified = None
|
||||||
"login" - this session has been logged in from another session
|
self.room_is_private = None
|
||||||
account_id: str
|
self.version = None # the version of the code being run and served by the server
|
||||||
|
self.pm_with_nick = None
|
||||||
"logout" - this session has been logged out from another session
|
self.pm_with_user_id = None
|
||||||
no parameters
|
|
||||||
|
self._inhabitant = inhabitant
|
||||||
"pm" - another session initiated a pm with you
|
self._status = Room.DISCONNECTED
|
||||||
from: str - the id of the user inviting the client to chat privately
|
self._connected_future = asyncio.Future()
|
||||||
from_nick: str - the nick of the inviting user
|
|
||||||
from_room: str - the room where the invitation was sent from
|
# TODO: Allow for all parameters of Connection() to be specified in Room().
|
||||||
pm_id: str - the private chat can be accessed at /room/pm:PMID
|
self._connection = Connection(
|
||||||
|
self.format_room_url(self.roomname, human=self.human),
|
||||||
"disconect" - corresponds to http://api.euphoria.io/#disconnect-event (if
|
self._receive_packet,
|
||||||
the reason is "authentication changed", the room automatically reconnects)
|
self._disconnected,
|
||||||
reason: str - the reason for disconnection
|
cookiejar
|
||||||
"""
|
)
|
||||||
|
|
||||||
URL_FORMAT = "wss://euphoria.io/room/{}/ws"
|
async def exit(self):
|
||||||
|
self._status = Room.CLOSED
|
||||||
def __init__(self,
|
await self._connection.stop()
|
||||||
name: str,
|
|
||||||
password: Optional[str] = None,
|
# ROOM COMMANDS
|
||||||
target_nick: str = "",
|
# These always return a response from the server.
|
||||||
url_format: str = URL_FORMAT,
|
# If the connection is lost while one of these commands is called,
|
||||||
cookie_file: Optional[str] = None,
|
# the command will retry once the bot has reconnected.
|
||||||
) -> None:
|
|
||||||
self._name = name
|
async def get_message(self, mid):
|
||||||
self._password = password
|
if self._status == Room.CLOSED:
|
||||||
self._target_nick = target_nick
|
raise RoomClosed()
|
||||||
self._url_format = url_format
|
|
||||||
|
ptype, data, error, throttled = await self._send_while_connected(
|
||||||
self._session: Optional[LiveSession] = None
|
"get-message",
|
||||||
self._account: Optional[Account] = None
|
id=mid
|
||||||
self._private: Optional[bool] = None
|
)
|
||||||
self._version: Optional[str] = None
|
|
||||||
self._users: Optional[LiveSessionListing] = None
|
return Message.from_dict(data)
|
||||||
self._pm_with_nick: Optional[str] = None
|
|
||||||
self._pm_with_user_id: Optional[str] = None
|
async def log(self, n, before_mid=None):
|
||||||
self._server_version: Optional[str] = None
|
if self._status == Room.CLOSED:
|
||||||
|
raise RoomClosed()
|
||||||
# Connected management
|
|
||||||
self._url = self._url_format.format(self._name)
|
if before_mid:
|
||||||
self._connection = Connection(self._url, cookie_file=cookie_file)
|
ptype, data, error, throttled = await self._send_while_connected(
|
||||||
self._events = Events()
|
"log",
|
||||||
|
n=n,
|
||||||
self._connected = asyncio.Event()
|
before=before_mid
|
||||||
self._connected_successfully = False
|
)
|
||||||
self._hello_received = False
|
else:
|
||||||
self._snapshot_received = False
|
ptype, data, error, throttled = await self._send_while_connected(
|
||||||
|
"log",
|
||||||
self._connection.register_event("reconnecting", self._on_reconnecting)
|
n=n
|
||||||
self._connection.register_event("hello-event", self._on_hello_event)
|
)
|
||||||
self._connection.register_event("snapshot-event", self._on_snapshot_event)
|
|
||||||
self._connection.register_event("bounce-event", self._on_bounce_event)
|
return [Message.from_dict(d) for d in data.get("log")]
|
||||||
|
|
||||||
self._connection.register_event("disconnect-event", self._on_disconnect_event)
|
async def nick(self, nick):
|
||||||
self._connection.register_event("join-event", self._on_join_event)
|
if self._status == Room.CLOSED:
|
||||||
self._connection.register_event("login-event", self._on_login_event)
|
raise RoomClosed()
|
||||||
self._connection.register_event("logout-event", self._on_logout_event)
|
|
||||||
self._connection.register_event("network-event", self._on_network_event)
|
self.target_nick = nick
|
||||||
self._connection.register_event("nick-event", self._on_nick_event)
|
ptype, data, error, throttled = await self._send_while_connected(
|
||||||
self._connection.register_event("edit-message-event", self._on_edit_message_event)
|
"nick",
|
||||||
self._connection.register_event("part-event", self._on_part_event)
|
name=nick
|
||||||
self._connection.register_event("pm-initiate-event", self._on_pm_initiate_event)
|
)
|
||||||
self._connection.register_event("send-event", self._on_send_event)
|
|
||||||
|
sid = data.get("session_id")
|
||||||
def register_event(self,
|
uid = data.get("id")
|
||||||
event: str,
|
from_nick = data.get("from")
|
||||||
callback: Callable[..., Awaitable[None]]
|
to_nick = data.get("to")
|
||||||
) -> None:
|
|
||||||
"""
|
self.session.nick = to_nick
|
||||||
Register an event callback.
|
return sid, uid, from_nick, to_nick
|
||||||
|
|
||||||
For an overview of the possible events, see the Room docstring.
|
async def pm(self, uid):
|
||||||
"""
|
if self._status == Room.CLOSED:
|
||||||
|
raise RoomClosed()
|
||||||
self._events.register(event, callback)
|
|
||||||
|
ptype, data, error, throttled = await self._send_while_connected(
|
||||||
# Connecting, reconnecting and disconnecting
|
"pm-initiate",
|
||||||
|
user_id=uid
|
||||||
async def _try_set_connected(self) -> None:
|
)
|
||||||
packets_received = self._hello_received and self._snapshot_received
|
|
||||||
if packets_received and not self._connected.is_set():
|
# Just ignoring non-authenticated errors
|
||||||
await self._set_nick_if_necessary()
|
pm_id = data.get("pm_id")
|
||||||
self._set_connected()
|
to_nick = data.get("to_nick")
|
||||||
|
return pm_id, to_nick
|
||||||
async def _set_nick_if_necessary(self) -> None:
|
|
||||||
nick_needs_updating = (self._session is None
|
async def send(self, content, parent_mid=None):
|
||||||
or self._target_nick != self._session.nick)
|
if parent_mid:
|
||||||
|
ptype, data, error, throttled = await self._send_while_connected(
|
||||||
if self._target_nick and nick_needs_updating:
|
"send",
|
||||||
await self._nick(self._target_nick)
|
content=content,
|
||||||
|
parent=parent_mid
|
||||||
def _set_connected(self) -> None:
|
)
|
||||||
self._connected_successfully = True
|
else:
|
||||||
self._connected.set()
|
ptype, data, error, throttled = await self._send_while_connected(
|
||||||
|
"send",
|
||||||
def _set_connected_failed(self) -> None:
|
content=content
|
||||||
if not self._connected.is_set():
|
)
|
||||||
self._connected_successfully = False
|
|
||||||
self._connected.set()
|
return Message.from_dict(data)
|
||||||
|
|
||||||
def _set_connected_reset(self) -> None:
|
async def who(self):
|
||||||
self._connected.clear()
|
ptype, data, error, throttled = await self._send_while_connected("who")
|
||||||
self._connected_successfully = False
|
self.listing = Listing.from_dict(data.get("listing"))
|
||||||
self._hello_received = False
|
|
||||||
self._snapshot_received = False
|
# COMMUNICATION WITH CONNECTION
|
||||||
|
|
||||||
async def _on_reconnecting(self) -> None:
|
async def _disconnected(self):
|
||||||
self._set_connected_reset()
|
# While disconnected, keep the last known session info, listing etc.
|
||||||
|
# All of this is instead reset when the hello/snapshot events are received.
|
||||||
async def _on_hello_event(self, packet: Any) -> None:
|
self.status = Room.DISCONNECTED
|
||||||
data = packet["data"]
|
self._connected_future = asyncio.Future()
|
||||||
|
|
||||||
self._session = LiveSession.from_data(self, data["session"])
|
await self._inhabitant.disconnected(self)
|
||||||
self._private = data["room_is_private"]
|
|
||||||
self._version = data["version"]
|
async def _receive_packet(self, ptype, data, error, throttled):
|
||||||
|
# Ignoring errors and throttling for now
|
||||||
if "account" in data:
|
functions = {
|
||||||
self._account = Account.from_data(data)
|
"bounce-event": self._event_bounce,
|
||||||
|
#"disconnect-event": self._event_disconnect, # Not important, can ignore
|
||||||
self._hello_received = True
|
"hello-event": self._event_hello,
|
||||||
await self._try_set_connected()
|
"join-event": self._event_join,
|
||||||
|
#"login-event": self._event_login,
|
||||||
async def _on_snapshot_event(self, packet: Any) -> None:
|
#"logout-event": self._event_logout,
|
||||||
data = packet["data"]
|
"network-event": self._event_network,
|
||||||
|
"nick-event": self._event_nick,
|
||||||
self._server_version = data["version"]
|
#"edit-message-event": self._event_edit_message,
|
||||||
self._users = LiveSessionListing.from_data(self, data["listing"])
|
"part-event": self._event_part,
|
||||||
self._pm_with_nick = data.get("pm_with_nick")
|
"ping-event": self._event_ping,
|
||||||
self._pm_with_user_id = data.get("pm_with_user_id")
|
"pm-initiate-event": self._event_pm_initiate,
|
||||||
|
"send-event": self._event_send,
|
||||||
# Update session nick
|
"snapshot-event": self._event_snapshot,
|
||||||
nick = data.get("nick")
|
}
|
||||||
if nick is not None and self._session is not None:
|
|
||||||
self._session = self.session.with_nick(nick)
|
function = functions.get(ptype)
|
||||||
|
if function:
|
||||||
|
await function(data)
|
||||||
|
|
||||||
|
async def _event_bounce(self, data):
|
||||||
|
if self.password is not None:
|
||||||
|
try:
|
||||||
|
data = {"type": passcode, "passcode": self.password}
|
||||||
|
response = await self._connection.send("auth", data=data)
|
||||||
|
rdata = response.get("data")
|
||||||
|
success = rdata.get("success")
|
||||||
|
if not success:
|
||||||
|
reason = rdata.get("reason")
|
||||||
|
raise AuthenticationRequired(f"Could not join &{self.roomname}: {reason}")
|
||||||
|
except ConnectionClosed:
|
||||||
|
pass
|
||||||
|
else:
|
||||||
|
raise AuthenticationRequired(f"&{self.roomname} is password locked but no password was given")
|
||||||
|
|
||||||
|
async def _event_hello(self, data):
|
||||||
|
self.session = Session.from_dict(data.get("session"))
|
||||||
|
self.room_is_private = data.get("room_is_private")
|
||||||
|
self.version = data.get("version")
|
||||||
|
self.account = data.get("account", None)
|
||||||
|
self.account_has_access = data.get("account_has_access", None)
|
||||||
|
self.account_email_verified = data.get("account_email_verified", None)
|
||||||
|
|
||||||
|
async def _event_join(self, data):
|
||||||
|
session = Session.from_dict(data)
|
||||||
|
self.listing.add(session)
|
||||||
|
await self._inhabitant.join(self, session)
|
||||||
|
|
||||||
|
async def _event_network(self, data):
|
||||||
|
server_id = data.get("server_id")
|
||||||
|
server_era = data.get("server_era")
|
||||||
|
|
||||||
|
sessions = self.listing.remove_combo(server_id, server_era)
|
||||||
|
for session in sessions:
|
||||||
|
await self._inhabitant.part(self, session)
|
||||||
|
|
||||||
|
async def _event_nick(self, data):
|
||||||
|
sid = data.get("session_id")
|
||||||
|
uid = data.get("user_id")
|
||||||
|
from_nick = data.get("from")
|
||||||
|
to_nick = data.get("to")
|
||||||
|
|
||||||
|
session = self.listing.by_sid(sid)
|
||||||
|
if session:
|
||||||
|
session.nick = to_nick
|
||||||
|
|
||||||
|
await self._inhabitant.nick(self, sid, uid, from_nick, to_nick)
|
||||||
|
|
||||||
|
async def _event_part(self, data):
|
||||||
|
session = Session.from_dict(data)
|
||||||
|
self.listing.remove(session.sid)
|
||||||
|
await self._inhabitant.part(self, session)
|
||||||
|
|
||||||
|
async def _event_ping(self, data):
|
||||||
|
try:
|
||||||
|
new_data = {"time": data.get("time")}
|
||||||
|
await self._connection.send( "ping-reply", data=new_data, await_response=False)
|
||||||
|
except ConnectionClosed:
|
||||||
|
pass
|
||||||
|
|
||||||
|
async def _event_pm_initiate(self, data):
|
||||||
|
from_uid = data.get("from")
|
||||||
|
from_nick = data.get("from_nick")
|
||||||
|
from_room = data.get("from_room")
|
||||||
|
pm_id = data.get("pm_id")
|
||||||
|
|
||||||
|
await self._inhabitant.pm(self, from_uid, from_nick, from_room, pm_id)
|
||||||
|
|
||||||
|
async def _event_send(self, data):
|
||||||
|
message = Message.from_dict(data)
|
||||||
|
|
||||||
|
await self._inhabitant.send(self, message)
|
||||||
|
|
||||||
|
# TODO: Figure out a way to bring fast-forwarding into this
|
||||||
|
|
||||||
|
async def _event_snapshot(self, data):
|
||||||
|
# Update listing
|
||||||
|
self.listing = Listing()
|
||||||
|
sessions = [Session.from_dict(d) for d in data.get("listing")]
|
||||||
|
for session in sessions:
|
||||||
|
self.listing.add(session)
|
||||||
|
|
||||||
|
# Update room info
|
||||||
|
self.pm_with_nick = data.get("pm_with_nick", None),
|
||||||
|
self.pm_with_user_id = data.get("pm_with_user_id", None)
|
||||||
|
self.session.nick = data.get("nick", None)
|
||||||
|
|
||||||
|
# Make sure a room is not CONNECTED without a nick
|
||||||
|
if self.target_nick and self.target_nick != self.session.nick:
|
||||||
|
try:
|
||||||
|
_, nick_data, _, _ = await self._connection.send("nick", data={"name": self.target_nick})
|
||||||
|
self.session.nick = nick_data.get("to")
|
||||||
|
except ConnectionClosed:
|
||||||
|
return # Aww, we've lost connection again
|
||||||
|
|
||||||
|
# Now, we're finally connected again!
|
||||||
|
self.status = Room.CONNECTED
|
||||||
|
if not self._connected_future.done(): # Should never be done already, I think
|
||||||
|
self._connected_future.set_result(None)
|
||||||
|
|
||||||
|
# Let's let the inhabitant know.
|
||||||
|
logger.debug("Letting inhabitant know")
|
||||||
|
log = [Message.from_dict(m) for m in data.get("log")]
|
||||||
|
await self._inhabitant.connected(self, log)
|
||||||
|
|
||||||
|
# TODO: Figure out a way to bring fast-forwarding into this
|
||||||
|
# Should probably happen where this comment is
|
||||||
|
|
||||||
|
# SOME USEFUL PUBLIC METHODS
|
||||||
|
|
||||||
# Send "snapshot" event
|
@staticmethod
|
||||||
messages = [LiveMessage.from_data(self, msg_data)
|
def format_room_url(roomname, private=False, human=False):
|
||||||
for msg_data in data["log"]]
|
if private:
|
||||||
self._events.fire("snapshot", messages)
|
roomname = f"pm:{roomname}"
|
||||||
|
|
||||||
self._snapshot_received = True
|
url = f"wss://euphoria.io/room/{roomname}/ws"
|
||||||
await self._try_set_connected()
|
|
||||||
|
|
||||||
async def _on_bounce_event(self, packet: Any) -> None:
|
if human:
|
||||||
data = packet["data"]
|
url = f"{url}?h=1"
|
||||||
|
|
||||||
# Can we even authenticate? (Assuming that passcode authentication is
|
return url
|
||||||
# available if no authentication options are given: Euphoria doesn't
|
|
||||||
# (always) send authentication options, even when passcode
|
|
||||||
# authentication works.)
|
|
||||||
if not "passcode" in data.get("auth_options", ["passcode"]):
|
|
||||||
self._set_connected_failed()
|
|
||||||
return
|
|
||||||
|
|
||||||
# If so, do we have a password?
|
async def connected(self):
|
||||||
if self._password is None:
|
await self._connected_future
|
||||||
self._set_connected_failed()
|
|
||||||
return
|
|
||||||
|
|
||||||
reply = await self._connection.send(
|
# REST OF THE IMPLEMENTATION
|
||||||
"auth",
|
|
||||||
{"type": "passcode", "passcode": self._password}
|
|
||||||
)
|
|
||||||
|
|
||||||
if not reply["data"]["success"]:
|
async def _send_while_connected(self, *args, **kwargs):
|
||||||
self._set_connected_failed()
|
while True:
|
||||||
|
if self._status == Room.CLOSED:
|
||||||
|
raise RoomClosed()
|
||||||
|
|
||||||
async def connect(self) -> bool:
|
try:
|
||||||
"""
|
await self.connected()
|
||||||
Attempt to connect to the room and start handling events.
|
return await self._connection.send(*args, data=kwargs)
|
||||||
|
except ConnectionClosed:
|
||||||
|
pass # just try again
|
||||||
|
|
||||||
This function returns once the Room is fully connected, i. e.
|
|
||||||
authenticated, using the correct nick and able to post messages.
|
|
||||||
"""
|
|
||||||
|
|
||||||
if not await self._connection.connect():
|
class Inhabitant:
|
||||||
return False
|
"""
|
||||||
|
TODO
|
||||||
|
"""
|
||||||
|
|
||||||
await self._connected.wait()
|
# ROOM EVENTS
|
||||||
if not self._connected_successfully:
|
# These functions are called by the room when something happens.
|
||||||
return False
|
# They're launched via asyncio.ensure_future(), so they don't block execution of the room.
|
||||||
|
# Just overwrite the events you need (make sure to keep the arguments the same though).
|
||||||
|
|
||||||
self._events.fire("connected")
|
async def disconnected(self, room):
|
||||||
return True
|
pass
|
||||||
|
|
||||||
async def disconnect(self) -> None:
|
async def connected(self, room, log):
|
||||||
"""
|
pass
|
||||||
Disconnect from the room and stop the Room.
|
|
||||||
|
|
||||||
This function has the potential to mess things up, and it has not yet
|
async def join(self, room, session):
|
||||||
been tested thoroughly. Use at your own risk, especially if you want to
|
pass
|
||||||
call connect() after calling disconnect().
|
|
||||||
"""
|
|
||||||
|
|
||||||
self._set_connected_reset()
|
async def part(self, room, session):
|
||||||
await self._connection.disconnect()
|
pass
|
||||||
|
|
||||||
# Other events
|
async def nick(self, room, sid, uid, from_nick, to_nick):
|
||||||
|
pass
|
||||||
|
|
||||||
async def _on_disconnect_event(self, packet: Any) -> None:
|
async def send(self, room, message):
|
||||||
reason = packet["data"]["reason"]
|
pass
|
||||||
|
|
||||||
if reason == "authentication changed":
|
async def fast_forward(self, room, message):
|
||||||
await self._connection.reconnect()
|
pass
|
||||||
|
|
||||||
self._events.fire("disconnect", reason)
|
async def pm(self, room, from_uid, from_nick, from_room, pm_id):
|
||||||
|
pass
|
||||||
async def _on_join_event(self, packet: Any) -> None:
|
|
||||||
data = packet["data"]
|
|
||||||
|
|
||||||
session = LiveSession.from_data(self, data)
|
|
||||||
self._users = self.users.with_join(session)
|
|
||||||
|
|
||||||
logger.info(f"&{self.name}: {session.atmention} joined")
|
|
||||||
self._events.fire("join", session)
|
|
||||||
|
|
||||||
async def _on_login_event(self, packet: Any) -> None:
|
|
||||||
"""
|
|
||||||
Just reconnect, see
|
|
||||||
https://github.com/euphoria-io/heim/blob/master/client/lib/stores/chat.js#L275-L276
|
|
||||||
"""
|
|
||||||
|
|
||||||
data = packet["data"]
|
|
||||||
|
|
||||||
account_id = data["account_id"]
|
|
||||||
|
|
||||||
self._events.fire("login", account_id)
|
|
||||||
logger.info(f"&{self.name}: Got logged in to {account_id}, reconnecting")
|
|
||||||
|
|
||||||
await self._connection.reconnect()
|
|
||||||
|
|
||||||
async def _on_logout_event(self, packet: Any) -> None:
|
|
||||||
"""
|
|
||||||
Just reconnect, see
|
|
||||||
https://github.com/euphoria-io/heim/blob/master/client/lib/stores/chat.js#L275-L276
|
|
||||||
"""
|
|
||||||
|
|
||||||
self._events.fire("logout")
|
|
||||||
logger.info(f"&{self.name}: Got logged out, reconnecting")
|
|
||||||
|
|
||||||
await self._connection.reconnect()
|
|
||||||
|
|
||||||
async def _on_network_event(self, packet: Any) -> None:
|
|
||||||
data = packet["data"]
|
|
||||||
|
|
||||||
if data["type"] == "partition":
|
|
||||||
server_id = data["server_id"]
|
|
||||||
server_era = data["server_era"]
|
|
||||||
|
|
||||||
users = self.users
|
|
||||||
|
|
||||||
for user in self.users:
|
|
||||||
if user.server_id == server_id and user.server_era == server_era:
|
|
||||||
users = users.with_part(user)
|
|
||||||
logger.info(f"&{self.name}: {user.atmention} left")
|
|
||||||
self._events.fire("part", user)
|
|
||||||
|
|
||||||
self._users = users
|
|
||||||
|
|
||||||
async def _on_nick_event(self, packet: Any) -> None:
|
|
||||||
data = packet["data"]
|
|
||||||
session_id = data["session_id"]
|
|
||||||
nick_from = data["from"]
|
|
||||||
nick_to = data["to"]
|
|
||||||
|
|
||||||
session = self.users.get(session_id)
|
|
||||||
if session is not None:
|
|
||||||
self._users = self.users.with_nick(session, nick_to)
|
|
||||||
else:
|
|
||||||
await self.who() # recalibrating self._users
|
|
||||||
|
|
||||||
logger.info(f"&{self.name}: {atmention(nick_from)} is now called {atmention(nick_to)}")
|
|
||||||
self._events.fire("nick", session, nick_from, nick_to)
|
|
||||||
|
|
||||||
async def _on_edit_message_event(self, packet: Any) -> None:
|
|
||||||
data = packet["data"]
|
|
||||||
|
|
||||||
message = LiveMessage.from_data(self, data)
|
|
||||||
|
|
||||||
self._events.fire("edit", message)
|
|
||||||
|
|
||||||
async def _on_part_event(self, packet: Any) -> None:
|
|
||||||
data = packet["data"]
|
|
||||||
|
|
||||||
session = LiveSession.from_data(self, data)
|
|
||||||
self._users = self.users.with_part(session)
|
|
||||||
|
|
||||||
logger.info(f"&{self.name}: {session.atmention} left")
|
|
||||||
self._events.fire("part", session)
|
|
||||||
|
|
||||||
async def _on_pm_initiate_event(self, packet: Any) -> None:
|
|
||||||
data = packet["data"]
|
|
||||||
from_id = data["from"]
|
|
||||||
from_nick = data["from_nick"]
|
|
||||||
from_room = data["from_room"]
|
|
||||||
pm_id = data["pm_id"]
|
|
||||||
|
|
||||||
self._events.fire("pm", from_id, from_nick, from_room, pm_id)
|
|
||||||
|
|
||||||
async def _on_send_event(self, packet: Any) -> None:
|
|
||||||
data = packet["data"]
|
|
||||||
|
|
||||||
message = LiveMessage.from_data(self, data)
|
|
||||||
|
|
||||||
self._events.fire("send", message)
|
|
||||||
|
|
||||||
# Attributes, ordered the same as in __init__
|
|
||||||
|
|
||||||
def _wrap_optional(self, x: Optional[T]) -> T:
|
|
||||||
if x is None:
|
|
||||||
raise RoomNotConnectedException()
|
|
||||||
|
|
||||||
return x
|
|
||||||
|
|
||||||
@property
|
|
||||||
def name(self) -> str:
|
|
||||||
return self._name
|
|
||||||
|
|
||||||
@property
|
|
||||||
def password(self) -> Optional[str]:
|
|
||||||
return self._password
|
|
||||||
|
|
||||||
@property
|
|
||||||
def target_nick(self) -> str:
|
|
||||||
return self._target_nick
|
|
||||||
|
|
||||||
@property
|
|
||||||
def url_format(self) -> str:
|
|
||||||
return self._url_format
|
|
||||||
|
|
||||||
@property
|
|
||||||
def session(self) -> LiveSession:
|
|
||||||
return self._wrap_optional(self._session)
|
|
||||||
|
|
||||||
@property
|
|
||||||
def account(self) -> Account:
|
|
||||||
return self._wrap_optional(self._account)
|
|
||||||
|
|
||||||
@property
|
|
||||||
def private(self) -> bool:
|
|
||||||
return self._wrap_optional(self._private)
|
|
||||||
|
|
||||||
@property
|
|
||||||
def version(self) -> str:
|
|
||||||
return self._wrap_optional(self._version)
|
|
||||||
|
|
||||||
@property
|
|
||||||
def users(self) -> LiveSessionListing:
|
|
||||||
return self._wrap_optional(self._users)
|
|
||||||
|
|
||||||
@property
|
|
||||||
def pm_with_nick(self) -> str:
|
|
||||||
return self._wrap_optional(self._pm_with_nick)
|
|
||||||
|
|
||||||
@property
|
|
||||||
def pm_with_user_id(self) -> str:
|
|
||||||
return self._wrap_optional(self._pm_with_user_id)
|
|
||||||
|
|
||||||
@property
|
|
||||||
def url(self) -> str:
|
|
||||||
return self._url
|
|
||||||
|
|
||||||
# Functionality
|
|
||||||
|
|
||||||
def _extract_data(self, packet: Any) -> Any:
|
|
||||||
error = packet.get("error")
|
|
||||||
if error is not None:
|
|
||||||
raise EuphError(error)
|
|
||||||
|
|
||||||
return packet["data"]
|
|
||||||
|
|
||||||
async def _ensure_connected(self) -> None:
|
|
||||||
await self._connected.wait()
|
|
||||||
|
|
||||||
if not self._connected_successfully:
|
|
||||||
raise RoomNotConnectedException()
|
|
||||||
|
|
||||||
async def send(self,
|
|
||||||
content: str,
|
|
||||||
parent_id: Optional[str] = None
|
|
||||||
) -> LiveMessage:
|
|
||||||
await self._ensure_connected()
|
|
||||||
|
|
||||||
data = {"content": content}
|
|
||||||
if parent_id is not None:
|
|
||||||
data["parent"] = parent_id
|
|
||||||
|
|
||||||
reply = await self._connection.send("send", data)
|
|
||||||
data = self._extract_data(reply)
|
|
||||||
|
|
||||||
return LiveMessage.from_data(self, data)
|
|
||||||
|
|
||||||
async def _nick(self, nick: str) -> str:
|
|
||||||
"""
|
|
||||||
This function implements all of the nick-setting logic except waiting
|
|
||||||
for the room to actually connect. This is because connect() actually
|
|
||||||
uses this function to set the desired nick before the room is
|
|
||||||
connected.
|
|
||||||
"""
|
|
||||||
|
|
||||||
logger.debug(f"Setting nick to {nick!r}")
|
|
||||||
|
|
||||||
self._target_nick = nick
|
|
||||||
|
|
||||||
reply = await self._connection.send("nick", {"name": nick})
|
|
||||||
data = self._extract_data(reply)
|
|
||||||
|
|
||||||
new_nick = data["to"]
|
|
||||||
self._target_nick = new_nick
|
|
||||||
|
|
||||||
if self._session is not None:
|
|
||||||
self._session = self._session.with_nick(new_nick)
|
|
||||||
|
|
||||||
logger.debug(f"Set nick to {new_nick!r}")
|
|
||||||
|
|
||||||
return new_nick
|
|
||||||
|
|
||||||
async def nick(self, nick: str) -> str:
|
|
||||||
await self._ensure_connected()
|
|
||||||
|
|
||||||
return await self._nick(nick)
|
|
||||||
|
|
||||||
async def get(self, message_id: str) -> LiveMessage:
|
|
||||||
await self._ensure_connected()
|
|
||||||
|
|
||||||
reply = await self._connection.send("get-message", {"id": message_id})
|
|
||||||
data = self._extract_data(reply)
|
|
||||||
|
|
||||||
return LiveMessage.from_data(self, data)
|
|
||||||
|
|
||||||
async def log(self,
|
|
||||||
amount: int,
|
|
||||||
before_id: Optional[str] = None
|
|
||||||
) -> List[LiveMessage]:
|
|
||||||
await self._ensure_connected()
|
|
||||||
|
|
||||||
data: Any = {"n": amount}
|
|
||||||
if before_id is not None:
|
|
||||||
data["before"] = before_id
|
|
||||||
|
|
||||||
reply = await self._connection.send("log", data)
|
|
||||||
data = self._extract_data(reply)
|
|
||||||
|
|
||||||
messages = [LiveMessage.from_data(self, msg_data)
|
|
||||||
for msg_data in data["log"]]
|
|
||||||
return messages
|
|
||||||
|
|
||||||
async def who(self) -> LiveSessionListing:
|
|
||||||
await self._ensure_connected()
|
|
||||||
|
|
||||||
reply = await self._connection.send("who", {})
|
|
||||||
data = self._extract_data(reply)
|
|
||||||
|
|
||||||
users = LiveSessionListing.from_data(self, data["listing"])
|
|
||||||
# Assumes that self._session is set (we're connected)
|
|
||||||
session = users.get(self.session.session_id)
|
|
||||||
if session is not None:
|
|
||||||
self._session = session
|
|
||||||
self._users = users.with_part(self._session)
|
|
||||||
else:
|
|
||||||
self._users = users
|
|
||||||
|
|
||||||
return self._users
|
|
||||||
|
|
||||||
async def login(self, email: str, password: str) -> Tuple[bool, str]:
|
|
||||||
"""
|
|
||||||
Since euphoria appears to only support email authentication, this way
|
|
||||||
of logging in is hardcoded here.
|
|
||||||
|
|
||||||
Returns whether the login was successful. If it was, the second
|
|
||||||
parameter is the account id. If it wasn't, the second parameter is the
|
|
||||||
reason why the login failed.
|
|
||||||
"""
|
|
||||||
|
|
||||||
data: Any = {
|
|
||||||
"namespace": "email",
|
|
||||||
"id": email,
|
|
||||||
"password": password,
|
|
||||||
}
|
|
||||||
|
|
||||||
reply = await self._connection.send("login", data)
|
|
||||||
data = self._extract_data(reply)
|
|
||||||
|
|
||||||
success: bool = data["success"]
|
|
||||||
account_id_or_reason = data.get("account_id") or data["reason"]
|
|
||||||
|
|
||||||
if success:
|
|
||||||
logger.info(f"&{self.name}: Logged in as {account_id_or_reason}")
|
|
||||||
else:
|
|
||||||
logger.info(f"&{self.name}: Failed to log in with {email} because {account_id_or_reason}")
|
|
||||||
|
|
||||||
await self._connection.reconnect()
|
|
||||||
|
|
||||||
return success, account_id_or_reason
|
|
||||||
|
|
||||||
async def logout(self) -> None:
|
|
||||||
await self._connection.send("logout", {})
|
|
||||||
|
|
||||||
logger.info(f"&{self.name}: Logged out")
|
|
||||||
|
|
||||||
await self._connection.reconnect()
|
|
||||||
|
|
||||||
async def pm(self, user_id: str) -> Tuple[str, str]:
|
|
||||||
"""
|
|
||||||
Returns the pm_id of the pm and the nick of the person being pinged.
|
|
||||||
"""
|
|
||||||
|
|
||||||
data = {"user_id": user_id}
|
|
||||||
|
|
||||||
reply = await self._connection.send("pm-initiate", data)
|
|
||||||
data = self._extract_data(reply)
|
|
||||||
|
|
||||||
pm_id = data["pm_id"]
|
|
||||||
to_nick = data["to_nick"]
|
|
||||||
return pm_id, to_nick
|
|
||||||
|
|
|
||||||
|
|
@ -1,324 +0,0 @@
|
||||||
import re
|
|
||||||
from typing import (TYPE_CHECKING, Any, Dict, Iterable, Iterator, List,
|
|
||||||
Optional, Tuple)
|
|
||||||
|
|
||||||
from .util import mention, normalize
|
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
|
||||||
from .room import Room
|
|
||||||
|
|
||||||
__all__ = ["Account", "Session", "LiveSession", "LiveSessionListing"]
|
|
||||||
|
|
||||||
class Account:
|
|
||||||
"""
|
|
||||||
This class represents a http://api.euphoria.io/#personalaccountview, with a
|
|
||||||
few added fields stolen from the hello-event (see
|
|
||||||
http://api.euphoria.io/#hello-event).
|
|
||||||
"""
|
|
||||||
|
|
||||||
def __init__(self,
|
|
||||||
account_id: str,
|
|
||||||
name: str,
|
|
||||||
email: str,
|
|
||||||
has_access: Optional[bool],
|
|
||||||
email_verified: Optional[bool]
|
|
||||||
) -> None:
|
|
||||||
self._account_id = account_id
|
|
||||||
self._name = name
|
|
||||||
self._email = email
|
|
||||||
self._has_access = has_access
|
|
||||||
self._email_verified = email_verified
|
|
||||||
|
|
||||||
@classmethod
|
|
||||||
def from_data(cls, data: Any) -> "Account":
|
|
||||||
"""
|
|
||||||
The data parameter must be the "data" part of a hello-event.
|
|
||||||
|
|
||||||
If, in the future, a PersonalAccountView appears in other places, this
|
|
||||||
function might have to be changed.
|
|
||||||
"""
|
|
||||||
|
|
||||||
view = data["account"]
|
|
||||||
|
|
||||||
account_id = view["id"]
|
|
||||||
name = view["name"]
|
|
||||||
email = view["email"]
|
|
||||||
|
|
||||||
has_access = data.get("account_has_access")
|
|
||||||
email_verified = data.get("account_email_verified")
|
|
||||||
|
|
||||||
return cls(account_id, name, email, has_access, email_verified)
|
|
||||||
|
|
||||||
# Attributes
|
|
||||||
|
|
||||||
@property
|
|
||||||
def account_id(self) -> str:
|
|
||||||
return self._account_id
|
|
||||||
|
|
||||||
@property
|
|
||||||
def name(self) -> str:
|
|
||||||
return self._name
|
|
||||||
|
|
||||||
@property
|
|
||||||
def email(self) -> str:
|
|
||||||
return self._email
|
|
||||||
|
|
||||||
@property
|
|
||||||
def has_access(self) -> Optional[bool]:
|
|
||||||
return self._has_access
|
|
||||||
|
|
||||||
@property
|
|
||||||
def email_verified(self) -> Optional[bool]:
|
|
||||||
return self._email_verified
|
|
||||||
|
|
||||||
class Session:
|
|
||||||
_ID_SPLIT_RE = re.compile(r"(agent|account|bot):(.*)")
|
|
||||||
|
|
||||||
def __init__(self,
|
|
||||||
room_name: str,
|
|
||||||
user_id: str,
|
|
||||||
nick: str,
|
|
||||||
server_id: str,
|
|
||||||
server_era: str,
|
|
||||||
session_id: str,
|
|
||||||
is_staff: bool,
|
|
||||||
is_manager: bool,
|
|
||||||
client_address: Optional[str]
|
|
||||||
) -> None:
|
|
||||||
self._room_name = room_name
|
|
||||||
self._user_id = user_id
|
|
||||||
|
|
||||||
self._id_type: Optional[str]
|
|
||||||
match = self._ID_SPLIT_RE.fullmatch(self._user_id)
|
|
||||||
if match is not None:
|
|
||||||
self._id_type = match.group(1)
|
|
||||||
else:
|
|
||||||
self._id_type = None
|
|
||||||
|
|
||||||
self._nick = nick
|
|
||||||
self._server_id = server_id
|
|
||||||
self._server_era = server_era
|
|
||||||
self._session_id = session_id
|
|
||||||
self._is_staff = is_staff
|
|
||||||
self._is_manager = is_manager
|
|
||||||
self._client_address = client_address
|
|
||||||
|
|
||||||
def _copy(self) -> "Session":
|
|
||||||
return Session(self.room_name, self.user_id, self.nick, self.server_id,
|
|
||||||
self.server_era, self.session_id, self.is_staff,
|
|
||||||
self.is_manager, self.client_address)
|
|
||||||
|
|
||||||
@classmethod
|
|
||||||
def from_data(cls, room_name: str, data: Any) -> "Session":
|
|
||||||
user_id = data["id"]
|
|
||||||
nick = data["name"]
|
|
||||||
server_id = data["server_id"]
|
|
||||||
server_era = data["server_era"]
|
|
||||||
session_id = data["session_id"]
|
|
||||||
is_staff = data.get("is_staff", False)
|
|
||||||
is_manager = data.get("is_manager", False)
|
|
||||||
client_address = data.get("client_address")
|
|
||||||
|
|
||||||
return cls(room_name, user_id, nick, server_id, server_era, session_id,
|
|
||||||
is_staff, is_manager, client_address)
|
|
||||||
|
|
||||||
def with_nick(self, nick: str) -> "Session":
|
|
||||||
copy = self._copy()
|
|
||||||
copy._nick = nick
|
|
||||||
return copy
|
|
||||||
|
|
||||||
# Attributes
|
|
||||||
|
|
||||||
@property
|
|
||||||
def room_name(self) -> str:
|
|
||||||
return self._room_name
|
|
||||||
|
|
||||||
@property
|
|
||||||
def user_id(self) -> str:
|
|
||||||
return self._user_id
|
|
||||||
|
|
||||||
@property
|
|
||||||
def nick(self) -> str:
|
|
||||||
return self._nick
|
|
||||||
|
|
||||||
@property
|
|
||||||
def server_id(self) -> str:
|
|
||||||
return self._server_id
|
|
||||||
|
|
||||||
@property
|
|
||||||
def server_era(self) -> str:
|
|
||||||
return self._server_era
|
|
||||||
|
|
||||||
@property
|
|
||||||
def session_id(self) -> str:
|
|
||||||
return self._session_id
|
|
||||||
|
|
||||||
@property
|
|
||||||
def is_staff(self) -> bool:
|
|
||||||
return self._is_staff
|
|
||||||
|
|
||||||
@property
|
|
||||||
def is_manager(self) -> bool:
|
|
||||||
return self._is_manager
|
|
||||||
|
|
||||||
@property
|
|
||||||
def client_address(self) -> Optional[str]:
|
|
||||||
return self._client_address
|
|
||||||
|
|
||||||
@property
|
|
||||||
def mention(self) -> str:
|
|
||||||
return mention(self.nick, ping=False)
|
|
||||||
|
|
||||||
@property
|
|
||||||
def atmention(self) -> str:
|
|
||||||
return mention(self.nick, ping=True)
|
|
||||||
|
|
||||||
@property
|
|
||||||
def normalize(self) -> str:
|
|
||||||
return normalize(self.nick)
|
|
||||||
|
|
||||||
@property
|
|
||||||
def is_person(self) -> bool:
|
|
||||||
return self._id_type is None or self._id_type in ["agent", "account"]
|
|
||||||
|
|
||||||
@property
|
|
||||||
def is_agent(self) -> bool:
|
|
||||||
return self._id_type == "agent"
|
|
||||||
|
|
||||||
@property
|
|
||||||
def is_account(self) -> bool:
|
|
||||||
return self._id_type == "account"
|
|
||||||
|
|
||||||
@property
|
|
||||||
def is_bot(self) -> bool:
|
|
||||||
return self._id_type == "bot"
|
|
||||||
|
|
||||||
class LiveSession(Session):
|
|
||||||
def __init__(self,
|
|
||||||
room: "Room",
|
|
||||||
user_id: str,
|
|
||||||
nick: str,
|
|
||||||
server_id: str,
|
|
||||||
server_era: str,
|
|
||||||
session_id: str,
|
|
||||||
is_staff: bool,
|
|
||||||
is_manager: bool,
|
|
||||||
client_address: Optional[str]
|
|
||||||
) -> None:
|
|
||||||
super().__init__(room.name, user_id, nick, server_id, server_era,
|
|
||||||
session_id, is_staff, is_manager, client_address)
|
|
||||||
self._room = room
|
|
||||||
|
|
||||||
def _copy(self) -> "LiveSession":
|
|
||||||
return self.from_session(self._room, self)
|
|
||||||
|
|
||||||
# Ignoring the type discrepancy since it is more convenient this way
|
|
||||||
@classmethod
|
|
||||||
def from_data(cls, # type: ignore
|
|
||||||
room: "Room",
|
|
||||||
data: Any
|
|
||||||
) -> "LiveSession":
|
|
||||||
return cls.from_session(room, Session.from_data(room.name, data))
|
|
||||||
|
|
||||||
@classmethod
|
|
||||||
def from_session(cls, room: "Room", session: Session) -> "LiveSession":
|
|
||||||
return cls(room, session.user_id, session.nick, session.server_id,
|
|
||||||
session.server_era, session.session_id, session.is_staff,
|
|
||||||
session.is_manager, session.client_address)
|
|
||||||
|
|
||||||
def with_nick(self, nick: str) -> "LiveSession":
|
|
||||||
copy = self._copy()
|
|
||||||
copy._nick = nick
|
|
||||||
return copy
|
|
||||||
|
|
||||||
# Attributes
|
|
||||||
|
|
||||||
@property
|
|
||||||
def room(self) -> "Room":
|
|
||||||
return self._room
|
|
||||||
|
|
||||||
# Live stuff
|
|
||||||
|
|
||||||
async def pm(self) -> Tuple[str, str]:
|
|
||||||
"""
|
|
||||||
See Room.pm
|
|
||||||
"""
|
|
||||||
|
|
||||||
return await self.room.pm(self.user_id)
|
|
||||||
|
|
||||||
class LiveSessionListing:
|
|
||||||
def __init__(self, room: "Room", sessions: Iterable[LiveSession]) -> None:
|
|
||||||
self._room = room
|
|
||||||
# just to make sure it doesn't get changed on us
|
|
||||||
self._sessions: Dict[str, LiveSession] = {session.session_id: session
|
|
||||||
for session in sessions}
|
|
||||||
|
|
||||||
def __iter__(self) -> Iterator[LiveSession]:
|
|
||||||
return self._sessions.values().__iter__()
|
|
||||||
|
|
||||||
def _copy(self) -> "LiveSessionListing":
|
|
||||||
return LiveSessionListing(self.room, self)
|
|
||||||
|
|
||||||
@classmethod
|
|
||||||
def from_data(cls,
|
|
||||||
room: "Room",
|
|
||||||
data: Any,
|
|
||||||
exclude_id: Optional[str] = None
|
|
||||||
) -> "LiveSessionListing":
|
|
||||||
sessions = [LiveSession.from_data(room, subdata) for subdata in data]
|
|
||||||
|
|
||||||
if exclude_id:
|
|
||||||
sessions = [session for session in sessions
|
|
||||||
if session.session_id != exclude_id]
|
|
||||||
|
|
||||||
return cls(room, sessions)
|
|
||||||
|
|
||||||
def get(self, session_id: str) -> Optional[LiveSession]:
|
|
||||||
return self._sessions.get(session_id)
|
|
||||||
|
|
||||||
def with_join(self, session: LiveSession) -> "LiveSessionListing":
|
|
||||||
copy = self._copy()
|
|
||||||
copy._sessions[session.session_id] = session
|
|
||||||
return copy
|
|
||||||
|
|
||||||
def with_part(self, session: LiveSession) -> "LiveSessionListing":
|
|
||||||
copy = self._copy()
|
|
||||||
|
|
||||||
if session.session_id in copy._sessions:
|
|
||||||
del copy._sessions[session.session_id]
|
|
||||||
|
|
||||||
return copy
|
|
||||||
|
|
||||||
def with_nick(self,
|
|
||||||
session: LiveSession,
|
|
||||||
new_nick: str
|
|
||||||
) -> "LiveSessionListing":
|
|
||||||
copy = self._copy()
|
|
||||||
copy._sessions[session.session_id] = session.with_nick(new_nick)
|
|
||||||
return copy
|
|
||||||
|
|
||||||
# Attributes
|
|
||||||
|
|
||||||
@property
|
|
||||||
def room(self) -> "Room":
|
|
||||||
return self._room
|
|
||||||
|
|
||||||
@property
|
|
||||||
def all(self) -> List[LiveSession]:
|
|
||||||
return list(self._sessions.values())
|
|
||||||
|
|
||||||
@property
|
|
||||||
def people(self) -> List[LiveSession]:
|
|
||||||
return [session for session in self if session.is_person]
|
|
||||||
|
|
||||||
@property
|
|
||||||
def accounts(self) -> List[LiveSession]:
|
|
||||||
return [session for session in self if session.is_account]
|
|
||||||
|
|
||||||
@property
|
|
||||||
def agents(self) -> List[LiveSession]:
|
|
||||||
return [session for session in self if session.is_agent]
|
|
||||||
|
|
||||||
@property
|
|
||||||
def bots(self) -> List[LiveSession]:
|
|
||||||
return [session for session in self if session.is_bot]
|
|
||||||
|
|
@ -1,73 +0,0 @@
|
||||||
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
|
|
||||||
203
yaboli/utils.py
Normal file
203
yaboli/utils.py
Normal file
|
|
@ -0,0 +1,203 @@
|
||||||
|
import asyncio
|
||||||
|
import logging
|
||||||
|
import time
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
__all__ = [
|
||||||
|
"parallel",
|
||||||
|
"mention", "mention_reduced", "similar",
|
||||||
|
"format_time", "format_time_delta",
|
||||||
|
"Session", "Listing", "Message",
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
# alias for parallel message sending
|
||||||
|
parallel = asyncio.ensure_future
|
||||||
|
|
||||||
|
def mention(nick):
|
||||||
|
return "".join(c for c in nick if c not in ".!?;&<'\"" and not c.isspace())
|
||||||
|
|
||||||
|
def mention_reduced(nick):
|
||||||
|
return mention(nick).lower()
|
||||||
|
|
||||||
|
def similar(nick1, nick2):
|
||||||
|
return mention_reduced(nick1) == mention_reduced(nick2)
|
||||||
|
|
||||||
|
def format_time(timestamp):
|
||||||
|
return time.strftime(
|
||||||
|
"%Y-%m-%d %H:%M:%S UTC",
|
||||||
|
time.gmtime(timestamp)
|
||||||
|
)
|
||||||
|
|
||||||
|
def format_time_delta(delta):
|
||||||
|
if delta < 0:
|
||||||
|
result = "-"
|
||||||
|
else:
|
||||||
|
result = ""
|
||||||
|
|
||||||
|
delta = int(delta)
|
||||||
|
|
||||||
|
second = 1
|
||||||
|
minute = second*60
|
||||||
|
hour = minute*60
|
||||||
|
day = hour*24
|
||||||
|
|
||||||
|
if delta >= day:
|
||||||
|
result += f"{delta//day}d "
|
||||||
|
delta = delta%day
|
||||||
|
|
||||||
|
if delta >= hour:
|
||||||
|
result += f"{delta//hour}h "
|
||||||
|
delta = delta%hour
|
||||||
|
|
||||||
|
if delta >= minute:
|
||||||
|
result += f"{delta//minute}m "
|
||||||
|
delta = delta%minute
|
||||||
|
|
||||||
|
result += f"{delta}s"
|
||||||
|
|
||||||
|
return result
|
||||||
|
|
||||||
|
class Session:
|
||||||
|
def __init__(self, user_id, nick, server_id, server_era, session_id, is_staff=None,
|
||||||
|
is_manager=None, client_address=None, real_address=None):
|
||||||
|
self.user_id = user_id
|
||||||
|
self.nick = nick
|
||||||
|
self.server_id = server_id
|
||||||
|
self.server_era = server_era
|
||||||
|
self.session_id = session_id
|
||||||
|
self.is_staff = is_staff
|
||||||
|
self.is_manager = is_manager
|
||||||
|
self.client_address = client_address
|
||||||
|
self.real_address = real_address
|
||||||
|
|
||||||
|
@property
|
||||||
|
def uid(self):
|
||||||
|
return self.user_id
|
||||||
|
|
||||||
|
@uid.setter
|
||||||
|
def uid(self, new_uid):
|
||||||
|
self.user_id = new_uid
|
||||||
|
|
||||||
|
@property
|
||||||
|
def sid(self):
|
||||||
|
return self.session_id
|
||||||
|
|
||||||
|
@sid.setter
|
||||||
|
def sid(self, new_sid):
|
||||||
|
self.session_id = new_sid
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def from_dict(cls, d):
|
||||||
|
return cls(
|
||||||
|
d.get("id"),
|
||||||
|
d.get("name"),
|
||||||
|
d.get("server_id"),
|
||||||
|
d.get("server_era"),
|
||||||
|
d.get("session_id"),
|
||||||
|
d.get("is_staff", None),
|
||||||
|
d.get("is_manager", None),
|
||||||
|
d.get("client_address", None),
|
||||||
|
d.get("real_address", None)
|
||||||
|
)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def client_type(self):
|
||||||
|
# account, agent or bot
|
||||||
|
return self.user_id.split(":")[0]
|
||||||
|
|
||||||
|
class Listing:
|
||||||
|
def __init__(self):
|
||||||
|
self._sessions = {}
|
||||||
|
|
||||||
|
def __len__(self):
|
||||||
|
return len(self._sessions)
|
||||||
|
|
||||||
|
def add(self, session):
|
||||||
|
self._sessions[session.session_id] = session
|
||||||
|
|
||||||
|
def remove(self, session_id):
|
||||||
|
self._sessions.pop(session_id)
|
||||||
|
|
||||||
|
def remove_combo(self, server_id, server_era):
|
||||||
|
removed = [ses for ses in self._sessions.items()
|
||||||
|
if ses.server_id == server_id and ses.server_era == server_era]
|
||||||
|
|
||||||
|
self._sessions = {i: ses for i, ses in self._sessions.items()
|
||||||
|
if ses.server_id != server_id and ses.server_era != server_era}
|
||||||
|
|
||||||
|
return removed
|
||||||
|
|
||||||
|
def by_sid(self, session_id):
|
||||||
|
return self._sessions.get(session_id);
|
||||||
|
|
||||||
|
def by_uid(self, user_id):
|
||||||
|
return [ses for ses in self._sessions if ses.user_id == user_id]
|
||||||
|
|
||||||
|
def get(self, types=["agent", "account", "bot"], lurker=None):
|
||||||
|
sessions = []
|
||||||
|
for uid, ses in self._sessions.items():
|
||||||
|
if ses.client_type not in types:
|
||||||
|
continue
|
||||||
|
|
||||||
|
is_lurker = not ses.nick # "" or None
|
||||||
|
if lurker is None or lurker == is_lurker:
|
||||||
|
sessions.append(ses)
|
||||||
|
|
||||||
|
return sessions
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def from_dict(cls, d):
|
||||||
|
listing = cls()
|
||||||
|
for session in d:
|
||||||
|
listing.add(Session.from_dict(session))
|
||||||
|
return listing
|
||||||
|
|
||||||
|
#def get_people(self):
|
||||||
|
#return self.get(types=["agent", "account"])
|
||||||
|
|
||||||
|
#def get_accounts(self):
|
||||||
|
#return self.get(types=["account"])
|
||||||
|
|
||||||
|
#def get_agents(self):
|
||||||
|
#return self.get(types=["agent"])
|
||||||
|
|
||||||
|
#def get_bots(self):
|
||||||
|
#return self.get(types=["bot"])
|
||||||
|
|
||||||
|
class Message():
|
||||||
|
def __init__(self, message_id, time, sender, content, parent=None, previous_edit_id=None,
|
||||||
|
encryption_key=None, edited=None, deleted=None, truncated=None):
|
||||||
|
self.message_id = message_id
|
||||||
|
self.time = time
|
||||||
|
self.sender = sender
|
||||||
|
self.content = content
|
||||||
|
self.parent = parent
|
||||||
|
self.previous_edit_id = previous_edit_id
|
||||||
|
self.encryption_key = encryption_key
|
||||||
|
self.edited = edited
|
||||||
|
self.deleted = deleted
|
||||||
|
self.truncated = truncated
|
||||||
|
|
||||||
|
@property
|
||||||
|
def mid(self):
|
||||||
|
return self.message_id
|
||||||
|
|
||||||
|
@mid.setter
|
||||||
|
def mid(self, new_mid):
|
||||||
|
self.message_id = new_mid
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def from_dict(cls, d):
|
||||||
|
return cls(
|
||||||
|
d.get("id"),
|
||||||
|
d.get("time"),
|
||||||
|
Session.from_dict(d.get("sender")),
|
||||||
|
d.get("content"),
|
||||||
|
d.get("parent", None),
|
||||||
|
d.get("previous_edit_id", None),
|
||||||
|
d.get("encryption_key", None),
|
||||||
|
d.get("edited", None),
|
||||||
|
d.get("deleted", None),
|
||||||
|
d.get("truncated", None)
|
||||||
|
)
|
||||||
Loading…
Add table
Add a link
Reference in a new issue