From aec90479989f7a6bc9e1865a7364afcc2210f0d6 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Tristan=20Dani=C3=ABl=20Maat?= <tm@tlater.net>
Date: Fri, 19 May 2023 19:46:11 +0100
Subject: [PATCH] Initial implementation of the bot

---
 .gitignore               |   1 +
 flake.lock               |  27 ++++++++
 flake.nix                |  58 ++++++++++++++++
 src/__init__.py          |   0
 src/config.py            |  57 ++++++++++++++++
 src/main.py              |  36 ++++++++++
 src/matrixbot.py         | 107 +++++++++++++++++++++++++++++
 src/tlsappointmentbot.py | 141 +++++++++++++++++++++++++++++++++++++++
 8 files changed, 427 insertions(+)
 create mode 100644 .gitignore
 create mode 100644 flake.lock
 create mode 100644 flake.nix
 create mode 100644 src/__init__.py
 create mode 100644 src/config.py
 create mode 100644 src/main.py
 create mode 100644 src/matrixbot.py
 create mode 100644 src/tlsappointmentbot.py

diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..d241384
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1 @@
+/src/__pycache__/
diff --git a/flake.lock b/flake.lock
new file mode 100644
index 0000000..5cbdc72
--- /dev/null
+++ b/flake.lock
@@ -0,0 +1,27 @@
+{
+  "nodes": {
+    "nixpkgs": {
+      "locked": {
+        "lastModified": 1659446231,
+        "narHash": "sha256-hekabNdTdgR/iLsgce5TGWmfIDZ86qjPhxDg/8TlzhE=",
+        "owner": "nixos",
+        "repo": "nixpkgs",
+        "rev": "eabc38219184cc3e04a974fe31857d8e0eac098d",
+        "type": "github"
+      },
+      "original": {
+        "owner": "nixos",
+        "ref": "nixos-21.11",
+        "repo": "nixpkgs",
+        "type": "github"
+      }
+    },
+    "root": {
+      "inputs": {
+        "nixpkgs": "nixpkgs"
+      }
+    }
+  },
+  "root": "root",
+  "version": 7
+}
diff --git a/flake.nix b/flake.nix
new file mode 100644
index 0000000..ab3caa4
--- /dev/null
+++ b/flake.nix
@@ -0,0 +1,58 @@
+{
+  description = "Script to check for TLS appointments";
+
+  inputs = {
+    nixpkgs.url = "github:nixos/nixpkgs/nixos-21.11";
+  };
+
+  outputs = {self, nixpkgs, ...}: let
+    system = "x86_64-linux";
+    pkgs = nixpkgs.legacyPackages.${system};
+  in {
+    packages.${system}.matrix-nio = nixpkgs.legacyPackages.${system}.python3Packages.matrix-nio.overridePythonAttrs (old: {
+      version = "0.20.2";
+
+      src = pkgs.fetchFromGitHub {
+        owner = "poljar";
+        repo = "matrix-nio";
+        rev = "0.20.2";
+        hash = "sha256-opxYQZC6g2BvSk46/3Uf7UC2AMofYqLZ/v15X6YGqeU=";
+      };
+
+      checkInputs = [nixpkgs.legacyPackages.${system}.python3Packages.pytest-flake8];
+
+      postPatch = ''
+        rm -r tests
+        mkdir -p tests
+
+        echo 'def test_thing():' > tests/test_thing.py
+        echo '    pass' >> tests/test_thing.py
+
+        substituteInPlace pyproject.toml \
+              --replace 'aiofiles = "^23.1.0"' 'aiofiles = "*"' \
+              --replace 'aiohttp-socks = "^0.7.0"' 'aiohttp-socks = "*"' \
+              --replace 'h11 = "^0.14.0"' 'h11 = "*"' \
+              --replace 'jsonschema = "^4.4.0"' 'jsonschema = "*"' \
+              --replace 'aiohttp = "^3.8.3"' 'aiohttp = "*"' \
+              --replace 'cachetools = { version = "^4.2.1", optional = true }' 'cachetools = { version = "*", optional = true }'
+      '';
+    });
+
+    devShells.${system}.default = pkgs.mkShell {
+      packages = with pkgs; [
+        geckodriver
+
+        (python3.withPackages (ppkgs:
+          with ppkgs; [
+            self.packages.${system}.matrix-nio
+            selenium
+
+            python-lsp-server
+            pyls-isort
+            pylsp-mypy
+            python-lsp-black
+          ]))
+      ];
+    };
+  };
+}
diff --git a/src/__init__.py b/src/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/src/config.py b/src/config.py
new file mode 100644
index 0000000..9e2c4bd
--- /dev/null
+++ b/src/config.py
@@ -0,0 +1,57 @@
+"""Configuration helpers."""
+
+import argparse
+import datetime
+import logging
+import os
+import sys
+from dataclasses import dataclass
+from pathlib import Path
+
+logger = logging.getLogger(__name__)
+
+
+@dataclass
+class Config:
+    """Dataclass holding CLI/config settings."""
+
+    matrix_homeserver: str
+    matrix_user: str
+
+    tls_instance: str
+    tls_user: str
+
+    credentials_dir: Path
+    latest_appointment: datetime.datetime
+
+    @classmethod
+    def from_args(cls):
+        """Create a config instance from CLI arguments."""
+        parser = argparse.ArgumentParser(
+            description="Monitor TLSContact for new appointments and notify via matrix."
+        )
+
+        parser.add_argument("matrix_homeserver")
+        parser.add_argument("matrix_user")
+
+        parser.add_argument("tls_instance")  # https://nl.tlscontact.com/cn
+        parser.add_argument("tls_user")
+
+        parser.add_argument("--credentials-dir", type=Path, default=None)
+        parser.add_argument("--latest-appointment", default=None)
+
+        args = parser.parse_args()
+
+        if args.credentials_dir is None:
+            if os.getenv("CREDENTIALS_DIRECTORY"):
+                args.credentials_dir = Path(os.getenv("CREDENTIALS_DIRECTORY"))
+            else:
+                logger.critical("missing credentials directory")
+                sys.exit(1)
+
+        if args.latest_appointment is not None:
+            args.latest_appointment = datetime.datetime.strptime(
+                args.latest_appointment, "%Y-%m-%d"
+            )
+
+        return cls(**vars(args))
diff --git a/src/main.py b/src/main.py
new file mode 100644
index 0000000..f178ee9
--- /dev/null
+++ b/src/main.py
@@ -0,0 +1,36 @@
+"""Simple bot to watch for tlscontact visa slots."""
+
+import asyncio
+import logging
+import sys
+import traceback
+
+from .config import Config
+from .matrixbot import MatrixBot
+from .tlsappointmentbot import TLSAppointmentBot
+
+logger = logging.getLogger(__name__)
+
+
+def main():
+    """Run the tlsappointment bot."""
+    logging.basicConfig(level=logging.INFO)
+    config = Config.from_args()
+
+    matrix_bot = MatrixBot(config)
+    tls_bot = TLSAppointmentBot(matrix_bot, config)
+
+    async def stuff():
+        await asyncio.gather(matrix_bot.run(), tls_bot.run())
+
+    asyncio.run(stuff())
+
+
+if __name__ == "__main__":
+    try:
+        main()
+    except Exception:
+        logger.critical(traceback.format_exc().strip())
+        sys.exit(1)
+    except KeyboardInterrupt:
+        sys.exit(0)
diff --git a/src/matrixbot.py b/src/matrixbot.py
new file mode 100644
index 0000000..417ec26
--- /dev/null
+++ b/src/matrixbot.py
@@ -0,0 +1,107 @@
+"""Manage sending matrix messages to matrix rooms."""
+
+import datetime
+import logging
+from typing import List
+
+import nio
+
+from .config import Config
+
+logger = logging.getLogger(__name__)
+
+
+class MatrixBot:
+    """A class for managing the matrix bot that handles messaging users."""
+
+    def __init__(self, config: Config):
+        """Initialize some variables for the bot."""
+        # Basic setup
+        self.client = None
+        self.initial_sync_done = None
+
+        # Matrix configuration
+        self.homeserver = config.matrix_homeserver
+        self.bot_id = config.matrix_user
+        self.bot_password_file = config.credentials_dir / "bot-password"
+
+    async def _on_error(self, response):
+        if self.client:
+            await self.client.close()
+        raise Exception(response)
+
+    async def _on_sync(self, response):
+        if not self.initial_sync_done:
+            self.initial_sync_done = True
+            for room_id in self.client.rooms:
+                logger.info(f"joined room {room_id}")
+
+                if self.client.rooms[room_id].member_count < 2:
+                    await self.client.room_leave(room_id)
+                    logger.debug(f"left room {room_id} since all other users left")
+
+            logger.info("initial sync done, ready for work")
+
+    async def _on_invite(self, room, event):
+        logger.info(f"invited by {event.sender} to {room.room_id}")
+
+        if event.sender in ["@tlater:matrix.tlater.net", "@yuanyuan:matrix.tlater.net"]:
+            await self.client.join(room.room_id)
+            await self.client.room_send(
+                room_id=room.room_id,
+                message_type="m.room.message",
+                content={
+                    "msgtype": "m.text",
+                    "body": "Hi! I'll inform you if new slots become available.",
+                },
+            )
+        else:
+            logger.info(f"rejected invite by {event.sender}")
+            await self.client.room_leave(room.room_id)
+
+    async def send_appointments(self, slots: List[datetime.datetime]):
+        """Send a message with spotted appointments to all joined rooms."""
+        assert self.client
+
+        for room_id in self.client.rooms:
+            logger.info(f"notifying room {room_id} about new slots")
+
+            message = "Appointment time slots spotted:\n\n" + "\n".join(
+                slot.strftime("%Y-%m-%d %H:%M") for slot in slots
+            )
+
+            await self.client.room_send(
+                room_id=room_id,
+                message_type="m.room.message",
+                content={
+                    "msgtype": "m.text",
+                    "body": message,
+                },
+            )
+
+    async def send_warning(self):
+        """Send a message telling everyone that we're no longer running."""
+        assert self.client
+
+        for room_id in self.client.rooms:
+            await self.client.room_send(
+                room_id=room_id,
+                message_type="m.room.message",
+                content={
+                    "msgtype": "m.text",
+                    "body": "Something went wrong, tell Tristan, I might be broken!",
+                },
+            )
+
+    async def run(self):
+        """Start the bot."""
+        logger.info(f"Connecting to {self.homeserver}")
+        self.client = nio.AsyncClient(self.homeserver, self.bot_id)
+        self.client.device_id = "TLSContactAppointmentBot"
+        self.client.add_response_callback(self._on_error, nio.SyncError)
+        self.client.add_response_callback(self._on_sync, nio.SyncResponse)
+        self.client.add_event_callback(self._on_invite, nio.InviteMemberEvent)
+
+        logger.info(await self.client.login(self.bot_password_file.read_text()))
+        await self.client.sync_forever(timeout=30000, loop_sleep_time=200)
+        await self.client.close()
diff --git a/src/tlsappointmentbot.py b/src/tlsappointmentbot.py
new file mode 100644
index 0000000..fe587d3
--- /dev/null
+++ b/src/tlsappointmentbot.py
@@ -0,0 +1,141 @@
+"""The actual appointment checking bot."""
+
+import asyncio
+import datetime
+import logging
+from typing import List
+
+from selenium import webdriver
+from selenium.webdriver.common.by import By
+from selenium.webdriver.firefox.options import Options
+from selenium.webdriver.support import expected_conditions as ec
+from selenium.webdriver.support import wait
+
+from .config import Config
+from .matrixbot import MatrixBot
+
+logger = logging.getLogger(__name__)
+
+
+class TLSAppointmentBot:
+    """A bot for periodically checking the tls appointment website."""
+
+    def __init__(self, matrix_bot: MatrixBot, config: Config):
+        """Set up the TLSAppointmentBot."""
+        self.matrix_bot = matrix_bot
+        self.config = config
+
+        options = Options()
+        options.add_argument("--headless")
+        options.add_argument("--disable-gpu")
+        self.driver = webdriver.Firefox(options=options)
+
+    async def run(self):
+        """Run the TLSAppointmentBot."""
+        # Wait for the matrix bot to be ready
+        while not self.matrix_bot.initial_sync_done:
+            await asyncio.sleep(1)
+
+        while True:
+            try:
+                logger.info("checking for appointment slots")
+                while not self._is_logged_in():
+                    logger.info("relogging since login expired")
+                    self._login()
+
+                appointments = sorted(
+                    filter(
+                        lambda app: self.config.latest_appointment is None
+                        or app < self.config.latest_appointment,
+                        self._get_appointments(),
+                    )
+                )
+                if appointments:
+                    logger.info("found appointments, notifying")
+                    await self.matrix_bot.send_appointments(appointments)
+
+            except Exception:
+                await self.matrix_bot.send_warning()
+
+            await asyncio.sleep(2 * 60)
+
+    def _is_logged_in(self) -> bool:
+        self.driver.get(f"{self.config.tls_instance}/BJS/index.php")
+        button = wait.WebDriverWait(self.driver, 10).until(
+            ec.visibility_of_element_located((By.CSS_SELECTOR, "a.nav-link.login"))
+        )
+        return button.text == "LOG OUT"
+
+    def _login(self):
+        self.driver.get(f"{self.config.tls_instance}/BJS/login.php")
+        [email, pwd, btn] = wait.WebDriverWait(self.driver, 10).until(
+            ec.visibility_of_all_elements_located(
+                (
+                    By.XPATH,
+                    "//input[@id='email']|//input[@id='pwd']|//input[@type='button']",
+                )
+            )
+        )
+        email.send_keys(self.config.tls_user)
+        pwd.send_keys((self.config.credentials_dir / "tls-password").read_text())
+        btn.click()
+
+    def _get_appointments(self) -> List[datetime.datetime]:
+        self.driver.get(f"{self.config.tls_instance}/BJS/myapp.php")
+
+        # Make sure we're on the page, and that we can see the
+        # appointment table
+        wait.WebDriverWait(self.driver, 10).until(
+            ec.visibility_of_element_located((By.XPATH, "//tr[@class='amend']"))
+        )
+
+        # If the user has already made an appointment, and is just
+        # trying to reschedule, there will be an "Amend" button we
+        # need to press first.
+        amend_button = self.driver.find_elements(
+            By.XPATH, "//input[@type='button' and @value='Amend']"
+        )
+
+        if amend_button:
+            wait.WebDriverWait(self.driver, 10).until(
+                ec.visibility_of_element_located((By.CLASS_NAME, "osano-cm-dialog"))
+            )
+
+            # Make sure the cookie banner doesn't potentially
+            # interfere with the page
+            self.driver.execute_script(
+                """
+                let banner = document.getElementsByClassName("osano-cm-dialog")[0];
+                banner.parentNode.removeChild(banner);
+                """
+            )
+
+            amend_button[0].click()
+
+        timetable = wait.WebDriverWait(self.driver, 10).until(
+            ec.visibility_of_element_located((By.ID, "timeTable"))
+        )
+        year = timetable.find_element(By.CLASS_NAME, "year-month-title").text.split()[1]
+
+        appointments: List[datetime.datetime] = []
+        for day in timetable.find_elements_by_class_name("inner_timeslot"):
+            date = day.find_element_by_class_name("appt-table-d")
+            slots = day.find_elements_by_css_selector(".appt-table-btn.dispo")
+
+            [month, num, _] = date.text.split()
+
+            logger.debug(f"{len(slots)} appointment slots on {month} {num}, {year}")
+
+            appointments.extend(
+                datetime.datetime.strptime(
+                    f"{year}-{month}-{num}T{slot.text}", "%Y-%B-%dT%H:%M"
+                )
+                for slot in slots
+            )
+
+        return appointments
+
+    def quit(self):
+        """Quit the TLSAppointmentBot, including cleaning up cookies."""
+        self.driver.delete_all_cookies()
+        self.driver.quit()