Skip to content

Commit f3c4db6

Browse files
committed
Add Telegram group and topic notifications
1 parent 27bcbfa commit f3c4db6

8 files changed

Lines changed: 147 additions & 9 deletions

File tree

.env.example

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,3 +36,5 @@ STOPLIGA_MAX_RESPONSE_BYTES=2097152
3636
# Optional Telegram notifications.
3737
# STOPLIGA_TELEGRAM_BOT_TOKEN=123456:replace-me
3838
# STOPLIGA_TELEGRAM_CHAT_ID=123456789
39+
# STOPLIGA_TELEGRAM_GROUP_ID=-1001234567890
40+
# STOPLIGA_TELEGRAM_TOPIC_ID=42

README.md

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -133,8 +133,18 @@ STOPLIGA_MAX_RESPONSE_BYTES=2097152
133133
# STOPLIGA_GOTIFY_VERIFY_TLS=true
134134
# STOPLIGA_TELEGRAM_BOT_TOKEN=123456:replace-me
135135
# STOPLIGA_TELEGRAM_CHAT_ID=123456789
136+
# STOPLIGA_TELEGRAM_GROUP_ID=-1001234567890
137+
# STOPLIGA_TELEGRAM_TOPIC_ID=42
136138
```
137139

140+
Telegram options:
141+
142+
- `STOPLIGA_TELEGRAM_CHAT_ID`: send to a private chat or to any chat id you already use today
143+
- `STOPLIGA_TELEGRAM_GROUP_ID`: explicit target for a Telegram group or supergroup
144+
- `STOPLIGA_TELEGRAM_TOPIC_ID`: optional forum topic id inside that Telegram group
145+
- set either `STOPLIGA_TELEGRAM_CHAT_ID` or `STOPLIGA_TELEGRAM_GROUP_ID`, not both
146+
- if `STOPLIGA_TELEGRAM_TOPIC_ID` is set, StopLiga sends the message with Telegram `message_thread_id`
147+
138148
## Sync Cycle
139149

140150
With `STOPLIGA_SYNC_INTERVAL_SECONDS=300`, StopLiga runs a full sync every 5 minutes:

config.example.toml

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -51,3 +51,5 @@ verify_tls = false
5151
# gotify_verify_tls = true
5252
# telegram_bot_token = "123456:replace-me"
5353
# telegram_chat_id = "123456789"
54+
# telegram_group_id = "-1001234567890"
55+
# telegram_topic_id = 42

src/stopliga/config.py

Lines changed: 22 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -200,6 +200,8 @@ def load_config_file(path: Path | None) -> dict[str, Any]:
200200
"gotify_priority": notifications.get("gotify_priority"),
201201
"telegram_bot_token": notifications.get("telegram_bot_token"),
202202
"telegram_chat_id": notifications.get("telegram_chat_id"),
203+
"telegram_group_id": notifications.get("telegram_group_id"),
204+
"telegram_topic_id": notifications.get("telegram_topic_id"),
203205
"notification_timeout": notifications.get("timeout"),
204206
"notification_retries": notifications.get("retries"),
205207
"notification_verify_tls": notifications.get("verify_tls"),
@@ -260,6 +262,8 @@ def build_parser() -> argparse.ArgumentParser:
260262
parser.add_argument("--gotify-priority", type=int, default=None, help="Gotify priority")
261263
parser.add_argument("--telegram-bot-token", default=None, help="Telegram bot token")
262264
parser.add_argument("--telegram-chat-id", default=None, help="Telegram user/chat id")
265+
parser.add_argument("--telegram-group-id", default=None, help="Telegram group or supergroup id")
266+
parser.add_argument("--telegram-topic-id", type=int, default=None, help="Telegram forum topic id")
263267
parser.add_argument("--notification-timeout", type=float, default=None, help="Notification HTTP timeout in seconds")
264268
parser.add_argument("--notification-retries", type=int, default=None, help="Notification retry count")
265269

@@ -447,6 +451,13 @@ def load_config(args: argparse.Namespace, environ: Mapping[str, str] | None = No
447451
telegram_chat_id=str(
448452
_first(args.telegram_chat_id, _env_value(env, "STOPLIGA_TELEGRAM_CHAT_ID"), file_cfg.get("telegram_chat_id"), DEFAULTS.telegram_chat_id)
449453
) if _first(args.telegram_chat_id, _env_value(env, "STOPLIGA_TELEGRAM_CHAT_ID"), file_cfg.get("telegram_chat_id"), DEFAULTS.telegram_chat_id) is not None else None,
454+
telegram_group_id=str(
455+
_first(args.telegram_group_id, _env_value(env, "STOPLIGA_TELEGRAM_GROUP_ID"), file_cfg.get("telegram_group_id"), DEFAULTS.telegram_group_id)
456+
) if _first(args.telegram_group_id, _env_value(env, "STOPLIGA_TELEGRAM_GROUP_ID"), file_cfg.get("telegram_group_id"), DEFAULTS.telegram_group_id) is not None else None,
457+
telegram_topic_id=_parse_int(
458+
_first(args.telegram_topic_id, _env_value(env, "STOPLIGA_TELEGRAM_TOPIC_ID"), file_cfg.get("telegram_topic_id")),
459+
field_name="telegram_topic_id",
460+
) if _first(args.telegram_topic_id, _env_value(env, "STOPLIGA_TELEGRAM_TOPIC_ID"), file_cfg.get("telegram_topic_id")) is not None else None,
450461
notification_timeout=_parse_float(
451462
_first(args.notification_timeout, _env_value(env, "STOPLIGA_NOTIFICATION_TIMEOUT"), file_cfg.get("notification_timeout"), DEFAULTS.notification_timeout),
452463
field_name="notification_timeout",
@@ -509,8 +520,17 @@ def validate_config(config: Config, *, validate_connection: bool) -> None:
509520
raise ConfigError("Automatic route creation requires both STOPLIGA_VPN_NAME and STOPLIGA_TARGETS")
510521
if bool(config.gotify_url) != bool(config.gotify_token):
511522
raise ConfigError("Gotify notifications require both STOPLIGA_GOTIFY_URL and STOPLIGA_GOTIFY_TOKEN")
512-
if bool(config.telegram_bot_token) != bool(config.telegram_chat_id):
513-
raise ConfigError("Telegram notifications require both STOPLIGA_TELEGRAM_BOT_TOKEN and STOPLIGA_TELEGRAM_CHAT_ID")
523+
if config.telegram_chat_id and config.telegram_group_id:
524+
raise ConfigError("Set either STOPLIGA_TELEGRAM_CHAT_ID or STOPLIGA_TELEGRAM_GROUP_ID, not both")
525+
telegram_target = config.resolved_telegram_chat_id()
526+
if bool(config.telegram_bot_token) != bool(telegram_target):
527+
raise ConfigError(
528+
"Telegram notifications require STOPLIGA_TELEGRAM_BOT_TOKEN and either STOPLIGA_TELEGRAM_CHAT_ID or STOPLIGA_TELEGRAM_GROUP_ID"
529+
)
530+
if config.telegram_topic_id is not None and not telegram_target:
531+
raise ConfigError("STOPLIGA_TELEGRAM_TOPIC_ID requires STOPLIGA_TELEGRAM_GROUP_ID or STOPLIGA_TELEGRAM_CHAT_ID")
532+
if config.telegram_topic_id is not None and config.telegram_topic_id <= 0:
533+
raise ConfigError("STOPLIGA_TELEGRAM_TOPIC_ID must be > 0")
514534
if validate_connection:
515535
_validate_unifi_host(config.host or "")
516536
if config.gotify_url:

src/stopliga/models.py

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,8 @@ class Config:
5252
gotify_priority: int = 5
5353
telegram_bot_token: str | None = None
5454
telegram_chat_id: str | None = None
55+
telegram_group_id: str | None = None
56+
telegram_topic_id: int | None = None
5557
notification_timeout: float = 10.0
5658
notification_retries: int = 2
5759
notification_verify_tls: bool = True
@@ -68,9 +70,12 @@ def has_local_api_access(self) -> bool:
6870
def has_notifications(self) -> bool:
6971
return bool(
7072
(self.gotify_url and self.gotify_token)
71-
or (self.telegram_bot_token and self.telegram_chat_id)
73+
or (self.telegram_bot_token and self.resolved_telegram_chat_id())
7274
)
7375

76+
def resolved_telegram_chat_id(self) -> str | None:
77+
return self.telegram_group_id or self.telegram_chat_id
78+
7479
def resolved_health_max_age(self) -> int:
7580
if self.health_max_age_seconds is not None and self.health_max_age_seconds > 0:
7681
return self.health_max_age_seconds

src/stopliga/notifier.py

Lines changed: 10 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -165,17 +165,21 @@ def send_notifications(config: Config, result: SyncResult, previous_state: dict[
165165
failures["gotify"] = str(exc)
166166
log_event(logger, logging.ERROR, "notification_provider_failed", provider="gotify", error=exc)
167167

168-
if config.telegram_bot_token and config.telegram_chat_id:
168+
telegram_target = config.resolved_telegram_chat_id()
169+
if config.telegram_bot_token and telegram_target:
169170
try:
170171
telegram_url = f"https://api.telegram.org/bot{config.telegram_bot_token}/sendMessage"
171172
request_config = _telegram_request_config(config)
173+
telegram_payload = {
174+
"chat_id": telegram_target,
175+
"text": message,
176+
"disable_web_page_preview": True,
177+
}
178+
if config.telegram_topic_id is not None:
179+
telegram_payload["message_thread_id"] = config.telegram_topic_id
172180
_post_json(
173181
telegram_url,
174-
{
175-
"chat_id": config.telegram_chat_id,
176-
"text": message,
177-
"disable_web_page_preview": True,
178-
},
182+
telegram_payload,
179183
timeout=request_config.timeout,
180184
retries=request_config.retries,
181185
verify_tls=request_config.verify_tls,

tests/test_config.py

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -144,6 +144,50 @@ def test_partial_telegram_configuration_is_rejected(self) -> None:
144144
},
145145
)
146146

147+
def test_telegram_group_and_chat_id_are_mutually_exclusive(self) -> None:
148+
parser = build_parser()
149+
args = parser.parse_args([])
150+
with self.assertRaises(ConfigError):
151+
load_config(
152+
args,
153+
{
154+
"UNIFI_HOST": "10.0.0.2",
155+
"UNIFI_API_KEY": "test-api-key",
156+
"STOPLIGA_TELEGRAM_BOT_TOKEN": "123456:test",
157+
"STOPLIGA_TELEGRAM_CHAT_ID": "1234",
158+
"STOPLIGA_TELEGRAM_GROUP_ID": "-1001234567890",
159+
},
160+
)
161+
162+
def test_telegram_topic_requires_chat_target(self) -> None:
163+
parser = build_parser()
164+
args = parser.parse_args([])
165+
with self.assertRaises(ConfigError):
166+
load_config(
167+
args,
168+
{
169+
"UNIFI_HOST": "10.0.0.2",
170+
"UNIFI_API_KEY": "test-api-key",
171+
"STOPLIGA_TELEGRAM_TOPIC_ID": "42",
172+
},
173+
)
174+
175+
def test_telegram_group_and_topic_load_successfully(self) -> None:
176+
parser = build_parser()
177+
args = parser.parse_args([])
178+
config = load_config(
179+
args,
180+
{
181+
"UNIFI_HOST": "10.0.0.2",
182+
"UNIFI_API_KEY": "test-api-key",
183+
"STOPLIGA_TELEGRAM_BOT_TOKEN": "123456:test",
184+
"STOPLIGA_TELEGRAM_GROUP_ID": "-1001234567890",
185+
"STOPLIGA_TELEGRAM_TOPIC_ID": "42",
186+
},
187+
)
188+
self.assertEqual(config.telegram_group_id, "-1001234567890")
189+
self.assertEqual(config.telegram_topic_id, 42)
190+
147191
def test_empty_route_name_is_rejected(self) -> None:
148192
parser = build_parser()
149193
args = parser.parse_args([])

tests/test_integration.py

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1054,6 +1054,57 @@ def fake_post_json(
10541054
self.assertIn("Block status: ACTIVE -> INACTIVE", state.telegram_messages[0]["text"])
10551055
self.assertIn("Blocking: INACTIVE", state.telegram_messages[0]["text"])
10561056

1057+
def test_telegram_notification_can_target_group_topic(self) -> None:
1058+
state = FakeState(
1059+
status_payload={"isBlocked": False},
1060+
ip_lines=["192.0.2.10"],
1061+
route={
1062+
"_id": "route-1",
1063+
"name": "LaLiga",
1064+
"enabled": True,
1065+
"network_id": "vpn-network-1",
1066+
"target_devices": [{"client_mac": "aa:bb:cc:dd:ee:01", "type": "CLIENT"}],
1067+
"ip_addresses": [{"ip_or_subnet": "192.0.2.10", "ip_version": "IPv4"}],
1068+
},
1069+
)
1070+
with tempfile.TemporaryDirectory() as tmpdir, TestServer(state, https=True) as unifi, TestServer(state, https=False) as feed:
1071+
state_path = Path(tmpdir) / "state.json"
1072+
state_path.write_text(json.dumps({"last_is_blocked": True}), encoding="utf-8")
1073+
config = self.make_config(
1074+
state_dir=tmpdir,
1075+
port=int(unifi.base_url.rsplit(":", 1)[1]),
1076+
status_url=f"{feed.base_url}/feed/status.json",
1077+
ip_list_url=f"{feed.base_url}/feed/ip_list.txt",
1078+
telegram_bot_token=f"{feed.base_url}/telegram/bot-token".replace(f"{feed.base_url}/", ""),
1079+
telegram_group_id="-1009876543210",
1080+
telegram_topic_id=77,
1081+
)
1082+
import stopliga.notifier as notifier # noqa: WPS433
1083+
1084+
original_post_json = notifier._post_json
1085+
1086+
def fake_post_json(
1087+
url: str,
1088+
payload: dict[str, Any],
1089+
*,
1090+
timeout: float,
1091+
retries: int,
1092+
verify_tls: bool,
1093+
ca_file: Any,
1094+
) -> None:
1095+
state.telegram_messages.append({"url": url, **payload})
1096+
1097+
notifier._post_json = fake_post_json
1098+
try:
1099+
StopLigaService(config).run_once()
1100+
finally:
1101+
notifier._post_json = original_post_json
1102+
1103+
self.assertEqual(len(state.telegram_messages), 1)
1104+
self.assertEqual(state.telegram_messages[0]["chat_id"], "-1009876543210")
1105+
self.assertEqual(state.telegram_messages[0]["message_thread_id"], 77)
1106+
self.assertIn("Block status: ACTIVE -> INACTIVE", state.telegram_messages[0]["text"])
1107+
10571108
def test_notification_error_redacts_telegram_token(self) -> None:
10581109
safe = _safe_notification_url("https://api.telegram.org/bot123456:secret-token/sendMessage")
10591110
self.assertEqual(safe, "https://api.telegram.org/bot***/sendMessage")

0 commit comments

Comments
 (0)