Compare commits

..

17 commits

Author SHA1 Message Date
Joscha
23329238c6 Ignore notes 2017-05-23 06:03:34 +00:00
Joscha
966034bdde Move utilities to seperate file 2017-04-03 20:36:27 +00:00
Joscha
2529c2d238 Rewrite session and connection
A session and connection now have a room assigned to them for their lifetime.
You can't connect a session to another room.
The launch() function must only be called once.
2017-04-02 20:10:59 +00:00
Joscha
c4fdb2942e Change switching rooms 2017-04-02 14:26:03 +00:00
Joscha
75b2108b47 Remove old files 2017-03-29 20:56:22 +00:00
Joscha
f56af13ede Add function to update sessions from a listing
This logic is used multiple times in the session.
2017-03-29 20:35:45 +00:00
Joscha
eb2b459216 Change all event and reply handling functions to hidden 2017-03-29 20:30:52 +00:00
Joscha
e9354194cf Deal with almost all other (useful) events and commands 2017-03-29 20:25:43 +00:00
Joscha
14bae17104 Handle connecting to rooms 2017-03-29 17:24:29 +00:00
Joscha
04f7c9c781 Change "add_callback" functions to "subscribe" functions 2017-03-28 20:52:39 +00:00
Joscha
f366a02758 Add Session with a few events already implemented 2017-03-28 20:27:45 +00:00
Joscha
1b9d12d253 Revise Connection room switching logic 2017-03-28 20:27:21 +00:00
Joscha
f1314c7ec1 Add context handlers to Connection and use system ca_cert file 2017-03-28 16:24:32 +00:00
Joscha
aee8e5c118 Clean up Connection and add logging 2017-03-28 13:41:20 +00:00
Joscha
3b3ce99625 Add logging and log formats 2017-03-28 13:40:54 +00:00
Joscha
4e37154737 Clean up basic_types 2017-03-28 07:38:09 +00:00
Joscha
dd4b5144a9 Reorganize Message and SessionView into basic_types 2017-03-27 21:26:15 +00:00
31 changed files with 2242 additions and 3488 deletions

6
.gitignore vendored
View file

@ -1,4 +1,2 @@
__pycache__/ yaboli/__pycache__/
*.egg-info/ *.txt
/.mypy_cache/
/.venv/

View file

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

21
LICENSE
View file

@ -1,21 +0,0 @@
MIT License
Copyright (c) 2018 - 2019 Garmelon
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

View file

@ -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

View file

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

View file

