Compare commits

..

50 commits

Author SHA1 Message Date
eba398e5d3 Bump version to 1.2.0 2022-08-21 14:26:04 +02:00
37c4ba703a Switch to pyproject.toml style setuptools config 2022-08-21 14:25:04 +02:00
74caea4e92 Update websockets dependency 2022-08-21 14:10:50 +02:00
1d25b596bb Bump version to 1.1.5 2020-01-26 22:50:20 +00:00
455d2af251 Use IOError to catch more exceptions 2019-11-30 16:30:52 +00:00
1b9860ba1e Bump version to 1.1.4 2019-06-21 07:23:49 +00:00
66b56a450e Fix room firing incorrect event 2019-06-21 07:21:50 +00:00
2215e75c34 Add config file to example 2019-04-20 19:31:11 +00:00
7024686ff2 Update example gitignore to latest version 2019-04-20 19:01:25 +00:00
1c409601db Update echobot to latest yaboli version 2019-04-20 18:55:47 +00:00
74a8adfa58 Fix imports 2019-04-19 11:09:08 +00:00
6a15e1a948 Add docstrings to Bot functions 2019-04-19 11:08:19 +00:00
eb9cc4f9bd Make KILL_REPLY and RESTART_REPLY optional 2019-04-19 10:52:39 +00:00
ca56de710c Fix changelog formatting 2019-04-19 10:04:12 +00:00
83af4ff9e8 Bump version to 1.1.3 2019-04-19 10:01:50 +00:00
de4ba53de8 Fix config file not reloading on bot restart 2019-04-19 09:57:09 +00:00
d9f25a04fb Time out when creating the ws connections 2019-04-14 22:25:42 +00:00
e53ce42e99 Bump version to 1.1.2 2019-04-14 20:03:29 +00:00
1297cf201b Fix room authentication 2019-04-14 19:56:14 +00:00
838c364066 Bump version to 1.1.1 2019-04-14 19:28:40 +00:00
c579adca9a Re-add database 2019-04-14 19:27:16 +00:00
7e74499f81 Bump version to 1.1.0 2019-04-14 16:12:33 +00:00
7780cb92de Update the docs 2019-04-14 16:10:40 +00:00
86472afb3f Pass along ConfigParsers instead of file names 2019-04-14 16:07:37 +00:00
2f7502723b Bump version to 1.0.0 2019-04-13 22:09:58 +00:00
7e56de60da Clean up changelog and readme 2019-04-13 21:49:00 +00:00
24128a460a Add fancy argument parsing 2019-04-13 21:47:27 +00:00
135640ca44 Log in/out and pm 2019-04-13 20:31:46 +00:00
0d58f61652 Clean up readme 2019-04-13 15:36:02 +00:00
e09e2d215f Add cookie support 2019-04-13 15:32:58 +00:00
7b7ddaa0d1 Save bot config file 2019-04-13 00:30:18 +00:00
ac70f45229 Add some basic documentation 2019-04-13 00:22:42 +00:00
b726d8f9f0 Add !restart command to botrulez 2019-04-12 23:14:48 +00:00
b7579b5b78 Bump version to 0.2.0 2019-04-12 21:04:51 +00:00
8f576b1147 Update installation instructions 2019-04-12 20:49:30 +00:00
6741d36009 Clean up 2019-04-12 20:40:47 +00:00
5586020d1e Change config file format 2019-04-12 20:40:27 +00:00
f40fb2d45d Add on_connected to client 2019-04-12 20:14:22 +00:00
f46ca47a28 Add ALIASES variable to Bot 2019-04-12 20:05:36 +00:00
1d772e7215 Set version number update reminder 2019-04-12 19:31:42 +00:00
1d66b3a518 Clean up 2019-04-12 19:20:13 +00:00
903ba4973b Use setuptools 2019-04-12 18:13:15 +00:00
62e5adc878 Add logging and bot starting utils 2019-04-12 13:28:40 +00:00
8cd2c8d125 Use config files for bots 2019-04-12 12:53:34 +00:00
a78f57db7a Untruncate LiveMessages 2019-04-12 12:12:04 +00:00
3255ea770e Implement !kill 2019-04-12 12:08:26 +00:00
a0f7c8e84a Implement !uptime 2019-04-12 11:37:36 +00:00
14b4e74c7e Add README 2019-04-12 00:28:51 +00:00
2bf512d8dc Rename argstr to raw 2019-04-12 00:28:08 +00:00
8dd94b6ac8 Clean up 2019-04-11 23:51:28 +00:00
28 changed files with 1185 additions and 234 deletions

16
.gitignore vendored
View file

@ -1,12 +1,4 @@
# python stuff __pycache__/
*/__pycache__/ *.egg-info/
/.mypy_cache/
# venv stuff /.venv/
bin/
include/
lib/
lib64
pyvenv.cfg
# mypy stuff
.mypy_cache/

60
CHANGELOG.md Normal file
View file

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

View file

@ -1,6 +1,6 @@
MIT License MIT License
Copyright (c) 2018 Garmelon Copyright (c) 2018 - 2019 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 Normal file
View file

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

13
docs/bot_setup.md Normal file
View file

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

89
docs/index.md Normal file
View file

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

View file

@ -1,26 +0,0 @@
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 Normal file
View file

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

View file

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

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

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

View file

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

View file

@ -1,39 +0,0 @@
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)

30
pyproject.toml Normal file
View file

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

View file

@ -1 +0,0 @@
websockets==7.0

66
test.py
View file

@ -1,66 +0,0 @@
# 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())

View file

@ -1,9 +1,13 @@
from typing import List import asyncio
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 *
@ -12,11 +16,14 @@ from .room import *
from .session import * from .session import *
from .util import * from .util import *
__all__: List[str] = [] __all__ = ["STYLE", "FORMAT", "DATE_FORMAT", "FORMATTER", "enable_logging",
"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__
@ -24,3 +31,53 @@ __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())

View file

@ -1,28 +1,120 @@
import configparser
import datetime
import logging import logging
from typing import List, Optional from typing import Callable, 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"] __all__ = ["Bot", "BotConstructor"]
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"
def __init__(self) -> None: GENERAL_SECTION = "general"
super().__init__() ROOMS_SECTION = "rooms"
def __init__(self,
config: configparser.ConfigParser,
config_file: str,
) -> None:
self.config = config
self.config_file = config_file
nick = self.config[self.GENERAL_SECTION].get("nick")
if nick is None:
logger.warn(("'nick' not set in config file. Defaulting to empty"
" nick"))
nick = ""
cookie_file = self.config[self.GENERAL_SECTION].get("cookie_file")
if cookie_file is None:
logger.warn(("'cookie_file' not set in config file. Using no cookie"
" file."))
super().__init__(nick, cookie_file=cookie_file)
self._commands: List[Command] = [] self._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,
@ -30,6 +122,23 @@ 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)
@ -38,6 +147,21 @@ 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)
@ -48,6 +172,13 @@ 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)
@ -57,11 +188,31 @@ 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,
@ -74,8 +225,41 @@ 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)
@ -87,11 +271,24 @@ 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,
@ -99,6 +296,10 @@ 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]))
@ -107,5 +308,70 @@ 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]

View file

@ -1,7 +1,7 @@
import asyncio import asyncio
import functools import functools
import logging import logging
from typing import Dict, List, Optional from typing import Dict, List, Optional, Union
from .message import LiveMessage from .message import LiveMessage
from .room import Room from .room import Room
@ -12,9 +12,12 @@ logger = logging.getLogger(__name__)
__all__ = ["Client"] __all__ = ["Client"]
class Client: class Client:
DEFAULT_NICK = "" def __init__(self,
default_nick: str,
def __init__(self) -> None: cookie_file: Optional[str] = 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()
@ -49,14 +52,34 @@ 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",
@ -103,6 +126,9 @@ 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
@ -126,6 +152,12 @@ 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,

View file

@ -23,13 +23,74 @@ __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, argstr: str) -> None: def __init__(self, raw: str) -> None:
self._argstr = argstr self._raw = raw
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
@ -94,31 +155,62 @@ class ArgumentData:
return text.split() return text.split()
def _parse_fancy(self, args: List[str]) -> FancyArgs: def _parse_fancy(self, args: List[str]) -> FancyArgs:
raise NotImplementedError 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 @property
def argstr(self) -> str: def raw(self) -> str:
return self._argstr return self._raw
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._argstr, escaped) self._basic_escaped = self._split(self._raw, 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._argstr, escaped) self._basic = self._split(self._raw, 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._argstr, escaped) basic = self._split(self._raw, 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._argstr, escaped) basic = self._split(self._raw, escaped)
self._fancy = self._parse_fancy(basic) self._fancy = self._parse_fancy(basic)
return self._fancy return self._fancy
@ -126,8 +218,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, argstr: str) -> None: def __init__(self, nick: str, raw: str) -> None:
super().__init__(argstr) super().__init__(raw)
self._nick = nick self._nick = nick

View file

@ -6,6 +6,7 @@ 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 *
@ -81,6 +82,9 @@ 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
@ -97,8 +101,9 @@ class Connection:
# Initialising # Initialising
def __init__(self, url: str) -> None: def __init__(self, url: str, cookie_file: Optional[str] = None) -> 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
@ -181,7 +186,12 @@ 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 websockets.connect(self._url) ws = await asyncio.wait_for(
websockets.connect(self._url,
extra_headers=self._cookie_jar.get_cookies_as_headers()),
self.CONNECT_TIMEOUT
)
logger.debug(f"Established ws connection to {self._url!r}")
self._ws = ws self._ws = ws
self._awaiting_replies = {} self._awaiting_replies = {}
@ -189,10 +199,15 @@ 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,
socket.gaierror): OSError, asyncio.TimeoutError):
logger.debug("Connection failed") logger.debug("Connection failed")
return False return False
@ -438,10 +453,11 @@ 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", None) packet_id = packet.get("id")
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, None) future = self._awaiting_replies.get(packet_id)
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

77
yaboli/cookiejar.py Normal file
View file

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

40
yaboli/database.py Normal file
View file

@ -0,0 +1,40 @@
import asyncio
import logging
import sqlite3
from typing import Any, Awaitable, Callable, TypeVar
from .util import asyncify
logger = logging.getLogger(__name__)
__all__ = ["Database", "operation"]
T = TypeVar('T')
def operation(func: Callable[..., T]) -> Callable[..., Awaitable[T]]:
async def wrapper(self: Any, *args: Any, **kwargs: Any) -> T:
async with self as db:
while True:
try:
return await asyncify(func, self, db, *args, **kwargs)
except sqlite3.OperationalError as e:
logger.warn(f"Operational error encountered: {e}")
await asyncio.sleep(5)
return wrapper
class Database:
def __init__(self, database: str) -> None:
self._connection = sqlite3.connect(database, check_same_thread=False)
self._lock = asyncio.Lock()
self.initialize(self._connection)
def initialize(self, db: Any) -> None:
pass
async def __aenter__(self) -> Any:
await self._lock.__aenter__()
return self._connection
async def __aexit__(self, *args: Any, **kwargs: Any) -> Any:
return await self._lock.__aexit__(*args, **kwargs)

View file

@ -10,7 +10,6 @@ __all__ = [
# Doing stuff in a room # Doing stuff in a room
"RoomNotConnectedException", "RoomNotConnectedException",
"EuphError", "EuphError",
"RoomClosedException",
] ]
class EuphException(Exception): class EuphException(Exception):
@ -66,14 +65,3 @@ 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

View file

@ -166,5 +166,8 @@ 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)

View file

