init
This commit is contained in:
121 files changed
+17641
No files matched your search
@@ -0,0 +1,169 @@
|
||||
"""Outbound messages: Telegram first, Slack next, the log as a last resort.
|
||||
|
||||
The chunking, the HTML parse mode and the "Telegram > Slack > console" order are
|
||||
jira-helper's, because they are the right answers. What is added here is quiet
|
||||
hours and a record of what was sent, so the nudges cannot turn into spam.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from dataclasses import dataclass
|
||||
from datetime import date
|
||||
|
||||
import httpx
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.models import EventLog
|
||||
from app.settings_schema import AppSettings
|
||||
from app.timeutil import in_window, local_date, local_time
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
# Telegram rejects messages longer than 4096 characters; leave headroom.
|
||||
_TELEGRAM_MAX_CHARS = 4000
|
||||
_TIMEOUT = 20
|
||||
|
||||
|
||||
class NotifyError(RuntimeError):
|
||||
pass
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class Delivery:
|
||||
channel: str
|
||||
ok: bool
|
||||
detail: str = ""
|
||||
|
||||
def as_dict(self) -> dict[str, object]:
|
||||
return {"channel": self.channel, "ok": self.ok, "detail": self.detail}
|
||||
|
||||
|
||||
def _chunk(text: str, size: int = _TELEGRAM_MAX_CHARS) -> list[str]:
|
||||
"""Split on line boundaries so a message never exceeds Telegram's limit."""
|
||||
chunks: list[str] = []
|
||||
current = ""
|
||||
for line in text.split("\n"):
|
||||
candidate = f"{current}\n{line}" if current else line
|
||||
if len(candidate) > size and current:
|
||||
chunks.append(current)
|
||||
current = line
|
||||
else:
|
||||
current = candidate
|
||||
if current:
|
||||
chunks.append(current)
|
||||
return chunks or [""]
|
||||
|
||||
|
||||
def send_telegram(
|
||||
settings: AppSettings, text: str, *, reply_markup: dict | None = None
|
||||
) -> Delivery:
|
||||
telegram = settings.notifications.telegram
|
||||
if not (telegram.enabled and telegram.bot_token and telegram.chat_id):
|
||||
return Delivery("telegram", False, "Telegram is not configured.")
|
||||
|
||||
url = f"https://api.telegram.org/bot{telegram.bot_token}/sendMessage"
|
||||
parts = _chunk(text)
|
||||
try:
|
||||
with httpx.Client(timeout=_TIMEOUT) as client:
|
||||
for index, part in enumerate(parts):
|
||||
payload = {
|
||||
"chat_id": telegram.chat_id,
|
||||
"text": part,
|
||||
"parse_mode": "HTML",
|
||||
"disable_web_page_preview": True,
|
||||
}
|
||||
# Buttons belong on the last chunk, where the message ends.
|
||||
if reply_markup and index == len(parts) - 1:
|
||||
payload["reply_markup"] = reply_markup
|
||||
resp = client.post(url, json=payload)
|
||||
if not resp.is_success:
|
||||
return Delivery(
|
||||
"telegram", False, f"{resp.status_code}: {resp.text[:200]}"
|
||||
)
|
||||
except httpx.HTTPError as exc:
|
||||
return Delivery("telegram", False, str(exc))
|
||||
return Delivery("telegram", True, f"{len(parts)} message(s)")
|
||||
|
||||
|
||||
def send_slack(settings: AppSettings, text: str) -> Delivery:
|
||||
slack = settings.notifications.slack
|
||||
if not (slack.enabled and slack.webhook_url):
|
||||
return Delivery("slack", False, "Slack is not configured.")
|
||||
try:
|
||||
with httpx.Client(timeout=_TIMEOUT) as client:
|
||||
resp = client.post(slack.webhook_url, json={"text": _strip_html(text)})
|
||||
if not resp.is_success:
|
||||
return Delivery("slack", False, f"{resp.status_code}: {resp.text[:200]}")
|
||||
except httpx.HTTPError as exc:
|
||||
return Delivery("slack", False, str(exc))
|
||||
return Delivery("slack", True, "ok")
|
||||
|
||||
|
||||
def _strip_html(text: str) -> str:
|
||||
import re
|
||||
|
||||
return re.sub(r"<[^>]+>", "", text)
|
||||
|
||||
|
||||
def in_quiet_hours(settings: AppSettings) -> bool:
|
||||
quiet = settings.notifications.telegram.quiet_hours
|
||||
if not quiet.enabled:
|
||||
return False
|
||||
return in_window(local_time(settings.timezone), quiet.start, quiet.end)
|
||||
|
||||
|
||||
def notify(
|
||||
settings: AppSettings,
|
||||
text: str,
|
||||
*,
|
||||
urgent: bool = False,
|
||||
reply_markup: dict | None = None,
|
||||
) -> list[Delivery]:
|
||||
"""Send to every configured channel. `urgent` ignores quiet hours."""
|
||||
if not urgent and in_quiet_hours(settings):
|
||||
return [Delivery("quiet-hours", False, "suppressed by quiet hours")]
|
||||
|
||||
deliveries: list[Delivery] = []
|
||||
telegram = send_telegram(settings, text, reply_markup=reply_markup)
|
||||
if telegram.ok or telegram.detail != "Telegram is not configured.":
|
||||
deliveries.append(telegram)
|
||||
slack = send_slack(settings, text)
|
||||
if slack.ok or slack.detail != "Slack is not configured.":
|
||||
deliveries.append(slack)
|
||||
if not deliveries:
|
||||
log.info("notification (no channel configured):\n%s", _strip_html(text))
|
||||
deliveries.append(Delivery("log", True, "written to the server log"))
|
||||
return deliveries
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# "Did we already send this today?"
|
||||
# --------------------------------------------------------------------------
|
||||
def already_sent(session: Session, kind: str, day: date) -> bool:
|
||||
return (
|
||||
session.scalar(
|
||||
select(EventLog.id).where(EventLog.kind == kind, EventLog.local_date == day)
|
||||
)
|
||||
is not None
|
||||
)
|
||||
|
||||
|
||||
def count_sent(session: Session, kind: str, day: date) -> int:
|
||||
return len(
|
||||
list(
|
||||
session.scalars(
|
||||
select(EventLog.id).where(EventLog.kind == kind, EventLog.local_date == day)
|
||||
)
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def record_sent(session: Session, kind: str, day: date, payload: dict | None = None) -> None:
|
||||
session.add(EventLog(kind=kind, local_date=day, payload=payload or {}))
|
||||
session.commit()
|
||||
|
||||
|
||||
def today(settings: AppSettings) -> date:
|
||||
return local_date(settings.timezone)
|
||||
Reference in new issue
Block a user