@ -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`.

View file

@ -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

View file

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

View file

@ -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)

View file

@ -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

View file

@ -1,4 +0,0 @@
[mypy]
disallow_untyped_defs = True
disallow_incomplete_defs = True
no_implicit_optional = True

View file

@ -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.

View file

@ -1,83 +1,11 @@
import asyncio
import configparser
import logging import logging
from typing import Callable, Dict logging.basicConfig(
level=logging.DEBUG,
from .bot import * format="[{levelname: <7}] in {threadName: <17} <{name}>: {message}",
from .client import * style="{"
from .command import *
from .connection import *
from .database import *
from .events import *
from .exceptions import *
from .message import *
from .module import *
from .room import *
from .session import *
from .util import *
__all__ = ["STYLE", "FORMAT", "DATE_FORMAT", "FORMATTER", "enable_logging",
"run", "run_modulebot"]
__all__ += bot.__all__
__all__ += client.__all__
__all__ += command.__all__
__all__ += connection.__all__
__all__ += database.__all__
__all__ += events.__all__
__all__ += exceptions.__all__
__all__ += message.__all__
__all__ += module.__all__
__all__ += room.__all__
__all__ += session.__all__
__all__ += util.__all__
STYLE = "{"
FORMAT = "{asctime} [{levelname:<7}] <{name}>: {message}"
DATE_FORMAT = "%F %T"
FORMATTER = logging.Formatter(
fmt=FORMAT,
datefmt=DATE_FORMAT,
style=STYLE
) )
def enable_logging(name: str = "yaboli", level: int = logging.INFO) -> None: from .basic_types import Message, SessionView
handler = logging.StreamHandler() from .callbacks import Callbacks
handler.setFormatter(FORMATTER) from .connection import Connection
from .session import Session
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())

163
yaboli/basic_types.py Normal file
View file

@ -0,0 +1,163 @@
import time
class SessionView():
"""
This class keeps track of session details.
http://api.euphoria.io/#sessionview
"""
def __init__(self, id, name, server_id, server_era, session_id, is_staff=None, is_manager=None):
"""
id - agent/account id
name - name of the client when the SessionView was captured
server_id - id of the server
server_era - era of the server
session_id - session id (unique across euphoria)
is_staff - client is staff
is_manager - client is manager
"""
self.id = id
self.name = name
self.server_id = server_id
self.server_era = server_era
self.session_id = session_id
self.staff = is_staff
self.manager = is_manager
@classmethod
def from_data(cls, data):
"""
Creates and returns a session created from the data.
data - a euphoria SessionView
"""
view = cls(None, None, None, None, None)
view.read_data(data)
return view
def read_data(self, data):
if "id" in data: self.id = data.get("id")
if "name" in data: self.name = data.get("name")
if "server_id" in data: self.server_id = data.get("server_id")
if "server_era" in data: self.server_era = data.get("server_era")
if "session_id" in data: self.session_id = data.get("session_id")
if "is_staff" in data: self.is_staff = data.get("is_staff")
if "is_manager" in data: self.is_manager = data.get("is_manager")
def session_type(self):
"""
session_type() -> str
The session's type (bot, account, agent).
"""
return self.id.split(":")[0] if ":" in self.id else None
class Message():
"""
This class represents a single euphoria message.
http://api.euphoria.io/#message
"""
def __init__(self, id, time, sender, content, parent=None, edited=None, previous_edit_id=None,
deleted=None, truncated=None, encryption_key_id=None):
"""
id - message id
time - time the message was sent (epoch)
sender - SessionView of the sender
content - content of the message
parent - id of the parent message, or None
edited - time of last edit (epoch)
previous_edit_id - edit id of the most recent edit of this message
deleted - time of deletion (epoch)
truncated - message was truncated
encryption_key_id - id of the key that encrypts the message in storage
"""
self.id = id
self.time = time
self.sender = sender
self.content = content
self.parent = parent
self.edited = edited
self.previous_edit_id = previous_edit_id
self.deleted = deleted
self.truncated = truncated
self.encryption_key_id = encryption_key_id
@classmethod
def from_data(self, data):
"""
Creates and returns a message created from the data.
NOTE: This also creates a session object using the data in "sender".
data - a euphoria message: http://api.euphoria.io/#message
"""
sender = SessionView.from_data(data.get("sender"))
return self(
data.get("id"),
data.get("time"),
sender,
data.get("content"),
parent=data.get("parent"),
edited=data.get("edited"),
deleted=data.get("deleted"),
truncated=data.get("truncated"),
previous_edit_id=data.get("previous_edit_id"),
encryption_key_id=data.get("encryption_key_id")
)
def time_formatted(self, date=True):
"""
time_formatted(date=True) -> str
date - include date in format
Time in a readable format:
With date: YYYY-MM-DD HH:MM:SS
Without date: HH:MM:SS
"""
if date:
return time.strftime("%Y-%m-%d %H:%M:%S", time.gmtime(self.time))
else:
return time.strftime("%H:%M:%S", time.gmtime(self.time))
def formatted(self, show_time=False, date=True, insert_string=None, repeat_insert_string=True):
"""
formatted() -> strftime
The message contents in the following format (does not end on a newline):
<time><insert_string>[<sender name>] message content
<insert_string> more message on a new line
If repeat_insert_string is False, the insert_string will only appear
on the first line.
If show_time is False, the time will not appear in the first line of
the formatted message.
The date option works like it does in Message.time_formatted().
"""
msgtime = self.time_formatted(date) if show_time else ""
if insert_string is None:
insert_string = " " if show_time else ""
lines = self.content.split("\n")
# first line
msg = "{}{}[{}] {}\n".format(msgtime, insert_string, self.sender.name, lines[0])
# all other lines
for line in lines[1:]:
msg += "{}{} {} {}\n".format(
" "*len(msgtime),
insert_string if repeat_insert_string else " "*len(insert_string),
" "*len(self.sender.name),
line
)
return msg[:-1] # remove trailing newline

View file

@ -1,377 +1,600 @@
import configparser import time
import datetime
import logging
from typing import Callable, List, Optional
from .client import Client from . import callbacks
from .command import * from . import exceptions
from .message import LiveMessage, Message from . import room
from .room import Room
from .util import *
logger = logging.getLogger(__name__) class Bot():
__all__ = ["Bot", "BotConstructor"]
class Bot(Client):
""" """
A Bot is a Client that responds to commands and uses a config file to Empty bot class that can be built upon.
automatically set its nick and join rooms. Takes care of extended botrulez.
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] = [] def __init__(self, roomname, nick="yaboli", password=None, manager=None,
created_in=None, created_by=None):
PING_REPLY: str = "Pong!"
HELP_GENERAL: Optional[str] = None
HELP_SPECIFIC: Optional[List[str]] = None
KILL_REPLY: Optional[str] = "/me dies"
RESTART_REPLY: Optional[str] = "/me restarts"
GENERAL_SECTION = "general"
ROOMS_SECTION = "rooms"
def __init__(self,
config: configparser.ConfigParser,
config_file: str,
) -> None:
self.config = config
self.config_file = config_file
nick = self.config[self.GENERAL_SECTION].get("nick")
if nick is None:
logger.warn(("'nick' not set in config file. Defaulting to empty"
" nick"))
nick = ""
cookie_file = self.config[self.GENERAL_SECTION].get("cookie_file")
if cookie_file is None:
logger.warn(("'cookie_file' not set in config file. Using no cookie"
" file."))
super().__init__(nick, cookie_file=cookie_file)
self._commands: List[Command] = []
self.start_time = datetime.datetime.now()
def save_config(self) -> None:
""" """
Save the current state of self.config to the file passed in __init__ as roomname - name of the room to connect to
the config_file parameter. nick - nick to assume, None -> no nick
password - room password (in case the room is private)
Usually, this is the file that self.config was loaded from (if you use created_in - room the bot was created in
run or run_modulebot). created_by - nick of the person the bot was created by
""" """
with open(self.config_file, "w") as f: self.start_time = time.time()
self.config.write(f)
async def started(self) -> None: self.created_by = created_by
self.created_in = created_in
self.manager = manager
# modify/customize this in your __init__() function (or anywhere else you want, for that matter)
self.bot_description = ("This bot complies with the botrulez™ (https://github.com/jedevc/botrulez),\n"
"plus a few extra commands.")
self.helptexts = {}
self.detailed_helptexts = {}
self.room = room.Room(roomname, nick=nick, password=password)
self.room.add_callback("message", self.on_message)
self.commands = callbacks.Callbacks()
self.bot_specific_commands = []
self.add_command("clone", self.clone_command, "Clone this bot to another room.", # possibly add option to set nick?
("!clone @bot [ <room> [ --pw=<password> ] ]\n"
"<room> : the name of the room to clone the bot to\n"
"--pw : the room's password\n\n"
"Clone this bot to the room specified.\n"
"If the target room is passworded, you can use the --pw option to set\n"
"a password for the bot to use.\n"
"If no room is specified, this will use the current room and password."),
bot_specific=False)
self.add_command("help", self.help_command, "Show help information about the bot.",
("!help @bot [ -s | -c | <command> ]\n"
"-s : general syntax help\n"
"-c : only list the commands\n"
"<command> : any command from !help @bot -c\n\n"
"Shows detailed help for a command if you specify a command name.\n"
"Shows a list of commands and short description if no arguments are given."))
self.add_command("kill", self.kill_command, "Kill (stop) the bot.",
("!kill @bot [ -r ]\n"
"-r : restart the bot (will change the id)\n\n"
"The bot disconnects from the room and stops."))
self.add_command("ping", self.ping_command, "Replies 'Pong!'.",
("!ping @bot\n\n"
"This command was originally used to help distinguish bots from\n"
"people. Since the Great UI Change, this is no longer necessary as\n"
"bots and people are displayed separately."))
self.add_command("restart", self.restart_command, "Restart the bot (shorthand for !kill @bot -r).",
("!restart @bot\n\n"
"Restart the bot.\n"
"Short for !kill @bot -r"))
self.add_command("send", self.send_command, "Send the bot to another room.",
("!send @bot <room> [ --pw=<password> ]\n"
"--pw : the room's password\n\n"
"Sends this bot to the room specified. If the target room is passworded,\n"
"you can use the --pw option to set a password for the bot to use."))
self.add_command("uptime", self.uptime_command, "Show bot uptime since last (re-)start.",
("!uptime @bot [ -i s]\n"
"-i : show more detailed information\n\n"
"Shows the bot's uptime since the last start or restart.\n"
"Shows additional information (i.e. id) if the -i flag is set."))
self.add_command("show", self.show_command, detailed_helptext="You've found a hidden command! :)")
self.room.launch()
def stop(self):
""" """
This Client function is overwritten in order to join all the rooms stop() -> None
listed in the "rooms" section of self.config.
If you need to overwrite this function but want to keep the auto-join Kill this bot.
functionality, make sure to await super().started().
""" """
for room, password in self.config[self.ROOMS_SECTION].items(): self.room.stop()
if password is None:
await self.join(room) def add_command(self, command, function, helptext=None, detailed_helptext=None,
bot_specific=True):
"""
add_command(command, function, helptext, detailed_helptext, bot_specific) -> None
Subscribe a function to a command and add a help text.
If no help text is provided, the command won't be displayed by the !help command.
This overwrites any previously added command.
You can "hide" commands by specifying only the detailed helptext,
or no helptext at all.
If the command is not bot specific, no id has to be specified if there are multiple bots
with the same nick in a room.
"""
command = command.lower()
self.commands.remove(command)
self.commands.add(command, function)
if helptext and not command in self.helptexts:
self.helptexts[command] = helptext
elif not helptext and command in self.helptexts:
self.helptexts.pop(command)
if detailed_helptext and not command in self.detailed_helptexts:
self.detailed_helptexts[command] = detailed_helptext
elif not detailed_helptext and command in self.detailed_helptexts:
self.detailed_helptexts.pop(command)
if bot_specific and not command in self.bot_specific_commands:
self.bot_specific_commands.append(command)
elif not bot_specific and command in self.bot_specific_commands:
self.bot_specific_commands.remove(command)
def call_command(self, message):
"""
call_command(message) -> None
Calls all functions subscribed to the command with the arguments supplied in the message.
Deals with the situation that multiple bots of the same type and nick are in the same room.
"""
try:
command, bot_id, nick, arguments, flags, options = self.parse(message.content)
except exceptions.ParseMessageException:
return
else: else:
await self.join(room, password=password) command = command.lower()
nick = self.room.mentionable(nick).lower()
# Registering commands if not self.commands.exists(command):
return
def register(self, command: Command) -> None: if not nick == self.mentionable().lower():
return
if bot_id is not None: # id specified
if self.manager.get(bot_id) == self:
self.commands.call(command, message, arguments, flags, options)
else:
return
else: # no id specified
bots = self.manager.get_similar(self.roomname(), nick)
if self.manager.get_id(self) == min(bots): # only one bot should act
# either the bot is unique or the command is not bot-specific
if not command in self.bot_specific_commands or len(bots) == 1:
self.commands.call(command, message, arguments, flags, options)
else: # user has to select a bot
msg = ("There are multiple bots with that nick in this room. To select one,\n"
"please specify its id (from the list below) as follows:\n"
"!{} <id> @{} [your arguments...]\n").format(command, nick)
for bot_id in sorted(bots):
bot = bots[bot_id]
msg += "\n{} - @{} ({})".format(bot_id, bot.mentionable(), bot.creation_info())
self.room.send_message(msg, parent=message.id)
def roomname(self):
""" """
Register a Command (from the yaboli.command submodule). roomname() -> roomname
Usually, you don't have to call this function yourself. The room the bot is connected to.
""" """
self._commands.append(command) return self.room.room
def register_general(self, def password(self):
name: str,
cmdfunc: GeneralCommandFunction,
args: bool = True
) -> None:
""" """
Register a function as general bot command (i. e. no @mention of the password() -> password
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 current room's password.
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) return self.room.password
self.register(command)
def register_specific(self, def nick(self):
name: str,
cmdfunc: SpecificCommandFunction,
args: bool = True
) -> None:
""" """
Register a function as specific bot command (i. e. @mention of the bot nick() -> nick
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 The bot's full nick.
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) return self.room.nick
self.register(command)
# Processing commands def mentionable(self):
async def process_commands(self,
room: Room,
message: LiveMessage,
aliases: List[str] = []
) -> None:
""" """
If the message contains a command, call all matching command functions mentionable() -> nick
that were previously registered.
This function is usually called by the overwritten on_send() function. The bot's nick in a mentionable format.
""" """
nicks = [room.session.nick] + aliases return self.room.mentionable()
data = CommandData.from_string(message.content)
if data is not None: def creation_info(self):
logger.debug(f"Processing command from {message.content!r}")
for command in self._commands:
await command.run(room, message, nicks, data)
async def on_send(self, room: Room, message: LiveMessage) -> None:
""" """
This Client function is overwritten in order to automatically call creation_info() -> str
process_commands() with self.ALIASES.
If you need to overwrite this function, make sure to await Formatted info about the bot's creation
process_commands() with self.ALIASES somewhere in your function, or
await super().on_send().
""" """
await self.process_commands(room, message, aliases=self.ALIASES) info = "created {}".format(self.format_date())
# Help util if self.created_by:
info += " by @{}".format(self.room.mentionable(self.created_by))
def format_help(self, room: Room, lines: List[str]) -> str: if self.created_in:
info += " in &{}".format(self.created_in)
return info
def format_date(self, seconds=None):
""" """
Format a list of strings into a string, replacing certain placeholders format_date(seconds) -> str
with the actual values.
This function uses the str.format() function to replace the following: Format a time in epoch format to the format specified in self.date_format.
Defaults to self.start_time.
- {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) if seconds is None:
params = { seconds = self.start_time
"nick": room.session.nick,
"mention": room.session.mention,
"atmention": room.session.atmention,
}
return text.format(**params)
# Botrulez return time.strftime("%Y-%m-%d %H:%M:%S", time.gmtime(seconds))
def register_botrulez(self, def format_delta(self, delta=None):
ping: bool = True,
help_: bool = True,
uptime: bool = True,
kill: bool = False,
restart: bool = False,
) -> None:
""" """
Register the commands necessary for the bot to conform to the botrulez format_delta(delta) -> str
(https://github.com/jedevc/botrulez). Also includes a few optional
botrulez commands that are disabled by default.
- ping - register general and specific cmd_ping() Format a difference in seconds to the following format:
- help_ - register cmd_help_general() and cmd_help_specific() [- ][<days>d ][<hours>h ][<minutes>m ]<seconds>s
- uptime - register specific cmd_uptime Defaults to the current uptime if no delta is specified.
- 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 not delta:
self.register_general("ping", self.cmd_ping, args=False) delta = time.time() - self.start_time
self.register_specific("ping", self.cmd_ping, args=False)
if help_: delta = int(delta)
if self.HELP_GENERAL is None and self.HELP_SPECIFIC is None: uptime = ""
logger.warn(("HELP_GENERAL and HELP_SPECIFIC are None, but the"
" help command is enabled"))
self.register_general("help", self.cmd_help_general, args=False)
self.register_specific("help", self.cmd_help_specific, args=False)
if uptime: if delta < 0:
self.register_specific("uptime", self.cmd_uptime, args=False) uptime += "- "
delta = -delta
if kill: if delta >= 24*60*60:
self.register_specific("kill", self.cmd_kill, args=False) uptime +="{}d ".format(delta//(24*60*60))
delta %= 24*60*60
if restart: if delta >= 60*60:
self.register_specific("restart", self.cmd_restart, args=False) uptime += "{}h ".format(delta//(60*60))
delta %= 60*60
async def cmd_ping(self, if delta >= 60:
room: Room, uptime += "{}m ".format(delta//60)
message: LiveMessage, delta %= 60
args: ArgumentData
) -> None: uptime += "{}s".format(delta)
return uptime
def parse_command(self, message):
""" """
Reply with self.PING_REPLY. parse_command(message_content) -> command, bot_id, nick, argpart
Parse the "!command[ bot_id] @botname[ argpart]" part of a command.
""" """
await message.reply(self.PING_REPLY) # command name (!command)
split = message.split(maxsplit=1)
async def cmd_help_general(self, if len(split) < 2:
room: Room, raise exceptions.ParseMessageException("Not enough arguments")
message: LiveMessage, elif split[0][:1] != "!":
args: ArgumentData raise exceptions.ParseMessageException("Not a command")
) -> None:
command = split[0][1:]
message = split[1]
split = message.split(maxsplit=1)
# bot id
try:
bot_id = int(split[0])
except ValueError:
bot_id = None
else:
if len(split) <= 1:
raise exceptions.ParseMessageException("No bot nick")
message = split[1]
split = message.split(maxsplit=1)
# bot nick (@mention)
if split[0][:1] != "@":
raise exceptions.ParseMessageException("No bot nick")
nick = split[0][1:]
# arguments to the command
if len(split) > 1:
argpart = split[1]
else:
argpart = None
return command, bot_id, nick, argpart
def parse_arguments(self, argstr):
""" """
Reply with self.HELP_GENERAL, if it is not None. Uses format_help(). parse_arguments(argstr) -> arguments, flags, options
Parse the argument part of a command.
""" """
if self.HELP_GENERAL is not None: argstr += " " # so the last argument will also be captured
await message.reply(self.format_help(room, [self.HELP_GENERAL]))
async def cmd_help_specific(self, escaping = False
room: Room, quot_marks = None
message: LiveMessage, type_signs = 0
args: SpecificArgumentData option = None
) -> None: word = ""
arguments = []
flags = ""
options = {}
for char in argstr:
# backslash-escaping
if escaping:
word += char
escaping = False
elif char == "\\":
escaping = True
# quotation mark escaped strings
elif quot_marks:
if char == quot_marks:
quot_marks = None
else:
word += char
elif char in ['"', "'"]:
quot_marks = char
# type signs
elif char == "-":
if type_signs < 2 and not word:
type_signs += 1
else:
word += char
# "=" in options
elif char == "=" and type_signs == 2 and word and not option:
option = word
word = ""
# space - evaluate information collected so far
elif char == " ":
if word:
if type_signs == 0: # argument
arguments.append(word)
elif type_signs == 1: # flag(s)
for flag in word:
if not flag in flags:
flags += flag
elif type_signs == 2: # option
if option:
options[option] = word
else:
options[word] = True
# reset all state variables
escaping = False
quot_marks = None
type_signs = 0
option = None
word = ""
# all other chars and situations
else:
word += char
return arguments, flags, options
def parse(self, message):
""" """
Reply with self.HELP_SPECIFIC, if it is not None. Uses format_help(). parse(message_content) -> bool
Parse a message.
""" """
if self.HELP_SPECIFIC is not None: command, bot_id, nick, argpart = self.parse_command(message)
await message.reply(self.format_help(room, self.HELP_SPECIFIC))
async def cmd_uptime(self, if argpart:
room: Room, arguments, flags, options = self.parse_arguments(argpart)
message: LiveMessage, else:
args: SpecificArgumentData arguments = []
) -> None: flags = ""
options = {}
return command, bot_id, nick, arguments, flags, options
# ----- HANDLING OF EVENTS -----
def on_message(self, message):
""" """
Reply with the bot's uptime in the format specified by the botrulez. on_message(message) -> None
This uses the time that the Bot was first started, not the time the Gets called when a message is received (see __init__).
respective Room was created. A !restart (see register_botrulez()) will If you want to add a command to your bot, consider using add_command instead of overwriting
reset the bot uptime, but leaving and re-joining a room or losing this function.
connection won't.
""" """
time = format_time(self.start_time) self.call_command(message)
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, # ----- COMMANDS -----
room: Room,
message: LiveMessage, def clone_command(self, message, arguments, flags, options):
args: SpecificArgumentData
) -> None:
""" """
Remove the bot from this room. clone_command(message, *arguments, flags, options) -> None
If self.KILL_REPLY is not None, replies with that before leaving the Create a new bot.
room.
""" """
logger.info(f"Killed in &{room.name} by {message.sender.atmention}") if not arguments:
room = self.roomname()
password = self.room.password
else:
room = arguments[0]
if self.KILL_REPLY is not None: if room[:1] == "&":
await message.reply(self.KILL_REPLY) room = room[1:]
await self.part(room) if "pw" in options and options["pw"] is not True:
password = options["pw"]
else:
password = None
async def cmd_restart(self, try:
room: Room, bot = self.manager.create(room, password=password, created_in=self.roomname(),
message: LiveMessage, created_by=message.sender.name)
args: SpecificArgumentData except exceptions.CreateBotException:
) -> None: self.room.send_message("Bot could not be cloned.", parent=message.id)
else:
self.room.send_message("Cloned @{} to &{}.".format(bot.mentionable(), room),
parent=message.id)
def help_command(self, message, arguments, flags, options):
""" """
Restart the whole Bot. help_command(message, *arguments, flags, options) -> None
This is done by stopping the Bot, since the run() or run_modulebot() Show help about the bot.
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 arguments: # detailed help for one command
command = arguments[0]
if command[:1] == "!":
command = command[1:]
if self.RESTART_REPLY is not None: if command in self.detailed_helptexts:
await message.reply(self.RESTART_REPLY) msg = "Detailed help for !{}:\n".format(command)
msg += self.detailed_helptexts[command]
else:
msg = "No detailed help text found for !{}.".format(command)
if command in self.helptexts:
msg += "\n\n" + self.helptexts[command]
await self.stop() elif "s" in flags: # detailed syntax help
msg = ("SYNTAX HELP PLACEHOLDER")
BotConstructor = Callable[[configparser.ConfigParser, str], Bot] else: # just list all commands
msg = ""
if not "c" in flags:
msg += self.bot_description + "\n\n"
msg += "This bot supports the following commands:"
for command in sorted(self.helptexts):
helptext = self.helptexts[command]
msg += "\n!{} - {}".format(command, helptext)
if not "c" in flags:
msg += ("\n\nFor help on the command syntax, try: !help @{0} -s\n"
"For detailed help on a command, try: !help @{0} <command>\n"
"(Hint: Most commands have extra functionality, which is listed in their detailed help.)")
msg = msg.format(self.mentionable())
self.room.send_message(msg, parent=message.id)
def kill_command(self, message, arguments, flags, options):
"""
kill_command(message, *arguments, flags, options) -> None
stop the bot.
"""
if "r" in flags:
bot = self.manager.create(self.roomname())
bot.created_by = self.created_by
bot.created_in = self.created_in
self.room.send_message("/me exits.", message.id)
self.manager.remove(self.manager.get_id(self))
def ping_command(self, message, arguments, flags, options):
"""
ping_command(message, *arguments, flags, options) -> None
Send a "Pong!" reply on a !ping command.
"""
self.room.send_message("Pong!", parent=message.id)
def restart_command(self, message, arguments, flags, options):
"""
restart_command(message, *arguments, flags, options) -> None
Restart the bot (shorthand for !kill @bot -r).
"""
self.commands.call("kill", message, [], "r", {})
def send_command(self, message, arguments, flags, options):
"""
_command(message, *arguments, flags, options) -> None
Send this bot to another room.
"""
if not arguments:
return
else:
room = arguments[0]
if room[:1] == "&":
room = room[1:]
if "pw" in options and options["pw"] is not True:
password = options["pw"]
else:
password = None
self.room.send_message("/me moves to &{}.".format(room), parent=message.id)
self.room.change(room, password=password)
self.room.launch()
def show_command(self, message, arguments, flags, options):
"""
show_command(message, arguments, flags, options) -> None
Show arguments, flags and options.
"""
msg = "arguments: {}\nflags: {}\noptions: {}".format(arguments, repr(flags), options)
self.room.send_message(msg, parent=message.id)
def uptime_command(self, message, arguments, flags, options):
"""
uptime_command(message, arguments, flags, options) -> None
Show uptime and other info.
"""
stime = self.format_date()
utime = self.format_delta()
if "i" in flags:
msg = "uptime: {} ({})".format(stime, utime)
msg += "\nid: {}".format(self.manager.get_id(self))
msg += "\n{}".format(self.creation_info())
else:
msg = "/me is up since {} ({}).".format(stime, utime)
self.room.send_message(msg, message.id)

149
yaboli/botmanager.py Normal file
View file

@ -0,0 +1,149 @@
import json
from . import bot
from . import exceptions
class BotManager():
"""
Keep track of multiple bots in different rooms.
"""
def __init__(self, bot_class, default_nick="yaboli", max_bots=100,
bots_file="bots.json", data_file="data.json"):
"""
bot_class - class to create instances of
default_nick - default nick for all bots to assume when no nick is specified
max_bots - maximum number of bots allowed to exist simultaneously
None or 0 - no limit
bots_file - file the bot backups are saved to
None - no bot backups
data_file - file the bot data is saved to
- None - bot data isn't saved
"""
self.bot_class = bot_class
self.max_bots = max_bots
self.default_nick = default_nick
self.bots_file = bots_file
self.data_file = data_file
self._bots = {}
self._bot_id = 0
self._bot_data = {}
self._load_bots()
def create(self, room, password=None, nick=None, created_in=None, created_by=None):
"""
create(room, password, nick) -> bot
Create a new bot in room.
"""
if nick is None:
nick = self.default_nick
if self.max_bots and len(self._bots) >= self.max_bots:
raise exceptions.CreateBotException("max_bots limit hit")
else:
bot = self.bot_class(room, nick=nick, password=password, manager=self,
created_in=created_in, created_by=created_by)
self._bots[self._bot_id] = bot
self._bot_id += 1
self._save_bots()
return bot
def remove(self, bot_id):
"""
remove(bot_id) -> None
Kill a bot and remove it from the list of bots.
"""
if bot_id in self._bots:
self._bots[bot_id].stop()
self._bots.pop(bot_id)
self._save_bots()
def get(self, bot_id):
"""
get(self, bot_id) -> bot
Return bot with that id, if found.
"""
if bot_id in self._bots:
return self._bots[bot_id]
def get_id(self, bot):
"""
get_id(bot) -> bot_id
Return the bot id, if the bot is known.
"""
for bot_id, own_bot in self._bots.items():
if bot == own_bot:
return bot_id
def get_similar(self, room, nick):
"""
get_by_room(room, nick) -> dict
Collect all bots that are connected to the room and have that nick.
"""
return {bot_id: bot for bot_id, bot in self._bots.items()
if bot.roomname() == room and bot.mentionable().lower() == nick.lower()}
def _load_bots(self):
"""
_load_bots() -> None
Load and create bots from self.bots_file.
"""
if not self.bots_file:
return
try:
with open(self.bots_file) as f:
bots = json.load(f)
except FileNotFoundError:
pass
else:
for bot_info in bots:
bot = self.create(bot_info["room"], password=bot_info["password"],
nick=bot_info["nick"])
bot.created_in = bot_info["created_in"]
bot.created_by = bot_info["created_by"]
def _save_bots(self):
"""
_save_bots() -> None
Save all current bots to self.bots_file.
"""
if not self.bots_file:
return
bots = []
for bot_id, bot in self._bots.items():
bot_info = {}
bot_info["room"] = bot.roomname()
bot_info["password"] = bot.password()
bot_info["nick"] = bot.nick()
bot_info["created_in"] = bot.created_in
bot_info["created_by"] = bot.created_by
bots.append(bot_info)
with open(self.bots_file, "w") as f:
json.dump(bots, f)

62
yaboli/callbacks.py Normal file
View file

@ -0,0 +1,62 @@
class Callbacks():
"""
Manage callbacks
"""
def __init__(self):
self._callbacks = {}
def add(self, event, callback, *args, **kwargs):
"""
add(event, callback, *args, **kwargs) -> None
Add a function to be called on event.
The function will be called with *args and **kwargs.
Certain arguments might be added, depending on the event.
"""
if not event in self._callbacks:
self._callbacks[event] = []
callback_info = {
"callback": callback,
"args": args,
"kwargs": kwargs
}
self._callbacks[event].append(callback_info)
def remove(self, event):
"""
remove(event) -> None
Remove all callbacks attached to that event.
"""
if event in self._callbacks:
del self._callbacks[event]
def call(self, event, *args):
"""
call(event) -> None
Call all callbacks subscribed to the event with *args and the arguments specified when the
callback was added.
"""
if event in self._callbacks:
for c_info in self._callbacks[event]:
c = c_info["callback"]
args = c_info["args"] + args
kwargs = c_info["kwargs"]
c(*args, **kwargs)
def exists(self, event):
"""
exists(event) -> bool
Are any functions subscribed to this event?
"""
return event in self._callbacks

View file

@ -1,171 +0,0 @@
import asyncio
import functools
import logging
from typing import Dict, List, Optional, Union
from .message import LiveMessage
from .room import Room
from .session import LiveSession
logger = logging.getLogger(__name__)
__all__ = ["Client"]
class Client:
def __init__(self,
default_nick: str,
cookie_file: Optional[str] = None,
) -> None:
self._default_nick = default_nick
self._cookie_file = cookie_file
self._rooms: Dict[str, List[Room]] = {}
self._stop = asyncio.Event()
async def run(self) -> None:
await self.started()
await self._stop.wait()
async def stop(self) -> None:
await self.stopping()
tasks = []
for rooms in self._rooms.values():
for room in rooms:
tasks.append(asyncio.create_task(self.part(room)))
for task in tasks:
await task
self._stop.set()
# Managing rooms
def get(self, room_name: str) -> Optional[Room]:
rooms = self._rooms.get(room_name)
if rooms: # None or [] are False-y
return rooms[0]
else:
return None
def get_all(self, room_name: str) -> List[Room]:
return self._rooms.get(room_name, [])
async def join(self,
room_name: str,
password: Optional[str] = None,
nick: Optional[str] = None,
cookie_file: Union[str, bool] = True,
) -> Optional[Room]:
"""
cookie_file is the name of the file to store the cookies in. If it is
True, the client default is used. If it is False, no cookie file name
will be used.
"""
logger.info(f"Joining &{room_name}")
if nick is None:
nick = self._default_nick
this_cookie_file: Optional[str]
if isinstance(cookie_file, str): # This way, mypy doesn't complain
this_cookie_file = cookie_file
elif cookie_file:
this_cookie_file = self._cookie_file
else:
this_cookie_file = None
room = Room(room_name, password=password, target_nick=nick,
cookie_file=this_cookie_file)
room.register_event("connected",
functools.partial(self.on_connected, room))
room.register_event("snapshot",
functools.partial(self.on_snapshot, room))
room.register_event("send",
functools.partial(self.on_send, room))
room.register_event("join",
functools.partial(self.on_join, room))
room.register_event("part",
functools.partial(self.on_part, room))
room.register_event("nick",
functools.partial(self.on_nick, room))
room.register_event("edit",
functools.partial(self.on_edit, room))
room.register_event("pm",
functools.partial(self.on_pm, room))
room.register_event("disconnect",
functools.partial(self.on_disconnect, room))
if await room.connect():
rooms = self._rooms.get(room_name, [])
rooms.append(room)
self._rooms[room_name] = rooms
return room
else:
logger.warn(f"Could not join &{room.name}")
return None
async def part(self, room: Room) -> None:
logger.info(f"Leaving &{room.name}")
rooms = self._rooms.get(room.name, [])
rooms = [r for r in rooms if r is not room]
self._rooms[room.name] = rooms
await room.disconnect()
# Management stuff - overwrite these functions
async def started(self) -> None:
pass
async def stopping(self) -> None:
pass
# Event stuff - overwrite these functions
async def on_connected(self, room: Room) -> None:
pass
async def on_snapshot(self, room: Room, messages: List[LiveMessage]) -> None:
pass
async def on_send(self, room: Room, message: LiveMessage) -> None:
pass
async def on_join(self, room: Room, user: LiveSession) -> None:
pass
async def on_part(self, room: Room, user: LiveSession) -> None:
pass
async def on_nick(self,
room: Room,
user: LiveSession,
from_nick: str,
to_nick: str
) -> None:
pass
async def on_edit(self, room: Room, message: LiveMessage) -> None:
pass
async def on_login(self, room: Room, account_id: str) -> None:
pass
async def on_logout(self, room: Room) -> None:
pass
async def on_pm(self,
room: Room,
from_id: str,
from_nick: str,
from_room: str,
pm_id: str
) -> None:
pass
async def on_disconnect(self, room: Room, reason: str) -> None:
pass

View file

@ -1,384 +0,0 @@
import abc
import re
from typing import (Awaitable, Callable, Dict, List, NamedTuple, Optional,
Pattern, Tuple)
from .message import LiveMessage
from .room import Room
from .util import similar
# Different ways of parsing commands:
#
# - raw string
#
# - split into arguments by whitespace
# - parsed into positional, optional, flags
#
# - The above two with or without bash-style escaping
#
# All of the above can be done with any argstr, even with an empty one.
__all__ = ["FancyArgs", "ArgumentData", "SpecificArgumentData", "CommandData",
"Command", "GeneralCommandFunction", "GeneralCommand",
"SpecificCommandFunction", "SpecificCommand"]
class FancyArgs(NamedTuple):
"""
The fancy argument parser supports arguments of the following formats:
FLAGS:
These are one or more characters preceded by a single dash. Examples:
-a, -fghf, -vv
The fancy argument parser counts how often each character (also called
flag) appears. Each flag that appears once or more gets an entry in the
"flags" dict of the form: flags[flag] = amount
Exception: A single dash ("-") is interpreted as a positional argument.
OPTIONAL:
These are arguments of the form --<name> or --<name>=<value>, where <name>
is the name of the optional argument and <value> is its (optional) value.
Due to this syntax, the <name> may not include any "=" signs.
The optional arguments are collected in a dict of the form:
optional[name] = value or None
If the optional argument included a "=" after the name, but no further
characters, its value is the empty string. If it didn't include a "=" after
the name, its value is None.
If more than one optional argument appears with the same name, the last
argument's value is kept and all previous values discarded.
POSITIONAL:
Positional arguments are all arguments that don't start with "-" or "--".
They are compiled in a list and ordered in the same order they appeared in
after the command.
RAW:
At any time, a single "--" argument may be inserted. This separates the
positional and optional arguments and the flags from the raw arguments. All
arguments after the "--" are interpreted as raw arguments, even flags,
optional arguments and further "--"s.
For example, consider the following arguments:
ab -cd -c --ef=g --h i -- j --klm -nop -- qr
positional: ["ab", "i"]
optional: {"ef": "g", "h": None}
flags: {"c": 2, "d": 1}
raw: ["j", "--klm", "-nop", "--", "qr"]
"""
positional: List[str]
optional: Dict[str, Optional[str]]
flags: Dict[str, int]
raw: List[str]
class ArgumentData:
def __init__(self, raw: str) -> None:
self._raw = raw
self._basic: Optional[List[str]] = None
self._basic_escaped: Optional[List[str]] = None
self._fancy: Optional[FancyArgs] = None
self._fancy_escaped: Optional[FancyArgs] = None
def _split_escaped(self, text: str) -> List[str]:
"""
Splits the string into individual arguments, while allowing
bash-inspired quoting/escaping.
A single backslash escapes the immediately following character.
Double quotes allow backslash escapes, but escape all other characters.
Single quotes escape all characters.
The remaining string is split at all unescaped while space characters
(using str.isspace), similar to str.split without any arguments.
"""
words: List[str] = []
word: List[str] = []
backslash = False
quotes: Optional[str] = None
for char in text:
if backslash:
backslash = False
word.append(char)
elif quotes is not None:
if quotes == "\"" and char == "\\":
backslash = True
elif char == quotes:
quotes = None
else:
word.append(char)
elif char == "\\":
backslash = True
elif char in ["\"", "'"]:
quotes = char
elif char.isspace():
if word:
words.append("".join(word))
word = []
else:
word.append(char)
# ignoring any left-over backslashes or open quotes at the end
if word:
words.append("".join(word))
return words
def _split(self, text: str, escaped: bool) -> List[str]:
if escaped:
return self._split_escaped(text)
else:
return text.split()
def _parse_fancy(self, args: List[str]) -> FancyArgs:
positional: List[str] = []
optional: Dict[str, Optional[str]] = {}
flags: Dict[str, int] = {}
raw: List[str] = []
is_raw = False
for arg in args:
# raw arguments
if is_raw:
raw.append(arg)
# raw arguments separator
elif arg == "--":
is_raw = True
# optional arguments
elif arg[:2] == "--":
split = arg[2:].split("=", maxsplit=1)
name = split[0]
value = split[1] if len(split) == 2 else None
optional[name] = value
# the "-" exception
elif arg == "-":
positional.append(arg)
# flags
elif arg[:1] == "-":
for char in arg[1:]:
flags[char] = flags.get(char, 0) + 1
# positional arguments
else:
positional.append(arg)
return FancyArgs(positional, optional, flags, raw)
@property
def raw(self) -> str:
return self._raw
def basic(self, escaped: bool = True) -> List[str]:
if escaped:
if self._basic_escaped is None:
self._basic_escaped = self._split(self._raw, escaped)
return self._basic_escaped
else:
if self._basic is None:
self._basic = self._split(self._raw, escaped)
return self._basic
def fancy(self, escaped: bool = True) -> FancyArgs:
if escaped:
if self._fancy_escaped is None:
basic = self._split(self._raw, escaped)
self._fancy_escaped = self._parse_fancy(basic)
return self._fancy_escaped
else:
if self._fancy is None:
basic = self._split(self._raw, escaped)
self._fancy = self._parse_fancy(basic)
return self._fancy
def has_args(self) -> bool:
return bool(self.basic()) # The list of arguments is empty
class SpecificArgumentData(ArgumentData):
def __init__(self, nick: str, raw: str) -> None:
super().__init__(raw)
self._nick = nick
@property
def nick(self) -> str:
return self._nick
class CommandData:
_NAME_RE = re.compile(r"^!(\S+)")
_MENTION_RE = re.compile(r"^\s+@(\S+)")
def __init__(self,
name: str,
general: ArgumentData,
specific: Optional[SpecificArgumentData]
) -> None:
self._name = name
self._general = general
self._specific = specific
@property
def name(self) -> str:
return self._name
@property
def general(self) -> ArgumentData:
return self._general
@property
def specific(self) -> Optional[SpecificArgumentData]:
return self._specific
@staticmethod
def _take(pattern: Pattern, text: str) -> Optional[Tuple[str, str]]:
"""
Returns the pattern's first group and the rest of the string that
didn't get matched by the pattern.
Anchoring the pattern to the beginning of the string is the
responsibility of the pattern writer.
"""
match = pattern.match(text)
if not match:
return None
group = match.group(1)
rest = text[match.end():]
return group, rest
@classmethod
def from_string(cls, string: str) -> "Optional[CommandData]":
# If it looks like it should work in the euphoria UI, it should work.
# Since euphoria strips whitespace chars from the beginning and end of
# messages, we do too.
string = string.strip()
name_part = cls._take(cls._NAME_RE, string)
if name_part is None: return None
name, name_rest = name_part
general = ArgumentData(name_rest)
specific: Optional[SpecificArgumentData]
mention_part = cls._take(cls._MENTION_RE, name_rest)
if mention_part is None:
specific = None
else:
mention, rest = mention_part
specific = SpecificArgumentData(mention, rest)
return cls(name, general, specific)
class Command(abc.ABC):
def __init__(self, name: str) -> None:
self._name = name
async def run(self,
room: Room,
message: LiveMessage,
nicks: List[str],
data: CommandData,
) -> None:
if data.name == self._name:
await self._run(room, message, nicks, data)
@abc.abstractmethod
async def _run(self,
room: Room,
message: LiveMessage,
nicks: List[str],
data: CommandData,
) -> None:
pass
# General command
GeneralCommandFunction = Callable[[Room, LiveMessage, ArgumentData],
Awaitable[None]]
class GeneralCommand(Command):
def __init__(self,
name: str,
cmdfunc: GeneralCommandFunction,
args: bool
) -> None:
super().__init__(name)
self._cmdfunc = cmdfunc
self._args = args
async def _run(self,
room: Room,
message: LiveMessage,
nicks: List[str],
data: CommandData,
) -> None:
# Do we have arguments if we shouldn't?
if not self._args and data.general.has_args():
return
await self._cmdfunc(room, message, data.general)
# Specific command
SpecificCommandFunction = Callable[[Room, LiveMessage, SpecificArgumentData],
Awaitable[None]]
class SpecificCommand(Command):
def __init__(self,
name: str,
cmdfunc: SpecificCommandFunction,
args: bool
) -> None:
super().__init__(name)
self._cmdfunc = cmdfunc
self._args = args
async def _run(self,
room: Room,
message: LiveMessage,
nicks: List[str],
data: CommandData,
) -> None:
# Is this a specific command?
if data.specific is None:
return
# Are we being mentioned?
for nick in nicks:
if similar(nick, data.specific.nick):
break
else:
return # Yay, a rare occurrence of this structure!
# Do we have arguments if we shouldn't?
if not self._args and data.specific.has_args():
return
await self._cmdfunc(room, message, data.specific)

View file

@ -1,571 +1,279 @@
import asyncio
import json import json
import logging import logging
import socket import socket
from typing import Any, Awaitable, Callable, Dict, Optional import ssl
import time
import threading
import websocket
from websocket import WebSocketException as WSException
import websockets from .callbacks import Callbacks
from .cookiejar import CookieJar SSLOPT = {"ca_certs": ssl.get_default_verify_paths().cafile}
from .events import Events #SSLOPT = {"cert_reqs": ssl.CERT_NONE}
from .exceptions import * ROOM_FORMAT = "wss://euphoria.io/room/{}/ws"
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
#logger.setLevel(logging.INFO)
__all__ = ["Connection"] class Connection():
# This class could probably be cleaned up by introducing one or two well-placed
# Locks something for the next rewrite :P
class Connection:
""" """
The Connection handles the lower-level stuff required when connecting to Stays connected to a room in its own thread.
euphoria, such as: Callback functions are called when a packet is received.
- Creating a websocket connection Callbacks:
- Encoding and decoding packets (json) - all the message types from api.euphoria.io
- Waiting for the server's asynchronous replies to packets These pass the packet data and errors (if any) as arguments to the called functions.
- Keeping the connection alive (ping, ping-reply packets) The other callbacks don't pass any special arguments.
- Reconnecting (timeout while connecting, no pings received in some time) - "connect"
- "disconnect"
It doesn't respond to any events other than the ping-event and is otherwise - "stop"
"dumb".
Life cycle of a Connection:
1. create connection and register event callbacks
2. call connect()
3. send and receive packets, reconnecting automatically when connection is
lost
4. call disconnect(), then go to 2.
IN PHASE 1, parameters such as the url the Connection should connect to are
set. Usually, event callbacks are also registered in this phase.
IN PHASE 2, the Connection attempts to connect to the url set in phase 1.
If successfully connected, it fires a "connected" event.
IN PHASE 3, the Connection listenes for packets from the server and fires
the corresponding events. Packets can be sent using the Connection.
If the Connection has to reconnect for some reason, it first fires a
"reconnecting" event. Then it tries to reconnect until it has established a
connection to euphoria again. After the connection is reestablished, it
fires a "reconnected" event.
IN PHASE 4, the Connection fires a "disconnecting" event and then closes
the connection to euphoria. This event is the last event that is fired
until connect() is called again.
Events:
- "connected" : No arguments
- "reconnecting" : No arguments
- "reconnected" : No arguments
- "disconnecting" : No arguments
- "<euph event name>": the packet, parsed as JSON
Events ending with "-ing" ("reconnecting", "disconnecting") are fired at
the beginning of the process they represent. Events ending with "-ed"
("connected", "reconnected") are fired after the process they represent has
finished.
Examples for the last category of events include "message-event",
"part-event" and "ping".
""" """
# Timeout for waiting for the ws connection to be established def __init__(self, room, url_format=ROOM_FORMAT, tries=10, delay=30):
CONNECT_TIMEOUT = 10 # seconds """
url_format - url the bot will connect to, where the room name is represented by {}
tries - how often to try to reconnect when connection is lost (-1 - try forever)
delay - time (in seconds) to wait between tries
"""
# Maximum duration between euphoria's ping messages. Euphoria usually sends self.room = room
# ping messages every 20 to 30 seconds. self.tries = tries
PING_TIMEOUT = 40 # seconds self.delay = delay
self.url_format = url_format
# The delay between reconnect attempts. self.start_time = None
RECONNECT_DELAY = 40 # seconds
# States the Connection may be in self._stopping = True
_NOT_RUNNING = "not running"
_CONNECTING = "connecting"
_RUNNING = "running"
_RECONNECTING = "reconnecting"
_DISCONNECTING = "disconnecting"
# Initialising
def __init__(self, url: str, cookie_file: Optional[str] = None) -> None:
self._url = url
self._cookie_jar = CookieJar(cookie_file)
self._events = Events()
self._packet_id = 0
# This is the current status of the connection. It can be set to one of
# _NOT_RUNNING, _CONNECTING, _RUNNING, _RECONNECTING, or
# _DISCONNECTING.
#
# Always be careful to set any state-dependent variables.
self._state = self._NOT_RUNNING
self._connected_condition = asyncio.Condition()
self._disconnected_condition = asyncio.Condition()
self._event_loop: Optional[asyncio.Task[None]] = None
# These must always be (re)set together. If one of them is None, all
# must be None.
self._ws = None self._ws = None
self._awaiting_replies: Optional[Dict[str, asyncio.Future[Any]]] = None self._thread = None
self._ping_check: Optional[asyncio.Task[None]] = None self._send_id = 0
self._callbacks = Callbacks()
self._id_callbacks = Callbacks()
self._lock = threading.RLock()
self.register_event("ping-event", self._ping_pong) def __enter__(self):
self._lock.acquire()
return self
def register_event(self, def __exit__(self, exc_type, exc_value, traceback):
event: str, self._lock.release()
callback: Callable[..., Awaitable[None]]
) -> None: def _connect(self, tries=10, delay=30):
""" """
Register an event callback. _connect(tries, delay) -> bool
For an overview of the possible events, see the Connection docstring. delay - delay between retries (in seconds)
""" tries - maximum number of retries
-1 -> retry indefinitely
self._events.register(event, callback)
Returns True on success, False on failure.
# Connecting and disconnecting
Connect to the room.
async def _disconnect(self) -> None:
"""
Disconnect _ws and clean up _ws, _awaiting_replies and _ping_check.
Important: The caller must ensure that this function is called in valid
circumstances and not called twice at the same time. _disconnect() does
not check or manipulate _state.
"""
if self._ws is not None:
logger.debug("Closing ws connection")
await self._ws.close()
# Checking self._ws again since during the above await, another
# disconnect call could have finished cleaning up.
if self._ws is None:
# This indicates that _ws, _awaiting_replies and _ping_check are
# cleaned up
logger.debug("Ws connection already cleaned up")
return
logger.debug("Cancelling futures waiting for replies")
for future in self._awaiting_replies.values():
future.set_exception(ConnectionClosedException())
logger.debug("Cancelling ping check task")
self._ping_check.cancel()
logger.debug("Cleaning up variables")
self._ws = None
self._awaiting_replies = None
self._ping_check = None
async def _connect(self) -> bool:
"""
Attempts once to create a ws connection.
Important: The caller must ensure that this function is called in valid
circumstances and not called twice at the same time. _connect() does
not check or manipulate _state, nor does it perform cleanup on
_awaiting_replies or _ping_check.
""" """
while tries != 0:
try: try:
logger.debug(f"Creating ws connection to {self._url!r}") url = self.url_format.format(self.room)
ws = await asyncio.wait_for( logger.info("Connecting to url: {!r} ({} {} left)".format(
websockets.connect(self._url, url,
extra_headers=self._cookie_jar.get_cookies_as_headers()), tries-1 if tries > 0 else "infinite",
self.CONNECT_TIMEOUT "tries" if (tries-1) != 1 else "try" # proper english :D
))
self._ws = websocket.create_connection(
url,
enable_multithread=True,
sslopt=SSLOPT
) )
logger.debug(f"Established ws connection to {self._url!r}")
self._ws = ws except (WSException, socket.gaierror, TimeoutError):
self._awaiting_replies = {} if tries > 0:
logger.debug("Starting ping check") tries -= 1
self._ping_check = asyncio.create_task( if tries != 0:
self._disconnect_in(self.PING_TIMEOUT)) logger.info("Connection failed. Retrying in {} seconds.".format(delay))
time.sleep(delay)
# Put received cookies into cookie jar else:
for set_cookie in ws.response_headers.get_all("Set-Cookie"): logger.info("No more tries, stopping.")
self._cookie_jar.add_cookie(set_cookie) self.stop()
self._cookie_jar.save()
else:
logger.debug("Connected")
self._callbacks.call("connect")
return True return True
except (websockets.InvalidHandshake, websockets.InvalidStatusCode,
OSError, asyncio.TimeoutError):
logger.debug("Connection failed")
return False return False
async def _disconnect_in(self, delay: int) -> None: def _run(self):
await asyncio.sleep(delay)
logger.debug(f"Disconnect timeout of {delay}s elapsed, disconnecting...")
# Starting the _disconnect function in another task because otherwise,
# its own CancelledError would inhibit _disconnect() from completing
# the disconnect.
#
# We don't need to check the state because _disconnect_in only runs
# while the _state is _RUNNING.
asyncio.create_task(self._disconnect())
async def _reconnect(self) -> bool:
""" """
This function should only be called from the event loop while the _run() -> None
_state is _RUNNING.
Receive messages.
""" """
if self._state != self._RUNNING: logger.debug("Running")
raise IncorrectStateException("This should never happen")
logger.debug("Reconnecting...") while not self._stopping:
self._events.fire("reconnecting") try:
self._state = self._RECONNECTING j = self._ws.recv()
self._handle_json(j)
except (WSException, ConnectionResetError):
if not self._stopping:
self.disconnect()
self._connect(self.tries, self.delay)
await self._disconnect() logger.debug("Finished running")
success = await self._connect() self._thread = None
self._state = self._RUNNING def _handle_json(self, data):
self._events.fire("reconnected")
logger.debug("Sending connected notification")
async with self._connected_condition:
self._connected_condition.notify_all()
logger.debug("Reconnected" if success else "Reconnection failed")
return success
async def connect(self) -> bool:
""" """
Attempt to create a connection to the Connection's url. _handle_json(data) -> None
Returns True if the Connection could connect to the url and is now Handle incoming 'raw' data.
running. Returns False if the Connection could not connect to the url
and is not running.
Exceptions:
This function must be called while the connection is not running,
otherwise an IncorrectStateException will be thrown. To stop a
Connection, use disconnect().
""" """
# Special exception message for _CONNECTING. packet = json.loads(data)
if self._state == self._CONNECTING: self._handle_packet(packet)
raise IncorrectStateException(("connect() may not be called"
" multiple times."))
if self._state != self._NOT_RUNNING: def _handle_packet(self, packet):
raise IncorrectStateException(("disconnect() must complete before" """
" connect() may be called again.")) _handle_packet(ptype, data) -> None
logger.debug("Connecting...") Handle incoming packets
"""
# Now we're sure we're in the _NOT_RUNNING state, we can set our state. ptype = packet.get("type")
# Important: No await-ing has occurred between checking the state and logger.debug("Handling packet of type {}.".format(ptype))
# setting it.
self._state = self._CONNECTING
success = await self._connect() data = packet.get("data")
if "error" in packet:
logger.debug("Error in packet: {!r}".format(error))
if success: if "id" in packet:
logger.debug("Starting event loop") self._id_callbacks.call(packet["id"], data, packet)
self._event_loop = asyncio.create_task(self._run()) self._id_callbacks.remove(packet["id"])
self._state = self._RUNNING
self._events.fire("connected") self._callbacks.call(packet["type"], data, packet)
def _send_json(self, data):
"""
_send_json(data) -> None
Send 'raw' json.
"""
if self._ws:
try:
self._ws.send(json.dumps(data))
except WSException:
self.disconnect()
def launch(self):
"""
launch() -> bool
Connect to the room and spawn a new thread.
Returns True if connecting was successful and a new thread was spawned.
"""
if self._connect(1):
self.start_time = time.time()
self._stopping = False
self._thread = threading.Thread(
target=self._run,
name="{}-{}".format(int(self.start_time), self.room)
)
logger.debug("Launching new thread: {}".format(self._thread.name))
self._thread.start()
return True
else: else:
self._state = self._NOT_RUNNING return False
logger.debug("Sending connected notification") def disconnect(self):
async with self._connected_condition:
self._connected_condition.notify_all()
logger.debug("Connected" if success else "Connection failed")
return success
async def disconnect(self) -> None:
""" """
Close and stop the Connection, if it is currently (re-)connecting or disconnect() -> None
running. Does nothing if the Connection is not running.
This function returns once the Connection has stopped running. Disconnect from the room.
This will cause the connection to reconnect.
To completely disconnect, use stop().
""" """
# Possible states left: _NOT_RUNNING, _CONNECTING, _RUNNING, if self._ws:
# _RECONNECTING, _DISCONNECTING logger.debug("Closing connection!")
self._ws.abort()
# Waiting until the current connection attempt is finished. Using a self._ws.close()
# while loop since the event loop might have started to reconnect again self._ws = None
# while the await is still waiting.
while self._state in [self._CONNECTING, self._RECONNECTING]:
# After _CONNECTING, the state can either be _NOT_RUNNING or
# _RUNNING. After _RECONNECTING, the state must be _RUNNING.
async with self._connected_condition:
await self._connected_condition.wait()
# Possible states left: _NOT_RUNNING, _RUNNING, _DISCONNECTING
if self._state == self._NOT_RUNNING:
# No need to do anything since we're already disconnected
logger.debug("Already disconnected")
return
# Possible states left: _RUNNING, _DISCONNECTING
if self._state == self._DISCONNECTING:
# Wait until the disconnecting currently going on is complete. This
# is to prevent the disconnect() function from ever returning
# without the disconnecting process being finished.
logger.debug("Already disconnecting, waiting for it to finish...")
async with self._disconnected_condition:
await self._disconnected_condition.wait()
logger.debug("Disconnected, finished waiting")
return
# Possible states left: _RUNNING
# By principle of exclusion, the only state left is _RUNNING. Doing an
# explicit check though, just to make sure.
if self._state != self._RUNNING:
raise IncorrectStateException("This should never happen.")
logger.debug("Disconnecting...")
self._events.fire("disconnecting")
# Now we're sure we're in the _RUNNING state, we can set our state.
# Important: No await-ing has occurred between checking the state and
# setting it.
self._state = self._DISCONNECTING
await self._disconnect()
# We know that _event_loop is not None, but this is to keep mypy happy.
logger.debug("Waiting for event loop")
if self._event_loop is not None:
await self._event_loop
self._event_loop = None
self._state = self._NOT_RUNNING
# Notify all other disconnect()s waiting
logger.debug("Sending disconnected notification")
async with self._disconnected_condition:
self._disconnected_condition.notify_all()
logger.debug("Disconnected") logger.debug("Disconnected")
self._id_callbacks = Callbacks() # we don't need the old id callbacks any more
self._callbacks.call("disconnect")
async def reconnect(self) -> None: def stop(self):
""" """
Forces the Connection to reconnect. stop() -> None
This function may return before the reconnect process is finished. Close the connection to the room.
Joins the thread launched by self.launch().
Exceptions:
This function must be called while the connection is (re-)connecting or
running, otherwise an IncorrectStateException will be thrown.
""" """
if self._state in [self._CONNECTING, self._RECONNECTING]: logger.debug("Stopping")
logger.debug("Already (re-)connecting, waiting for it to finish...") self._stopping = True
async with self._connected_condition: self.disconnect()
await self._connected_condition.wait()
logger.debug("(Re-)connected, finished waiting") self._callbacks.call("stop")
return
if self._state != self._RUNNING: if self._thread and self._thread != threading.current_thread():
raise IncorrectStateException(("reconnect() may not be called while" self._thread.join()
" the connection is not running."))
# Disconnecting via task because otherwise, the _connected_condition def next_id(self):
# might fire before we start waiting for it.
#
# The event loop will reconenct after the ws connection has been
# disconnected.
logger.debug("Disconnecting and letting the event loop reconnect")
await self._disconnect()
# Running
async def _run(self) -> None:
""" """
The main loop that runs during phase 3 next_id() -> id
Returns the id that will be used for the next package.
""" """
while True: return str(self._send_id)
# The "Exiting event loop" checks are a bit ugly. They're in place
# so that the event loop exits on its own at predefined positions
# instead of randomly getting thrown a CancelledError.
#
# Now that I think about it, the whole function looks kinda ugly.
# Maybe one day (yeah, right), I'll clean this up. I want to get it
# working first though.
if self._state != self._RUNNING: def subscribe(self, ptype, callback, *args, **kwargs):
logger.debug("Exiting event loop")
return
if self._ws is not None:
try:
logger.debug("Receiving ws packets")
async for packet in self._ws:
logger.debug(f"Received packet {packet}")
packet_data = json.loads(packet)
self._process_packet(packet_data)
except websockets.ConnectionClosed:
logger.debug("Stopped receiving ws packets")
else:
logger.debug("No ws connection found")
if self._state != self._RUNNING:
logger.debug("Exiting event loop")
return
logger.debug("Attempting to reconnect")
while not await self._reconnect():
logger.debug("Reconnect attempt not successful")
if self._state != self._RUNNING:
logger.debug("Exiting event loop")
return
logger.debug(f"Sleeping for {self.RECONNECT_DELAY}s and retrying")
await asyncio.sleep(self.RECONNECT_DELAY)
def _process_packet(self, packet: Any) -> None:
# This function assumes that the packet is formed correctly according
# to http://api.euphoria.io/#packets.
# First, notify whoever's waiting for this packet
packet_id = packet.get("id")
if packet_id is not None and self._awaiting_replies is not None:
future = self._awaiting_replies.get(packet_id)
if future is not None:
del self._awaiting_replies[packet_id]
future.set_result(packet)
# Then, send the corresponding event
packet_type = packet["type"]
self._events.fire(packet_type, packet)
# Finally, reset the ping check
if packet_type == "ping-event":
logger.debug("Resetting ping check")
if self._ping_check is not None:
self._ping_check.cancel()
self._ping_check = asyncio.create_task(
self._disconnect_in(self.PING_TIMEOUT))
async def _do_if_possible(self, coroutine: Awaitable[None]) -> None:
""" """
Try to run a coroutine, ignoring any IncorrectStateExceptions. subscribe(ptype, callback, *args, **kwargs) -> None
"""
try:
await coroutine
except IncorrectStateException:
pass
async def _send_if_possible(self, packet_type: str, data: Any,) -> None: Add a function to be called when a packet of type ptype is received.
"""
This function tries to send a packet without awaiting the reply.
It ignores IncorrectStateExceptions, meaning that if it is called while
in the wrong state, nothing will happen.
""" """
try: self._callbacks.add(ptype, callback, *args, **kwargs)
await self.send(packet_type, data, await_reply=False)
except IncorrectStateException:
logger.debug("Could not send (disconnecting or already disconnected)")
async def _ping_pong(self, packet: Any) -> None: def subscribe_to_id(self, pid, callback, *args, **kwargs):
""" """
Implements http://api.euphoria.io/#ping and is called as "ping-event" subscribe_to_id(pid, callback, *args, **kwargs) -> None
callback.
"""
logger.debug("Pong!")
await self._do_if_possible(self.send(
"ping-reply",
{"time": packet["data"]["time"]},
await_reply=False
))
async def send(self, Add a function to be called when a packet with id pid is received.
packet_type: str,
data: Any,
await_reply: bool = True
) -> Any:
"""
Send a packet of type packet_type to the server.
The object passed as data will make up the packet's "data" section and
must be json-serializable.
This function will return the complete json-deserialized reply package,
unless await_reply is set to False, in which case it will immediately
return None.
Exceptions:
This function must be called while the Connection is (re-)connecting or
running, otherwise an IncorrectStateException will be thrown.
If the connection closes unexpectedly while sending the packet or
waiting for the reply, a ConnectionClosedException will be thrown.
""" """
while self._state in [self._CONNECTING, self._RECONNECTING]: self._id_callbacks.add(pid, callback, *args, **kwargs)
async with self._connected_condition:
await self._connected_condition.wait()
if self._state != self._RUNNING: def subscribe_to_next(self, callback, *args, **kwargs):
raise IncorrectStateException(("send() must be called while the" """
" Connection is running")) subscribe_to_next(callback, *args, **kwargs) -> None
# We're now definitely in the _RUNNING state Add a function to be called when the answer to the next message sent is received.
"""
# Since we're in the _RUNNING state, _ws and _awaiting_replies are not self._id_callbacks.add(self.next_id(), callback, *args, **kwargs)
# None. This check is to satisfy mypy.
if self._ws is None or self._awaiting_replies is None:
raise IncorrectStateException("This should never happen")
packet_id = str(self._packet_id) def send_packet(self, ptype, **kwargs):
self._packet_id += 1 """
send_packet(ptype, **kwargs) -> None
# Doing this before the await below since we know that Send a formatted packet.
# _awaiting_replies is not None while the _state is _RUNNING. """
if await_reply:
response: asyncio.Future[Any] = asyncio.Future()
self._awaiting_replies[packet_id] = response
text = json.dumps({"id": packet_id, "type": packet_type, "data": data}) packet = {
logger.debug(f"Sending packet {text}") "type": ptype,
try: "data": kwargs or None,
await self._ws.send(text) "id": str(self._send_id)
except websockets.ConnectionClosed: }
raise ConnectionClosedException() # as promised in the docstring self._send_id += 1
self._send_json(packet)
if await_reply:
await response
# If the response Future was completed with a
# ConnectionClosedException via set_exception(), response.result()
# will re-raise that exception.
return response.result()
else:
return None

View file

@ -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()

View file

@ -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)

View file

@ -1,25 +0,0 @@
import asyncio
import logging
from typing import Any, Awaitable, Callable, Dict, List
logger = logging.getLogger(__name__)
__all__ = ["Events"]
class Events:
def __init__(self) -> None:
self._callbacks: Dict[str, List[Callable[..., Awaitable[None]]]] = {}
def register(self,
event: str,
callback: Callable[..., Awaitable[None]]
) -> None:
callback_list = self._callbacks.get(event, [])
callback_list.append(callback)
self._callbacks[event] = callback_list
logger.debug(f"Registered callback for event {event!r}")
def fire(self, event: str, *args: Any, **kwargs: Any) -> None:
logger.debug(f"Calling callbacks for event {event!r}")
for callback in self._callbacks.get(event, []):
asyncio.create_task(callback(*args, **kwargs))

View file

@ -1,67 +0,0 @@
__all__ = [
"EuphException",
# Connection exceptions
"IncorrectStateException",
"ConnectionClosedException",
# Joining a room
"JoinException",
"CouldNotConnectException",
"CouldNotAuthenticateException",
# Doing stuff in a room
"RoomNotConnectedException",
"EuphError",
]
class EuphException(Exception):
pass
# Connection exceptions
class IncorrectStateException(EuphException):
"""
A Connection function was called while the Connection was in the incorrect
state.
"""
pass
class ConnectionClosedException(EuphException):
"""
The connection was closed unexpectedly.
"""
pass
# Joining a room
class JoinException(EuphException):
"""
An exception that happened while joining a room.
"""
pass
class CouldNotConnectException(JoinException):
"""
Could not establish a websocket connection to euphoria.
"""
pass
class CouldNotAuthenticateException(JoinException):
"""
The password is either incorrect or not set, even though authentication is
required.
"""
pass
# Doing stuff in a room
class RoomNotConnectedException(EuphException):
"""
Either the Room's connect() function has not been called or it has not
completed successfully.
"""
pass
class EuphError(EuphException):
"""
The euphoria server has sent back an "error" field in its response.
"""
pass

View file

@ -1,173 +0,0 @@
import datetime
from typing import TYPE_CHECKING, Any, List, Optional
from .session import LiveSession, Session
if TYPE_CHECKING:
from .room import Room
__all__ = ["Message", "LiveMessage"]
class Message:
def __init__(self,
room_name: str,
message_id: str,
parent_id: Optional[str],
previous_edit_id: Optional[str],
timestamp: int,
sender: Session,
content: str,
encryption_key_id: Optional[str],
edited_timestamp: Optional[int],
deleted_timestamp: Optional[int],
truncated: bool
) -> None:
self._room_name = room_name
self._message_id = message_id
self._parent_id = parent_id
self._previous_edit_id = previous_edit_id
self._timestamp = timestamp
self._sender = sender
self._content = content
self._encryption_key_id = encryption_key_id
self._edited_timestamp = edited_timestamp
self._deleted_timestamp = deleted_timestamp
self._truncated = truncated
@classmethod
def from_data(cls, room_name: str, data: Any) -> "Message":
message_id = data["id"]
parent_id = data.get("parent")
previous_edit_id = data.get("previous_edit_id")
timestamp = data["time"]
sender = Session.from_data(room_name, data["sender"])
content = data["content"]
encryption_key_id = data.get("encryption_key_id")
edited_timestamp = data.get("edited")
deleted_timestamp = data.get("deleted")
truncated = data.get("truncated", False)
return cls(room_name, message_id, parent_id, previous_edit_id,
timestamp, sender, content, encryption_key_id,
edited_timestamp, deleted_timestamp, truncated)
# Attributes
@property
def room_name(self) -> str:
return self._room_name
@property
def message_id(self) -> str:
return self._message_id
@property
def parent_id(self) -> Optional[str]:
return self._parent_id
@property
def previous_edit_id(self) -> Optional[str]:
return self._previous_edit_id
@property
def time(self) -> datetime.datetime:
return datetime.datetime.fromtimestamp(self.timestamp)
@property
def timestamp(self) -> int:
return self._timestamp
@property
def sender(self) -> Session:
return self._sender
@property
def content(self) -> str:
return self._content
@property
def encryption_key_id(self) -> Optional[str]:
return self._encryption_key_id
@property
def edited_time(self) -> Optional[datetime.datetime]:
if self.edited_timestamp is not None:
return datetime.datetime.fromtimestamp(self.edited_timestamp)
else:
return None
@property
def edited_timestamp(self) -> Optional[int]:
return self._edited_timestamp
@property
def deleted_time(self) -> Optional[datetime.datetime]:
if self.deleted_timestamp is not None:
return datetime.datetime.fromtimestamp(self.deleted_timestamp)
else:
return None
@property
def deleted_timestamp(self) -> Optional[int]:
return self._deleted_timestamp
@property
def truncated(self) -> bool:
return self._truncated
class LiveMessage(Message):
def __init__(self,
room: "Room",
message_id: str,
parent_id: Optional[str],
previous_edit_id: Optional[str],
timestamp: int,
sender: LiveSession,
content: str,
encryption_key_id: Optional[str],
edited_timestamp: Optional[int],
deleted_timestamp: Optional[int],
truncated: bool
) -> None:
super().__init__(room.name, message_id, parent_id, previous_edit_id,
timestamp, sender, content, encryption_key_id,
edited_timestamp, deleted_timestamp, truncated)
self._room = room
self._live_sender = sender
@classmethod
def from_data(cls, # type: ignore
room: "Room",
data: Any
) -> "LiveMessage":
return cls.from_message(room, Message.from_data(room.name, data))
@classmethod
def from_message(cls, room: "Room", message: Message) -> "LiveMessage":
live_sender = LiveSession.from_session(room, message.sender)
return cls(room, message.message_id, message.parent_id,
message.previous_edit_id, message.timestamp, live_sender,
message.content, message.encryption_key_id,
message.edited_timestamp, message.deleted_timestamp,
message.truncated)
# Attributes
@property
def room(self) -> "Room":
return self._room
@property
def sender(self) -> LiveSession:
return self._live_sender
# Live stuff
async def reply(self, content: str) -> "LiveMessage":
return await self.room.send(content, parent_id=self.message_id)
async def get(self) -> "LiveMessage":
return await self.room.get(self.message_id)
async def before(self, amount: int) -> List["LiveMessage"]:
return await self.room.log(amount, before_id=self.message_id)

4
yaboli/messagedb.py Normal file
View file

@ -0,0 +1,4 @@
import sqlite3
class MessageDB():
pass

View file

@ -1,214 +0,0 @@
import configparser
import logging
from typing import Callable, Dict, List, Optional
from .bot import Bot
from .command import *
from .message import LiveMessage
from .room import Room
from .session import LiveSession
from .util import *
logger = logging.getLogger(__name__)
__all__ = ["Module", "ModuleConstructor", "ModuleBot", "ModuleBotConstructor"]
class Module(Bot):
DESCRIPTION: Optional[str] = None
def __init__(self,
config: configparser.ConfigParser,
config_file: str,
standalone: bool = True,
) -> None:
super().__init__(config, config_file)
self.standalone = standalone
ModuleConstructor = Callable[[configparser.ConfigParser, str, bool], Module]
class ModuleBot(Bot):
HELP_PRE: Optional[List[str]] = [
"This bot contains the following modules:"
]
HELP_POST: Optional[List[str]] = [
"",
"For module-specific help, try \"!help {atmention} <module>\".",
]
MODULE_HELP_LIMIT = 5
MODULES_SECTION = "modules"
def __init__(self,
config: configparser.ConfigParser,
config_file: str,
module_constructors: Dict[str, ModuleConstructor],
) -> None:
super().__init__(config, config_file)
self.module_constructors = module_constructors
self.modules: Dict[str, Module] = {}
# Load initial modules
for module_name in self.config[self.MODULES_SECTION]:
module_constructor = self.module_constructors.get(module_name)
if module_constructor is None:
logger.warn(f"Module {module_name} not found")
continue
# standalone is set to False
module = module_constructor(self.config, self.config_file, False)
self.load_module(module_name, module)
def load_module(self, name: str, module: Module) -> None:
if name in self.modules:
logger.warn(f"Module {name!r} is already registered, overwriting...")
self.modules[name] = module
def unload_module(self, name: str) -> None:
if name in self.modules:
del self.modules[name]
# Better help messages
def compile_module_overview(self) -> List[str]:
lines = []
if self.HELP_PRE is not None:
lines.extend(self.HELP_PRE)
any_modules = False
modules_without_desc: List[str] = []
for module_name in sorted(self.modules):
any_modules = True
module = self.modules[module_name]
if module.DESCRIPTION is None:
modules_without_desc.append(module_name)
else:
line = f"\t{module_name}{module.DESCRIPTION}"
lines.append(line)
if modules_without_desc:
lines.append("\t" + ", ".join(modules_without_desc))
if not any_modules:
lines.append("No modules loaded.")
if self.HELP_POST is not None:
lines.extend(self.HELP_POST)
return lines
def compile_module_help(self, module_name: str) -> List[str]:
module = self.modules.get(module_name)
if module is None:
return [f"Module {module_name!r} not found."]
elif module.HELP_SPECIFIC is None:
return [f"Module {module_name!r} has no detailed help message."]
return module.HELP_SPECIFIC
async def cmd_modules_help(self,
room: Room,
message: LiveMessage,
args: SpecificArgumentData
) -> None:
if args.has_args():
if len(args.basic()) > self.MODULE_HELP_LIMIT:
limit = self.MODULE_HELP_LIMIT
text = f"A maximum of {limit} module{plural(limit)} is allowed."
await message.reply(text)
else:
for module_name in args.basic():
help_lines = self.compile_module_help(module_name)
await message.reply(self.format_help(room, help_lines))
else:
help_lines = self.compile_module_overview()
await message.reply(self.format_help(room, help_lines))
# Sending along all kinds of events
async def on_connected(self, room: Room) -> None:
await super().on_connected(room)
for module in self.modules.values():
await module.on_connected(room)
async def on_snapshot(self, room: Room, messages: List[LiveMessage]) -> None:
await super().on_snapshot(room, messages)
for module in self.modules.values():
await module.on_snapshot(room, messages)
async def on_send(self, room: Room, message: LiveMessage) -> None:
await super().on_send(room, message)
for module in self.modules.values():
await module.on_send(room, message)
async def on_join(self, room: Room, user: LiveSession) -> None:
await super().on_join(room, user)
for module in self.modules.values():
await module.on_join(room, user)
async def on_part(self, room: Room, user: LiveSession) -> None:
await super().on_part(room, user)
for module in self.modules.values():
await module.on_part(room, user)
async def on_nick(self,
room: Room,
user: LiveSession,
from_nick: str,
to_nick: str
) -> None:
await super().on_nick(room, user, from_nick, to_nick)
for module in self.modules.values():
await module.on_nick(room, user, from_nick, to_nick)
async def on_edit(self, room: Room, message: LiveMessage) -> None:
await super().on_edit(room, message)
for module in self.modules.values():
await module.on_edit(room, message)
async def on_login(self, room: Room, account_id: str) -> None:
await super().on_login(room, account_id)
for module in self.modules.values():
await module.on_login(room, account_id)
async def on_logout(self, room: Room) -> None:
await super().on_logout(room)
for module in self.modules.values():
await module.on_logout(room)
async def on_pm(self,
room: Room,
from_id: str,
from_nick: str,
from_room: str,
pm_id: str
) -> None:
await super().on_pm(room, from_id, from_nick, from_room, pm_id)
for module in self.modules.values():
await module.on_pm(room, from_id, from_nick, from_room, pm_id)
async def on_disconnect(self, room: Room, reason: str) -> None:
await super().on_disconnect(room, reason)
for module in self.modules.values():
await module.on_disconnect(room, reason)
ModuleBotConstructor = Callable[
[configparser.ConfigParser, str, Dict[str, ModuleConstructor]],
Bot
]

File diff suppressed because it is too large Load diff

View file

@ -1,324 +1,344 @@
import re import logging
from typing import (TYPE_CHECKING, Any, Dict, Iterable, Iterator, List, import threading
Optional, Tuple)
from .util import mention, normalize from .callbacks import Callbacks
from .connection import Connection
from .basic_types import Message, SessionView, mention
if TYPE_CHECKING: logger = logging.getLogger(__name__)
from .room import Room
__all__ = ["Account", "Session", "LiveSession", "LiveSessionListing"] class Session():
class Account:
""" """
This class represents a http://api.euphoria.io/#personalaccountview, with a Deals with the things arising from being connected to a room, such as:
few added fields stolen from the hello-event (see - playing ping pong
http://api.euphoria.io/#hello-event). - having a name (usually)
- seeing other clients
- sending and receiving messages
event (args) | meaning
--------------------|-------------------------------------------------
join (bool) | joining the room was successful/not successful
| Callbacks for this event are cleared whenever it is called.
enter | can view the room
ready | can view the room and post messages (has a nick)
sessions-update | self.sessions has changed
own-session-update | your own message has changed
message (msg) | a message has been received (no own messages)
own-message (msg) | a message that you have sent
""" """
def __init__(self, def __init__(self, room, password=None, name=None, timeout=10):
account_id: str, self.password = password
name: str, self.real_name = name
email: str,
has_access: Optional[bool],
email_verified: Optional[bool]
) -> None:
self._account_id = account_id
self._name = name
self._email = email
self._has_access = has_access
self._email_verified = email_verified
@classmethod self._room_accessible = False
def from_data(cls, data: Any) -> "Account": self._room_accessible_event = threading.Event()
""" self._room_accessible_timeout = threading.Timer(timeout, self.stop)
The data parameter must be the "data" part of a hello-event.
If, in the future, a PersonalAccountView appears in other places, this self._connection = Connection(room)
function might have to be changed. self._connection.subscribe("disconnect", self._reset_variables)
""" # and now the packet types
self._connection.subscribe("bounce-event", self._handle_bounce_event)
self._connection.subscribe("disconnect-event", self._handle_disconnect_event)
self._connection.subscribe("hello-event", self._handle_hello_event)
self._connection.subscribe("join-event", self._handle_join_event)
self._connection.subscribe("logout-event", self._handle_logout_event)
self._connection.subscribe("network-event", self._handle_network_event)
self._connection.subscribe("nick-event", self._handle_nick_event)
self._connection.subscribe("edit-message-event", self._handle_edit_message_event)
self._connection.subscribe("part-event", self._handle_part_event)
self._connection.subscribe("ping-event", self._handle_ping_event)
self._connection.subscribe("pm-initiate-event", self._handle_pm_initiate_event)
self._connection.subscribe("send-event", self._handle_send_event)
self._connection.subscribe("snapshot-event", self._handle_snapshot_event)
view = data["account"] self._callbacks = Callbacks()
self.subscribe("enter", self._on_enter)
account_id = view["id"] #self._hello_event_completed = False
name = view["name"] #self._snapshot_event_completed = False
email = view["email"] #self._ready = False
#self.my_session = SessionView(None, None, None, None, None)
#self.sessions = {} # sessions in the room
#self.room_is_private = None
#self.server_version = None
has_access = data.get("account_has_access") self._reset_variables()
email_verified = data.get("account_email_verified")
return cls(account_id, name, email, has_access, email_verified) def _reset_variables(self):
logger.debug("Resetting room-related variables")
self._room_accessible = False
# Attributes self.my_session = SessionView(None, None, None, None, None)
self.sessions = {}
@property self._hello_event_completed = False
def account_id(self) -> str: self._snapshot_event_completed = False
return self._account_id self._ready = False
@property self.room_is_private = None
def name(self) -> str: self.server_version = None
return self._name
@property def _set_name(self, new_name):
def email(self) -> str: with self._connection as conn:
return self._email logger.debug("Setting name to {!r}".format(new_name))
conn.subscribe_to_next(self._handle_nick_reply)
conn.send_packet("nick", name=new_name)
@property def _on_enter(self):
def has_access(self) -> Optional[bool]: logger.info("Connected and authenticated.")
return self._has_access self._room_accessible_timeout.cancel()
self._room_accessible = True
self._room_accessible_event.set()
self._room_accessible_event.clear()
@property if self.real_name:
def email_verified(self) -> Optional[bool]: self._set_name(self.real_name)
return self._email_verified
class Session: def launch(self, timeout=10):
_ID_SPLIT_RE = re.compile(r"(agent|account|bot):(.*)") logger.info("Launching session &{}.".format(room))
def __init__(self, self._room_accessible_timeout.start()
room_name: str,
user_id: str,
nick: str,
server_id: str,
server_era: str,
session_id: str,
is_staff: bool,
is_manager: bool,
client_address: Optional[str]
) -> None:
self._room_name = room_name
self._user_id = user_id
self._id_type: Optional[str] if self._connection.launch(room):
match = self._ID_SPLIT_RE.fullmatch(self._user_id) logger.debug("Connection established. Waiting for correct events")
if match is not None: self._room_accessible_event.wait()
self._id_type = match.group(1) return self._room_accessible
else: else:
self._id_type = None logger.warn("Could not connect to room url.")
return False
self._nick = nick def launch(self):
self._server_id = server_id return self._connection.launch()
self._server_era = server_era
self._session_id = session_id
self._is_staff = is_staff
self._is_manager = is_manager
self._client_address = client_address
def _copy(self) -> "Session": def stop(self):
return Session(self.room_name, self.user_id, self.nick, self.server_id, logger.info("Stopping")
self.server_era, self.session_id, self.is_staff, self._room_accessible_timeout.cancel()
self.is_manager, self.client_address) self._room_accessible = False
self._room_accessible_event.set()
self._room_accessible_event.clear()
@classmethod with self._connection as conn:
def from_data(cls, room_name: str, data: Any) -> "Session": conn.stop()
user_id = data["id"]
nick = data["name"]
server_id = data["server_id"]
server_era = data["server_era"]
session_id = data["session_id"]
is_staff = data.get("is_staff", False)
is_manager = data.get("is_manager", False)
client_address = data.get("client_address")
return cls(room_name, user_id, nick, server_id, server_era, session_id, def subscribe(self, event, callback, *args, **kwargs):
is_staff, is_manager, client_address) logger.debug("Adding callback {} to {}".format(callback, event))
self._callbacks.add(event, callback, *args, **kwargs)
def with_nick(self, nick: str) -> "Session": def send(self, content, parent=None):
copy = self._copy() if self._ready:
copy._nick = nick self._connection.send_packet("send", content=content, parent=parent)
return copy logger.debug("Message sent.")
else:
# Attributes logger.warn("Attempted to send message while not ready.")
@property @property
def room_name(self) -> str: def name(self):
return self._room_name return self.my_session.name
@name.setter
def name(self, new_name):
self.real_name = new_name
if not self._ready:
self._set_name(new_name)
@property @property
def user_id(self) -> str: def room(self):
return self._user_id return self._connection.room
@property @property
def nick(self) -> str: def start_time(self):
return self._nick return self._connection.start_time
@property def refresh_sessions(self):
def server_id(self) -> str: logger.debug("Refreshing sessions")
return self._server_id self._connection.send_packet("who")
@property def _set_sessions_from_listing(self, listing):
def server_era(self) -> str: self.sessions = {}
return self._server_era
@property for item in listing:
def session_id(self) -> str: view = SessionView.from_data(item)
return self._session_id self.sessions[view.session_id] = view
@property self._callbacks.call("sessions-update")
def is_staff(self) -> bool:
return self._is_staff
@property def _revert_to_revious_room(self):
def is_manager(self) -> bool: self._callbacks.call("join", False)
return self._is_manager
@property if self._prev_room:
def client_address(self) -> Optional[str]: self.password = self._prev_password
return self._client_address self.room = self._prev_room # shouldn't do this
@property self._prev_room = None
def mention(self) -> str: self._prev_password = None
return mention(self.nick, ping=False) else:
self.stop()
@property def _handle_bounce_event(self, data, packet):
def atmention(self) -> str: if data.get("reason") == "authentication required":
return mention(self.nick, ping=True) if self.password:
with self._connection as conn:
conn.subscribe_to_next(self._handle_auth_reply)
conn.send_packet("auth", type="passcode", passcode=self.password)
else:
logger.warn("Could not access &{}: No password.".format(self._connection.room))
self.stop()
@property def _handle_disconnect_event(self, data, packet):
def normalize(self) -> str: self._connection.disconnect() # should reconnect
return normalize(self.nick)
@property def _handle_hello_event(self, data, packet):
def is_person(self) -> bool: self.my_session.read_data(data.get("session"))
return self._id_type is None or self._id_type in ["agent", "account"] self._callbacks.call("own-session-update")
@property self.room_is_private = data.get("room_is_private")
def is_agent(self) -> bool: self.server_version = data.get("version")
return self._id_type == "agent"
@property self._hello_event_completed = True
def is_account(self) -> bool: if self._snapshot_event_completed:
return self._id_type == "account" self._callbacks.call("enter")
@property def _handle_join_event(self, data, packet):
def is_bot(self) -> bool: view = SessionView.from_data(data)
return self._id_type == "bot" self.sessions[view.session_id] = view
class LiveSession(Session): if view.name:
def __init__(self, logger.debug("@{} joined the room.".format(mention(view.name)))
room: "Room", else:
user_id: str, logger.debug("Someone joined the room.")
nick: str,
server_id: str,
server_era: str,
session_id: str,
is_staff: bool,
is_manager: bool,
client_address: Optional[str]
) -> None:
super().__init__(room.name, user_id, nick, server_id, server_era,
session_id, is_staff, is_manager, client_address)
self._room = room
def _copy(self) -> "LiveSession": self._callbacks.call("sessions-update")
return self.from_session(self._room, self)
# Ignoring the type discrepancy since it is more convenient this way def _handle_logout_event(self, data, packet):
@classmethod # no idea why this should happen to the bot
def from_data(cls, # type: ignore # just reconnect, in case it does happen
room: "Room", self._connection.disconnect()
data: Any
) -> "LiveSession":
return cls.from_session(room, Session.from_data(room.name, data))
@classmethod def _handle_network_event(self, data, packet):
def from_session(cls, room: "Room", session: Session) -> "LiveSession": if data.get("type") == "partition":
return cls(room, session.user_id, session.nick, session.server_id, prev_len = len(self.sessions)
session.server_era, session.session_id, session.is_staff,
session.is_manager, session.client_address)
def with_nick(self, nick: str) -> "LiveSession": # only remove views matching the server_id/server_era combo
copy = self._copy() self.sessions = {
copy._nick = nick sid: view for sid, view in self.sessions.items()
return copy if view.server_id != data.get("server_id")
or view.server_era != data.get("server_era")
}
# Attributes if len(sessions) != prev_len:
logger.info("Some people left after a network event.")
else:
logger.info("No people left after a network event.")
@property self._callbacks.call("sessions-update")
def room(self) -> "Room":
return self._room
# Live stuff def _handle_nick_event(self, data, packet):
session_id = data.get("session_id")
async def pm(self) -> Tuple[str, str]: if session_id not in self.sessions:
""" logger.warn("SessionView not found: Refreshing sessions.")
See Room.pm self.refresh_sessions()
""" else:
self.sessions[session_id].name = data.get("to")
return await self.room.pm(self.user_id) if data.get("from"):
logger.debug("@{} changed their name to @{}.".format(
mention(data.get("from")),
mention(data.get("to"))
))
else:
logger.debug("Someone changed their name to @{}.".format(
mention(data.get("to"))
))
class LiveSessionListing: self._callbacks.call("sessions-update")
def __init__(self, room: "Room", sessions: Iterable[LiveSession]) -> None:
self._room = room
# just to make sure it doesn't get changed on us
self._sessions: Dict[str, LiveSession] = {session.session_id: session
for session in sessions}
def __iter__(self) -> Iterator[LiveSession]: def _handle_edit_message_event(self, data, packet):
return self._sessions.values().__iter__() # TODO: implement
pass
def _copy(self) -> "LiveSessionListing": def _handle_part_event(self, data, packet):
return LiveSessionListing(self.room, self) view = SessionView.from_data(data)
if view.session_id not in self.sessions:
logger.warn("SessionView not found: Refreshing sessions.")
self.refresh_sessions()
else:
del self.sessions[view.session_id]
@classmethod if view.name:
def from_data(cls, logger.debug("@{} left the room.".format(mention(view.name)))
room: "Room", else:
data: Any, logger.debug("Someone left the room.")
exclude_id: Optional[str] = None
) -> "LiveSessionListing":
sessions = [LiveSession.from_data(room, subdata) for subdata in data]
if exclude_id: self._callbacks.call("sessions-update")
sessions = [session for session in sessions
if session.session_id != exclude_id]
return cls(room, sessions) def _handle_ping_event(self, data, packet):
with self._connection as conn:
conn.send_packet("ping-reply", time=data.get("time"))
def get(self, session_id: str) -> Optional[LiveSession]: def _handle_pm_initiate_event(self, data, error):
return self._sessions.get(session_id) pass # placeholder, maybe implemented in the future
def with_join(self, session: LiveSession) -> "LiveSessionListing": def _handle_send_event(self, data, error):
copy = self._copy() # TODO: implement
copy._sessions[session.session_id] = session msg = Message.from_data(data)
return copy self._callbacks.call("message", msg)
def with_part(self, session: LiveSession) -> "LiveSessionListing": def _handle_snapshot_event(self, data, packet):
copy = self._copy() # deal with connected sessions
self._set_sessions_from_listing(data.get("listing"))
if session.session_id in copy._sessions: # deal with messages
del copy._sessions[session.session_id] # TODO: implement
return copy # deal with other info
self.server_version = data.get("version")
if "nick" in data:
self.my_session.name = data.get("nick")
self._callbacks.call("own-session-update")
def with_nick(self, self._snapshot_event_completed = True
session: LiveSession, if self._hello_event_completed:
new_nick: str self._callbacks.call("enter")
) -> "LiveSessionListing":
copy = self._copy()
copy._sessions[session.session_id] = session.with_nick(new_nick)
return copy
# Attributes
@property def _handle_auth_reply(self, data, packet):
def room(self) -> "Room": if not data.get("success"):
return self._room logger.warn("Could not authenticate, reason: {!r}".format(data.get("reason")))
self.stop()
else:
logger.debug("Authetication complete, password was correct.")
@property def _handle_get_message_reply(self, data, packet):
def all(self) -> List[LiveSession]: # TODO: implement
return list(self._sessions.values()) pass
@property def _handle_log_event(self, data, packet):
def people(self) -> List[LiveSession]: # TODO: implement
return [session for session in self if session.is_person] pass
@property def _handle_nick_reply(self, data, packet):
def accounts(self) -> List[LiveSession]: first_name = not self.name
return [session for session in self if session.is_account]
@property if data.get("from"):
def agents(self) -> List[LiveSession]: logger.info("Changed name from {!r} to {!r}.".format(data.get("from"), data.get("to")))
return [session for session in self if session.is_agent] else:
logger.info("Changed name to {!r}.".format(data.get("to")))
@property self.my_session.name = data.get("to")
def bots(self) -> List[LiveSession]: self._callbacks.call("own-session-update")
return [session for session in self if session.is_bot]
if first_name:
self._ready = True
self._callbacks.call("ready")
def _handle_send_reply(self, data, packet):
# TODO: implement
msg = Message.from_data(data)
self._callbacks.call("own-message", msg)
def _handle_who_reply(self, data, packet):
self._set_sessions_from_listing(data.get("listing"))

View file

@ -1,73 +0,0 @@
import asyncio
import datetime
import functools
import re
from typing import Any, Callable
__all__ = ["asyncify", "mention", "atmention", "normalize", "similar",
"plural", "format_time", "format_delta"]
async def asyncify(func: Callable[..., Any], *args: Any, **kwargs: Any) -> Any:
func_with_args = functools.partial(func, *args, **kwargs)
loop = asyncio.get_running_loop()
return await loop.run_in_executor(None, func_with_args)
# Name/nick related functions
def mention(nick: str, ping: bool = False) -> str:
mentioned = re.sub(r"""[,.!?;&<'"\s]""", "", nick)
return "@" + mentioned if ping else mentioned
def atmention(nick: str) -> str:
return mention(nick, ping=True)
def normalize(nick: str) -> str:
return mention(nick, ping=False).lower()
def similar(nick_a: str, nick_b: str) -> bool:
return normalize(nick_a) == normalize(nick_b)
# Other formatting
def plural(
number: int,
if_plural: str = "s",
if_singular: str = ""
) -> str:
if number in [1, -1]:
return if_singular
else:
return if_plural
def format_time(time: datetime.datetime) -> str:
return time.strftime("%F %T")
def format_delta(delta: datetime.timedelta) -> str:
seconds = int(delta.total_seconds())
negative = seconds < 0
seconds = abs(seconds)
days = seconds // (60 * 60 * 24)
seconds -= days * (60 * 60 * 24)
hours = seconds // (60 * 60)
seconds -= hours * (60 * 60)
minutes = seconds // 60
seconds -= minutes * 60
text: str
if days > 0:
text = f"{days}d {hours}h {minutes}m {seconds}s"
elif hours > 0:
text = f"{hours}h {minutes}m {seconds}s"
elif minutes > 0:
text = f"{minutes}m {seconds}s"
else:
text = f"{seconds}s"
if negative:
text = "- " + text
return text

21
yaboli/utils.py Normal file
View file

@ -0,0 +1,21 @@
def mention(name):
"""
mention(name) -> name
Removes all whitespace and some special characters from the name,
such that the resulting name, if prepended with a "@", will mention the user.
"""
return "".join(c for c in name if not c in ".!?;&<'\"" and not c.isspace())
def reduce_name(name):
"""
reduce_name(name) -> name
Reduces a name to a form which can be compared with other such forms.
If two such forms are equal, they are both mentioned by the same @mentions,
and should be considered identical when used to identify users.
"""
#TODO: implement
pass