├── CNAME ├── serenissimo ├── migration │ ├── __init__.py │ ├── 001-add-settings.py │ ├── 002-update-check-interval.py │ └── 000-from-json-to-db.py ├── db │ ├── common.py │ ├── log.py │ ├── data.sql │ ├── user.py │ ├── __init__.py │ ├── tables.sql │ ├── subscription.py │ └── stats.py ├── __init__.py ├── stats.py ├── check_all.py ├── bot.py ├── feedback.py ├── disabled.py ├── snooze.py ├── agent.py └── main.py ├── setup.md ├── setup.py ├── .gitignore ├── tests ├── test_agent.py ├── test.py └── test_db.py ├── README.md └── LICENSE /CNAME: -------------------------------------------------------------------------------- 1 | serenissimo.granzotto.net -------------------------------------------------------------------------------- /serenissimo/migration/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /serenissimo/db/common.py: -------------------------------------------------------------------------------- 1 | def select_last_id(c): 2 | select = "SELECT last_insert_rowid() AS id" 3 | return c.execute(select).fetchone()["id"] 4 | -------------------------------------------------------------------------------- /serenissimo/__init__.py: -------------------------------------------------------------------------------- 1 | import logging 2 | from dotenv import load_dotenv 3 | 4 | logging.basicConfig( 5 | level=logging.INFO, format="%(asctime)s - %(levelname)s - %(message)s" 6 | ) 7 | 8 | load_dotenv() -------------------------------------------------------------------------------- /serenissimo/migration/001-add-settings.py: -------------------------------------------------------------------------------- 1 | from serenissimo import db 2 | 3 | MIGRATION = """ 4 | ALTER TABLE user ADD COLUMN snooze_from INTEGER; 5 | ALTER TABLE user ADD COLUMN snooze_to INTEGER; 6 | """ 7 | 8 | with db.transaction() as t: 9 | t.executescript(MIGRATION) 10 | -------------------------------------------------------------------------------- /serenissimo/db/log.py: -------------------------------------------------------------------------------- 1 | from .common import select_last_id 2 | 3 | 4 | def insert(c, name, x=None, y=None, z=None): 5 | insert = "INSERT INTO log (name, x, y, z) VALUES (?, ?, ?, ?)" 6 | c.execute( 7 | insert, 8 | (name, x, y, z), 9 | ) 10 | return select_last_id(c) 11 | -------------------------------------------------------------------------------- /serenissimo/migration/002-update-check-interval.py: -------------------------------------------------------------------------------- 1 | from serenissimo import db 2 | 3 | MIGRATION = """ 4 | UPDATE status 5 | SET update_interval = (SELECT 30 * 60) 6 | WHERE id = 'eligible' OR id = 'maybe_eligible'; 7 | """ 8 | 9 | with db.transaction() as t: 10 | t.executescript(MIGRATION) 11 | -------------------------------------------------------------------------------- /setup.md: -------------------------------------------------------------------------------- 1 | # Serenissimo 2 | 3 | Serenissimo is a bot that helps you get an appointment to get vaccinated. It works for the Veneto region. 4 | 5 | https://vaccinicovid.regione.veneto.it/ 6 | 7 | ## Setup 8 | 9 | ``` 10 | python3 -m venv .venv 11 | source .venv/bin/activate 12 | pip install -r requirements.txt 13 | ``` -------------------------------------------------------------------------------- /serenissimo/migration/000-from-json-to-db.py: -------------------------------------------------------------------------------- 1 | from .. import db 2 | import json 3 | 4 | insert = """ 5 | INSERT INTO subscription (user_id, ulss_id, fiscal_code, status_id, last_check, locations) 6 | VALUES (?, ?, ?, ?, ?, ?)""" 7 | 8 | with db.transaction() as t: 9 | db.init(t) 10 | db.init_data(t) 11 | 12 | for k, v in json.load(open("./db.json")).items(): 13 | with db.transaction() as t: 14 | sid = db.user.insert(t, k) 15 | if "ulss" in v and "cf" in v: 16 | t.execute( 17 | insert, 18 | ( 19 | sid, 20 | int(v["ulss"]), 21 | v["cf"], 22 | v.get("state", "unknown"), 23 | int(v.get("last_check", 0)), 24 | json.dumps(v.get("locations")), 25 | ), 26 | ) 27 | -------------------------------------------------------------------------------- /serenissimo/stats.py: -------------------------------------------------------------------------------- 1 | from io import BytesIO 2 | import matplotlib.pyplot as plt 3 | from .bot import bot, from_admin 4 | from . import db 5 | 6 | 7 | @bot.message_handler(commands=["stats"]) 8 | def message_handler(message): 9 | if not from_admin(message): 10 | return 11 | 12 | with db.connection(row_factory=None) as c: 13 | s = db.stats.group_subscribers_by_day(c) 14 | n = db.stats.group_notifications_by_day(c) 15 | e = db.stats.group_errors_by_day(c) 16 | 17 | fig, axs = plt.subplots(3) 18 | axs[0].set_title("New subscribers") 19 | axs[0].bar(*list(zip(*s))) 20 | axs[1].set_title("Notifications sent") 21 | axs[1].bar(*list(zip(*n))) 22 | axs[2].set_title("Errors") 23 | axs[2].bar(*list(zip(*e))) 24 | plt.tight_layout() 25 | 26 | buffer = BytesIO() 27 | plt.savefig(buffer, format="png") 28 | buffer.seek(0) 29 | bot.send_photo(message.chat.id, buffer) 30 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | import setuptools 2 | 3 | with open("README.md", "r", encoding="utf-8") as fh: 4 | long_description = fh.read() 5 | 6 | setuptools.setup( 7 | name="serenissimo", 8 | version="0.0.1", 9 | author="Alberto Granzotto", 10 | author_email="agranzot@mailbox.org", 11 | description="A bot to help people in Veneto to find a spot to get vaccinated.", 12 | install_requires=[ 13 | "requests>=2,<3", 14 | "beautifulsoup4>=4,<5", 15 | "matplotlib>=3,<4", 16 | "pyTelegramBotAPI>=3,<4", 17 | "python-codicefiscale==0.3.7", 18 | "python-dotenv==0.17.0", 19 | ], 20 | long_description=long_description, 21 | long_description_content_type="text/markdown", 22 | url="https://github.com/vrde/serenissimo", 23 | project_urls={ 24 | "Bug Tracker": "https://github.com/vrde/serenissimo/issues", 25 | }, 26 | classifiers=[ 27 | "Programming Language :: Python :: 3", 28 | "License :: OSI Approved :: Mozilla Public License 2.0 (MPL 2.0)", 29 | "Operating System :: OS Independent", 30 | ], 31 | package_data={"": ["*.sql"]}, 32 | package_dir={"": "."}, 33 | packages=setuptools.find_packages(where="."), 34 | python_requires=">=3.6", 35 | ) 36 | -------------------------------------------------------------------------------- /serenissimo/db/data.sql: -------------------------------------------------------------------------------- 1 | INSERT 2 | OR IGNORE INTO ulss (id, name) 3 | VALUES (1, 'Dolomiti'), 4 | (2, 'Marca Trevigiana'), 5 | (3, 'Serenissima'), 6 | (4, 'Veneto Orientale'), 7 | (5, 'Polesana'), 8 | (6, 'Euganea'), 9 | (7, 'Pedemontana'), 10 | (8, 'Berica'), 11 | (9, 'Scaligera'); 12 | INSERT 13 | OR IGNORE INTO status (id, update_interval) 14 | VALUES ( 15 | 'unknown', 16 | ( 17 | SELECT 60 * 60 18 | ) 19 | ), 20 | ( 21 | 'eligible', 22 | ( 23 | SELECT 30 * 60 24 | ) 25 | ), 26 | ( 27 | 'maybe_eligible', 28 | ( 29 | SELECT 30 * 60 30 | ) 31 | ), 32 | ( 33 | 'not_eligible', 34 | ( 35 | SELECT 6 * 60 * 60 36 | ) 37 | ), 38 | ( 39 | 'not_registered', 40 | ( 41 | SELECT 24 * 60 * 60 42 | ) 43 | ), 44 | ( 45 | 'wrong_health_insurance_number', 46 | ( 47 | SELECT 7 * 24 * 60 * 60 48 | ) 49 | ), 50 | ( 51 | 'already_booked', 52 | ( 53 | SELECT 7 * 24 * 60 * 60 54 | ) 55 | ), 56 | ( 57 | 'already_vaccinated', 58 | ( 59 | SELECT 7 * 24 * 60 * 60 60 | ) 61 | ); -------------------------------------------------------------------------------- /serenissimo/check_all.py: -------------------------------------------------------------------------------- 1 | from time import sleep 2 | from serenissimo import agent, db 3 | 4 | SELECT_ALL_SUBSCRIPTIONS = """SELECT user.id as user_id, 5 | user.telegram_id, 6 | subscription.id as subscription_id, 7 | subscription.ulss_id, 8 | subscription.fiscal_code, 9 | subscription.health_insurance_number, 10 | subscription.status_id, 11 | subscription.last_check, 12 | subscription.locations 13 | FROM user 14 | INNER JOIN subscription ON (user.id = subscription.user_id) 15 | INNER JOIN status ON (status.id = subscription.status_id) 16 | WHERE status_id NOT IN ("already_booked", "already_vaccinated") 17 | --AND subscription.ulss_id = 6 18 | AND subscription.ulss_id IS NOT NULL 19 | AND subscription.fiscal_code IS NOT NULL 20 | AND subscription.health_insurance_number IS NOT NULL""" 21 | 22 | with db.connection() as c: 23 | for s in c.execute(SELECT_ALL_SUBSCRIPTIONS): 24 | ulss_id = s["ulss_id"] 25 | fiscal_code = s["fiscal_code"] 26 | health_insurance_number = s["health_insurance_number"] 27 | status_id, available, unavailable = agent.check( 28 | ulss_id, fiscal_code, health_insurance_number 29 | ) 30 | print(f"{ulss_id} {fiscal_code} {health_insurance_number} {status_id}") 31 | print("Available locations:") 32 | print(agent.format_locations(available)) 33 | print("Unavailable locations:") 34 | print(agent.format_locations(unavailable)) 35 | print() 36 | sleep(0) 37 | -------------------------------------------------------------------------------- /serenissimo/db/user.py: -------------------------------------------------------------------------------- 1 | from .common import select_last_id 2 | 3 | 4 | def by_id(c, id): 5 | select = "SELECT * FROM user WHERE id = ?" 6 | r = c.execute(select, (id,)) 7 | return r.fetchone() 8 | 9 | 10 | def by_telegram_id(c, telegram_id): 11 | select = "SELECT * FROM user WHERE telegram_id = ?" 12 | r = c.execute(select, (telegram_id,)) 13 | return r.fetchone() 14 | 15 | 16 | def insert(c, telegram_id): 17 | insert = "INSERT INTO user (telegram_id) VALUES (?)" 18 | c.execute(insert, (telegram_id,)) 19 | return select_last_id(c) 20 | 21 | 22 | def update(c, id, snooze_from=None, snooze_to=None): 23 | update = """ 24 | UPDATE 25 | user 26 | SET 27 | snooze_from = coalesce(:snooze_from, snooze_from), 28 | snooze_to = coalesce(:snooze_to, snooze_to) 29 | WHERE 30 | id = :id""" 31 | return c.execute( 32 | update, 33 | { 34 | "id": id, 35 | "snooze_from": snooze_from, 36 | "snooze_to": snooze_to, 37 | }, 38 | ) 39 | 40 | 41 | def reset_snooze(c, id): 42 | update = """ 43 | UPDATE 44 | user 45 | SET 46 | snooze_from = NULL, 47 | snooze_to = NULL 48 | WHERE 49 | id = :id""" 50 | return c.execute( 51 | update, 52 | {"id": id}, 53 | ) 54 | 55 | 56 | def delete(c, user_id): 57 | delete = "DELETE FROM user WHERE id = ?" 58 | return c.execute(delete, (user_id,)) 59 | 60 | 61 | def select_active(c): 62 | select = """ 63 | SELECT user.* 64 | FROM user 65 | INNER JOIN subscription ON (user.id = subscription.user_id) 66 | WHERE subscription.ulss_id IS NOT NULL 67 | AND subscription.fiscal_code IS NOT NULL 68 | AND subscription.health_insurance_number IS NOT NULL""" 69 | return c.execute(select) 70 | -------------------------------------------------------------------------------- /serenissimo/db/__init__.py: -------------------------------------------------------------------------------- 1 | # https://charlesleifer.com/blog/going-fast-with-sqlite-and-python/ 2 | # https://www.skoumal.com/en/parallel-read-and-write-in-sqlite/ 3 | # https://docs.oracle.com/database/bdb181/html/bdb-sql/lockhandling.html 4 | 5 | import sqlite3 6 | from contextlib import contextmanager 7 | from . import user, subscription, log, stats 8 | import pkg_resources 9 | 10 | 11 | import logging 12 | 13 | logger = logging.getLogger() 14 | 15 | 16 | def dict_factory(c, row): 17 | d = {} 18 | for idx, col in enumerate(c.description): 19 | d[col[0]] = row[idx] 20 | return d 21 | 22 | 23 | @contextmanager 24 | def transaction(database="db.sqlite") -> sqlite3.Connection: 25 | # We must issue a "BEGIN IMMEDIATE" explicitly when running in auto-commit mode. 26 | c = connect(database) 27 | c.execute("BEGIN IMMEDIATE") 28 | try: 29 | yield c 30 | except: 31 | c.rollback() 32 | raise 33 | else: 34 | c.commit() 35 | finally: 36 | c.close() 37 | 38 | 39 | @contextmanager 40 | def connection(database="db.sqlite", row_factory=dict_factory) -> sqlite3.Connection: 41 | c = connect(database, row_factory=row_factory) 42 | try: 43 | yield c 44 | except: 45 | raise 46 | finally: 47 | c.close() 48 | 49 | 50 | total = 0 51 | 52 | 53 | def tracer(id): 54 | global total 55 | i = total 56 | total += 1 57 | 58 | def trace(statement): 59 | print("\n".join("{}: {}".format(id, t) for t in statement.split("\n"))) 60 | print() 61 | 62 | return trace 63 | 64 | 65 | def connect(database="db.sqlite", row_factory=dict_factory) -> sqlite3.Connection: 66 | c = sqlite3.connect(database, isolation_level=None) 67 | c.execute("PRAGMA foreign_keys = ON") 68 | c.execute("PRAGMA journal_mode = wal") 69 | if row_factory: 70 | c.row_factory = dict_factory 71 | # c.set_trace_callback(tracer(i)) 72 | return c 73 | 74 | 75 | def init(c: sqlite3.Connection) -> None: 76 | script = pkg_resources.resource_string(__name__, "tables.sql") 77 | c.executescript(script.decode("utf8")) 78 | 79 | 80 | def init_data(c: sqlite3.Connection) -> None: 81 | script = pkg_resources.resource_string(__name__, "data.sql") 82 | c.executescript(script.decode("utf8")) 83 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Custom paths 2 | 3 | *.sqlite* 4 | *.sqlite-shm 5 | *.sqlite-wal 6 | .vscode 7 | db.json 8 | 9 | # Byte-compiled / optimized / DLL files 10 | __pycache__/ 11 | *.py[cod] 12 | *$py.class 13 | 14 | # C extensions 15 | *.so 16 | 17 | # Distribution / packaging 18 | .Python 19 | build/ 20 | develop-eggs/ 21 | dist/ 22 | downloads/ 23 | eggs/ 24 | .eggs/ 25 | lib/ 26 | lib64/ 27 | parts/ 28 | sdist/ 29 | var/ 30 | wheels/ 31 | pip-wheel-metadata/ 32 | share/python-wheels/ 33 | *.egg-info/ 34 | .installed.cfg 35 | *.egg 36 | MANIFEST 37 | 38 | # PyInstaller 39 | # Usually these files are written by a python script from a template 40 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 41 | *.manifest 42 | *.spec 43 | 44 | # Installer logs 45 | pip-log.txt 46 | pip-delete-this-directory.txt 47 | 48 | # Unit test / coverage reports 49 | htmlcov/ 50 | .tox/ 51 | .nox/ 52 | .coverage 53 | .coverage.* 54 | .cache 55 | nosetests.xml 56 | coverage.xml 57 | *.cover 58 | *.py,cover 59 | .hypothesis/ 60 | .pytest_cache/ 61 | 62 | # Translations 63 | *.mo 64 | *.pot 65 | 66 | # Django stuff: 67 | *.log 68 | local_settings.py 69 | db.sqlite3 70 | db.sqlite3-journal 71 | 72 | # Flask stuff: 73 | instance/ 74 | .webassets-cache 75 | 76 | # Scrapy stuff: 77 | .scrapy 78 | 79 | # Sphinx documentation 80 | docs/_build/ 81 | 82 | # PyBuilder 83 | target/ 84 | 85 | # Jupyter Notebook 86 | .ipynb_checkpoints 87 | 88 | # IPython 89 | profile_default/ 90 | ipython_config.py 91 | 92 | # pyenv 93 | .python-version 94 | 95 | # pipenv 96 | # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. 97 | # However, in case of collaboration, if having platform-specific dependencies or dependencies 98 | # having no cross-platform support, pipenv may install dependencies that don't work, or not 99 | # install all needed dependencies. 100 | #Pipfile.lock 101 | 102 | # PEP 582; used by e.g. github.com/David-OConnor/pyflow 103 | __pypackages__/ 104 | 105 | # Celery stuff 106 | celerybeat-schedule 107 | celerybeat.pid 108 | 109 | # SageMath parsed files 110 | *.sage.py 111 | 112 | # Environments 113 | .env 114 | .venv 115 | env/ 116 | venv/ 117 | ENV/ 118 | env.bak/ 119 | venv.bak/ 120 | 121 | # Spyder project settings 122 | .spyderproject 123 | .spyproject 124 | 125 | # Rope project settings 126 | .ropeproject 127 | 128 | # mkdocs documentation 129 | /site 130 | 131 | # mypy 132 | .mypy_cache/ 133 | .dmypy.json 134 | dmypy.json 135 | 136 | # Pyre type checker 137 | .pyre/ 138 | -------------------------------------------------------------------------------- /serenissimo/db/tables.sql: -------------------------------------------------------------------------------- 1 | CREATE TABLE IF NOT EXISTS user ( 2 | id INTEGER PRIMARY KEY AUTOINCREMENT, 3 | telegram_id TEXT, 4 | ts DATETIME DEFAULT (CAST(strftime('%s', 'now') AS INT)), 5 | last_message DATETIME, 6 | snooze_from INTEGER, 7 | snooze_to INTEGER 8 | ); 9 | CREATE UNIQUE INDEX IF NOT EXISTS idx_user_telegram_id ON user(telegram_id); 10 | CREATE TABLE IF NOT EXISTS subscription ( 11 | id INTEGER PRIMARY KEY AUTOINCREMENT, 12 | user_id INTEGER, 13 | ulss_id INTEGER, 14 | status_id TEXT NOT NULL DEFAULT 'unknown', 15 | fiscal_code TEXT NULL, 16 | health_insurance_number TEXT NULL, 17 | locations TEXT NOT NULL DEFAULT 'null', 18 | last_check DATETIME DEFAULT 0, 19 | ts DATETIME DEFAULT (CAST(strftime('%s', 'now') AS INT)), 20 | FOREIGN KEY (user_id) REFERENCES user (id) ON DELETE CASCADE, 21 | FOREIGN KEY (ulss_id) REFERENCES ulss (id), 22 | FOREIGN KEY (status_id) REFERENCES status (id) 23 | ); 24 | CREATE INDEX IF NOT EXISTS idx_subscription_status_id ON subscription(status_id); 25 | CREATE INDEX IF NOT EXISTS idx_subscription_last_check ON subscription(last_check); 26 | CREATE TABLE IF NOT EXISTS ulss ( 27 | id INTEGER NOT NULL PRIMARY KEY, 28 | name TEXT NOT NULL 29 | ); 30 | CREATE TABLE IF NOT EXISTS status ( 31 | id TEXT NOT NULL PRIMARY KEY, 32 | update_interval INTEGER 33 | ); 34 | CREATE TABLE IF NOT EXISTS log ( 35 | name TEXT NOT NULL, 36 | x TEXT NULL, 37 | y TEXT NULL, 38 | z TEXT NULL, 39 | ts DATETIME DEFAULT (CAST(strftime('%s', 'now') AS INT)) 40 | ); 41 | CREATE INDEX IF NOT EXISTS idx_log_name ON log(name); 42 | -- 43 | -- 44 | -- Views 45 | -- 46 | DROP VIEW IF EXISTS view_stale_subscriptions; 47 | CREATE VIEW IF NOT EXISTS view_stale_subscriptions AS WITH const AS ( 48 | SELECT CAST(strftime('%s', 'now') AS INT) AS now_utc, 49 | CAST( 50 | strftime('%H', datetime('now', '+2 hours')) AS INT 51 | ) AS hour_cest 52 | ) 53 | SELECT user.id as user_id, 54 | user.telegram_id, 55 | subscription.id as subscription_id, 56 | subscription.ulss_id, 57 | subscription.fiscal_code, 58 | subscription.health_insurance_number, 59 | subscription.status_id, 60 | subscription.last_check, 61 | subscription.locations 62 | FROM const, 63 | user 64 | INNER JOIN subscription ON (user.id = subscription.user_id) 65 | INNER JOIN status ON (status.id = subscription.status_id) 66 | WHERE status_id NOT IN ("already_booked", "already_vaccinated") 67 | AND subscription.ulss_id IS NOT NULL 68 | AND subscription.fiscal_code IS NOT NULL 69 | AND subscription.health_insurance_number IS NOT NULL 70 | AND subscription.last_check <= now_utc - status.update_interval 71 | AND ( 72 | ( 73 | -- If one or both values are NULL, check. 74 | user.snooze_from IS NULL 75 | OR user.snooze_to IS NULL 76 | ) 77 | OR ( 78 | user.snooze_from >= user.snooze_to 79 | AND ( 80 | hour_cest < user.snooze_from 81 | AND hour_cest >= user.snooze_to 82 | ) 83 | OR ( 84 | user.snooze_from < user.snooze_to 85 | AND ( 86 | hour_cest < user.snooze_from 87 | OR hour_cest >= user.snooze_to 88 | ) 89 | ) 90 | ) 91 | ) 92 | ORDER BY subscription.last_check ASC; -------------------------------------------------------------------------------- /serenissimo/db/subscription.py: -------------------------------------------------------------------------------- 1 | from .common import select_last_id 2 | 3 | 4 | def insert(c, user_id, ulss_id=None, fiscal_code=None, health_insurance_number=None): 5 | # This is a bot, so users insert fields incrementally. We ask for the ULSS 6 | # first, then the fiscal_code, so there can be only one "incomplete" 7 | # subscription. 8 | insert = """ 9 | INSERT INTO subscription (user_id, ulss_id, fiscal_code, health_insurance_number) 10 | VALUES (?, ?, ?, ?)""" 11 | c.execute(insert, (user_id, ulss_id, fiscal_code, health_insurance_number)) 12 | return select_last_id(c) 13 | 14 | 15 | def update( 16 | c, 17 | id, 18 | ulss_id=None, 19 | fiscal_code=None, 20 | health_insurance_number=None, 21 | status_id=None, 22 | locations=None, 23 | set_last_check=False, 24 | ): 25 | update = """ 26 | UPDATE 27 | subscription 28 | SET 29 | ulss_id = coalesce(:ulss_id, ulss_id), 30 | fiscal_code = coalesce(:fiscal_code, fiscal_code), 31 | health_insurance_number = coalesce(:health_insurance_number, health_insurance_number), 32 | status_id = coalesce(:status_id, status_id), 33 | locations = coalesce(:locations, locations), 34 | last_check = (SELECT CASE WHEN :set_last_check THEN CAST(strftime('%s', 'now') AS INT) ELSE last_check END) 35 | 36 | WHERE id = :id""" 37 | 38 | return c.execute( 39 | update, 40 | { 41 | "id": id, 42 | "ulss_id": ulss_id, 43 | "fiscal_code": fiscal_code, 44 | "health_insurance_number": health_insurance_number, 45 | "status_id": status_id, 46 | "locations": locations, 47 | "set_last_check": set_last_check, 48 | }, 49 | ) 50 | 51 | 52 | def delete(c, subscription_id): 53 | delete = """DELETE FROM subscription WHERE subscription_id = ?""" 54 | return c.execute(delete, (subscription_id,)) 55 | 56 | 57 | def by_user(c, user_id): 58 | select = "SELECT * FROM subscription WHERE user_id = ? ORDER BY id ASC" 59 | return c.execute(select, (user_id,)) 60 | 61 | 62 | def last_by_user(c, user_id): 63 | select = """ 64 | SELECT * 65 | FROM subscription 66 | WHERE user_id = ? 67 | ORDER BY id DESC 68 | LIMIT 1""" 69 | return c.execute(select, (user_id,)).fetchone() 70 | 71 | 72 | def by_id(c, subscription_id): 73 | select = """ 74 | SELECT user.id as user_id, 75 | user.telegram_id, 76 | subscription.id as subscription_id, 77 | subscription.ulss_id, 78 | subscription.fiscal_code, 79 | subscription.health_insurance_number, 80 | subscription.status_id, 81 | subscription.last_check, 82 | subscription.locations 83 | 84 | FROM user 85 | INNER JOIN subscription ON (user.id = subscription.user_id) 86 | LEFT JOIN status ON (status.id = subscription.status_id) 87 | 88 | WHERE subscription.id = ?""" 89 | return c.execute(select, (subscription_id,)).fetchone() 90 | 91 | 92 | def select_stale(c): 93 | select = "SELECT * FROM view_stale_subscriptions" 94 | return c.execute(select) -------------------------------------------------------------------------------- /serenissimo/bot.py: -------------------------------------------------------------------------------- 1 | import logging 2 | import os 3 | 4 | import telebot 5 | from telebot import apihelper 6 | 7 | from . import db 8 | 9 | log = logging.getLogger() 10 | 11 | 12 | DEV = os.getenv("DEV") 13 | ADMIN_ID = os.getenv("ADMIN_ID") 14 | TOKEN = os.getenv("TELEGRAM_TOKEN") 15 | 16 | bot = telebot.TeleBot(TOKEN, parse_mode=None) 17 | 18 | 19 | def from_admin(message): 20 | return ADMIN_ID == str(message.from_user.id) 21 | 22 | 23 | def send_message(telegram_id, *messages, reply_markup=None, parse_mode="HTML"): 24 | if DEV: 25 | telegram_id = ADMIN_ID 26 | try: 27 | bot.send_message( 28 | telegram_id, 29 | "\n".join(messages), 30 | reply_markup=reply_markup, 31 | parse_mode=parse_mode, 32 | disable_web_page_preview=True, 33 | ) 34 | except apihelper.ApiTelegramException as e: 35 | log.exception("Error sending message %s", "\n".join(messages)) 36 | if e.error_code == 403: 37 | # User blocked us, remove them 38 | log.info("User %s blocked us, delete all their data", telegram_id) 39 | with db.transaction() as t: 40 | user = db.user.by_telegram_id(t, telegram_id) 41 | if user: 42 | db.user.delete(t, user["id"]) 43 | 44 | 45 | def reply_to(message, *messages): 46 | telegram_id = str(message.from_user.id) 47 | try: 48 | bot.reply_to(message, "\n".join(messages)) 49 | except apihelper.ApiTelegramException as e: 50 | log.exception("Error sending message %s", "\n".join(messages)) 51 | if e.error_code == 403: 52 | # User blocked us, remove them 53 | log.info("User %s blocked us, delete all their data", telegram_id) 54 | with db.transaction() as t: 55 | user = db.user.by_telegram_id(t, telegram_id) 56 | if user: 57 | db.user.delete(t, user["id"]) 58 | 59 | 60 | def edit_message_text( 61 | text, 62 | chat_id=None, 63 | message_id=None, 64 | inline_message_id=None, 65 | parse_mode=None, 66 | disable_web_page_preview=None, 67 | reply_markup=None, 68 | ): 69 | try: 70 | bot.edit_message_text( 71 | text, 72 | chat_id=chat_id, 73 | message_id=message_id, 74 | inline_message_id=inline_message_id, 75 | parse_mode=parse_mode, 76 | disable_web_page_preview=disable_web_page_preview, 77 | reply_markup=reply_markup, 78 | ) 79 | except apihelper.ApiTelegramException as e: 80 | if e.error_code == 400: 81 | log.warning("Error editing message: %s", e) 82 | else: 83 | raise 84 | 85 | 86 | def edit_message_reply_markup( 87 | chat_id=None, message_id=None, inline_message_id=None, reply_markup=None 88 | ): 89 | try: 90 | bot.edit_message_reply_markup( 91 | chat_id=chat_id, 92 | message_id=message_id, 93 | inline_message_id=inline_message_id, 94 | reply_markup=reply_markup, 95 | ) 96 | except apihelper.ApiTelegramException as e: 97 | if e.error_code == 400: 98 | log.warning("Error editing message: %s", e) 99 | else: 100 | raise 101 | -------------------------------------------------------------------------------- /serenissimo/feedback.py: -------------------------------------------------------------------------------- 1 | from .bot import bot, send_message, edit_message_text, edit_message_reply_markup 2 | from . import db 3 | from telebot.types import InlineKeyboardMarkup, InlineKeyboardButton 4 | 5 | 6 | BUTTON = "Ho prenotato la vaccinazione, cancellami" 7 | MESSAGE = "Ottima notizia! Serenissimo ti ha aiutato a trovare posto?" 8 | MESSAGE_UNDO = ( 9 | "OK, non ho cancellato la tua iscrizione, continuerai a ricevere notifiche." 10 | ) 11 | MESSAGE_REPLY = "Ho cancellato i tuoi dati e non riceverai più alcuna notifica. Per ricominciare puoi usare il bottone qui sotto.\n\nSe hai un commento o un messaggio da condividere, scrivimi a agranzot@mailbox.org, altrimenti buon vaccino!" 12 | MESSAGE_REPLY_NO = "Ho cancellato i tuoi dati e non riceverai più alcuna notifica. Per ricominciare puoi usare il bottone qui sotto.\n\nMi dispiace non esserti stato d'aiuto, c'è qualcosa che posso migliorare? Se sì, scrivimi a agranzot@mailbox.org, altrimenti buon vaccino!" 13 | 14 | 15 | def gen_markup_init(markup=None): 16 | if not markup: 17 | markup = InlineKeyboardMarkup() 18 | markup.add(InlineKeyboardButton(BUTTON, callback_data="feedback_init")) 19 | return markup 20 | 21 | 22 | def gen_markup_feedback(): 23 | markup = InlineKeyboardMarkup() 24 | markup.add( 25 | InlineKeyboardButton( 26 | "Sì 👍", 27 | callback_data="feedback_yes", 28 | ), 29 | InlineKeyboardButton( 30 | "No 👎", 31 | callback_data="feedback_no", 32 | ), 33 | row_width=2, 34 | ) 35 | markup.add(InlineKeyboardButton("Annulla", callback_data="feedback_undo")) 36 | return markup 37 | 38 | 39 | @bot.callback_query_handler(func=lambda call: call.data.startswith("feedback_")) 40 | def callback_query(call): 41 | telegram_id = str(call.from_user.id) 42 | call_id = call.id 43 | message_id = call.message.id 44 | data = call.data 45 | 46 | with db.connection() as c: 47 | user = db.user.by_telegram_id(c, telegram_id) 48 | if not user: 49 | bot.answer_callback_query(call_id, show_alert=False) 50 | return 51 | 52 | markup = InlineKeyboardMarkup() 53 | markup.add(InlineKeyboardButton("Ricomincia", callback_data="main_start")) 54 | 55 | # Empty reply to the original query 56 | bot.answer_callback_query(call_id, show_alert=False) 57 | if data == "feedback_init": 58 | # Create a new message to display the extra information 59 | send_message(telegram_id, MESSAGE, reply_markup=gen_markup_feedback()) 60 | elif data == "feedback_undo": 61 | edit_message_reply_markup(telegram_id, message_id, reply_markup=False) 62 | send_message(telegram_id, MESSAGE_UNDO) 63 | elif data == "feedback_yes": 64 | with db.transaction() as t: 65 | user = db.user.by_telegram_id(t, telegram_id) 66 | if user: 67 | db.user.delete(t, user["id"]) 68 | db.log.insert(t, "booked", True) 69 | send_message(telegram_id, MESSAGE_REPLY, reply_markup=markup) 70 | elif data == "feedback_no": 71 | with db.transaction() as t: 72 | user = db.user.by_telegram_id(t, telegram_id) 73 | if user: 74 | db.user.delete(t, user["id"]) 75 | db.log.insert(t, "booked", False) 76 | send_message(telegram_id, MESSAGE_REPLY_NO, reply_markup=markup) 77 | -------------------------------------------------------------------------------- /tests/test_agent.py: -------------------------------------------------------------------------------- 1 | import unittest 2 | 3 | from serenissimo import agent 4 | 5 | 6 | class TestAgent(unittest.TestCase): 7 | def test_cohort_selection(self): 8 | html = """ 9 | 10 | 11 |

