Files
notify-bridge/packages/server/src/notify_bridge_server/main.py
T
alexei.dolgolyov 1167d138a3 feat: locale-aware command templates, debounced auto-sync, entity pickers
- Locale-aware templates: CommandTemplateSlot now has a locale column,
  allowing each slot to have per-language variants (EN/RU). Templates
  are resolved at runtime from the Telegram user's language_code.

- Merged system configs: "Default Commands (EN)" and "(RU)" merged
  into a single "Default Commands" config with locale-aware slots.
  Migration handles existing data automatically.

- Configurable command descriptions: hardcoded COMMAND_DESCRIPTIONS
  replaced with desc_* template slots (desc_status, desc_help, etc.)
  that users can customize per locale. setMyCommands registers all
  locales explicitly.

- Removed locale from CommandConfig: no longer needed since locale
  is derived from the Telegram user's language at runtime.

- Debounced command auto-sync: after command config/tracker changes,
  affected bots are marked dirty and synced after a 30s debounce
  window. Manual "Sync with Telegram" button still works.

- Entity pickers in LinkedTargetsSection: replaced 6 plain <select>
  elements with EntitySelect components (search, icons, keyboard nav).
  Added onselect callback and size="sm" props to EntitySelect.
2026-03-22 03:14:51 +03:00

263 lines
11 KiB
Python

