init commit

This commit is contained in:
Andrew Morgan 2019-09-25 14:26:29 +02:00
commit 4cf191d9aa
12 changed files with 715 additions and 0 deletions

13
.gitignore vendored Normal file
View file

@ -0,0 +1,13 @@
# PyCharm
.idea/
# Python virtualenv environment folders
env/
env3/
.env/
# Bot local files
*.db
# Python
__pycache__/

177
LICENSE Normal file
View file

@ -0,0 +1,177 @@
Apache License
Version 2.0, January 2004
http://www.apache.org/licenses/
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
1. Definitions.
"License" shall mean the terms and conditions for use, reproduction,
and distribution as defined by Sections 1 through 9 of this document.
"Licensor" shall mean the copyright owner or entity authorized by
the copyright owner that is granting the License.
"Legal Entity" shall mean the union of the acting entity and all
other entities that control, are controlled by, or are under common
control with that entity. For the purposes of this definition,
"control" means (i) the power, direct or indirect, to cause the
direction or management of such entity, whether by contract or
otherwise, or (ii) ownership of fifty percent (50%) or more of the
outstanding shares, or (iii) beneficial ownership of such entity.
"You" (or "Your") shall mean an individual or Legal Entity
exercising permissions granted by this License.
"Source" form shall mean the preferred form for making modifications,
including but not limited to software source code, documentation
source, and configuration files.
"Object" form shall mean any form resulting from mechanical
transformation or translation of a Source form, including but
not limited to compiled object code, generated documentation,
and conversions to other media types.
"Work" shall mean the work of authorship, whether in Source or
Object form, made available under the License, as indicated by a
copyright notice that is included in or attached to the work
(an example is provided in the Appendix below).
"Derivative Works" shall mean any work, whether in Source or Object
form, that is based on (or derived from) the Work and for which the
editorial revisions, annotations, elaborations, or other modifications
represent, as a whole, an original work of authorship. For the purposes
of this License, Derivative Works shall not include works that remain
separable from, or merely link (or bind by name) to the interfaces of,
the Work and Derivative Works thereof.
"Contribution" shall mean any work of authorship, including
the original version of the Work and any modifications or additions
to that Work or Derivative Works thereof, that is intentionally
submitted to Licensor for inclusion in the Work by the copyright owner
or by an individual or Legal Entity authorized to submit on behalf of
the copyright owner. For the purposes of this definition, "submitted"
means any form of electronic, verbal, or written communication sent
to the Licensor or its representatives, including but not limited to
communication on electronic mailing lists, source code control systems,
and issue tracking systems that are managed by, or on behalf of, the
Licensor for the purpose of discussing and improving the Work, but
excluding communication that is conspicuously marked or otherwise
designated in writing by the copyright owner as "Not a Contribution."
"Contributor" shall mean Licensor and any individual or Legal Entity
on behalf of whom a Contribution has been received by Licensor and
subsequently incorporated within the Work.
2. Grant of Copyright License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
copyright license to reproduce, prepare Derivative Works of,
publicly display, publicly perform, sublicense, and distribute the
Work and such Derivative Works in Source or Object form.
3. Grant of Patent License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
(except as stated in this section) patent license to make, have made,
use, offer to sell, sell, import, and otherwise transfer the Work,
where such license applies only to those patent claims licensable
by such Contributor that are necessarily infringed by their
Contribution(s) alone or by combination of their Contribution(s)
with the Work to which such Contribution(s) was submitted. If You
institute patent litigation against any entity (including a
cross-claim or counterclaim in a lawsuit) alleging that the Work
or a Contribution incorporated within the Work constitutes direct
or contributory patent infringement, then any patent licenses
granted to You under this License for that Work shall terminate
as of the date such litigation is filed.
4. Redistribution. You may reproduce and distribute copies of the
Work or Derivative Works thereof in any medium, with or without
modifications, and in Source or Object form, provided that You
meet the following conditions:
(a) You must give any other recipients of the Work or
Derivative Works a copy of this License; and
(b) You must cause any modified files to carry prominent notices
stating that You changed the files; and
(c) You must retain, in the Source form of any Derivative Works
that You distribute, all copyright, patent, trademark, and
attribution notices from the Source form of the Work,
excluding those notices that do not pertain to any part of
the Derivative Works; and
(d) If the Work includes a "NOTICE" text file as part of its
distribution, then any Derivative Works that You distribute must
include a readable copy of the attribution notices contained
within such NOTICE file, excluding those notices that do not
pertain to any part of the Derivative Works, in at least one
of the following places: within a NOTICE text file distributed
as part of the Derivative Works; within the Source form or
documentation, if provided along with the Derivative Works; or,
within a display generated by the Derivative Works, if and
wherever such third-party notices normally appear. The contents
of the NOTICE file are for informational purposes only and
do not modify the License. You may add Your own attribution
notices within Derivative Works that You distribute, alongside
or as an addendum to the NOTICE text from the Work, provided
that such additional attribution notices cannot be construed
as modifying the License.
You may add Your own copyright statement to Your modifications and
may provide additional or different license terms and conditions
for use, reproduction, or distribution of Your modifications, or
for any such Derivative Works as a whole, provided Your use,
reproduction, and distribution of the Work otherwise complies with
the conditions stated in this License.
5. Submission of Contributions. Unless You explicitly state otherwise,
any Contribution intentionally submitted for inclusion in the Work
by You to the Licensor shall be under the terms and conditions of
this License, without any additional terms or conditions.
Notwithstanding the above, nothing herein shall supersede or modify
the terms of any separate license agreement you may have executed
with Licensor regarding such Contributions.
6. Trademarks. This License does not grant permission to use the trade
names, trademarks, service marks, or product names of the Licensor,
except as required for reasonable and customary use in describing the
origin of the Work and reproducing the content of the NOTICE file.
7. Disclaimer of Warranty. Unless required by applicable law or
agreed to in writing, Licensor provides the Work (and each
Contributor provides its Contributions) on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
implied, including, without limitation, any warranties or conditions
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
PARTICULAR PURPOSE. You are solely responsible for determining the
appropriateness of using or redistributing the Work and assume any
risks associated with Your exercise of permissions under this License.
8. Limitation of Liability. In no event and under no legal theory,
whether in tort (including negligence), contract, or otherwise,
unless required by applicable law (such as deliberate and grossly
negligent acts) or agreed to in writing, shall any Contributor be
liable to You for damages, including any direct, indirect, special,
incidental, or consequential damages of any character arising as a
result of this License or out of the use or inability to use the
Work (including but not limited to damages for loss of goodwill,
work stoppage, computer failure or malfunction, or any and all
other commercial damages or losses), even if such Contributor
has been advised of the possibility of such damages.
9. Accepting Warranty or Additional Liability. While redistributing
the Work or Derivative Works thereof, You may choose to offer,
and charge a fee for, acceptance of support, warranty, indemnity,
or other liability obligations and/or rights consistent with this
License. However, in accepting such obligations, You may act only
on Your own behalf and on Your sole responsibility, not on behalf
of any other Contributor, and only if You agree to indemnify,
defend, and hold each Contributor harmless for any liability
incurred by, or claims asserted against, such Contributor by reason
of your accepting any such warranty or additional liability.
END OF TERMS AND CONDITIONS

