diff --git a/.flake8 b/.flake8 new file mode 100644 index 0000000..82f16c4 --- /dev/null +++ b/.flake8 @@ -0,0 +1,8 @@ +[flake8] +max-line-length = 99 +import-order-style = google +exclude = + env/* + venv/* +accept-encodings = utf-8 +ignore = E203 diff --git a/.github/workflows/python-lint.yml b/.github/workflows/python-lint.yml new file mode 100644 index 0000000..ca0415e --- /dev/null +++ b/.github/workflows/python-lint.yml @@ -0,0 +1,29 @@ +name: Python lint + +on: [push, pull_request] + +jobs: + build: + runs-on: ubuntu-latest + strategy: + matrix: + python-version: [3.8] + steps: + - uses: actions/checkout@v2 + - name: Set up Python ${{ matrix.python-version }} + uses: actions/setup-python@v2 + with: + python-version: ${{ matrix.python-version }} + - name: Install dependencies + run: | + python -m pip install --upgrade pip + pip install -r requirements-dev.txt + - name: Lint with black + run: | + black . --check + - name: Lint with flake8 + run: | + # stop the build if there are Python syntax errors or undefined names + flake8 . --count --select=E9,F63,F7,F82 --show-source --statistics + # exit-zero treats all errors as warnings. The GitHub editor is 127 chars wide + flake8 . --count --exit-zero --statistics diff --git a/.gitignore b/.gitignore index 9f4d89d..fb8ec61 100644 --- a/.gitignore +++ b/.gitignore @@ -17,12 +17,10 @@ __pycache__/ # Config file config.yaml -tokens.csv -rooms.csv -volunteers.csv -volunteer_rooms.csv -presenters.csv -presenter_rooms.csv +*.csv + +# Data files +store/ # Log files *.log diff --git a/bot_actions.py b/bot_actions.py index 32db290..000bd2b 100644 --- a/bot_actions.py +++ b/bot_actions.py @@ -1,45 +1,54 @@ -from nio import Api +# coding=utf-8 + from hashlib import sha256 -def valid_token(token, tokens,sender): +import logging + +from nio import Api + +logger = logging.getLogger(__name__) + + +def valid_token(token, tokens, sender): h = sha256() h.update(token.encode("utf-8")) msg = h.hexdigest() if msg in tokens: - if tokens[msg] == 'unused': + if tokens[msg] == "unused": return True, msg elif tokens[msg] == sender: return True, msg - return False, "" + return False, msg + -async def community_invite(client, group, sender):# +async def community_invite(client, group, sender): # if not group: return - path = "groups/{}/admin/users/invite/{}".format(group,sender) - data = {"user_id":sender} + path = "groups/{}/admin/users/invite/{}".format(group, sender) + data = {"user_id": sender} query_parameters = {"access_token": client.access_token} path = Api._build_path(path, query_parameters) - print(path) - await client.send("PUT", - path, - Api.to_json(data), - headers = {"Content-Type": "application/json"} - ) + logging.debug("community_invite path: %r", path) + await client.send( + "PUT", path, Api.to_json(data), headers={"Content-Type": "application/json"} + ) return + def is_admin(user): user = str(user) - print(user) + logging.debug("is_admin? %s", user) try: f = open("admin.csv", "rt") for nick in f.readlines(): - print(nick) + logging.debug("is_admin line: %s", nick) if user == nick.rstrip(): f.close() return True f.close() except FileNotFoundError: - print("no admin.csv") + logging.error("No admin.csv") return False + def get_alias(roomid): return roomid diff --git a/bot_commands.py b/bot_commands.py index 6418917..7e6df58 100644 --- a/bot_commands.py +++ b/bot_commands.py @@ -1,7 +1,21 @@ +# coding=utf-8 + +from asyncio import Lock +import csv +import logging +from os import fsync, rename + +from bot_actions import community_invite, is_admin, valid_token from chat_functions import send_text_to_room -from bot_actions import valid_token, community_invite, is_admin, get_alias from nio import RoomResolveAliasResponse +logger = logging.getLogger(__name__) + +_attendee_token_lock = Lock() +_presenter_token_lock = Lock() +_volunteer_token_lock = Lock() + + class Command(object): def __init__(self, client, store, config, command, room, event): """A command made by a user @@ -27,9 +41,9 @@ def __init__(self, client, store, config, command, room, event): self.event = event self.args = self.command.split()[1:] - async def process(self): """Process the command""" + logging.debug("Got command from %s: %r", self.event.sender, self.command) trigger = self.command.lower() if trigger.startswith("help"): await self._show_help() @@ -38,7 +52,7 @@ async def process(self): elif trigger.startswith("ticket"): await self._process_request("attendee") elif trigger.startswith("volunteer"): - #await self._volunteer_request() + # await self._volunteer_request() await self._process_request("volunteer") elif trigger.startswith("presenter"): await self._process_request("presenter") @@ -52,98 +66,144 @@ async def process(self): async def _process_request(self, ticket_type): """!h $ticket_type $token""" - if not self.args: - response = "You need to add your token after {}".format(self.command) + if not self.args: + response = ( + "Add the ticket code from your email after the command, like this: \n" + f"`{self.command} a1b2c3d4e5...`" + ) await send_text_to_room(self.client, self.room.room_id, response) return - print("args are:" + " ".join([str(x) for x in self.args])) - print("from: " + self.event.sender) + logging.debug("ticket cmd from %s for %s", self.event.sender, ticket_type) token = str(self.args[0]) if len(token) != 64: - response = "Token must be 64 characters, check your ticket again or if you have trouble, please send an email to helpdesk2020@helpdesk.hope.net" + response = ( + "Token must be 64 characters, check your ticket again or if you " + "have trouble, please send an email to helpdesk2020@helpdesk.hope.net" + ) await send_text_to_room(self.client, self.room.room_id, response) return + lock = _attendee_token_lock tokens = self.config.tokens rooms = self.config.rooms group = self.config.community filename = "tokens.csv" if ticket_type == "presenter": + lock = _presenter_token_lock tokens = self.config.presenter_tokens rooms = self.config.presenter_rooms group = self.config.presenter_community filename = "presenters.csv" - print("presenter") elif ticket_type == "volunteer": + lock = _volunteer_token_lock tokens = self.config.volunteer_tokens rooms = self.config.volunteer_rooms group = self.config.volunteer_community filename = "volunteers.csv" - valid, h = valid_token(token, tokens,self.event.sender) - if valid: - response = "Verified ticket. You should now be invited to the {} rooms.".format(ticket_type) - await send_text_to_room(self.client, self.room.room_id, response) - print(rooms) - for r in rooms: - await self.client.room_invite(r, self.event.sender) - if tokens[h] == "unused": - await send_text_to_room(self.client, self.room.room_id, "Inviting you to the HOPE community...") + # Make sure other tasks don't interfere with our token[] manipulation or writing + async with lock: + valid, h = valid_token(token, tokens, self.event.sender) + if valid: + response = ( + "Verified ticket. You should now be invited to the HOPE " + f"{ticket_type} chat rooms and community." + ) + await send_text_to_room(self.client, self.room.room_id, response) + logging.debug("Inviting %s to %s", self.event.sender, ",".join(rooms)) + for r in rooms: + await self.client.room_invite(r, self.event.sender) await community_invite(self.client, group, self.event.sender) - tokens[h] = self.event.sender - with open(filename, 'w') as f: - for key in tokens.keys(): - f.write("%s,%s\n"%(key,tokens[key])) - return - else: - response = "This is not a valid token, check your ticket again or email helpdesk2020@helpdesk.hope.net" - await send_text_to_room(self.client, self.room.room_id, response) - return + + if tokens[h] == "unused": + tokens[h] = self.event.sender + filename_temp = filename + ".atomic" + with open(filename_temp, "w") as f: + csv_writer = csv.writer(f) + csv_writer.writerows(tokens.items()) + f.flush() + fsync(f.fileno()) + rename(filename_temp, filename) + + return + else: + logging.info( + "ticket invalid: %s: %s %s (%s)", + self.event.sender, + ticket_type, + token, + tokens.get(h, ""), + ) + # notify outside lock block + response = ( + "This is not a valid token, check your ticket again or " + "email helpdesk2020@helpdesk.hope.net" + ) + await send_text_to_room(self.client, self.room.room_id, response) async def _volunteer_request(self): response = "Inviting you to the HOPE volunteer rooms..." await send_text_to_room(self.client, self.room.room_id, response) for r in self.config.volunteer_rooms: await self.client.room_invite(r, self.event.sender) - await send_text_to_room(self.client, self.room.room_id, "Inviting you to the HOPE community") - await community_invite(self.client, self.config.volunteer_community, self.event.sender) - return + await send_text_to_room( + self.client, self.room.room_id, "Inviting you to the HOPE community" + ) + await community_invite( + self.client, self.config.volunteer_community, self.event.sender + ) async def _show_help(self): """Show the help text""" if not self.args: - text = ("Hello, I'm the HOPE CoreBot! To be invited to the official conference channels message me with `ticket `. You can see more information (important for presenters) at https://wiki.hope.net/index.php?title=Conference_bot") + text = ( + "Hello, I'm the HOPE CoreBot! To be invited to the official " + "conference channels message me with `ticket `. " + "You can find more information (important for presenters) on the " + "[conference bot wiki](https://wiki.hope.net/index.php?title=Conference_bot)." + ) await send_text_to_room(self.client, self.room.room_id, text) - return + async def _the_planet(self): text = "HACK THE PLANET https://youtu.be/YV78vobCyIo?t=55" await send_text_to_room(self.client, self.room.room_id, text) - return async def _trashing(self): text = """They\'re TRASHING our rights, man! They\'re - TRASHING the flow of data! They\'re TRASHING! - TRASHING! TRASHING! HACK THE PLANET! HACK - THE PLANET!""" + TRASHING the flow of data! They\'re TRASHING! + TRASHING! TRASHING! HACK THE PLANET! HACK + THE PLANET!""" await send_text_to_room(self.client, self.room.room_id, text) - return + async def _group(self): await send_text_to_room(self.client, self.room.room_id, "inviting to group") await community_invite(self.client, self.config, self.event.sender) async def _notice(self): - print("notice") + msg = "@room " + " ".join(map(str, self.args[1:])) + logging.warning( + "notice used by %s at %s to send: %r", + self.event.sender, + self.room.room_id, + msg, + ) if len(self.args) < 2: - await send_text_to_room(self.client, self.room.room_id, "notice args: ,,,") + await send_text_to_room( + self.client, + self.room.room_id, + "notice args: ,,,", + ) return resp = await self.client.room_resolve_alias(self.args[0]) if not isinstance(resp, RoomResolveAliasResponse): - print("bad room alias") + logging.info("notice: bad room alias %s", self.args[0]) + await send_text_to_room( + self.client, self.room.room_id, "Invalid room alias" + ) return room_id = resp.room_id - msg = "@room " + ' '.join(map(str, self.args[1:])) - print("send {} to {}".format(msg,room_id)) await send_text_to_room(self.client, room_id, msg) - return + await send_text_to_room(self.client, self.room.room_id, "Sent") + async def _invite(self): - #invite user to set of rooms - return + # invite user to set of rooms + pass diff --git a/callbacks.py b/callbacks.py index c26ddc4..ebf3715 100644 --- a/callbacks.py +++ b/callbacks.py @@ -1,18 +1,16 @@ -from chat_functions import ( - send_text_to_room, -) +# coding=utf-8 + +from asyncio import create_task +import logging + from bot_commands import Command -from nio import ( - JoinError, -) from message_responses import Message +from nio import JoinError -import logging logger = logging.getLogger(__name__) class Callbacks(object): - def __init__(self, client, store, config): """ Args: @@ -60,10 +58,11 @@ async def message(self, room, event): # treat it as a command if has_command_prefix: # Remove the command prefix - msg = msg[len(self.command_prefix):] + msg = msg[len(self.command_prefix) :] command = Command(self.client, self.store, self.config, msg, room, event) - await command.process() + # Spawn a task and don't wait for it + create_task(command.process()) async def invite(self, room, event): """Callback for when an invite is received. Join the room specified in the invite""" @@ -75,7 +74,8 @@ async def invite(self, room, event): if type(result) == JoinError: logger.error( f"Error joining room {room.room_id} (attempt %d): %s", - attempt, result.message, + attempt, + result.message, ) else: break diff --git a/chat_functions.py b/chat_functions.py index fd6ffc5..92e6c8d 100644 --- a/chat_functions.py +++ b/chat_functions.py @@ -1,18 +1,15 @@ +# coding=utf-8 + import logging -from nio import ( - SendRetryError -) + from markdown import markdown +from nio import SendRetryError logger = logging.getLogger(__name__) async def send_text_to_room( - client, - room_id, - message, - notice=True, - markdown_convert=True + client, room_id, message, notice=True, markdown_convert=True ): """Send text to a matrix room @@ -43,11 +40,7 @@ async def send_text_to_room( try: await client.room_send( - room_id, - "m.room.message", - content, - ignore_unverified_devices=True, + room_id, "m.room.message", content, ignore_unverified_devices=True, ) except SendRetryError: logger.exception(f"Unable to send message response to {room_id}") - diff --git a/config.py b/config.py index 42d9150..e6e41f4 100644 --- a/config.py +++ b/config.py @@ -1,11 +1,14 @@ +# coding=utf-8 + +import csv import logging -import re import os -import yaml -import csv +import re import sys -from typing import List, Any +from typing import Any, List + from errors import ConfigError +import yaml logger = logging.getLogger() @@ -24,34 +27,51 @@ def __init__(self, filepath): self.config = yaml.safe_load(file_stream.read()) # Logging setup - formatter = logging.Formatter('%(asctime)s | %(name)s [%(levelname)s] %(message)s') + formatter = logging.Formatter( + "%(asctime)s | %(name)s [%(levelname)s] %(message)s" + ) log_level = self._get_cfg(["logging", "level"], default="INFO") logger.setLevel(log_level) - file_logging_enabled = self._get_cfg(["logging", "file_logging", "enabled"], default=False) - file_logging_filepath = self._get_cfg(["logging", "file_logging", "filepath"], default="bot.log") + peewee_log_level = self._get_cfg(["logging", "peewee_level"], default="INFO") + logging.getLogger("peewee").setLevel(peewee_log_level) + + file_logging_enabled = self._get_cfg( + ["logging", "file_logging", "enabled"], default=False + ) + file_logging_filepath = self._get_cfg( + ["logging", "file_logging", "filepath"], default="bot.log" + ) if file_logging_enabled: handler = logging.FileHandler(file_logging_filepath) handler.setFormatter(formatter) logger.addHandler(handler) - console_logging_enabled = self._get_cfg(["logging", "console_logging", "enabled"], default=True) + console_logging_enabled = self._get_cfg( + ["logging", "console_logging", "enabled"], default=True + ) if console_logging_enabled: handler = logging.StreamHandler(sys.stdout) handler.setFormatter(formatter) logger.addHandler(handler) # Storage setup - self.database_filepath = self._get_cfg(["storage", "database_filepath"], required=True) - self.store_filepath = self._get_cfg(["storage", "store_filepath"], required=True) + self.database_filepath = self._get_cfg( + ["storage", "database_filepath"], required=True + ) + self.store_filepath = self._get_cfg( + ["storage", "store_filepath"], required=True + ) # Create the store folder if it doesn't exist if not os.path.isdir(self.store_filepath): if not os.path.exists(self.store_filepath): os.mkdir(self.store_filepath) else: - raise ConfigError(f"storage.store_filepath '{self.store_filepath}' is not a directory") + raise ConfigError( + f"storage.store_filepath '{self.store_filepath}' is not a directory" + ) # Matrix bot account setup self.user_id = self._get_cfg(["matrix", "user_id"], required=True) @@ -60,34 +80,40 @@ def __init__(self, filepath): self.user_password = self._get_cfg(["matrix", "user_password"], required=True) self.device_id = self._get_cfg(["matrix", "device_id"], required=True) - self.device_name = self._get_cfg(["matrix", "device_name"], default="nio-template") + self.device_name = self._get_cfg( + ["matrix", "device_name"], default="nio-template" + ) self.homeserver_url = self._get_cfg(["matrix", "homeserver_url"], required=True) self.command_prefix = self._get_cfg(["command_prefix"], default="!c") + " " - self.rooms_path = self._get_cfg(["rooms_path"],required=True) + self.rooms_path = self._get_cfg(["rooms_path"], required=True) self.tokens_path = self._get_cfg(["tokens_path"], required=True) self.community = self._get_cfg(["community"], required=False) - self.volunteer_community = self._get_cfg(["volunteer_community"], required=False) - self.presenter_community = self._get_cfg(["presenter_community"], required=False) - with open(self.tokens_path, 'rt') as f: + self.volunteer_community = self._get_cfg( + ["volunteer_community"], required=False + ) + self.presenter_community = self._get_cfg( + ["presenter_community"], required=False + ) + with open(self.tokens_path, "rt") as f: reader = csv.reader(f) self.tokens = dict(reader) - with open(self.rooms_path, 'rt') as f: - self.rooms = f.read().splitlines() + with open(self.rooms_path, "rt") as f: + self.rooms = f.read().splitlines() try: f = open("volunteers.csv", "rt") reader = csv.reader(f) self.volunteer_tokens = dict(reader) f.close() except FileNotFoundError: - print('No volunteers.csv') + logger.error("No volunteers.csv") self.volunteer_tokens = [] try: f = open("volunteer_rooms.csv", "rt") self.volunteer_rooms = f.read().splitlines() f.close() except FileNotFoundError: - print('No volunteer_rooms.csv') + logger.error("No volunteer_rooms.csv") self.volunteer_rooms = [] try: f = open("presenters.csv", "rt") @@ -95,20 +121,18 @@ def __init__(self, filepath): self.presenter_tokens = dict(reader) f.close() except FileNotFoundError: - print('No presenters.csv') + logger.error("No presenters.csv") self.presenter_tokens = [] try: f = open("presenter_rooms.csv", "rt") self.presenter_rooms = f.read().splitlines() f.close() except FileNotFoundError: - print('No presenter_rooms.csv') + logger.error("No presenter_rooms.csv") self.presenter_rooms = [] + def _get_cfg( - self, - path: List[str], - default: Any = None, - required: bool = True, + self, path: List[str], default: Any = None, required: bool = True, ) -> Any: """Get a config option from a path and option name, specifying whether it is required. diff --git a/errors.py b/errors.py index ec4284e..e937171 100644 --- a/errors.py +++ b/errors.py @@ -1,8 +1,12 @@ +# coding=utf-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,)) \ No newline at end of file + super(ConfigError, self).__init__("%s" % (msg,)) diff --git a/main.py b/main.py index 19b3cfa..443744f 100644 --- a/main.py +++ b/main.py @@ -1,23 +1,22 @@ #!/usr/bin/env python3 +# coding=utf-8 -import logging import asyncio +import logging import sys from time import sleep + +from aiohttp import ClientConnectionError, ServerDisconnectedError +from callbacks import Callbacks +from config import Config from nio import ( AsyncClient, AsyncClientConfig, - RoomMessageText, InviteMemberEvent, - LoginError, LocalProtocolError, + LoginError, + RoomMessageText, ) -from aiohttp import ( - ServerDisconnectedError, - ClientConnectionError -) -from callbacks import Callbacks -from config import Config from storage import Storage logger = logging.getLogger(__name__) @@ -64,13 +63,12 @@ async def main(): # Try to login with the configured username/password try: login_response = await client.login( - password=config.user_password, - device_name=config.device_name, + password=config.user_password, device_name=config.device_name, ) # Check if login failed if type(login_response) == LoginError: - logger.error(f"Failed to login: %s", login_response.message) + logger.error("Failed to login: %s", login_response.message) return False except LocalProtocolError as e: # There's an edge case here where the user hasn't installed the correct C @@ -78,7 +76,8 @@ async def main(): logger.fatal( "Failed to login. Have you installed the correct dependencies? " "https://github.com/poljar/matrix-nio#installation " - "Error: %s", e + "Error: %s", + e, ) return False @@ -101,4 +100,5 @@ async def main(): # Make sure to close the client connection on disconnect await client.close() + asyncio.get_event_loop().run_until_complete(main()) diff --git a/message_responses.py b/message_responses.py index 744bcb3..dbdd870 100644 --- a/message_responses.py +++ b/message_responses.py @@ -1,11 +1,13 @@ -from chat_functions import send_text_to_room +# coding=utf-8 + import logging +from chat_functions import send_text_to_room + logger = logging.getLogger(__name__) class Message(object): - def __init__(self, client, store, config, message_content, room, event): """Initialize a new Message @@ -38,4 +40,3 @@ async def _hello_world(self): """Say hello""" text = "Hello, world!" await send_text_to_room(self.client, self.room.room_id, text) - diff --git a/requirements-dev.txt b/requirements-dev.txt new file mode 100644 index 0000000..d44363e --- /dev/null +++ b/requirements-dev.txt @@ -0,0 +1,4 @@ +black +flake8 +flake8-coding +flake8-import-order diff --git a/sample.config.yaml b/sample.config.yaml index 3733722..bb65c03 100644 --- a/sample.config.yaml +++ b/sample.config.yaml @@ -31,6 +31,7 @@ logging: # Logging level # Allowed levels are 'INFO', 'WARNING', 'ERROR', 'DEBUG' where DEBUG is most verbose level: INFO + peewee_level: INFO # Configure logging to a file file_logging: # Whether logging to a file is enabled diff --git a/storage.py b/storage.py index 65473df..a169b5d 100644 --- a/storage.py +++ b/storage.py @@ -1,6 +1,8 @@ -import sqlite3 -import os.path +# coding=utf-8 + import logging +import os.path +import sqlite3 latest_db_version = 0 @@ -34,10 +36,12 @@ def _initial_setup(self): self.cursor = self.conn.cursor() # Sync token table - self.cursor.execute("CREATE TABLE sync_token (" - "dedupe_id INTEGER PRIMARY KEY, " - "token TEXT NOT NULL" - ")") + self.cursor.execute( + "CREATE TABLE sync_token (" + "dedupe_id INTEGER PRIMARY KEY, " + "token TEXT NOT NULL" + ")" + ) logger.info("Database setup complete")