"""Notify Bridge Server — FastAPI application entry point."""
import logging
from contextlib import asynccontextmanager
from fastapi import FastAPI
# Ensure app-level loggers are visible
logging.basicConfig(level=logging.INFO)
logging.getLogger("notify_bridge_server").setLevel(logging.DEBUG)
logging.getLogger("notify_bridge_core").setLevel(logging.DEBUG)
from .database.engine import init_db
from .database.models import * # noqa: F401,F403 — ensure all models registered
from .auth.routes import router as auth_router
from .api.providers import router as providers_router
from .api.notification_trackers import router as notification_trackers_router
from .api.notification_tracker_targets import router as notification_tracker_targets_router
from .api.tracking_configs import router as tracking_configs_router
from .api.template_configs import router as template_configs_router
from .api.targets import router as targets_router
from .api.target_receivers import router as target_receivers_router
from .api.telegram_bots import router as telegram_bots_router
from .api.email_bots import router as email_bots_router
from .api.matrix_bots import router as matrix_bots_router
from .api.users import router as users_router
from .api.status import router as status_router
from .api.template_vars import router as template_vars_router
from .api.app_settings import router as app_settings_router
from .api.command_configs import router as command_configs_router
from .api.command_trackers import router as command_trackers_router
from .api.command_template_configs import router as command_template_configs_router
from .commands.webhook import router as webhook_router, set_webhook_secret
@asynccontextmanager
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, migrate_template_locale, migrate_receivers_from_config, migrate_command_slot_locale
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 migrate_command_slot_locale(engine)
await _seed_default_templates()
await _seed_default_command_templates()
# Configure webhook secret from DB setting (falls back to env var)
from sqlmodel.ext.asyncio.session import AsyncSession as _AS
from .api.app_settings import get_setting as _get_setting
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, 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)
# Register routes — static paths before parameterized
app.include_router(auth_router)
app.include_router(template_vars_router)
app.include_router(providers_router)
app.include_router(notification_trackers_router)
app.include_router(notification_tracker_targets_router)
app.include_router(tracking_configs_router)
app.include_router(template_configs_router)
app.include_router(targets_router)
app.include_router(target_receivers_router)
app.include_router(telegram_bots_router)
app.include_router(email_bots_router)
app.include_router(matrix_bots_router)
app.include_router(users_router)
app.include_router(status_router)
app.include_router(app_settings_router)
app.include_router(command_configs_router)
app.include_router(command_trackers_router)
app.include_router(command_template_configs_router)
app.include_router(webhook_router)
@app.get("/api/health")
async def health():
return {"status": "ok"}
async def _seed_default_templates():
"""Seed or update default (system-owned) templates on startup.
Uses TemplateSlot child rows for template content.
"""
from sqlalchemy import text
from sqlmodel import func, select
from sqlmodel.ext.asyncio.session import AsyncSession
from .database.engine import get_engine
from .database.models import TemplateConfig, TemplateSlot
from notify_bridge_core.templates.defaults import load_default_templates
engine = get_engine()
async with AsyncSession(engine) as session:
# Find existing system-owned templates
result = await session.exec(
select(TemplateConfig).where(TemplateConfig.user_id == 0)
)
system_configs = result.all()
existing_locales = {
(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"):
slots = load_default_templates(locale)
if not slots:
continue
if locale not in existing_locales:
# Create missing system template via raw SQL
# (legacy NOT NULL columns may still exist in the DB)
name = f"Default ({locale.upper()})"
desc = f"Default Immich templates ({locale.upper()})"
# Get column names to build INSERT with defaults for legacy cols
col_info = (await session.execute(
text("PRAGMA table_info(template_config)")
)).fetchall()
col_names = [c[1] for c in col_info if c[1] != "id"]
values = {}
from datetime import datetime, timezone
now = datetime.now(timezone.utc).isoformat()
for col in col_names:
if col == "user_id":
values[col] = 0
elif col == "provider_type":
values[col] = "immich"
elif col == "name":
values[col] = name
elif col == "description":
values[col] = desc
elif col == "created_at":
values[col] = now
elif col == "date_format":
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())
placeholders = ", ".join(f":{k}" for k in values.keys())
await session.execute(
text(f"INSERT INTO template_config ({cols_str}) VALUES ({placeholders})"),
values,
)
# Get the inserted ID
row = (await session.execute(text("SELECT last_insert_rowid()"))).scalar()
config_id = row
for slot_name, template_text in slots.items():
session.add(TemplateSlot(
config_id=config_id,
slot_name=slot_name,
template=template_text,
))
else:
# Update existing system template slots
config = existing_locales[locale]
for slot_name, template_text in slots.items():
slot_result = await session.exec(
select(TemplateSlot).where(
TemplateSlot.config_id == config.id,
TemplateSlot.slot_name == slot_name,
)
)
existing = slot_result.first()
if existing:
existing.template = template_text
session.add(existing)
else:
session.add(TemplateSlot(
config_id=config.id,
slot_name=slot_name,
template=template_text,
))
await session.commit()
async def _seed_default_command_templates():
"""Seed or update default command response templates on startup.
Creates a single 'Default Commands' config with locale-aware slots
(each slot has an EN and RU version stored as separate rows).
"""
from sqlmodel import func, select
from sqlmodel.ext.asyncio.session import AsyncSession
from .database.engine import get_engine
from .database.models import CommandTemplateConfig, CommandTemplateSlot
from notify_bridge_core.templates.command_defaults import load_default_command_templates
engine = get_engine()
async with AsyncSession(engine) as session:
# Find or create the system-owned config
result = await session.exec(
select(CommandTemplateConfig).where(CommandTemplateConfig.user_id == 0)
)
system_configs = result.all()
if not system_configs:
# First startup — create single merged config
config = CommandTemplateConfig(
user_id=0,
provider_type="immich",
name="Default Commands",
description="Default Immich command templates",
)
session.add(config)
await session.flush()
else:
config = system_configs[0]
# Upsert slots for each locale
for locale in ("en", "ru"):
slots = load_default_command_templates(locale)
if not slots:
continue
for slot_name, template_text in slots.items():
slot_result = await session.exec(
select(CommandTemplateSlot).where(
CommandTemplateSlot.config_id == config.id,
CommandTemplateSlot.slot_name == slot_name,
CommandTemplateSlot.locale == locale,
)
)
existing = slot_result.first()
if existing:
existing.template = template_text
session.add(existing)
else:
session.add(CommandTemplateSlot(
config_id=config.id,
slot_name=slot_name,
locale=locale,
template=template_text,
))
await session.commit()
def run():
import uvicorn
uvicorn.run(app, host="0.0.0.0", port=8420)