Initial implementation of the bot
This commit is contained in:
commit
aec9047998
1
.gitignore
vendored
Normal file
1
.gitignore
vendored
Normal file
|
@ -0,0 +1 @@
|
|||
/src/__pycache__/
|
27
flake.lock
Normal file
27
flake.lock
Normal file
|
@ -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
|
||||
}
|
58
flake.nix
Normal file
58
flake.nix
Normal file
|
@ -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
|
||||
]))
|
||||
];
|
||||
};
|
||||
};
|
||||
}
|
0
src/__init__.py
Normal file
0
src/__init__.py
Normal file
57
src/config.py
Normal file
57
src/config.py
Normal file
|
@ -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))
|
36
src/main.py
Normal file
36
src/main.py
Normal file
|
@ -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)
|
107
src/matrixbot.py
Normal file
107
src/matrixbot.py
Normal file
|
@ -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()
|
141
src/tlsappointmentbot.py
Normal file
141
src/tlsappointmentbot.py
Normal file
|
@ -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()
|
Loading…
Reference in a new issue