4b6817381b
CI (OpenClaw E2E) / openclaw test (push) Has been cancelled
CI / coverage-report (push) Has been cancelled
CI / test-kubernetes (push) Has been cancelled
CI / should-run-thorough (push) Has been cancelled
CI / test-thorough (cloudwatch-demo) (push) Has been cancelled
CI / test-thorough (flink-ecs) (push) Has been cancelled
CI / test-thorough (upstream-lambda) (push) Has been cancelled
CI / test-thorough (prefect-ecs-fargate) (push) Has been cancelled
Release / build-binaries (zip, opensre.exe, onefile, windows-latest, windows-x64) (push) Has been cancelled
Benchmark image — build + push to ECR (any adapter) / build + push (push) Has been cancelled
CI / quality (ubuntu-latest) (push) Has been cancelled
CI / test (tools-runtime) (push) Has been cancelled
CI / test (e2e-general) (push) Has been cancelled
CI / test (cli-runtime) (push) Has been cancelled
CI / test (e2e-provider-and-openclaw) (push) Has been cancelled
CI / test (integrations-and-misc) (push) Has been cancelled
Release / verify (push) Has been cancelled
Release / build-python-dist (push) Has been cancelled
Release / build-binaries (tar.gz, opensre, onedir, macos-15-intel, darwin-x64) (push) Has been cancelled
Release / build-binaries (tar.gz, opensre, onedir, macos-latest, darwin-arm64) (push) Has been cancelled
Release / build-binaries (tar.gz, opensre, onedir, ubuntu-22.04, linux-x64) (push) Has been cancelled
Release / publish-release (push) Has been cancelled
Release / publish-main-release (push) Has been cancelled
Interactive Shell Live (PR + post-merge) / turn-checks (no-LLM) (push) Has been cancelled
CodeQL / Analyze (python) (push) Has been cancelled
Interactive Shell Live (PR + post-merge) / turn-live shard ${{ matrix.shard_index }} (push) Has been cancelled
Release / prepare (push) Has been cancelled
Release / build-binaries (tar.gz, opensre, onedir, ubuntu-22.04-arm, linux-arm64) (push) Has been cancelled
Synthetic Deterministic Tests / Synthetic offline (deterministic) (push) Has been cancelled
110 lines
3.8 KiB
Python
110 lines
3.8 KiB
Python
"""Telegram alarm dispatcher with per-key cooldown.
|
|
|
|
Shared by features that need throttled Telegram alerts (watchdog thresholds,
|
|
Hermes incident sinks, the Telegram send-message tool). The dispatcher takes
|
|
a string key (e.g. a threshold name or incident fingerprint) and suppresses
|
|
repeat deliveries for the same key within the cooldown window.
|
|
|
|
Credential resolution lives in
|
|
:mod:`integrations.telegram.credentials`; raw transport in
|
|
:mod:`integrations.telegram.delivery`. This module owns only the
|
|
throttling + dispatch policy.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import logging
|
|
import threading
|
|
import time
|
|
|
|
from integrations.telegram.credentials import TelegramCredentials
|
|
from integrations.telegram.delivery import (
|
|
post_telegram_message,
|
|
truncate_for_telegram_html,
|
|
)
|
|
from platform.common.truncation import truncate
|
|
from platform.notifications.limits import MAX_MESSAGE_SIZE
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
_DEFAULT_COOLDOWN_SECONDS = 300.0
|
|
_TELEGRAM_MESSAGE_LIMIT = MAX_MESSAGE_SIZE
|
|
|
|
|
|
class AlarmDispatcher:
|
|
"""Dispatch Telegram alarms with per-key cooldown."""
|
|
|
|
def __init__(
|
|
self,
|
|
creds: TelegramCredentials,
|
|
*,
|
|
cooldown_seconds: float = _DEFAULT_COOLDOWN_SECONDS,
|
|
parse_mode: str = "",
|
|
) -> None:
|
|
self._creds = creds
|
|
self._cooldown_seconds = cooldown_seconds
|
|
self._parse_mode = parse_mode
|
|
self._last_dispatched: dict[str, float] = {}
|
|
self._lock = threading.Lock()
|
|
|
|
def dispatch(self, threshold_name: str, message: str) -> bool:
|
|
"""Send to Telegram unless this threshold is in cooldown."""
|
|
now = self._now()
|
|
|
|
# Reserve the cooldown slot under the lock BEFORE the network call so
|
|
# a concurrent dispatch on the same threshold sees the reservation and
|
|
# is suppressed. Without this, two threads could both pass the check
|
|
# (state of last_dispatched at "check" time != "use" time, classic
|
|
# TOCTOU) and both send.
|
|
with self._lock:
|
|
last = self._last_dispatched.get(threshold_name)
|
|
if last is not None and (now - last) < self._cooldown_seconds:
|
|
logger.debug(
|
|
"alarm suppressed by cooldown: name=%s remaining=%.1fs",
|
|
threshold_name,
|
|
self._cooldown_seconds - (now - last),
|
|
)
|
|
return False
|
|
self._last_dispatched[threshold_name] = now
|
|
|
|
if self._parse_mode.upper() == "HTML":
|
|
text = truncate_for_telegram_html(message, _TELEGRAM_MESSAGE_LIMIT, suffix="…")
|
|
else:
|
|
text = truncate(message, _TELEGRAM_MESSAGE_LIMIT, suffix="…")
|
|
|
|
# The cooldown slot was reserved before this network call (see lock
|
|
# block above). If ``post_telegram_message`` returns ``ok=False`` OR
|
|
# raises, the slot stays armed for the cooldown window and the next
|
|
# caller for the same key is silently suppressed — emit the same
|
|
# warning in both paths so operators see the original failure
|
|
# instead of only the suppression debug line.
|
|
try:
|
|
ok, error, _ = post_telegram_message(
|
|
chat_id=self._creds.chat_id,
|
|
text=text,
|
|
bot_token=self._creds.bot_token,
|
|
parse_mode=self._parse_mode,
|
|
)
|
|
except Exception as exc:
|
|
logger.warning(
|
|
"alarm delivery raised and cooldown remains armed: name=%s error=%s",
|
|
threshold_name,
|
|
exc,
|
|
exc_info=True,
|
|
)
|
|
return False
|
|
|
|
if ok:
|
|
return True
|
|
|
|
logger.warning(
|
|
"alarm delivery failed and cooldown remains armed: name=%s error=%s",
|
|
threshold_name,
|
|
error,
|
|
)
|
|
return False
|
|
|
|
@staticmethod
|
|
def _now() -> float:
|
|
return time.monotonic()
|