Selezionare la categoria per la quale si vuole autocertificarsi

12 |
Si ricorda che al momento della vaccinazione verrà richiesto un documento di identità e un autocertificazione che attesti l'effettiva appartenenza alla categoria selezionata
13 | 14 |
15 | 16 |
17 | 18 | 19 | 20 | """ 21 | urls = agent.extract_urls(html, 0) 22 | self.assertEqual( 23 | urls, 24 | [ 25 | [ 26 | "https://vaccinicovid.regione.veneto.it/ulss0/azione/controllocf/corte/152", 27 | "Vulnerabili", 28 | ], 29 | [ 30 | "https://vaccinicovid.regione.veneto.it/ulss0/azione/controllocf/corte/153", 31 | "Persone con disabilità grave (L. 104 art. 3 c.3)", 32 | ], 33 | [ 34 | "https://vaccinicovid.regione.veneto.it/ulss0/azione/controllocf/corte/154", 35 | "Over 80", 36 | ], 37 | [ 38 | "https://vaccinicovid.regione.veneto.it/ulss0/azione/controllocf/corte/1120", 39 | "Oncologici", 40 | ], 41 | ], 42 | ) 43 | 44 | def test_service(self): 45 | html = """ 46 |

Selezionare un servizio

47 | 48 | 49 | 50 |
51 |
52 | 53 | """ 54 | 55 | urls = agent.extract_urls(html, 0) 56 | self.assertEqual( 57 | urls, 58 | [ 59 | [ 60 | "https://vaccinicovid.regione.veneto.it/ulss0/azione/sceglisede/servizio/101", 61 | "Estremamente vulnerabili nati prima del 1951", 62 | ], 63 | [ 64 | "https://vaccinicovid.regione.veneto.it/ulss0/azione/sceglisede/servizio/614", 65 | "Vulnerabili e Disabili (R)", 66 | ], 67 | ], 68 | ) 69 | 70 | def test_redirect(self): 71 | html = """ """ 72 | urls = agent.extract_urls(html, 0) 73 | self.assertEqual( 74 | urls, 75 | [ 76 | [ 77 | "https://vaccinicovid.regione.veneto.it/ulss0/azione/sceglisede/servizio/608", 78 | "", 79 | ] 80 | ], 81 | ) 82 | 83 | 84 | if __name__ == "__main__": 85 | unittest.main() 86 | -------------------------------------------------------------------------------- /serenissimo/db/stats.py: -------------------------------------------------------------------------------- 1 | def select(c): 2 | count_booked = """ 3 | SELECT COUNT(*) as booked 4 | FROM log 5 | WHERE name = 'booked' AND x = 1""" 6 | count_incomplete = """ 7 | SELECT COUNT(*) as users 8 | FROM user 9 | INNER JOIN subscription ON (user.id = subscription.user_id) 10 | WHERE ulss_id IS NOT NULL 11 | AND fiscal_code IS NOT NULL 12 | AND health_insurance_number IS NULL 13 | """ 14 | count_users = """ 15 | SELECT COUNT(*) as users 16 | FROM user 17 | INNER JOIN subscription ON (user.id = subscription.user_id) 18 | WHERE ulss_id IS NOT NULL 19 | AND fiscal_code IS NOT NULL 20 | AND health_insurance_number IS NOT NULL 21 | """ 22 | return { 23 | "booked": c.execute(count_booked).fetchone()["booked"], 24 | "users": c.execute(count_users).fetchone()["users"], 25 | "users_incomplete": c.execute(count_incomplete).fetchone()["users"], 26 | } 27 | 28 | 29 | def group_subscribers_by_day(c): 30 | select = """ 31 | WITH RECURSIVE days(day) AS ( 32 | SELECT 0 33 | UNION ALL 34 | SELECT day -1 35 | FROM days 36 | WHERE day > -14 37 | ) 38 | SELECT days.day, 39 | coalesce(vals.total, 0) 40 | FROM days 41 | LEFT OUTER JOIN ( 42 | SELECT CAST( 43 | ( 44 | julianday(date(user.ts, 'unixepoch')) - julianday('now') 45 | ) AS INTEGER 46 | ) as day, 47 | count(*) as total 48 | FROM user 49 | join subscription on user.id = subscription.user_id 50 | WHERE fiscal_code IS NOT NULL 51 | AND ulss_id IS NOT NULL 52 | AND health_insurance_number IS NOT NULL 53 | AND user.ts > strftime('%s', date('now', '-14 days')) 54 | GROUP BY date(user.ts, 'unixepoch') 55 | ) AS vals ON (days.day = vals.day) 56 | """ 57 | 58 | return c.execute(select).fetchall() 59 | 60 | 61 | def group_notifications_by_day(c): 62 | select = """ 63 | WITH RECURSIVE days(day) AS ( 64 | SELECT 0 65 | UNION ALL 66 | SELECT day -1 67 | FROM days 68 | WHERE day > -14 69 | ) 70 | SELECT days.day, 71 | coalesce(vals.total, 0) 72 | FROM days 73 | LEFT OUTER JOIN ( 74 | SELECT CAST( 75 | ( 76 | julianday(date(ts, 'unixepoch')) - julianday('now') 77 | ) AS INTEGER 78 | ) as day, 79 | count(*) as total 80 | FROM log 81 | WHERE ts > strftime('%s', date('now', '-14 days')) 82 | AND name IN ("notification") 83 | GROUP BY date(ts, 'unixepoch') 84 | ) AS vals ON (days.day = vals.day) 85 | """ 86 | 87 | return c.execute(select).fetchall() 88 | 89 | 90 | def group_errors_by_day(c): 91 | select = """ 92 | WITH RECURSIVE days(day) AS ( 93 | SELECT 0 94 | UNION ALL 95 | SELECT day -1 96 | FROM days 97 | WHERE day > -14 98 | ) 99 | SELECT days.day, 100 | coalesce(vals.total, 0) 101 | FROM days 102 | LEFT OUTER JOIN ( 103 | SELECT CAST( 104 | ( 105 | julianday(date(ts, 'unixepoch')) - julianday('now') 106 | ) AS INTEGER 107 | ) as day, 108 | count(*) as total 109 | FROM log 110 | WHERE ts > strftime('%s', date('now', '-14 days')) 111 | AND name IN ("http-error", "application-error") 112 | GROUP BY date(ts, 'unixepoch') 113 | ) AS vals ON (days.day = vals.day) 114 | """ 115 | 116 | return c.execute(select).fetchall() 117 | -------------------------------------------------------------------------------- /serenissimo/disabled.py: -------------------------------------------------------------------------------- 1 | import json 2 | import logging 3 | import os 4 | import sys 5 | import re 6 | import traceback 7 | from collections import Counter 8 | from threading import Thread 9 | from time import sleep, time 10 | 11 | 12 | from telebot import apihelper 13 | from .bot import bot, send_message, reply_to 14 | 15 | from . import snooze 16 | from . import feedback 17 | from . import stats 18 | from . import db 19 | from .agent import ( 20 | HTTPException, 21 | ApplicationException, 22 | UnknownPayload, 23 | check, 24 | format_locations, 25 | ) 26 | 27 | log = logging.getLogger() 28 | 29 | 30 | DEV = os.getenv("DEV") 31 | ADMIN_ID = os.getenv("ADMIN_ID") 32 | 33 | @bot.message_handler(commands=["start", "ricomincia"]) 34 | @bot.message_handler( 35 | func=lambda message: message.text and message.text.strip().lower() == "ricomincia" 36 | ) 37 | def send_welcome(message): 38 | telegram_id = str(message.from_user.id) 39 | send_message( 40 | telegram_id, 41 | "Ciao, Serenissimo è stato disattivato il 25 agosto.", 42 | "", 43 | "Per prenotarti per la vaccinazione visita il sito https://vaccinicovid.regione.veneto.it/.", 44 | "Il bot è gestito da Alberto Granzotto, per informazioni digita /info", 45 | ) 46 | 47 | 48 | @bot.message_handler(commands=["cancella"]) 49 | @bot.message_handler( 50 | func=lambda message: message.text and message.text.strip().lower() == "cancella" 51 | ) 52 | def delete_message(message): 53 | telegram_id = str(message.from_user.id) 54 | with db.transaction() as t: 55 | user = db.user.by_telegram_id(t, telegram_id) 56 | if user: 57 | db.user.delete(t, user["id"]) 58 | send_message( 59 | telegram_id, 60 | "Ho cancellato i tuoi dati, non riceverai più nessuna notifica.", 61 | "Se vuoi ricominciare digita /ricomincia", 62 | ) 63 | 64 | 65 | 66 | @bot.message_handler(commands=["info", "informazioni", "aiuto", "privacy"]) 67 | @bot.message_handler( 68 | func=lambda message: message.text 69 | and message.text.strip().lower() in ["info", "aiuto", "privacy"] 70 | ) 71 | def send_info(message): 72 | chat_id = str(message.from_user.id) 73 | send_message( 74 | chat_id, 75 | 'Questo bot è stato creato da Alberto Granzotto (agranzot@mailbox.org). ' 76 | "Ho creato il bot di mia iniziativa, se trovi errori o hai correzioni mandami una mail. " 77 | "Il codice sorgente è rilasciato come software libero ed è disponibile su GitHub: https://github.com/vrde/serenissimo", 78 | "", 79 | "Per cancellarti, digita /cancella", 80 | "", 81 | "Informativa sulla privacy:", 82 | "- Nel database i dati memorizzati sono:", 83 | " - Il tuo identificativo di Telegram (NON il numero di telefono).", 84 | " - Il suo codice fiscale.", 85 | " - Le ultime sei cifre della tua tessera sanitaria.", 86 | " - La ULSS di riferimento.", 87 | "- I tuoi dati sono memorizzati in un server in Germania.", 88 | '- Se digiti "cancella", i tuoi dati vengono eliminati completamente.', 89 | "- Il codice del bot è pubblicato su https://github.com/vrde/serenissimo e chiunque può verificarne il funzionamento.", 90 | ) 91 | 92 | 93 | @bot.message_handler(func=lambda message: True) 94 | def fallback_message(message): 95 | telegram_id = str(message.from_user.id) 96 | if message.text: 97 | log.info("Unknown message: %s", message.text) 98 | reply_to( 99 | message, 100 | "Ciao, Serenissimo è stato disattivato il 25 agosto.", 101 | "", 102 | "Per prenotarti per la vaccinazione visita il sito https://vaccinicovid.regione.veneto.it/.", 103 | "Il bot è gestito da Alberto Granzotto, per informazioni digita /info", 104 | ) 105 | 106 | 107 | if __name__ == "__main__": 108 | log.info("Start Serenissimo bot, viva el doge, viva el mar!") 109 | with db.transaction() as t: 110 | db.init(t) 111 | db.init_data(t) 112 | apihelper.SESSION_TIME_TO_LIVE = 5 * 60 113 | try: 114 | bot.polling() 115 | except KeyboardInterrupt: 116 | sys.exit() 117 | except Exception as e: 118 | stack = traceback.format_exception(*sys.exc_info()) 119 | send_message(ADMIN_ID, "🤬🤬🤬\n" + "".join(stack)) 120 | print("".join(stack)) 121 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Serenissimo, domande frequenti 2 | 3 | ## Chi sei? 4 | 5 | Sono [Alberto Granzotto](https://www.granzotto.net/), libero professionista a Berlino. Mi occupo di servizi software, privacy, decentralizzazione. La mia email è . 6 | 7 | ## Cos'è Serenissimo? 8 | 9 | Serenissimo è un bot (un assistente virtuale) che ti manda un messaggio sul telefono quando si liberano posti per il vaccino in Veneto. Puoi parlare con il bot tramite l'applicazione di messaggistica [Telegram](https://telegram.org/). Il bot ti chiede la ULSS di appartenenza e il tuo codice fiscale. Ottenuti i dati li memorizza e controlla periodicamente sul [sito ufficiale della Regione Veneto](https://vaccinicovid.regione.veneto.it/) i posti disponibili, se ce ne sono ti manda un messaggio. 10 | 11 | ## Perché hai fatto Serenissimo? 12 | 13 | Il bot nasce da un'esigenza personale. Mia madre deve prenotarsi per il vaccino anti-covid. Controlla spesso, delle volte ogni ora, il sito ufficiale della Regione Veneto. Come lei, altre persone che conosco controllano continuamente (spesso per i propri genitori anziani) il sito ufficiale dei vaccini, sperando di trovare un posto. 14 | 15 | Mi sono chiesto: perché non invertire il paradigma? Perché non avvisare le persone quando si liberano posti per la vaccinazione, invece di fargli attivamente controllare la situazione? Questo vale soprattutto per le persone anziane, che spesso si affidano ai propri figli per usare questi sistemi. 16 | 17 | ## Perché ricevo notifiche per categorie a cui non appartengo? 18 | 19 | Il portale della Regione non effettua la corrispondenza tra il codice fiscale e l'appartenenza o meno alle categorie a rischio. Questo significa che se appartieni a una categoria a rischio ma, una volta inserito il tuo codice fiscale, ricevi l'avviso che non sei tra gli aventi diritto, sta a te autocertificarti nel portale. 20 | 21 | Serenissimo si attiene alle informazioni ricavabili dal portale della Regione. Di conseguenza Serenissimo non può capire in automatico se appartieni a una specifica categoria e preferisce mostrarti tutti gli appuntamenti disponibili, lasciando a te la scelta della categoria di appartenenza. 22 | 23 | Se hai suggerimenti o proposte su come risolvere questo problema, scrivimi a . 24 | 25 | ## Perché devo inserire la ULSS quando hai già il mio codice fiscale? 26 | 27 | Il portale della Regione Veneto funziona così. Avevo pensato di chiedere solo il codice fiscale e "cercare" in tutte le 9 ULSS, ma questo avrebbe aumentato il numero di richieste del bot. 28 | 29 | ## Il bot rischia di rendere la piattaforma di vaccinazione inaccessibile? 30 | 31 | No. **Il bot fa richieste sequenziali, non in parallelo.** Cosa significa? Immagina di essere all'ufficio delle poste, davanti a te ci sono 10 sportelli liberi. Preferisci avere davanti a te una persona con 100 lettere, o 100 persone con una lettera? Meglio una persona con 100 lettere, visto che terrà occupato solo uno degli sportelli disponibili. Il bot ha tante lettere in mano ma occupa solo uno degli sportelli, e per molto poco tempo! 32 | 33 | Entro volentieri nei dettagli tecnici. Tutto quello che dico è verificabile dal [codice sorgente](https://github.com/vrde/serenissimo): 34 | 35 | - Per gli utenti che rientrano nelle categorie da vaccinare, il bot controlla ogni 30 minuti se ci sono dei posti liberi. 36 | - Per gli utenti che non rientrano ancora nelle categorie da vaccinare, il bot controlla ogni 4 ore la situazione. 37 | 38 | Il bot **non crea un carico maggiore ai server** perché le richieste che fa sono a nome di altri utenti. Inoltre il bot ottimizza le richieste, non carica file statici come immagini, JavaScript o fogli di stile, alleggerendo il traffico. 39 | 40 | ## Privilegi le persone che sanno usare le nuove tecnologie? 41 | 42 | No. Non ho costruito il bot per *saltare la fila*, fare i furbi o per privilegiare alcune categorie di persone piuttosto che altre. **Al contrario** il bot è pensato per rendere più accessibile il servizio di prenotazione, soprattutto per i meno esperti e per chi ha difficoltà a controllare ripetutamente lo stato delle prenotazioni. 43 | 44 | ## Serenissimo può inviare SMS? 45 | 46 | Al momento no, ma valuterò se implementare questa funzione in futuro. 47 | 48 | ## I miei dati sono al sicuro? 49 | 50 | Sì. Informativa sulla privacy: 51 | 52 | - I tuoi dati vengono usati esclusivamente per controllare la disponibilità di un appuntamento per la vaccinazione usando il sito https://vaccinicovid.regione.veneto.it/ 53 | - Nel database i dati memorizzati sono: 54 | - Il tuo identificativo di Telegram (NON il numero di telefono). 55 | - Il tuo codice fiscale. 56 | - La ULSS di riferimento. 57 | - I tuoi dati sono memorizzati in un server in Germania. 58 | - Se digiti "cancella", i tuoi dati vengono eliminati completamente. 59 | - Il codice del bot è pubblicato su https://github.com/vrde/serenissimo e chiunque può verificarne il funzionamento. -------------------------------------------------------------------------------- /serenissimo/snooze.py: -------------------------------------------------------------------------------- 1 | from .bot import bot, send_message, edit_message_text, edit_message_reply_markup 2 | from . import db 3 | from telebot.types import InlineKeyboardMarkup, InlineKeyboardButton 4 | 5 | 6 | MESSAGE_CLOSED = ( 7 | "Le notifiche notturne ti disturbano?\nSe sì, schiaccia il pulsante qui sotto 👇" 8 | ) 9 | BUTTON_CLOSED = "🌜 Modifica gli orari delle notifiche 🦉" 10 | MESSAGE_OPEN = "⏰ se vuoi disattivare le notifiche di notte, seleziona l'intervallo orario che preferisci e non ti disturberò!" 11 | 12 | 13 | def init_message(telegram_id): 14 | send_message(telegram_id, MESSAGE_CLOSED, reply_markup=gen_markup_settings()) 15 | 16 | 17 | def gen_markup_init(): 18 | markup = InlineKeyboardMarkup() 19 | markup.add(InlineKeyboardButton(BUTTON_CLOSED, callback_data="snooze_init")) 20 | return markup 21 | 22 | 23 | def gen_markup_settings(): 24 | markup = InlineKeyboardMarkup() 25 | markup.add(InlineKeyboardButton(BUTTON_CLOSED, callback_data="snooze_show")) 26 | return markup 27 | 28 | 29 | def gen_markup_snooze(snooze_from, snooze_to): 30 | markup = InlineKeyboardMarkup() 31 | keys = [ 32 | ["👇 Dalle ore 👇", "snooze_noop"], 33 | ["👇 Alle ore 👇", "snooze_noop"], 34 | ["20:00", "snooze_from_20"], 35 | ["6:00", "snooze_to_06"], 36 | ["22:00", "snooze_from_22"], 37 | ["8:00", "snooze_to_08"], 38 | ["24:00", "snooze_from_24"], 39 | ["10:00", "snooze_to_10"], 40 | ] 41 | 42 | from_key = None if snooze_from is None else f"snooze_from_{snooze_from:02}" 43 | to_key = None if snooze_to is None else f"snooze_to_{snooze_to:02}" 44 | 45 | buttons = [ 46 | InlineKeyboardButton( 47 | f"{label} ✅" if key in [from_key, to_key] else label, 48 | callback_data=key, 49 | ) 50 | for label, key in keys 51 | ] 52 | 53 | label_no_thanks = "No grazie, lascia le notifiche attive" 54 | 55 | markup.add(*buttons, row_width=2) 56 | markup.add( 57 | InlineKeyboardButton( 58 | f"{label_no_thanks} ✅" 59 | if from_key is None and to_key is None 60 | else label_no_thanks, 61 | callback_data="snooze_none", 62 | ), 63 | row_width=1, 64 | ) 65 | # Show save button only if the user selected a valid interval or an empty interval 66 | if bool(snooze_from is None) == bool(snooze_to is None): 67 | markup.add( 68 | InlineKeyboardButton("Salva e chiudi", callback_data="snooze_hide"), 69 | ) 70 | return markup 71 | 72 | 73 | @bot.callback_query_handler(func=lambda call: call.data.startswith("snooze_")) 74 | def callback_query(call): 75 | telegram_id = str(call.from_user.id) 76 | call_id = call.id 77 | message_id = call.message.id 78 | data = call.data 79 | 80 | if not data.startswith("snooze_"): 81 | return 82 | 83 | with db.connection() as c: 84 | user = db.user.by_telegram_id(c, telegram_id) 85 | if not user: 86 | return 87 | snooze_from_current = user["snooze_from"] 88 | snooze_to_current = user["snooze_to"] 89 | snooze_from = snooze_from_current 90 | snooze_to = snooze_to_current 91 | 92 | if data == "snooze_noop": 93 | bot.answer_callback_query(call_id, show_alert=False) 94 | elif data.startswith("snooze_"): 95 | # Show snooze message 96 | if data == "snooze_init": 97 | # Remove the button from the previous message 98 | edit_message_reply_markup(telegram_id, message_id, reply_markup=False) 99 | # Empty reply to the original query 100 | bot.answer_callback_query(call_id, show_alert=False) 101 | # Create a new message to display the extra information 102 | send_message( 103 | telegram_id, 104 | MESSAGE_OPEN, 105 | reply_markup=gen_markup_snooze(snooze_from_current, snooze_to_current), 106 | ) 107 | # Show snooze markup 108 | elif data == "snooze_show": 109 | interval = "" 110 | if snooze_from is not None and snooze_to is not None: 111 | interval = f"Non ti mando notifiche tra le {snooze_from}:00 e le {snooze_to}:00" 112 | edit_message_text( 113 | f"{MESSAGE_OPEN}\n\n{interval}", 114 | telegram_id, 115 | message_id, 116 | reply_markup=gen_markup_snooze(snooze_from_current, snooze_to_current), 117 | parse_mode="HTML", 118 | ) 119 | bot.answer_callback_query(call_id, show_alert=False) 120 | # Hide snooze markup 121 | elif data == "snooze_hide": 122 | edit_message_text( 123 | MESSAGE_CLOSED, 124 | telegram_id, 125 | message_id, 126 | reply_markup=gen_markup_settings(), 127 | ) 128 | bot.answer_callback_query(call_id, "Impostazioni salvate") 129 | else: 130 | if data.startswith("snooze_from"): 131 | snooze_from = int(data.split("_").pop()) 132 | with db.transaction() as t: 133 | db.user.update(t, user["id"], snooze_from=snooze_from) 134 | if data.startswith("snooze_to"): 135 | snooze_to = int(data.split("_").pop()) 136 | with db.transaction() as t: 137 | db.user.update(t, user["id"], snooze_to=snooze_to) 138 | if data.startswith("snooze_none"): 139 | snooze_from = None 140 | snooze_to = None 141 | with db.transaction() as t: 142 | db.user.reset_snooze(t, user["id"]) 143 | 144 | interval = "" 145 | if snooze_from is not None and snooze_to is not None: 146 | interval = f"\n\nNon ti mando notifiche tra le {snooze_from}:00 e le {snooze_to}:00" 147 | 148 | # If the bot tries to edit a message but the content is the same, it gets a 400, 149 | # so we check if there are actual changes to push 150 | if snooze_from != snooze_from_current or snooze_to != snooze_to_current: 151 | edit_message_text( 152 | f"{MESSAGE_OPEN}{interval}", 153 | telegram_id, 154 | message_id, 155 | reply_markup=gen_markup_snooze(snooze_from, snooze_to), 156 | parse_mode="HTML", 157 | ) 158 | bot.answer_callback_query(call_id, show_alert=False) 159 | -------------------------------------------------------------------------------- /tests/test.py: -------------------------------------------------------------------------------- 1 | import unittest 2 | 3 | from serenissimo import agent 4 | 5 | 6 | class TestStep1(unittest.TestCase): 7 | def test_step_1_parse__eligible(self): 8 | html = """ """ 9 | state, url = agent.step_1_parse(html, "XXXXXXXXXXXXXXXX", "0") 10 | self.assertEqual(state, "eligible") 11 | self.assertEqual( 12 | url, 13 | "https://vaccinicovid.regione.veneto.it/ulss0/azione/sceglisede/servizio/178", 14 | ) 15 | 16 | def test_step_1_parse__not_registered(self): 17 | html = """ 18 | 19 | 20 |
21 | 22 | Il codice fiscale inserito non risulta tra quelli registrati presso questa ULSS. Torna alla homepage e seleziona la tua ULSS di riferimento. 23 | 24 |
25 |
26 | 27 | 28 | 29 | """ 30 | with self.assertRaises(agent.NotRegisteredError) as context: 31 | agent.step_1_parse(html, "XXXXXXXXXXXXXXXX", "0") 32 | self.assertTrue( 33 | "Il codice fiscale inserito non risulta tra quelli registrati presso questa ULSS" 34 | in str(context.exception) 35 | ) 36 | 37 | def test_step_1_parse__already_vaccinated(self): 38 | html = """ 39 |
40 | 41 | Per il codice fiscale inserito è già iniziato il percorso vaccinale 42 | 43 | 44 |
45 |
46 | 47 | """ 48 | with self.assertRaises(agent.AlreadyVaccinatedError) as context: 49 | agent.step_1_parse(html, "XXXXXXXXXXXXXXXX", "0") 50 | self.assertTrue( 51 | "Per il codice fiscale inserito è già iniziato il percorso vaccinale" 52 | in str(context.exception) 53 | ) 54 | 55 | def test_step_1_parse__already_booked(self): 56 | html = """ 57 |
58 | 59 | Per il codice fiscale inserito è già registrata una prenotazione. 60 | 61 |
62 |
63 | 64 | """ 65 | 66 | with self.assertRaises(agent.AlreadyBookedError) as context: 67 | agent.step_1_parse(html, "XXXXXXXXXXXXXXXX", "0") 68 | self.assertTrue( 69 | "Per il codice fiscale inserito è già registrata una prenotazione" 70 | in str(context.exception) 71 | ) 72 | 73 | def test_step_1_parse__maybe_eligible(self): 74 | html = """ 75 | 76 |
77 | 78 | Attenzione non appartieni alle categorie che attualmente possono prenotare 79 | 80 | , se ritieni di rientrarci utilizza il pulsante sottostante per accedere al processo di autocertificazione. 81 |

82 |
83 | Autocertificati 84 |
85 |
86 |
87 | 88 | 89 | """ 90 | 91 | def test_step_1_maybe_eligible(self): 92 | html = """ 93 | 94 | 95 |

Selezionare la categoria per la quale si vuole autocertificarsi

96 |
Si ricorda che al momento della vaccinazione verrà richiesto un documento di identità e un autocertificazione che attesti l'effettiva appartenenza alla categoria selezionata
97 | 98 |
99 | 100 |
101 | 102 | """ 103 | 104 | options = agent.step_1_maybe_eligible(html, "XXXXXXXXXXXXXXXX", 0) 105 | self.assertDictEqual( 106 | options, 107 | { 108 | "Estremamente vulnerabili nati prima del 1951": "https://vaccinicovid.regione.veneto.it/ulss0/azione/controllocf/corte/1105", 109 | "Disabili gravi (L.104 art.3 c.3)": "https://vaccinicovid.regione.veneto.it/ulss0/azione/controllocf/corte/1106", 110 | }, 111 | ) 112 | 113 | def test_step_1_eligible(self): 114 | html = """ 115 | 116 | 117 | 118 | 119 | 120 | 121 |

Selezionare una sede

122 | 123 |
124 | 125 |
126 | 127 | 128 | 129 | 130 | 131 | 132 | 133 | 134 | """ 135 | available, unavailable = agent.step_1_eligible(html, "XXXXXXXXXXXXXXXX", 0) 136 | self.assertEqual( 137 | available, ["Dolo PALAZZETTO DELLO SPORT Viale dello Sport 1, Dolo (VE)"] 138 | ) 139 | self.assertEqual( 140 | unavailable, 141 | [ 142 | "Chioggia ASPO [DISPONIBILITA ESAURITA] Via Maestri del Lavoro 50, Chioggia (VE)", 143 | "Mirano BOCCIODROMO [DISPONIBILITA ESAURITA] Via G. Matteotti 46, Mirano (VE)", 144 | "Venezia PALA EXPO [DISPONIBILITA ESAURITA] Via Galileo Ferraris 5, Marghera (VE)", 145 | "Venezia RAMPA SANTA CHIARA [DISPONIBILITA ESAURITA] Rampa Santa Chiara, Venezia (ex Sede ACI)", 146 | ], 147 | ) 148 | 149 | def test_step_1_maybe_eligible_cohort(self): 150 | html = """ 151 | 152 | 153 |

Selezionare la categoria per la quale si vuole autocertificarsi

154 |
Si ricorda che al momento della vaccinazione verrà richiesto un documento di identità e un autocertificazione che attesti l'effettiva appartenenza alla categoria selezionata
155 | 156 |
157 | 158 |
159 | 160 | 161 | """ 162 | pass 163 | 164 | 165 | if __name__ == "__main__": 166 | unittest.main() 167 | -------------------------------------------------------------------------------- /serenissimo/agent.py: -------------------------------------------------------------------------------- 1 | import re 2 | 3 | import requests 4 | from bs4 import BeautifulSoup 5 | 6 | 7 | class RecoverableException(Exception): 8 | pass 9 | 10 | 11 | class ApplicationException(RecoverableException): 12 | pass 13 | 14 | 15 | class HTTPException(RecoverableException): 16 | pass 17 | 18 | 19 | class UnknownPayload(Exception): 20 | def __init__(self, message, html, cf, ulss): 21 | super().__init__(message) 22 | self.html = html 23 | self.cf = cf 24 | self.ulss = ulss 25 | 26 | 27 | URL_ROOT = "https://vaccinicovid.regione.veneto.it" 28 | URL_ULSS = URL_ROOT + "/ulss{}" 29 | URL_COHORT_CHOOSE = URL_ULSS + "/azione/sceglicorte/" 30 | URL_COHORT_SELECT = URL_ULSS + "/azione/controllocf/corte/{}" 31 | URL_SERVICE = URL_ULSS + "/azione/sceglisede/servizio/{}" 32 | URL_CHECK = URL_ULSS + "/azione/controllocf" 33 | 34 | 35 | def check(ulss, fiscal_code, health_insurance_number): 36 | try: 37 | return _check(ulss, fiscal_code, health_insurance_number) 38 | except requests.exceptions.RequestException: 39 | raise HTTPException() 40 | 41 | 42 | def _check(ulss, fiscal_code, health_insurance_number): 43 | session = requests.Session() 44 | data = {"cod_fiscale": fiscal_code, "num_tessera": health_insurance_number} 45 | 46 | # Get cookie 47 | session.get(URL_ULSS.format(ulss)) 48 | 49 | # Submit the form 50 | r = session.post(URL_CHECK.format(ulss), data=data) 51 | if r.status_code != 200: 52 | raise HTTPException() 53 | html = r.text 54 | 55 | # Ginepraio time! 56 | try: 57 | state, url = start(html, fiscal_code, ulss) 58 | except UnknownPayload as e: 59 | # Seems like ULSS 1 has a different "start" page, so we try to extract locations 60 | # directly from the html 61 | state = "maybe_eligible" 62 | available, unavailable = locations(session, None, ulss, html=html) 63 | if not available and not unavailable: 64 | raise e 65 | return state, available, unavailable 66 | 67 | # That's a horrible patch 68 | if state == "eligible" and url is None: 69 | available, unavailable = locations(session, None, ulss, html=html) 70 | if not available and not unavailable: 71 | raise UnknownPayload("Error understanding payload", html, fiscal_code, ulss) 72 | return state, available, unavailable 73 | 74 | if url is None: 75 | return state, None, None 76 | else: 77 | available, unavailable = locations(session, url, ulss) 78 | return state, available, unavailable 79 | 80 | 81 | def locations(session, url, ulss, max_depth=5, html=None): 82 | if max_depth == 0: 83 | return None, None 84 | 85 | if html is None: 86 | r = session.post(url) 87 | if r.status_code != 200: 88 | raise HTTPException() 89 | html = r.text 90 | 91 | if url and "sceglisede" in url: 92 | available, unavailable = extract_locations(html) 93 | else: 94 | available, unavailable = {}, {} 95 | for url, label in extract_urls(html, ulss): 96 | sub_available, sub_unavailable = locations( 97 | session, url, ulss, max_depth=max_depth - 1 98 | ) 99 | if sub_available: 100 | available[label] = sub_available 101 | if sub_unavailable: 102 | unavailable[label] = sub_unavailable 103 | 104 | # Yes this can be done better 105 | available_keys = list(available.keys()) 106 | unavailable_keys = list(unavailable.keys()) 107 | if available_keys and available_keys[0] == "": 108 | available = available[""] 109 | if unavailable_keys and unavailable_keys[0] == "": 110 | unavailable = unavailable[""] 111 | 112 | return available, unavailable 113 | 114 | 115 | def start(html, cf, ulss): 116 | # Check if the response is a redirect to step 2 117 | matches = re.findall(r"act_step\(2,(\d+)", html.replace(" ", "")) 118 | if len(matches) == 1: 119 | return "eligible", URL_SERVICE.format(ulss, matches[0]) 120 | 121 | if html.replace(" ", "").startswith("