99
README.md Normal file
View file

@ -0,0 +1,99 @@
# Nio Template
A template for creating bots with
[matrix-nio](https://github.com/poljar/matrix-nio). The documentation for
matrix-nio can be found
[here](https://matrix-nio.readthedocs.io/en/latest/nio.html).
## Project structure
### `main.py`
Initialises the config file, the bot store, and nio's AsyncClient (which is
used to retrieve and send events to a matrix homeserver). It also registering
some callbacks on the AsyncClient to tell it to call some functions when
certain events are received (such as an invite to a room, or a new message in a
room the bot is in).
It also starts the sync loop. Matrix clients "sync" with a homeserver, by
asking constantly asking for new events. Each time they do, the client gets a
sync token (stored in the `next_batch` field of the sync response). If the
client provides this token the next time it syncs (using the `since` parameter
on the `AsyncClient.sync` method), the homeserver will only return new event
*since* those specified by the given token.
This token is saved in the database created by `storage.py` so even if the bot
quits unexpectantly, it will continue syncing where it left off next time it
is started.
### `config.py`
This file reads a config file at a given path (hardcoded as `config.yaml` in
`main.py`), processes everything in it and makes the values available to the
rest of the bot's code so it knows what to do. Most of the options in the given
config file have default values, so things will continue to work even if an
option is left out of the config file. Obviously there are some config values
that are required though, like the homeserver URL, username, access token etc.
Otherwise the bot can't function.
### `storage.py`
Creates (if necessary) and connects to a SQLite3 database and provides commands
to put or retrieve data from it. Table definitions should be specified in
`_initial_setup`, and any necessary migrations should be put in
`_run_migrations`. There's currently no defined method for how migrations
should work though.
The `sync_token` table and `get_sync_token`, `save_sync_tokens` should be left
in tact so that the bot can save its progress when syncing events from the
homeserver.
### `callbacks.py`
Holds callback methods which get run when the bot get a certain type of event
from the homserver during sync. The type and name of the method to be called
are specified in `main.py`. Currently there are two defined methods, one that
gets called when a message is sent in a room the bot is in, and another that
runs when the bot receives an invite to the room.
The message callback function, `message`, checks if the message was for the
bot, and whether it was a command. If both of those are true, the bot will
process that command.
The invite callback function, `invite`, processes the invite event and attempts
to join the room. This way, the bot will auto-join any room it is invited to.
### `bot_commands.py`
Where all the bot's commands are defined. New commands should be defined in
`process` with an associated private method. `echo` and `help` commands are
provided by default.
A `Command` object is created when a message comes in that's recognised as a
command from a user directed at the bot (either through the specified command
prefix (defined by the bot's config file), or through a private message
directly to the bot. The `process` command is then called for the bot to act on
that command.
### `chat_functions.py`
A separate file to hold helper methods related to messaging. Mostly just for
organisational purposes. Currently just holds `send_text_to_room`, a helper
method for sending formatted messages to a room.
### `errors.py`
Custom error types for the bot. Currently there's only one special type that's
defined for when a error is found while the config file is being processed.
### `sample.config.yaml`
The sample configuration file. People running your bot should be advised to
copy this file to `config.yaml`, then edit it according to their needs. Be sure
never to check the edited `config.yaml` into source control since it'll likely
contain sensitive details like an access token!
## Questions?
Any questions? Ask in
[#nio-template:amorgan.xyz](https://matrix.to/#/!vmWBOsOkoOtVHMzZgN:amorgan.xyz?via=amorgan.xyz)!

62
bot_commands.py Normal file
View file

@ -0,0 +1,62 @@
from chat_functions import send_text_to_room
class Command(object):
def __init__(self, client, store, command, room, event):
"""A command made by a user
Args:
client (nio.AsyncClient): The client to communicate to matrix with
store (Storage): Bot storage
command (str): The command and arguments
room (nio.rooms.MatrixRoom): The room the command was sent in
event (nio.events.room_events.RoomMessageText): The event describing the command
"""
self.client = client
self.store = store
self.command = command
self.room = room
self.event = event
self.args = self.command.split()[1:]
async def process(self):
"""Process the command"""
if self.command.startswith("echo"):
await self._echo()
elif self.command.startswith("help"):
await self._show_help()
else:
await self._unknown_command()
async def _echo(self):
"""Echo back the command's arguments"""
response = " ".join(self.args)
await send_text_to_room(self.client, self.room.room_id, response)
async def _show_help(self):
"""Show the help text"""
if not self.args:
text = ("Hello, I am a bot made with matrix-nio! Use `help commands` to view "
"available commands.")
await send_text_to_room(self.client, self.room.room_id, text)
return
topic = self.args[0]
if topic == "rules":
text = "These are the rules!"
elif topic == "commands":
text = "Available commands"
else:
text = "Unknown help topic!"
await send_text_to_room(self.client, self.room.room_id, text)
async def _unknown_command(self):
await send_text_to_room(
self.client,
self.room.room_id,
f"Unknown command '{self.command}'. Try the 'help' command for more information.",
)

76
callbacks.py Normal file
View file

@ -0,0 +1,76 @@
from chat_functions import (
send_text_to_room,
)
from bot_commands import Command
from nio import (
JoinError,
)
import logging
logger = logging.getLogger(__name__)
class Callbacks(object):
def __init__(self, client, store, command_prefix):
"""
Args:
client (nio.AsyncClient): nio client used to interact with matrix
store (Storage): Bot storage
command_prefix (str): The prefix for bot commands
"""
self.client = client
self.store = store
self.command_prefix = command_prefix
async def message(self, room, event):
"""Callback for when a message event is received
Args:
room (nio.rooms.MatrixRoom): The room the event came from
event (nio.events.room_events.RoomMessageText): The event defining the message
"""
# Extract the message text
msg = event.body
# Ignore messages from ourselves
if event.sender == self.client.user:
return
logger.debug(
f"Bot message received for room {room.display_name} | "
f"{room.user_name(event.sender)}: {msg}"
)
# Ignore message if in a public room without command prefix
has_command_prefix = msg.startswith(self.command_prefix)
if not has_command_prefix and not room.is_group:
return
if has_command_prefix:
# Remove the command prefix
msg = msg[len(self.command_prefix):]
command = Command(self.client, self.store, msg, room, event)
await command.process()
async def invite(self, room, event):
"""Callback for when an invite is received. Join the room specified in the invite"""
logger.debug(f"Got invite to {room.room_id} from {event.sender}.")
# Attempt to join 3 times before giving up
for attempt in range(3):
result = await self.client.join(room.room_id)
if type(result) == JoinError:
logger.error(
f"Error joining room {room.room_id} (attempt %d): %s",
attempt, result.message,
)
else:
logger.info(f"Joined {room.room_id}")
break

39
chat_functions.py Normal file
View file

@ -0,0 +1,39 @@
import logging
from nio import (
SendRetryError
)
from markdown import markdown
logger = logging.getLogger(__name__)
async def send_text_to_room(client, room_id, message, markdown_convert=True):
"""Send text to a matrix room
Args:
client (nio.AsyncClient): The client to communicate to matrix with
room_id (str): The ID of the room to send the message to
message (str): The message content
markdown_convert (bool): Whether to convert the message content to markdown.
Defaults to true.
"""
formatted = message
if markdown_convert:
formatted = markdown(message)
try:
await client.room_send(
room_id,
"m.room.message",
{
"msgtype": "m.text",
"format": "org.matrix.custom.html",
"body": message,
"formatted_body": formatted,
}
)
except SendRetryError:
logger.exception(f"Unable to send message response to {room_id}")

69
config.py Normal file
View file

@ -0,0 +1,69 @@
import logging
import re
import os
import yaml
import sys
from errors import ConfigError
logger = logging.getLogger()
class Config(object):
def __init__(self, filepath):
"""
Args:
filepath (str): Path to config file
"""
if not os.path.isfile(filepath):
raise ConfigError(f"Config file '{filepath}' does not exist")
# Load in the config file at the given filepath
with open(filepath) as file_stream:
config = yaml.full_load(file_stream.read())
# Logging setup
formatter = logging.Formatter('%(asctime)s | %(name)s [%(levelname)s] %(message)s')
log_dict = config.get("logging", {})
log_level = log_dict.get("level", "INFO")
logger.setLevel(log_level)
file_logging = log_dict.get("file_logging", {})
file_logging_enabled = file_logging.get("enabled", False)
file_logging_filepath = file_logging.get("filepath", "bot.log")
if file_logging_enabled:
handler = logging.FileHandler(file_logging_filepath)
handler.setFormatter(formatter)
logger.addHandler(handler)
console_logging = log_dict.get("console_logging", {})
console_logging_enabled = console_logging.get("enabled", True)
if console_logging_enabled:
handler = logging.StreamHandler(sys.stdout)
handler.setFormatter(formatter)
logger.addHandler(handler)
# Database setup
database_dict = config.get("database", {})
self.database_filepath = database_dict.get("filepath")
# Matrix bot account setup
matrix = config.get("matrix", {})
self.user_id = matrix.get("user_id")
if not self.user_id:
raise ConfigError("matrix.user_id is a required field")
elif not re.match("@.*:.*", self.user_id):
raise ConfigError("matrix.user_id must be in the form @name:domain")
self.access_token = matrix.get("access_token")
if not self.access_token:
raise ConfigError("matrix.access_token is a required field")
self.device_id = matrix.get("device_id", "cribbage bot")
self.homeserver_url = matrix.get("homeserver_url")
if not self.homeserver_url:
raise ConfigError("matrix.homeserver_url is a required field")
self.command_prefix = config.get("command_prefix", "!c") + " "

8
errors.py Normal file
View file

@ -0,0 +1,8 @@
class ConfigError(RuntimeError):
"""An error encountered during reading the config file
Args:
msg (str): The message displayed to the user on error
"""
def __init__(self, msg):
super(ConfigError, self).__init__("%s" % (msg,))

60
main.py Normal file
View file

@ -0,0 +1,60 @@
#!/usr/bin/env python3
import logging
import asyncio
from nio import (
AsyncClient,
AsyncClientConfig,
RoomMessageText,
InviteEvent,
)
from callbacks import Callbacks
from config import Config
from storage import Storage
logger = logging.getLogger(__name__)
async def main():
# Read config file
config = Config("config.yaml")
# Configure the database
store = Storage(config.database_filepath)
# Configuration options for the AsyncClient
client_config = AsyncClientConfig(
max_limit_exceeded=0,
max_timeouts=0,
)
# Initialize the matrix client
client = AsyncClient(
config.homeserver_url,
config.user_id,
device_id=config.device_id,
config=client_config,
)
# Assign an access token to the bot instead of logging in and creating a new device
client.access_token = config.access_token
# Set up event callbacks
callbacks = Callbacks(client, store, config.command_prefix)
client.add_event_callback(callbacks.message, (RoomMessageText,))
client.add_event_callback(callbacks.invite, (InviteEvent,))
# Retrieve the last sync token if it exists
token = store.get_sync_token()
# Sync loop
while True:
# Sync with the server
sync_response = await client.sync(timeout=30000, full_state=True, since=token)
# Save the latest sync token
token = sync_response.next_batch
if token:
store.save_sync_token(token)
asyncio.get_event_loop().run_until_complete(main())

3
requirements.txt Normal file
View file

@ -0,0 +1,3 @@
matrix-nio>=0.6
Markdown>=3.1.1
PyYAML>=5.1.2

37
sample.config.yaml Normal file
View file

@ -0,0 +1,37 @@
# Welcome to the sample config file
# Below you will find various config sections and options
# Default values are shown
# The string to prefix messages with to talk to the bot in group chats
command_prefix: "!c"
# Options for connecting to the bot's Matrix account
matrix:
# The Matrix User ID of the bot account
user_id: "@bot:example.com"
# The access token of the bot account
access_token: ""
# The device ID given on login
device_id: ABCDEFGHIJ
# The URL of the homeserver to connect to
homeserver_url: https://example.com
database:
# The path to the database
filepath: "bot.db"
# Logging setup
logging:
# Logging level
# Allowed levels are 'INFO', 'WARNING', 'ERROR', 'DEBUG' where DEBUG is most verbose
level: INFO
# Configure logging to a file
file_logging:
# Whether logging to a file is enabled
enabled: false
# The path to the file to log to. May be relative or absolute
filepath: bot.log
# Configure logging to the console output
console_logging:
# Whether logging to the console is enabled
enabled: true

72
storage.py Normal file
View file

@ -0,0 +1,72 @@
import sqlite3
import os.path
latest_db_version = 0
class Storage(object):
def __init__(self, db_path):
"""Setup the database
Runs an initial setup or migrations depending on whether a database file has already
been created
Args:
db_path (str): The name of the database file
"""
self.db_path = db_path
# Check if a database has already been connected
if os.path.isfile(self.db_path):
self._run_migrations()
else:
self._initial_setup()
def _initial_setup(self):
"""Initial setup of the database"""
print("Performing initial setup")
# Initialize a connection to the database
conn = sqlite3.connect(self.db_path)
self.cursor = conn.cursor()
# Sync token table
self.cursor.execute("CREATE TABLE sync_token ("
"token TEXT PRIMARY KEY"
")")
def _run_migrations(self):
"""Execute database migrations"""
# Initialize a connection to the database
conn = sqlite3.connect(self.db_path)
self.cursor = conn.cursor()
print("Running migration")
pass
def get_sync_token(self):
"""Retrieve the next_batch token from the last sync response.
Used to sync without retrieving messages we've processed in the past
Returns:
A str containing the last sync token or None if one does not exist
"""
self.cursor.execute("SELECT token FROM sync_token")
rows = self.cursor.fetchone()
if not rows:
return None
return rows[0]
def save_sync_token(self, token):
"""Save a token from a sync response.
Can be retrieved later to sync from where we left off
Args:
token (str): A next_batch token as part of a sync response
"""
self.cursor.execute("INSERT OR REPLACE INTO sync_token"
" (token) VALUES (?)", (token,))