├── 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
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 |
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
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("