mirror of
https://github.com/BOSWatch/BW3-Core.git
synced 2025-12-06 07:12:04 +01:00
Compare commits
10 commits
ff3d2161bf
...
9ca1198c5d
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9ca1198c5d | ||
|
|
22f1b7dc29 | ||
|
|
9a3b3336aa | ||
|
|
8ec7aecc2f | ||
|
|
ce3bec23d4 | ||
|
|
d016f264d3 | ||
|
|
71fc7c52c2 | ||
|
|
4415697d31 | ||
|
|
c5015f2160 | ||
|
|
81b93a9f77 |
|
|
@ -2,16 +2,16 @@
|
||||||
---
|
---
|
||||||
|
|
||||||
## Beschreibung
|
## Beschreibung
|
||||||
Mit diesem Plugin ist es moeglich, Telegram-Nachrichten für POCSAG-Alarmierungen zu senden.
|
Dieses Plugin ermöglicht das Versenden von Telegram-Nachrichten für verschiedene Alarmierungsarten.
|
||||||
Außerdem werden Locations versendet, wenn die Felder `lat` und `lon` im Paket definiert sind. (beispielsweise durch das [Geocoding](../modul/geocoding.md) Modul)
|
Wenn im eingehenden Paket die Felder `lat` und `lon` vorhanden sind (z. B. durch das [Geocoding](../modul/geocoding.md) Modul), wird zusätzlich automatisch der Standort als Telegram-Location gesendet.
|
||||||
|
|
||||||
Die abarbeitung der Alarmierungen erfolgt per Queue nach den Limits der Telegram API, damit keine Nachrichten verloren gehen, diese Funktion kann mit dem ```queue``` Parameter deaktiviert werden.
|
Das Senden der Nachrichten erfolgt über eine interne Queue mit Retry-Logik und exponentiellem Backoff, um die Vorgaben der Telegram API einzuhalten und Nachrichtenverluste zu verhindern. Die Retry-Parameter (max_retries, initial_delay, max_delay) können in der Konfiguration angepasst werden.
|
||||||
|
|
||||||
## Unterstütze Alarmtypen
|
## Unterstütze Alarmtypen
|
||||||
- Fms
|
- FMS
|
||||||
- Pocsag
|
- POCSAG
|
||||||
- Zvei
|
- ZVEI
|
||||||
- Msg
|
- MSG
|
||||||
|
|
||||||
## Resource
|
## Resource
|
||||||
`telegram`
|
`telegram`
|
||||||
|
|
@ -20,16 +20,17 @@ Die abarbeitung der Alarmierungen erfolgt per Queue nach den Limits der Telegram
|
||||||
|
|
||||||
|Feld|Beschreibung|Default|
|
|Feld|Beschreibung|Default|
|
||||||
|----|------------|-------|
|
|----|------------|-------|
|
||||||
|botToken|Der Api-Key des Telegram-Bots||
|
|botToken|Der Api-Key des Telegram-Bots|-|
|
||||||
|chatIds|Liste mit Chat-Ids der Empfängers / der Emfänger-Gruppen||
|
|chatIds|Liste mit Chat-Ids der Empfängers / der Empfänger-Gruppen|-|
|
||||||
|startup_message|Nachricht, dass das Telegram-Plugin erfolgreich geladen wurde|leer|
|
|startup_message|Nachricht beim erfolgreichen Initialisieren des Plugins|leer|
|
||||||
|message_fms|Format der Nachricht für FMS|`{FMS}`|
|
|message_fms|Formatvorlage für FMS-Alarm|`{FMS}`|
|
||||||
|message_pocsag|Format der Nachricht für Pocsag|`{RIC}({SRIC})\n{MSG}`|
|
|message_pocsag|Formatvorlage für POCSAG|`{RIC}({SRIC})\n{MSG}`|
|
||||||
|message_zvei|Format der Nachricht für ZVEI|`{TONE}`|
|
|message_zvei|Formatvorlage für ZVEI|`{TONE}`|
|
||||||
|message_msg|Format der Nachricht für MSG||
|
|message_msg|Formatvorlage für MSG-Nachricht|-|
|
||||||
|max_retries|Anzahl der Versuche, bis das Senden abgebrochen wird|5|
|
|max_retries|Anzahl Wiederholungsversuche bei Fehlern|5|
|
||||||
|initial_delay|Verzögerung des zweiten Sendeversuchs|2 [Sek.]|
|
|initial_delay|Initiale Wartezeit bei Wiederholungsversuchen|2 [Sek.]|
|
||||||
|max_delay|Maximale Verzögerung|60 [Sek.]|
|
|max_delay|Maximale Retry-Verzögerung|300 [Sek.]|
|
||||||
|
|parse_mode|Formatierung ("HTML" oder "MarkdownV2"), Case-sensitive!|leer|
|
||||||
|
|
||||||
**Beispiel:**
|
**Beispiel:**
|
||||||
```yaml
|
```yaml
|
||||||
|
|
@ -37,20 +38,32 @@ Die abarbeitung der Alarmierungen erfolgt per Queue nach den Limits der Telegram
|
||||||
name: Telegram Plugin
|
name: Telegram Plugin
|
||||||
res: telegram
|
res: telegram
|
||||||
config:
|
config:
|
||||||
message_pocsag: "{RIC}({SRIC})\n{MSG}"
|
message_pocsag: |
|
||||||
|
<b>POCSAG Alarm:</b>
|
||||||
|
RIC: <b>{RIC}</b> ({SRIC})
|
||||||
|
{MSG}
|
||||||
|
parse_mode: "HTML"
|
||||||
startup_message: "Server up and running!"
|
startup_message: "Server up and running!"
|
||||||
botToken: "BOT_TOKEN"
|
botToken: "BOT_TOKEN"
|
||||||
chatIds:
|
chatIds:
|
||||||
- "CHAT_ID"
|
- "CHAT_ID"
|
||||||
```
|
```
|
||||||
|
|
||||||
|
Hinweis:
|
||||||
|
Über parse_mode kannst du Telegram-Formatierungen verwenden:
|
||||||
|
|
||||||
|
- HTML: `<b>fett</b>`, `<i>kursiv</i>`, `<u>unterstrichen</u>`, `<s>durchgestrichen</s>`, ...
|
||||||
|
- MarkdownV2: `**fett**`, `__unterstrichen__`, `_italic \*text_` usw. (Escape-Regeln beachten)
|
||||||
|
|
||||||
|
Block-Strings (|) eignen sich perfekt für mehrzeilige Nachrichten und vermeiden Escape-Zeichen wie \n
|
||||||
|
|
||||||
---
|
---
|
||||||
## Modul Abhängigkeiten
|
## Modul Abhängigkeiten
|
||||||
Aus dem Modul [Geocoding](../modul/geocoding.md) (optional/nur POCSAG):
|
OPTIONAL, nur für POCSAG-Locationversand: Aus dem Modul [Geocoding](../modul/geocoding.md):
|
||||||
|
|
||||||
- `lat`
|
- `lat`
|
||||||
- `lon`
|
- `lon`
|
||||||
|
|
||||||
---
|
---
|
||||||
## Externe Abhängigkeiten
|
## Externe Abhängigkeiten
|
||||||
- python-telegram-bot
|
keine
|
||||||
|
|
|
||||||
|
|
@ -9,8 +9,8 @@ r"""!
|
||||||
German BOS Information Script
|
German BOS Information Script
|
||||||
by Bastian Schroll
|
by Bastian Schroll
|
||||||
|
|
||||||
@file: http.py
|
@file: install_service.py
|
||||||
@date: 21.07.2025
|
@date: 15.11.2025
|
||||||
@author: Claus Schichl
|
@author: Claus Schichl
|
||||||
@description: Install Service File with argparse CLI
|
@description: Install Service File with argparse CLI
|
||||||
"""
|
"""
|
||||||
|
|
@ -21,13 +21,9 @@ import sys
|
||||||
import logging
|
import logging
|
||||||
import argparse
|
import argparse
|
||||||
import yaml
|
import yaml
|
||||||
from colorama import init as colorama_init, Fore, Style
|
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
# === Initialisiere Colorama für Windows/Konsole ===
|
# === constants for directories and files ===
|
||||||
colorama_init(autoreset=True)
|
|
||||||
|
|
||||||
# === Konstanten ===
|
|
||||||
BASE_DIR = Path(__file__).resolve().parent
|
BASE_DIR = Path(__file__).resolve().parent
|
||||||
BW_DIR = '/opt/boswatch3'
|
BW_DIR = '/opt/boswatch3'
|
||||||
SERVICE_DIR = Path('/etc/systemd/system')
|
SERVICE_DIR = Path('/etc/systemd/system')
|
||||||
|
|
@ -35,7 +31,8 @@ CONFIG_DIR = (BASE_DIR / 'config').resolve()
|
||||||
LOG_FILE = (BASE_DIR / 'log' / 'install' / 'service_install.log').resolve()
|
LOG_FILE = (BASE_DIR / 'log' / 'install' / 'service_install.log').resolve()
|
||||||
os.makedirs(LOG_FILE.parent, exist_ok=True)
|
os.makedirs(LOG_FILE.parent, exist_ok=True)
|
||||||
|
|
||||||
# === Sprache (kapselt globale Variable) ===
|
|
||||||
|
# === language management (default german)===
|
||||||
_lang = 'de'
|
_lang = 'de'
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -48,7 +45,7 @@ def set_lang(lang):
|
||||||
_lang = lang
|
_lang = lang
|
||||||
|
|
||||||
|
|
||||||
# === Texte ===
|
# === text-dictionary ===
|
||||||
TEXT = {
|
TEXT = {
|
||||||
"de": {
|
"de": {
|
||||||
"script_title": "🛠️ BOSWatch Service Manager",
|
"script_title": "🛠️ BOSWatch Service Manager",
|
||||||
|
|
@ -88,7 +85,17 @@ TEXT = {
|
||||||
"service_active": "✅ Service {0} läuft erfolgreich.",
|
"service_active": "✅ Service {0} läuft erfolgreich.",
|
||||||
"service_inactive": "⚠ Service {0} ist **nicht aktiv** – bitte prüfen.",
|
"service_inactive": "⚠ Service {0} ist **nicht aktiv** – bitte prüfen.",
|
||||||
"dryrun_status_check": "🧪 [Dry-Run] Service-Status von {0} würde jetzt geprüft.",
|
"dryrun_status_check": "🧪 [Dry-Run] Service-Status von {0} würde jetzt geprüft.",
|
||||||
"max_attempts_exceeded": "❌ Maximale Anzahl an Eingabeversuchen überschritten. Das Menü wird beendet."
|
"max_attempts_exceeded": "❌ Maximale Anzahl an Eingabeversuchen überschritten. Das Menü wird beendet.",
|
||||||
|
"user_interrupt": "\nAbbruch durch Benutzer.",
|
||||||
|
"unhandled_error": "Unbehandelter Fehler: {}",
|
||||||
|
"max_retries_skip": "Maximale Anzahl Eingabeversuche überschritten. Überspringe Service.",
|
||||||
|
"max_retries_exit": "Maximale Anzahl Eingabeversuche überschritten. Beende Programm.",
|
||||||
|
"colorama_missing": "⚠️ Colorama nicht installiert – versuche automatische Installation...",
|
||||||
|
"colorama_install": "➡️ Installiere Colorama...",
|
||||||
|
"colorama_install_ok": "✅ Colorama erfolgreich installiert.",
|
||||||
|
"colorama_install_fail": "❌ Colorama konnte nicht automatisch installiert werden.",
|
||||||
|
"verify_timeout": "⚠ Timeout bei systemd-analyze verify für: {}",
|
||||||
|
"status_timeout": "⚠ Timeout beim Prüfen des Service-Status: {}"
|
||||||
|
|
||||||
},
|
},
|
||||||
"en": {
|
"en": {
|
||||||
|
|
@ -129,14 +136,78 @@ TEXT = {
|
||||||
"service_active": "✅ Service {0} is running successfully.",
|
"service_active": "✅ Service {0} is running successfully.",
|
||||||
"service_inactive": "⚠ Service {0} is **not active** – please check.",
|
"service_inactive": "⚠ Service {0} is **not active** – please check.",
|
||||||
"dryrun_status_check": "🧪 [Dry-Run] Service status of {0} would be checked now.",
|
"dryrun_status_check": "🧪 [Dry-Run] Service status of {0} would be checked now.",
|
||||||
"max_attempts_exceeded": "❌ Maximum number of input attempts exceeded. Exiting menu."
|
"max_attempts_exceeded": "❌ Maximum number of input attempts exceeded. Exiting menu.",
|
||||||
|
"user_interrupt": "\nInterrupted by user.",
|
||||||
|
"unhandled_error": "Unhandled error: {}",
|
||||||
|
"max_retries_skip": "Maximum input attempts exceeded. Skipping service.",
|
||||||
|
"max_retries_exit": "Maximum input attempts exceeded. Exiting program.",
|
||||||
|
"colorama_missing": "⚠️ Colorama not installed – attempting automatic installation...",
|
||||||
|
"colorama_install": "➡️ Installing Colorama...",
|
||||||
|
"colorama_install_ok": "✅ Colorama installed successfully.",
|
||||||
|
"colorama_install_fail": "❌ Colorama could not be installed automatically.",
|
||||||
|
"verify_timeout": "⚠ Timeout during systemd-analyze verify for: {}",
|
||||||
|
"status_timeout": "⚠ Timeout while checking service status: {}"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
# === Logging Setup ===
|
# === COLORAMA AUTO-INSTALL (dual language) ===
|
||||||
def setup_logging(verbose=False, quiet=False):
|
def colorama_auto_install():
|
||||||
|
r"""
|
||||||
|
Auto-installs colorama if missing.
|
||||||
|
Note: Language detection happens before colorama is available.
|
||||||
"""
|
"""
|
||||||
|
# recognize language early (before colorama installation)
|
||||||
|
import argparse
|
||||||
|
early_parser = argparse.ArgumentParser(add_help=False)
|
||||||
|
early_parser.add_argument('--lang', '-l', choices=['de', 'en'], default='de')
|
||||||
|
early_args, _ = early_parser.parse_known_args()
|
||||||
|
lang = early_args.lang
|
||||||
|
|
||||||
|
# use text from global TEXT dictionary
|
||||||
|
txt = TEXT[lang]
|
||||||
|
|
||||||
|
try:
|
||||||
|
from colorama import init as colorama_init, Fore, Style
|
||||||
|
colorama_init(autoreset=True)
|
||||||
|
return True, Fore, Style
|
||||||
|
except ImportError:
|
||||||
|
print(txt["colorama_missing"])
|
||||||
|
|
||||||
|
# install Colorama
|
||||||
|
print(txt["colorama_install"])
|
||||||
|
subprocess.run(["sudo", "apt", "install", "-y", "python3-colorama"], check=False)
|
||||||
|
|
||||||
|
# retry importing Colorama
|
||||||
|
try:
|
||||||
|
from colorama import init as colorama_init, Fore, Style
|
||||||
|
colorama_init(autoreset=True)
|
||||||
|
print(txt["colorama_install_ok"])
|
||||||
|
return True, Fore, Style
|
||||||
|
except ImportError:
|
||||||
|
print(txt["colorama_install_fail"])
|
||||||
|
return False, None, None
|
||||||
|
|
||||||
|
|
||||||
|
# === import / install colorama ===
|
||||||
|
colorama_available, Fore, Style = colorama_auto_install()
|
||||||
|
|
||||||
|
if not colorama_available:
|
||||||
|
# provides dummy classes if colorama is not available (no crash)
|
||||||
|
class DummyStyle:
|
||||||
|
RESET_ALL = ""
|
||||||
|
BRIGHT = ""
|
||||||
|
|
||||||
|
class DummyFore:
|
||||||
|
RED = GREEN = YELLOW = BLUE = CYAN = MAGENTA = WHITE = RESET = ""
|
||||||
|
|
||||||
|
Fore = DummyFore()
|
||||||
|
Style = DummyStyle()
|
||||||
|
|
||||||
|
|
||||||
|
# === logging Setup ===
|
||||||
|
def setup_logging(verbose=False, quiet=False):
|
||||||
|
r"""
|
||||||
Setup logging to file and console with colorized output.
|
Setup logging to file and console with colorized output.
|
||||||
"""
|
"""
|
||||||
log_level = logging.INFO
|
log_level = logging.INFO
|
||||||
|
|
@ -178,7 +249,7 @@ def setup_logging(verbose=False, quiet=False):
|
||||||
|
|
||||||
|
|
||||||
def t(key):
|
def t(key):
|
||||||
"""
|
r"""
|
||||||
Translation helper: returns the localized string for the given key.
|
Translation helper: returns the localized string for the given key.
|
||||||
"""
|
"""
|
||||||
lang = get_lang()
|
lang = get_lang()
|
||||||
|
|
@ -186,7 +257,7 @@ def t(key):
|
||||||
|
|
||||||
|
|
||||||
def get_user_input(prompt, valid_inputs, max_attempts=3):
|
def get_user_input(prompt, valid_inputs, max_attempts=3):
|
||||||
"""
|
r"""
|
||||||
Prompt user for input until a valid input from valid_inputs is entered or max_attempts exceeded.
|
Prompt user for input until a valid input from valid_inputs is entered or max_attempts exceeded.
|
||||||
Raises RuntimeError on failure.
|
Raises RuntimeError on failure.
|
||||||
"""
|
"""
|
||||||
|
|
@ -197,22 +268,22 @@ def get_user_input(prompt, valid_inputs, max_attempts=3):
|
||||||
return value
|
return value
|
||||||
logging.warning(t("invalid_input").format(", ".join(valid_inputs)))
|
logging.warning(t("invalid_input").format(", ".join(valid_inputs)))
|
||||||
attempts += 1
|
attempts += 1
|
||||||
raise RuntimeError("Maximale Anzahl Eingabeversuche überschritten.")
|
raise RuntimeError(t("max_attempts_exceeded"))
|
||||||
|
|
||||||
|
|
||||||
def list_yaml_files():
|
def list_yaml_files():
|
||||||
"""
|
r"""
|
||||||
Returns a list of .yaml or .yml files in the config directory.
|
Returns a list of .yaml or .yml files in the config directory.
|
||||||
"""
|
"""
|
||||||
return [f.name for f in CONFIG_DIR.glob("*.y*ml")]
|
return sorted([f.name for f in CONFIG_DIR.glob("*.y*ml")])
|
||||||
|
|
||||||
|
|
||||||
def test_yaml_file(file_path):
|
def test_yaml_file(file_path):
|
||||||
"""
|
r"""
|
||||||
Tests if YAML file can be loaded without error.
|
Tests if YAML file can be loaded without error.
|
||||||
"""
|
"""
|
||||||
try:
|
try:
|
||||||
content = file_path.read_text()
|
content = file_path.read_text(encoding='utf-8')
|
||||||
yaml.safe_load(content)
|
yaml.safe_load(content)
|
||||||
return True
|
return True
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
|
|
@ -221,11 +292,11 @@ def test_yaml_file(file_path):
|
||||||
|
|
||||||
|
|
||||||
def detect_yaml_type(file_path):
|
def detect_yaml_type(file_path):
|
||||||
"""
|
r"""
|
||||||
Detects if YAML config is 'client' or 'server' type.
|
Detects if YAML config is 'client' or 'server' type.
|
||||||
"""
|
"""
|
||||||
try:
|
try:
|
||||||
with open(file_path, 'r') as f:
|
with open(file_path, 'r', encoding='utf-8') as f:
|
||||||
data = yaml.safe_load(f)
|
data = yaml.safe_load(f)
|
||||||
if 'client' in data:
|
if 'client' in data:
|
||||||
return 'client'
|
return 'client'
|
||||||
|
|
@ -240,7 +311,7 @@ def detect_yaml_type(file_path):
|
||||||
|
|
||||||
|
|
||||||
def execute(command, dry_run=False):
|
def execute(command, dry_run=False):
|
||||||
"""
|
r"""
|
||||||
Executes shell command unless dry_run is True.
|
Executes shell command unless dry_run is True.
|
||||||
"""
|
"""
|
||||||
logging.debug(f"→ {command}")
|
logging.debug(f"→ {command}")
|
||||||
|
|
@ -249,21 +320,28 @@ def execute(command, dry_run=False):
|
||||||
|
|
||||||
|
|
||||||
def verify_service(service_path):
|
def verify_service(service_path):
|
||||||
"""
|
r"""
|
||||||
Runs 'systemd-analyze verify' on the service file and logs warnings/errors.
|
Runs 'systemd-analyze verify' on the service file and logs warnings/errors.
|
||||||
"""
|
"""
|
||||||
try:
|
try:
|
||||||
result = subprocess.run(['systemd-analyze', 'verify', service_path], capture_output=True, text=True)
|
result = subprocess.run(
|
||||||
|
['systemd-analyze', 'verify', service_path],
|
||||||
|
capture_output=True,
|
||||||
|
text=True,
|
||||||
|
timeout=10
|
||||||
|
)
|
||||||
if result.returncode != 0 or result.stderr:
|
if result.returncode != 0 or result.stderr:
|
||||||
logging.warning(t("verify_warn").format(result.stderr.strip()))
|
logging.warning(t("verify_warn").format(result.stderr.strip()))
|
||||||
else:
|
else:
|
||||||
logging.debug(t("verify_ok").format(os.path.basename(service_path)))
|
logging.debug(t("verify_ok").format(os.path.basename(service_path)))
|
||||||
|
except subprocess.TimeoutExpired:
|
||||||
|
logging.warning(t("verify_timeout").format(os.path.basename(service_path)))
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logging.error(t("yaml_error").format(service_path, e))
|
logging.error(t("yaml_error").format(service_path, e))
|
||||||
|
|
||||||
|
|
||||||
def install_service(yaml_file, dry_run=False):
|
def install_service(yaml_file, dry_run=False):
|
||||||
"""
|
r"""
|
||||||
Creates and installs systemd service based on YAML config.
|
Creates and installs systemd service based on YAML config.
|
||||||
"""
|
"""
|
||||||
yaml_path = CONFIG_DIR / yaml_file
|
yaml_path = CONFIG_DIR / yaml_file
|
||||||
|
|
@ -280,7 +358,7 @@ def install_service(yaml_file, dry_run=False):
|
||||||
service_path = SERVICE_DIR / service_name
|
service_path = SERVICE_DIR / service_name
|
||||||
|
|
||||||
if is_server:
|
if is_server:
|
||||||
exec_line = f"/usr/bin/python {BW_DIR}/bw_server.py -c {yaml_file}"
|
exec_line = f"/usr/bin/python3 {BW_DIR}/bw_server.py -c {yaml_file}"
|
||||||
description = "BOSWatch Server"
|
description = "BOSWatch Server"
|
||||||
after = "network-online.target"
|
after = "network-online.target"
|
||||||
wants = "Wants=network-online.target"
|
wants = "Wants=network-online.target"
|
||||||
|
|
@ -309,49 +387,56 @@ WantedBy=multi-user.target
|
||||||
|
|
||||||
if not dry_run:
|
if not dry_run:
|
||||||
try:
|
try:
|
||||||
with open(service_path, 'w') as f:
|
with open(service_path, 'w', encoding='utf-8') as f:
|
||||||
f.write(service_content)
|
f.write(service_content)
|
||||||
except IOError as e:
|
except IOError as e:
|
||||||
logging.error(t("file_write_error").format(service_path, e))
|
logging.error(t("file_write_error").format(service_path, e))
|
||||||
return
|
return
|
||||||
verify_service(service_path)
|
verify_service(service_path)
|
||||||
|
|
||||||
execute("systemctl daemon-reexec", dry_run=dry_run)
|
execute("systemctl daemon-reload", dry_run=dry_run)
|
||||||
execute(f"systemctl enable {service_name}", dry_run=dry_run)
|
execute(f"systemctl enable {service_name}", dry_run=dry_run)
|
||||||
execute(f"systemctl start {service_name}", dry_run=dry_run)
|
execute(f"systemctl start {service_name}", dry_run=dry_run)
|
||||||
|
|
||||||
if not dry_run:
|
if not dry_run:
|
||||||
try:
|
try:
|
||||||
subprocess.run(
|
subprocess.run(
|
||||||
["systemctl", "is-active", "--quiet", service_name],
|
["systemctl", "is-active", "--quiet", service_name],
|
||||||
check=True
|
check=True,
|
||||||
|
timeout=5
|
||||||
)
|
)
|
||||||
logging.info(t("service_active").format(service_name))
|
logging.info(t("service_active").format(service_name))
|
||||||
except subprocess.CalledProcessError:
|
except subprocess.CalledProcessError:
|
||||||
logging.warning(t("service_inactive").format(service_name))
|
logging.warning(t("service_inactive").format(service_name))
|
||||||
|
except subprocess.TimeoutExpired:
|
||||||
|
logging.warning(t("status_timeout").format(service_name))
|
||||||
else:
|
else:
|
||||||
logging.info(t("dryrun_status_check").format(service_name))
|
logging.info(t("dryrun_status_check").format(service_name))
|
||||||
|
|
||||||
|
|
||||||
def remove_service(service_name, dry_run=False):
|
def remove_service(service_name, dry_run=False):
|
||||||
"""
|
r"""
|
||||||
Stops, disables and removes the given systemd service.
|
Stops, disables and removes the given systemd service.
|
||||||
"""
|
"""
|
||||||
logging.warning(t("removing_service").format(service_name))
|
logging.warning(t("removing_service").format(service_name))
|
||||||
execute(f"systemctl stop {service_name}", dry_run=dry_run)
|
execute(f"systemctl stop {service_name}", dry_run=dry_run)
|
||||||
execute(f"systemctl disable {service_name}", dry_run=dry_run)
|
execute(f"systemctl disable {service_name}", dry_run=dry_run)
|
||||||
service_path = Path(SERVICE_DIR) / service_name
|
|
||||||
|
service_path = SERVICE_DIR / service_name
|
||||||
if not dry_run and service_path.exists():
|
if not dry_run and service_path.exists():
|
||||||
try:
|
try:
|
||||||
os.remove(service_path)
|
service_path.unlink()
|
||||||
logging.info(t("service_deleted").format(service_name))
|
logging.info(t("service_deleted").format(service_name))
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logging.error(t("file_write_error").format(service_path, e))
|
logging.error(t("file_write_error").format(service_path, e))
|
||||||
else:
|
else:
|
||||||
logging.warning(t("service_not_found").format(service_name))
|
logging.warning(t("service_not_found").format(service_name))
|
||||||
|
|
||||||
|
execute("systemctl daemon-reload", dry_run=dry_run)
|
||||||
|
|
||||||
|
|
||||||
def remove_menu(dry_run=False):
|
def remove_menu(dry_run=False):
|
||||||
"""
|
r"""
|
||||||
Interactive menu to remove services.
|
Interactive menu to remove services.
|
||||||
"""
|
"""
|
||||||
while True:
|
while True:
|
||||||
|
|
@ -384,16 +469,16 @@ def remove_menu(dry_run=False):
|
||||||
elif auswahl == 'a':
|
elif auswahl == 'a':
|
||||||
for s in services:
|
for s in services:
|
||||||
remove_service(s, dry_run=dry_run)
|
remove_service(s, dry_run=dry_run)
|
||||||
# Danach direkt weiter zur nächsten Schleife (aktualisierte Liste!)
|
# directly continue to the next loop (updated list!)
|
||||||
continue
|
continue
|
||||||
else:
|
else:
|
||||||
remove_service(services[int(auswahl)], dry_run=dry_run)
|
remove_service(services[int(auswahl)], dry_run=dry_run)
|
||||||
# Danach ebenfalls weiter zur nächsten Schleife (aktualisierte Liste!)
|
# also directly continue to the next loop (updated list!)
|
||||||
continue
|
continue
|
||||||
|
|
||||||
|
|
||||||
def init_language():
|
def init_language():
|
||||||
"""
|
r"""
|
||||||
Parses --lang/-l argument early to set language before other parsing.
|
Parses --lang/-l argument early to set language before other parsing.
|
||||||
"""
|
"""
|
||||||
lang_parser = argparse.ArgumentParser(add_help=False)
|
lang_parser = argparse.ArgumentParser(add_help=False)
|
||||||
|
|
@ -410,8 +495,8 @@ def init_language():
|
||||||
|
|
||||||
|
|
||||||
def main(dry_run=False):
|
def main(dry_run=False):
|
||||||
"""
|
r"""
|
||||||
Hauptprogramm: Service installieren oder entfernen.
|
main program: install or remove service.
|
||||||
"""
|
"""
|
||||||
print(Fore.GREEN + Style.BRIGHT + t("script_title") + Style.RESET_ALL)
|
print(Fore.GREEN + Style.BRIGHT + t("script_title") + Style.RESET_ALL)
|
||||||
print(t('mode_dry') if dry_run else t('mode_live'))
|
print(t('mode_dry') if dry_run else t('mode_live'))
|
||||||
|
|
@ -432,7 +517,7 @@ def main(dry_run=False):
|
||||||
try:
|
try:
|
||||||
action = get_user_input(t("action_prompt"), ['i', 'r', 'e'])
|
action = get_user_input(t("action_prompt"), ['i', 'r', 'e'])
|
||||||
except RuntimeError:
|
except RuntimeError:
|
||||||
logging.error("Maximale Anzahl Eingabeversuche überschritten. Beende Programm.")
|
logging.error(t("max_retries_exit"))
|
||||||
sys.exit(1)
|
sys.exit(1)
|
||||||
|
|
||||||
if action == 'e':
|
if action == 'e':
|
||||||
|
|
@ -444,7 +529,7 @@ def main(dry_run=False):
|
||||||
try:
|
try:
|
||||||
edited = get_user_input(t("edited_prompt"), ['y', 'n'])
|
edited = get_user_input(t("edited_prompt"), ['y', 'n'])
|
||||||
except RuntimeError:
|
except RuntimeError:
|
||||||
logging.error("Maximale Anzahl Eingabeversuche überschritten. Beende Programm.")
|
logging.error(t("max_retries_exit"))
|
||||||
sys.exit(1)
|
sys.exit(1)
|
||||||
|
|
||||||
if edited == 'n':
|
if edited == 'n':
|
||||||
|
|
@ -464,7 +549,7 @@ def main(dry_run=False):
|
||||||
try:
|
try:
|
||||||
install = get_user_input(t("install_confirm").format(yaml_file), ['y', 'n'])
|
install = get_user_input(t("install_confirm").format(yaml_file), ['y', 'n'])
|
||||||
except RuntimeError:
|
except RuntimeError:
|
||||||
logging.error("Maximale Anzahl Eingabeversuche überschritten. Überspringe Service.")
|
logging.error(t("max_retries_skip"))
|
||||||
skipped += 1
|
skipped += 1
|
||||||
continue
|
continue
|
||||||
|
|
||||||
|
|
@ -501,9 +586,8 @@ if __name__ == "__main__":
|
||||||
try:
|
try:
|
||||||
main(dry_run=args.dry_run)
|
main(dry_run=args.dry_run)
|
||||||
except KeyboardInterrupt:
|
except KeyboardInterrupt:
|
||||||
print("\nAbbruch durch Benutzer.")
|
print(t("user_interrupt"))
|
||||||
sys.exit(1)
|
sys.exit(1)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logging.critical(f"Unbehandelter Fehler: {e}")
|
logging.critical(t("unhandled_error").format(e))
|
||||||
sys.exit(1)
|
sys.exit(1)
|
||||||
# === Ende des Skripts ===
|
|
||||||
|
|
|
||||||
|
|
@ -57,8 +57,11 @@ class BoswatchPlugin(PluginBase):
|
||||||
if self.cursor.fetchone() is None:
|
if self.cursor.fetchone() is None:
|
||||||
with open('init_db.sql') as f:
|
with open('init_db.sql') as f:
|
||||||
for stmnt in f.read().split(';'):
|
for stmnt in f.read().split(';'):
|
||||||
self.cursor.execute(stmnt)
|
# Change: Remove whitespace and check for empty strings
|
||||||
self.connection.commit()
|
clean_stmnt = stmnt.strip()
|
||||||
|
if clean_stmnt: # only if the string is not empty
|
||||||
|
self.cursor.execute(clean_stmnt)
|
||||||
|
self.connection.commit()
|
||||||
|
|
||||||
self.cursor.close()
|
self.cursor.close()
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -10,7 +10,7 @@ r"""!
|
||||||
by Bastian Schroll
|
by Bastian Schroll
|
||||||
|
|
||||||
@file: telegram.py
|
@file: telegram.py
|
||||||
@date: 12.07.2025
|
@date: 17.11.2025
|
||||||
@author: Claus Schichl nach der Idee von Jan Speller
|
@author: Claus Schichl nach der Idee von Jan Speller
|
||||||
@description: Telegram-Plugin mit Retry-Logik ohne externe Telegram-Abhängigkeiten
|
@description: Telegram-Plugin mit Retry-Logik ohne externe Telegram-Abhängigkeiten
|
||||||
"""
|
"""
|
||||||
|
|
@ -31,16 +31,17 @@ logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
# ===========================
|
# ===========================
|
||||||
# TelegramSender-Klasse
|
# TelegramSender-Class
|
||||||
# ===========================
|
# ===========================
|
||||||
|
|
||||||
class TelegramSender:
|
class TelegramSender:
|
||||||
def __init__(self, bot_token, chat_ids, max_retries=None, initial_delay=None, max_delay=None):
|
def __init__(self, bot_token, chat_ids, max_retries=None, initial_delay=None, max_delay=None, parse_mode=None):
|
||||||
self.bot_token = bot_token
|
self.bot_token = bot_token
|
||||||
self.chat_ids = chat_ids
|
self.chat_ids = chat_ids
|
||||||
self.max_retries = max_retries if max_retries is not None else 5
|
self.max_retries = max_retries if max_retries is not None else 5
|
||||||
self.initial_delay = initial_delay if initial_delay is not None else 2
|
self.initial_delay = initial_delay if initial_delay is not None else 2
|
||||||
self.max_delay = max_delay if max_delay is not None else 300
|
self.max_delay = max_delay if max_delay is not None else 300
|
||||||
|
self.parse_mode = parse_mode
|
||||||
self.msg_queue = queue.Queue()
|
self.msg_queue = queue.Queue()
|
||||||
self._worker = threading.Thread(target=self._worker_loop, daemon=True)
|
self._worker = threading.Thread(target=self._worker_loop, daemon=True)
|
||||||
self._worker.start()
|
self._worker.start()
|
||||||
|
|
@ -75,11 +76,11 @@ class TelegramSender:
|
||||||
logger.warning(f"Erneutes Einreihen der Nachricht (Versuch {retry_count + 1}).")
|
logger.warning(f"Erneutes Einreihen der Nachricht (Versuch {retry_count + 1}).")
|
||||||
self.msg_queue.put((msg_type, chat_id, content, retry_count + 1))
|
self.msg_queue.put((msg_type, chat_id, content, retry_count + 1))
|
||||||
|
|
||||||
# Nutze den von Telegram gelieferten Wert (retry_after), falls vorhanden
|
# use the Telegram-provided value (retry_after) if available
|
||||||
wait_time = custom_delay if custom_delay is not None else delay
|
wait_time = custom_delay if custom_delay is not None else delay
|
||||||
time.sleep(wait_time)
|
time.sleep(wait_time)
|
||||||
|
|
||||||
# Erhöhe Delay für den nächsten Versuch (exponentielles Backoff)
|
# increase delay for the next attempt (exponential backoff)
|
||||||
delay = min(delay * 2, self.max_delay)
|
delay = min(delay * 2, self.max_delay)
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
|
|
@ -91,8 +92,10 @@ class TelegramSender:
|
||||||
url = f"https://api.telegram.org/bot{self.bot_token}/sendMessage"
|
url = f"https://api.telegram.org/bot{self.bot_token}/sendMessage"
|
||||||
payload = {
|
payload = {
|
||||||
'chat_id': chat_id,
|
'chat_id': chat_id,
|
||||||
'text': content
|
'text': content,
|
||||||
}
|
}
|
||||||
|
if self.parse_mode:
|
||||||
|
payload['parse_mode'] = self.parse_mode
|
||||||
elif msg_type == "location":
|
elif msg_type == "location":
|
||||||
url = f"https://api.telegram.org/bot{self.bot_token}/sendLocation"
|
url = f"https://api.telegram.org/bot{self.bot_token}/sendLocation"
|
||||||
payload = {
|
payload = {
|
||||||
|
|
@ -101,25 +104,25 @@ class TelegramSender:
|
||||||
}
|
}
|
||||||
else:
|
else:
|
||||||
logger.error("Unbekannter Nachrichtentyp.")
|
logger.error("Unbekannter Nachrichtentyp.")
|
||||||
return False, True, None # Unbekannter Typ = permanent falsch
|
return False, True, None # unknown message type = permanent failure
|
||||||
|
|
||||||
try:
|
try:
|
||||||
custom_delay = None # Standardwert für Rückgabe, außer bei 429
|
custom_delay = None # standardvalue for return, except in case of 429
|
||||||
|
|
||||||
response = requests.post(url, data=payload, timeout=10)
|
response = requests.post(url, data=payload, timeout=10)
|
||||||
|
|
||||||
if response.status_code == 429:
|
if response.status_code == 429:
|
||||||
custom_delay = response.json().get("parameters", {}).get("retry_after", 5)
|
custom_delay = response.json().get("parameters", {}).get("retry_after", 5)
|
||||||
logger.warning(f"Rate Limit erreicht – warte {custom_delay} Sekunden.")
|
logger.warning(f"Rate Limit erreicht – warte {custom_delay} Sekunden.")
|
||||||
return False, False, custom_delay # Telegram gibt genaue Wartezeit vor
|
return False, False, custom_delay # Telegram gives exact wait time
|
||||||
|
|
||||||
if response.status_code == 400:
|
if response.status_code == 400:
|
||||||
logger.error("Ungültige Parameter – Nachricht wird nicht erneut gesendet.")
|
logger.error("Ungültige Parameter – Nachricht wird nicht erneut gesendet.")
|
||||||
return False, True, custom_delay # Permanent fehlerhaft
|
return False, True, custom_delay # permanent failure
|
||||||
|
|
||||||
if response.status_code == 401:
|
if response.status_code == 401:
|
||||||
logger.critical("Ungültiger Bot-Token – bitte prüfen!")
|
logger.critical("Ungültiger Bot-Token – bitte prüfen!")
|
||||||
return False, True, custom_delay # Permanent fehlerhaft
|
return False, True, custom_delay # permanent failure
|
||||||
|
|
||||||
response.raise_for_status()
|
response.raise_for_status()
|
||||||
logger.info(f"Erfolgreich gesendet an Chat-ID {chat_id}")
|
logger.info(f"Erfolgreich gesendet an Chat-ID {chat_id}")
|
||||||
|
|
@ -131,7 +134,7 @@ class TelegramSender:
|
||||||
|
|
||||||
|
|
||||||
# ===========================
|
# ===========================
|
||||||
# BoswatchPlugin-Klasse
|
# BoswatchPlugin-Class
|
||||||
# ===========================
|
# ===========================
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -149,17 +152,19 @@ class BoswatchPlugin(PluginBase):
|
||||||
logger.error("botToken oder chatIds fehlen in der Konfiguration!")
|
logger.error("botToken oder chatIds fehlen in der Konfiguration!")
|
||||||
return
|
return
|
||||||
|
|
||||||
# Konfigurierbare Parameter mit Fallback-Defaults
|
# configurable parameters with fallback defaults
|
||||||
max_retries = self.config.get("max_retries")
|
max_retries = self.config.get("max_retries")
|
||||||
initial_delay = self.config.get("initial_delay")
|
initial_delay = self.config.get("initial_delay")
|
||||||
max_delay = self.config.get("max_delay")
|
max_delay = self.config.get("max_delay")
|
||||||
|
parse_mode = self.config.get("parse_mode")
|
||||||
|
|
||||||
self.sender = TelegramSender(
|
self.sender = TelegramSender(
|
||||||
bot_token=bot_token,
|
bot_token=bot_token,
|
||||||
chat_ids=chat_ids,
|
chat_ids=chat_ids,
|
||||||
max_retries=max_retries,
|
max_retries=max_retries,
|
||||||
initial_delay=initial_delay,
|
initial_delay=initial_delay,
|
||||||
max_delay=max_delay
|
max_delay=max_delay,
|
||||||
|
parse_mode=parse_mode
|
||||||
)
|
)
|
||||||
|
|
||||||
startup_message = self.config.get("startup_message")
|
startup_message = self.config.get("startup_message")
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue