├── .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 | 
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 adaptto 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 |9 | Keine Nachrichten vorhanden 10 |
11 |33 | Über {{ user.platform | capitalize() }} am {{ user.last_communication_str() }} Uhr 34 |
35 | 36 | {% endif %} 37 | {% endblock %} 38 | {% block messagecontrols %} 39 | {% if user %} 40 | 63 | {% endif %} 64 | {% endblock %} 65 | {% block messages %} 66 | {% if not user %} 67 |{{ s }}
96 | {% endfor %} 97 | {% endblock %} 98 | {% block reports %} 99 |{{ 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-tokenEin 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 |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 |