├── .github └── workflows │ └── python-workflow.yml ├── .gitignore ├── .gitmodules ├── Dockerfile ├── LICENSE ├── README.md ├── covidbot ├── __init__.py ├── __main__.py ├── bot.py ├── covid_data │ ├── WorkingDayChecker.py │ ├── __init__.py │ ├── covid_data.py │ ├── models.py │ ├── updater │ │ ├── __init__.py │ │ ├── cases.py │ │ ├── districts.py │ │ ├── hospital.py │ │ ├── icu.py │ │ ├── rules.py │ │ ├── rvalue.py │ │ ├── updater.py │ │ ├── utils.py │ │ └── vaccination.py │ └── visualization.py ├── feedback_notifier.py ├── interfaces │ ├── __init__.py │ ├── bot_response.py │ ├── facebook_interface.py │ ├── fbmessenger_interface.py │ ├── instagram_interface.py │ ├── mastodon_interface.py │ ├── matrix_interface.py │ ├── messenger_interface.py │ ├── signal_interface.py │ ├── single_command_interface.py │ ├── telegram_interface.py │ ├── threema_interface.py │ └── twitter_interface.py ├── location_service.py ├── metrics.py ├── report_generator.py ├── settings.py ├── tests │ ├── __init__.py │ ├── test_WorkingDayChecker.py │ ├── test_bot.py │ ├── test_covid_data.py │ ├── test_data_updater.py │ ├── test_location_service.py │ ├── test_single_command_interface.py │ ├── test_subscription_manager.py │ ├── test_user_hint_service.py │ ├── test_utils.py │ └── test_visualization.py ├── user_hint_service.py ├── user_manager.py └── utils.py ├── docker-compose.yaml ├── docs ├── .nojekyll ├── Makefile ├── conf.py ├── index.rst ├── make.bat ├── readme.rst └── source │ ├── covidbot.covid_data.rst │ ├── covidbot.rst │ ├── covidbot.tests.rst │ └── modules.rst ├── feedback ├── __init__.py ├── __main__.py └── feedback_manager.py ├── html └── infections-2021-04-11-0.jpg ├── logs └── test ├── requirements.txt └── resources ├── 2021-01-16-testdata-counties.sql ├── 2021-01-16-testdata-covid_data.sql ├── 2021-01-first-newsletter-v2.html ├── 2021-01-first-newsletter.html ├── 2021-02-26-correction.html ├── 2021-02-second-newsletter.html ├── 2021-03-18-sorry-no-data.html ├── 2021-03-newsletter.html ├── 2021-05-newsletter.html ├── 2021-06-newsletter.html ├── 2021-08-newsletter.html ├── 2021-11-newsletter.html ├── 2021-12-09-correction.html ├── 2023-04-standby.html ├── 2023-08-end.html ├── cloud-init ├── config.default.ini ├── config.unittest.ini ├── counties.sql ├── d64-logo.png ├── db-migration.sql ├── error-disabled-user-per-day.md ├── feedback-templates ├── base.jinja2 ├── single.jinja2 └── static │ ├── favicon.ico │ ├── logo.jpg │ └── style.css ├── germany_rs.geojson ├── logo-300-dpi.png ├── logo.png ├── logo.svg ├── matrix-generate-access-token.sh ├── signal-no2uuid.sh ├── signal-reset-session.sh ├── signal-trust-new-keys.sh ├── testuser_empty.json ├── testzentren.csv ├── threema-publicKey.txt ├── threema.gif ├── user-tips.csv └── website ├── d64-bg.png ├── favicon.png ├── fonts.css ├── fonts ├── lato-v17-latin-300.eot ├── lato-v17-latin-300.svg ├── lato-v17-latin-300.ttf ├── lato-v17-latin-300.woff ├── lato-v17-latin-300.woff2 ├── lato-v17-latin-300italic.eot ├── lato-v17-latin-300italic.svg ├── lato-v17-latin-300italic.ttf ├── lato-v17-latin-300italic.woff ├── lato-v17-latin-300italic.woff2 ├── lato-v17-latin-italic.eot ├── lato-v17-latin-italic.svg ├── lato-v17-latin-italic.ttf ├── lato-v17-latin-italic.woff ├── lato-v17-latin-italic.woff2 ├── lato-v17-latin-regular.eot ├── lato-v17-latin-regular.woff └── lato-v17-latin-regular.woff2 ├── index.html ├── logo.png ├── style.css └── threema-verification.png /.github/workflows/python-workflow.yml: -------------------------------------------------------------------------------- 1 | name: Unit Tests 2 | 3 | on: [push, pull_request] 4 | jobs: 5 | lint: 6 | name: Lint 7 | runs-on: ubuntu-latest 8 | steps: 9 | - uses: actions/checkout@v2 10 | - name: Set up Python 11 | uses: actions/setup-python@v2 12 | with: 13 | python-version: 3.8 14 | - name: Install dependencies 15 | run: | 16 | python -m pip install --upgrade pip 17 | python -m pip install flake8 18 | - name: Lint with flake8 19 | run: | 20 | # stop the build if there are Python syntax errors or undefined names 21 | flake8 covidbot --count --select=E9,F63,F7,F82 --show-source --statistics 22 | flake8 covidbot --count --exit-zero --max-complexity=10 --max-line-length=120 --statistics 23 | test: 24 | name: Unit Tests 25 | runs-on: ubuntu-latest 26 | steps: 27 | - uses: actions/checkout@v2 28 | - name: Set up Python 29 | uses: actions/setup-python@v2 30 | with: 31 | python-version: 3.8 32 | - name: Start MariaDB 33 | uses: getong/mariadb-action@v1.1 34 | with: 35 | host port: 3307 36 | container port: 3307 37 | collation server: 'utf8mb4_unicode_ci' 38 | mariadb version: '10.3.22' 39 | mysql database: 'covid_test_db' 40 | mysql root password: 'covid_bot' 41 | - name: Install dependencies 42 | run: | 43 | sudo apt-get install libolm-dev 44 | python -m pip install --upgrade pip 45 | pip install -r requirements.txt 46 | - name: Test with unittest 47 | run: | 48 | python -m unittest 49 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | venv/* 2 | .idea/* 3 | *__pycache__* 4 | venv3.8/* 5 | 6 | # Secret configuration 7 | config.ini 8 | resources/threema-privateKey.txt 9 | 10 | # Generated files 11 | user.json 12 | covidbot/tests/*.json 13 | data.csv 14 | *.log 15 | /covidbot/tests/current_test.csv 16 | *.pyc 17 | graphics/* 18 | 19 | # Sphinx documentation 20 | docs/_build/ -------------------------------------------------------------------------------- /.gitmodules: -------------------------------------------------------------------------------- 1 | [submodule "resources/signald"] 2 | path = resources/signald 3 | url = https://gitlab.com/signald/signald.git 4 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM python:3 2 | 3 | WORKDIR /usr/src/app 4 | 5 | COPY requirements.txt ./ 6 | RUN pip install --no-cache-dir -r requirements.txt 7 | 8 | COPY . . 9 | 10 | CMD [ "python", "-m", "covidbot" ] 11 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Der Covidbot ist ~~im Standby~~ Abgeschaltet! 2 | Die Pandemie flaut ab, die [RKI-Zahlen verlieren an Aussagekraft](https://www.mdr.de/nachrichten/deutschland/gesellschaft/corona-keine-rki-zahlen-mehr-100.html). 3 | Andere Datenquellen, die unser Bot nutzt, werden eingestellt. 4 | Wenn wir dem Bot auf dem Laufenden halten wollen, bedeutet das mehr Arbeit als wir an Kapazität zur Verfügung stellen können (und derzeit wollen). 5 | 6 | ~~Aufgrund dessen versetzen wir unseren Covidbot in einen *Standby*-Modus. 7 | Das heißt:~~ 8 | * ~~Standardmäßig werden keine automatischen Berichte mehr versendet (Dies lässt sich über den Befehl "Berichte" wieder aktivieren)~~ 9 | * ~~Datenquellen, die wegfallen (bspw. die Impfdaten) werden nicht durch neue Quellen ersetzt~~ 10 | 11 | Da mittlerweile noch mehr Quellen weggefallen sind, haben wir uns dazu entschieden den Bot abzuschalten. 12 | 13 | Falls sich die Lage wieder verschärfen sollte, können wir den Bot allerdings jederzeit reaktivieren. 14 | 15 | # Der D64 Covidbot 16 | Ein Bot zu Deinen Diensten: Unser Covidbot versorgt Dich einmal am Tag mit den aktuellen Infektions-, Todes- und Impfzahlen der von Dir ausgewählten Orte. 17 | Abonniere ihn einfach in Deinem Lieblingsmessenger, indem Du den Telegram-Bot startest oder bei Signal oder Threema eine Nachricht mit "Start" schickst, nachdem Du den Bot als Kontakt hinzugefügt hast. 18 | [Telegram](https://t.me/CovidInzidenzBot) | [Threema](https://threema.id/*COVINFO?text=Start) | Signal (Beta): Füge +4915792453845 als Kontakt hinzu. 19 | [https://covidbot.d-64.org](https://covidbot.d-64.org) 20 | 21 | ## Features 22 | * Jederzeit aktuelle Infektionszahlen für alle Orte in Deutschland mit Grafik 23 | * Morgens ein täglicher Bericht mit den abonnierten Orten 24 | * R-Wert und Impfdaten 25 | 26 | ## Demo 27 | ![](resources/threema.gif) 28 | 29 | 30 | ## Installation 31 | ### Voraussetzungen 32 | Unterschiedlich, je nach Messengern die eingesetzt werden. Es wird immer min. Python3.8 benötigt, sowie eine MySQL Datenbank. 33 | 34 | ### Installation 35 | Kopiere die Default-Config Datei und passe `config.ini` an. Wenn du einen Messenger nicht nutzen möchtest, muss der Config Teil nicht existieren. 36 | Immer benötigt wird `[DATABASE]`. 37 | `cp resources/config.default.ini config.ini` 38 | 39 | Anschließend müssen die Requirements installiert werden. 40 | `pip install -r requirements.txt` 41 | 42 | Benutzung des Bots: 43 | ```shell 44 | $ python -m covidbot --help 45 | usage: python -m covidbot 46 | 47 | optional arguments: 48 | -h, --help show this help message and exit 49 | --verbose, -v 50 | --config CONFIG_FILE, -c CONFIG_FILE 51 | --platform {threema,telegram,signal,shell} 52 | Platform that should be used 53 | --check-updates Run platform independent jobs, such as checking for new data 54 | --daily-report Send daily reports if available, requires --platform 55 | --message-user Send a message to users 56 | --file MESSAGE_FILE Message, requires --message-user 57 | --all Intended receivers, requires --platform 58 | --specific USER [USER ...] 59 | Intended receivers, requires --platform 60 | ``` 61 | 62 | Mit `python -m covidbot --platform shell` kann man den Bot im Shell Modus starten. 63 | Es läuft komplett im Terminal und ist gut geeignet, um etwas ohne Messenger Zugang zu testen: 64 | ```shell 65 | $ python -m covidbot --platform shell 66 | Please enter input: 67 | > Start 68 | Hallo, 69 | über diesen Bot kannst Du Dir die vom Robert-Koch-Institut (RKI) bereitgestellten COVID-19-Daten anzeigen lassen und sie dauerhaft kostenlos abonnieren. Einen Überblick über alle Befehle erhältst du über "Hilfe". 70 | 71 | Schicke einfach eine Nachricht mit dem Ort, für den Du Informationen erhalten möchtest. Der Ort kann entweder ein Bundesland oder ein Stadt-/ Landkreis sein. Du kannst auch einen Standort senden! Wenn die Daten des Ortes nur gesammelt für eine übergeordneten Landkreis oder eine Region vorliegen, werden dir diese vorgeschlagen. Du kannst beliebig viele Orte abonnieren und unabhängig von diesen auch die aktuellen Zahlen für andere Orte ansehen. 72 | > Daten Berlin 73 | 𝗕𝗲𝗿𝗹𝗶𝗻 74 | 75 | 🏥 𝗜𝗻𝗳𝗲𝗸𝘁𝗶𝗼𝗻𝘀𝗱𝗮𝘁𝗲𝗻 76 | Die 7-Tage-Inzidenz (Anzahl der Infektionen je 100.000 Einwohner:innen) liegt bei 57,36 ↗. 77 | 78 | Neuinfektionen (seit gestern): Keine Daten 79 | Infektionen seit Ausbruch der Pandemie: 124.518 80 | 81 | Neue Todesfälle (seit gestern): Keine Daten 82 | Todesfälle seit Ausbruch der Pandemie: 2.598 83 | 84 | 💉 𝗜𝗺𝗽𝗳𝗱𝗮𝘁𝗲𝗻 85 | 3,49% der Bevölkerung haben mindestens eine Impfung erhalten, 1,86% sind - Stand 13.02.2021 - vollständig geimpft. 86 | 87 | Verabreichte Erstimpfdosen: 127.951 88 | Verabreichte Zweitimpfdosen: 68.363 89 | 90 | 𝘐𝘯𝘧𝘦𝘬𝘵𝘪𝘰𝘯𝘴𝘥𝘢𝘵𝘦𝘯 𝘷𝘰𝘮 13.02.2021 91 | 𝘋𝘢𝘵𝘦𝘯 𝘷𝘰𝘮 𝘙𝘰𝘣𝘦𝘳𝘵 𝘒𝘰𝘤𝘩-𝘐𝘯𝘴𝘵𝘪𝘵𝘶𝘵 (𝘙𝘒𝘐), 𝘓𝘪𝘻𝘦𝘯𝘻: 𝘥𝘭-𝘥𝘦/𝘣𝘺-2-0, 𝘸𝘦𝘪𝘵𝘦𝘳𝘦 𝘐𝘯𝘧𝘰𝘳𝘮𝘢𝘵𝘪𝘰𝘯𝘦𝘯 𝘧𝘪𝘯𝘥𝘦𝘴𝘵 𝘋𝘶 𝘪𝘮 𝘋𝘢𝘴𝘩𝘣𝘰𝘢𝘳𝘥 𝘥𝘦𝘴 𝘙𝘒𝘐 (https://corona.rki.de/) 𝘶𝘯𝘥 𝘥𝘦𝘮 𝘐𝘮𝘱𝘧𝘥𝘢𝘴𝘩𝘣𝘰𝘢𝘳𝘥 (https://impfdashboard.de/). 𝘚𝘦𝘯𝘥𝘦 "𝘐𝘯𝘧𝘰" 𝘶𝘮 𝘦𝘪𝘯𝘦 𝘌𝘳𝘭𝘢̈𝘶𝘵𝘦𝘳𝘶𝘯𝘨 𝘥𝘦𝘳 𝘋𝘢𝘵𝘦𝘯 𝘻𝘶 𝘦𝘳𝘩𝘢𝘭𝘵𝘦𝘯. 92 | ``` 93 | 94 | ### Cronjobs einrichten 95 | Unser Bot verlässt sich darauf, dass er regelmäßig mit Flags gestartet wird um 96 | * Daten zu updaten (`--check-updates`) 97 | * Berichte zu versenden (`--platform PLATFORM --daily-report`) 98 | 99 | Es kann zu Problemen kommen, wenn der Update Prozess oder der Report Prozess eines einzelnen Messengers parallel läuft. 100 | Um das zu verhindern, nutzen wir Lockfiles mit Flock. 101 | 102 | ```shell 103 | # Data Update 104 | */15 * * * * /usr/bin/env bash -c 'cd /home/covidbot/covid-bot && source venv/bin/activate && /usr/bin/flock -n resources/tmp/data-update.lock python -m covidbot --check-updates' 105 | 106 | # Messenger 107 | */5 * * * * /usr/bin/env bash -c 'cd /home/covidbot/covid-bot && source venv/bin/activate && /usr/bin/flock -n resources/tmp/signal-reports.lock python -m covidbot --daily-report --platform signal' 108 | */5 * * * * /usr/bin/env bash -c 'cd /home/covidbot/covid-bot && source venv/bin/activate && /usr/bin/flock -n resources/tmp/threema-reports.lock python -m covidbot --daily-report --platform threema' 109 | */5 * * * * /usr/bin/env bash -c 'cd /home/covidbot/covid-bot && source venv/bin/activate && /usr/bin/flock -n resources/tmp/telegram-reports.lock python -m covidbot --daily-report --platform telegram' 110 | 111 | # Restart due to graph cache currently not dependent on date 112 | 20 8 * * * supervisorctl restart telegrambot 113 | 20 8 * * * supervisorctl restart threemabot 114 | ``` 115 | 116 | ## Architektur 117 | 118 | 119 | ## Credits 120 | Die Informationen über die Corona-Infektionen werden von der offiziellen Schnittstelle des RKI für [Landkreise](https://hub.arcgis.com/datasets/917fc37a709542548cc3be077a786c17_0) und [Bundesländer](https://npgeo-corona-npgeo-de.hub.arcgis.com/datasets/ef4b445a53c1406892257fe63129a8ea_0) abgerufen und stehen unter der Open Data Datenlizenz Deutschland – Namensnennung – Version 2.0. 121 | Weitere Informationen sind auch im [Dashboard des RKI](https://corona.rki.de/) sowie dem [NPGEO Corona Hub 2020](https://npgeo-corona-npgeo-de.hub.arcgis.com/) zu finden. 122 | 123 | Welche Bibliotheken und andere Dienste wir noch verwenden, kannst Du unter [Credits](https://github.com/eknoes/covid-bot/wiki/Credits) im Wiki einsehen. 124 | 125 | ## Ein Projekt von D64 - Zentrum für Digitalen Fortschritt 126 | D64 versteht sich als Denkfabrik des digitalen Wandels. Wir sind von der gesamtgesellschaftlichen Auswirkung des Internets auf sämtliche Bereiche des öffentlichen und privaten Lebens überzeugt. D64 will Taktgeber und Ratgeber für die Politik sein, um Deutschland für die digitale Demokratie aufzustellen. Leitgedanke des Vereins ist die Frage, wie das Internet dazu beitragen kann, eine gerechte Gesellschaft zu fördern. Wir finanzieren uns ausschließlich durch Mitgliedsbeiträge. [Werde Mitglied und hilf mit, das Internet freier, gerechter und solidarischer zu machen!](https://d-64.org/mitglied-werden/) 127 | 128 | [Datenschutzerklärung](https://github.com/eknoes/covid-bot/wiki/Datenschutz) | [Impressum](https://github.com/eknoes/covid-bot/wiki/Impressum) 129 | -------------------------------------------------------------------------------- /covidbot/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/eknoes/covidbot/565450e6ab558d5e91b4fcec1d161628d56fe865/covidbot/__init__.py -------------------------------------------------------------------------------- /covidbot/covid_data/WorkingDayChecker.py: -------------------------------------------------------------------------------- 1 | import datetime 2 | import json 3 | from typing import Optional 4 | 5 | import requests 6 | 7 | 8 | class WorkingDayChecker: 9 | 10 | def __init__(self): 11 | self.holidays = dict() 12 | 13 | def _data(self, year): 14 | if not year in self.holidays: 15 | header = { 16 | "User-Agent": "CovidBot (https://github.com/eknoes/covid-bot | https://covidbot.d-64.org)"} 17 | response = requests.get( 18 | f'https://feiertage-api.de/api/?jahr={year}', 19 | headers=header) 20 | 21 | if not response or response.status_code < 200 or response.status_code >= 300: 22 | raise ConnectionError(f"Can't connect to feiertage-api.de: {response}") 23 | self.holidays[year] = json.loads(response.text) 24 | 25 | return self.holidays[year] 26 | 27 | def is_valid_state(self, state: str) -> bool: 28 | return state.upper() in self._data(datetime.date.today().year) 29 | 30 | def check_holiday(self, for_day: datetime.date, state: Optional[str] = "NATIONAL") -> bool: 31 | if not state or state == "BUND": 32 | state = "NATIONAL" 33 | state = state.upper() 34 | 35 | if not self.is_valid_state(state): 36 | raise ValueError(f"{state} not a valid name to check for holidays") 37 | 38 | if for_day.weekday() == 6: 39 | return True 40 | 41 | for_day_iso = for_day.isoformat() 42 | for _, info in self._data(for_day.year)[state].items(): 43 | if for_day_iso == info['datum']: 44 | return True 45 | 46 | return False 47 | 48 | -------------------------------------------------------------------------------- /covidbot/covid_data/__init__.py: -------------------------------------------------------------------------------- 1 | from .covid_data import CovidData 2 | from .models import DistrictData, TrendValue, RValueData, VaccinationData 3 | from .updater.cases import RKIKeyDataUpdater, RKIHistoryUpdater 4 | from .updater.icu import ICUGermanyUpdater, ICUGermanyHistoryUpdater 5 | #from .updater.rules import RulesGermanyUpdater 6 | from .updater.rvalue import RValueGermanyUpdater 7 | from .updater.utils import clean_district_name 8 | from .updater.vaccination import VaccinationGermanyUpdater 9 | from .updater.hospital import HospitalisationRKIUpdater 10 | from .visualization import Visualization 11 | -------------------------------------------------------------------------------- /covidbot/covid_data/models.py: -------------------------------------------------------------------------------- 1 | from dataclasses import dataclass 2 | from datetime import date, datetime 3 | from enum import Enum 4 | from typing import Optional, List 5 | 6 | 7 | class TrendValue(Enum): 8 | UP = 0 9 | SAME = 1 10 | DOWN = 2 11 | 12 | 13 | @dataclass 14 | class District: 15 | name: str 16 | id: int 17 | type: Optional[str] = None 18 | parent: Optional[int] = None 19 | 20 | 21 | @dataclass 22 | class VaccinationData: 23 | vaccinated_booster: int 24 | vaccinated_full: int 25 | vaccinated_partial: int 26 | booster_rate: float 27 | full_rate: float 28 | partial_rate: float 29 | date: date 30 | last_update: datetime 31 | avg_speed: Optional[int] = None 32 | doses_diff: Optional[int] = None 33 | 34 | 35 | @dataclass 36 | class RValueData: 37 | date: date 38 | r_value_7day: float 39 | r_trend: Optional[TrendValue] = None 40 | 41 | 42 | @dataclass 43 | class ICUFacts: 44 | districts_total: int 45 | districts_full: int 46 | districts_full_trend: Optional[TrendValue] 47 | districts_low: int 48 | districts_low_trend: Optional[TrendValue] 49 | 50 | @dataclass 51 | class ICUData: 52 | date: date 53 | clear_beds: int 54 | clear_beds_children: int 55 | occupied_beds: int 56 | occupied_covid: int 57 | occupied_beds_children: int 58 | covid_ventilated: int 59 | last_update: datetime 60 | occupied_beds_trend: Optional[TrendValue] = None 61 | occupied_covid_trend: Optional[TrendValue] = None 62 | facts: Optional[ICUFacts] = None 63 | 64 | def total_beds(self) -> int: 65 | return self.clear_beds + self.occupied_beds 66 | 67 | def percent_occupied(self) -> float: 68 | return self.occupied_beds / self.total_beds() * 100 69 | 70 | def percent_covid(self) -> float: 71 | return self.occupied_covid / self.total_beds() * 100 72 | 73 | def percent_ventilated(self) -> float: 74 | if self.covid_ventilated == 0 or self.occupied_covid == 0: 75 | return 0 76 | return self.covid_ventilated / self.occupied_covid * 100 77 | 78 | 79 | @dataclass 80 | class RuleData: 81 | date: datetime 82 | text: str 83 | link: str 84 | 85 | 86 | @dataclass 87 | class IncidenceIntervalData: 88 | upper_threshold: Optional[int] = None 89 | upper_threshold_days: Optional[int] = None 90 | upper_threshold_working_days: Optional[int] = None 91 | lower_threshold: Optional[int] = None 92 | lower_threshold_days: Optional[int] = None 93 | lower_threshold_working_days: Optional[int] = None 94 | 95 | 96 | @dataclass 97 | class HospitalizationAgeGroup: 98 | cases: int 99 | incidence: float 100 | age_group: str 101 | 102 | 103 | @dataclass 104 | class Hospitalization: 105 | cases: int 106 | incidence: float 107 | date: datetime 108 | groups: Optional[List[HospitalizationAgeGroup]] = None 109 | 110 | @dataclass 111 | class DistrictFacts: 112 | highest_incidence: Optional[float] = None 113 | highest_incidence_date: Optional[date] = None 114 | highest_cases: Optional[int] = None 115 | highest_cases_date: Optional[date] = None 116 | first_case_date: Optional[date] = None 117 | highest_deaths: Optional[int] = None 118 | highest_deaths_date: Optional[date] = None 119 | first_death_date: Optional[date] = None 120 | 121 | 122 | @dataclass 123 | class DistrictData(District): 124 | date: Optional[date] = None 125 | incidence: Optional[float] = None 126 | incidence_trend: Optional[TrendValue] = None 127 | new_cases: Optional[int] = None 128 | cases_trend: Optional[TrendValue] = None 129 | new_deaths: Optional[int] = None 130 | deaths_trend: Optional[TrendValue] = None 131 | total_cases: Optional[int] = None 132 | total_deaths: Optional[int] = None 133 | last_update: Optional[datetime] = None 134 | # Optional, pluggable data 135 | incidence_interval_data: Optional[IncidenceIntervalData] = None 136 | vaccinations: Optional[VaccinationData] = None 137 | r_value: Optional[RValueData] = None 138 | icu_data: Optional[ICUData] = None 139 | rules: Optional[RuleData] = None 140 | facts: Optional[DistrictFacts] = None 141 | hospitalisation: Optional[Hospitalization] = None 142 | -------------------------------------------------------------------------------- /covidbot/covid_data/updater/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/eknoes/covidbot/565450e6ab558d5e91b4fcec1d161628d56fe865/covidbot/covid_data/updater/__init__.py -------------------------------------------------------------------------------- /covidbot/covid_data/updater/districts.py: -------------------------------------------------------------------------------- 1 | import logging 2 | from datetime import datetime 3 | from typing import Optional 4 | 5 | from covidbot.covid_data.updater.updater import Updater 6 | 7 | 8 | class RKIDistrictsUpdater(Updater): 9 | RKI_LK_SQL = "resources/counties.sql" 10 | log = logging.getLogger(__name__) 11 | 12 | def get_last_update(self) -> Optional[datetime]: 13 | return None 14 | 15 | def update(self) -> bool: 16 | with self.connection.cursor() as cursor: 17 | cursor.execute("SELECT COUNT(rs), COUNT(population) FROM counties") 18 | record = cursor.fetchone() 19 | if record[0] == 428 and record[1] == 428: 20 | return False 21 | 22 | with self.connection.cursor(dictionary=True) as cursor: 23 | with open(self.RKI_LK_SQL, "r") as f: 24 | cursor.execute(f.read()) 25 | self.connection.commit() 26 | self.log.debug("Finished inserting county data") -------------------------------------------------------------------------------- /covidbot/covid_data/updater/hospital.py: -------------------------------------------------------------------------------- 1 | import csv 2 | import logging 3 | from datetime import datetime, timedelta 4 | from typing import Optional 5 | 6 | from covidbot.covid_data.updater.updater import Updater 7 | 8 | 9 | class HospitalisationRKIUpdater(Updater): 10 | log = logging.getLogger(__name__) 11 | URL = "https://raw.githubusercontent.com/robert-koch-institut/COVID-19-Hospitalisierungen_in_Deutschland/master/Aktuell_Deutschland_COVID-19-Hospitalisierungen.csv" 12 | 13 | def get_last_update(self) -> Optional[datetime]: 14 | with self.connection.cursor() as cursor: 15 | germany_id = self.get_district_id("Deutschland") 16 | cursor.execute("SELECT MAX(updated) FROM hospitalisation WHERE district_id=%s", [germany_id]) 17 | row = cursor.fetchone() 18 | if row: 19 | return row[0] 20 | 21 | def update(self) -> bool: 22 | last_update = self.get_last_update() 23 | 24 | if last_update and datetime.now() - last_update < timedelta(hours=12): 25 | return False 26 | 27 | new_data = False 28 | response = self.get_resource(self.URL) 29 | if response: 30 | self.log.debug("Got Hospitalisation Data from RKI") 31 | hospital_data = response.splitlines() 32 | reader = csv.DictReader(hospital_data, quoting=csv.QUOTE_NONE) 33 | 34 | with self.connection.cursor() as cursor: 35 | for row in reader: 36 | if row['Bundesland'] == "Bundesgebiet": 37 | row['Bundesland'] = "Deutschland" 38 | district_id = self.get_district_id(row['Bundesland']) 39 | if district_id is None: 40 | raise ValueError(f"No district_id for {row['Bundesland']}") 41 | 42 | if row['7T_Hospitalisierung_Faelle'] == "NA" or row['7T_Hospitalisierung_Inzidenz'] == "NA": 43 | continue 44 | 45 | updated = datetime.fromisoformat(row['Datum']) 46 | cursor.execute("SELECT id FROM hospitalisation WHERE date = %s AND district_id=%s AND age=%s", 47 | [updated, district_id, row['Altersgruppe']]) 48 | data_id = cursor.fetchone() 49 | if data_id: 50 | cursor.execute("UPDATE hospitalisation SET number=%s, incidence=%s, updated=CURRENT_TIMESTAMP() WHERE id=%s", [row['7T_Hospitalisierung_Faelle'], row['7T_Hospitalisierung_Inzidenz'], data_id[0]]) 51 | continue 52 | 53 | new_data = True 54 | 55 | cursor.execute('INSERT INTO hospitalisation (district_id, date, age, number, incidence) VALUES (%s, %s, %s, %s, %s)', 56 | [district_id, updated, row['Altersgruppe'], row['7T_Hospitalisierung_Faelle'], row['7T_Hospitalisierung_Inzidenz']]) 57 | self.connection.commit() 58 | return new_data 59 | -------------------------------------------------------------------------------- /covidbot/covid_data/updater/icu.py: -------------------------------------------------------------------------------- 1 | import csv 2 | import logging 3 | from datetime import datetime, timedelta, date 4 | from typing import Optional 5 | 6 | from covidbot.covid_data.updater.updater import Updater 7 | 8 | 9 | class ICUGermanyUpdater(Updater): 10 | log = logging.getLogger(__name__) 11 | URL = "https://diviexchange.blob.core.windows.net/%24web/DIVI_Intensivregister_Auszug_pro_Landkreis.csv" 12 | 13 | def get_last_update(self) -> Optional[datetime]: 14 | with self.connection.cursor() as cursor: 15 | cursor.execute("SELECT MAX(updated) FROM icu_beds") 16 | row = cursor.fetchone() 17 | if row: 18 | return row[0] 19 | 20 | def update(self) -> bool: 21 | last_update = self.get_last_update() 22 | 23 | if last_update and datetime.now() - last_update < timedelta(hours=12): 24 | return False 25 | 26 | response = self.get_resource(self.URL) 27 | if response: 28 | self.log.debug("Got ICU Data from DIVI") 29 | divi_data = response.splitlines() 30 | reader = csv.DictReader(divi_data) 31 | results = [] 32 | for row in reader: 33 | # Berlin is here AGS = 11000 34 | if row['gemeindeschluessel'] == '11000': 35 | row['gemeindeschluessel'] = '11' 36 | results.append((row['gemeindeschluessel'], row['daten_stand'], row['betten_frei_nur_erwachsen'], row['betten_belegt_nur_erwachsen'], 37 | row['faelle_covid_aktuell'], row['faelle_covid_aktuell_invasiv_beatmet'], int(row['betten_frei']) - int(row['betten_frei_nur_erwachsen']), int(row['betten_belegt']) - int(row['betten_belegt_nur_erwachsen']))) 38 | 39 | with self.connection.cursor() as cursor: 40 | for row in results: 41 | cursor.execute("INSERT IGNORE INTO icu_beds (district_id, date, clear, occupied, occupied_covid," 42 | " covid_ventilated, occupied_children, clear_children) VALUES (%s, %s, %s, %s, %s, %s, %s, %s)", row) 43 | 44 | # Calculate aggregated values for states 45 | for i in range(2): 46 | cursor.execute( 47 | "INSERT IGNORE INTO icu_beds (district_id, date, clear, occupied, occupied_covid, covid_ventilated, occupied_children, clear_children, updated) " 48 | "SELECT c.parent, date, SUM(clear), SUM(occupied), SUM(occupied_covid), " 49 | "SUM(covid_ventilated), SUM(occupied_children), SUM(clear_children), updated FROM icu_beds " 50 | "INNER JOIN counties c on c.rs = icu_beds.district_id " 51 | "GROUP BY c.parent, date " 52 | "HAVING (COUNT(c.parent) = (SELECT COUNT(*) FROM counties WHERE parent=c.parent) OR c.parent > 0) AND parent IS NOT NULL") 53 | self.connection.commit() 54 | if last_update != self.get_last_update(): 55 | return True 56 | return False 57 | 58 | 59 | class ICUGermanyHistoryUpdater(Updater): 60 | URL = "https://diviexchange.blob.core.windows.net/%24web/zeitreihe-tagesdaten.csv" 61 | log = logging.getLogger(__name__) 62 | 63 | def get_last_update(self) -> Optional[datetime]: 64 | with self.connection.cursor() as cursor: 65 | cursor.execute("SELECT MIN(date) FROM icu_beds") 66 | row = cursor.fetchone() 67 | if row: 68 | return row[0] 69 | 70 | def update(self) -> bool: 71 | last_update = self.get_last_update() 72 | if last_update is not None and last_update == date(2020, 4, 24): 73 | return False 74 | 75 | new_data = False 76 | 77 | data = self.get_resource(self.URL, True) 78 | if not data: 79 | return new_data 80 | 81 | reader = csv.DictReader(data.splitlines()) 82 | 83 | with self.connection.cursor(dictionary=True) as cursor: 84 | results = [] 85 | 86 | key_district_id = "gemeindeschluessel" 87 | key_covid_ventilated = "faelle_covid_aktuell_invasiv_beatmet" 88 | key_covid = "faelle_covid_aktuell" 89 | 90 | for row in reader: 91 | # Berlin is here AGS = 11000 92 | if row[key_district_id] == '11000': 93 | row[key_district_id] = '11' 94 | 95 | if key_covid_ventilated: 96 | num_ventilated = row[key_covid_ventilated] 97 | else: 98 | num_ventilated = None 99 | 100 | if key_covid: 101 | num_covid = row[key_covid] 102 | else: 103 | num_covid = None 104 | 105 | row_contents = [row[key_district_id], row['date'], row['betten_frei_nur_erwachsen'], row['betten_belegt_nur_erwachsen'], 106 | num_covid, num_ventilated, int(row['betten_frei']) - int(row['betten_frei_nur_erwachsen']), int(row['betten_belegt']) - int(row['betten_belegt_nur_erwachsen'])] 107 | results.append(row_contents) 108 | 109 | cursor.executemany( 110 | "INSERT IGNORE INTO icu_beds (district_id, date, clear, occupied, occupied_covid," 111 | " covid_ventilated, clear_children, occupied_children) VALUES (%s, %s, %s, %s, %s, %s, %s, %s)", results) 112 | 113 | # Calculate aggregated values for states 114 | for i in range(2): 115 | cursor.execute( 116 | "INSERT IGNORE INTO icu_beds (district_id, date, clear, occupied, occupied_covid, covid_ventilated, occupied_children, clear_children, updated) " 117 | "SELECT c.parent, date, SUM(clear), SUM(occupied), SUM(occupied_covid), " 118 | "SUM(covid_ventilated), SUM(occupied_children), SUM(clear_children), updated FROM icu_beds " 119 | "INNER JOIN counties c on c.rs = icu_beds.district_id " 120 | "GROUP BY c.parent, date " 121 | "HAVING (COUNT(c.parent) = (SELECT COUNT(*) FROM counties WHERE parent=c.parent) OR c.parent > 0) AND parent IS NOT NULL") 122 | self.connection.commit() 123 | new_data = True 124 | return new_data 125 | -------------------------------------------------------------------------------- /covidbot/covid_data/updater/rules.py: -------------------------------------------------------------------------------- 1 | import logging 2 | from datetime import datetime, timedelta 3 | from typing import Optional 4 | 5 | import ujson as json 6 | 7 | from covidbot.covid_data.updater.updater import Updater 8 | 9 | 10 | class RulesGermanyUpdater(Updater): 11 | log = logging.getLogger(__name__) 12 | URL = "https://tourismus-wegweiser.de/json/" 13 | 14 | def get_last_update(self) -> Optional[datetime]: 15 | with self.connection.cursor() as cursor: 16 | cursor.execute("SELECT MAX(updated) FROM district_rules") 17 | row = cursor.fetchone() 18 | if row: 19 | return row[0] 20 | 21 | def update(self) -> bool: 22 | last_update = self.get_last_update() 23 | if last_update and datetime.now() - last_update < timedelta(hours=12): 24 | return False 25 | 26 | new_data = False 27 | response = self.get_resource(self.URL) 28 | if response: 29 | self.log.debug("Got RulesGermany Data") 30 | data = json.loads(response) 31 | updated = datetime.now() 32 | with self.connection.cursor() as cursor: 33 | from covidbot.utils import adapt_text 34 | for bl in data: 35 | district_id = self.get_district_id(bl['Bundesland']) 36 | if not district_id: 37 | self.log.warning(f"Could not get ID of {bl['Bundesland']}") 38 | continue 39 | 40 | text = bl['Überblick'] 41 | text = adapt_text(text, just_strip=True) 42 | link = f'https://tourismus-wegweiser.de/detail/?bl={bl["Kürzel"]}' 43 | 44 | cursor.execute("SELECT text, link FROM district_rules WHERE district_id=%s", [district_id]) 45 | row = cursor.fetchone() 46 | if row: 47 | if row[0] == text and row[1] == link: 48 | continue 49 | cursor.execute("UPDATE district_rules SET text=%s, link=%s, updated=%s WHERE district_id=%s", 50 | [text, link, updated, district_id]) 51 | else: 52 | cursor.execute("INSERT INTO district_rules (district_id, text, link, updated) " 53 | "VALUES (%s, %s, %s, %s)", [district_id, text, link, updated]) 54 | new_data = True 55 | self.connection.commit() 56 | return new_data 57 | -------------------------------------------------------------------------------- /covidbot/covid_data/updater/rvalue.py: -------------------------------------------------------------------------------- 1 | import csv 2 | import logging 3 | from datetime import datetime, date 4 | from typing import Optional 5 | 6 | from covidbot.covid_data.updater.updater import Updater 7 | 8 | 9 | class RValueGermanyUpdater(Updater): 10 | log = logging.getLogger(__name__) 11 | URL = "https://raw.githubusercontent.com/robert-koch-institut/SARS-CoV-2-Nowcasting_und_-R-Schaetzung/main/" \ 12 | "Nowcast_R_aktuell.csv" 13 | R_VALUE_7DAY_CSV_KEY = "PS_7_Tage_R_Wert" 14 | R_VALUE_7DAY_CSV_KEY_ALT = "Sch�tzer_7_Tage_R_Wert" 15 | 16 | def get_last_update(self) -> Optional[datetime]: 17 | with self.connection.cursor() as cursor: 18 | cursor.execute("SELECT MAX(updated) FROM covid_r_value") 19 | row = cursor.fetchone() 20 | if row: 21 | return row[0] 22 | 23 | def update(self) -> bool: 24 | last_update = self.get_last_update() 25 | if last_update and last_update.date() == date.today(): 26 | return False 27 | 28 | new_data = False 29 | response = self.get_resource(self.URL) 30 | 31 | if response: 32 | self.log.debug("Got R-Value Data") 33 | 34 | rki_data = response.splitlines() 35 | reader = csv.DictReader(rki_data, delimiter=',', ) 36 | district_id = self.get_district_id("Deutschland") 37 | if district_id is None: 38 | raise ValueError("No district_id for Deutschland") 39 | 40 | with self.connection.cursor() as cursor: 41 | for row in reader: 42 | # RKI appends Erläuterungen to Data 43 | if row['Datum'] == 'Erläuterung': 44 | break 45 | 46 | if row['Datum'] == '': 47 | continue 48 | 49 | if self.R_VALUE_7DAY_CSV_KEY not in row: 50 | if self.R_VALUE_7DAY_CSV_KEY_ALT not in row: 51 | raise ValueError(f"{self.R_VALUE_7DAY_CSV_KEY} is not in CSV!") 52 | r_value = row[self.R_VALUE_7DAY_CSV_KEY_ALT] 53 | else: 54 | r_value = row[self.R_VALUE_7DAY_CSV_KEY] 55 | 56 | if not r_value: 57 | continue 58 | else: 59 | r_value = float(r_value) 60 | 61 | try: 62 | r_date = datetime.strptime(row['Datum'], "%Y-%m-%d").date() 63 | except ValueError as e: 64 | self.log.error(f"Could not get date of string {row['Datum']}", exc_info=e) 65 | continue 66 | 67 | cursor.execute("SELECT id FROM covid_r_value WHERE district_id=%s AND r_date=%s", 68 | [district_id, r_date]) 69 | if cursor.fetchone(): 70 | continue 71 | 72 | new_data = True 73 | cursor.execute("INSERT INTO covid_r_value (district_id, r_date, `7day_r_value`, updated) " 74 | "VALUES (%s, %s, %s, %s)", [district_id, r_date, r_value, datetime.now()]) 75 | self.connection.commit() 76 | return new_data 77 | -------------------------------------------------------------------------------- /covidbot/covid_data/updater/updater.py: -------------------------------------------------------------------------------- 1 | import logging 2 | import random 3 | from abc import ABC, abstractmethod 4 | from datetime import datetime 5 | from typing import Optional 6 | 7 | import requests 8 | from mysql.connector import MySQLConnection 9 | 10 | from covidbot.covid_data.covid_data import CovidDatabaseCreator 11 | 12 | 13 | class Updater(ABC): 14 | connection: MySQLConnection 15 | log: logging.Logger 16 | 17 | def __init__(self, conn: MySQLConnection): 18 | self.connection = conn 19 | self.log = logging.getLogger(str(self.__class__.__name__)) 20 | CovidDatabaseCreator(self.connection) 21 | 22 | def get_resource(self, url: str, force=False) -> Optional[str]: 23 | header = {"User-Agent": "CovidBot (https://github.com/eknoes/covid-bot | https://covidbot.d-64.org)"} 24 | last_update = self.get_last_update() 25 | if not force and last_update: 26 | # need to use our own day/month, as locale can't be changed on the fly and we have to ensure not asking for 27 | # Mär in March 28 | day = ["Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun"][last_update.weekday()] 29 | month = ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"] 30 | month = month[last_update.month - 1] 31 | header["If-Modified-Since"] = day + ", " + last_update.strftime(f'%d {month} %Y %H:%M:%S GMT') 32 | 33 | self.log.debug(f"Requesting url {url}") 34 | response = requests.get(url, headers=header) 35 | 36 | if response.status_code == 200: 37 | return response.text 38 | elif response.status_code == 304: 39 | self.log.info("HTTP304: No new data available") 40 | else: 41 | raise ValueError(f"Updater Response Status Code is {response.status_code}: {response.reason}\n{url}") 42 | 43 | @abstractmethod 44 | def update(self) -> bool: 45 | pass 46 | 47 | @abstractmethod 48 | def get_last_update(self) -> Optional[datetime]: 49 | pass 50 | 51 | def get_district_id(self, district_name: str) -> Optional[int]: 52 | with self.connection.cursor() as cursor: 53 | cursor.execute('SELECT rs, county_name FROM counties WHERE county_name LIKE %s', 54 | ["%" + district_name + "%"]) 55 | rows = cursor.fetchall() 56 | if rows: 57 | if len(rows) == 1: 58 | return rows[0][0] 59 | 60 | for row in rows: 61 | if row[1] == district_name: 62 | return row[0] 63 | 64 | cursor.execute('SELECT district_id, alt_name FROM county_alt_names WHERE alt_name LIKE %s', 65 | [f'%{district_name}%']) 66 | rows = cursor.fetchall() 67 | if rows: 68 | if len(rows) == 1: 69 | return rows[0][0] 70 | 71 | for row in rows: 72 | if row[1] == district_name: 73 | return row[0] 74 | -------------------------------------------------------------------------------- /covidbot/covid_data/updater/utils.py: -------------------------------------------------------------------------------- 1 | from typing import Optional 2 | 3 | 4 | def clean_district_name(county_name: str) -> Optional[str]: 5 | if county_name is not None and county_name.count(" ") > 0: 6 | return " ".join(county_name.split(" ")[1:]) 7 | return county_name 8 | -------------------------------------------------------------------------------- /covidbot/covid_data/updater/vaccination.py: -------------------------------------------------------------------------------- 1 | import io 2 | import logging 3 | from datetime import datetime, timedelta 4 | from typing import Optional 5 | 6 | import numpy 7 | import pandas as pd 8 | 9 | from covidbot.covid_data.updater.districts import RKIDistrictsUpdater 10 | from covidbot.covid_data.updater.updater import Updater 11 | from covidbot.utils import date_range 12 | 13 | 14 | class VaccinationGermanyUpdater(Updater): 15 | log = logging.getLogger(__name__) 16 | URL = "https://raw.githubusercontent.com/robert-koch-institut/COVID-19-Impfungen_in_Deutschland/master/Aktuell_Deutschland_Bundeslaender_COVID-19-Impfungen.csv" 17 | 18 | def get_last_update(self) -> Optional[datetime]: 19 | with self.connection.cursor() as cursor: 20 | germany_id = self.get_district_id("Deutschland") 21 | cursor.execute("SELECT MAX(last_update) FROM covid_vaccinations WHERE district_id=%s", [germany_id]) 22 | row = cursor.fetchone() 23 | if row: 24 | return row[0] 25 | 26 | def update(self) -> bool: 27 | last_update = self.get_last_update() 28 | district_id = self.get_district_id("Deutschland") 29 | new_data = False 30 | 31 | if district_id is None: 32 | raise ValueError("No district_id for Deutschland") 33 | 34 | if last_update and datetime.now() - last_update < timedelta(hours=12): 35 | return False 36 | 37 | # Make sure population exists 38 | RKIDistrictsUpdater(self.connection).update() 39 | 40 | response = self.get_resource(self.URL) 41 | if response: 42 | self.log.debug("Got Vaccination Data from RKI") 43 | data = pd.read_csv(io.StringIO(response), parse_dates=["Impfdatum"]) 44 | 45 | population = {d_id: None for d_id in range(1, 17)} 46 | with self.connection.cursor() as cursor: 47 | max_date = data['Impfdatum'].max().date() 48 | cursor.execute("SELECT MAX(date) FROM covid_vaccinations") 49 | row = cursor.fetchone() 50 | if row[0] is None: 51 | min_date = data['Impfdatum'].min().date() 52 | else: 53 | min_date = row[0] 54 | 55 | cursor.execute("SELECT population FROM counties WHERE rs=0") 56 | fed_population = cursor.fetchone()[0] 57 | 58 | for date in date_range(start_date=min_date + timedelta(days=1), end_date=max_date + timedelta(days=1)): 59 | current_data = data.query("Impfdatum <= @date").groupby(['BundeslandId_Impfort', 'Impfserie', 'Impfstoff']).sum() 60 | if current_data.empty: 61 | continue 62 | 63 | new_data = True 64 | self.log.info(f"Got new vaccination data for {date}") 65 | for district_id in range(1, 17): 66 | doses_diff = int(data.query("Impfdatum == @date and BundeslandId_Impfort == @district_id")[['Anzahl']].sum()) 67 | 68 | district_data = current_data.query(f"BundeslandId_Impfort == @district_id") 69 | 70 | if not population[district_id]: 71 | cursor.execute('SELECT population FROM counties WHERE rs=%s', [district_id]) 72 | population[district_id] = cursor.fetchone()[0] 73 | if not population[district_id]: 74 | self.log.warning(f"Can't fetch population for {district_id}") 75 | continue 76 | 77 | district_partial = int(district_data.query("Impfserie == 1").sum()) 78 | district_full = int(district_data.query("Impfserie == 2").sum() + district_data.query("Impfserie == 1 and Impfstoff == 'Janssen'").sum()) 79 | district_booster = int(district_data.query("Impfserie == 3").sum()) 80 | 81 | rate_partial = district_partial / population[district_id] 82 | rate_full = district_full / population[district_id] 83 | rate_booster = district_booster / population[district_id] 84 | 85 | cursor.execute('INSERT INTO covid_vaccinations (district_id, date, vaccinated_partial, ' 86 | 'vaccinated_full, vaccinated_booster, rate_partial, rate_full, rate_booster, ' 87 | 'doses_diff) VALUE (%s, %s, %s, %s, %s, %s, %s, %s, %s)', 88 | [district_id, date, 89 | district_partial, district_full, district_booster, 90 | rate_partial, rate_full, rate_booster, 91 | int(doses_diff)]) 92 | 93 | # Federal data 94 | fed_data = current_data.groupby(['Impfserie']).sum() 95 | 96 | fed_partial, fed_full, fed_booster = 0, 0, 0 97 | if 1 in fed_data.index: 98 | fed_partial = int(fed_data.at[1, 'Anzahl']) 99 | if 2 in fed_data.index: 100 | # Janssen is counted as partial and full vaccination, but as a single dose 101 | fed_full = int(fed_data.at[2, 'Anzahl']) + \ 102 | int(data.query("Impfdatum <= @date and Impfstoff == 'Janssen' and Impfserie == 1")[['Anzahl']].sum()) 103 | if 3 in fed_data.index: 104 | fed_booster = int(fed_data.at[3, 'Anzahl']) 105 | 106 | fed_doses = int(data.query("Impfdatum == @date")[['Anzahl']].sum()) 107 | 108 | cursor.execute('INSERT INTO covid_vaccinations (district_id, date, vaccinated_partial, ' 109 | 'vaccinated_full, vaccinated_booster, rate_partial, rate_full, rate_booster, ' 110 | 'doses_diff) VALUE (%s, %s, %s, %s, %s, %s, %s, %s, %s)', 111 | [0, date, 112 | fed_partial, 113 | fed_full, 114 | fed_booster, 115 | fed_partial / fed_population, fed_full / fed_population, fed_booster / fed_population, 116 | fed_doses]) 117 | 118 | self.connection.commit() 119 | return new_data 120 | -------------------------------------------------------------------------------- /covidbot/feedback_notifier.py: -------------------------------------------------------------------------------- 1 | import logging 2 | import time 3 | from typing import List, Union 4 | 5 | from telegram import ParseMode 6 | from telegram.ext import Updater 7 | 8 | from covidbot.interfaces.messenger_interface import MessengerInterface 9 | from covidbot.user_manager import UserManager 10 | 11 | 12 | class FeedbackNotifier(MessengerInterface): 13 | log = logging.getLogger(__name__) 14 | 15 | def __init__(self, api_key: str, dev_chat_id: int, user_manager: UserManager): 16 | self.dev_chat_id = dev_chat_id 17 | self.user_manager = user_manager 18 | self.updater = Updater(api_key) 19 | 20 | async def send_unconfirmed_reports(self) -> None: 21 | # This method is not used for daily reports, but to forward feedback to the developers 22 | i = 0 23 | for message in self.user_manager.get_feedback_notifications(): 24 | if i == 20: 25 | time.sleep(1) 26 | i += 1 27 | self.updater.bot.send_message(chat_id=self.dev_chat_id, text=message, parse_mode=ParseMode.HTML, 28 | timeout=10) 29 | 30 | async def send_message_to_users(self, message: str, users: List[Union[str, int]]): 31 | raise NotImplementedError("This is just an interface to forward feedback from users to developers") 32 | 33 | def run(self) -> None: 34 | pass 35 | -------------------------------------------------------------------------------- /covidbot/interfaces/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/eknoes/covidbot/565450e6ab558d5e91b4fcec1d161628d56fe865/covidbot/interfaces/__init__.py -------------------------------------------------------------------------------- /covidbot/interfaces/bot_response.py: -------------------------------------------------------------------------------- 1 | from dataclasses import dataclass 2 | from typing import Optional, List 3 | 4 | 5 | @dataclass 6 | class UserChoice: 7 | label: str 8 | callback_data: str 9 | alt_text: Optional[str] = None 10 | alt_help: Optional[str] = None 11 | 12 | 13 | @dataclass 14 | class BotResponse: 15 | message: str 16 | images: Optional[List[str]] = None 17 | choices: List[UserChoice] = None 18 | 19 | def __str__(self): 20 | if not self.choices: 21 | return self.message 22 | 23 | message = self.message + '\n\n' 24 | message += "🙋 Mögliche Aktionen:\n" 25 | for choice in self.choices: 26 | if choice.alt_text: 27 | message += f'• {choice.alt_text}\n' 28 | 29 | if self.choices[0].alt_help: 30 | message += f'\n{self.choices[0].alt_help}' 31 | else: 32 | message += '\nDu kannst auch einen Ort oder einen anderen Befehl senden um fortzufahren' 33 | 34 | return message 35 | -------------------------------------------------------------------------------- /covidbot/interfaces/facebook_interface.py: -------------------------------------------------------------------------------- 1 | import logging 2 | import os 3 | import shutil 4 | import urllib.parse 5 | from typing import List, Optional, Iterable 6 | 7 | import requests 8 | 9 | from covidbot.covid_data import CovidData, Visualization 10 | from covidbot.interfaces.single_command_interface import SingleCommandInterface, SingleArgumentRequest 11 | from covidbot.user_manager import UserManager 12 | from covidbot.interfaces.bot_response import BotResponse 13 | 14 | 15 | class FacebookInterface(SingleCommandInterface): 16 | log = logging.getLogger(__name__) 17 | 18 | access_token: str 19 | page_id: str 20 | web_dir: str 21 | url: str 22 | 23 | def __init__(self, page_id: str, access_token: str, web_dir: str, url: str, user_manager: UserManager, 24 | covid_data: CovidData, 25 | visualization: Visualization, no_write: bool = False): 26 | super().__init__(user_manager, covid_data, visualization, 0, no_write) 27 | self.page_id = page_id 28 | self.access_token = access_token 29 | self.web_dir = web_dir 30 | self.url = url 31 | self.save_follower_number() 32 | 33 | def save_follower_number(self): 34 | response = requests.request("GET", 35 | f"https://graph.facebook.com/{self.page_id}?fields=followers_count&access_token={self.access_token}") 36 | if response.status_code == 200: 37 | number = response.json()['followers_count'] 38 | self.user_manager.set_platform_user_number(number) 39 | else: 40 | self.log.error(f"Facebook API returned {response.status_code}: {response.text}") 41 | 42 | def write_message(self, messages: List[BotResponse], reply_obj: Optional[object] = None) -> bool: 43 | message_text = "" 44 | media_file = None 45 | for response in messages: 46 | message_text += response.message + '\n\n' 47 | if not media_file and response.images: 48 | media_file = response.images[0] 49 | 50 | message_text += "\n\nUnser Covidbot versorgt Dich einmal am Tag mit den aktuellen Infektions-, Todes- und " \ 51 | "Impfzahlen der von Dir ausgewählten Orte, schreib uns einfach eine Nachricht im Facebook " \ 52 | "Messenger oder auf anderen Plattformen: https://covidbot.d-64.org" 53 | 54 | message = urllib.parse.quote_plus(message_text) 55 | 56 | if media_file: 57 | try: 58 | file_loc = shutil.copy2(media_file, self.web_dir) 59 | except shutil.SameFileError: 60 | file_loc = media_file 61 | 62 | url = self.url + os.path.basename(file_loc) 63 | response = requests.request("POST", f"https://graph.facebook.com/{self.page_id}/photos?" 64 | f"caption={message}&url={url}&access_token={self.access_token}") 65 | else: 66 | response = requests.request("POST", f"https://graph.facebook.com/{self.page_id}/feed?" 67 | f"message={message}&access_token={self.access_token}") 68 | if response.status_code != 200: 69 | self.log.error(f"Facebook API returned {response.status_code}: {response.text}") 70 | raise ValueError(response.json()) 71 | 72 | self.log.debug(response) 73 | post_id = response.json()['id'] 74 | if not post_id: 75 | self.log.error("Facebook API did not return an id") 76 | return False 77 | return True 78 | 79 | def get_mentions(self) -> Iterable[SingleArgumentRequest]: 80 | raise NotImplementedError(f"{__name__} does not support individual queries") 81 | -------------------------------------------------------------------------------- /covidbot/interfaces/fbmessenger_interface.py: -------------------------------------------------------------------------------- 1 | import asyncio 2 | import logging 3 | import os 4 | import signal 5 | import traceback 6 | from typing import List, Union 7 | 8 | import prometheus_async 9 | from fbmessenger import Messenger 10 | from fbmessenger.errors import MessengerError 11 | from fbmessenger.models import Message, PostbackButton 12 | 13 | from covidbot.interfaces.messenger_interface import MessengerInterface 14 | from covidbot.metrics import RECV_MESSAGE_COUNT, SENT_MESSAGE_COUNT, BOT_RESPONSE_TIME 15 | from covidbot.bot import Bot 16 | from covidbot.settings import BotUserSettings 17 | from covidbot.user_hint_service import UserHintService 18 | from covidbot.utils import adapt_text, split_message 19 | from covidbot.interfaces.bot_response import BotResponse 20 | 21 | 22 | class FBMessengerInterface(MessengerInterface): 23 | bot: Bot 24 | fb_messenger: Messenger 25 | port: int 26 | log = logging.getLogger(__name__) 27 | 28 | def __init__(self, bot: Bot, access_token: str, verify_token: str, port: int, web_dir: str, 29 | public_url: str): 30 | self.bot = bot 31 | self.fb_messenger = Messenger(access_token, verify_token, self.handle_messenger_msg, web_dir, public_url) 32 | self.port = port 33 | 34 | def run(self): 35 | logging.info("Run Facebook Messenger Interface") 36 | # Set Get Started and Greeting Text 37 | asyncio.ensure_future( 38 | self.fb_messenger.set_greeting_text('Hallo {{user_first_name}}, zu Deinen Diensten: Ich versorge Dich ' 39 | 'mit den aktuellen Infektions-, Todes- und Impfzahlen ' 40 | 'der von Dir ausgewählten Orte aus offiziellen Quellen.')) 41 | asyncio.ensure_future(self.fb_messenger.set_get_started_payload('/start')) 42 | self.fb_messenger.start_receiving(port=self.port) 43 | 44 | @prometheus_async.aio.time(BOT_RESPONSE_TIME) 45 | async def handle_messenger_msg(self, message: Message): 46 | RECV_MESSAGE_COUNT.inc() 47 | try: 48 | user_input = message.text 49 | if message.payload: 50 | user_input = message.payload 51 | responses = self.bot.handle_input(user_input, message.sender_id) 52 | for response in responses: 53 | await self.send_bot_response(message.sender_id, response) 54 | except Exception as e: 55 | self.log.exception("An error happened while handling a FB Messenger message", exc_info=e) 56 | self.log.exception(f"Message from {message.sender_id}: {message.text}") 57 | self.log.exception("Exiting!") 58 | await self.fb_messenger.send_reply(message, adapt_text(self.bot.get_error_message().message)) 59 | 60 | try: 61 | tb_list = traceback.format_exception(None, e, e.__traceback__) 62 | tb_string = ''.join(tb_list) 63 | 64 | await self.sendMessageToDev(f"An exception occurred: {tb_string}\n" 65 | f"Message from {message.sender_id}: {message.text}") 66 | except Exception: 67 | self.log.error(f"Could not send message to developers") 68 | 69 | # Just exit on exception 70 | os.kill(os.getpid(), signal.SIGINT) 71 | 72 | async def send_bot_response(self, user: str, response: BotResponse): 73 | if response.message: 74 | images = response.images 75 | disable_unicode = not self.bot.get_user_setting(user, BotUserSettings.FORMATTING) 76 | max_chars = 2000 77 | if response.choices: 78 | max_chars = 640 79 | messages = split_message(adapt_text(str(response), just_strip=disable_unicode), max_chars=max_chars) 80 | for i in range(0, len(messages)): 81 | buttons = None 82 | if response.choices and i == len(messages) - 1: 83 | buttons = [] 84 | if len(response.choices) > 3: 85 | response.choices = response.choices[:3] 86 | for choice in response.choices: 87 | buttons.append(PostbackButton(choice.label, choice.callback_data)) 88 | await self.fb_messenger.send_message(user, messages[i], images=images, buttons=buttons) 89 | images = None 90 | SENT_MESSAGE_COUNT.inc() 91 | 92 | async def send_unconfirmed_reports(self) -> None: 93 | unconfirmed_reports = self.bot.get_available_user_messages() 94 | 95 | for report, userid, message in unconfirmed_reports: 96 | try: 97 | for elem in message: 98 | await self.send_bot_response(userid, elem) 99 | self.bot.confirm_message_send(report, userid) 100 | self.log.warning(f"Sent report to {userid}") 101 | except MessengerError as e: 102 | self.log.exception(f"Can't send report: {e.code} {e.subcode} {e.message}", exc_info=e) 103 | self.bot.disable_user(userid) 104 | 105 | async def send_message_to_users(self, message: str, users: List[Union[str, int]]): 106 | if not users: 107 | users = map(lambda x: x.platform_id, self.bot.get_all_users()) 108 | 109 | message = UserHintService.format_commands(message, self.bot.command_formatter) 110 | 111 | for user in users: 112 | disable_unicode = not self.bot.get_user_setting(user, BotUserSettings.FORMATTING) 113 | await self.fb_messenger.send_message(user, adapt_text(message, just_strip=disable_unicode)) 114 | self.log.warning(f"Sent message to {user}") 115 | 116 | async def sendMessageToDev(self, message: str): 117 | self.log.error(f"Not yet implemented, send following to dev: {message}") 118 | -------------------------------------------------------------------------------- /covidbot/interfaces/instagram_interface.py: -------------------------------------------------------------------------------- 1 | import logging 2 | import os 3 | import shutil 4 | import urllib.parse 5 | from typing import List, Optional, Iterable 6 | 7 | import requests 8 | 9 | from covidbot.covid_data import CovidData, Visualization 10 | from covidbot.interfaces.single_command_interface import SingleCommandInterface, SingleArgumentRequest 11 | from covidbot.user_manager import UserManager 12 | from covidbot.interfaces.bot_response import BotResponse 13 | 14 | 15 | class InstagramInterface(SingleCommandInterface): 16 | log = logging.getLogger(__name__) 17 | 18 | access_token: str 19 | account_id: str 20 | web_dir: str 21 | url: str 22 | 23 | def __init__(self, account_id: str, access_token: str, web_dir: str, url: str, user_manager: UserManager, 24 | covid_data: CovidData, 25 | visualization: Visualization, no_write: bool = False): 26 | super().__init__(user_manager, covid_data, visualization, 0, no_write) 27 | self.account_id = account_id 28 | self.access_token = access_token 29 | self.web_dir = web_dir 30 | self.url = url 31 | self.save_follower_number() 32 | 33 | def save_follower_number(self): 34 | response = requests.request("GET", f"https://graph.facebook.com/{self.account_id}?fields=followers_count&" 35 | f"access_token={self.access_token}") 36 | if response.status_code == 200: 37 | number = response.json()['followers_count'] 38 | self.user_manager.set_platform_user_number(number) 39 | else: 40 | self.log.error(f"Instagram API returned {response.status_code}: {response.text}") 41 | self.log.error(response.content) 42 | 43 | def write_message(self, messages: List[BotResponse], reply_obj: Optional[object] = None) -> bool: 44 | message_text = "" 45 | media_file = None 46 | for response in messages: 47 | message_text += response.message + '\n\n' 48 | if not media_file and response.images: 49 | media_file = response.images[0] 50 | 51 | if not media_file: 52 | self.log.warning("Instagram Interface can just post a single media file with caption, skipping") 53 | return True 54 | 55 | try: 56 | file_loc = shutil.copy2(media_file, self.web_dir) 57 | except shutil.SameFileError: 58 | file_loc = media_file 59 | 60 | url = self.url + os.path.basename(file_loc) 61 | message_text += "\n\nUnser Covidbot versorgt Dich einmal am Tag mit den aktuellen Infektions-, Todes- und " \ 62 | "Impfzahlen der von Dir ausgewählten Orte. Abonniere ihn einfach auf Telegram, Threema oder " \ 63 | "Signal. Den Link dazu findest du in unserer Bio!" 64 | 65 | if len(message_text) > 2200: 66 | raise ValueError(f"Caption too long: {len(message_text)} characters") 67 | message_text = urllib.parse.quote_plus(message_text) 68 | media_response = requests.request("POST", f"https://graph.facebook.com/{self.account_id}/media?" 69 | f"caption={message_text}&image_url={url}&access_token={self.access_token}") 70 | self.log.debug(media_response) 71 | if media_response.status_code != 200: 72 | self.log.error(f"Instagram API returned {media_response.status_code}: {media_response.text}") 73 | return False 74 | 75 | image_id = media_response.json()['id'] 76 | if not image_id: 77 | self.log.error("Instagram API did not return an image id") 78 | return False 79 | 80 | post_response = requests.request("POST", f"https://graph.facebook.com/{self.account_id}/media_publish?" 81 | f"creation_id={image_id}&access_token={self.access_token}") 82 | self.log.debug(post_response) 83 | if post_response.status_code != 200: 84 | self.log.error(f"Instagram API returned {post_response.status_code}: {post_response.text}") 85 | return False 86 | 87 | return True 88 | 89 | def get_mentions(self) -> Iterable[SingleArgumentRequest]: 90 | raise NotImplementedError("InstagramInterface does not support individual queries") 91 | -------------------------------------------------------------------------------- /covidbot/interfaces/mastodon_interface.py: -------------------------------------------------------------------------------- 1 | import logging 2 | from typing import List, Optional, Iterable 3 | 4 | from mastodon import Mastodon, MastodonAPIError 5 | 6 | from covidbot.covid_data import CovidData, Visualization 7 | from covidbot.metrics import API_RATE_LIMIT, API_RESPONSE_TIME, SENT_MESSAGE_COUNT 8 | from covidbot.interfaces.single_command_interface import SingleCommandInterface, SingleArgumentRequest 9 | from covidbot.user_manager import UserManager 10 | from covidbot.utils import general_tag_pattern 11 | from covidbot.interfaces.bot_response import BotResponse 12 | 13 | 14 | class MastodonInterface(SingleCommandInterface): 15 | log = logging.getLogger(__name__) 16 | user_manager: UserManager 17 | data: CovidData 18 | viz: Visualization 19 | mastodon: Mastodon 20 | 21 | INFECTIONS_UID = "infections" 22 | VACCINATIONS_UID = "vaccinations" 23 | ICU_UID = "icu" 24 | 25 | def __init__(self, access_token: str, mastodon_url: str, user_manager: UserManager, covid_data: CovidData, 26 | visualization: Visualization, no_write: bool = False): 27 | super().__init__(user_manager, covid_data, visualization, 5, no_write) 28 | self.mastodon = Mastodon(access_token=access_token, api_base_url=mastodon_url) 29 | self.update_follower_number() 30 | 31 | def update_follower_number(self): 32 | info = self.mastodon.me() 33 | number = info['followers_count'] 34 | self.user_manager.set_platform_user_number(number) 35 | 36 | def upload_media(self, filename: str) -> str: 37 | upload_resp = self.mastodon.media_post(filename, mime_type="image/jpeg") 38 | if not upload_resp: 39 | raise ValueError(f"Could not upload media to Mastodon. API response {upload_resp.status_code}: " 40 | f"{upload_resp.text}") 41 | 42 | return upload_resp['id'] 43 | 44 | def write_message(self, messages: List[BotResponse], reply_obj: Optional[object] = None) -> bool: 45 | for message in messages: 46 | media_ids = [] 47 | if message.images: 48 | for file in message.images: 49 | media_ids.append(self.upload_media(file)) 50 | try: 51 | with API_RESPONSE_TIME.labels(platform='mastodon').time(): 52 | if not reply_obj: 53 | response = self.mastodon.status_post(message.message, media_ids=media_ids, language="deu", 54 | visibility="unlisted") 55 | else: 56 | response = self.mastodon.status_reply(reply_obj, message.message, media_ids=media_ids, 57 | language="deu", ) 58 | self.update_metrics() 59 | if response: 60 | self.log.info(f"Toot sent successfully {len(message.message)} chars)") 61 | SENT_MESSAGE_COUNT.inc() 62 | reply_obj = response 63 | else: 64 | raise ValueError(f"Could not send toot!") 65 | except MastodonAPIError as api_error: 66 | self.log.error(f"Got error on API access: {api_error}", exc_info=api_error) 67 | raise api_error 68 | return True 69 | 70 | def update_metrics(self): 71 | if self.mastodon.ratelimit_limit: 72 | API_RATE_LIMIT.labels(platform='mastodon', type='limit').set(self.mastodon.ratelimit_limit) 73 | 74 | if self.mastodon.ratelimit_remaining: 75 | API_RATE_LIMIT.labels(platform='mastodon', type='remaining').set(self.mastodon.ratelimit_remaining) 76 | 77 | def get_mentions(self) -> Iterable[SingleArgumentRequest]: 78 | with API_RESPONSE_TIME.labels(platform='mastodon').time(): 79 | notifications = self.mastodon.notifications( 80 | exclude_types=['follow', 'favourite', 'reblog' 'poll', 'follow_request']) 81 | self.update_metrics() 82 | mentions = [] 83 | bot_name = "@D64_Covidbot" 84 | for n in notifications: 85 | if n['type'] != "mention": 86 | continue 87 | text = general_tag_pattern.sub("", n['status']['content']) 88 | mention_pos = text.lower().find(bot_name.lower()) 89 | text = text[mention_pos + len(bot_name):] 90 | if text: 91 | created = n['status']['created_at'] 92 | mentions.append(SingleArgumentRequest(n['status']['id'], text, n['status'], created)) 93 | return mentions 94 | -------------------------------------------------------------------------------- /covidbot/interfaces/messenger_interface.py: -------------------------------------------------------------------------------- 1 | import logging 2 | from abc import ABC, abstractmethod 3 | from typing import Union, List 4 | 5 | 6 | class MessengerInterface(ABC): 7 | """Interface to implement the bot on a certain platform""" 8 | 9 | @abstractmethod 10 | async def send_unconfirmed_reports(self) -> None: 11 | """Checks :py:meth:`covidbot.Bot.get_unconfirmed_daily_reports` for new reports and sends them to the users of 12 | the implemented platform. 13 | """ 14 | pass 15 | 16 | @abstractmethod 17 | async def send_message_to_users(self, message: str, users: List[Union[str, int]]): 18 | """Sends a message to a set of users 19 | 20 | Args: 21 | message: Message to sent, may contain HTML 22 | users: List of platform_id, if empty send to all users 23 | """ 24 | pass 25 | 26 | @abstractmethod 27 | def run(self): 28 | """Runs the Bot on the implemented platform 29 | 30 | """ 31 | pass 32 | -------------------------------------------------------------------------------- /covidbot/interfaces/threema_interface.py: -------------------------------------------------------------------------------- 1 | import logging 2 | import os 3 | import signal 4 | import traceback 5 | from typing import List, Union 6 | 7 | import prometheus_async 8 | import threema.gateway as threema 9 | from aiohttp import web 10 | from threema.gateway.e2e import create_application, add_callback_route, TextMessage, Message, ImageMessage, \ 11 | DeliveryReceipt 12 | 13 | from covidbot.interfaces.messenger_interface import MessengerInterface 14 | from covidbot.metrics import RECV_MESSAGE_COUNT, SENT_MESSAGE_COUNT, SENT_IMAGES_COUNT, BOT_RESPONSE_TIME 15 | from covidbot.bot import Bot 16 | from covidbot.user_hint_service import UserHintService 17 | from covidbot.utils import adapt_text, split_message 18 | from covidbot.interfaces.bot_response import BotResponse 19 | 20 | 21 | class ThreemaInterface(MessengerInterface): 22 | threema_id: str 23 | secret: str 24 | private_key: str 25 | bot: Bot 26 | connection: threema.Connection 27 | dev_chat: str 28 | log = logging.getLogger(__name__) 29 | 30 | def __init__(self, threema_id: str, threema_secret: str, threema_key: str, callback_path: str, bot: Bot, dev_chat: str): 31 | self.bot = bot 32 | self.threema_id = threema_id 33 | self.threema_secret = threema_secret 34 | self.threema_key = threema_key 35 | self.connection = threema.Connection( 36 | identity=self.threema_id, 37 | secret=self.threema_secret, 38 | key=self.threema_key 39 | ) 40 | self.dev_chat = dev_chat 41 | self.callback_path = callback_path 42 | 43 | def run(self): 44 | logging.info("Run Threema Interface") 45 | # Create the application and register the handler for incoming messages 46 | application = create_application(self.connection) 47 | add_callback_route(self.connection, application, self.handle_threema_msg, path=self.callback_path) 48 | web.run_app(application, port=9000, access_log=logging.getLogger('threema_api')) 49 | 50 | @prometheus_async.aio.time(BOT_RESPONSE_TIME) 51 | async def handle_threema_msg(self, message: Message): 52 | if type(message) == TextMessage: 53 | RECV_MESSAGE_COUNT.inc() 54 | message: TextMessage 55 | try: 56 | responses = self.bot.handle_input(message.text, message.from_id) 57 | for response in responses: 58 | await self.send_bot_response(message.from_id, response) 59 | except Exception as e: 60 | self.log.exception("An error happened while handling a Threema message", exc_info=e) 61 | self.log.exception(f"Message from {message.from_id}: {message.text}") 62 | self.log.exception("Exiting!") 63 | 64 | try: 65 | response_msg = TextMessage(self.connection, 66 | text=adapt_text(self.bot.get_error_message().message, True), 67 | to_id=message.from_id) 68 | await response_msg.send() 69 | except Exception: 70 | self.log.error(f"Could not send message to {message.from_id}") 71 | 72 | try: 73 | tb_list = traceback.format_exception(None, e, e.__traceback__) 74 | tb_string = ''.join(tb_list) 75 | 76 | await self.sendMessageToDev(f"An exception occurred: {tb_string}\n" 77 | f"Message from {message.from_id}: {message.text}") 78 | except Exception: 79 | self.log.error(f"Could not send message to developers") 80 | 81 | # Just exit on exception 82 | os.kill(os.getpid(), signal.SIGINT) 83 | elif type(message) == DeliveryReceipt: 84 | pass 85 | else: 86 | self.log.warning(f"Received unknown message type {type(message)}: {message}") 87 | 88 | async def send_bot_response(self, user: str, response: BotResponse): 89 | if response.images: 90 | for image in response.images: 91 | response_img = ImageMessage(self.connection, image_path=image, to_id=user) 92 | await response_img.send() 93 | SENT_IMAGES_COUNT.inc() 94 | 95 | if response.message: 96 | message_parts = split_message(adapt_text(str(response), threema_format=True), max_bytes=3500) 97 | for m in message_parts: 98 | response_msg = TextMessage(self.connection, text=m, to_id=user) 99 | await response_msg.send() 100 | SENT_MESSAGE_COUNT.inc() 101 | 102 | async def send_unconfirmed_reports(self) -> None: 103 | if not self.bot.user_messages_available(): 104 | await self.connection.close() 105 | return 106 | 107 | for report_type, userid, message in self.bot.get_available_user_messages(): 108 | try: 109 | for elem in message: 110 | await self.send_bot_response(userid, elem) 111 | self.log.warning(f"Sent report to {userid}") 112 | self.bot.confirm_message_send(report_type, userid) 113 | except threema.KeyServerError as error: 114 | self.log.error(f"Got KeyServer Error {error.status}: {error.status_description[error.status]} ", 115 | exc_info=error) 116 | if error.status == 404: 117 | self.bot.delete_user(userid) 118 | await self.connection.close() 119 | 120 | async def send_message_to_users(self, message: str, users: List[Union[str, int]]): 121 | if not users: 122 | users = map(lambda x: x.platform_id, self.bot.get_all_users()) 123 | 124 | message = UserHintService.format_commands(message, self.bot.command_formatter) 125 | 126 | for user in users: 127 | await TextMessage(self.connection, text=adapt_text(message, True), to_id=user).send() 128 | self.log.warning(f"Sent message to {user}") 129 | 130 | async def sendMessageToDev(self, message: str): 131 | await TextMessage(self.connection, text=adapt_text(message, True), to_id=self.dev_chat).send() 132 | -------------------------------------------------------------------------------- /covidbot/interfaces/twitter_interface.py: -------------------------------------------------------------------------------- 1 | import logging 2 | import re 3 | import time 4 | from datetime import datetime, timezone 5 | from typing import List, Optional, Iterable 6 | 7 | from TwitterAPI import TwitterAPI, TwitterResponse, TwitterConnectionError 8 | 9 | from covidbot.covid_data import CovidData, Visualization 10 | from covidbot.location_service import LocationService 11 | from covidbot.metrics import SENT_MESSAGE_COUNT, API_RATE_LIMIT, API_RESPONSE_TIME, \ 12 | API_RESPONSE_CODE, API_ERROR 13 | from covidbot.interfaces.single_command_interface import SingleCommandInterface, SingleArgumentRequest 14 | from covidbot.user_manager import UserManager 15 | from covidbot.utils import replace_by_list 16 | from covidbot.interfaces.bot_response import BotResponse 17 | 18 | 19 | class TwitterInterface(SingleCommandInterface): 20 | log = logging.getLogger(__name__) 21 | user_manager: UserManager 22 | data: CovidData 23 | viz: Visualization 24 | twitter: TwitterAPI 25 | handle_regex = re.compile('@(\w){1,15}') 26 | location_service: LocationService 27 | 28 | INFECTIONS_UID = "infections" 29 | VACCINATIONS_UID = "vaccinations" 30 | ICU_UID = "icu" 31 | 32 | def __init__(self, consumer_key: str, consumer_secret: str, access_token_key: str, access_token_secret: str, 33 | user_manager: UserManager, covid_data: CovidData, visualization: Visualization, 34 | no_write: bool = False): 35 | super().__init__(user_manager, covid_data, visualization, 15, no_write) 36 | self.twitter = TwitterAPI(consumer_key, consumer_secret, access_token_key, access_token_secret, 37 | api_version='1.1') 38 | self.twitter.CONNECTION_TIMEOUT = 120 39 | self.twitter.REST_TIMEOUT = 120 40 | self.twitter.STREAMING_TIMEOUT = 120 41 | self.rki_name = "@rki_de" 42 | self.bmg_name = "@BMG_Bund" 43 | self.update_follower_number() 44 | 45 | def update_follower_number(self): 46 | response = self.twitter.request('users/show', {'user_id': 1367862514579542017}) 47 | if response.status_code == 200: 48 | number = response.json()['followers_count'] 49 | self.user_manager.set_platform_user_number(number) 50 | 51 | def write_message(self, messages: List[BotResponse], reply_obj: Optional[object] = None) -> bool: 52 | if reply_obj and type(reply_obj) != int: 53 | raise ValueError("Twitter client needs reply_obj to be int") 54 | 55 | for message in messages: 56 | data = {'status': message.message} 57 | if message.images: 58 | # Upload filenames 59 | media_ids = [] 60 | for file in message.images: 61 | with open(file, "rb") as f: 62 | upload_resp = self.twitter.request('media/upload', None, {'media': f.read()}) 63 | if upload_resp.status_code != 200: 64 | if upload_resp.status_code == 429: # Rate Limit exceed 65 | reset_time = int(upload_resp.headers.get("x-rate-limit-reset", 0)) 66 | if reset_time: 67 | sleep_time = (datetime.fromtimestamp(reset_time, timezone.utc) - datetime.now( 68 | tz=timezone.utc)).seconds 69 | self.log.warning(f"Rate Limit exceed: Wait for reset in {sleep_time}s") 70 | time.sleep(sleep_time) 71 | return False 72 | raise ValueError( 73 | f"Could not upload graph to twitter. API response {upload_resp.status_code}: " 74 | f"{upload_resp.text}") 75 | 76 | media_ids.append(upload_resp.json()['media_id']) 77 | 78 | data['media_ids'] = ",".join(map(str, media_ids)) 79 | 80 | if reply_obj: 81 | data['in_reply_to_status_id'] = reply_obj 82 | data['auto_populate_reply_metadata'] = True 83 | 84 | with API_RESPONSE_TIME.labels(platform='twitter').time(): 85 | response = self.twitter.request('statuses/update', data) 86 | 87 | if 200 <= response.status_code < 300: 88 | self.log.info(f"Tweet sent successfully {len(data['status'])} chars), response: {response.status_code}") 89 | SENT_MESSAGE_COUNT.inc() 90 | self.update_twitter_metrics(response) 91 | reply_obj = response.json()['id'] 92 | else: 93 | if upload_resp.status_code == 429: # Rate Limit exceed 94 | reset_time = int(upload_resp.headers.get("x-rate-limit-reset", 0)) 95 | if reset_time: 96 | sleep_time = (datetime.fromtimestamp(reset_time, timezone.utc) - datetime.now(tz=timezone.utc)).seconds 97 | self.log.warning(f"Rate Limit exceed: Wait for reset in {sleep_time}s") 98 | time.sleep(sleep_time) 99 | return False 100 | raise ValueError(f"Could not send tweet: API Code {response.status_code}: {response.text}") 101 | return True 102 | 103 | @staticmethod 104 | def update_twitter_metrics(response: TwitterResponse): 105 | quota = response.get_quota() 106 | if 'limit' in quota and quota['limit']: 107 | API_RATE_LIMIT.labels(platform='twitter', type='limit').set(quota['limit']) 108 | 109 | if 'remaining' in quota and quota['remaining']: 110 | API_RATE_LIMIT.labels(platform='twitter', type='remaining').set(quota['remaining']) 111 | API_RESPONSE_CODE.labels(platform='twitter', code=response.status_code).inc() 112 | 113 | def get_mentions(self) -> Iterable[SingleArgumentRequest]: 114 | return [] # Workaround: We do not reply anymore to single tweets on twitter 115 | 116 | try: 117 | with API_RESPONSE_TIME.labels(platform='twitter').time(): 118 | response = self.twitter.request(f"statuses/mentions_timeline", params={'tweet_mode': 'extended', 119 | 'count': 200, 120 | 'trim_user': 1}) 121 | except TwitterConnectionError as e: 122 | self.log.warning(f"TwitterConnectionError while fetching mentions: {e}", exc_info=e) 123 | API_ERROR.inc() 124 | return [] 125 | self.update_twitter_metrics(response) 126 | mentions = [] 127 | if 200 <= response.status_code < 300: 128 | for tweet in response: 129 | if self.user_manager.is_message_answered(tweet['id']): 130 | continue 131 | 132 | mention_position = 0 133 | for mention in tweet['entities']['user_mentions']: 134 | if mention['id'] == 1367862514579542017: 135 | mention_position = mention['indices'][1] 136 | break 137 | 138 | arguments = self.handle_regex.sub("", tweet['full_text'][mention_position:]).strip() 139 | if arguments: 140 | # As our locale is different, we have to adapt Twitter Time & Date String 141 | day_en = ["Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun"] 142 | day_de = ["Mo", "Di", "Mi", "Do", "Fr", "Sa", "So"] 143 | month_en = ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"] 144 | month_de = ["Jan", "Feb", "Mär", "Apr", "Mai", "Jun", "Jul", "Aug", "Sep", "Okt", "Nov", "Dez"] 145 | 146 | localized_str = replace_by_list(tweet['created_at'], day_en + month_en, day_de + month_de) 147 | created = None 148 | try: 149 | created = datetime.strptime(localized_str, "%a %b %d %H:%M:%S %z %Y") 150 | except ValueError as e: 151 | self.log.warning(f"Cant parse twitters date string {localized_str}:", exc_info=e) 152 | 153 | mentions.append(SingleArgumentRequest(tweet['id'], arguments, tweet['id'], created)) 154 | return mentions 155 | -------------------------------------------------------------------------------- /covidbot/location_service.py: -------------------------------------------------------------------------------- 1 | import logging 2 | from typing import List, Optional 3 | 4 | import requests 5 | import ujson as json 6 | from shapely.geometry import shape, Point 7 | 8 | from covidbot.metrics import LOCATION_OSM_LOOKUP, LOCATION_GEO_LOOKUP 9 | 10 | 11 | class GeoLookup: 12 | json_data: Optional[dict] 13 | filename: str 14 | 15 | def __init__(self, filename: str): 16 | self.filename = filename 17 | 18 | def __enter__(self): 19 | with open(self.filename, "r") as file: 20 | self.json_data = json.load(file) 21 | return self 22 | 23 | def __exit__(self, exc_type, exc_val, exc_tb): 24 | del self.json_data 25 | 26 | def find_rs(self, lon: float, lat: float) -> Optional[int]: 27 | if not self.json_data: 28 | raise Exception("GeoLookup has to be used in with context") 29 | 30 | point = Point(lon, lat) 31 | 32 | # check each polygon to see if it contains the point 33 | for feature in self.json_data['features']: 34 | polygon = shape(feature['geometry']) 35 | if polygon.contains(point): 36 | return int(feature['properties']['RS']) 37 | 38 | 39 | class LocationService: 40 | geolookup: Optional[GeoLookup] 41 | 42 | def __init__(self, filename: str): 43 | self.geolookup = GeoLookup(filename) 44 | 45 | @LOCATION_GEO_LOOKUP.time() 46 | def find_rs(self, lon: float, lat: float) -> Optional[int]: 47 | with self.geolookup as lookup: 48 | return lookup.find_rs(lon, lat) 49 | 50 | @LOCATION_OSM_LOOKUP.time() 51 | def find_location(self, name: str, strict=False) -> List[int]: 52 | p = {'countrycodes': 'de', 'format': 'jsonv2'} 53 | if strict: 54 | p['city'] = name 55 | else: 56 | p['q'] = name 57 | 58 | request = requests.get("https://nominatim.openstreetmap.org/search.php", 59 | params=p, 60 | headers={'User-Agent': 'CovidBot (https://github.com/eknoes/covid-bot)'} 61 | ) 62 | if request.status_code < 200 or request.status_code > 299: 63 | logging.warning(f"Did not get a 2XX response from Nominatim for query {name} " 64 | f"but {request.status_code}: {request.reason}") 65 | return [] 66 | response = request.json() 67 | result = [] 68 | stricter_results = [] 69 | with self.geolookup as geolookup: 70 | for item in response: 71 | if strict and item['importance'] < 0.4: 72 | continue 73 | 74 | rs = geolookup.find_rs(float(item['lon']), float(item['lat'])) 75 | if rs and rs not in result: 76 | result.append(rs) 77 | 78 | if strict and item['display_name'].find(name) == 0: 79 | first_part = item['display_name'].split(",")[0] 80 | if first_part == name: 81 | return [rs] 82 | stricter_results.append(rs) 83 | 84 | if strict and stricter_results: 85 | return stricter_results 86 | return result 87 | -------------------------------------------------------------------------------- /covidbot/metrics.py: -------------------------------------------------------------------------------- 1 | import logging 2 | from sqlite3 import OperationalError 3 | 4 | from mysql.connector import MySQLConnection 5 | from prometheus_client.metrics import Counter, Gauge, Summary 6 | 7 | RECV_MESSAGE_COUNT = Counter('bot_recv_message_count', 'Received messages') 8 | SENT_MESSAGE_COUNT = Counter('bot_sent_message_count', 'Sent text messages') 9 | FAILED_MESSAGE_COUNT = Counter('bot_failed_message_count', 10 | 'Number of messages failed to send') 11 | SENT_IMAGES_COUNT = Counter('bot_sent_images_count', 'Sent images') 12 | BOT_COMMAND_COUNT = Counter('bot_command_total', 'Received Bot Commands', ['command']) 13 | 14 | BOT_RESPONSE_TIME = Summary('bot_response_time', 'Latency of requests') 15 | 16 | # SingleCommand 17 | DISCARDED_MESSAGE_COUNT = Counter('bot_discard_message_count', 18 | 'Received but discarded messages') 19 | SINGLE_COMMAND_RESPONSE_TIME = Summary('bot_response_time_single', 20 | 'Response time to single command input') 21 | 22 | # User statistics 23 | USER_COUNT = Gauge('bot_total_user', 'Number of Bot users', ['platform']) 24 | AVERAGE_SUBSCRIPTION_COUNT = Gauge('bot_avg_subscriptions', 25 | 'Average No. of subscriptions') 26 | 27 | # Visualization related 28 | CREATED_GRAPHS = Counter('bot_viz_created_graph_count', 'Number of created graphs', 29 | ['type']) 30 | CACHED_GRAPHS = Counter('bot_viz_cached_graph_count', 'Number of created graphs', 31 | ['type']) 32 | 33 | # Location Service 34 | LOCATION_OSM_LOOKUP = Summary('bot_location_osm_lookup', 'Duration of OSM Requests') 35 | LOCATION_GEO_LOOKUP = Summary('bot_location_geo_lookup', 36 | 'Time used for geolocation lookup') 37 | LOCATION_DB_LOOKUP = Summary('bot_location_db_lookup', 'Time used for database lookup') 38 | 39 | # Twitter Metrics 40 | API_RATE_LIMIT = Gauge('bot_api_rate_limit', 'Current Rate Limit', ['platform', 'type']) 41 | API_RESPONSE_CODE = Counter('bot_api_response_code', 'Twitter API response codes', 42 | ['platform', 'code']) 43 | API_RESPONSE_TIME = Summary('bot_api_response_time', 'Twitter API response time', 44 | ['platform']) 45 | API_ERROR = Counter('bot_api_connection_error', 'Twitter API connection error') 46 | 47 | # Error Metrics 48 | BOT_SEND_MESSAGE_ERRORS = Counter('bot_send_message_error', 49 | 'Number of errors while sending a message', 50 | ['platform', 'error']) 51 | 52 | 53 | class MonitorMetrics: 54 | connection: MySQLConnection 55 | 56 | def __init__(self, connection: MySQLConnection): 57 | self.connection = connection 58 | self.log = logging.getLogger(__name__) 59 | 60 | def get_social_network_user_number(self, name: str) -> int: 61 | try: 62 | with self.connection.cursor(dictionary=True) as cursor: 63 | cursor.execute( 64 | 'SELECT user FROM platform_statistics WHERE platform=%s LIMIT 1', 65 | [name]) 66 | rows = cursor.fetchall() 67 | if rows: 68 | return rows[0]['user'] 69 | return 0 70 | except OperationalError as e: 71 | self.log.exception(f"OperationalError: {e.msg}", exc_info=e) 72 | self.connection.reconnect() 73 | return self.get_social_network_user_number(name) 74 | 75 | def get_user_number(self, name: str) -> int: 76 | try: 77 | with self.connection.cursor(dictionary=True) as cursor: 78 | cursor.execute( 79 | "SELECT COUNT(user_id) as user_num FROM bot_user WHERE platform=%s AND activated=1", 80 | [name]) 81 | row = cursor.fetchone() 82 | if row and 'user_num' in row and row['user_num']: 83 | return row['user_num'] 84 | return 0 85 | except OperationalError as e: 86 | self.log.exception(f"OperationalError: {e.msg}", exc_info=e) 87 | self.connection.reconnect() 88 | return self.get_user_number(name) 89 | 90 | def get_average_subscriptions(self) -> float: 91 | try: 92 | with self.connection.cursor(dictionary=True) as cursor: 93 | cursor.execute( 94 | "SELECT COUNT(*)/COUNT(DISTINCT user_id) as mean FROM subscriptions ORDER BY user_id " 95 | "LIMIT 1") 96 | row = cursor.fetchone() 97 | 98 | if row['mean']: 99 | return row['mean'] 100 | return 1.0 101 | except OperationalError as e: 102 | self.log.exception(f"OperationalError: {e.msg}", exc_info=e) 103 | self.connection.reconnect() 104 | return self.get_average_subscriptions() 105 | -------------------------------------------------------------------------------- /covidbot/settings.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | from enum import Enum 3 | from typing import List 4 | 5 | 6 | class BotUserSettings(Enum): 7 | REPORT_GRAPHICS = "report_graphics" 8 | REPORT_INCLUDE_ICU = "report_include_icu" 9 | REPORT_INCLUDE_VACCINATION = "report_include_vaccination" 10 | REPORT_EXTENSIVE_GRAPHICS = "report_extensive_graphics" 11 | REPORT_ALL_INFECTION_GRAPHS = "report_all_infection_graphics" 12 | FORMATTING = "disable_fake_format" 13 | REPORT_SLEEP_MODE = "report_sleep_mode" 14 | REPORT_WEEKLY = "report_weekly" 15 | SUNDAY_REPORT = "disable_sunday" 16 | 17 | @staticmethod 18 | def default(setting: BotUserSettings) -> bool: 19 | if setting == BotUserSettings.REPORT_GRAPHICS: 20 | return True 21 | elif setting == BotUserSettings.REPORT_INCLUDE_ICU: 22 | return True 23 | elif setting == BotUserSettings.REPORT_INCLUDE_VACCINATION: 24 | return True 25 | elif setting == BotUserSettings.REPORT_EXTENSIVE_GRAPHICS: 26 | return False 27 | elif setting == BotUserSettings.FORMATTING: 28 | return True 29 | elif setting == BotUserSettings.REPORT_ALL_INFECTION_GRAPHS: 30 | return False 31 | elif setting == BotUserSettings.REPORT_SLEEP_MODE: 32 | return False 33 | elif setting == BotUserSettings.REPORT_WEEKLY: 34 | return False 35 | elif setting == BotUserSettings.SUNDAY_REPORT: 36 | return False 37 | 38 | @staticmethod 39 | def title(setting: BotUserSettings) -> str: 40 | if setting == BotUserSettings.REPORT_GRAPHICS: 41 | return "Grafiken im Bericht" 42 | elif setting == BotUserSettings.REPORT_INCLUDE_ICU: 43 | return "Intensivbetten im Bericht" 44 | elif setting == BotUserSettings.REPORT_INCLUDE_VACCINATION: 45 | return "Impfungen im Bericht" 46 | elif setting == BotUserSettings.REPORT_EXTENSIVE_GRAPHICS: 47 | return "Weitere Grafiken im Bericht" 48 | elif setting == BotUserSettings.FORMATTING: 49 | return "Formatierung" 50 | elif setting == BotUserSettings.REPORT_ALL_INFECTION_GRAPHS: 51 | return "Alle Infektionsgrafiken im Bericht" 52 | elif setting == BotUserSettings.REPORT_SLEEP_MODE: 53 | return "Bericht Pausieren" 54 | elif setting == BotUserSettings.REPORT_WEEKLY: 55 | return "Wöchentlicher Bericht" 56 | elif setting == BotUserSettings.SUNDAY_REPORT: 57 | return "Sonntags- & Montagsbericht" 58 | 59 | @staticmethod 60 | def description(setting: BotUserSettings) -> str: 61 | if setting == BotUserSettings.REPORT_GRAPHICS: 62 | return "(De)aktiviert die Grafiken im täglichen Bericht." 63 | elif setting == BotUserSettings.REPORT_INCLUDE_ICU: 64 | return "Diese Option zeigt im Bericht einen Überblick über die " \ 65 | "Intensivbettenkapazität in Deutschland." 66 | elif setting == BotUserSettings.REPORT_INCLUDE_VACCINATION: 67 | return "Diese Option zeigt im Bericht einen Überblick über die " \ 68 | "Impfungen in Deutschland." 69 | elif setting == BotUserSettings.REPORT_EXTENSIVE_GRAPHICS: 70 | return "Mit dieser Option werden im Bericht weitere Grafiken versendet." 71 | elif setting == BotUserSettings.FORMATTING: 72 | return "Signal und Facebook Messenger Nutzer:innen können mit dieser Option die Formatierung der " \ 73 | "Nachrichten (de)aktivieren. Diese ist auf manchen Geräten bei Signal und Facebook " \ 74 | "Messenger nicht lesbar." 75 | elif setting == BotUserSettings.REPORT_ALL_INFECTION_GRAPHS: 76 | return "Mit dieser Option bekommst du im Bericht eine Neuinfektionsgrafik für jeden " \ 77 | "abonnierten Ort." 78 | elif setting == BotUserSettings.REPORT_SLEEP_MODE: 79 | return "Pausiere den Bericht, solange die 7-Tage-Inzidenz in allen von dir abonnierten Orte unter 10 liegt." 80 | elif setting == BotUserSettings.REPORT_WEEKLY: 81 | return "Mit dieser Option bekommst du deinen persönlichen Bericht nur am Dienstag" 82 | elif setting == BotUserSettings.SUNDAY_REPORT: 83 | return "Da am Sonntag und Montag in der Regel keine Infektionszahlen gemeldet werden, kann der Infektionsbericht für diesen Tag ausgeschaltet werden." 84 | 85 | @staticmethod 86 | def command_key(setting: BotUserSettings) -> List[str]: 87 | if setting == BotUserSettings.REPORT_GRAPHICS: 88 | return ["grafik"] 89 | elif setting == BotUserSettings.REPORT_INCLUDE_ICU: 90 | return ["intensiv"] 91 | elif setting == BotUserSettings.REPORT_INCLUDE_VACCINATION: 92 | return ["impfung"] 93 | elif setting == BotUserSettings.REPORT_EXTENSIVE_GRAPHICS: 94 | return ["plus-grafik"] 95 | elif setting == BotUserSettings.FORMATTING: 96 | return ["formatierung"] 97 | elif setting == BotUserSettings.REPORT_ALL_INFECTION_GRAPHS: 98 | return ["neuinfektion-grafik"] 99 | elif setting == BotUserSettings.REPORT_SLEEP_MODE: 100 | return ["pause"] 101 | elif setting == BotUserSettings.REPORT_WEEKLY: 102 | return ["woechentlich", "wöchentlich"] 103 | elif setting == BotUserSettings.SUNDAY_REPORT: 104 | return ["sonntag"] 105 | -------------------------------------------------------------------------------- /covidbot/tests/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/eknoes/covidbot/565450e6ab558d5e91b4fcec1d161628d56fe865/covidbot/tests/__init__.py -------------------------------------------------------------------------------- /covidbot/tests/test_WorkingDayChecker.py: -------------------------------------------------------------------------------- 1 | import datetime 2 | from unittest import TestCase 3 | 4 | from covidbot.covid_data.WorkingDayChecker import WorkingDayChecker 5 | 6 | 7 | class TestWorkingDayChecker(TestCase): 8 | def test_check_holiday(self): 9 | checker = WorkingDayChecker() 10 | 11 | # Holidays 12 | self.assertTrue(checker.check_holiday(datetime.date(year=2021, month=12, day=25))) 13 | self.assertTrue(checker.check_holiday(datetime.date(year=2021, month=1, day=1))) 14 | 15 | self.assertTrue(checker.check_holiday(datetime.date(year=2021, month=5, day=16)), "Sundays are not working " 16 | "days!") 17 | self.assertFalse(checker.check_holiday(datetime.date(year=2021, month=5, day=15)), "Saturdays are working " 18 | "days!") 19 | -------------------------------------------------------------------------------- /covidbot/tests/test_bot.py: -------------------------------------------------------------------------------- 1 | from datetime import datetime, timedelta 2 | from unittest import TestCase 3 | 4 | from mysql.connector import MySQLConnection 5 | 6 | from covidbot.__main__ import parse_config, get_connection 7 | from covidbot.covid_data import CovidData, RKIKeyDataUpdater, VaccinationGermanyUpdater, \ 8 | RValueGermanyUpdater, \ 9 | Visualization, DistrictData, HospitalisationRKIUpdater 10 | from covidbot.bot import Bot 11 | from covidbot.settings import BotUserSettings 12 | from covidbot.user_manager import UserManager 13 | 14 | 15 | class TestBot(TestCase): 16 | conn: MySQLConnection 17 | 18 | @classmethod 19 | def setUpClass(cls) -> None: 20 | cfg = parse_config("resources/config.unittest.ini") 21 | cls.conn = get_connection(cfg) 22 | 23 | with cls.conn.cursor(dictionary=True) as cursor: 24 | cursor.execute("DROP TABLE IF EXISTS covid_data;") 25 | cursor.execute("DROP TABLE IF EXISTS covid_vaccinations;") 26 | cursor.execute("DROP TABLE IF EXISTS covid_r_value;") 27 | cursor.execute("DROP TABLE IF EXISTS hospitalisation;") 28 | cursor.execute("DROP TABLE IF EXISTS icu_beds;") 29 | cursor.execute("DROP TABLE IF EXISTS district_rules;") 30 | cursor.execute("DROP TABLE IF EXISTS county_alt_names;") 31 | cursor.execute("DROP TABLE IF EXISTS counties;") 32 | 33 | # Update Data 34 | RKIKeyDataUpdater(cls.conn).update() 35 | VaccinationGermanyUpdater(cls.conn).update() 36 | RValueGermanyUpdater(cls.conn).update() 37 | HospitalisationRKIUpdater(cls.conn).update() 38 | 39 | cls.user_manager = UserManager("unittest", cls.conn, activated_default=True) 40 | cls.data = CovidData(connection=cls.conn) 41 | cls.interface = Bot(cls.user_manager, cls.data, 42 | Visualization(cls.conn, ".", disable_cache=True), lambda x: x) 43 | 44 | @classmethod 45 | def tearDownClass(cls) -> None: 46 | cls.conn.close() 47 | 48 | # noinspection SqlWithoutWhere 49 | def setUp(self) -> None: 50 | with self.conn.cursor() as cursor: 51 | cursor.execute('TRUNCATE subscriptions') 52 | cursor.execute('TRUNCATE report_subscriptions') 53 | cursor.execute('TRUNCATE bot_user_settings') 54 | cursor.execute('TRUNCATE bot_user_sent_reports') 55 | cursor.execute('TRUNCATE user_feedback') 56 | cursor.execute('TRUNCATE user_responses') 57 | cursor.execute('TRUNCATE user_ticket_tag') 58 | cursor.execute('DELETE FROM bot_user') 59 | 60 | def test_update_with_subscribers(self): 61 | hessen_id = self.interface.find_district_id("Hessen")[1][0].id 62 | bayern_id = self.interface.find_district_id("Bayern")[1][0].id 63 | 64 | platform_id1 = "uid1" 65 | platform_id2 = "uid2" 66 | 67 | uid1 = self.user_manager.get_user_id(platform_id1) 68 | uid2 = self.user_manager.get_user_id(platform_id2) 69 | 70 | self.user_manager.add_subscription(uid1, hessen_id) 71 | self.user_manager.add_subscription(uid2, bayern_id) 72 | 73 | self.assertEqual([], [1 for _ in self.interface.get_available_user_messages()], 74 | "New users should not get a report") 75 | 76 | with self.conn.cursor() as cursor: 77 | for uid in [uid1, uid2]: 78 | cursor.execute('UPDATE bot_user SET created=%s WHERE user_id=%s', 79 | [datetime.now() - timedelta(days=4), uid]) 80 | cursor.execute('TRUNCATE bot_user_sent_reports') 81 | 82 | self.user_manager.set_user_setting(uid1, BotUserSettings.SUNDAY_REPORT, True) 83 | self.user_manager.set_user_setting(uid2, BotUserSettings.SUNDAY_REPORT, True) 84 | update = self.interface.get_available_user_messages() 85 | i = 0 86 | for report, uid, reports in update: 87 | if uid == platform_id1: 88 | self.assertRegex(reports[0].message, "Hessen", "A subscribed district must be part of the daily report") 89 | self.assertEqual(self.interface.reportHandler("", uid1), reports, 90 | "The daily report should be equal to the manual report") 91 | if uid == platform_id2: 92 | self.assertRegex(reports[0].message, "Bayern", "A subscribed district must be part of the daily report") 93 | self.assertEqual(self.interface.reportHandler("", uid2), reports, 94 | "The daily report should be equal to the manual report") 95 | self.interface.confirm_message_send(report, uid) 96 | 97 | i += 1 98 | 99 | self.assertEqual(2, i, "New data should trigger 2 updates") 100 | self.assertEqual([], [1 for _ in self.interface.get_available_user_messages()], 101 | "If both users already have current report, " 102 | "it should not be sent again") 103 | 104 | def test_update_no_subscribers(self): 105 | self.assertEqual([], [1 for _ in self.interface.get_available_user_messages()], 106 | "Empty subscribers should generate empty " 107 | "update list") 108 | 109 | def test_no_update_new_subscriber(self): 110 | user1 = self.user_manager.get_user_id("uid1") 111 | self.user_manager.add_subscription(user1, 0) 112 | self.assertEqual([], [1 for _ in self.interface.get_available_user_messages()], 113 | "New subscriber should get his first report on next day") 114 | 115 | def test_sort_districts(self): 116 | districts = [DistrictData(incidence=0, name="A", id=1), DistrictData(incidence=0, name="C", id=3), 117 | DistrictData(incidence=0, name="B", id=2)] 118 | actual_names = list(map(lambda d: d.name, self.interface.sort_districts(districts))) 119 | 120 | self.assertEqual("A", actual_names[0], "Districts should be sorted alphabetically") 121 | self.assertEqual("B", actual_names[1], "Districts should be sorted alphabetically") 122 | self.assertEqual("C", actual_names[2], "Districts should be sorted alphabetically") 123 | 124 | def test_sample_session(self): 125 | # Sample Session, should be improved a lot 126 | uid = "1" 127 | self.assertIsNotNone(self.interface.handle_input("Start", uid)) 128 | self.assertIsNotNone(self.interface.handle_input("Darmstadt", uid)) 129 | self.assertIsNotNone(self.interface.handle_input("Stadt Darmstadt", uid)) 130 | self.assertIsNotNone(self.interface.handle_input("Abo", uid)) 131 | self.assertIsNotNone(self.interface.handle_input("Impfungen", uid)) 132 | self.assertIsNotNone(self.interface.handle_input("Abo Dresden", uid)) 133 | self.assertIsNotNone(self.interface.handle_input("Bericht", uid)) 134 | self.assertIsNotNone(self.interface.handle_input("Statistik", uid)) 135 | self.assertIsNotNone(self.interface.handle_input("Regeln Berlin", uid)) 136 | self.assertIsNotNone(self.interface.handle_input("Loeschmich", uid)) 137 | self.assertEqual("Deine Daten wurden erfolgreich gelöscht.", self.interface.handle_input("Ja", uid)[0].message) 138 | -------------------------------------------------------------------------------- /covidbot/tests/test_covid_data.py: -------------------------------------------------------------------------------- 1 | from unittest import TestCase 2 | 3 | from mysql.connector import MySQLConnection 4 | 5 | from covidbot.__main__ import parse_config, get_connection 6 | from covidbot.covid_data import CovidData, RKIKeyDataUpdater, RKIHistoryUpdater, \ 7 | ICUGermanyUpdater, VaccinationGermanyUpdater, \ 8 | RValueGermanyUpdater, HospitalisationRKIUpdater, ICUGermanyHistoryUpdater 9 | 10 | 11 | class CovidDataTest(TestCase): 12 | conn: MySQLConnection 13 | 14 | @classmethod 15 | def setUpClass(cls) -> None: 16 | cfg = parse_config("resources/config.unittest.ini") 17 | cls.conn = get_connection(cfg) 18 | 19 | with cls.conn.cursor() as cursor: 20 | cursor.execute("TRUNCATE TABLE covid_data;") 21 | cursor.execute("TRUNCATE TABLE covid_vaccinations;") 22 | cursor.execute("TRUNCATE TABLE covid_r_value;") 23 | cursor.execute("TRUNCATE TABLE hospitalisation;") 24 | cursor.execute("TRUNCATE TABLE icu_beds;") 25 | cursor.execute("TRUNCATE TABLE district_rules;") 26 | cursor.execute("TRUNCATE TABLE county_alt_names;") 27 | # noinspection SqlWithoutWhere 28 | cursor.execute("DELETE FROM counties ORDER BY parent DESC;") 29 | RKIKeyDataUpdater(cls.conn).update() 30 | history = RKIHistoryUpdater(cls.conn) 31 | history.max_delta = 0 32 | history.min_delta = 10 33 | history.update() 34 | 35 | for updater in [RValueGermanyUpdater, VaccinationGermanyUpdater, 36 | ICUGermanyUpdater, 37 | ICUGermanyHistoryUpdater, 38 | HospitalisationRKIUpdater]: 39 | updater(cls.conn).update() 40 | 41 | cls.conn.commit() 42 | 43 | @classmethod 44 | def tearDownClass(cls) -> None: 45 | cls.conn.close() 46 | 47 | def setUp(self) -> None: 48 | self.data = CovidData(self.conn) 49 | 50 | def tearDown(self) -> None: 51 | del self.data 52 | 53 | def test_unicode(self): 54 | self.assertIsNotNone( 55 | self.data.search_district_by_name("den neuen Bericht finde ich super! 👍🏽")) 56 | 57 | def test_find_ags(self): 58 | self.assertEqual(2, len(self.data.search_district_by_name("Kassel")), 59 | "2 Entities should be found for Kassel") 60 | self.assertEqual(1, len(self.data.search_district_by_name("Essen")), 61 | "Exact match should be chosen") 62 | self.assertEqual(1, len(self.data.search_district_by_name("Berlin")), 63 | "Exact match should be chosen") 64 | self.assertEqual(1, len(self.data.search_district_by_name("Kassel Stadt")), 65 | "Kassel Stadt should match SK Kassel") 66 | self.assertEqual(1, len(self.data.search_district_by_name("Stadt Kassel")), 67 | "Stadt Kassel should match SK Kassel") 68 | self.assertEqual(1, len(self.data.search_district_by_name("Göttingen")), 69 | "Göttingen should match") 70 | self.assertEqual(1, len(self.data.search_district_by_name("Kassel Land")), 71 | "Kassel Land should match LK Kassel") 72 | self.assertEqual(1, len(self.data.search_district_by_name("Bundesland Hessen")), 73 | "Exact match should be chosen") 74 | 75 | def test_find_abbr(self): 76 | self.assertEqual(1, len(self.data.search_district_by_name("SH"))) 77 | self.assertEqual(1, len(self.data.search_district_by_name("NRW")), 78 | "NRW should match") 79 | 80 | def test_get_district_data(self): 81 | data = self.data.get_district_data(3151) 82 | 83 | self.assertIsNotNone(data, "Data for District#11 must be available") 84 | self.assertIsNotNone(data.new_cases, "New Cases for today must be available") 85 | self.assertIsNotNone(data.new_deaths, "New Deaths for today must be available") 86 | self.assertIsNotNone(data.incidence, "Incidence for today must be available") 87 | self.assertIsNotNone(data.cases_trend, "Trend for today must be available") 88 | self.assertIsNotNone(data.deaths_trend, "Trend for today must be available") 89 | self.assertIsNotNone(data.incidence_trend, "Trend for today must be available") 90 | 91 | non_existent = self.data.get_district_data(9999999999999) 92 | self.assertIsNone(non_existent, 93 | "get_district_data should return None for non-existing data") 94 | 95 | def test_get_root_district_data(self): 96 | self.data.get_district_data(0) 97 | -------------------------------------------------------------------------------- /covidbot/tests/test_data_updater.py: -------------------------------------------------------------------------------- 1 | from unittest import TestCase 2 | 3 | from mysql.connector import MySQLConnection 4 | 5 | from covidbot.__main__ import parse_config, get_connection 6 | from covidbot.covid_data import RKIKeyDataUpdater, RValueGermanyUpdater, \ 7 | VaccinationGermanyUpdater, HospitalisationRKIUpdater, ICUGermanyHistoryUpdater, \ 8 | RKIHistoryUpdater 9 | from covidbot.covid_data import clean_district_name, ICUGermanyUpdater 10 | 11 | class TestUpdater(TestCase): 12 | conn: MySQLConnection 13 | 14 | @classmethod 15 | def setUpClass(cls) -> None: 16 | cfg = parse_config("resources/config.unittest.ini") 17 | cls.conn = get_connection(cfg) 18 | 19 | @classmethod 20 | def tearDownClass(cls) -> None: 21 | cls.conn.close() 22 | 23 | def test_update(self): 24 | with self.conn.cursor() as c: 25 | c.execute("DROP TABLE covid_data") 26 | c.execute("DROP TABLE covid_vaccinations") 27 | c.execute("DROP TABLE covid_r_value") 28 | c.execute("DROP TABLE hospitalisation") 29 | c.execute("DROP TABLE district_rules") 30 | c.execute("DROP TABLE icu_beds") 31 | 32 | for updater_class in [RKIKeyDataUpdater, RKIHistoryUpdater, RValueGermanyUpdater, 33 | VaccinationGermanyUpdater, ICUGermanyUpdater, 34 | ICUGermanyHistoryUpdater, 35 | HospitalisationRKIUpdater]: 36 | updater = updater_class(self.conn) 37 | self.assertTrue(updater.update(), f"{updater_class.__name__} should update") 38 | 39 | def test_clean_district_name(self): 40 | expected = [("Region Hannover", "Hannover"), ("LK Kassel", "Kassel"), 41 | ("LK Dillingen a.d.Donau", "Dillingen a.d.Donau"), 42 | ("LK Bad Tölz-Wolfratshausen", "Bad Tölz-Wolfratshausen"), 43 | ("Berlin", "Berlin")] 44 | for item in expected: 45 | self.assertEqual(item[1], clean_district_name(item[0]), 46 | "Clean name of " + item[0] + " should be " + item[1]) 47 | -------------------------------------------------------------------------------- /covidbot/tests/test_location_service.py: -------------------------------------------------------------------------------- 1 | from unittest import TestCase 2 | 3 | from covidbot.location_service import LocationService 4 | 5 | 6 | class TestLocationService(TestCase): 7 | location_service: LocationService 8 | 9 | def setUp(self) -> None: 10 | self.location_service = LocationService("resources/germany_rs.geojson") 11 | 12 | def test_find_rs(self): 13 | expected = [((10.47304756818778, 52.49145414079065), 3151)] 14 | for ex in expected: 15 | self.assertEqual(ex[1], self.location_service.find_rs(ex[0][0], ex[0][1])) 16 | 17 | self.assertIsNone(self.location_service.find_rs(2.323020153685483, 48.83753707055439), 18 | "Paris should not resolve to a RS") 19 | 20 | def test_find_location(self): 21 | self.assertCountEqual([3151], self.location_service.find_location("Neubokel")) 22 | self.assertCountEqual([6631, 6633, 16069], self.location_service.find_location("Simmershausen")) 23 | -------------------------------------------------------------------------------- /covidbot/tests/test_single_command_interface.py: -------------------------------------------------------------------------------- 1 | from typing import Iterable, Optional, List 2 | from unittest import TestCase 3 | 4 | from covidbot.__main__ import parse_config, get_connection 5 | from covidbot.covid_data import CovidData, Visualization 6 | from covidbot.interfaces.single_command_interface import SingleCommandInterface, SingleArgumentRequest 7 | from covidbot.user_manager import UserManager 8 | 9 | 10 | class NonAbstractSingleCommandInterface(SingleCommandInterface): 11 | 12 | def write_message(self, message: str, media_files: Optional[List[str]] = None, 13 | reply_obj: Optional[object] = None) -> bool: 14 | pass 15 | 16 | def get_mentions(self) -> Iterable[SingleArgumentRequest]: 17 | pass 18 | 19 | 20 | class TestSingleCommandInterface(TestCase): 21 | 22 | @classmethod 23 | def setUpClass(cls) -> None: 24 | cfg = parse_config("resources/config.unittest.ini") 25 | cls.conn = get_connection(cfg) 26 | 27 | cls.interface = NonAbstractSingleCommandInterface(UserManager("test", cls.conn), CovidData(cls.conn), 28 | Visualization(cls.conn, "."), 0, True) 29 | 30 | def test_find_district(self): 31 | self.assertEqual(5113, self.interface.find_district("Essen"), "Result for Essen should be Essen") 32 | self.assertEqual(6, self.interface.find_district("Hessen"), "Result for Hessen should be Hessen") 33 | self.assertEqual(5515, self.interface.find_district("Münster"), "Result for Münster should be Münster") 34 | self.assertEqual(5515, self.interface.find_district("Münster Westfalen"), 35 | "Result for Münster Westfalen should be Münster") 36 | self.assertEqual(6435, self.interface.find_district("Hanau"), "Result for Hanau should be MKK") 37 | self.assertEqual(9772, self.interface.find_district("Landkreis Augsburg"), 38 | "Result for LK Augsburg should be correct") 39 | self.assertEqual(8221, self.interface.find_district("Betreutes Trinken"), 40 | "Result for Betreutes Trinken should be Heidelberg") 41 | self.assertEqual(8215, self.interface.find_district("Rheinstetten"), "Result for Rheinstetten is missing") 42 | self.assertEqual(3361, self.interface.find_district("Achim"), "Result for Achim is missing") 43 | 44 | def test_find_district_no_query(self): 45 | self.assertIsNone(self.interface.find_district("via Threema, Telegram oder Signal")) 46 | self.assertIsNone(self.interface.find_district( 47 | "ist die Sterblichkeit bei euch gegenüber LK mit niedriger Inzidenz deutlich erhöht?")) 48 | self.assertIsNone(self.interface.find_district("gut, brauche ihn aber vermutlich nicht")) 49 | self.assertIsNone(self.interface.find_district("Wie wird den jetzt gezählt?")) 50 | self.assertIsNone(self.interface.find_district("Risklayer sagt grad eben dies:")) 51 | self.assertIsNone( 52 | self.interface.find_district("Das ist aber dann so dass die größten Schwätzer sich am meisten kümmern?")) 53 | self.assertIsNone(self.interface.find_district("Coole Idee und gute Umsetzung")) 54 | self.assertIsNone(self.interface.find_district("Wie funktioniert der Twitterbot?")) 55 | self.assertIsNone(self.interface.find_district("der einen mit aktuellen Zahlen rund um #COVID19 versorgt")) 56 | self.assertIsNone(self.interface.find_district("ist nun auch bei Twitter aus dem Ei ")) 57 | self.assertIsNone(self.interface.find_district("Riesige Kudos gehen raus an")) 58 | self.assertIsNone(self.interface.find_district("Vielen Dank für eure mega Arbeit")) 59 | self.assertIsNone(self.interface.find_district("Super! Funktioniert klasse")) 60 | self.assertIsNone(self.interface.find_district("Das ist echt großartig, was ihr geleistet habt.")) 61 | self.assertIsNone(self.interface.find_district("Klar...")) 62 | self.assertIsNone( 63 | self.interface.find_district("Bitte korrigiert bei den Regeln für Berlin die Angabe zu den Kindern.")) 64 | -------------------------------------------------------------------------------- /covidbot/tests/test_user_hint_service.py: -------------------------------------------------------------------------------- 1 | from unittest import TestCase 2 | 3 | from covidbot.user_hint_service import UserHintService 4 | 5 | 6 | class TestUserHints(TestCase): 7 | def test_user_hints(self): 8 | formatter = lambda x: f"/{x}" 9 | 10 | expected = "Lorem /ipsum sim /dolor" 11 | actual = UserHintService.format_commands("Lorem {ipsum} sim {dolor}", formatter) 12 | self.assertEqual(expected, actual, "Commands should be formatted correctly in User Hints") 13 | 14 | formatter = lambda x: f"'{x}'" 15 | expected = "Lorem 'ipsum' sim 'dolor'" 16 | actual = UserHintService.format_commands("Lorem {ipsum} sim {dolor}", formatter) 17 | self.assertEqual(expected, actual, "Commands should be formatted correctly in User Hints") 18 | -------------------------------------------------------------------------------- /covidbot/tests/test_utils.py: -------------------------------------------------------------------------------- 1 | from unittest import TestCase 2 | 3 | from covidbot.utils import * 4 | 5 | 6 | class Test(TestCase): 7 | def test_adapt_text_unicode(self): 8 | test_str = "Dies ist ein Test!" 9 | actual = adapt_text(test_str) 10 | expected = "𝗗𝗶𝗲𝘀 𝗶𝘀𝘁 𝗲𝗶𝗻 𝗧𝗲𝘀𝘁!" 11 | self.assertEqual(expected, actual, "adapt_text should replace bold text with Unicode characters") 12 | 13 | test_str = "Dies ist ein Test!" 14 | actual = adapt_text(test_str) 15 | expected = "𝘋𝘪𝘦𝘴 𝘪𝘴𝘵 𝘦𝘪𝘯 𝘛𝘦𝘴𝘵!" 16 | self.assertEqual(expected, actual, "adapt_text should replace italic text with Unicode characters") 17 | 18 | test_str = "Städte" 19 | actual = adapt_text(test_str) 20 | expected = "𝗦𝘁𝗮̈𝗱𝘁𝗲" 21 | self.assertEqual(expected, actual, "adapt_text should replace bold Städte correctly") 22 | 23 | def test_adapt_text_markdown(self): 24 | test_str = "Dies ist ein Test mit ein paar schönen Umlauten wie üäö!" 25 | actual = adapt_text(test_str, threema_format=True) 26 | expected = "*Dies ist ein Test mit ein paar schönen Umlauten wie üäö!*" 27 | self.assertEqual(expected, actual, "adapt_text should insert bold markdown") 28 | 29 | test_str = "Dies ist ein Test mit ein paar schönen Umlauten wie üäö!" 30 | actual = adapt_text(test_str, threema_format=True) 31 | expected = "_Dies ist ein Test mit ein paar schönen Umlauten wie üäö!_" 32 | self.assertEqual(expected, actual, "adapt_text should insert italic markdown") 33 | 34 | def test_adapt_text_links(self): 35 | test_str = "D-64" 36 | actual = adapt_text(test_str) 37 | expected = "D-64 (https://d-64.org/)" 38 | self.assertEqual(expected, actual, "adapt_text should remove but link should remain") 39 | 40 | test_str = "D-64 und der CCC leisten " \ 41 | "wertvolle Arbeit!" 42 | actual = adapt_text(test_str) 43 | expected = "D-64 (https://d-64.org/) und der CCC (https://www.ccc.de/) leisten wertvolle Arbeit!" 44 | self.assertEqual(expected, actual, "adapt_text work with several links") 45 | 46 | test_str = "hier" 47 | actual = adapt_text(test_str) 48 | expected = "hier (https://tourismus-wegweiser.de/widget/detail/?bl=he&sel=no)" 49 | self.assertEqual(expected, actual) 50 | 51 | def test_strip(self): 52 | test_str = "D-64" 53 | actual = adapt_text(test_str) 54 | expected = "D-64" 55 | self.assertEqual(expected, actual, "adapt_text should remove all html tags but a,b,i") 56 | 57 | def test_url_in_italic(self): 58 | test_str = "Mehr Infos hier und da" 59 | actual = adapt_text(test_str) 60 | expected = "𝘔𝘦𝘩𝘳 𝘐𝘯𝘧𝘰𝘴 𝘩𝘪𝘦𝘳 (https://test.de/) 𝘶𝘯𝘥 𝘥𝘢 (https://test2.de/)" 61 | self.assertEqual(expected, actual, "adapt_text should replace links in italic mode and make them not italic") 62 | 63 | def test_url_in_markdown(self): 64 | test_str = "Mehr Infos hier und da" 65 | actual = adapt_text(test_str, threema_format=True) 66 | expected = "_Mehr Infos hier_ (https://test.de/) _und da_ (https://test2.de/)" 67 | self.assertEqual(expected, actual, "adapt_text should omit links in italic mode") 68 | 69 | test_str = "Mehr Infos hier und da" 70 | actual = adapt_text(test_str, threema_format=True) 71 | expected = "*Mehr Infos hier* (https://test.de/) *und da* (https://test2.de/)" 72 | self.assertEqual(expected, actual, "adapt_text should omit links in italic mode") 73 | 74 | def test_adapt_strip(self): 75 | test_str = "

Absatz 1.

Kein Absatz.

Absatz 2

" 76 | actual = adapt_text(test_str, just_strip=True) 77 | expected = "Absatz 1.\nKein Absatz.\nAbsatz 2" 78 | self.assertEqual(expected, actual, "Adapt text should also adapt

to linebreaks") 79 | 80 | def test_format_int(self): 81 | expected = "1.121" 82 | actual = format_int(1121) 83 | self.assertEqual(expected, actual, "Ints should be formatted for German localization") 84 | 85 | def test_format_incidence(self): 86 | expected = "1,21" 87 | actual = format_float(1.21) 88 | self.assertEqual(expected, actual, "Incidence should be formatted for German localization") 89 | 90 | def test_format_noun(self): 91 | expected = "1 Neuinfektion" 92 | actual = format_noun(1, FormattableNoun.NEW_INFECTIONS) 93 | self.assertEqual(expected, actual) 94 | 95 | expected = "2 Neuinfektionen" 96 | actual = format_noun(2, FormattableNoun.NEW_INFECTIONS) 97 | self.assertEqual(expected, actual) 98 | 99 | expected = "0 Neuinfektionen" 100 | actual = format_noun(0, FormattableNoun.NEW_INFECTIONS) 101 | self.assertEqual(expected, actual) 102 | 103 | expected = "1 Todesfall" 104 | actual = format_noun(1, FormattableNoun.DEATHS) 105 | self.assertEqual(expected, actual) 106 | 107 | expected = "2 Todesfälle" 108 | actual = format_noun(2, FormattableNoun.DEATHS) 109 | self.assertEqual(expected, actual) 110 | 111 | expected = "0 Todesfälle" 112 | actual = format_noun(0, FormattableNoun.DEATHS) 113 | self.assertEqual(expected, actual) 114 | 115 | def test_get_trend(self): 116 | self.assertEqual(TrendValue.SAME, get_trend(99, 100)) 117 | self.assertEqual(TrendValue.SAME, get_trend(100, 101)) 118 | self.assertEqual(TrendValue.SAME, get_trend(100, 100)) 119 | self.assertEqual(TrendValue.UP, get_trend(98, 101)) 120 | self.assertEqual(TrendValue.DOWN, get_trend(102, 100)) 121 | -------------------------------------------------------------------------------- /covidbot/tests/test_visualization.py: -------------------------------------------------------------------------------- 1 | from unittest import TestCase 2 | 3 | from covidbot.covid_data import Visualization 4 | 5 | 6 | class TestVisualization(TestCase): 7 | def test_tick_formatter_german_numbers(self): 8 | self.assertEqual("1,1 Mio.", Visualization.tick_formatter_german_numbers(1100000, 0)) 9 | self.assertEqual("900.000", Visualization.tick_formatter_german_numbers(900000, 0)) 10 | -------------------------------------------------------------------------------- /covidbot/user_hint_service.py: -------------------------------------------------------------------------------- 1 | import csv 2 | import datetime 3 | import os 4 | import re 5 | from typing import Callable, Optional 6 | 7 | 8 | class UserHintService: 9 | FILE = "resources/user-tips.csv" 10 | current_hint: Optional[str] = None 11 | current_date: datetime.date = datetime.date.today() 12 | command_fmt: Callable[[str], str] 13 | command_regex = re.compile("{([\w\s]*)}") 14 | 15 | def __init__(self, command_formatter: Callable[[str], str]): 16 | self.command_fmt = command_formatter 17 | 18 | def get_hint_of_today(self) -> str: 19 | if self.current_hint and self.current_date == datetime.date.today(): 20 | return self.current_hint 21 | 22 | if os.path.isfile(self.FILE): 23 | with open(self.FILE, "r") as f: 24 | reader = csv.DictReader(f, delimiter=";") 25 | today = datetime.date.today() 26 | for row in reader: 27 | if row['date'] == today.isoformat(): 28 | self.current_hint = self.format_commands(row['message'], self.command_fmt) 29 | self.current_date = today 30 | return self.current_hint 31 | 32 | @staticmethod 33 | def format_commands(message: str, formatter: Callable[[str], str]) -> str: 34 | return UserHintService.command_regex.sub(lambda x: formatter(x.group(1)), message) 35 | -------------------------------------------------------------------------------- /docker-compose.yaml: -------------------------------------------------------------------------------- 1 | version: '3' 2 | 3 | volumes: 4 | covid-mariadb: 5 | 6 | services: 7 | 8 | #---------------------------------------------------------------- 9 | # Gitea 10 | #---------------------------------------------------------------- 11 | covidbot: 12 | image: hanneshil/covidbot 13 | build: . 14 | container_name: covidbot 15 | restart: always 16 | depends_on: 17 | - covid-mariadb 18 | 19 | #---------------------------------------------------------------- 20 | # Postgre for CovidBot 21 | #---------------------------------------------------------------- 22 | covid-mariadb: 23 | image: mariadb:latest 24 | container_name: covid-mariadb 25 | environment: 26 | - MYSQL_ROOT_PASSWORD=covid 27 | - MYSQL_USER=covid 28 | - MYSQL_PASSWORD=covid 29 | - MYSQL_DATABASE=covid 30 | volumes: 31 | - covid-mariadb:/var/lib/mysql 32 | restart: always 33 | -------------------------------------------------------------------------------- /docs/.nojekyll: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/eknoes/covidbot/565450e6ab558d5e91b4fcec1d161628d56fe865/docs/.nojekyll -------------------------------------------------------------------------------- /docs/Makefile: -------------------------------------------------------------------------------- 1 | # Minimal makefile for Sphinx documentation 2 | # 3 | 4 | # You can set these variables from the command line, and also 5 | # from the environment for the first two. 6 | SPHINXOPTS ?= 7 | SPHINXBUILD ?= sphinx-build 8 | SOURCEDIR = . 9 | BUILDDIR = _build 10 | 11 | # Put it first so that "make" without argument is like "make help". 12 | help: 13 | @$(SPHINXBUILD) -M help "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O) 14 | 15 | .PHONY: help Makefile 16 | 17 | # Catch-all target: route all unknown targets to Sphinx using the new 18 | # "make mode" option. $(O) is meant as a shortcut for $(SPHINXOPTS). 19 | %: Makefile 20 | @$(SPHINXBUILD) -M $@ "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O) 21 | -------------------------------------------------------------------------------- /docs/conf.py: -------------------------------------------------------------------------------- 1 | # Configuration file for the Sphinx documentation builder. 2 | # 3 | # This file only contains a selection of the most common options. For a full 4 | # list see the documentation: 5 | # https://www.sphinx-doc.org/en/master/usage/configuration.html 6 | 7 | # -- Path setup -------------------------------------------------------------- 8 | 9 | # If extensions (or modules to document with autodoc) are in another directory, 10 | # add these directories to sys.path here. If the directory is relative to the 11 | # documentation root, use os.path.abspath to make it absolute, like shown here. 12 | # 13 | import os 14 | import sys 15 | sys.path.insert(0, os.path.abspath('.')) 16 | sys.path.insert(0, os.path.abspath('../')) 17 | 18 | # -- Project information ----------------------------------------------------- 19 | 20 | project = 'Covidbot' 21 | copyright = '2021, Sönke Huster, Erik Tuchtfeld' 22 | author = 'Sönke Huster, Erik Tuchtfeld' 23 | 24 | 25 | # -- General configuration --------------------------------------------------- 26 | 27 | # Add any Sphinx extension module names here, as strings. They can be 28 | # extensions coming with Sphinx (named 'sphinx.ext.*') or your custom 29 | # ones. 30 | extensions = ['sphinx.ext.napoleon', 'recommonmark'] 31 | 32 | # Add any paths that contain templates here, relative to this directory. 33 | templates_path = ['_templates'] 34 | 35 | # The language for content autogenerated by Sphinx. Refer to documentation 36 | # for a list of supported languages. 37 | # 38 | # This is also used if you do content translation via gettext catalogs. 39 | # Usually you set "language" from the command line for these cases. 40 | language = 'en' 41 | 42 | # List of patterns, relative to source directory, that match files and 43 | # directories to ignore when looking for source files. 44 | # This pattern also affects html_static_path and html_extra_path. 45 | exclude_patterns = ['_build', 'Thumbs.db', '.DS_Store'] 46 | 47 | 48 | # -- Options for HTML output ------------------------------------------------- 49 | 50 | # The theme to use for HTML and HTML Help pages. See the documentation for 51 | # a list of builtin themes. 52 | # 53 | html_theme = 'alabaster' 54 | 55 | # Add any paths that contain custom static files (such as style sheets) here, 56 | # relative to this directory. They are copied after the builtin static files, 57 | # so a file named "default.css" will overwrite the builtin "default.css". 58 | html_static_path = ['_static'] -------------------------------------------------------------------------------- /docs/index.rst: -------------------------------------------------------------------------------- 1 | Welcome to Covidbot's documentation! 2 | ==================================== 3 | 4 | .. toctree:: 5 | :maxdepth: 2 6 | :caption: Contents: 7 | readme 8 | 9 | 10 | 11 | Indices and tables 12 | ================== 13 | 14 | * :ref:`genindex` 15 | * :ref:`modindex` 16 | * :ref:`readme` 17 | * :ref:`search` 18 | -------------------------------------------------------------------------------- /docs/make.bat: -------------------------------------------------------------------------------- 1 | @ECHO OFF 2 | 3 | pushd %~dp0 4 | 5 | REM Command file for Sphinx documentation 6 | 7 | if "%SPHINXBUILD%" == "" ( 8 | set SPHINXBUILD=sphinx-build 9 | ) 10 | set SOURCEDIR=. 11 | set BUILDDIR=_build 12 | 13 | if "%1" == "" goto help 14 | 15 | %SPHINXBUILD% >NUL 2>NUL 16 | if errorlevel 9009 ( 17 | echo. 18 | echo.The 'sphinx-build' command was not found. Make sure you have Sphinx 19 | echo.installed, then set the SPHINXBUILD environment variable to point 20 | echo.to the full path of the 'sphinx-build' executable. Alternatively you 21 | echo.may add the Sphinx directory to PATH. 22 | echo. 23 | echo.If you don't have Sphinx installed, grab it from 24 | echo.http://sphinx-doc.org/ 25 | exit /b 1 26 | ) 27 | 28 | %SPHINXBUILD% -M %1 %SOURCEDIR% %BUILDDIR% %SPHINXOPTS% %O% 29 | goto end 30 | 31 | :help 32 | %SPHINXBUILD% -M help %SOURCEDIR% %BUILDDIR% %SPHINXOPTS% %O% 33 | 34 | :end 35 | popd 36 | -------------------------------------------------------------------------------- /docs/readme.rst: -------------------------------------------------------------------------------- 1 | Readme File 2 | =========== 3 | 4 | .. mdinclude:: ../README.md -------------------------------------------------------------------------------- /docs/source/covidbot.covid_data.rst: -------------------------------------------------------------------------------- 1 | covidbot.covid\_data package 2 | ============================ 3 | 4 | Submodules 5 | ---------- 6 | 7 | covidbot.covid\_data.covid\_data module 8 | --------------------------------------- 9 | 10 | .. automodule:: covidbot.covid_data.covid_data 11 | :members: 12 | :undoc-members: 13 | :show-inheritance: 14 | 15 | covidbot.covid\_data.models module 16 | ---------------------------------- 17 | 18 | .. automodule:: covidbot.covid_data.models 19 | :members: 20 | :undoc-members: 21 | :show-inheritance: 22 | 23 | covidbot.covid\_data.updater module 24 | ----------------------------------- 25 | 26 | .. automodule:: covidbot.covid_data.updater 27 | :members: 28 | :undoc-members: 29 | :show-inheritance: 30 | 31 | Module contents 32 | --------------- 33 | 34 | .. automodule:: covidbot.covid_data 35 | :members: 36 | :undoc-members: 37 | :show-inheritance: 38 | -------------------------------------------------------------------------------- /docs/source/covidbot.rst: -------------------------------------------------------------------------------- 1 | covidbot package 2 | ================ 3 | 4 | Subpackages 5 | ----------- 6 | 7 | .. toctree:: 8 | :maxdepth: 4 9 | 10 | covidbot.covid_data 11 | covidbot.tests 12 | 13 | Submodules 14 | ---------- 15 | 16 | covidbot.bot module 17 | ------------------- 18 | 19 | .. automodule:: covidbot.bot 20 | :members: 21 | :undoc-members: 22 | :show-inheritance: 23 | 24 | covidbot.feedback\_forwarder module 25 | ----------------------------------- 26 | 27 | .. automodule:: covidbot.feedback_forwarder 28 | :members: 29 | :undoc-members: 30 | :show-inheritance: 31 | 32 | covidbot.location\_service module 33 | --------------------------------- 34 | 35 | .. automodule:: covidbot.location_service 36 | :members: 37 | :undoc-members: 38 | :show-inheritance: 39 | 40 | covidbot.messenger\_interface module 41 | ------------------------------------ 42 | 43 | .. automodule:: covidbot.messenger_interface 44 | :members: 45 | :undoc-members: 46 | :show-inheritance: 47 | 48 | covidbot.signal\_interface module 49 | --------------------------------- 50 | 51 | .. automodule:: covidbot.signal_interface 52 | :members: 53 | :undoc-members: 54 | :show-inheritance: 55 | 56 | covidbot.telegram\_interface module 57 | ----------------------------------- 58 | 59 | .. automodule:: covidbot.telegram_interface 60 | :members: 61 | :undoc-members: 62 | :show-inheritance: 63 | 64 | covidbot.text\_interface module 65 | ------------------------------- 66 | 67 | .. automodule:: covidbot.text_interface 68 | :members: 69 | :undoc-members: 70 | :show-inheritance: 71 | 72 | covidbot.threema\_interface module 73 | ---------------------------------- 74 | 75 | .. automodule:: covidbot.threema_interface 76 | :members: 77 | :undoc-members: 78 | :show-inheritance: 79 | 80 | covidbot.user\_manager module 81 | ----------------------------- 82 | 83 | .. automodule:: covidbot.user_manager 84 | :members: 85 | :undoc-members: 86 | :show-inheritance: 87 | 88 | covidbot.utils module 89 | --------------------- 90 | 91 | .. automodule:: covidbot.utils 92 | :members: 93 | :undoc-members: 94 | :show-inheritance: 95 | 96 | Module contents 97 | --------------- 98 | 99 | .. automodule:: covidbot 100 | :members: 101 | :undoc-members: 102 | :show-inheritance: 103 | -------------------------------------------------------------------------------- /docs/source/covidbot.tests.rst: -------------------------------------------------------------------------------- 1 | covidbot.tests package 2 | ====================== 3 | 4 | Submodules 5 | ---------- 6 | 7 | covidbot.tests.test\_bot module 8 | ------------------------------- 9 | 10 | .. automodule:: covidbot.tests.test_bot 11 | :members: 12 | :undoc-members: 13 | :show-inheritance: 14 | 15 | covidbot.tests.test\_covid\_data module 16 | --------------------------------------- 17 | 18 | .. automodule:: covidbot.tests.test_covid_data 19 | :members: 20 | :undoc-members: 21 | :show-inheritance: 22 | 23 | covidbot.tests.test\_data\_updater module 24 | ----------------------------------------- 25 | 26 | .. automodule:: covidbot.tests.test_data_updater 27 | :members: 28 | :undoc-members: 29 | :show-inheritance: 30 | 31 | covidbot.tests.test\_location\_service module 32 | --------------------------------------------- 33 | 34 | .. automodule:: covidbot.tests.test_location_service 35 | :members: 36 | :undoc-members: 37 | :show-inheritance: 38 | 39 | covidbot.tests.test\_subscription\_manager module 40 | ------------------------------------------------- 41 | 42 | .. automodule:: covidbot.tests.test_subscription_manager 43 | :members: 44 | :undoc-members: 45 | :show-inheritance: 46 | 47 | covidbot.tests.test\_utils module 48 | --------------------------------- 49 | 50 | .. automodule:: covidbot.tests.test_utils 51 | :members: 52 | :undoc-members: 53 | :show-inheritance: 54 | 55 | Module contents 56 | --------------- 57 | 58 | .. automodule:: covidbot.tests 59 | :members: 60 | :undoc-members: 61 | :show-inheritance: 62 | -------------------------------------------------------------------------------- /docs/source/modules.rst: -------------------------------------------------------------------------------- 1 | covidbot 2 | ======== 3 | 4 | .. toctree:: 5 | :maxdepth: 4 6 | 7 | covidbot 8 | -------------------------------------------------------------------------------- /feedback/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/eknoes/covidbot/565450e6ab558d5e91b4fcec1d161628d56fe865/feedback/__init__.py -------------------------------------------------------------------------------- /feedback/__main__.py: -------------------------------------------------------------------------------- 1 | import aiohttp_jinja2 2 | import jinja2 3 | from aiohttp import web 4 | from aiohttp.web_exceptions import HTTPNotFound, HTTPFound, HTTPBadRequest 5 | from aiohttp.web_request import Request 6 | from mysql.connector import OperationalError 7 | 8 | from covidbot.__main__ import get_connection, parse_config 9 | from feedback.feedback_manager import FeedbackManager 10 | 11 | routes = web.RouteTableDef() 12 | config = parse_config('config.ini') 13 | connection = get_connection(config, autocommit=True) 14 | user_manager = FeedbackManager(connection) 15 | base_url = config.get('FEEDBACK', 'BASE_URL', fallback='') 16 | 17 | 18 | @routes.get(base_url + r"/user/{user_id:\d+}") 19 | @routes.get(base_url + "/") 20 | @aiohttp_jinja2.template('single.jinja2') 21 | async def show_user(request: Request): 22 | try: 23 | comm_unread, comm_read, comm_answered = user_manager.get_all_communication() 24 | except OperationalError as e: 25 | global connection 26 | if connection: 27 | connection.close() 28 | 29 | connection = get_connection(config, autocommit=True) 30 | user_manager.connection = connection 31 | comm_unread, comm_read, comm_answered = user_manager.get_all_communication() 32 | 33 | user = None 34 | communication = None 35 | 36 | status = None 37 | if 'status' in request.query: 38 | status = request.query['status'] 39 | 40 | if request.match_info.get("user_id"): 41 | user_id = int(request.match_info.get("user_id")) 42 | for s, comm in [("unread", comm_unread), ("read", comm_read), ("answered", comm_answered)]: 43 | for c in comm: 44 | if c.user_id == user_id: 45 | user = c 46 | communication = comm 47 | 48 | if status: 49 | status = s 50 | break 51 | if not user: 52 | raise HTTPNotFound() 53 | else: 54 | if status == "read": 55 | communication = comm_read 56 | elif status == "answered": 57 | communication = comm_answered 58 | elif status == "unread": 59 | communication = comm_unread 60 | else: 61 | communication = comm_unread + comm_read + comm_answered 62 | 63 | active_tag = None 64 | if 'tag' in request.query and (not user or request.query['tag'] in user.tags): 65 | communication = list(filter(lambda x: request.query['tag'] in x.tags, communication)) 66 | active_tag = request.query['tag'] 67 | 68 | if communication and not user: 69 | user = communication[0] 70 | 71 | subs, reports = [], [] 72 | if user: 73 | subs = user_manager.get_user_subscriptions(user.user_id) 74 | reports = user_manager.get_user_report_subscriptions(user.user_id) 75 | 76 | return {'messagelist': communication, 'user': user, 'base_url': base_url, 'num_unread': len(comm_unread), 77 | 'available_tags': user_manager.get_available_tags(), 'active_status': status, 'active_tag': active_tag, 78 | 'user_subs': subs, 79 | 'user_reports': reports} 80 | 81 | 82 | @routes.post(base_url + r"/user/{user_id:\d+}") 83 | @routes.post(base_url + "/") 84 | async def post_user(request: Request): 85 | form = await request.post() 86 | user_id = form.get('user_id') 87 | if not user_id: 88 | raise HTTPBadRequest(reason="You need to set a user_id") 89 | 90 | user_id = int(user_id) 91 | if form.get('mark_read'): 92 | user_manager.mark_user_read(user_id) 93 | elif form.get('mark_unread'): 94 | user_manager.mark_user_unread(user_id) 95 | elif form.get('reply'): 96 | user_manager.message_user(user_id, form.get('message')) 97 | user_manager.mark_user_read(user_id) 98 | elif form.get('remove_tag'): 99 | user_manager.remove_user_tag(user_id, form.get('remove_tag')) 100 | elif form.get('add_tag'): 101 | user_manager.add_user_tag(user_id, form.get('add_tag')) 102 | user_manager.mark_user_read(user_id) 103 | else: 104 | raise HTTPBadRequest(reason="You have to make some action") 105 | 106 | if request.query: 107 | raise HTTPFound(base_url + "/?" + request.query_string) 108 | raise HTTPFound(request.path_qs) 109 | 110 | 111 | def run(): 112 | app = web.Application() 113 | app.add_routes(routes) 114 | app.add_routes([web.static(base_url + '/static', 'resources/feedback-templates/static')]) 115 | aiohttp_jinja2.setup(app, 116 | loader=jinja2.FileSystemLoader('resources/feedback-templates/')) 117 | web.run_app(app, port=config.getint("FEEDBACK", "PORT", fallback=8080)) 118 | 119 | 120 | if __name__ == "__main__": 121 | run() 122 | -------------------------------------------------------------------------------- /feedback/feedback_manager.py: -------------------------------------------------------------------------------- 1 | import logging 2 | from dataclasses import dataclass 3 | from datetime import datetime 4 | from enum import Enum 5 | from typing import List, Dict, Tuple, Optional 6 | 7 | from mysql.connector import MySQLConnection 8 | 9 | 10 | class CommunicationState(Enum): 11 | UNREAD = "unread" 12 | READ = "read" 13 | ANSWERED = "answered" 14 | 15 | 16 | class TicketState(Enum): 17 | CREATED = "created" 18 | SENT = "sent" 19 | READ = "read" 20 | 21 | 22 | @dataclass 23 | class SingleTicket: 24 | author: int 25 | message: str 26 | date: datetime 27 | state: TicketState 28 | 29 | def meta_str(self) -> str: 30 | state_str = "" 31 | if self.state == TicketState.CREATED: 32 | state_str = "📤" 33 | elif self.state == TicketState.SENT: 34 | state_str = "✉️" 35 | elif self.state == TicketState.READ: 36 | state_str = "📃" 37 | 38 | return self.date.strftime("%d.%m.%Y %H:%M") + f' Uhr {state_str}' 39 | 40 | 41 | @dataclass 42 | class Communication: 43 | user_id: int 44 | platform: str 45 | messages: List[SingleTicket] 46 | tags: List[str] 47 | 48 | def last_communication(self) -> datetime: 49 | return self.messages[-1].date 50 | 51 | def last_communication_str(self) -> str: 52 | return self.messages[-1].date.strftime("%d.%m.%Y %H:%M") 53 | 54 | def get_tags_html(self) -> str: 55 | result = "" 56 | for t in self.tags: 57 | result += f' {t.capitalize()}' 58 | return result 59 | 60 | def state(self) -> CommunicationState: 61 | for i in range(len(self.messages), 0, -1): 62 | m = self.messages[i - 1] 63 | if m.author != 0 and m.state != TicketState.READ: 64 | return CommunicationState.UNREAD 65 | 66 | if self.messages[-1].author == 0: 67 | return CommunicationState.ANSWERED 68 | 69 | return CommunicationState.READ 70 | 71 | def desc(self) -> str: 72 | desc = self.messages[-1].message[:100] 73 | 74 | if len(desc) < len(self.messages[-1].message): 75 | desc += "..." 76 | return desc 77 | 78 | 79 | class FeedbackManager(object): 80 | connection: MySQLConnection 81 | log = logging.getLogger(__name__) 82 | 83 | def __init__(self, db_connection: MySQLConnection): 84 | self.connection = db_connection 85 | 86 | def get_all_communication(self) -> Tuple[List[Communication], List[Communication], List[Communication]]: 87 | results: Dict[int, Communication] 88 | results = {} 89 | with self.connection.cursor(dictionary=True) as cursor: 90 | cursor.execute( 91 | "(SELECT b.user_id, b.platform, feedback, added, is_read, 1 as from_user FROM user_feedback " 92 | "LEFT JOIN bot_user b on b.user_id = user_feedback.user_id) " 93 | "UNION " 94 | "(SELECT receiver_id, bu.platform, message, user_responses.created, sent, 0 FROM user_responses " 95 | "LEFT JOIN bot_user bu on bu.user_id = user_responses.receiver_id WHERE hidden=0)") 96 | for row in cursor.fetchall(): 97 | if not results.get(row['user_id']): 98 | results[row['user_id']] = Communication(row['user_id'], row['platform'], [], []) 99 | 100 | author_id = row['user_id'] 101 | if row['from_user'] == 0: 102 | author_id = 0 103 | 104 | state = TicketState.SENT 105 | if row['is_read'] == '1' and row['from_user'] == 1: 106 | state = TicketState.READ 107 | elif not row['is_read'] and row['from_user'] == 0: 108 | state = TicketState.CREATED 109 | 110 | results[row['user_id']].messages.append( 111 | SingleTicket(author_id, row['feedback'], row['added'], state)) 112 | 113 | unread = [] 114 | read = [] 115 | answered = [] 116 | for key, value in results.items(): 117 | value.messages.sort(key=lambda x: x.date) 118 | value.tags = self.get_user_tags(value.user_id) 119 | if value.state() == CommunicationState.UNREAD: 120 | unread.append(value) 121 | elif value.state() == CommunicationState.ANSWERED: 122 | answered.append(value) 123 | elif value.state() == CommunicationState.READ: 124 | read.append(value) 125 | 126 | unread.sort(key=lambda x: x.last_communication(), reverse=True) 127 | read.sort(key=lambda x: x.last_communication(), reverse=True) 128 | answered.sort(key=lambda x: x.last_communication(), reverse=True) 129 | 130 | return unread, read, answered 131 | 132 | def mark_user_read(self, user_id: int): 133 | with self.connection.cursor() as cursor: 134 | cursor.execute('UPDATE user_feedback SET is_read=1 WHERE user_id=%s', [user_id]) 135 | 136 | def mark_user_unread(self, user_id: int): 137 | with self.connection.cursor() as cursor: 138 | cursor.execute('UPDATE user_feedback SET is_read=0 WHERE user_id=%s', [user_id]) 139 | 140 | def message_user(self, user_id: int, message: str): 141 | with self.connection.cursor() as cursor: 142 | cursor.execute('INSERT INTO user_responses (receiver_id, message) VALUE (%s, %s)', [user_id, message]) 143 | 144 | def add_user_tag(self, user_id: int, tag: str): 145 | with self.connection.cursor() as cursor: 146 | cursor.execute('INSERT INTO user_ticket_tag (user_id, tag) VALUE (%s, %s)', [user_id, tag]) 147 | 148 | def remove_user_tag(self, user_id: int, tag: str): 149 | with self.connection.cursor() as cursor: 150 | cursor.execute('DELETE FROM user_ticket_tag WHERE user_id=%s AND tag=%s', [user_id, tag]) 151 | 152 | def get_user_tags(self, user_id: int) -> List[str]: 153 | with self.connection.cursor() as cursor: 154 | cursor.execute("SELECT DISTINCT tag FROM user_ticket_tag WHERE user_id=%s", [user_id]) 155 | tags = [] 156 | for r in cursor.fetchall(): 157 | tags.append(r[0]) 158 | return tags 159 | 160 | def get_user_subscriptions(self, user_id: int) -> List[str]: 161 | with self.connection.cursor() as cursor: 162 | results = [] 163 | cursor.execute("SELECT c.rs, c.county_name, subscriptions.added FROM subscriptions " 164 | "LEFT JOIN counties c on subscriptions.rs = c.rs " 165 | "WHERE user_id=%s", [user_id]) 166 | for r in cursor.fetchall(): 167 | results.append(f"{r[1]} (seit {r[2].strftime('%d.%m.%Y')})") 168 | return results 169 | 170 | def get_user_report_subscriptions(self, user_id: int) -> List[str]: 171 | with self.connection.cursor() as cursor: 172 | results = [] 173 | cursor.execute("SELECT report, added FROM report_subscriptions " 174 | "WHERE user_id=%s", [user_id]) 175 | for r in cursor.fetchall(): 176 | results.append(f"{r[0]} (seit {r[1].strftime('%d.%m.%Y')})") 177 | return results 178 | 179 | @staticmethod 180 | def get_available_tags() -> List[str]: 181 | return ["hilfe", "idee", "bug", "lob", "sönke", "erik"] 182 | -------------------------------------------------------------------------------- /html/infections-2021-04-11-0.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/eknoes/covidbot/565450e6ab558d5e91b4fcec1d161628d56fe865/html/infections-2021-04-11-0.jpg -------------------------------------------------------------------------------- /logs/test: -------------------------------------------------------------------------------- 1 | ## 22.04. 2 | * Twitter: 02:00 3 | * Signal: 02:16 4 | -> Neue RKI Daten um 04:00 5 | * Mastodon: 04:15 6 | * Threema: 04:15 -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | aiohttp<3.8.0 2 | aiohttp_jinja2==1.5 3 | matrix-nio[e2e]==0.20.1 4 | matplotlib==3.5.0 5 | mysql-connector-python==8.0.27 6 | numpy==1.22.0 7 | pandas==1.3.4 8 | Pillow==9.3.0 9 | prometheus-async==19.2.0 10 | prometheus-client==0.12.0 11 | python-telegram-bot==13.8.1 12 | pytz==2021.3 13 | requests==2.26.0 14 | semaphore-bot==0.15.0 15 | -e git+https://github.com/eknoes/simple-fbmessenger@main#egg=simple-fbmessenger 16 | Mastodon.py==1.5.2 17 | Shapely==1.8.0 18 | TwitterAPI==2.7.9.1 19 | threema.gateway==7.0.1 20 | tornado==6.3.2 21 | ujson==5.4.0 22 | urllib3==1.26.7 23 | -------------------------------------------------------------------------------- /resources/2021-01-first-newsletter-v2.html: -------------------------------------------------------------------------------- 1 | Hey Du! 2 | Unser Bot hat zum ersten (Monats)geburtstag 🎂 gleich seine Nutzer:innenzahl in 24 Stunden mehr als verdoppelt. Danke, dass Du ihn nutzt! Wir wollen Dir heute gerne einen kurzen Einblick in die Entwicklung des Bots geben. 3 | 4 | 🔮 WORAN ARBEITEN WIR? 5 | Es gibt viele Ideen für die Weiterentwicklung! Aktuell stehen bei uns die Impfdaten und die Intensivbettenauslastung ganz weit oben auf der Liste – neben der Unterstützung verschiedener Sprachen oder auch anderer Messenger. Außerdem möchten wir den Bot gerne internationalisieren, so dass auch Orte in anderen Ländern abonniert werden können. In Deutschland würden wir gerne auf die wichtigsten Verordnungen zu den von dir abonnierten Standorten verlinken. 6 | Wir entwickeln den Bot aber in unser Freizeit, bis alle Ideen umgesetzt sind, kann es also noch einen Moment dauern. 7 | 8 | 🙏 EIN DANKESCHÖN UND EIN WENIG POLITIK ✊ 9 | Dieser Bot wäre nicht möglich ohne die vielen tollen Open-Source-Projekte, die wir verwenden, vielen Dank dafür! Es wäre aber auch nicht möglich, wenn die Daten nicht frei und maschinenlesbar zur Verfügung ständen. Damit ehrenamtliche Projekte wie dieses funktionieren, ist es wichtig, dass öffentliche Einrichtungen ihre Daten grundsätzlich frei zur Verfügung stellen! 10 | 11 | 💬 FEEDBACK 12 | Hast Du eine gute Idee? Was wäre für Dich am wichtigsten? Wir freuen uns sehr über Feedback – egal ob Lob, Kritik, Fehler oder Anregungen. Sende es einfach als Nachricht an den Bot! Und falls du mitprogrammieren möchtest, schau gerne auf unserem GitHub-Repository (https://github.com/eknoes/covid-bot/) vorbei! 13 | 14 | Und als letztes: Es wäre super cool, wenn Du den Bot an deine Freund:innen weiterempfiehlst, damit er noch mehr interessierten Menschen helfen kann. Danke! 🎉 -------------------------------------------------------------------------------- /resources/2021-01-first-newsletter.html: -------------------------------------------------------------------------------- 1 | Hey Du! 2 | Unser Bot ist jetzt ziemlich genau einen Monat alt. 🎂 Danke, dass Du ihn nutzt! Wir wollen Dir heute Abend gerne einen kurzen Einblick in die Entwicklung des Bots geben. 3 | 4 | 🛠️ NEUE FEATURES 5 | • Schöne Grafiken 📈 Berichte enthalten jetzt Diagramme, diese sind auch für alle einzelnen Orte und Bundesländer abrufbereit 6 | • Verbesserte Suche 🔎 Schick uns deinen Standort, deine Postleitzahl oder die Kneipe bei dir an der Ecke: Wir finden die dazugehörigen RKI-Zahlen für deinen Landkreis! 7 | • Statistik 🤓 Mit einem Aufruf von /statistik kann ein Ranking von Orten und die aktuelle Nutzer:innenzahl abgerufen werden 8 | • Feedback 📣️ Schicke dem Bot einfach eine Nachricht mit deinem Feedback, welches dann - nach einer Bestätigung von Dir – an uns gesendet wird 9 | 10 | 🔮 WAS NOCH? 11 | Es gibt viele Ideen für die Weiterentwicklung! Aktuell stehen bei uns die Impfdaten und die Intensivbettenauslastung ganz weit oben auf der Liste – neben der Unterstützung verschiedener Sprachen. Außerdem möchten wir den Bot gerne internationalisieren, so dass auch Orte in anderen Ländern abonniert werden können. In Deutschland würden wir gerne auf die wichtigsten Verordnungen zu den von dir abonnierten Standorten verlinken. 12 | Wir entwickeln den Bot aber in unser Freizeit, bis alle Ideen umgesetzt sind, kann es also noch einen Moment dauern. 13 | 14 | 🙏 EIN DANKESCHÖN UND EIN WENIG POLITIK ✊ 15 | Dieser Bot wäre nicht möglich ohne die vielen tollen Open-Source-Projekte, die wir verwenden, vielen Dank dafür! Es wäre aber auch nicht möglich, wenn die Daten nicht frei und maschinenlesbar zur Verfügung ständen. Damit ehrenamtliche Projekte wie dieses funktionieren, ist es wichtig, dass öffentliche Einrichtungen ihre Daten grundsätzlich frei zur Verfügung stellen! 16 | 17 | 💬 FEEDBACK 18 | Hast Du eine gute Idee? Was wäre für Dich am wichtigsten? Wir freuen uns sehr über Feedback – egal ob Lob, Kritik, Fehler oder Anregungen. Sende es einfach als Nachricht an den Bot! Und falls du mitprogrammieren möchtest, schau gerne auf unserem GitHub-Repository (https://github.com/eknoes/covid-bot/) vorbei! 19 | 20 | Und als letztes: Es wäre super cool, wenn Du den Bot an deine Freund:innen weiterempfiehlst, damit er noch mehr interessierten Menschen helfen kann. Danke! 🎉 -------------------------------------------------------------------------------- /resources/2021-02-26-correction.html: -------------------------------------------------------------------------------- 1 | Guten Morgen! 2 | Leider hat sich in unserem Bericht heute morgen ein großer Fehler eingeschlichen - die Zahl der Neuinfektionen in Deutschland beträgt laut RKI 9.997, die bundesweite 7-Tage-Inzidenz beträgt 62.6. 3 | Wir melden uns mit einem korrekten Bericht bei dir, sobald wir den Fehler lokalisiert und behoben haben. -------------------------------------------------------------------------------- /resources/2021-02-second-newsletter.html: -------------------------------------------------------------------------------- 1 | Guten Abend! 2 | Wir waren in den vergangenen Wochen fleißig und möchten Dir deshalb von unseren neuesten Entwicklungen berichten: 3 | 4 | 🔐 COVIDBOT AUF SIGNAL 5 | Unser Bot lässt sich jetzt auch mit dem Messenger Signal nutzen! Schick einfach eine Signal-Nachricht mit "Start" an die +4915792453845, um den Bot zu starten. 6 | 7 | 🛠️ WEITERE NEUE FEATURES 8 | • Impfdaten 💉 Die täglichen Berichte enthalten jetzt aktuelle Daten zu den Impfungen. Du kannst sie auch nur für die einzelnen Bundesländer abrufen. 9 | • R-Wert 📈 Neben den Impfdaten wird jetzt auch die zuletzt gemeldete Reproduktionszahl für Deutschland in den Berichten aufgeführt. Diese beschreibt die Anzahl der Menschen, die eine infizierte Person im Durchschnitt ansteckt und ist entscheidend für den Verlauf der Pandemie. 10 | • Verbesserte Statistik 🤓 Mit einem Aufruf von /statistik kann ein Ranking der Top-10 abonnierten Orte und die aktuelle Nutzer:innenzahl abgerufen werden. 11 | • Mehr Informationen ℹ️ Mit dem Befehl /info erhält man nun eine kurze Erläuterung, was die wichtigsten Werte bedeuten und wo man mehr Informationen erhält. 12 | 13 | 🔮 WAS NOCH? SAG DU ES UNS! 💬 14 | Wir haben noch viele Ideen für die Weiterentwicklung. Hast Du auch noch Vorschläge? Wir freuen uns sehr über Feedback – egal ob Lob, Kritik, Fehler, Anregungen oder Dank. Sende dein Feedback einfach als Nachricht an den Bot! 15 | 16 | 🙏 EIN DANKESCHÖN UND EIN WENIG POLITIK ✊ 17 | Dieser Bot wäre nicht möglich ohne die vielen tollen Open-Source-Projekte, die wir verwenden. Vielen Dank dafür! Es wäre aber auch nicht möglich, wenn die Daten nicht frei und maschinenlesbar zur Verfügung ständen. Damit ehrenamtliche Projekte wie dieses funktionieren, ist es wichtig, dass öffentliche Einrichtungen ihre Daten grundsätzlich frei zur Verfügung stellen! 18 | 19 | 🧒🏽👦🏻 SHARING IS CARING 👩🏾🧑🏼 20 | Und als Letztes: Wir würden uns sehr freuen, wenn Du den Bot an Deine Freund:innen weiterempfiehlst, damit er noch mehr interessierten Menschen helfen kann. Teile einfach den Link https://covidbot.d-64.org, dort erhalten sie alle relevanten Informationen. Vielen Dank! 🎉 21 | -------------------------------------------------------------------------------- /resources/2021-03-18-sorry-no-data.html: -------------------------------------------------------------------------------- 1 | Guten Tag, 2 | Leider hat das RKI Probleme beim Aktualisieren der maschinenlesbaren Schnittstelle, die von unserem Bot sowie auch vom RKI Dashboard genutzt wird. 3 | Laut Tagesschau.de (https://www.tagesschau.de/inland/rki-zahlen-inzidenz-103.html) wurden heute 17.500 Neuinfektionen gemeldet, die bundesweite 7-Tage-Inzidenz steigt auf 90. Das ist eine Steigerung von ca. 3000 Neuinfektionen zur Vorwoche. 4 | Wir melden uns mit einem personalisierten Bericht bei dir, sobald uns die Daten vorliegen. 5 | Viele Grüße aus dem Maschinenraum! -------------------------------------------------------------------------------- /resources/2021-03-newsletter.html: -------------------------------------------------------------------------------- 1 | Guten Abend! 2 | Unseren Bot nutzen mittlerweile deutlich über 3500 Menschen 🥳 und wir haben in den letzten Wochen fleißig weiter an neuen Features, der Stabilität und neuen Plattformen gearbeitet! 3 | 4 | Vielen Dank für deine Unterstützung! Hier ein Überblick über die Entwicklungen der letzten Wochen: 5 | 6 | 🛠️ NEUE FEATURES 7 | • Neue Grafiken 📈 Wir haben unsere Grafiken grundlegend überarbeitet. Zusätzlich bieten wir nun auch Grafiken zu den Impfungen und der Inzidenz an. Die Inzidenzgrafik wird dir angezeigt, wenn du die Daten für einen Ort anforderst, also bspw. mit {Daten Hannover} 8 | • Twitter, Mastodon & Instagram 💬 Unser Bot ist jetzt auch auf Twitter, Mastodon und Instagram vertreten. Die Links findest du auf https://covidbot.d-64.org 9 | • Intensivdaten 🏥️ Die Daten über die Intensivbettenbelegung sind jetzt in den Detailberichten für verschiedene Orte verfügbar 10 | • Impfübersicht 💉 Die Detailberichte der Bundesländer enthalten nun Informationen über den jeweiligen Fortschritt der Impfungen und mit dem Befehl {Impfungen} bekommst du einen detaillierten Impfbericht für ganz Deutschland 11 | • Regeln 👆 Für den groben Überblick, was gerade erlaubt ist oder nicht, stellen wir jetzt die Regeln für die Bundesländer zur Verfügung! Mit dem Kommando {Regeln} und deinem Ort, also bspw. mit {Regeln Hannover}, erhältst du einen Überblick über die geltenden Verordnungen sowie weiterführende Links 12 | 13 | 🙏 WIR BRAUCHEN DEINE HILFE! 14 | Wir wollen sehr gerne den täglichen Bericht verbessern! Dafür benötigen wir dein Feedback: Würdest du ihn gerne seltener erhalten? Welche Informationen und Grafiken interessieren dich? Wir überlegen derzeit, verschiedene Optionen anzubieten, damit alle von uns das bekommen, was sie wollen! Wir würden uns sehr freuen, wenn du an dieser kurzen Umfrage teilnehmen könntest: https://d-64.org/umfrage/d64-covidbot-nutzungsumfrage/ 15 | 16 | 🔮 WAS NOCH? SAG DU ES UNS! 💬 17 | Hast Du noch Vorschläge? Wir freuen uns sehr über Feedback – egal ob Lob, Kritik, Fehler, Anregungen oder Dank. Sende dein Feedback einfach als Nachricht an den Bot! 18 | 19 | 🙏 EIN DANKESCHÖN UND EIN WENIG POLITIK ✊ 20 | Dieser Bot wäre nicht möglich ohne die vielen tollen Open-Source-Projekte, die wir verwenden. Vielen Dank dafür! Es wäre aber auch nicht möglich, wenn die Daten nicht frei und maschinenlesbar zur Verfügung ständen. Damit ehrenamtliche Projekte wie dieses funktionieren, ist es wichtig, dass öffentliche Einrichtungen ihre Daten grundsätzlich frei zur Verfügung stellen! 21 | 22 | 🧒🏽👦🏻 SHARING IS CARING 👩🏾🧑🏼 23 | Und als Letztes: Wir würden uns sehr freuen, wenn Du den Bot an Deine Freund:innen weiterempfiehlst, damit er noch mehr interessierten Menschen helfen kann. Teile einfach den Link https://covidbot.d-64.org, dort erhalten sie alle relevanten Informationen. Vielen Dank! 🎉 24 | -------------------------------------------------------------------------------- /resources/2021-05-newsletter.html: -------------------------------------------------------------------------------- 1 | Guten Abend! 2 | Unseren Bot nutzen mittlerweile 9500 Menschen 🥳 und wir haben in der letzten Zeit fleißig weiter an neuen Features und der Stabilität gearbeitet. 3 | 4 | Hier ein Überblick über die Entwicklungen der letzten Wochen: 5 | 6 | 🆕 BESSERE TÄGLICHE BERICHTE 7 | • Übersichtlicher 📁 Wir haben den Bericht grundlegend überarbeitet, sodass man alle Informationen besser überblickt, die neue Version kommt ab morgen. Vielen Dank an dieser Stelle an alle Beta-Tester:innen! 8 | • Einstellungen ⚙️ Der Bericht ist nun konfigurierbar. Unter {Einstellungen} gibt es einen Überblick, was man nun ein- und ausblenden kann. 9 | • Weitere Berichte 💉 🏥 Berichte zu Impfungen und der Intensivbettenlage️ können nun separat aktiviert werden. Du erhältst dann direkt zur Veröffentlichung der Daten die Infos für deine Orte. Sende {Berichte}, um die jeweiligen Berichte zu verwalten. 10 | 11 | 🛠 NEUE FEATURES 12 | • Historie 📜 Mit dem neuen Befehl {Historie ORT}, also bspw. {Historie Berlin} bekommst du einen Überblick über den bisherigen Verlauf der Pandemie. 13 | • Daten ohne Meldeverzug 👌 Wir pflegen nun die korrigierten Daten des RKIs ein, sodass die Grafiken und Rückblicke akkurat sind. 14 | • Impfungen nach Bundesland 💉 Mit dem Befehl {Impfungen ORT}, also bspw. {Impfungen Hessen}, bekommst du nun auch Daten zur Impflage in den einzelnen Bundesländern. 15 | • Verbesserte Grafiken 📊 Wir haben die Grafiken optimiert und wollen so eine bessere Verständlichkeit für die Bundesnotbremse erreichen. 16 | • Neuer Server & Feedbacktool 👨‍💻 Auch im Hintergrund haben wir ein bisschen herumgeschraubt, damit alles besser läuft. 17 | • Bessere Hilfe 🤷 Der Bot bekommt immer mehr Funktionen die man kaum noch alle überblicken kann: Deshalb haben wir den Hilfetext verbessert und den Start mit dem Bot überarbeitet: Nach der ersten Nachricht an den Bot - oder wenn man {Start} sendet - wird man nun besser in die Benutzung eingeführt. Außerdem bekommt man nun mit {Hilfe} einen knappen Überblick über die wichtigsten Funktionen. 18 | 19 | 🔮 WAS NOCH? SAG DU ES UNS! 💬 20 | Hast Du noch Vorschläge? Wir freuen uns sehr über Feedback – egal ob Lob, Kritik, Fehler, Anregungen oder Dank. Sende dein Feedback einfach als Nachricht an den Bot! 21 | 22 | 🙏 EIN DANKESCHÖN UND EIN WENIG POLITIK ✊ 23 | Dieser Bot wäre nicht möglich ohne die vielen tollen Open-Source-Projekte, die wir verwenden. Vielen Dank dafür! Es wäre aber auch nicht möglich, wenn die Daten nicht frei und maschinenlesbar zur Verfügung ständen. Damit ehrenamtliche Projekte wie dieses funktionieren, ist es wichtig, dass öffentliche Einrichtungen ihre Daten grundsätzlich frei zur Verfügung stellen! 24 | 25 | 🧒🏽👦🏻 SHARING IS CARING 👩🏾🧑🏼 26 | Und als Letztes: Wir würden uns sehr freuen, wenn Du den Bot an Deine Freund:innen weiterempfiehlst, damit er noch mehr interessierten Menschen helfen kann. Teile einfach den Link https://covidbot.d-64.org, dort erhalten sie alle relevanten Informationen. Vielen Dank! 🎉 27 | -------------------------------------------------------------------------------- /resources/2021-06-newsletter.html: -------------------------------------------------------------------------------- 1 | Guten Abend! 2 | Die Sonne ist warm, das Eis kalt und die Infektionszahlen sind niedrig. ☀️ 3 | 4 | Um den Covidbot diesen neuen Bedingungen anzupassen, haben wir einen Pause-Modus 😴 eingebaut: 5 | Wenn du {Sleep} an den Bot sendest, wird die tägliche Versendung deines Infektionsberichts pausiert, solange die Inzidenz in allen von dir abonnierten Orten unter 10 liegt. 6 | 7 | Außerdem wollen wir dich noch auf den Impfbericht 💉 aufmerksam machen: Hiermit erhältst du direkt nach der (täglichen) Veröffentlichung der Impfdaten eine Übersicht über den Fortschritt in den Bundesländern der von dir abonnierten Orte. Sende {Berichte}, um die jeweiligen Berichte zu verwalten. 8 | 9 | ✊ MITMACHEN UND PROGRESSIVE DIGITALPOLITIK GESTALTEN 📱 10 | Dieser Bot ist ein Projekt von D64 - Zentrum für digitale Fortschritt. Unser Ziel ist es, die Grundwerte Freiheit, Gerechtigkeit und Solidarität durch eine progressive Digitalpolitik zu verwirklichen. Dafür wirken wir mit Hilfe der breitgefächerten Expertise unserer Mitglieder als unabhängiger Verein, der in allen Themenbereichen der Digitalisierung vordenkt und Impulse gibt. 11 | Wir würden uns freuen, wenn du Lust hättest, bei uns mitzuarbeiten! Weitere Information zu unseren Arbeitsgemeinschaften (und den Link zum Beitrittsformular) findest du unter: https://d-64.org/verein/unsere-arbeitsgruppen 12 | -------------------------------------------------------------------------------- /resources/2021-08-newsletter.html: -------------------------------------------------------------------------------- 1 | Hallo! 2 | Ab und an ist eine (Sommer-)Pause wichtig, dieses Mal hat sich der Bot aber doch schneller erholt als geplant. 🎉 3 | 4 | 🟢 TÄGLICHER BERICHT UND GEPLANTE NEUERUNGEN 🗞 5 | Wir sind wieder online und bereit. Ab morgen gibt es den üblichen täglichen Bericht (und der Bot antwortet auch wieder auf alle Anfragen). Wir werden uns außerdem in den nächsten Wochen bemühen, dass neue Werte - wie bspw. die Hospitalisierungsrate oder Differenzierungen bei den Ansteckungen zwischen Geimpften und Nicht-Geimpften - auch im Bericht berücksichtigt werden. Das gilt insbesondere, sobald sich rechtliche Maßnahmen hieran orientieren. 6 | 7 | Habt noch einen schönen Sommer, bleibt gesund! 8 | 9 | ✊ MITMACHEN UND PROGRESSIVE DIGITALPOLITIK GESTALTEN 📱 10 | Dieser Bot ist ein Projekt von D64 - Zentrum für digitale Fortschritt. Unser Ziel ist es, die Grundwerte Freiheit, Gerechtigkeit und Solidarität durch eine progressive Digitalpolitik zu verwirklichen. Dafür wirken wir mit Hilfe der breitgefächerten Expertise unserer Mitglieder als unabhängiger Verein, der in allen Themenbereichen der Digitalisierung vordenkt und Impulse gibt. 11 | Wir würden uns freuen, wenn du Lust hättest, bei uns mitzuarbeiten! Weitere Information zu unseren Arbeitsgemeinschaften (und den Link zum Beitrittsformular) findest du unter: https://d-64.org/verein/unsere-arbeitsgruppen 12 | -------------------------------------------------------------------------------- /resources/2021-11-newsletter.html: -------------------------------------------------------------------------------- 1 | Guten Abend! 2 | 3 | Unsere Hoffnung war, dass der Covidbot nach diesem Sommer nicht mehr relevant sein wird. Doch leider ist das Gegenteil der Fall: Die Inzidenzen steigen munter weiter, die Lage ist äußerst kritisch. 4 | Im Frühjahr haben wir einen einfachen Plausibilitätscheck für die Daten einprogrammiert, um Fehlaussendungen zu vermeiden: Meldet das RKI über 100.000 Neuinfektionen, verwirft unser Bot die Daten und sendet dies als Fehler an das Entwicklerteam. 5 | Diesen Plausibilitätscheck mussten wir nun entfernen und nehmen das als Anlass für ein kleines, allgemeines Update der Änderungen der letzten Wochen: 6 | 7 | • Boosterimpfungen 💉 Wir stellen die Daten über die Auffrischungsimpfungen jetzt in Grafiken und den Berichten dar. 8 | • Hospitalisierungen 🏥 Der Bericht enthält nun auch die Hospitalisierungsdaten. Aufgrund verschiedener Meldesysteme ist die Datenlage hier aber nicht optimal, es kann daher regional zu erheblichen Abweichungen zwischen den Veröffentlichungen der lokalen Behörden und den RKI-Daten, die wir beziehen, kommen. 9 | • Wöchentlicher Bericht 📜 Es ist nun möglich, statt einem täglichen einen wöchentlichen Bericht zu erhalten. Dies kann in den Einstellungen geändert werden. Sende dafür {Einstellungen} an den Bot. 10 | • Kleinere Verbesserungen Wir haben ein paar Fehler behoben und dafür gesorgt, dass die Achsenbeschriftungen an den Grafiken auch bei den hohen Inzidenzen lesbar bleiben. 11 | 12 | Außerdem gibt es öfter die Nachfrage, ob wir die Inzidenz unterschiedlich nach dem Impfstatus ausweisen können. Dies ist derzeit leider nicht möglich, da dafür nicht alle Bundesländer Daten über eine einheitliche Schnittstelle bereitstellen. 13 | 14 | Komm gesund durch diesen Winter! 15 | 16 | 🔮 WAS NOCH? SAG DU ES UNS! 💬 17 | Hast Du noch Vorschläge? Wir freuen uns sehr über Feedback – egal ob Lob, Kritik, Fehler, Anregungen oder Dank. Sende dein Feedback einfach als Nachricht an den Bot! 18 | 19 | 🙏 EIN DANKESCHÖN UND EIN WENIG POLITIK ✊ 20 | Dieser Bot wäre nicht möglich ohne die vielen tollen Open-Source-Projekte, die wir verwenden. Vielen Dank dafür! Es wäre aber auch nicht möglich, wenn die Daten nicht frei und maschinenlesbar zur Verfügung ständen. Damit ehrenamtliche Projekte wie dieses funktionieren, ist es wichtig, dass öffentliche Einrichtungen ihre Daten grundsätzlich frei zur Verfügung stellen! 21 | 22 | ✊ MITMACHEN UND PROGRESSIVE DIGITALPOLITIK GESTALTEN 📱 23 | Dieser Bot ist ein Projekt von D64 - Zentrum für digitale Fortschritt. Unser Ziel ist es, die Grundwerte Freiheit, Gerechtigkeit und Solidarität durch eine progressive Digitalpolitik zu verwirklichen. Dafür wirken wir mit Hilfe der breitgefächerten Expertise unserer Mitglieder als unabhängiger Verein, der in allen Themenbereichen der Digitalisierung vordenkt und Impulse gibt. 24 | Wir würden uns freuen, wenn du Lust hättest, bei uns mitzuarbeiten! Weitere Information zu unseren Arbeitsgemeinschaften (und den Link zum Beitrittsformular) findest du unter: https://d-64.org/verein/unsere-arbeitsgruppen 25 | 26 | 🧒🏽👦🏻 SHARING IS CARING 👩🏾🧑🏼 27 | Und als Letztes: Wir würden uns sehr freuen, wenn Du den Bot an Deine Freund:innen weiterempfiehlst, damit er noch mehr interessierten Menschen helfen kann. Teile einfach den Link https://covidbot.d-64.org, dort erhalten sie alle relevanten Informationen. Vielen Dank! 🎉 28 | -------------------------------------------------------------------------------- /resources/2021-12-09-correction.html: -------------------------------------------------------------------------------- 1 | Guten Morgen! 2 | Da hätten wir den Plausibilitätscheck wohl doch nicht entfernen sollen - leider meldet das RKI heute dieselben Daten wie gestern. Wir melden uns mit einem korrigierten Bericht, sobald der Fehler behoben ist! -------------------------------------------------------------------------------- /resources/2023-04-standby.html: -------------------------------------------------------------------------------- 1 | Guten Abend! 2 | 3 | Wir versetzen den Covidbot heute in den Standby-Modus. 4 | 5 | Leider nimmt die Qualität - und damit auch die Relevanz - der Daten zunehmend ab, weil immer weniger getestet und statistisch erfasst wird. Dazu kommt die technische Wartung, die mehr Kapazitäten in Anspruch nimmt, als wir aktuell leisten können und wollen. 6 | 7 | Danke, dass du den Covidbot genutzt hast! 8 | 9 | ❔ WELCHE FOLGEN HAT DER STANDBY MODUS? 🧐 10 | 11 | Abschalten des täglichen Berichts 📜 12 | Auf Grund der sinkenden Bedeutung der RKI-Zahlen haben wir uns dafür entschieden, den täglichen Bericht zu deaktivieren. Der Bericht kann jederzeit manuell über den Befehl {Bericht} angefordert werden, die Zahlen bleiben auch - solange die von uns genutzte Schnittstelle aktiv bleibt - weiter aktuell. Du kannst den automatischen Bericht auch wieder aktivieren, folge dazu den Einstellungen, die du über den Befehl {Berichte} erhältst. 13 | 14 | Impfdaten 💉 15 | Die Schnittstelle, über die wir die Daten über die COVID-19 Impfungen erhalten habe, wurde vorübergehend eingestellt. Von daher können wir über den Bot keine aktuellen Daten mehr übermitteln. 16 | 17 | ✉️ FEEDBACK 💬 18 | Hast du zum Abschluss noch Kommentare oder Fragen? Sende es einfach als Nachricht an den Bot! 19 | 20 | 🙏 EIN DANKESCHÖN UND EIN WENIG POLITIK ✊ 21 | Dieser Bot wäre nicht möglich ohne die vielen tollen Open-Source-Projekte, die wir verwenden. Vielen Dank dafür! Es wäre aber auch nicht möglich, wenn die Daten nicht frei und maschinenlesbar zur Verfügung ständen. Damit ehrenamtliche Projekte wie dieses funktionieren, ist es wichtig, dass öffentliche Einrichtungen ihre Daten grundsätzlich frei zur Verfügung stellen! 22 | 23 | ✊ MITMACHEN UND PROGRESSIVE DIGITALPOLITIK GESTALTEN 📱 24 | Dieser Bot war - und ist - ein Projekt von D64 - Zentrum für digitale Fortschritt. Unser Ziel ist es, die Grundwerte Freiheit, Gerechtigkeit und Solidarität durch eine progressive Digitalpolitik zu verwirklichen. Dafür wirken wir mit Hilfe der breitgefächerten Expertise unserer Mitglieder als unabhängiger Verein, der in allen Themenbereichen der Digitalisierung vordenkt und Impulse gibt. 25 | Wir würden uns freuen, wenn du Lust hättest, bei uns mitzuarbeiten! Weitere Information zu unseren Arbeitsgemeinschaften (und den Link zum Beitrittsformular) findest du unter: https://d-64.org/verein/unsere-arbeitsgruppen 26 | -------------------------------------------------------------------------------- /resources/2023-08-end.html: -------------------------------------------------------------------------------- 1 | Guten Abend! 2 | 3 | Wir schalten den Covidbot heute vollständig ab. 4 | 5 | Vor vier Monaten haben wir ihn einen Standby versetzt. Seitdem wurden jedoch weitere Datenquellen abgeschaltet, sodass der Bot kaum noch relevante Zahlen liefern kann. Dazu kommt die technische Wartung, die mehr Kapazitäten in Anspruch nimmt, als wir aktuell leisten können und wollen. 6 | 7 | Danke, dass du den Covidbot genutzt hast! 8 | 9 | ✉️ FEEDBACK 💬 10 | Hast du zum Abschluss noch Kommentare oder Fragen? Du kannst uns über die Mailadresse auf unserer Homepage weiter erreichen: https://covidbot.d-64.org 11 | 12 | 🙏 EIN DANKESCHÖN UND EIN WENIG POLITIK ✊ 13 | Dieser Bot wäre nicht möglich ohne die vielen tollen Open-Source-Projekte, die wir verwenden. Vielen Dank dafür! Es wäre aber auch nicht möglich, wenn die Daten nicht frei und maschinenlesbar zur Verfügung ständen. Damit ehrenamtliche Projekte wie dieses funktionieren, ist es wichtig, dass öffentliche Einrichtungen ihre Daten grundsätzlich frei zur Verfügung stellen! 14 | Den Quellcode für diesen Bot haben wir auch unter https://github.com/eknoes/covidbot veröffentlicht. 15 | 16 | ✊ MITMACHEN UND PROGRESSIVE DIGITALPOLITIK GESTALTEN 📱 17 | Dieser Bot war ein Projekt von D64 - Zentrum für digitale Fortschritt. Unser Ziel ist es, die Grundwerte Freiheit, Gerechtigkeit und Solidarität durch eine progressive Digitalpolitik zu verwirklichen. Dafür wirken wir mit Hilfe der breitgefächerten Expertise unserer Mitglieder als unabhängiger Verein, der in allen Themenbereichen der Digitalisierung vordenkt und Impulse gibt. 18 | Wir würden uns freuen, wenn du Lust hättest, bei uns mitzuarbeiten! Weitere Information zu unseren Arbeitsgemeinschaften (und den Link zum Beitrittsformular) findest du unter: https://d-64.org/verein/unsere-arbeitsgruppen 19 | -------------------------------------------------------------------------------- /resources/cloud-init: -------------------------------------------------------------------------------- 1 | #cloud-config 2 | users: 3 | - name: bot 4 | primary_group: bot 5 | groups: users, sudo 6 | sudo: ALL=(ALL) NOPASSWD:ALL 7 | shell: /bin/bash 8 | ssh_authorized_keys: 9 | - ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAABAQCvpSt2mS1aocFOqEW/zOO7cD1ZTg0FTAe9IiVv/r6exkw2uKRtdu/W/AWfvmVWB2wdAdFUoOGZteeUv3PeDCRUDHdNF1akLvlLrwWpVsFvxjIIzAdpDYFxFFcpUmLpWV/XQIXCtFnfVCXsb0pk6cmuspBP28K3TuvlUagm7TMSXBqbWrvVoc3y7UtJU4+ry+MOTNuIgDxfMP+PuBw3rxq7cCsSFVmchL6IYvLuX/TeAnCrAx5n/LjFp+xsCjamQImPYsDKs7HT5mhOPUErOclMll0XQ/XKibKHb6FX7vga0kaptbpMmBdw0MsEJQ4rdsHevvTbQpAYUParS2r5ddnj me@eknoes.de 10 | package_update: true 11 | package_upgrade: true 12 | packages: 13 | - mariadb-server 14 | - nginx 15 | - php-fpm 16 | - php-mysql 17 | - python3-venv 18 | - git 19 | - ufw 20 | - default-jre 21 | - make 22 | 23 | runcmd: 24 | - ufw allow OpenSSH 25 | - ufw allow "Nginx Full" 26 | - ufw enable 27 | - sed -i -e '/^\(#\|\)PermitRootLogin/s/^.*$/PermitRootLogin no/' /etc/ssh/sshd_config 28 | - sed -i -e '/^\(#\|\)PasswordAuthentication/s/^.*$/PasswordAuthentication no/' /etc/ssh/sshd_config 29 | - sed -i -e '/^\(#\|\)X11Forwarding/s/^.*$/X11Forwarding no/' /etc/ssh/sshd_config 30 | - sed -i -e '/^\(#\|\)AllowTcpForwarding/s/^.*$/AllowTcpForwarding no/' /etc/ssh/sshd_config 31 | - sed -i -e '/^\(#\|\)AllowAgentForwarding/s/^.*$/AllowAgentForwarding no/' /etc/ssh/sshd_config 32 | - sed -i -e '/^\(#\|\)AuthorizedKeysFile/s/^.*$/AuthorizedKeysFile .ssh\/authorized_keys/' /etc/ssh/sshd_config 33 | - sed -i '$a AllowUsers bot root' /etc/ssh/sshd_config 34 | - locale-gen de_DE 35 | - locale-gen de_DE.UTF-8 36 | - update-locale 37 | - sudo -u bot git clone https://gitlab.com/signald/signald.git /home/bot/signald 38 | - sudo -u bot git clone https://github.com/eknoes/covidbot.git /home/bot/covidbot 39 | - reboot now 40 | -------------------------------------------------------------------------------- /resources/config.default.ini: -------------------------------------------------------------------------------- 1 | [GENERAL] 2 | CACHE_DIR = graphics 3 | 4 | [TELEGRAM] 5 | API_KEY = TOKEN 6 | DEV_CHAT = CHAT_ID 7 | 8 | [SIGNAL] 9 | PHONE_NUMBER = BOT_PHONE 10 | SIGNALD_SOCKET = resources/signald.sock 11 | DEV_CHAT = DEV_PHONE 12 | 13 | [THREEMA] 14 | ID = BOT_THREEMA_ID 15 | PRIVATE_KEY = BOT_PK 16 | SECRET = THREEMA_SECRET 17 | DEV_CHAT = DEV_THREEMA_ID 18 | 19 | [DATABASE] 20 | HOST = localhost 21 | PORT = 3306 22 | USER = user 23 | PASSWORD = password 24 | DATABASE = database -------------------------------------------------------------------------------- /resources/config.unittest.ini: -------------------------------------------------------------------------------- 1 | [GENERAL] 2 | CACHE_DIR = graphics 3 | LOGS_DIR = logs 4 | 5 | [DATABASE] 6 | HOST = localhost 7 | PORT = 3307 8 | USER = root 9 | PASSWORD = covid_bot 10 | DATABASE = covid_test_db -------------------------------------------------------------------------------- /resources/d64-logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/eknoes/covidbot/565450e6ab558d5e91b4fcec1d161628d56fe865/resources/d64-logo.png -------------------------------------------------------------------------------- /resources/db-migration.sql: -------------------------------------------------------------------------------- 1 | alter table covid_data 2 | add last_update DATETIME default NOW() null; 3 | 4 | alter table bot_user_sent_reports add report VARCHAR(40); 5 | UPDATE bot_user_sent_reports SET report='cases-germany' WHERE report IS NULL; 6 | 7 | alter table bot_user change added created datetime(6) default current_timestamp(6) not null; 8 | 9 | DROP VIEW covid_data_calculated; 10 | 11 | INSERT IGNORE INTO report_subscriptions (user_id, report) SELECT user_id, 'cases-germany' FROM bot_user -------------------------------------------------------------------------------- /resources/error-disabled-user-per-day.md: -------------------------------------------------------------------------------- 1 | # Disabled users per day 2 | 3 | SQL Query: 4 | ```sql 5 | SELECT COUNT(platform_id), platform, DATE(reports.d) FROM bot_user LEFT JOIN (SELECT user_id, MAX(sent_report) as d FROM bot_user_sent_reports GROUP BY user_id) as reports ON reports.user_id = bot_user.user_id WHERE activated=0 GROUP BY platform, DATE(reports.d) ORDER BY DATE(reports.d) 6 | ``` 7 | 8 | | COUNT\(platform\_id\) | platform | DATE\(reports.d\) | 9 | | :--- | :--- | :--- | 10 | | 530 | signal | 2021-08-10 | 11 | | 40 | messenger | 2021-08-10 | 12 | | 3 | signal | 2021-08-11 | 13 | | 1 | signal | 2021-08-12 | 14 | | 2 | signal | 2021-08-15 | 15 | | 1 | signal | 2021-08-16 | 16 | | 1 | signal | 2021-08-17 | 17 | | 3 | signal | 2021-08-19 | 18 | | 2 | signal | 2021-08-20 | 19 | | 1 | messenger | 2021-08-21 | 20 | | 1 | signal | 2021-08-22 | 21 | | 2 | signal | 2021-08-23 | 22 | | 2 | signal | 2021-08-24 | 23 | | 1 | signal | 2021-08-25 | 24 | | 1 | messenger | 2021-08-26 | 25 | | 1 | signal | 2021-08-29 | 26 | | 3 | signal | 2021-08-30 | 27 | | 1 | signal | 2021-08-31 | 28 | | 1 | signal | 2021-09-01 | 29 | | 2 | signal | 2021-09-02 | 30 | | 197 | messenger | 2021-09-02 | 31 | | 2 | signal | 2021-09-04 | 32 | | 2 | signal | 2021-09-07 | 33 | | 2 | signal | 2021-09-08 | 34 | | 1 | signal | 2021-09-09 | 35 | | 1 | signal | 2021-09-12 | 36 | | 1 | signal | 2021-09-14 | 37 | | 1 | signal | 2021-09-17 | 38 | | 1 | signal | 2021-09-18 | 39 | | 1 | signal | 2021-09-19 | 40 | | 1 | signal | 2021-09-20 | 41 | | 2 | signal | 2021-09-21 | 42 | | 1 | signal | 2021-09-22 | 43 | | 2 | signal | 2021-09-23 | 44 | | 1 | signal | 2021-09-24 | 45 | | 1 | messenger | 2021-09-26 | 46 | | 367 | signal | 2021-09-26 | 47 | | 290 | signal | 2021-09-27 | 48 | | 1 | signal | 2021-09-29 | 49 | -------------------------------------------------------------------------------- /resources/feedback-templates/base.jinja2: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | Covidbot Feedback 5 | 6 | 7 | 8 | 9 | 10 | 11 |

12 | 31 | 32 |
33 | {% block messagelist %} 34 | {% endblock %} 35 |
36 | 37 |
38 | 86 |
87 |
88 | 89 | 90 | 145 | -------------------------------------------------------------------------------- /resources/feedback-templates/single.jinja2: -------------------------------------------------------------------------------- 1 | {% extends "base.jinja2" %} 2 | {% block messagelist %} 3 | {% if not messagelist %} 4 |
5 |
6 | 7 | 8 | 11 |
12 |
13 | 14 | {% endif %} 15 | {% for item in messagelist %} 16 |
17 | 18 |
19 | 20 | 21 | 24 |
25 |
26 |
27 | {% endfor %} 28 | {% endblock %} 29 | {% block messageheader %} 30 | {% if user %} 31 |

{{ user.platform | capitalize() }}nutzer:in #{{ user.user_id }}

32 |

33 | Über {{ user.platform | capitalize() }} am {{ user.last_communication_str() }} Uhr 34 |

35 |

{{ user.get_tags_html() | safe }}

36 | {% endif %} 37 | {% endblock %} 38 | {% block messagecontrols %} 39 | {% if user %} 40 |
41 | 42 |
43 | {% for t in available_tags %} 44 | {% if not t in user.tags %} 45 | 48 | {% else %} 49 | 52 | {% endif %} 53 | {% endfor %} 54 |
55 |
56 | {% if user.state().value == "unread" %} 57 | 58 | {% else %} 59 | 60 | {% endif %} 61 |
62 |
63 | {% endif %} 64 | {% endblock %} 65 | {% block messages %} 66 | {% if not user %} 67 |

Keine Nachrichten vorhanden

68 | {% endif %} 69 | {% for message in user.messages %} 70 |
71 |

{{ message.message | replace("\n", "
") | safe }}

72 |

{{ message.meta_str() | safe }}

73 |
74 | {% endfor %} 75 | {% endblock %} 76 | {% block replyform %} 77 | {% if user %} 78 |
79 | 80 | 81 |
82 | 83 | 87 | 88 |
89 |
90 | {% endif %} 91 | {% endblock %} 92 | {% block subscriptions %} 93 |
Abos
94 | {% for s in user_subs %} 95 |

{{ s }}

96 | {% endfor %} 97 | {% endblock %} 98 | {% block reports %} 99 |
Berichte
100 | {% for s in user_reports %} 101 |

{{ s | capitalize() }}

102 | {% endfor %} 103 | {% endblock %} -------------------------------------------------------------------------------- /resources/feedback-templates/static/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/eknoes/covidbot/565450e6ab558d5e91b4fcec1d161628d56fe865/resources/feedback-templates/static/favicon.ico -------------------------------------------------------------------------------- /resources/feedback-templates/static/logo.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/eknoes/covidbot/565450e6ab558d5e91b4fcec1d161628d56fe865/resources/feedback-templates/static/logo.jpg -------------------------------------------------------------------------------- /resources/feedback-templates/static/style.css: -------------------------------------------------------------------------------- 1 | /* 2 | * -- BASE STYLES -- 3 | * Most of these are inherited from Base, but I want to change a few. 4 | */ 5 | body { 6 | color: #333; 7 | background: #EEEEEE; 8 | } 9 | 10 | 11 | 12 | a { 13 | text-decoration: none; 14 | color: #1b98f8; 15 | } 16 | 17 | 18 | /* 19 | * -- HELPER STYLES -- 20 | * Over-riding some of the .pure-button styles to make my buttons look unique 21 | */ 22 | .primary-button, 23 | .secondary-button { 24 | -webkit-box-shadow: none; 25 | -moz-box-shadow: none; 26 | box-shadow: none; 27 | border-radius: 20px; 28 | } 29 | .primary-button { 30 | color: #fff; 31 | background: #1b98f8; 32 | margin: 1em 0; 33 | } 34 | .secondary-button { 35 | background: #fff; 36 | border: 1px solid #ddd; 37 | color: #666; 38 | padding: 0.5em 2em; 39 | font-size: 80%; 40 | } 41 | 42 | /* 43 | * -- LAYOUT STYLES -- 44 | * This layout consists of three main elements, `#nav` (navigation bar), `#list` (email list), and `#main` (email content). All 3 elements are within `#layout` 45 | */ 46 | #layout, #nav, #list, #main { 47 | margin: 0; 48 | padding: 0; 49 | height: 100%; 50 | } 51 | 52 | #main { 53 | border-top: 1px solid #DDDDDD; 54 | } 55 | 56 | /* Make the navigation 100% width on phones */ 57 | #nav { 58 | width: 100%; 59 | height: 40px; 60 | position: relative; 61 | background: rgb(37, 42, 58); 62 | text-align: center; 63 | } 64 | /* Show the "Menu" button on phones */ 65 | #nav .nav-menu-button { 66 | display: block; 67 | top: 0.5em; 68 | right: 0.5em; 69 | position: absolute; 70 | } 71 | 72 | /* When "Menu" is clicked, the navbar should be 80% height */ 73 | #nav.active { 74 | height: 80%; 75 | } 76 | /* Don't show the navigation items... */ 77 | .nav-inner { 78 | display: none; 79 | } 80 | 81 | /* ...until the "Menu" button is clicked */ 82 | #nav.active .nav-inner { 83 | display: block; 84 | padding: 2em 0; 85 | } 86 | 87 | 88 | /* 89 | * -- NAV BAR STYLES -- 90 | * Styling the default .pure-menu to look a little more unique. 91 | */ 92 | #nav .pure-menu { 93 | background: transparent; 94 | border: none; 95 | text-align: left; 96 | } 97 | #nav .pure-menu-link:hover, 98 | #nav .pure-menu-link:focus { 99 | background: rgb(55, 60, 90); 100 | } 101 | #nav .pure-menu-link { 102 | color: #fff; 103 | margin-left: 0.5em; 104 | } 105 | #nav .pure-menu-heading { 106 | border-bottom: none; 107 | font-size:110%; 108 | color: rgb(75, 113, 151); 109 | } 110 | 111 | #nav .pure-menu-item { 112 | height: auto; 113 | } 114 | 115 | #nav .menu-item-current { 116 | background: #414470; 117 | } 118 | 119 | /* 120 | * -- EMAIL STYLES -- 121 | * Styles relevant to the email messages, labels, counts, and more. 122 | */ 123 | .email-count { 124 | color: rgb(75, 113, 151); 125 | } 126 | 127 | .ticket-color-tag { 128 | width: 15px; 129 | height: 15px; 130 | display: inline-block; 131 | margin-right: 0.5em; 132 | border-radius: 3px; 133 | } 134 | 135 | .ticket-tag-hilfe { 136 | background: #47A1B3; 137 | } 138 | .ticket-tag-idee { 139 | background: yellow; 140 | } 141 | .ticket-tag-bug { 142 | background: red; 143 | } 144 | .ticket-tag-lob { 145 | background: green; 146 | } 147 | .ticket-tag-sönke { 148 | background: palevioletred; 149 | } 150 | 151 | .ticket-tag-erik { 152 | background: brown; 153 | } 154 | 155 | button.add-ticket-tag-button, button.remove-ticket-tag-button { 156 | margin-bottom: 0.2em; 157 | min-width: 6em; 158 | max-width: 100%; 159 | } 160 | 161 | button.remove-ticket-tag-button { 162 | background: #EEEEEE; 163 | color: #999999; 164 | } 165 | 166 | button.remove-ticket-tag-button span.ticket-tag-remove { 167 | color: darkred; 168 | font-weight: bold; 169 | } 170 | 171 | /* Email Item Styles */ 172 | .email-item { 173 | padding: 0.9em 1em; 174 | border-bottom: 1px solid #ddd; 175 | border-left: 6px solid transparent; 176 | } 177 | 178 | .email-avatar { 179 | border-radius: 3px; 180 | margin-right: 0.5em; 181 | } 182 | 183 | .email-subject { 184 | margin: 0; 185 | color: #333333; 186 | } 187 | .email-name { 188 | margin: 0; 189 | text-transform: uppercase; 190 | color: #aaaaaa; 191 | } 192 | 193 | .email-item-selected .email-name { 194 | color: #EEEEEE; 195 | } 196 | .email-desc { 197 | font-size: 80%; 198 | margin: 0.4em 0; 199 | color: #333333; 200 | } 201 | 202 | .email-item-selected { 203 | background: #00b1e6; 204 | color: white; 205 | } 206 | .email-item-unread { 207 | border-left: 6px solid #1b98f8; 208 | } 209 | 210 | .email-item-answered { 211 | background: #DDDDDD; 212 | } 213 | 214 | /* Chat bubble styles */ 215 | .email-content-body .user-message { 216 | text-align: left; 217 | background: #00b1e6; 218 | color: white; 219 | } 220 | 221 | .email-content-body .admin-message { 222 | text-align: left; 223 | margin-left: 5em; 224 | background: #EEEEEE; 225 | } 226 | 227 | .chat-message { 228 | padding: 1em; 229 | border-radius: 15px; 230 | margin: 1em 0; 231 | } 232 | 233 | .reply-form { 234 | text-align: left; 235 | padding: 1em; 236 | } 237 | 238 | p.message-meta { 239 | font-size: 80%; 240 | } 241 | 242 | .admin-message p.message-meta { 243 | text-align: right; 244 | color: #999999; 245 | } 246 | 247 | .user-message p.message-meta { 248 | color: #DDDDDD; 249 | } 250 | /* Email Content Styles */ 251 | .email-content-header, .email-content-body, .email-content-footer { 252 | padding: 1em 2em; 253 | } 254 | .email-content-header { 255 | position: fixed; 256 | border-bottom: 1px solid #ddd; 257 | border-left: 1px solid #ddd; 258 | background: white; 259 | right: 0; 260 | } 261 | 262 | .email-content-footer { 263 | border-top: 1px solid #ddd; 264 | } 265 | 266 | .email-content-title { 267 | margin: 0; 268 | } 269 | .email-content-subtitle { 270 | font-size: 1em; 271 | margin: 0; 272 | font-weight: normal; 273 | } 274 | .email-content-subtitle span { 275 | color: #999; 276 | } 277 | 278 | .ticket-tags { 279 | margin: 0.5em 0; 280 | } 281 | 282 | .ticket-tags span.ticket-tag { 283 | margin-right: 1em; 284 | } 285 | 286 | .ticket-tags span.ticket-color-tag { 287 | margin: 0; 288 | } 289 | 290 | .email-content-controls .secondary-button { 291 | margin-bottom: 0.3em; 292 | } 293 | 294 | .email-avatar { 295 | width: 40px; 296 | height: 40px; 297 | } 298 | 299 | 300 | 301 | @media (max-width: 40em) { 302 | div#list, div#main { 303 | overflow-y: scroll; 304 | } 305 | 306 | div#main { 307 | background: white; 308 | } 309 | 310 | .email-content-header { 311 | position: relative; 312 | } 313 | } 314 | 315 | /* 316 | * -- TABLET (AND UP) MEDIA QUERIES -- 317 | * On tablets and other medium-sized devices, we want to customize some 318 | * of the mobile styles. 319 | */ 320 | @media (min-width: 40em) { 321 | 322 | /* Move the layout over so we can fit the nav + list in on the left */ 323 | #layout { 324 | padding-left:500px; /* "left col (nav + list)" width */ 325 | position: relative; 326 | } 327 | 328 | /* These are position:fixed; elements that will be in the left 500px of the screen */ 329 | #nav, #list { 330 | position: fixed; 331 | top: 0; 332 | bottom: 0; 333 | overflow: auto; 334 | } 335 | #nav { 336 | margin-left:-500px; /* "left col (nav + list)" width */ 337 | width:150px; 338 | height: 100%; 339 | } 340 | 341 | /* Show the menu items on the larger screen */ 342 | .nav-inner { 343 | display: block; 344 | padding: 2em 0; 345 | } 346 | 347 | /* Hide the "Menu" button on larger screens */ 348 | #nav .nav-menu-button { 349 | display: none; 350 | } 351 | 352 | #list { 353 | margin-left: -350px; 354 | width: 100%; 355 | height: 33%; 356 | border-bottom: 1px solid #ddd; 357 | background: white; 358 | } 359 | 360 | #main { 361 | position: fixed; 362 | top: 33%; 363 | right: 0; 364 | bottom: 0; 365 | left: 150px; 366 | overflow: auto; 367 | width: auto; /* so that it's not 100% */ 368 | background: white; 369 | height: 100%; 370 | } 371 | 372 | .email-content-header { 373 | width: 100%; 374 | position: relative; 375 | } 376 | 377 | } 378 | 379 | /* 380 | * -- DESKTOP (AND UP) MEDIA QUERIES -- 381 | * On desktops and other large-sized devices, we want to customize some 382 | * of the mobile styles. 383 | */ 384 | @media (min-width: 60em) { 385 | 386 | /* This will take up the entire height, and be a little thinner */ 387 | #list { 388 | margin-left: -350px; 389 | width:350px; 390 | height: 100%; 391 | border-right: 1px solid #ddd; 392 | } 393 | 394 | /* This will now take up it's own column, so don't need position: fixed; */ 395 | #main { 396 | position: static; 397 | margin: 0; 398 | padding: 0; 399 | width: 100%; 400 | border: none; 401 | } 402 | 403 | .email-content-header { 404 | width: auto; 405 | } 406 | 407 | .reply-form { 408 | margin-left: 5em; 409 | } 410 | } 411 | -------------------------------------------------------------------------------- /resources/logo-300-dpi.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/eknoes/covidbot/565450e6ab558d5e91b4fcec1d161628d56fe865/resources/logo-300-dpi.png -------------------------------------------------------------------------------- /resources/logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/eknoes/covidbot/565450e6ab558d5e91b4fcec1d161628d56fe865/resources/logo.png -------------------------------------------------------------------------------- /resources/matrix-generate-access-token.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | if [ $# -ne 3 ]; then 4 | echo "Usage: matrix-generate-access-token " 5 | exit -1 6 | fi 7 | 8 | api_url=$(curl $3/.well-known/matrix/client | jq --raw-output '."m.homeserver".base_url') 9 | echo "Querying $api_url" 10 | response=$(curl -d '{"type":"m.login.password", "user":"'$1'", "password":"'$2'", "initial_device_display_name": "Covidbot Interface"}' "$api_url/_matrix/client/v3/login") 11 | 12 | echo "ACCESS_TOKEN=$(echo $response | jq --raw-output '.access_token') 13 | DEVICE_ID=$(echo $response | jq --raw-output '.device_id')" -------------------------------------------------------------------------------- /resources/signal-no2uuid.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/bash 2 | signald() { 3 | echo "$1" | timeout 3 nc -U /var/run/signald/signald.sock | jq 'select(.type != "version")' 4 | } 5 | 6 | if [ -z "$1" ] 7 | then 8 | echo "Please provide the users number as argument in international format"; 9 | exit 1; 10 | fi 11 | 12 | uuid=$(signald "{\"account\": \"+4915792453845\", \"address\": { \"number\": \"$1\" }, \"type\": \"get_identities\", \"version\": \"v1\" }" | jq -rc '.data.address.uuid'); 13 | 14 | if [ -z "$uuid" ] 15 | then 16 | echo "There is no account for this phone number"; 17 | exit 1; 18 | fi 19 | 20 | echo "Users UUID: $uuid" 21 | -------------------------------------------------------------------------------- /resources/signal-reset-session.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/bash 2 | signald() { 3 | echo "$1" | timeout 3 nc -U /var/run/signald/signald.sock | jq 'select(.type != "version")' 4 | } 5 | 6 | if [ -z "$1" ] 7 | then 8 | echo "Please provide the users number as argument in international format"; 9 | exit 1; 10 | fi 11 | 12 | trustcmd=$(signald "{\"account\": \"+4915792453845\", \"address\": { \"number\": \"$1\" }, \"type\": \"get_identities\", \"version\": \"v1\" }" | jq -rc '{"number": .data.address.number, "safety_number": (.data.identities[] | select(.trust_level == "UNTRUSTED") | .safety_number) } | "{\"type\": \"trust\", \"address\": {\"number\": \"\(.number)\"}, \"safety_number\": \"\(.safety_number)\", \"account\": \"+4915792453845\", \"trust_level\": \"TRUSTED_UNVERIFIED\", \"version\": \"v1\"}"'); 13 | 14 | if [ -z "$trustcmd" ] 15 | then 16 | echo "There is no untrusted key for this phone number"; 17 | exit 1; 18 | fi 19 | 20 | echo "Send: $trustcmd" 21 | signald "$trustcmd" 22 | -------------------------------------------------------------------------------- /resources/signal-trust-new-keys.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | set -euo pipefail 3 | 4 | # SET SIGNAL_ADMIN_PHONE as environment variable 5 | 6 | signald() { 7 | echo "$1" | nc -U /var/run/signald/signald.sock | jq 'select(.type != "version")' 8 | } 9 | 10 | signald '{"type": "list_accounts"}' | jq -r 'select(.type == "account_list") | .data.accounts[].username' | while read username; do 11 | signald "{\"type\": \"get_identities\", \"username\": \"$username\"}" | jq --arg username "$username" -rc '.data.identities[] | select(.trust_level == \"UNTRUSTED\") | "{\"type\": \"trust\", \"safety_number\": \"\(.safety_number)\", \"address\": {\"number\": \"\(.address.number)\"}, \"account\": \"$username\", \"trust_level\": \"TRUSTED_UNVERIFIED\", \"version\": \"v1\"}"' | while read trustcmd; do 12 | signald "$trustcmd" 13 | signald "{\"type\": \"send\", \"version\": \"v1\", \"recipientAddress\": {\"number\": \"$SIGNAL_ADMIN_PHONE\"}, \"username\": \"$username\", \"messageBody\": \"$(echo $trustcmd | jq -r .address.number) new fingerprint is $(echo $trustcmd | jq -r .fingerprint)\"}" 14 | done 15 | done -------------------------------------------------------------------------------- /resources/testuser_empty.json: -------------------------------------------------------------------------------- 1 | {"subscriptions": {}, "last_update": null} -------------------------------------------------------------------------------- /resources/testzentren.csv: -------------------------------------------------------------------------------- 1 | Bundesland,URL,API,API_Format,API_URL 2 | Baden-Württemberg,https://www.kvbawue.de/index.php?id=1102,No,, 3 | Bayern,https://www.stmgp.bayern.de/coronavirus/bayerische-teststrategie/,No,, 4 | Berlin,https://test-to-go.berlin/,No,, 5 | Brandenburg,https://brandenburg-testet.de/bb-testet/de/,No,, 6 | Bremen,https://www.gesundheit.bremen.de/gesundheit/corona/corona_ambulanz-32720,No,, 7 | Hamburg,https://www.hamburg.de/corona-schnelltest/,Yes,JSON,https://suche.transparenz.hamburg.de/api/3/action/package_show?id=corona-testzentren-hamburg 8 | Hessen,https://www.corona-test-hessen.de/?action=Daten,Yes,XML,https://www.corona-test-hessen.de/data/corona-test-hessen.xml?v=22836BA9C360BE1022B689D6DCA16813 9 | Mecklenburg-Vorpommern,https://www.regierung-mv.de/Landesregierung/wm/Aktuelles--Blickpunkte/Wichtige-Informationen-zu-Corona-Virus-Testzentren/,Yes,JSON,https://www.regierung-mv.de/gaialight-apps/testzentren/_gaia_json.php 10 | Niedersachsen,https://www.niedersachsen.de/Coronavirus/Testunghinweise-zur-testung-auf-corona-198156.html#wo,No,, 11 | Nordrhein-Westfalen,https://www1.wdr.de/nachrichten/themen/coronavirus/corona-schnelltest-kostenlos-teststellen-100.html,Yes,CSV,No stable URL 12 | Rheinland-Pfalz,https://covid-19-support.lsjv.rlp.de/hilfe/covid-19-test-dashboard/,Yes,CSV,No stable URL 13 | Saarland,https://www.saarland.de/DE/portale/corona/impfungtest/testzentrum/testmoeglichkeiten/schnelltestskommunen.html,No,, 14 | Sachsen,https://www.coronavirus.sachsen.de/coronatests-in-sachsen-9448.html,Yes,XLSX,https://www.coronavirus.sachsen.de/download/Testmoeglichkeiten.xlsx 15 | Sachsen-Anhalt,https://coronavirus.sachsen-anhalt.de/angebote-fuer-schnelltests/suche-nach-testzentren/?no_cache=1,No,, 16 | Schleswig-Holstein,https://www.schleswig-holstein.de/DE/Schwerpunkte/Coronavirus/Allgemeines/TeststationenKarte/teststationen_node.html?lang=de,No,, 17 | Thüringen,https://www.tmasgff.de/covid-19/tests,No,, 18 | -------------------------------------------------------------------------------- /resources/threema-publicKey.txt: -------------------------------------------------------------------------------- 1 | public:53710b5758553b757593fe90a7c36d7922c1e3b79ceac5650c84d25fe20d6609 2 | -------------------------------------------------------------------------------- /resources/threema.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/eknoes/covidbot/565450e6ab558d5e91b4fcec1d161628d56fe865/resources/threema.gif -------------------------------------------------------------------------------- /resources/website/d64-bg.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/eknoes/covidbot/565450e6ab558d5e91b4fcec1d161628d56fe865/resources/website/d64-bg.png -------------------------------------------------------------------------------- /resources/website/favicon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/eknoes/covidbot/565450e6ab558d5e91b4fcec1d161628d56fe865/resources/website/favicon.png -------------------------------------------------------------------------------- /resources/website/fonts.css: -------------------------------------------------------------------------------- 1 | /* lato-300 - latin */ 2 | @font-face { 3 | font-family: 'Lato'; 4 | font-style: normal; 5 | font-weight: 300; 6 | src: url('../fonts/lato-v17-latin-300.eot'); /* IE9 Compat Modes */ 7 | src: local(''), 8 | url('../fonts/lato-v17-latin-300.eot?#iefix') format('embedded-opentype'), /* IE6-IE8 */ 9 | url('../fonts/lato-v17-latin-300.woff2') format('woff2'), /* Super Modern Browsers */ 10 | url('../fonts/lato-v17-latin-300.woff') format('woff'), /* Modern Browsers */ 11 | url('../fonts/lato-v17-latin-300.ttf') format('truetype'), /* Safari, Android, iOS */ 12 | url('../fonts/lato-v17-latin-300.svg#Lato') format('svg'); /* Legacy iOS */ 13 | } 14 | 15 | /* lato-300italic - latin */ 16 | @font-face { 17 | font-family: 'Lato'; 18 | font-style: italic; 19 | font-weight: 300; 20 | src: url('../fonts/lato-v17-latin-300italic.eot'); /* IE9 Compat Modes */ 21 | src: local(''), 22 | url('../fonts/lato-v17-latin-300italic.eot?#iefix') format('embedded-opentype'), /* IE6-IE8 */ 23 | url('../fonts/lato-v17-latin-300italic.woff2') format('woff2'), /* Super Modern Browsers */ 24 | url('../fonts/lato-v17-latin-300italic.woff') format('woff'), /* Modern Browsers */ 25 | url('../fonts/lato-v17-latin-300italic.ttf') format('truetype'), /* Safari, Android, iOS */ 26 | url('../fonts/lato-v17-latin-300italic.svg#Lato') format('svg'); /* Legacy iOS */ 27 | } 28 | 29 | /* lato-regular - latin */ 30 | @font-face { 31 | font-family: 'Lato'; 32 | font-style: normal; 33 | font-weight: 400; 34 | src: url('../fonts/lato-v17-latin-regular.eot'); /* IE9 Compat Modes */ 35 | src: local(''), 36 | url('../fonts/lato-v17-latin-regular.eot?#iefix') format('embedded-opentype'), /* IE6-IE8 */ 37 | url('../fonts/lato-v17-latin-regular.woff2') format('woff2'), /* Super Modern Browsers */ 38 | url('../fonts/lato-v17-latin-regular.woff') format('woff'), /* Modern Browsers */ 39 | url('../fonts/lato-v17-latin-regular.ttf') format('truetype'), /* Safari, Android, iOS */ 40 | url('../fonts/lato-v17-latin-regular.svg#Lato') format('svg'); /* Legacy iOS */ 41 | } 42 | 43 | /* lato-italic - latin */ 44 | @font-face { 45 | font-family: 'Lato'; 46 | font-style: italic; 47 | font-weight: 400; 48 | src: url('../fonts/lato-v17-latin-italic.eot'); /* IE9 Compat Modes */ 49 | src: local(''), 50 | url('../fonts/lato-v17-latin-italic.eot?#iefix') format('embedded-opentype'), /* IE6-IE8 */ 51 | url('../fonts/lato-v17-latin-italic.woff2') format('woff2'), /* Super Modern Browsers */ 52 | url('../fonts/lato-v17-latin-italic.woff') format('woff'), /* Modern Browsers */ 53 | url('../fonts/lato-v17-latin-italic.ttf') format('truetype'), /* Safari, Android, iOS */ 54 | url('../fonts/lato-v17-latin-italic.svg#Lato') format('svg'); /* Legacy iOS */ 55 | } 56 | -------------------------------------------------------------------------------- /resources/website/fonts/lato-v17-latin-300.eot: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/eknoes/covidbot/565450e6ab558d5e91b4fcec1d161628d56fe865/resources/website/fonts/lato-v17-latin-300.eot -------------------------------------------------------------------------------- /resources/website/fonts/lato-v17-latin-300.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/eknoes/covidbot/565450e6ab558d5e91b4fcec1d161628d56fe865/resources/website/fonts/lato-v17-latin-300.ttf -------------------------------------------------------------------------------- /resources/website/fonts/lato-v17-latin-300.woff: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/eknoes/covidbot/565450e6ab558d5e91b4fcec1d161628d56fe865/resources/website/fonts/lato-v17-latin-300.woff -------------------------------------------------------------------------------- /resources/website/fonts/lato-v17-latin-300.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/eknoes/covidbot/565450e6ab558d5e91b4fcec1d161628d56fe865/resources/website/fonts/lato-v17-latin-300.woff2 -------------------------------------------------------------------------------- /resources/website/fonts/lato-v17-latin-300italic.eot: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/eknoes/covidbot/565450e6ab558d5e91b4fcec1d161628d56fe865/resources/website/fonts/lato-v17-latin-300italic.eot -------------------------------------------------------------------------------- /resources/website/fonts/lato-v17-latin-300italic.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/eknoes/covidbot/565450e6ab558d5e91b4fcec1d161628d56fe865/resources/website/fonts/lato-v17-latin-300italic.ttf -------------------------------------------------------------------------------- /resources/website/fonts/lato-v17-latin-300italic.woff: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/eknoes/covidbot/565450e6ab558d5e91b4fcec1d161628d56fe865/resources/website/fonts/lato-v17-latin-300italic.woff -------------------------------------------------------------------------------- /resources/website/fonts/lato-v17-latin-300italic.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/eknoes/covidbot/565450e6ab558d5e91b4fcec1d161628d56fe865/resources/website/fonts/lato-v17-latin-300italic.woff2 -------------------------------------------------------------------------------- /resources/website/fonts/lato-v17-latin-italic.eot: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/eknoes/covidbot/565450e6ab558d5e91b4fcec1d161628d56fe865/resources/website/fonts/lato-v17-latin-italic.eot -------------------------------------------------------------------------------- /resources/website/fonts/lato-v17-latin-italic.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/eknoes/covidbot/565450e6ab558d5e91b4fcec1d161628d56fe865/resources/website/fonts/lato-v17-latin-italic.ttf -------------------------------------------------------------------------------- /resources/website/fonts/lato-v17-latin-italic.woff: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/eknoes/covidbot/565450e6ab558d5e91b4fcec1d161628d56fe865/resources/website/fonts/lato-v17-latin-italic.woff -------------------------------------------------------------------------------- /resources/website/fonts/lato-v17-latin-italic.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/eknoes/covidbot/565450e6ab558d5e91b4fcec1d161628d56fe865/resources/website/fonts/lato-v17-latin-italic.woff2 -------------------------------------------------------------------------------- /resources/website/fonts/lato-v17-latin-regular.eot: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/eknoes/covidbot/565450e6ab558d5e91b4fcec1d161628d56fe865/resources/website/fonts/lato-v17-latin-regular.eot -------------------------------------------------------------------------------- /resources/website/fonts/lato-v17-latin-regular.woff: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/eknoes/covidbot/565450e6ab558d5e91b4fcec1d161628d56fe865/resources/website/fonts/lato-v17-latin-regular.woff -------------------------------------------------------------------------------- /resources/website/fonts/lato-v17-latin-regular.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/eknoes/covidbot/565450e6ab558d5e91b4fcec1d161628d56fe865/resources/website/fonts/lato-v17-latin-regular.woff2 -------------------------------------------------------------------------------- /resources/website/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | D64 Covidbot 14 | 15 | 16 |
17 | 18 | 21 |
22 |
23 |
24 | Messaging Text Bubble with text and chart in front of 3 Covid19 Virus 25 |

Der D64 Covidbot

26 |

Ein Bot zu Deinen Diensten: Unser Covidbot hat Dich einmal am Tag mit den aktuellen Infektions-, Todes- und Impfzahlen der von Dir ausgewählten Orte versorgt. Mitte August 2023 haben wir das Projekt beendet und den Bot abgeschaltet.

27 |
28 |
29 |
30 |

Entwicklung

31 |

Der Bot ist ein quelloffenes Projekt. Jede:r kann unseren Quellcode 32 | auf GitHub überprüfen und weiterentwickeln. Der Bot ist unter der 33 | 34 | 35 | GPL-3.0  lizensiert.

36 |

Wir sind per Mail erreichbar.

37 |
38 |
39 | 55 | 56 | 57 | -------------------------------------------------------------------------------- /resources/website/logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/eknoes/covidbot/565450e6ab558d5e91b4fcec1d161628d56fe865/resources/website/logo.png -------------------------------------------------------------------------------- /resources/website/style.css: -------------------------------------------------------------------------------- 1 | body { 2 | font-family: 'Lato', sans-serif; 3 | background: #fff; 4 | margin: 0; 5 | /* color: #999; */ 6 | } 7 | 8 | 9 | #sommerpause { 10 | color: #000; 11 | } 12 | 13 | h1, h2, h3 { 14 | color: #00abe5; 15 | } 16 | 17 | code { 18 | background-color: #eee; 19 | padding: 2px 5px; 20 | } 21 | 22 | #logoBackgroundImage { 23 | display: block; 24 | position: relative; 25 | width: 100%; 26 | height: 460px; 27 | object-fit: cover; 28 | } 29 | 30 | #logo { 31 | position: relative; 32 | top: -413px; 33 | margin-left: 15%; 34 | } 35 | 36 | main { 37 | width: 70%; 38 | margin: 0 auto; 39 | position: relative; 40 | top: -300px; 41 | background: #fff; 42 | padding: 30px; 43 | transition: margin 0.2s; 44 | border: 1px solid #eee; 45 | border-bottom: 0; 46 | } 47 | 48 | #hero img { 49 | max-width: 200px; 50 | border-radius: 40px; 51 | float: left; 52 | margin-right: 5em; 53 | margin-bottom: 2em; 54 | } 55 | 56 | #main-content { 57 | clear: both; 58 | } 59 | 60 | .buttons { 61 | text-align: center; 62 | } 63 | 64 | button { 65 | background: #00b1e6; 66 | color: #fff; 67 | padding: 1rem; 68 | font-weight: bold; 69 | margin: 1rem 2rem; 70 | min-width: 30%; 71 | border: none; 72 | transition-duration: 500ms; 73 | } 74 | 75 | button a { 76 | text-decoration: none; 77 | color: #fff; 78 | } 79 | 80 | button:hover { 81 | background: #0095c2; 82 | transition-duration: 500ms; 83 | } 84 | 85 | button.disabled { 86 | background: #eee; 87 | color: #aaa; 88 | } 89 | 90 | @media only screen and (max-width: 1000px) { 91 | button { 92 | width: 80%; 93 | font-size: 3em; 94 | } 95 | } 96 | 97 | 98 | @media only screen and (min-width: 1001px) { 99 | button { 100 | width: 250px; 101 | font-size: 1.5em; 102 | } 103 | } 104 | 105 | footer { 106 | margin-top: -300px; 107 | background-color: #eee; 108 | padding: 1em 0; 109 | color: #333333; 110 | font-size: 0.9rem; 111 | } 112 | 113 | #footer-content { 114 | width: 70%; 115 | margin: 0 auto; 116 | } 117 | 118 | #d64vorstellung img { 119 | float: left; 120 | margin-right: 2em; 121 | margin-bottom: 2em; 122 | } 123 | 124 | #legal { 125 | clear: both; 126 | width: 70%; 127 | margin: 0 auto; 128 | text-align: center; 129 | } 130 | -------------------------------------------------------------------------------- /resources/website/threema-verification.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/eknoes/covidbot/565450e6ab558d5e91b4fcec1d161628d56fe865/resources/website/threema-verification.png --------------------------------------------------------------------------------