Files
notify-bridge/packages/server/src/notify_bridge_server/services/dispatch_helpers.py
T
alexei.dolgolyov 6c3dd67c1b feat(tracking): per-config quiet hours with app-level IANA timezone
Add quiet_hours_enabled/start/end to TrackingConfig (HH:MM strings
interpreted in the app-level timezone AppSetting). The dispatch path
loads the app timezone once per run and passes it through
event_allowed_by_config -> in_quiet_hours, so overnight windows like
22:00-07:00 work correctly in any IANA tz.

Frontend exposes a Timezone field under Settings and a Quiet Hours
section on the Immich tracking-config form with time-picker inputs.
2026-04-22 02:31:48 +03:00

347 lines
13 KiB
Python

"""Shared dispatch helpers used by both watcher and webhook handlers."""
from __future__ import annotations
import logging
from datetime import datetime, time, timezone
from typing import Any
from zoneinfo import ZoneInfo, ZoneInfoNotFoundError
from sqlmodel import select
from sqlmodel.ext.asyncio.session import AsyncSession
from notify_bridge_core.models.events import ServiceEvent
from notify_bridge_core.notifications.receiver import Receiver, build_receiver
from ..database.models import (
EmailBot,
MatrixBot,
NotificationTarget,
NotificationTracker,
NotificationTrackerTarget,
TargetReceiver,
TelegramBot,
TelegramChat,
TemplateConfig,
TemplateSlot,
TrackingConfig,
)
_LOGGER = logging.getLogger(__name__)
def _resolve_zoneinfo(tz_name: str | None) -> ZoneInfo:
"""Resolve an IANA tz string to a ZoneInfo, falling back to UTC on any error."""
if not tz_name:
return ZoneInfo("UTC")
try:
return ZoneInfo(tz_name)
except (ZoneInfoNotFoundError, ValueError):
_LOGGER.warning("Unknown timezone %r; falling back to UTC", tz_name)
return ZoneInfo("UTC")
def in_quiet_hours(
start: str | None,
end: str | None,
tz_name: str | None = "UTC",
) -> bool:
"""Check if the current time (in the given timezone) is within the quiet window.
HH:MM strings are interpreted in the supplied timezone. If either bound is
missing, quiet hours are disabled.
"""
if not start or not end:
return False
try:
tz = _resolve_zoneinfo(tz_name)
now = datetime.now(timezone.utc).astimezone(tz).time()
t_start = time.fromisoformat(start)
t_end = time.fromisoformat(end)
if t_start <= t_end:
return t_start <= now <= t_end
else:
# Overnight window (e.g., 22:00 - 06:00)
return now >= t_start or now <= t_end
except (ValueError, TypeError):
return False
async def get_app_timezone(session: AsyncSession) -> str:
"""Load the app-level timezone from AppSetting (falls back to UTC)."""
from ..api.app_settings import get_setting
value = await get_setting(session, "timezone")
return value or "UTC"
def event_allowed_by_config(
event: ServiceEvent,
tc: TrackingConfig,
tz_name: str | None = "UTC",
) -> bool:
"""Check if an event is allowed by the tracking config's flags + quiet hours."""
# Quiet hours gate every event type when enabled.
if tc.quiet_hours_enabled and in_quiet_hours(
tc.quiet_hours_start, tc.quiet_hours_end, tz_name
):
return False
event_type = event.event_type.value
flag_map = {
# Immich events
"assets_added": tc.track_assets_added,
"assets_removed": tc.track_assets_removed,
"collection_renamed": tc.track_collection_renamed,
"collection_deleted": tc.track_collection_deleted,
"sharing_changed": tc.track_sharing_changed,
# Gitea events
"push": tc.track_push,
"issue_opened": tc.track_issue_opened,
"issue_closed": tc.track_issue_closed,
"issue_commented": tc.track_issue_commented,
"pr_opened": tc.track_pr_opened,
"pr_closed": tc.track_pr_closed,
"pr_merged": tc.track_pr_merged,
"pr_commented": tc.track_pr_commented,
"release_published": tc.track_release_published,
# Planka events
"card_created": tc.track_card_created,
"card_updated": tc.track_card_updated,
"card_moved": tc.track_card_moved,
"card_deleted": tc.track_card_deleted,
"card_commented": tc.track_card_commented,
"comment_updated": tc.track_comment_updated,
"board_created": tc.track_board_created,
"board_updated": tc.track_board_updated,
"board_deleted": tc.track_board_deleted,
"list_created": tc.track_list_created,
"list_updated": tc.track_list_updated,
"list_deleted": tc.track_list_deleted,
"attachment_created": tc.track_attachment_created,
"card_label_added": tc.track_card_label_added,
"task_completed": tc.track_task_completed,
# Scheduler events
"scheduled_message": tc.track_scheduled_message,
# Generic Webhook events
"webhook_received": tc.track_webhook_received,
# NUT (UPS) events
"ups_online": tc.track_ups_online,
"ups_on_battery": tc.track_ups_on_battery,
"ups_low_battery": tc.track_ups_low_battery,
"ups_battery_restored": tc.track_ups_battery_restored,
"ups_comms_lost": tc.track_ups_comms_lost,
"ups_comms_restored": tc.track_ups_comms_restored,
"ups_replace_battery": tc.track_ups_replace_battery,
"ups_overload": tc.track_ups_overload,
}
return flag_map.get(event_type, True)
async def _resolve_target(
session: AsyncSession,
target: NotificationTarget,
) -> dict[str, Any]:
"""Resolve a single target into dispatch-ready data (config + receivers + credentials).
Returns a dict with target_type, target_config, and receivers.
Does NOT include tracking_config or template_slots — those come from the tracker link.
"""
# Load receivers as typed Receiver objects
recv_result = await session.exec(
select(TargetReceiver).where(
TargetReceiver.target_id == target.id,
TargetReceiver.enabled == True,
)
)
recv_rows = recv_result.all()
# For Telegram targets, resolve locale from TelegramChat
chat_locale_map: dict[str, str] = {}
if target.type == "telegram":
bot_id = target.config.get("bot_id")
if bot_id:
chat_ids = [str(r.config.get("chat_id", "")) for r in recv_rows if r.config.get("chat_id")]
if chat_ids:
chat_result = await session.exec(
select(TelegramChat).where(
TelegramChat.bot_id == bot_id,
TelegramChat.chat_id.in_(chat_ids),
)
)
for chat in chat_result.all():
resolved = (
getattr(chat, 'language_override', '') or
getattr(chat, 'language_code', '') or ''
)
if resolved:
chat_locale_map[chat.chat_id] = resolved[:2].lower()
receivers: list[Receiver] = []
for r in recv_rows:
explicit_locale = getattr(r, 'locale', '') or ''
locale = explicit_locale
if not locale and target.type == "telegram":
chat_id = str(r.config.get("chat_id", ""))
locale = chat_locale_map.get(chat_id, "")
receivers.append(build_receiver(target.type, dict(r.config), locale))
target_config = dict(target.config)
# Inject chat_action for Telegram targets
if hasattr(target, 'chat_action') and target.chat_action:
target_config["chat_action"] = target.chat_action
# Inject bot credentials for bot-backed target types
if target.type == "email":
email_bot_id = target.config.get("email_bot_id")
if email_bot_id:
email_bot = await session.get(EmailBot, email_bot_id)
if email_bot:
target_config["smtp"] = {
"host": email_bot.smtp_host,
"port": email_bot.smtp_port,
"username": email_bot.smtp_username,
"password": email_bot.smtp_password,
"from_address": email_bot.email,
"from_name": email_bot.name,
"use_tls": email_bot.smtp_use_tls,
}
elif target.type == "matrix":
matrix_bot_id = target.config.get("matrix_bot_id")
if matrix_bot_id:
matrix_bot = await session.get(MatrixBot, matrix_bot_id)
if matrix_bot:
target_config["homeserver_url"] = matrix_bot.homeserver_url
target_config["access_token"] = matrix_bot.access_token
return {
"target_type": target.type,
"target_config": target_config,
"receivers": receivers,
}
async def load_link_data(
session: AsyncSession,
tracker_id: int,
*,
check_quiet_hours: bool = False,
) -> list[dict[str, Any]]:
"""Load tracker-target link data for dispatch.
Args:
session: Active database session.
tracker_id: ID of the tracker whose links to load.
check_quiet_hours: If True, skip links currently in quiet hours.
"""
# Load the tracker itself for default config IDs
tracker = await session.get(NotificationTracker, tracker_id)
default_tc_id = getattr(tracker, "default_tracking_config_id", None) if tracker else None
default_tmpl_id = getattr(tracker, "default_template_config_id", None) if tracker else None
tt_result = await session.exec(
select(NotificationTrackerTarget).where(
NotificationTrackerTarget.tracker_id == tracker_id
)
)
tracker_targets = tt_result.all()
# Filter enabled links and quiet hours upfront
active_links = [
tt for tt in tracker_targets
if tt.enabled and not (check_quiet_hours and in_quiet_hours(tt.quiet_hours_start, tt.quiet_hours_end))
]
if not active_links:
return []
# Batch-load targets
target_ids = list({tt.target_id for tt in active_links})
target_result = await session.exec(
select(NotificationTarget).where(NotificationTarget.id.in_(target_ids))
)
target_map = {t.id: t for t in target_result.all()}
# Batch-load tracking configs (per-link + tracker default)
tc_ids = list({tid for tid in
[tt.tracking_config_id for tt in active_links] + [default_tc_id]
if tid})
tc_map: dict[int, TrackingConfig] = {}
if tc_ids:
tc_result = await session.exec(select(TrackingConfig).where(TrackingConfig.id.in_(tc_ids)))
tc_map = {tc.id: tc for tc in tc_result.all()}
# Batch-load template configs (per-link + tracker default)
tmpl_ids = list({tid for tid in
[tt.template_config_id for tt in active_links] + [default_tmpl_id]
if tid})
tmpl_map: dict[int, TemplateConfig] = {}
if tmpl_ids:
tmpl_result = await session.exec(select(TemplateConfig).where(TemplateConfig.id.in_(tmpl_ids)))
tmpl_map = {tc.id: tc for tc in tmpl_result.all()}
# Batch-load template slots for all template configs
slots_by_config: dict[int, dict[str, dict[str, str]]] = {}
if tmpl_ids:
slot_result = await session.exec(
select(TemplateSlot).where(TemplateSlot.config_id.in_(tmpl_ids))
)
for s in slot_result.all():
event_key = s.slot_name.removeprefix("message_") if s.slot_name.startswith("message_") else s.slot_name
slots_by_config.setdefault(s.config_id, {}).setdefault(event_key, {})[s.locale] = s.template
# Pre-resolve broadcast children in one query to avoid N+1 per-child fetches
broadcast_child_ids: set[int] = set()
for tt in active_links:
target = target_map.get(tt.target_id)
if target and target.type == "broadcast":
disabled_ids = set(target.config.get("disabled_child_ids", []))
for cid in target.config.get("child_target_ids", []):
if cid not in disabled_ids:
broadcast_child_ids.add(cid)
child_target_map: dict[int, NotificationTarget] = {}
if broadcast_child_ids:
child_rows = await session.exec(
select(NotificationTarget).where(NotificationTarget.id.in_(broadcast_child_ids))
)
child_target_map = {t.id: t for t in child_rows.all()}
link_data: list[dict[str, Any]] = []
for tt in active_links:
target = target_map.get(tt.target_id)
if not target:
continue
# Per-link config overrides tracker defaults
tc_id = tt.tracking_config_id or default_tc_id
tmpl_id = tt.template_config_id or default_tmpl_id
tracking_config = tc_map.get(tc_id) if tc_id else None
template_config = tmpl_map.get(tmpl_id) if tmpl_id else None
template_slots = slots_by_config.get(template_config.id) if template_config else None
# Broadcast target: expand into child targets (pre-loaded above)
if target.type == "broadcast":
disabled_ids = set(target.config.get("disabled_child_ids", []))
for child_id in target.config.get("child_target_ids", []):
if child_id in disabled_ids:
continue
child_target = child_target_map.get(child_id)
if not child_target or child_target.type == "broadcast":
continue
resolved = await _resolve_target(session, child_target)
link_data.append({
**resolved,
"tracking_config": tracking_config,
"template_config": template_config,
"template_slots": template_slots,
})
continue
# Regular target
resolved = await _resolve_target(session, target)
link_data.append({
**resolved,
"tracking_config": tracking_config,
"template_config": template_config,
"template_slots": template_slots,
})
return link_data