Compare commits
No commits in common. "master" and "rewrite-5" have entirely different histories.
28 changed files with 234 additions and 1185 deletions
16
.gitignore
vendored
16
.gitignore
vendored
|
|
@ -1,4 +1,12 @@
|
||||||
__pycache__/
|
# python stuff
|
||||||
*.egg-info/
|
*/__pycache__/
|
||||||
/.mypy_cache/
|
|
||||||
/.venv/
|
# venv stuff
|
||||||
|
bin/
|
||||||
|
include/
|
||||||
|
lib/
|
||||||
|
lib64
|
||||||
|
pyvenv.cfg
|
||||||
|
|
||||||
|
# mypy stuff
|
||||||
|
.mypy_cache/
|
||||||
|
|
|
||||||
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
|
|
||||||
2
LICENSE
2
LICENSE
|
|
@ -1,6 +1,6 @@
|
||||||
MIT License
|
MIT License
|
||||||
|
|
||||||
Copyright (c) 2018 - 2019 Garmelon
|
Copyright (c) 2018 Garmelon
|
||||||
|
|
||||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||||
of this software and associated documentation files (the "Software"), to deal
|
of this software and associated documentation files (the "Software"), to deal
|
||||||
|
|
|
||||||
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`.
|
|
||||||
26
example.py
Normal file
26
example.py
Normal file
|
|
@ -0,0 +1,26 @@
|
||||||
|
import yyb
|
||||||
|
|
||||||
|
class MyClient(yyb.Client):
|
||||||
|
async def on_join(self, room):
|
||||||
|
await room.say("Hello!")
|
||||||
|
|
||||||
|
async def on_message(self, message):
|
||||||
|
if message.content == "reply to me"):
|
||||||
|
reply = await message.reply("reply")
|
||||||
|
await reply.reply("reply to the reply")
|
||||||
|
await message.room.say("stuff going on")
|
||||||
|
|
||||||
|
elif message.content == "hey, join &test!":
|
||||||
|
# returns room in phase 3, or throws JoinException
|
||||||
|
room = await self.join("test")
|
||||||
|
if room:
|
||||||
|
room.say("hey, I joined!")
|
||||||
|
else:
|
||||||
|
message.reply("didn't work :(")
|
||||||
|
|
||||||
|
async def before_part(self, room):
|
||||||
|
await room.say("Goodbye!")
|
||||||
|
|
||||||
|
# Something like this, I guess. It's still missing password fields though.
|
||||||
|
c = MyClient("my:bot:")
|
||||||
|
c.run("test", "bots")
|
||||||
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
|
|
||||||
39
info.txt
Normal file
39
info.txt
Normal file
|
|
@ -0,0 +1,39 @@
|
||||||
|
Signature of a normal function:
|
||||||
|
|
||||||
|
def a(b: int, c: str) -> bool:
|
||||||
|
pass
|
||||||
|
|
||||||
|
a # type: Callable[[int, str], bool]
|
||||||
|
|
||||||
|
Signature of an async function:
|
||||||
|
|
||||||
|
async def a(b: int, c: str) -> bool:
|
||||||
|
pass
|
||||||
|
|
||||||
|
a # type: Callable[[int, str], Awaitable[bool]]
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
Enable logging (from the websockets docs):
|
||||||
|
|
||||||
|
import logging
|
||||||
|
logger = logging.getLogger('websockets')
|
||||||
|
logger.setLevel(logging.INFO)
|
||||||
|
logger.addHandler(logging.StreamHandler())
|
||||||
|
|
||||||
|
Output format: See https://docs.python.org/3/library/logging.html#formatter-objects
|
||||||
|
|
||||||
|
Example formatting:
|
||||||
|
|
||||||
|
FORMAT = "{asctime} [{levelname:<7}] <{name}> {funcName}(): {message}"
|
||||||
|
DATE_FORMAT = "%F %T"
|
||||||
|
handler = logging.StreamHandler()
|
||||||
|
handler.setFormatter(logging.Formatter(
|
||||||
|
fmt=FORMAT,
|
||||||
|
datefmt=DATE_FORMAT,
|
||||||
|
style="{"
|
||||||
|
))
|
||||||
|
|
||||||
|
logger = logging.getLogger('yaboli')
|
||||||
|
logger.setLevel(logging.DEBUG)
|
||||||
|
logger.addHandler(handler)
|
||||||
|
|
@ -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
requirements.txt
Normal file
1
requirements.txt
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
websockets==7.0
|
||||||
66
test.py
Normal file
66
test.py
Normal file
|
|
@ -0,0 +1,66 @@
|
||||||
|
# These tests are not intended as serious tests, just as small scenarios to
|
||||||
|
# give yaboli something to do.
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import logging
|
||||||
|
|
||||||
|
import yaboli
|
||||||
|
|
||||||
|
FORMAT = "{asctime} [{levelname:<7}] <{name}> {funcName}(): {message}"
|
||||||
|
LEVEL = logging.DEBUG
|
||||||
|
#FORMAT = "{asctime} [{levelname:<7}] <{name}>: {message}"
|
||||||
|
#LEVEL = logging.INFO
|
||||||
|
|
||||||
|
DATE_FORMAT = "%F %T"
|
||||||
|
handler = logging.StreamHandler()
|
||||||
|
handler.setFormatter(logging.Formatter(
|
||||||
|
fmt=FORMAT,
|
||||||
|
datefmt=DATE_FORMAT,
|
||||||
|
style="{"
|
||||||
|
))
|
||||||
|
|
||||||
|
logger = logging.getLogger('yaboli')
|
||||||
|
logger.setLevel(LEVEL)
|
||||||
|
logger.addHandler(handler)
|
||||||
|
|
||||||
|
class TestModule(yaboli.Module):
|
||||||
|
PING_REPLY = "ModulePong!"
|
||||||
|
DESCRIPTION = "ModuleDescription"
|
||||||
|
HELP_GENERAL = "ModuleGeneralHelp"
|
||||||
|
HELP_SPECIFIC = ["ModuleGeneralHelp"]
|
||||||
|
|
||||||
|
class EchoModule(yaboli.Module):
|
||||||
|
DEFAULT_NICK = "echo"
|
||||||
|
DESCRIPTION = "echoes back the input arguments"
|
||||||
|
HELP_GENERAL = "/me " + DESCRIPTION
|
||||||
|
HELP_SPECIFIC = [
|
||||||
|
"!echo <args> – output the arguments, each in its own line"
|
||||||
|
#"!fancyecho <args> – same as !echo, but different parser"
|
||||||
|
]
|
||||||
|
|
||||||
|
def __init__(self, standalone: bool) -> None:
|
||||||
|
super().__init__(standalone)
|
||||||
|
|
||||||
|
self.register_general("echo", self.cmd_echo)
|
||||||
|
#self.register_general("fancyecho", self.cmd_fancyecho)
|
||||||
|
|
||||||
|
async def cmd_echo(self, room, message, args):
|
||||||
|
if args.has_args():
|
||||||
|
lines = [repr(arg) for arg in args.basic()]
|
||||||
|
await message.reply("\n".join(lines))
|
||||||
|
else:
|
||||||
|
await message.reply("No arguments")
|
||||||
|
|
||||||
|
class TestBot(yaboli.ModuleBot):
|
||||||
|
DEFAULT_NICK = "testbot"
|
||||||
|
|
||||||
|
async def started(self):
|
||||||
|
await self.join("test")
|
||||||
|
|
||||||
|
async def main():
|
||||||
|
tb = TestBot()
|
||||||
|
tb.register_module("test", TestModule(standalone=False))
|
||||||
|
tb.register_module("echo", EchoModule(standalone=False))
|
||||||
|
await tb.run()
|
||||||
|
|
||||||
|
asyncio.run(main())
|
||||||
|
|
@ -1,13 +1,9 @@
|
||||||
import asyncio
|
from typing import List
|
||||||
import configparser
|
|
||||||
import logging
|
|
||||||
from typing import Callable, Dict
|
|
||||||
|
|
||||||
from .bot import *
|
from .bot import *
|
||||||
from .client import *
|
from .client import *
|
||||||
from .command import *
|
from .command import *
|
||||||
from .connection import *
|
from .connection import *
|
||||||
from .database import *
|
|
||||||
from .events import *
|
from .events import *
|
||||||
from .exceptions import *
|
from .exceptions import *
|
||||||
from .message import *
|
from .message import *
|
||||||
|
|
@ -16,14 +12,11 @@ from .room import *
|
||||||
from .session import *
|
from .session import *
|
||||||
from .util import *
|
from .util import *
|
||||||
|
|
||||||
__all__ = ["STYLE", "FORMAT", "DATE_FORMAT", "FORMATTER", "enable_logging",
|
__all__: List[str] = []
|
||||||
"run", "run_modulebot"]
|
|
||||||
|
|
||||||
__all__ += bot.__all__
|
__all__ += bot.__all__
|
||||||
__all__ += client.__all__
|
__all__ += client.__all__
|
||||||
__all__ += command.__all__
|
__all__ += command.__all__
|
||||||
__all__ += connection.__all__
|
__all__ += connection.__all__
|
||||||
__all__ += database.__all__
|
|
||||||
__all__ += events.__all__
|
__all__ += events.__all__
|
||||||
__all__ += exceptions.__all__
|
__all__ += exceptions.__all__
|
||||||
__all__ += message.__all__
|
__all__ += message.__all__
|
||||||
|
|
@ -31,53 +24,3 @@ __all__ += module.__all__
|
||||||
__all__ += room.__all__
|
__all__ += room.__all__
|
||||||
__all__ += session.__all__
|
__all__ += session.__all__
|
||||||
__all__ += util.__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())
|
|
||||||
|
|
|
||||||
278
yaboli/bot.py
278
yaboli/bot.py
|
|
@ -1,120 +1,28 @@
|
||||||
import configparser
|
|
||||||
import datetime
|
|
||||||
import logging
|
import logging
|
||||||
from typing import Callable, List, Optional
|
from typing import List, Optional
|
||||||
|
|
||||||
from .client import Client
|
from .client import Client
|
||||||
from .command import *
|
from .command import *
|
||||||
from .message import LiveMessage, Message
|
from .message import LiveMessage, Message
|
||||||
from .room import Room
|
from .room import Room
|
||||||
from .util import *
|
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
__all__ = ["Bot", "BotConstructor"]
|
__all__ = ["Bot"]
|
||||||
|
|
||||||
class Bot(Client):
|
class Bot(Client):
|
||||||
"""
|
|
||||||
A Bot is a Client that responds to commands and uses a config file to
|
|
||||||
automatically set its nick and join rooms.
|
|
||||||
|
|
||||||
The config file is loaded as a ConfigParser by the run() or run_modulebot()
|
|
||||||
functions and has the following structure:
|
|
||||||
|
|
||||||
A "general" section which contains:
|
|
||||||
- nick - the default nick of the bot (set to the empty string if you don't
|
|
||||||
want to set a nick)
|
|
||||||
- cookie_file (optional) - the file the cookie should be saved in
|
|
||||||
|
|
||||||
A "rooms" section which contains a list of rooms that the bot should
|
|
||||||
automatically join. This section is optional if you overwrite started().
|
|
||||||
The room list should have the format "roomname" or "roomname = password".
|
|
||||||
|
|
||||||
A bot has the following attributes:
|
|
||||||
- ALIASES - list of alternate nicks the bot responds to (see
|
|
||||||
process_commands())
|
|
||||||
- PING_REPLY - used by cmd_ping()
|
|
||||||
- HELP_GENERAL - used by cmd_help_general()
|
|
||||||
- HELP_SPECIFIC - used by cmd_help_specific()
|
|
||||||
- KILL_REPLY - used by cmd_kill()
|
|
||||||
- RESTART_REPLY - used by cmd_restart()
|
|
||||||
- GENERAL_SECTION - the name of the "general" section in the config file
|
|
||||||
(see above) (default: "general")
|
|
||||||
- ROOMS_SECTION - the name of the "rooms" section in the config file (see
|
|
||||||
above) (default: "rooms")
|
|
||||||
"""
|
|
||||||
|
|
||||||
ALIASES: List[str] = []
|
|
||||||
|
|
||||||
PING_REPLY: str = "Pong!"
|
PING_REPLY: str = "Pong!"
|
||||||
HELP_GENERAL: Optional[str] = None
|
HELP_GENERAL: Optional[str] = None
|
||||||
HELP_SPECIFIC: Optional[List[str]] = None
|
HELP_SPECIFIC: Optional[List[str]] = None
|
||||||
KILL_REPLY: Optional[str] = "/me dies"
|
|
||||||
RESTART_REPLY: Optional[str] = "/me restarts"
|
|
||||||
|
|
||||||
GENERAL_SECTION = "general"
|
def __init__(self) -> None:
|
||||||
ROOMS_SECTION = "rooms"
|
super().__init__()
|
||||||
|
|
||||||
def __init__(self,
|
|
||||||
config: configparser.ConfigParser,
|
|
||||||
config_file: str,
|
|
||||||
) -> None:
|
|
||||||
self.config = config
|
|
||||||
self.config_file = config_file
|
|
||||||
|
|
||||||
nick = self.config[self.GENERAL_SECTION].get("nick")
|
|
||||||
if nick is None:
|
|
||||||
logger.warn(("'nick' not set in config file. Defaulting to empty"
|
|
||||||
" nick"))
|
|
||||||
nick = ""
|
|
||||||
|
|
||||||
cookie_file = self.config[self.GENERAL_SECTION].get("cookie_file")
|
|
||||||
if cookie_file is None:
|
|
||||||
logger.warn(("'cookie_file' not set in config file. Using no cookie"
|
|
||||||
" file."))
|
|
||||||
|
|
||||||
super().__init__(nick, cookie_file=cookie_file)
|
|
||||||
|
|
||||||
self._commands: List[Command] = []
|
self._commands: List[Command] = []
|
||||||
|
|
||||||
self.start_time = datetime.datetime.now()
|
|
||||||
|
|
||||||
def save_config(self) -> None:
|
|
||||||
"""
|
|
||||||
Save the current state of self.config to the file passed in __init__ as
|
|
||||||
the config_file parameter.
|
|
||||||
|
|
||||||
Usually, this is the file that self.config was loaded from (if you use
|
|
||||||
run or run_modulebot).
|
|
||||||
"""
|
|
||||||
|
|
||||||
with open(self.config_file, "w") as f:
|
|
||||||
self.config.write(f)
|
|
||||||
|
|
||||||
async def started(self) -> None:
|
|
||||||
"""
|
|
||||||
This Client function is overwritten in order to join all the rooms
|
|
||||||
listed in the "rooms" section of self.config.
|
|
||||||
|
|
||||||
If you need to overwrite this function but want to keep the auto-join
|
|
||||||
functionality, make sure to await super().started().
|
|
||||||
"""
|
|
||||||
|
|
||||||
for room, password in self.config[self.ROOMS_SECTION].items():
|
|
||||||
if password is None:
|
|
||||||
await self.join(room)
|
|
||||||
else:
|
|
||||||
await self.join(room, password=password)
|
|
||||||
|
|
||||||
# Registering commands
|
# Registering commands
|
||||||
|
|
||||||
def register(self, command: Command) -> None:
|
def register(self, command: Command) -> None:
|
||||||
"""
|
|
||||||
Register a Command (from the yaboli.command submodule).
|
|
||||||
|
|
||||||
Usually, you don't have to call this function yourself.
|
|
||||||
"""
|
|
||||||
|
|
||||||
self._commands.append(command)
|
self._commands.append(command)
|
||||||
|
|
||||||
def register_general(self,
|
def register_general(self,
|
||||||
|
|
@ -122,23 +30,6 @@ class Bot(Client):
|
||||||
cmdfunc: GeneralCommandFunction,
|
cmdfunc: GeneralCommandFunction,
|
||||||
args: bool = True
|
args: bool = True
|
||||||
) -> None:
|
) -> None:
|
||||||
"""
|
|
||||||
Register a function as general bot command (i. e. no @mention of the
|
|
||||||
bot nick after the !command). This function will be called by
|
|
||||||
process_commands() when the bot encounters a matching command.
|
|
||||||
|
|
||||||
name - the name of the command (If you want your command to be !hello,
|
|
||||||
the name is "hello".)
|
|
||||||
|
|
||||||
cmdfunc - the function that is called with the Room, LiveMessage and
|
|
||||||
ArgumentData when the bot encounters a matching command
|
|
||||||
|
|
||||||
args - whether the command may have arguments (If set to False, the
|
|
||||||
ArgumentData's has_args() function must also return False for the
|
|
||||||
command function to be called. If set to True, all ArgumentData is
|
|
||||||
valid.)
|
|
||||||
"""
|
|
||||||
|
|
||||||
command = GeneralCommand(name, cmdfunc, args)
|
command = GeneralCommand(name, cmdfunc, args)
|
||||||
self.register(command)
|
self.register(command)
|
||||||
|
|
||||||
|
|
@ -147,21 +38,6 @@ class Bot(Client):
|
||||||
cmdfunc: SpecificCommandFunction,
|
cmdfunc: SpecificCommandFunction,
|
||||||
args: bool = True
|
args: bool = True
|
||||||
) -> None:
|
) -> None:
|
||||||
"""
|
|
||||||
Register a function as specific bot command (i. e. @mention of the bot
|
|
||||||
nick after the !command is required). This function will be called by
|
|
||||||
process_commands() when the bot encounters a matching command.
|
|
||||||
|
|
||||||
name - the name of the command (see register_general() for an
|
|
||||||
explanation)
|
|
||||||
|
|
||||||
cmdfunc - the function that is called with the Room, LiveMessage and
|
|
||||||
SpecificArgumentData when the bot encounters a matching command
|
|
||||||
|
|
||||||
args - whether the command may have arguments (see register_general()
|
|
||||||
for an explanation)
|
|
||||||
"""
|
|
||||||
|
|
||||||
command = SpecificCommand(name, cmdfunc, args)
|
command = SpecificCommand(name, cmdfunc, args)
|
||||||
self.register(command)
|
self.register(command)
|
||||||
|
|
||||||
|
|
@ -172,13 +48,6 @@ class Bot(Client):
|
||||||
message: LiveMessage,
|
message: LiveMessage,
|
||||||
aliases: List[str] = []
|
aliases: List[str] = []
|
||||||
) -> None:
|
) -> None:
|
||||||
"""
|
|
||||||
If the message contains a command, call all matching command functions
|
|
||||||
that were previously registered.
|
|
||||||
|
|
||||||
This function is usually called by the overwritten on_send() function.
|
|
||||||
"""
|
|
||||||
|
|
||||||
nicks = [room.session.nick] + aliases
|
nicks = [room.session.nick] + aliases
|
||||||
data = CommandData.from_string(message.content)
|
data = CommandData.from_string(message.content)
|
||||||
|
|
||||||
|
|
@ -188,31 +57,11 @@ class Bot(Client):
|
||||||
await command.run(room, message, nicks, data)
|
await command.run(room, message, nicks, data)
|
||||||
|
|
||||||
async def on_send(self, room: Room, message: LiveMessage) -> None:
|
async def on_send(self, room: Room, message: LiveMessage) -> None:
|
||||||
"""
|
await self.process_commands(room, message)
|
||||||
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
|
# Help util
|
||||||
|
|
||||||
def format_help(self, room: Room, lines: List[str]) -> str:
|
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)
|
text = "\n".join(lines)
|
||||||
params = {
|
params = {
|
||||||
"nick": room.session.nick,
|
"nick": room.session.nick,
|
||||||
|
|
@ -225,41 +74,8 @@ class Bot(Client):
|
||||||
|
|
||||||
def register_botrulez(self,
|
def register_botrulez(self,
|
||||||
ping: bool = True,
|
ping: bool = True,
|
||||||
help_: bool = True,
|
help_: bool = True
|
||||||
uptime: bool = True,
|
|
||||||
kill: bool = False,
|
|
||||||
restart: bool = False,
|
|
||||||
) -> None:
|
) -> 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:
|
if ping:
|
||||||
self.register_general("ping", self.cmd_ping, args=False)
|
self.register_general("ping", self.cmd_ping, args=False)
|
||||||
self.register_specific("ping", self.cmd_ping, args=False)
|
self.register_specific("ping", self.cmd_ping, args=False)
|
||||||
|
|
@ -271,24 +87,11 @@ class Bot(Client):
|
||||||
self.register_general("help", self.cmd_help_general, args=False)
|
self.register_general("help", self.cmd_help_general, args=False)
|
||||||
self.register_specific("help", self.cmd_help_specific, 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,
|
async def cmd_ping(self,
|
||||||
room: Room,
|
room: Room,
|
||||||
message: LiveMessage,
|
message: LiveMessage,
|
||||||
args: ArgumentData
|
args: ArgumentData
|
||||||
) -> None:
|
) -> None:
|
||||||
"""
|
|
||||||
Reply with self.PING_REPLY.
|
|
||||||
"""
|
|
||||||
|
|
||||||
await message.reply(self.PING_REPLY)
|
await message.reply(self.PING_REPLY)
|
||||||
|
|
||||||
async def cmd_help_general(self,
|
async def cmd_help_general(self,
|
||||||
|
|
@ -296,10 +99,6 @@ class Bot(Client):
|
||||||
message: LiveMessage,
|
message: LiveMessage,
|
||||||
args: ArgumentData
|
args: ArgumentData
|
||||||
) -> None:
|
) -> None:
|
||||||
"""
|
|
||||||
Reply with self.HELP_GENERAL, if it is not None. Uses format_help().
|
|
||||||
"""
|
|
||||||
|
|
||||||
if self.HELP_GENERAL is not None:
|
if self.HELP_GENERAL is not None:
|
||||||
await message.reply(self.format_help(room, [self.HELP_GENERAL]))
|
await message.reply(self.format_help(room, [self.HELP_GENERAL]))
|
||||||
|
|
||||||
|
|
@ -308,70 +107,5 @@ class Bot(Client):
|
||||||
message: LiveMessage,
|
message: LiveMessage,
|
||||||
args: SpecificArgumentData
|
args: SpecificArgumentData
|
||||||
) -> None:
|
) -> None:
|
||||||
"""
|
|
||||||
Reply with self.HELP_SPECIFIC, if it is not None. Uses format_help().
|
|
||||||
"""
|
|
||||||
|
|
||||||
if self.HELP_SPECIFIC is not None:
|
if self.HELP_SPECIFIC is not None:
|
||||||
await message.reply(self.format_help(room, self.HELP_SPECIFIC))
|
await message.reply(self.format_help(room, self.HELP_SPECIFIC))
|
||||||
|
|
||||||
async def cmd_uptime(self,
|
|
||||||
room: Room,
|
|
||||||
message: LiveMessage,
|
|
||||||
args: SpecificArgumentData
|
|
||||||
) -> None:
|
|
||||||
"""
|
|
||||||
Reply with the bot's uptime in the format specified by the botrulez.
|
|
||||||
|
|
||||||
This uses the time that the Bot was first started, not the time the
|
|
||||||
respective Room was created. A !restart (see register_botrulez()) will
|
|
||||||
reset the bot uptime, but leaving and re-joining a room or losing
|
|
||||||
connection won't.
|
|
||||||
"""
|
|
||||||
|
|
||||||
time = format_time(self.start_time)
|
|
||||||
delta = format_delta(datetime.datetime.now() - self.start_time)
|
|
||||||
text = f"/me has been up since {time} UTC ({delta})"
|
|
||||||
await message.reply(text)
|
|
||||||
|
|
||||||
async def cmd_kill(self,
|
|
||||||
room: Room,
|
|
||||||
message: LiveMessage,
|
|
||||||
args: SpecificArgumentData
|
|
||||||
) -> None:
|
|
||||||
"""
|
|
||||||
Remove the bot from this room.
|
|
||||||
|
|
||||||
If self.KILL_REPLY is not None, replies with that before leaving the
|
|
||||||
room.
|
|
||||||
"""
|
|
||||||
|
|
||||||
logger.info(f"Killed in &{room.name} by {message.sender.atmention}")
|
|
||||||
|
|
||||||
if self.KILL_REPLY is not None:
|
|
||||||
await message.reply(self.KILL_REPLY)
|
|
||||||
|
|
||||||
await self.part(room)
|
|
||||||
|
|
||||||
async def cmd_restart(self,
|
|
||||||
room: Room,
|
|
||||||
message: LiveMessage,
|
|
||||||
args: SpecificArgumentData
|
|
||||||
) -> None:
|
|
||||||
"""
|
|
||||||
Restart the whole Bot.
|
|
||||||
|
|
||||||
This is done by stopping the Bot, since the run() or run_modulebot()
|
|
||||||
functions start the Bot in a while True loop.
|
|
||||||
|
|
||||||
If self.RESTART_REPLY is not None, replies with that before restarting.
|
|
||||||
"""
|
|
||||||
|
|
||||||
logger.info(f"Restarted in &{room.name} by {message.sender.atmention}")
|
|
||||||
|
|
||||||
if self.RESTART_REPLY is not None:
|
|
||||||
await message.reply(self.RESTART_REPLY)
|
|
||||||
|
|
||||||
await self.stop()
|
|
||||||
|
|
||||||
BotConstructor = Callable[[configparser.ConfigParser, str], Bot]
|
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
import asyncio
|
import asyncio
|
||||||
import functools
|
import functools
|
||||||
import logging
|
import logging
|
||||||
from typing import Dict, List, Optional, Union
|
from typing import Dict, List, Optional
|
||||||
|
|
||||||
from .message import LiveMessage
|
from .message import LiveMessage
|
||||||
from .room import Room
|
from .room import Room
|
||||||
|
|
@ -12,12 +12,9 @@ logger = logging.getLogger(__name__)
|
||||||
__all__ = ["Client"]
|
__all__ = ["Client"]
|
||||||
|
|
||||||
class Client:
|
class Client:
|
||||||
def __init__(self,
|
DEFAULT_NICK = ""
|
||||||
default_nick: str,
|
|
||||||
cookie_file: Optional[str] = None,
|
def __init__(self) -> None:
|
||||||
) -> None:
|
|
||||||
self._default_nick = default_nick
|
|
||||||
self._cookie_file = cookie_file
|
|
||||||
self._rooms: Dict[str, List[Room]] = {}
|
self._rooms: Dict[str, List[Room]] = {}
|
||||||
self._stop = asyncio.Event()
|
self._stop = asyncio.Event()
|
||||||
|
|
||||||
|
|
@ -52,34 +49,14 @@ class Client:
|
||||||
async def join(self,
|
async def join(self,
|
||||||
room_name: str,
|
room_name: str,
|
||||||
password: Optional[str] = None,
|
password: Optional[str] = None,
|
||||||
nick: Optional[str] = None,
|
nick: Optional[str] = None
|
||||||
cookie_file: Union[str, bool] = True,
|
|
||||||
) -> Optional[Room]:
|
) -> 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}")
|
logger.info(f"Joining &{room_name}")
|
||||||
|
|
||||||
if nick is None:
|
if nick is None:
|
||||||
nick = self._default_nick
|
nick = self.DEFAULT_NICK
|
||||||
|
room = Room(room_name, password=password, target_nick=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",
|
room.register_event("snapshot",
|
||||||
functools.partial(self.on_snapshot, room))
|
functools.partial(self.on_snapshot, room))
|
||||||
room.register_event("send",
|
room.register_event("send",
|
||||||
|
|
@ -126,9 +103,6 @@ class Client:
|
||||||
|
|
||||||
# Event stuff - overwrite these functions
|
# 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:
|
async def on_snapshot(self, room: Room, messages: List[LiveMessage]) -> None:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
|
|
@ -152,12 +126,6 @@ class Client:
|
||||||
async def on_edit(self, room: Room, message: LiveMessage) -> None:
|
async def on_edit(self, room: Room, message: LiveMessage) -> None:
|
||||||
pass
|
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,
|
async def on_pm(self,
|
||||||
room: Room,
|
room: Room,
|
||||||
from_id: str,
|
from_id: str,
|
||||||
|
|
|
||||||
|
|
@ -23,74 +23,13 @@ __all__ = ["FancyArgs", "ArgumentData", "SpecificArgumentData", "CommandData",
|
||||||
"SpecificCommandFunction", "SpecificCommand"]
|
"SpecificCommandFunction", "SpecificCommand"]
|
||||||
|
|
||||||
class FancyArgs(NamedTuple):
|
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]
|
positional: List[str]
|
||||||
optional: Dict[str, Optional[str]]
|
optional: Dict[str, Optional[str]]
|
||||||
flags: Dict[str, int]
|
flags: Dict[str, int]
|
||||||
raw: List[str]
|
|
||||||
|
|
||||||
class ArgumentData:
|
class ArgumentData:
|
||||||
def __init__(self, raw: str) -> None:
|
def __init__(self, argstr: str) -> None:
|
||||||
self._raw = raw
|
self._argstr = argstr
|
||||||
|
|
||||||
self._basic: Optional[List[str]] = None
|
self._basic: Optional[List[str]] = None
|
||||||
self._basic_escaped: Optional[List[str]] = None
|
self._basic_escaped: Optional[List[str]] = None
|
||||||
|
|
@ -155,62 +94,31 @@ class ArgumentData:
|
||||||
return text.split()
|
return text.split()
|
||||||
|
|
||||||
def _parse_fancy(self, args: List[str]) -> FancyArgs:
|
def _parse_fancy(self, args: List[str]) -> FancyArgs:
|
||||||
positional: List[str] = []
|
raise NotImplementedError
|
||||||
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
|
@property
|
||||||
def raw(self) -> str:
|
def argstr(self) -> str:
|
||||||
return self._raw
|
return self._argstr
|
||||||
|
|
||||||
def basic(self, escaped: bool = True) -> List[str]:
|
def basic(self, escaped: bool = True) -> List[str]:
|
||||||
if escaped:
|
if escaped:
|
||||||
if self._basic_escaped is None:
|
if self._basic_escaped is None:
|
||||||
self._basic_escaped = self._split(self._raw, escaped)
|
self._basic_escaped = self._split(self._argstr, escaped)
|
||||||
return self._basic_escaped
|
return self._basic_escaped
|
||||||
else:
|
else:
|
||||||
if self._basic is None:
|
if self._basic is None:
|
||||||
self._basic = self._split(self._raw, escaped)
|
self._basic = self._split(self._argstr, escaped)
|
||||||
return self._basic
|
return self._basic
|
||||||
|
|
||||||
def fancy(self, escaped: bool = True) -> FancyArgs:
|
def fancy(self, escaped: bool = True) -> FancyArgs:
|
||||||
if escaped:
|
if escaped:
|
||||||
if self._fancy_escaped is None:
|
if self._fancy_escaped is None:
|
||||||
basic = self._split(self._raw, escaped)
|
basic = self._split(self._argstr, escaped)
|
||||||
self._fancy_escaped = self._parse_fancy(basic)
|
self._fancy_escaped = self._parse_fancy(basic)
|
||||||
return self._fancy_escaped
|
return self._fancy_escaped
|
||||||
else:
|
else:
|
||||||
if self._fancy is None:
|
if self._fancy is None:
|
||||||
basic = self._split(self._raw, escaped)
|
basic = self._split(self._argstr, escaped)
|
||||||
self._fancy = self._parse_fancy(basic)
|
self._fancy = self._parse_fancy(basic)
|
||||||
return self._fancy
|
return self._fancy
|
||||||
|
|
||||||
|
|
@ -218,8 +126,8 @@ class ArgumentData:
|
||||||
return bool(self.basic()) # The list of arguments is empty
|
return bool(self.basic()) # The list of arguments is empty
|
||||||
|
|
||||||
class SpecificArgumentData(ArgumentData):
|
class SpecificArgumentData(ArgumentData):
|
||||||
def __init__(self, nick: str, raw: str) -> None:
|
def __init__(self, nick: str, argstr: str) -> None:
|
||||||
super().__init__(raw)
|
super().__init__(argstr)
|
||||||
|
|
||||||
self._nick = nick
|
self._nick = nick
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -6,7 +6,6 @@ from typing import Any, Awaitable, Callable, Dict, Optional
|
||||||
|
|
||||||
import websockets
|
import websockets
|
||||||
|
|
||||||
from .cookiejar import CookieJar
|
|
||||||
from .events import Events
|
from .events import Events
|
||||||
from .exceptions import *
|
from .exceptions import *
|
||||||
|
|
||||||
|
|
@ -82,9 +81,6 @@ class Connection:
|
||||||
"part-event" and "ping".
|
"part-event" and "ping".
|
||||||
"""
|
"""
|
||||||
|
|
||||||
# Timeout for waiting for the ws connection to be established
|
|
||||||
CONNECT_TIMEOUT = 10 # seconds
|
|
||||||
|
|
||||||
# Maximum duration between euphoria's ping messages. Euphoria usually sends
|
# Maximum duration between euphoria's ping messages. Euphoria usually sends
|
||||||
# ping messages every 20 to 30 seconds.
|
# ping messages every 20 to 30 seconds.
|
||||||
PING_TIMEOUT = 40 # seconds
|
PING_TIMEOUT = 40 # seconds
|
||||||
|
|
@ -101,9 +97,8 @@ class Connection:
|
||||||
|
|
||||||
# Initialising
|
# Initialising
|
||||||
|
|
||||||
def __init__(self, url: str, cookie_file: Optional[str] = None) -> None:
|
def __init__(self, url: str) -> None:
|
||||||
self._url = url
|
self._url = url
|
||||||
self._cookie_jar = CookieJar(cookie_file)
|
|
||||||
|
|
||||||
self._events = Events()
|
self._events = Events()
|
||||||
self._packet_id = 0
|
self._packet_id = 0
|
||||||
|
|
@ -186,12 +181,7 @@ class Connection:
|
||||||
|
|
||||||
try:
|
try:
|
||||||
logger.debug(f"Creating ws connection to {self._url!r}")
|
logger.debug(f"Creating ws connection to {self._url!r}")
|
||||||
ws = await asyncio.wait_for(
|
ws = await websockets.connect(self._url)
|
||||||
websockets.connect(self._url,
|
|
||||||
extra_headers=self._cookie_jar.get_cookies_as_headers()),
|
|
||||||
self.CONNECT_TIMEOUT
|
|
||||||
)
|
|
||||||
logger.debug(f"Established ws connection to {self._url!r}")
|
|
||||||
|
|
||||||
self._ws = ws
|
self._ws = ws
|
||||||
self._awaiting_replies = {}
|
self._awaiting_replies = {}
|
||||||
|
|
@ -199,15 +189,10 @@ class Connection:
|
||||||
self._ping_check = asyncio.create_task(
|
self._ping_check = asyncio.create_task(
|
||||||
self._disconnect_in(self.PING_TIMEOUT))
|
self._disconnect_in(self.PING_TIMEOUT))
|
||||||
|
|
||||||
# Put received cookies into cookie jar
|
|
||||||
for set_cookie in ws.response_headers.get_all("Set-Cookie"):
|
|
||||||
self._cookie_jar.add_cookie(set_cookie)
|
|
||||||
self._cookie_jar.save()
|
|
||||||
|
|
||||||
return True
|
return True
|
||||||
|
|
||||||
except (websockets.InvalidHandshake, websockets.InvalidStatusCode,
|
except (websockets.InvalidHandshake, websockets.InvalidStatusCode,
|
||||||
OSError, asyncio.TimeoutError):
|
socket.gaierror):
|
||||||
logger.debug("Connection failed")
|
logger.debug("Connection failed")
|
||||||
return False
|
return False
|
||||||
|
|
||||||
|
|
@ -453,11 +438,10 @@ class Connection:
|
||||||
# to http://api.euphoria.io/#packets.
|
# to http://api.euphoria.io/#packets.
|
||||||
|
|
||||||
# First, notify whoever's waiting for this packet
|
# First, notify whoever's waiting for this packet
|
||||||
packet_id = packet.get("id")
|
packet_id = packet.get("id", None)
|
||||||
if packet_id is not None and self._awaiting_replies is not None:
|
if packet_id is not None and self._awaiting_replies is not None:
|
||||||
future = self._awaiting_replies.get(packet_id)
|
future = self._awaiting_replies.get(packet_id, None)
|
||||||
if future is not None:
|
if future is not None:
|
||||||
del self._awaiting_replies[packet_id]
|
|
||||||
future.set_result(packet)
|
future.set_result(packet)
|
||||||
|
|
||||||
# Then, send the corresponding event
|
# Then, send the corresponding event
|
||||||
|
|
|
||||||
|
|
@ -1,77 +0,0 @@
|
||||||
import contextlib
|
|
||||||
import http.cookies as cookies
|
|
||||||
import logging
|
|
||||||
from typing import List, Optional, Tuple
|
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
|
||||||
|
|
||||||
__all__ = ["CookieJar"]
|
|
||||||
|
|
||||||
class CookieJar:
|
|
||||||
"""
|
|
||||||
Keeps your cookies in a file.
|
|
||||||
|
|
||||||
CookieJar doesn't attempt to discard old cookies, but that doesn't appear
|
|
||||||
to be necessary for keeping euphoria session cookies.
|
|
||||||
"""
|
|
||||||
|
|
||||||
def __init__(self, filename: Optional[str] = None) -> None:
|
|
||||||
self._filename = filename
|
|
||||||
self._cookies = cookies.SimpleCookie()
|
|
||||||
|
|
||||||
if not self._filename:
|
|
||||||
logger.warning("Could not load cookies, no filename given.")
|
|
||||||
return
|
|
||||||
|
|
||||||
with contextlib.suppress(FileNotFoundError):
|
|
||||||
logger.info(f"Loading cookies from {self._filename!r}")
|
|
||||||
with open(self._filename, "r") as f:
|
|
||||||
for line in f:
|
|
||||||
self._cookies.load(line)
|
|
||||||
|
|
||||||
def get_cookies(self) -> List[str]:
|
|
||||||
return [morsel.OutputString(attrs=[])
|
|
||||||
for morsel in self._cookies.values()]
|
|
||||||
|
|
||||||
def get_cookies_as_headers(self) -> List[Tuple[str, str]]:
|
|
||||||
"""
|
|
||||||
Return all stored cookies as tuples in a list. The first tuple entry is
|
|
||||||
always "Cookie".
|
|
||||||
"""
|
|
||||||
|
|
||||||
return [("Cookie", cookie) for cookie in self.get_cookies()]
|
|
||||||
|
|
||||||
def add_cookie(self, cookie: str) -> None:
|
|
||||||
"""
|
|
||||||
Parse cookie and add it to the jar.
|
|
||||||
|
|
||||||
Example cookie: "a=bcd; Path=/; Expires=Wed, 24 Jul 2019 14:57:52 GMT;
|
|
||||||
HttpOnly; Secure"
|
|
||||||
"""
|
|
||||||
|
|
||||||
logger.debug(f"Adding cookie {cookie!r}")
|
|
||||||
self._cookies.load(cookie)
|
|
||||||
|
|
||||||
def save(self) -> None:
|
|
||||||
"""
|
|
||||||
Saves all current cookies to the cookie jar file.
|
|
||||||
"""
|
|
||||||
|
|
||||||
if not self._filename:
|
|
||||||
logger.warning("Could not save cookies, no filename given.")
|
|
||||||
return
|
|
||||||
|
|
||||||
logger.info(f"Saving cookies to {self._filename!r}")
|
|
||||||
|
|
||||||
with open(self._filename, "w") as f:
|
|
||||||
for morsel in self._cookies.values():
|
|
||||||
cookie_string = morsel.OutputString()
|
|
||||||
f.write(f"{cookie_string}\n")
|
|
||||||
|
|
||||||
def clear(self) -> None:
|
|
||||||
"""
|
|
||||||
Removes all cookies from the cookie jar.
|
|
||||||
"""
|
|
||||||
|
|
||||||
logger.debug("OMNOMNOM, cookies are all gone!")
|
|
||||||
self._cookies = cookies.SimpleCookie()
|
|
||||||
|
|
@ -1,40 +0,0 @@
|
||||||
import asyncio
|
|
||||||
import logging
|
|
||||||
import sqlite3
|
|
||||||
from typing import Any, Awaitable, Callable, TypeVar
|
|
||||||
|
|
||||||
from .util import asyncify
|
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
|
||||||
|
|
||||||
__all__ = ["Database", "operation"]
|
|
||||||
|
|
||||||
T = TypeVar('T')
|
|
||||||
|
|
||||||
def operation(func: Callable[..., T]) -> Callable[..., Awaitable[T]]:
|
|
||||||
async def wrapper(self: Any, *args: Any, **kwargs: Any) -> T:
|
|
||||||
async with self as db:
|
|
||||||
while True:
|
|
||||||
try:
|
|
||||||
return await asyncify(func, self, db, *args, **kwargs)
|
|
||||||
except sqlite3.OperationalError as e:
|
|
||||||
logger.warn(f"Operational error encountered: {e}")
|
|
||||||
await asyncio.sleep(5)
|
|
||||||
return wrapper
|
|
||||||
|
|
||||||
class Database:
|
|
||||||
def __init__(self, database: str) -> None:
|
|
||||||
self._connection = sqlite3.connect(database, check_same_thread=False)
|
|
||||||
self._lock = asyncio.Lock()
|
|
||||||
|
|
||||||
self.initialize(self._connection)
|
|
||||||
|
|
||||||
def initialize(self, db: Any) -> None:
|
|
||||||
pass
|
|
||||||
|
|
||||||
async def __aenter__(self) -> Any:
|
|
||||||
await self._lock.__aenter__()
|
|
||||||
return self._connection
|
|
||||||
|
|
||||||
async def __aexit__(self, *args: Any, **kwargs: Any) -> Any:
|
|
||||||
return await self._lock.__aexit__(*args, **kwargs)
|
|
||||||
|
|
@ -10,6 +10,7 @@ __all__ = [
|
||||||
# Doing stuff in a room
|
# Doing stuff in a room
|
||||||
"RoomNotConnectedException",
|
"RoomNotConnectedException",
|
||||||
"EuphError",
|
"EuphError",
|
||||||
|
"RoomClosedException",
|
||||||
]
|
]
|
||||||
|
|
||||||
class EuphException(Exception):
|
class EuphException(Exception):
|
||||||
|
|
@ -65,3 +66,14 @@ class EuphError(EuphException):
|
||||||
The euphoria server has sent back an "error" field in its response.
|
The euphoria server has sent back an "error" field in its response.
|
||||||
"""
|
"""
|
||||||
pass
|
pass
|
||||||
|
|
||||||
|
# TODO This exception is not used currently, decide on whether to keep it or
|
||||||
|
# throw it away
|
||||||
|
class RoomClosedException(EuphException):
|
||||||
|
"""
|
||||||
|
The room has been closed already.
|
||||||
|
|
||||||
|
This means that phase 4 (see the docstring of Room) has been initiated or
|
||||||
|
completed.
|
||||||
|
"""
|
||||||
|
pass
|
||||||
|
|
|
||||||
|
|
@ -166,8 +166,5 @@ class LiveMessage(Message):
|
||||||
async def reply(self, content: str) -> "LiveMessage":
|
async def reply(self, content: str) -> "LiveMessage":
|
||||||
return await self.room.send(content, parent_id=self.message_id)
|
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"]:
|
async def before(self, amount: int) -> List["LiveMessage"]:
|
||||||
return await self.room.log(amount, before_id=self.message_id)
|
return await self.room.log(amount, before_id=self.message_id)
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,5 @@
|
||||||
import configparser
|
|
||||||
import logging
|
import logging
|
||||||
from typing import Callable, Dict, List, Optional
|
from typing import Dict, List, Optional
|
||||||
|
|
||||||
from .bot import Bot
|
from .bot import Bot
|
||||||
from .command import *
|
from .command import *
|
||||||
|
|
@ -11,77 +10,49 @@ from .util import *
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
__all__ = ["Module", "ModuleConstructor", "ModuleBot", "ModuleBotConstructor"]
|
__all__ = ["Module", "ModuleBot"]
|
||||||
|
|
||||||
class Module(Bot):
|
class Module(Bot):
|
||||||
DESCRIPTION: Optional[str] = None
|
DESCRIPTION: Optional[str] = None
|
||||||
|
|
||||||
def __init__(self,
|
def __init__(self, standalone: bool) -> None:
|
||||||
config: configparser.ConfigParser,
|
super().__init__()
|
||||||
config_file: str,
|
|
||||||
standalone: bool = True,
|
|
||||||
) -> None:
|
|
||||||
super().__init__(config, config_file)
|
|
||||||
|
|
||||||
self.standalone = standalone
|
self.standalone = standalone
|
||||||
|
|
||||||
ModuleConstructor = Callable[[configparser.ConfigParser, str, bool], Module]
|
|
||||||
|
|
||||||
class ModuleBot(Bot):
|
class ModuleBot(Bot):
|
||||||
HELP_PRE: Optional[List[str]] = [
|
HELP_PRE: Optional[List[str]] = [
|
||||||
"This bot contains the following modules:"
|
"This bot contains the following modules:"
|
||||||
]
|
]
|
||||||
HELP_POST: Optional[List[str]] = [
|
HELP_POST: Optional[List[str]] = [
|
||||||
"",
|
""
|
||||||
"For module-specific help, try \"!help {atmention} <module>\".",
|
"Use \"!help {atmention} <module>\" to get more information on a"
|
||||||
|
" specific module."
|
||||||
]
|
]
|
||||||
MODULE_HELP_LIMIT = 5
|
MODULE_HELP_LIMIT = 5
|
||||||
|
|
||||||
MODULES_SECTION = "modules"
|
def __init__(self) -> None:
|
||||||
|
super().__init__()
|
||||||
|
|
||||||
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] = {}
|
self.modules: Dict[str, Module] = {}
|
||||||
|
|
||||||
# Load initial modules
|
self.register_botrulez(help_=False)
|
||||||
for module_name in self.config[self.MODULES_SECTION]:
|
self.register_general("help", self.cmd_help_general, args=False)
|
||||||
module_constructor = self.module_constructors.get(module_name)
|
self.register_specific("help", self.cmd_help_specific, args=True)
|
||||||
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:
|
def register_module(self, name: str, module: Module) -> None:
|
||||||
if name in self.modules:
|
if name in self.modules:
|
||||||
logger.warn(f"Module {name!r} is already registered, overwriting...")
|
logger.warn(f"Module {name!r} is already registered, overwriting...")
|
||||||
self.modules[name] = module
|
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]:
|
def compile_module_overview(self) -> List[str]:
|
||||||
lines = []
|
lines = []
|
||||||
|
|
||||||
if self.HELP_PRE is not None:
|
if self.HELP_PRE is not None:
|
||||||
lines.extend(self.HELP_PRE)
|
lines.extend(self.HELP_PRE)
|
||||||
|
|
||||||
any_modules = False
|
|
||||||
|
|
||||||
modules_without_desc: List[str] = []
|
modules_without_desc: List[str] = []
|
||||||
for module_name in sorted(self.modules):
|
for module_name in sorted(self.modules):
|
||||||
any_modules = True
|
|
||||||
|
|
||||||
module = self.modules[module_name]
|
module = self.modules[module_name]
|
||||||
|
|
||||||
if module.DESCRIPTION is None:
|
if module.DESCRIPTION is None:
|
||||||
|
|
@ -91,10 +62,7 @@ class ModuleBot(Bot):
|
||||||
lines.append(line)
|
lines.append(line)
|
||||||
|
|
||||||
if modules_without_desc:
|
if modules_without_desc:
|
||||||
lines.append("\t" + ", ".join(modules_without_desc))
|
lines.append(", ".join(modules_without_desc))
|
||||||
|
|
||||||
if not any_modules:
|
|
||||||
lines.append("No modules loaded.")
|
|
||||||
|
|
||||||
if self.HELP_POST is not None:
|
if self.HELP_POST is not None:
|
||||||
lines.extend(self.HELP_POST)
|
lines.extend(self.HELP_POST)
|
||||||
|
|
@ -111,7 +79,8 @@ class ModuleBot(Bot):
|
||||||
|
|
||||||
return module.HELP_SPECIFIC
|
return module.HELP_SPECIFIC
|
||||||
|
|
||||||
async def cmd_modules_help(self,
|
# Overwriting the botrulez help function
|
||||||
|
async def cmd_help_specific(self,
|
||||||
room: Room,
|
room: Room,
|
||||||
message: LiveMessage,
|
message: LiveMessage,
|
||||||
args: SpecificArgumentData
|
args: SpecificArgumentData
|
||||||
|
|
@ -131,12 +100,6 @@ class ModuleBot(Bot):
|
||||||
|
|
||||||
# Sending along all kinds of events
|
# 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:
|
async def on_snapshot(self, room: Room, messages: List[LiveMessage]) -> None:
|
||||||
await super().on_snapshot(room, messages)
|
await super().on_snapshot(room, messages)
|
||||||
|
|
||||||
|
|
@ -178,18 +141,6 @@ class ModuleBot(Bot):
|
||||||
for module in self.modules.values():
|
for module in self.modules.values():
|
||||||
await module.on_edit(room, message)
|
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,
|
async def on_pm(self,
|
||||||
room: Room,
|
room: Room,
|
||||||
from_id: str,
|
from_id: str,
|
||||||
|
|
@ -207,8 +158,3 @@ class ModuleBot(Bot):
|
||||||
|
|
||||||
for module in self.modules.values():
|
for module in self.modules.values():
|
||||||
await module.on_disconnect(room, reason)
|
await module.on_disconnect(room, reason)
|
||||||
|
|
||||||
ModuleBotConstructor = Callable[
|
|
||||||
[configparser.ConfigParser, str, Dict[str, ModuleConstructor]],
|
|
||||||
Bot
|
|
||||||
]
|
|
||||||
|
|
|
||||||
139
yaboli/room.py
139
yaboli/room.py
|
|
@ -1,6 +1,6 @@
|
||||||
import asyncio
|
import asyncio
|
||||||
import logging
|
import logging
|
||||||
from typing import Any, Awaitable, Callable, List, Optional, Tuple, TypeVar
|
from typing import Any, Awaitable, Callable, List, Optional, TypeVar
|
||||||
|
|
||||||
from .connection import Connection
|
from .connection import Connection
|
||||||
from .events import Events
|
from .events import Events
|
||||||
|
|
@ -19,10 +19,6 @@ class Room:
|
||||||
"""
|
"""
|
||||||
Events and parameters:
|
Events and parameters:
|
||||||
|
|
||||||
"connected" - fired after the Room has authenticated, joined and set its
|
|
||||||
nick, meaning that now, messages can be sent
|
|
||||||
no parameters
|
|
||||||
|
|
||||||
"snapshot" - snapshot of the room's messages at the time of joining
|
"snapshot" - snapshot of the room's messages at the time of joining
|
||||||
messages: List[LiveMessage]
|
messages: List[LiveMessage]
|
||||||
|
|
||||||
|
|
@ -43,12 +39,6 @@ class Room:
|
||||||
"edit" - a message in the room has been modified or deleted
|
"edit" - a message in the room has been modified or deleted
|
||||||
message: LiveMessage
|
message: LiveMessage
|
||||||
|
|
||||||
"login" - this session has been logged in from another session
|
|
||||||
account_id: str
|
|
||||||
|
|
||||||
"logout" - this session has been logged out from another session
|
|
||||||
no parameters
|
|
||||||
|
|
||||||
"pm" - another session initiated a pm with you
|
"pm" - another session initiated a pm with you
|
||||||
from: str - the id of the user inviting the client to chat privately
|
from: str - the id of the user inviting the client to chat privately
|
||||||
from_nick: str - the nick of the inviting user
|
from_nick: str - the nick of the inviting user
|
||||||
|
|
@ -66,8 +56,7 @@ class Room:
|
||||||
name: str,
|
name: str,
|
||||||
password: Optional[str] = None,
|
password: Optional[str] = None,
|
||||||
target_nick: str = "",
|
target_nick: str = "",
|
||||||
url_format: str = URL_FORMAT,
|
url_format: str = URL_FORMAT
|
||||||
cookie_file: Optional[str] = None,
|
|
||||||
) -> None:
|
) -> None:
|
||||||
self._name = name
|
self._name = name
|
||||||
self._password = password
|
self._password = password
|
||||||
|
|
@ -85,7 +74,7 @@ class Room:
|
||||||
|
|
||||||
# Connected management
|
# Connected management
|
||||||
self._url = self._url_format.format(self._name)
|
self._url = self._url_format.format(self._name)
|
||||||
self._connection = Connection(self._url, cookie_file=cookie_file)
|
self._connection = Connection(self._url)
|
||||||
self._events = Events()
|
self._events = Events()
|
||||||
|
|
||||||
self._connected = asyncio.Event()
|
self._connected = asyncio.Event()
|
||||||
|
|
@ -123,20 +112,9 @@ class Room:
|
||||||
|
|
||||||
# Connecting, reconnecting and disconnecting
|
# Connecting, reconnecting and disconnecting
|
||||||
|
|
||||||
async def _try_set_connected(self) -> None:
|
def _set_connected(self) -> None:
|
||||||
packets_received = self._hello_received and self._snapshot_received
|
packets_received = self._hello_received and self._snapshot_received
|
||||||
if packets_received and not self._connected.is_set():
|
if packets_received and not self._connected.is_set():
|
||||||
await self._set_nick_if_necessary()
|
|
||||||
self._set_connected()
|
|
||||||
|
|
||||||
async def _set_nick_if_necessary(self) -> None:
|
|
||||||
nick_needs_updating = (self._session is None
|
|
||||||
or self._target_nick != self._session.nick)
|
|
||||||
|
|
||||||
if self._target_nick and nick_needs_updating:
|
|
||||||
await self._nick(self._target_nick)
|
|
||||||
|
|
||||||
def _set_connected(self) -> None:
|
|
||||||
self._connected_successfully = True
|
self._connected_successfully = True
|
||||||
self._connected.set()
|
self._connected.set()
|
||||||
|
|
||||||
|
|
@ -165,7 +143,7 @@ class Room:
|
||||||
self._account = Account.from_data(data)
|
self._account = Account.from_data(data)
|
||||||
|
|
||||||
self._hello_received = True
|
self._hello_received = True
|
||||||
await self._try_set_connected()
|
self._set_connected()
|
||||||
|
|
||||||
async def _on_snapshot_event(self, packet: Any) -> None:
|
async def _on_snapshot_event(self, packet: Any) -> None:
|
||||||
data = packet["data"]
|
data = packet["data"]
|
||||||
|
|
@ -180,22 +158,19 @@ class Room:
|
||||||
if nick is not None and self._session is not None:
|
if nick is not None and self._session is not None:
|
||||||
self._session = self.session.with_nick(nick)
|
self._session = self.session.with_nick(nick)
|
||||||
|
|
||||||
# Send "snapshot" event
|
# Send "session" event
|
||||||
messages = [LiveMessage.from_data(self, msg_data)
|
messages = [LiveMessage.from_data(self, msg_data)
|
||||||
for msg_data in data["log"]]
|
for msg_data in data["log"]]
|
||||||
self._events.fire("snapshot", messages)
|
self._events.fire("session", messages)
|
||||||
|
|
||||||
self._snapshot_received = True
|
self._snapshot_received = True
|
||||||
await self._try_set_connected()
|
self._set_connected()
|
||||||
|
|
||||||
async def _on_bounce_event(self, packet: Any) -> None:
|
async def _on_bounce_event(self, packet: Any) -> None:
|
||||||
data = packet["data"]
|
data = packet["data"]
|
||||||
|
|
||||||
# Can we even authenticate? (Assuming that passcode authentication is
|
# Can we even authenticate?
|
||||||
# available if no authentication options are given: Euphoria doesn't
|
if not "passcode" in data.get("auth_options", []):
|
||||||
# (always) send authentication options, even when passcode
|
|
||||||
# authentication works.)
|
|
||||||
if not "passcode" in data.get("auth_options", ["passcode"]):
|
|
||||||
self._set_connected_failed()
|
self._set_connected_failed()
|
||||||
return
|
return
|
||||||
|
|
||||||
|
|
@ -227,7 +202,11 @@ class Room:
|
||||||
if not self._connected_successfully:
|
if not self._connected_successfully:
|
||||||
return False
|
return False
|
||||||
|
|
||||||
self._events.fire("connected")
|
nick_needs_updating = (self._session is None
|
||||||
|
or self._target_nick != self._session.nick)
|
||||||
|
if self._target_nick and nick_needs_updating:
|
||||||
|
await self._nick(self._target_nick)
|
||||||
|
|
||||||
return True
|
return True
|
||||||
|
|
||||||
async def disconnect(self) -> None:
|
async def disconnect(self) -> None:
|
||||||
|
|
@ -258,34 +237,14 @@ class Room:
|
||||||
session = LiveSession.from_data(self, data)
|
session = LiveSession.from_data(self, data)
|
||||||
self._users = self.users.with_join(session)
|
self._users = self.users.with_join(session)
|
||||||
|
|
||||||
logger.info(f"&{self.name}: {session.atmention} joined")
|
logger.info(f"{session.atmention} joined")
|
||||||
self._events.fire("join", session)
|
self._events.fire("join", session)
|
||||||
|
|
||||||
async def _on_login_event(self, packet: Any) -> None:
|
async def _on_login_event(self, packet: Any) -> None:
|
||||||
"""
|
pass # TODO implement once cookie support is here
|
||||||
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:
|
async def _on_logout_event(self, packet: Any) -> None:
|
||||||
"""
|
pass # TODO implement once cookie support is here
|
||||||
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:
|
async def _on_network_event(self, packet: Any) -> None:
|
||||||
data = packet["data"]
|
data = packet["data"]
|
||||||
|
|
@ -299,7 +258,7 @@ class Room:
|
||||||
for user in self.users:
|
for user in self.users:
|
||||||
if user.server_id == server_id and user.server_era == server_era:
|
if user.server_id == server_id and user.server_era == server_era:
|
||||||
users = users.with_part(user)
|
users = users.with_part(user)
|
||||||
logger.info(f"&{self.name}: {user.atmention} left")
|
logger.info(f"{user.atmention} left")
|
||||||
self._events.fire("part", user)
|
self._events.fire("part", user)
|
||||||
|
|
||||||
self._users = users
|
self._users = users
|
||||||
|
|
@ -316,7 +275,7 @@ class Room:
|
||||||
else:
|
else:
|
||||||
await self.who() # recalibrating self._users
|
await self.who() # recalibrating self._users
|
||||||
|
|
||||||
logger.info(f"&{self.name}: {atmention(nick_from)} is now called {atmention(nick_to)}")
|
logger.info(f"{atmention(nick_from)} is now called {atmention(nick_to)}")
|
||||||
self._events.fire("nick", session, nick_from, nick_to)
|
self._events.fire("nick", session, nick_from, nick_to)
|
||||||
|
|
||||||
async def _on_edit_message_event(self, packet: Any) -> None:
|
async def _on_edit_message_event(self, packet: Any) -> None:
|
||||||
|
|
@ -332,7 +291,7 @@ class Room:
|
||||||
session = LiveSession.from_data(self, data)
|
session = LiveSession.from_data(self, data)
|
||||||
self._users = self.users.with_part(session)
|
self._users = self.users.with_part(session)
|
||||||
|
|
||||||
logger.info(f"&{self.name}: {session.atmention} left")
|
logger.info(f"{session.atmention} left")
|
||||||
self._events.fire("part", session)
|
self._events.fire("part", session)
|
||||||
|
|
||||||
async def _on_pm_initiate_event(self, packet: Any) -> None:
|
async def _on_pm_initiate_event(self, packet: Any) -> None:
|
||||||
|
|
@ -409,6 +368,10 @@ class Room:
|
||||||
|
|
||||||
# Functionality
|
# Functionality
|
||||||
|
|
||||||
|
# These functions require cookie support and are thus not implemented yet:
|
||||||
|
#
|
||||||
|
# login, logout, pm
|
||||||
|
|
||||||
def _extract_data(self, packet: Any) -> Any:
|
def _extract_data(self, packet: Any) -> Any:
|
||||||
error = packet.get("error")
|
error = packet.get("error")
|
||||||
if error is not None:
|
if error is not None:
|
||||||
|
|
@ -508,55 +471,3 @@ class Room:
|
||||||
self._users = users
|
self._users = users
|
||||||
|
|
||||||
return self._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,6 +1,5 @@
|
||||||
import re
|
import re
|
||||||
from typing import (TYPE_CHECKING, Any, Dict, Iterable, Iterator, List,
|
from typing import TYPE_CHECKING, Any, Dict, Iterable, Iterator, List, Optional
|
||||||
Optional, Tuple)
|
|
||||||
|
|
||||||
from .util import mention, normalize
|
from .util import mention, normalize
|
||||||
|
|
||||||
|
|
@ -239,12 +238,7 @@ class LiveSession(Session):
|
||||||
|
|
||||||
# Live stuff
|
# Live stuff
|
||||||
|
|
||||||
async def pm(self) -> Tuple[str, str]:
|
# TODO pm, once pm support is there.
|
||||||
"""
|
|
||||||
See Room.pm
|
|
||||||
"""
|
|
||||||
|
|
||||||
return await self.room.pm(self.user_id)
|
|
||||||
|
|
||||||
class LiveSessionListing:
|
class LiveSessionListing:
|
||||||
def __init__(self, room: "Room", sessions: Iterable[LiveSession]) -> None:
|
def __init__(self, room: "Room", sessions: Iterable[LiveSession]) -> None:
|
||||||
|
|
|
||||||
|
|
@ -1,16 +1,6 @@
|
||||||
import asyncio
|
|
||||||
import datetime
|
|
||||||
import functools
|
|
||||||
import re
|
import re
|
||||||
from typing import Any, Callable
|
|
||||||
|
|
||||||
__all__ = ["asyncify", "mention", "atmention", "normalize", "similar",
|
__all__ = ["mention", "atmention", "normalize", "similar", "plural"]
|
||||||
"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
|
# Name/nick related functions
|
||||||
|
|
||||||
|
|
@ -38,36 +28,3 @@ def plural(
|
||||||
return if_singular
|
return if_singular
|
||||||
else:
|
else:
|
||||||
return if_plural
|
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
|
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue