Compare commits

...

17 commits

12 changed files with 277 additions and 45 deletions

14
.gitignore vendored
View file

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

View file

@ -2,6 +2,29 @@
## Next version ## 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) ## 1.1.2 (2019-04-14)
- fix room authentication - fix room authentication
@ -16,7 +39,7 @@
- change how config files are passed along - change how config files are passed along
- change module system to support config file changes - change module system to support config file changes
# 1.0.0 (2019-04-13) ## 1.0.0 (2019-04-13)
- add fancy argument parsing - add fancy argument parsing
- add login and logout command to room - add login and logout command to room
@ -28,9 +51,9 @@
## 0.2.0 (2019-04-12) ## 0.2.0 (2019-04-12)
- change config file format
- add `ALIASES` variable to `Bot` - add `ALIASES` variable to `Bot`
- add `on_connected` function to `Client` - add `on_connected` function to `Client`
- change config file format
## 0.1.0 (2019-04-12) ## 0.1.0 (2019-04-12)

View file

@ -12,7 +12,7 @@ Ensure that you have at least Python 3.7 installed.
To install yaboli or update your installation to the latest version, run: To install yaboli or update your installation to the latest version, run:
``` ```
$ pip install git+https://github.com/Garmelon/yaboli@v1.1.2 $ 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. The use of [venv](https://docs.python.org/3/library/venv.html) is recommended.
@ -39,7 +39,17 @@ class EchoBot(yaboli.Bot):
await message.reply(args.raw) await message.reply(args.raw)
``` ```
The bot's nick, cookie file and default rooms are specified in a config file. 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` The help command from the botrulez uses the `HELP_GENERAL` and `HELP_SPECIFIC`
fields. fields.
@ -52,6 +62,9 @@ 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, 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. 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 ## TODOs
- [ ] document yaboli (markdown files in a "docs" folder?) - [ ] document yaboli (markdown files in a "docs" folder?)

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

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

View file

@ -8,13 +8,14 @@ class EchoBot(yaboli.Bot):
"!echo <text> reply with exactly <text>", "!echo <text> reply with exactly <text>",
] ]
def __init__(self, config_file): def __init__(self, *args, **kwargs):
super().__init__(config_file) super().__init__(*args, **kwargs)
self.register_botrulez(kill=True) self.register_botrulez(kill=True)
self.register_general("echo", self.cmd_echo) self.register_general("echo", self.cmd_echo)
async def cmd_echo(self, room, message, args): async def cmd_echo(self, room, message, args):
await message.reply(args.raw) text = args.raw.strip() # ignoring leading and trailing whitespace
await message.reply(text)
if __name__ == "__main__": if __name__ == "__main__":

View file

@ -8,6 +8,10 @@ lib/
lib64 lib64
pyvenv.cfg pyvenv.cfg
# config files # 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 *.conf
cookie_jar *.cookie

View file

@ -1,11 +1,13 @@
from setuptools import setup [build-system]
requires = ["setuptools"]
build-backend = "setuptools.build_meta"
setup( [project]
name="yaboli", name = "yaboli"
version="1.1.2", version = "1.2.0"
packages=["yaboli"], dependencies = [
install_requires=["websockets==7.0"], "websockets >=10.3, <11"
) ]
# When updating the version, also: # When updating the version, also:
# - update the README.md installation instructions # - update the README.md installation instructions

View file