@ -1,5 +1,6 @@
import configparser
import logging import logging
from typing import Dict, List, Optional from typing import Callable, Dict, List, Optional
from .bot import Bot from .bot import Bot
from .command import * from .command import *
@ -10,49 +11,77 @@ from .util import *
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
__all__ = ["Module", "ModuleBot"] __all__ = ["Module", "ModuleConstructor", "ModuleBot", "ModuleBotConstructor"]
class Module(Bot): class Module(Bot):
DESCRIPTION: Optional[str] = None DESCRIPTION: Optional[str] = None
def __init__(self, standalone: bool) -> None: def __init__(self,
super().__init__() config: configparser.ConfigParser,
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]] = [
"" "",
"Use \"!help {atmention} <module>\" to get more information on a" "For module-specific help, try \"!help {atmention} <module>\".",
" specific module."
] ]
MODULE_HELP_LIMIT = 5 MODULE_HELP_LIMIT = 5
def __init__(self) -> None: MODULES_SECTION = "modules"
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] = {}
self.register_botrulez(help_=False) # Load initial modules
self.register_general("help", self.cmd_help_general, args=False) for module_name in self.config[self.MODULES_SECTION]:
self.register_specific("help", self.cmd_help_specific, args=True) 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 register_module(self, name: str, module: Module) -> None: def load_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:
@ -62,7 +91,10 @@ class ModuleBot(Bot):
lines.append(line) lines.append(line)
if modules_without_desc: if modules_without_desc:
lines.append(", ".join(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: if self.HELP_POST is not None:
lines.extend(self.HELP_POST) lines.extend(self.HELP_POST)
@ -79,8 +111,7 @@ class ModuleBot(Bot):
return module.HELP_SPECIFIC return module.HELP_SPECIFIC
# Overwriting the botrulez help function async def cmd_modules_help(self,
async def cmd_help_specific(self,
room: Room, room: Room,
message: LiveMessage, message: LiveMessage,
args: SpecificArgumentData args: SpecificArgumentData
@ -100,6 +131,12 @@ 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)
@ -141,6 +178,18 @@ 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,
@ -158,3 +207,8 @@ 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
]

View file

@ -1,6 +1,6 @@
import asyncio import asyncio
import logging import logging
from typing import Any, Awaitable, Callable, List, Optional, TypeVar from typing import Any, Awaitable, Callable, List, Optional, Tuple, TypeVar
from .connection import Connection from .connection import Connection
from .events import Events from .events import Events
@ -19,6 +19,10 @@ 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]
@ -39,6 +43,12 @@ 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
@ -56,7 +66,8 @@ 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
@ -74,7 +85,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) self._connection = Connection(self._url, cookie_file=cookie_file)
self._events = Events() self._events = Events()
self._connected = asyncio.Event() self._connected = asyncio.Event()
@ -112,9 +123,20 @@ class Room:
# Connecting, reconnecting and disconnecting # Connecting, reconnecting and disconnecting
def _set_connected(self) -> None: async def _try_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()
@ -143,7 +165,7 @@ class Room:
self._account = Account.from_data(data) self._account = Account.from_data(data)
self._hello_received = True self._hello_received = True
self._set_connected() await self._try_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"]
@ -158,19 +180,22 @@ 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 "session" event # Send "snapshot" 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("session", messages) self._events.fire("snapshot", messages)
self._snapshot_received = True self._snapshot_received = True
self._set_connected() await self._try_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? # Can we even authenticate? (Assuming that passcode authentication is
if not "passcode" in data.get("auth_options", []): # 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() self._set_connected_failed()
return return
@ -202,11 +227,7 @@ class Room:
if not self._connected_successfully: if not self._connected_successfully:
return False return False
nick_needs_updating = (self._session is None self._events.fire("connected")
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:
@ -237,14 +258,34 @@ 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"{session.atmention} joined") logger.info(f"&{self.name}: {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"]
@ -258,7 +299,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"{user.atmention} left") logger.info(f"&{self.name}: {user.atmention} left")
self._events.fire("part", user) self._events.fire("part", user)
self._users = users self._users = users
@ -275,7 +316,7 @@ class Room:
else: else:
await self.who() # recalibrating self._users await self.who() # recalibrating self._users
logger.info(f"{atmention(nick_from)} is now called {atmention(nick_to)}") logger.info(f"&{self.name}: {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:
@ -291,7 +332,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"{session.atmention} left") logger.info(f"&{self.name}: {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:
@ -368,10 +409,6 @@ 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:
@ -471,3 +508,55 @@ 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

View file

@ -1,5 +1,6 @@
import re import re
from typing import TYPE_CHECKING, Any, Dict, Iterable, Iterator, List, Optional from typing import (TYPE_CHECKING, Any, Dict, Iterable, Iterator, List,
Optional, Tuple)
from .util import mention, normalize from .util import mention, normalize
@ -238,7 +239,12 @@ class LiveSession(Session):
# Live stuff # Live stuff
# TODO pm, once pm support is there. async def pm(self) -> Tuple[str, str]:
"""
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:

View file

@ -1,6 +1,16 @@
import asyncio
import datetime
import functools
import re import re
from typing import Any, Callable
__all__ = ["mention", "atmention", "normalize", "similar", "plural"] __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 # Name/nick related functions
@ -28,3 +38,36 @@ 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