feat: comprehensive code review fixes + receivers-only architecture
Security:
- Refuse startup with default secret_key in production (was just logging)
- Settings endpoint now requires admin role
- Password validation on initial setup
- DOM-based HTML sanitizer replaces regex in template previews
- Add *.log to .gitignore
Performance & reliability:
- Token refresh deduplication prevents race condition on concurrent 401s
- Theme media query listener registered once (no leak)
- IconPicker uses $derived instead of function call per render
- Snackbar uses single-batch state update instead of while loop
- Replace 11 inline hover handlers with CSS :hover in layout
Architecture - receivers-only:
- Delivery endpoints (chat_id, email, url, room_id, topic) now stored
exclusively in TargetReceiver rows, never in target.config
- Migration extracts existing delivery fields to receiver rows
- Notifier and dispatcher remove all config fallbacks
- Frontend targets page shows receivers list per target with
add/remove/toggle/test per receiver
- Single-receiver test endpoint: POST /targets/{id}/receivers/{id}/test
Code quality:
- Extract AuthLayout.svelte from login/setup (150 lines CSS dedup)
- Split telegram-bots page (754→51 lines + 3 tab components)
- Split notification-trackers page (547→432 lines + 4 components)
- Deduplicate _send_reply into shared handler.send_reply()
- Add locale column to template models, replace name-based detection
- Fix delete_notification_tracker dead protection check
- Fix check_telegram_bot query (filter by type, remove bogus OR)
- Add graceful scheduler shutdown in lifespan
- Consistent /bots?tab=telegram URLs across all nav links
i18n:
- Error page, chat actions, target types, provider types internationalized
- All new receiver UI strings in EN + RU
This commit is contained in:
@@ -8,7 +8,7 @@ from pydantic import BaseModel
|
||||
from sqlmodel import select
|
||||
from sqlmodel.ext.asyncio.session import AsyncSession
|
||||
|
||||
from ..auth.dependencies import get_current_user
|
||||
from ..auth.dependencies import require_admin
|
||||
from ..database.engine import get_session
|
||||
from ..database.models import AppSetting, TelegramBot, User
|
||||
|
||||
@@ -51,7 +51,7 @@ class SettingsUpdate(BaseModel):
|
||||
|
||||
@router.get("")
|
||||
async def get_settings(
|
||||
user: User = Depends(get_current_user),
|
||||
user: User = Depends(require_admin),
|
||||
session: AsyncSession = Depends(get_session),
|
||||
):
|
||||
"""Return all app settings."""
|
||||
@@ -64,7 +64,7 @@ async def get_settings(
|
||||
@router.put("")
|
||||
async def update_settings(
|
||||
body: SettingsUpdate,
|
||||
user: User = Depends(get_current_user),
|
||||
user: User = Depends(require_admin),
|
||||
session: AsyncSession = Depends(get_session),
|
||||
):
|
||||
"""Update app settings (admin). Re-registers webhooks when base URL changes."""
|
||||
|
||||
@@ -173,7 +173,11 @@ async def _find_system_default_template(
|
||||
)
|
||||
)
|
||||
templates = result.all()
|
||||
# Match by locale suffix in name, e.g. "(EN)" or "(RU)"
|
||||
# Match by locale column first, fall back to name suffix
|
||||
locale_lower = locale_upper.lower()
|
||||
for tpl in templates:
|
||||
if tpl.locale == locale_lower:
|
||||
return tpl
|
||||
for tpl in templates:
|
||||
if f"({locale_upper})" in tpl.name:
|
||||
return tpl
|
||||
|
||||
@@ -47,12 +47,12 @@ async def check_telegram_bot(session: AsyncSession, bot_id: int) -> list[str]:
|
||||
"""Check if a TelegramBot is used by any targets or command listeners."""
|
||||
consumers = []
|
||||
# Check notification targets with this bot in config
|
||||
result = await session.exec(select(NotificationTarget))
|
||||
result = await session.exec(
|
||||
select(NotificationTarget).where(NotificationTarget.type == "telegram")
|
||||
)
|
||||
for t in result.all():
|
||||
if t.config.get("bot_id") == bot_id or t.config.get("bot_token"):
|
||||
# Need to verify it's actually this bot
|
||||
if t.config.get("bot_id") == bot_id:
|
||||
consumers.append(f"Target: {t.name}")
|
||||
if t.config.get("bot_id") == bot_id:
|
||||
consumers.append(f"Target: {t.name}")
|
||||
# Check command tracker listeners
|
||||
result = await session.exec(
|
||||
select(CommandTrackerListener).where(
|
||||
|
||||
@@ -111,9 +111,7 @@ async def delete_notification_tracker(
|
||||
user: User = Depends(get_current_user),
|
||||
session: AsyncSession = Depends(get_session),
|
||||
):
|
||||
from .delete_protection import check_notification_tracker, raise_if_used
|
||||
tracker = await _get_user_tracker(session, tracker_id, user.id)
|
||||
raise_if_used(await check_notification_tracker(session, tracker.id), tracker.name)
|
||||
# Delete associated tracker-target links
|
||||
result = await session.exec(
|
||||
select(NotificationTrackerTarget).where(NotificationTrackerTarget.tracker_id == tracker_id)
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
import logging
|
||||
from typing import Any
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, status
|
||||
from pydantic import BaseModel
|
||||
from sqlmodel import select
|
||||
from sqlmodel.ext.asyncio.session import AsyncSession
|
||||
@@ -11,6 +11,7 @@ from sqlmodel.ext.asyncio.session import AsyncSession
|
||||
from ..auth.dependencies import get_current_user
|
||||
from ..database.engine import get_session
|
||||
from ..database.models import NotificationTarget, TargetReceiver, User
|
||||
from ..services.notifier import send_to_receiver
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
@@ -117,6 +118,25 @@ async def update_receiver(
|
||||
return _response(receiver)
|
||||
|
||||
|
||||
@router.post("/{receiver_id}/test")
|
||||
async def test_receiver(
|
||||
target_id: int,
|
||||
receiver_id: int,
|
||||
locale: str = Query("en"),
|
||||
user: User = Depends(get_current_user),
|
||||
session: AsyncSession = Depends(get_session),
|
||||
):
|
||||
"""Send a test notification to a single receiver."""
|
||||
target = await _get_user_target(session, target_id, user.id)
|
||||
receiver = await session.get(TargetReceiver, receiver_id)
|
||||
if not receiver or receiver.target_id != target_id:
|
||||
raise HTTPException(status_code=404, detail="Receiver not found")
|
||||
|
||||
from ..services.notifier import _get_test_message
|
||||
message = _get_test_message(locale, target.type)
|
||||
return await send_to_receiver(target, dict(receiver.config), message)
|
||||
|
||||
|
||||
@router.delete("/{receiver_id}", status_code=status.HTTP_204_NO_CONTENT)
|
||||
async def delete_receiver(
|
||||
target_id: int,
|
||||
|
||||
@@ -12,11 +12,46 @@ from ..auth.dependencies import get_current_user
|
||||
from ..database.engine import get_session
|
||||
from ..database.models import NotificationTarget, NotificationTrackerTarget, TargetReceiver, TelegramBot, TelegramChat, User
|
||||
from ..services.notifier import send_test_notification
|
||||
from .target_receivers import _receiver_key
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
router = APIRouter(prefix="/api/targets", tags=["targets"])
|
||||
|
||||
# Delivery fields that belong in TargetReceiver, NOT in target.config
|
||||
_DELIVERY_FIELDS: dict[str, str] = {
|
||||
"telegram": "chat_id",
|
||||
"webhook": "url",
|
||||
"email": "email",
|
||||
"discord": "webhook_url",
|
||||
"slack": "webhook_url",
|
||||
"ntfy": "topic",
|
||||
"matrix": "room_id",
|
||||
}
|
||||
|
||||
|
||||
def _extract_delivery_fields(target_type: str, config: dict[str, Any]) -> tuple[dict[str, Any], dict[str, Any]]:
|
||||
"""Split config into (clean_config, receiver_config).
|
||||
|
||||
Returns the target config with delivery fields removed,
|
||||
and a receiver config dict (empty if no delivery field found).
|
||||
"""
|
||||
field = _DELIVERY_FIELDS.get(target_type)
|
||||
if not field:
|
||||
return dict(config), {}
|
||||
|
||||
clean = dict(config)
|
||||
receiver_cfg: dict[str, Any] = {}
|
||||
|
||||
value = clean.pop(field, None)
|
||||
if value:
|
||||
receiver_cfg[field] = value
|
||||
# For webhook, also move headers to receiver config
|
||||
if target_type == "webhook" and "headers" in clean:
|
||||
receiver_cfg["headers"] = clean.pop("headers")
|
||||
|
||||
return clean, receiver_cfg
|
||||
|
||||
|
||||
class TargetCreate(BaseModel):
|
||||
type: str # "telegram" or "webhook"
|
||||
@@ -44,32 +79,38 @@ async def list_targets(
|
||||
)
|
||||
targets = result.all()
|
||||
|
||||
# Resolve chat names for telegram targets
|
||||
chat_names: dict[str, str] = {}
|
||||
for tgt in targets:
|
||||
if tgt.type == "telegram" and tgt.config.get("chat_id"):
|
||||
bot_id = tgt.config.get("bot_id")
|
||||
chat_id = str(tgt.config["chat_id"])
|
||||
if bot_id:
|
||||
chat_result = await session.exec(
|
||||
select(TelegramChat).where(
|
||||
TelegramChat.bot_id == bot_id,
|
||||
TelegramChat.chat_id == chat_id,
|
||||
)
|
||||
)
|
||||
chat = chat_result.first()
|
||||
if chat:
|
||||
chat_names[f"{bot_id}_{chat_id}"] = chat.title or chat.username or ""
|
||||
|
||||
# Load receiver counts
|
||||
receiver_counts: dict[int, int] = {}
|
||||
# Load receivers for each target
|
||||
target_receivers: dict[int, list[TargetReceiver]] = {}
|
||||
for tgt in targets:
|
||||
recv_result = await session.exec(
|
||||
select(TargetReceiver).where(TargetReceiver.target_id == tgt.id)
|
||||
)
|
||||
receiver_counts[tgt.id] = len(recv_result.all())
|
||||
target_receivers[tgt.id] = list(recv_result.all())
|
||||
|
||||
return [_target_response(t, chat_names, receiver_counts.get(t.id, 0)) for t in targets]
|
||||
# Resolve chat names from receivers for telegram targets
|
||||
chat_names: dict[str, str] = {}
|
||||
for tgt in targets:
|
||||
if tgt.type == "telegram":
|
||||
bot_id = tgt.config.get("bot_id")
|
||||
if not bot_id:
|
||||
continue
|
||||
for recv in target_receivers.get(tgt.id, []):
|
||||
chat_id = str(recv.config.get("chat_id", ""))
|
||||
if chat_id:
|
||||
chat_result = await session.exec(
|
||||
select(TelegramChat).where(
|
||||
TelegramChat.bot_id == bot_id,
|
||||
TelegramChat.chat_id == chat_id,
|
||||
)
|
||||
)
|
||||
chat = chat_result.first()
|
||||
if chat:
|
||||
chat_names[f"{bot_id}_{chat_id}"] = chat.title or chat.username or ""
|
||||
|
||||
return [
|
||||
_target_response(t, chat_names, target_receivers.get(t.id, []))
|
||||
for t in targets
|
||||
]
|
||||
|
||||
|
||||
@router.post("", status_code=status.HTTP_201_CREATED)
|
||||
@@ -85,15 +126,33 @@ async def create_target(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=f"Type must be one of: {', '.join(valid_types)}",
|
||||
)
|
||||
|
||||
# Extract delivery fields from config — they go into a TargetReceiver
|
||||
clean_config, receiver_cfg = _extract_delivery_fields(body.type, body.config)
|
||||
|
||||
target = NotificationTarget(
|
||||
user_id=user.id,
|
||||
type=body.type,
|
||||
name=body.name,
|
||||
icon=body.icon,
|
||||
config=body.config,
|
||||
config=clean_config,
|
||||
chat_action=body.chat_action,
|
||||
)
|
||||
session.add(target)
|
||||
await session.flush() # get target.id
|
||||
|
||||
# Auto-create a receiver if delivery fields were present
|
||||
if receiver_cfg:
|
||||
key = _receiver_key(body.type, receiver_cfg)
|
||||
receiver = TargetReceiver(
|
||||
target_id=target.id,
|
||||
name=body.name,
|
||||
config=receiver_cfg,
|
||||
receiver_key=key,
|
||||
enabled=True,
|
||||
)
|
||||
session.add(receiver)
|
||||
|
||||
await session.commit()
|
||||
await session.refresh(target)
|
||||
return {"id": target.id, "type": target.type, "name": target.name}
|
||||
@@ -107,7 +166,11 @@ async def get_target(
|
||||
):
|
||||
"""Get a specific notification target."""
|
||||
target = await _get_user_target(session, target_id, user.id)
|
||||
return _target_response(target)
|
||||
recv_result = await session.exec(
|
||||
select(TargetReceiver).where(TargetReceiver.target_id == target.id)
|
||||
)
|
||||
receivers = list(recv_result.all())
|
||||
return _target_response(target, receivers=receivers)
|
||||
|
||||
|
||||
@router.put("/{target_id}")
|
||||
@@ -119,8 +182,38 @@ async def update_target(
|
||||
):
|
||||
"""Update a notification target."""
|
||||
target = await _get_user_target(session, target_id, user.id)
|
||||
for field, value in body.model_dump(exclude_unset=True).items():
|
||||
setattr(target, field, value)
|
||||
updates = body.model_dump(exclude_unset=True)
|
||||
|
||||
# If config is being updated, extract any delivery fields
|
||||
if "config" in updates and updates["config"] is not None:
|
||||
clean_config, receiver_cfg = _extract_delivery_fields(target.type, updates["config"])
|
||||
updates["config"] = clean_config
|
||||
|
||||
# Update or create receiver if delivery fields were present
|
||||
if receiver_cfg:
|
||||
key = _receiver_key(target.type, receiver_cfg)
|
||||
existing_result = await session.exec(
|
||||
select(TargetReceiver).where(
|
||||
TargetReceiver.target_id == target.id,
|
||||
TargetReceiver.receiver_key == key,
|
||||
)
|
||||
)
|
||||
existing_recv = existing_result.first()
|
||||
if existing_recv:
|
||||
existing_recv.config = receiver_cfg
|
||||
session.add(existing_recv)
|
||||
else:
|
||||
receiver = TargetReceiver(
|
||||
target_id=target.id,
|
||||
name=target.name,
|
||||
config=receiver_cfg,
|
||||
receiver_key=key,
|
||||
enabled=True,
|
||||
)
|
||||
session.add(receiver)
|
||||
|
||||
for field_name, value in updates.items():
|
||||
setattr(target, field_name, value)
|
||||
session.add(target)
|
||||
await session.commit()
|
||||
await session.refresh(target)
|
||||
@@ -160,7 +253,12 @@ async def test_target(
|
||||
return result
|
||||
|
||||
|
||||
def _target_response(target: NotificationTarget, chat_names: dict[str, str] | None = None, receiver_count: int = 0) -> dict:
|
||||
def _target_response(
|
||||
target: NotificationTarget,
|
||||
chat_names: dict[str, str] | None = None,
|
||||
receivers: list[TargetReceiver] | None = None,
|
||||
) -> dict:
|
||||
recv_list = receivers or []
|
||||
resp = {
|
||||
"id": target.id,
|
||||
"type": target.type,
|
||||
@@ -168,16 +266,27 @@ def _target_response(target: NotificationTarget, chat_names: dict[str, str] | No
|
||||
"icon": target.icon,
|
||||
"config": _safe_config(target),
|
||||
"chat_action": target.chat_action,
|
||||
"receiver_count": receiver_count,
|
||||
"receiver_count": len(recv_list),
|
||||
"receivers": [
|
||||
{
|
||||
"id": r.id,
|
||||
"name": r.name,
|
||||
"config": dict(r.config),
|
||||
"receiver_key": r.receiver_key,
|
||||
"enabled": r.enabled,
|
||||
}
|
||||
for r in recv_list
|
||||
],
|
||||
"created_at": target.created_at.isoformat(),
|
||||
}
|
||||
# Attach resolved chat name for telegram targets
|
||||
# Attach resolved chat names from receivers for telegram targets
|
||||
if target.type == "telegram" and chat_names:
|
||||
bot_id = target.config.get("bot_id")
|
||||
chat_id = str(target.config.get("chat_id", ""))
|
||||
key = f"{bot_id}_{chat_id}"
|
||||
if key in chat_names:
|
||||
resp["chat_name"] = chat_names[key]
|
||||
for recv_resp in resp["receivers"]:
|
||||
chat_id = str(recv_resp["config"].get("chat_id", ""))
|
||||
key = f"{bot_id}_{chat_id}"
|
||||
if key in chat_names:
|
||||
recv_resp["chat_name"] = chat_names[key]
|
||||
return resp
|
||||
|
||||
|
||||
|
||||
@@ -56,6 +56,8 @@ async def setup(body: SetupRequest, session: AsyncSession = Depends(get_session)
|
||||
if count > 0:
|
||||
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="Setup already completed.")
|
||||
|
||||
if len(body.password) < 6:
|
||||
raise HTTPException(status_code=400, detail="Password must be at least 6 characters")
|
||||
user = User(username=body.username, hashed_password=_hash_password(body.password), role="admin")
|
||||
session.add(user)
|
||||
await session.commit()
|
||||
|
||||
@@ -541,6 +541,25 @@ def _format_assets(
|
||||
})
|
||||
|
||||
|
||||
async def send_reply(bot_token: str, chat_id: str, text: str) -> None:
|
||||
"""Send a text reply via Telegram Bot API, retrying without HTML on parse failure."""
|
||||
async with aiohttp.ClientSession() as http:
|
||||
url = f"{TELEGRAM_API_BASE_URL}{bot_token}/sendMessage"
|
||||
payload: dict[str, Any] = {"chat_id": chat_id, "text": text, "parse_mode": "HTML"}
|
||||
try:
|
||||
async with http.post(url, json=payload) as resp:
|
||||
if resp.status != 200:
|
||||
result = await resp.json()
|
||||
_LOGGER.debug("Telegram reply failed: %s", result.get("description"))
|
||||
if "parse" in str(result.get("description", "")).lower():
|
||||
payload.pop("parse_mode", None)
|
||||
async with http.post(url, json=payload) as retry_resp:
|
||||
if retry_resp.status != 200:
|
||||
_LOGGER.warning("Telegram reply failed on retry")
|
||||
except aiohttp.ClientError as err:
|
||||
_LOGGER.error("Failed to send Telegram reply: %s", err)
|
||||
|
||||
|
||||
async def send_media_group(
|
||||
bot_token: str, chat_id: str, media_items: list[dict[str, Any]],
|
||||
) -> None:
|
||||
|
||||
@@ -15,7 +15,7 @@ from notify_bridge_core.notifications.telegram.media import TELEGRAM_API_BASE_UR
|
||||
from ..database.engine import get_session
|
||||
from ..database.models import TelegramBot
|
||||
from ..services.telegram import save_chat_from_webhook
|
||||
from .handler import handle_command, send_media_group
|
||||
from .handler import handle_command, send_media_group, send_reply
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
@@ -81,32 +81,12 @@ async def telegram_webhook(
|
||||
if isinstance(cmd_response, list):
|
||||
await send_media_group(bot.token, chat_id, cmd_response)
|
||||
else:
|
||||
await _send_reply(bot.token, chat_id, cmd_response)
|
||||
await send_reply(bot.token, chat_id, cmd_response)
|
||||
return {"ok": True}
|
||||
|
||||
return {"ok": True, "skipped": "not_a_command"}
|
||||
|
||||
|
||||
async def _send_reply(bot_token: str, chat_id: str, text: str) -> None:
|
||||
"""Send a text reply via Telegram Bot API."""
|
||||
async with aiohttp.ClientSession() as http_session:
|
||||
url = f"{TELEGRAM_API_BASE_URL}{bot_token}/sendMessage"
|
||||
payload: dict[str, Any] = {"chat_id": chat_id, "text": text, "parse_mode": "HTML"}
|
||||
try:
|
||||
async with http_session.post(url, json=payload) as resp:
|
||||
if resp.status != 200:
|
||||
result = await resp.json()
|
||||
_LOGGER.debug("Telegram reply failed: %s", result.get("description"))
|
||||
# Retry without parse_mode if HTML fails
|
||||
if "parse" in str(result.get("description", "")).lower():
|
||||
payload.pop("parse_mode", None)
|
||||
async with http_session.post(url, json=payload) as retry_resp:
|
||||
if retry_resp.status != 200:
|
||||
_LOGGER.warning("Telegram reply failed on retry")
|
||||
except aiohttp.ClientError as err:
|
||||
_LOGGER.error("Failed to send Telegram reply: %s", err)
|
||||
|
||||
|
||||
async def register_webhook(bot_token: str, webhook_url: str, secret: str | None = None) -> dict:
|
||||
"""Register webhook URL with Telegram Bot API."""
|
||||
async with aiohttp.ClientSession() as http:
|
||||
|
||||
@@ -15,9 +15,8 @@ class Settings(BaseSettings):
|
||||
|
||||
def model_post_init(self, __context: Any) -> None:
|
||||
if self.secret_key == "change-me-in-production" and not self.debug:
|
||||
import logging
|
||||
logging.getLogger(__name__).critical(
|
||||
"SECURITY: Using default secret_key! "
|
||||
raise ValueError(
|
||||
"SECURITY: Cannot start with default secret_key in production. "
|
||||
"Set NOTIFY_BRIDGE_SECRET_KEY environment variable."
|
||||
)
|
||||
|
||||
|
||||
@@ -6,6 +6,7 @@ and the Phase 1 entity refactor (tracker → notification_tracker, etc.).
|
||||
|
||||
import json
|
||||
import logging
|
||||
from typing import Any
|
||||
|
||||
from sqlalchemy import text
|
||||
from sqlalchemy.ext.asyncio import AsyncEngine
|
||||
@@ -713,3 +714,139 @@ async def migrate_target_receivers(engine: AsyncEngine) -> None:
|
||||
|
||||
if migrated:
|
||||
logger.info("Migrated %d target receivers from legacy config", migrated)
|
||||
|
||||
|
||||
async def migrate_receivers_from_config(engine: AsyncEngine) -> None:
|
||||
"""Extract delivery endpoint fields from target.config into TargetReceiver rows.
|
||||
|
||||
For each NotificationTarget that still has a delivery field (chat_id, url,
|
||||
webhook_url, email, topic, room_id) in its config JSON:
|
||||
1. Create a TargetReceiver row (if one with the same key doesn't exist)
|
||||
2. Remove the delivery field(s) from the config JSON
|
||||
|
||||
Idempotent: checks for existing receiver before creating; only strips fields
|
||||
that are still present in config.
|
||||
"""
|
||||
# Mapping: target_type -> (delivery field in config, receiver config builder)
|
||||
_DELIVERY_FIELDS: dict[str, dict[str, str]] = {
|
||||
"telegram": {"chat_id": "chat_id"},
|
||||
"webhook": {"url": "url"},
|
||||
"email": {"email": "email"},
|
||||
"discord": {"webhook_url": "webhook_url"},
|
||||
"slack": {"webhook_url": "webhook_url"},
|
||||
"ntfy": {"topic": "topic"},
|
||||
"matrix": {"room_id": "room_id"},
|
||||
}
|
||||
|
||||
async with engine.begin() as conn:
|
||||
if not await _has_table(conn, "notification_target"):
|
||||
return
|
||||
if not await _has_table(conn, "target_receiver"):
|
||||
return
|
||||
|
||||
targets = (await conn.execute(
|
||||
text("SELECT id, type, config FROM notification_target")
|
||||
)).fetchall()
|
||||
|
||||
created = 0
|
||||
cleaned = 0
|
||||
for row in targets:
|
||||
target_id, target_type, raw_config = row[0], row[1], row[2]
|
||||
try:
|
||||
cfg = json.loads(raw_config) if isinstance(raw_config, str) else (raw_config or {})
|
||||
except (json.JSONDecodeError, TypeError):
|
||||
cfg = {}
|
||||
|
||||
field_map = _DELIVERY_FIELDS.get(target_type, {})
|
||||
if not field_map:
|
||||
continue
|
||||
|
||||
# Check if any delivery field is present in config
|
||||
delivery_field = list(field_map.keys())[0] # e.g. "chat_id", "url"
|
||||
delivery_value = cfg.get(delivery_field)
|
||||
if not delivery_value:
|
||||
continue
|
||||
|
||||
# Build receiver config
|
||||
receiver_config: dict[str, Any] = {delivery_field: delivery_value}
|
||||
# For webhook, also move headers to receiver config
|
||||
if target_type == "webhook" and "headers" in cfg:
|
||||
receiver_config["headers"] = cfg["headers"]
|
||||
|
||||
receiver_key = str(delivery_value)
|
||||
|
||||
# Check if receiver already exists
|
||||
existing = (await conn.execute(
|
||||
text(
|
||||
"SELECT id FROM target_receiver "
|
||||
"WHERE target_id = :tid AND receiver_key = :rk"
|
||||
),
|
||||
{"tid": target_id, "rk": receiver_key},
|
||||
)).fetchone()
|
||||
|
||||
if not existing:
|
||||
# Derive a name for the receiver
|
||||
if target_type == "telegram":
|
||||
name = f"Chat {delivery_value}"
|
||||
elif target_type == "webhook":
|
||||
name = str(delivery_value)[:50]
|
||||
elif target_type == "email":
|
||||
name = str(delivery_value)
|
||||
else:
|
||||
name = str(delivery_value)[:50]
|
||||
|
||||
await conn.execute(
|
||||
text(
|
||||
"INSERT INTO target_receiver "
|
||||
"(target_id, name, config, receiver_key, enabled, created_at) "
|
||||
"VALUES (:tid, :name, :cfg, :rk, 1, CURRENT_TIMESTAMP)"
|
||||
),
|
||||
{
|
||||
"tid": target_id,
|
||||
"name": name,
|
||||
"cfg": json.dumps(receiver_config),
|
||||
"rk": receiver_key,
|
||||
},
|
||||
)
|
||||
created += 1
|
||||
|
||||
# Remove delivery fields from config
|
||||
new_cfg = dict(cfg)
|
||||
new_cfg.pop(delivery_field, None)
|
||||
# For webhook, also remove headers (moved to receiver)
|
||||
if target_type == "webhook":
|
||||
new_cfg.pop("headers", None)
|
||||
|
||||
if new_cfg != cfg:
|
||||
await conn.execute(
|
||||
text(
|
||||
"UPDATE notification_target SET config = :cfg WHERE id = :tid"
|
||||
),
|
||||
{"cfg": json.dumps(new_cfg), "tid": target_id},
|
||||
)
|
||||
cleaned += 1
|
||||
|
||||
if created:
|
||||
logger.info("Created %d receiver rows from target config delivery fields", created)
|
||||
if cleaned:
|
||||
logger.info("Cleaned delivery fields from %d target configs", cleaned)
|
||||
|
||||
|
||||
async def migrate_template_locale(engine: AsyncEngine) -> None:
|
||||
"""Add locale column to template_config and command_template_config.
|
||||
|
||||
Backfill locale from name: "(RU)" -> "ru", else "en" for system-owned rows.
|
||||
"""
|
||||
async with engine.begin() as conn:
|
||||
for table in ("template_config", "command_template_config"):
|
||||
if await _has_column(conn, table, "locale"):
|
||||
continue
|
||||
logger.info("Adding locale column to %s", table)
|
||||
await conn.execute(text(f"ALTER TABLE {table} ADD COLUMN locale TEXT DEFAULT ''"))
|
||||
# Backfill system-owned rows
|
||||
await conn.execute(text(
|
||||
f"UPDATE {table} SET locale = 'ru' WHERE user_id = 0 AND name LIKE '%(RU)%'"
|
||||
))
|
||||
await conn.execute(text(
|
||||
f"UPDATE {table} SET locale = 'en' WHERE user_id = 0 AND locale = ''"
|
||||
))
|
||||
|
||||
@@ -170,6 +170,7 @@ class TemplateConfig(SQLModel, table=True):
|
||||
name: str
|
||||
description: str = Field(default="")
|
||||
icon: str = Field(default="")
|
||||
locale: str = Field(default="") # e.g. "en", "ru"; empty = unspecified
|
||||
|
||||
date_format: str = Field(default="%d.%m.%Y, %H:%M UTC")
|
||||
date_only_format: str = Field(default="%d.%m.%Y")
|
||||
@@ -330,6 +331,7 @@ class CommandTemplateConfig(SQLModel, table=True):
|
||||
name: str
|
||||
description: str = Field(default="")
|
||||
icon: str = Field(default="")
|
||||
locale: str = Field(default="") # e.g. "en", "ru"; empty = unspecified
|
||||
created_at: datetime = Field(default_factory=_utcnow)
|
||||
|
||||
|
||||
|
||||
@@ -39,13 +39,15 @@ async def lifespan(app: FastAPI):
|
||||
await init_db()
|
||||
# Run data migrations (idempotent)
|
||||
from .database.engine import get_engine
|
||||
from .database.migrations import migrate_schema, migrate_tracker_targets, migrate_entity_refactor, migrate_template_slots, migrate_target_receivers
|
||||
from .database.migrations import migrate_schema, migrate_tracker_targets, migrate_entity_refactor, migrate_template_slots, migrate_target_receivers, migrate_template_locale, migrate_receivers_from_config
|
||||
engine = get_engine()
|
||||
await migrate_schema(engine)
|
||||
await migrate_tracker_targets(engine)
|
||||
await migrate_entity_refactor(engine)
|
||||
await migrate_template_slots(engine)
|
||||
await migrate_target_receivers(engine)
|
||||
await migrate_template_locale(engine)
|
||||
await migrate_receivers_from_config(engine)
|
||||
await _seed_default_templates()
|
||||
await _seed_default_command_templates()
|
||||
# Configure webhook secret from DB setting (falls back to env var)
|
||||
@@ -54,9 +56,13 @@ async def lifespan(app: FastAPI):
|
||||
async with _AS(engine) as _session:
|
||||
_secret = await _get_setting(_session, "telegram_webhook_secret")
|
||||
set_webhook_secret(_secret or None)
|
||||
from .services.scheduler import start_scheduler
|
||||
from .services.scheduler import start_scheduler, get_scheduler
|
||||
await start_scheduler()
|
||||
yield
|
||||
# Graceful shutdown
|
||||
scheduler = get_scheduler()
|
||||
if scheduler.running:
|
||||
scheduler.shutdown()
|
||||
|
||||
|
||||
app = FastAPI(title="Notify Bridge", version="0.1.0", lifespan=lifespan)
|
||||
@@ -108,7 +114,8 @@ async def _seed_default_templates():
|
||||
)
|
||||
system_configs = result.all()
|
||||
existing_locales = {
|
||||
"ru" if "(RU)" in c.name else "en": c for c in system_configs
|
||||
(c.locale if c.locale else ("ru" if "(RU)" in c.name else "en")): c
|
||||
for c in system_configs
|
||||
}
|
||||
|
||||
for locale in ("en", "ru"):
|
||||
@@ -144,6 +151,8 @@ async def _seed_default_templates():
|
||||
values[col] = "%d.%m.%Y, %H:%M UTC"
|
||||
elif col == "date_only_format":
|
||||
values[col] = "%d.%m.%Y"
|
||||
elif col == "locale":
|
||||
values[col] = locale
|
||||
else:
|
||||
values[col] = "" # empty string for legacy columns
|
||||
cols_str = ", ".join(values.keys())
|
||||
@@ -211,6 +220,7 @@ async def _seed_default_command_templates():
|
||||
provider_type="immich",
|
||||
name=name,
|
||||
description=f"Default Immich command templates ({locale.upper()})",
|
||||
locale=locale,
|
||||
)
|
||||
session.add(config)
|
||||
await session.flush()
|
||||
@@ -227,7 +237,7 @@ async def _seed_default_command_templates():
|
||||
)
|
||||
system_configs = result.all()
|
||||
for config in system_configs:
|
||||
locale = "ru" if "(RU)" in config.name else "en"
|
||||
locale = config.locale if config.locale else ("ru" if "(RU)" in config.name else "en")
|
||||
slots = load_default_command_templates(locale)
|
||||
if not slots:
|
||||
continue
|
||||
|
||||
@@ -86,13 +86,8 @@ async def _send_telegram_broadcast(target: NotificationTarget, message: str, rec
|
||||
if not bot_token:
|
||||
return {"success": False, "error": "Missing bot_token"}
|
||||
|
||||
# Fall back to legacy chat_id if no receivers
|
||||
if not receivers:
|
||||
chat_id = target.config.get("chat_id")
|
||||
if chat_id:
|
||||
receivers = [{"chat_id": str(chat_id)}]
|
||||
else:
|
||||
return {"success": False, "error": "No receivers configured"}
|
||||
return {"success": False, "error": "No receivers configured"}
|
||||
|
||||
results: list[dict] = []
|
||||
async with aiohttp.ClientSession() as session:
|
||||
@@ -121,14 +116,8 @@ async def _send_telegram_broadcast(target: NotificationTarget, message: str, rec
|
||||
async def _send_webhook_broadcast(target: NotificationTarget, message: str, receivers: list[dict]) -> dict:
|
||||
from notify_bridge_core.notifications.webhook.client import WebhookClient
|
||||
|
||||
# Fall back to legacy url if no receivers
|
||||
if not receivers:
|
||||
url = target.config.get("url")
|
||||
headers = target.config.get("headers", {})
|
||||
if url:
|
||||
receivers = [{"url": url, "headers": headers}]
|
||||
else:
|
||||
return {"success": False, "error": "No receivers configured"}
|
||||
return {"success": False, "error": "No receivers configured"}
|
||||
|
||||
results: list[dict] = []
|
||||
async with aiohttp.ClientSession() as session:
|
||||
@@ -206,11 +195,7 @@ async def _send_email_broadcast(target: NotificationTarget, message: str, receiv
|
||||
async def _send_webhook_like_broadcast(target: NotificationTarget, message: str, receivers: list[dict]) -> dict:
|
||||
"""Broadcast for Discord and Slack — both use webhook URLs as receivers."""
|
||||
if not receivers:
|
||||
webhook_url = target.config.get("webhook_url")
|
||||
if webhook_url:
|
||||
receivers = [{"webhook_url": webhook_url}]
|
||||
else:
|
||||
return {"success": False, "error": "No receivers configured"}
|
||||
return {"success": False, "error": "No receivers configured"}
|
||||
|
||||
results: list[dict] = []
|
||||
async with aiohttp.ClientSession() as session:
|
||||
@@ -238,11 +223,7 @@ async def _send_ntfy_broadcast(target: NotificationTarget, message: str, receive
|
||||
auth_token = target.config.get("auth_token")
|
||||
|
||||
if not receivers:
|
||||
topic = target.config.get("topic")
|
||||
if topic:
|
||||
receivers = [{"topic": topic}]
|
||||
else:
|
||||
return {"success": False, "error": "No receivers configured"}
|
||||
return {"success": False, "error": "No receivers configured"}
|
||||
|
||||
from notify_bridge_core.notifications.ntfy.client import NtfyClient
|
||||
results: list[dict] = []
|
||||
@@ -307,6 +288,26 @@ def _aggregate(results: list[dict]) -> dict:
|
||||
# --- Public API used by routes ---
|
||||
|
||||
|
||||
async def send_to_receiver(target: NotificationTarget, receiver_config: dict, message: str) -> dict:
|
||||
"""Send a message to a single receiver of a target."""
|
||||
try:
|
||||
send_fn = {
|
||||
"telegram": _send_telegram_broadcast,
|
||||
"webhook": _send_webhook_broadcast,
|
||||
"email": _send_email_broadcast,
|
||||
"discord": _send_webhook_like_broadcast,
|
||||
"slack": _send_webhook_like_broadcast,
|
||||
"ntfy": _send_ntfy_broadcast,
|
||||
"matrix": _send_matrix_broadcast,
|
||||
}.get(target.type)
|
||||
if send_fn:
|
||||
return await send_fn(target, message, [receiver_config])
|
||||
return {"success": False, "error": f"Unknown target type: {target.type}"}
|
||||
except Exception as e:
|
||||
_LOGGER.error("Send to receiver failed: %s", e)
|
||||
return {"success": False, "error": str(e)}
|
||||
|
||||
|
||||
async def send_test_notification(target: NotificationTarget, locale: str = "en") -> dict:
|
||||
"""Send a simple test message."""
|
||||
message = _get_test_message(locale, target.type)
|
||||
|
||||
@@ -180,7 +180,7 @@ async def _poll_bot(bot_id: int) -> None:
|
||||
_last_update_id[bot_id] = updates[-1]["update_id"]
|
||||
|
||||
# Process each update
|
||||
from ..commands.handler import handle_command, send_media_group
|
||||
from ..commands.handler import handle_command, send_media_group, send_reply
|
||||
|
||||
for update in updates:
|
||||
message = update.get("message")
|
||||
@@ -210,22 +210,8 @@ async def _poll_bot(bot_id: int) -> None:
|
||||
if isinstance(cmd_response, list):
|
||||
await send_media_group(bot_token, chat_id, cmd_response)
|
||||
else:
|
||||
await _send_reply(bot_token, chat_id, cmd_response)
|
||||
await send_reply(bot_token, chat_id, cmd_response)
|
||||
except Exception:
|
||||
_LOGGER.error("Error handling command from bot %d", bot_id, exc_info=True)
|
||||
|
||||
|
||||
async def _send_reply(bot_token: str, chat_id: str, text: str) -> None:
|
||||
"""Send a text reply via Telegram Bot API."""
|
||||
async with aiohttp.ClientSession() as http:
|
||||
url = f"{TELEGRAM_API_BASE_URL}{bot_token}/sendMessage"
|
||||
payload: dict[str, Any] = {"chat_id": chat_id, "text": text, "parse_mode": "HTML"}
|
||||
try:
|
||||
async with http.post(url, json=payload) as resp:
|
||||
if resp.status != 200:
|
||||
result = await resp.json()
|
||||
if "parse" in str(result.get("description", "")).lower():
|
||||
payload.pop("parse_mode", None)
|
||||
await http.post(url, json=payload)
|
||||
except aiohttp.ClientError as err:
|
||||
_LOGGER.error("Failed to send Telegram reply: %s", err)
|
||||
|
||||
Reference in New Issue
Block a user