@ -17,7 +17,7 @@ from .session import *
from .util import * from .util import *
__all__ = ["STYLE", "FORMAT", "DATE_FORMAT", "FORMATTER", "enable_logging", __all__ = ["STYLE", "FORMAT", "DATE_FORMAT", "FORMATTER", "enable_logging",
"run"] "run", "run_modulebot"]
__all__ += bot.__all__ __all__ += bot.__all__
__all__ += client.__all__ __all__ += client.__all__
@ -54,12 +54,12 @@ def run(
bot_constructor: BotConstructor, bot_constructor: BotConstructor,
config_file: str = "bot.conf", config_file: str = "bot.conf",
) -> None: ) -> None:
# Load the config file
config = configparser.ConfigParser(allow_no_value=True)
config.read(config_file)
async def _run() -> None: async def _run() -> None:
while True: while True:
# Load the config file
config = configparser.ConfigParser(allow_no_value=True)
config.read(config_file)
bot = bot_constructor(config, config_file) bot = bot_constructor(config, config_file)
await bot.run() await bot.run()
@ -70,12 +70,12 @@ def run_modulebot(
module_constructors: Dict[str, ModuleConstructor], module_constructors: Dict[str, ModuleConstructor],
config_file: str = "bot.conf", config_file: str = "bot.conf",
) -> None: ) -> None:
# Load the config file
config = configparser.ConfigParser(allow_no_value=True)
config.read(config_file)
async def _run() -> None: async def _run() -> None:
while True: while True:
# Load the config file
config = configparser.ConfigParser(allow_no_value=True)
config.read(config_file)
modulebot = modulebot_constructor(config, config_file, modulebot = modulebot_constructor(config, config_file,
module_constructors) module_constructors)
await modulebot.run() await modulebot.run()

View file

@ -14,13 +14,43 @@ logger = logging.getLogger(__name__)
__all__ = ["Bot", "BotConstructor"] __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] = [] 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: str = "/me dies" KILL_REPLY: Optional[str] = "/me dies"
RESTART_REPLY: str = "/me restarts" RESTART_REPLY: Optional[str] = "/me restarts"
GENERAL_SECTION = "general" GENERAL_SECTION = "general"
ROOMS_SECTION = "rooms" ROOMS_SECTION = "rooms"
@ -50,10 +80,26 @@ class Bot(Client):
self.start_time = datetime.datetime.now() self.start_time = datetime.datetime.now()
def save_config(self) -> None: 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: with open(self.config_file, "w") as f:
self.config.write(f) self.config.write(f)
async def started(self) -> None: 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(): for room, password in self.config[self.ROOMS_SECTION].items():
if password is None: if password is None:
await self.join(room) await self.join(room)
@ -63,6 +109,12 @@ class Bot(Client):
# 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,
@ -70,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)
@ -78,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)
@ -88,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)
@ -97,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:
"""
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) 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,
@ -119,6 +230,36 @@ class Bot(Client):
kill: bool = False, kill: bool = False,
restart: 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)
@ -144,6 +285,10 @@ class Bot(Client):
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,
@ -151,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]))
@ -159,6 +308,10 @@ 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))
@ -167,6 +320,15 @@ class Bot(Client):
message: LiveMessage, message: LiveMessage,
args: SpecificArgumentData args: SpecificArgumentData
) -> None: ) -> 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) time = format_time(self.start_time)
delta = format_delta(datetime.datetime.now() - self.start_time) delta = format_delta(datetime.datetime.now() - self.start_time)
text = f"/me has been up since {time} UTC ({delta})" text = f"/me has been up since {time} UTC ({delta})"
@ -177,8 +339,18 @@ class Bot(Client):
message: LiveMessage, message: LiveMessage,
args: SpecificArgumentData args: SpecificArgumentData
) -> None: ) -> 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}") logger.info(f"Killed in &{room.name} by {message.sender.atmention}")
await message.reply(self.KILL_REPLY)
if self.KILL_REPLY is not None:
await message.reply(self.KILL_REPLY)
await self.part(room) await self.part(room)
async def cmd_restart(self, async def cmd_restart(self,
@ -186,8 +358,20 @@ class Bot(Client):
message: LiveMessage, message: LiveMessage,
args: SpecificArgumentData args: SpecificArgumentData
) -> None: ) -> 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}") logger.info(f"Restarted in &{room.name} by {message.sender.atmention}")
await message.reply(self.RESTART_REPLY)
if self.RESTART_REPLY is not None:
await message.reply(self.RESTART_REPLY)
await self.stop() await self.stop()
BotConstructor = Callable[[configparser.ConfigParser, str], Bot] BotConstructor = Callable[[configparser.ConfigParser, str], Bot]

View file

@ -82,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
@ -183,8 +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(
extra_headers=self._cookie_jar.get_cookies_as_headers()) 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 = {}
@ -200,7 +207,7 @@ class Connection:
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

View file

@ -180,10 +180,10 @@ 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
await self._try_set_connected() await self._try_set_connected()