notifications.py 32 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768
  1. """API routes for notification providers."""
  2. import json
  3. import logging
  4. import time
  5. from collections import defaultdict, deque
  6. from datetime import datetime, timedelta, timezone
  7. from fastapi import APIRouter, Depends, HTTPException, Query
  8. from fastapi.responses import FileResponse
  9. from sqlalchemy import delete, desc, func, select, update
  10. from sqlalchemy.ext.asyncio import AsyncSession
  11. from backend.app.core.auth import (
  12. RequirePermissionIfAuthEnabled,
  13. ScopedCaller,
  14. probe_permissions_if_auth_enabled,
  15. require_notification_send,
  16. )
  17. from backend.app.core.database import get_db
  18. from backend.app.core.permissions import Permission
  19. from backend.app.core.printer_scope import ensure_printers_exist
  20. from backend.app.models.notification import NotificationLog, NotificationProvider
  21. from backend.app.models.notification_lock_screen_widget import NotificationLockScreenWidget
  22. from backend.app.models.user import User
  23. from backend.app.schemas.notification import (
  24. AppMessage,
  25. AppMessageChannel,
  26. AppMessageResult,
  27. NotificationLogResponse,
  28. NotificationLogStats,
  29. NotificationProviderCreate,
  30. NotificationProviderResponse,
  31. NotificationProviderUpdate,
  32. NotificationTestRequest,
  33. NotificationTestResponse,
  34. )
  35. from backend.app.services.notification_service import notification_service
  36. from backend.app.services.notify_client import NotifyError, notify_credentials
  37. from backend.app.services.notify_live_activities import notify_live_activities
  38. from backend.app.services.notify_widgets import notify_widgets
  39. from backend.app.services.secret_fields import (
  40. SecretConfigError,
  41. mask_config,
  42. merge_config,
  43. reject_placeholder,
  44. )
  45. from backend.app.services.telegram_reactions import telegram_reaction_poller
  46. from backend.app.utils.notification_photos import find_notification_photo
  47. logger = logging.getLogger(__name__)
  48. router = APIRouter(prefix="/notifications", tags=["notifications"])
  49. def _notify_providers_changed() -> None:
  50. """Wake optional workers after a provider edit, without waiting for their HTTP work."""
  51. for worker in (notify_live_activities, notify_widgets):
  52. try:
  53. worker.providers_changed()
  54. except Exception:
  55. logger.exception("Could not refresh Notify worker configuration")
  56. async def _resync_reaction_poller():
  57. """Start/stop Telegram reaction polls after a provider changed (#3046).
  58. Never fails the request: the provider row is already saved, and the
  59. poller catches up on the next restart at worst.
  60. """
  61. try:
  62. await telegram_reaction_poller.sync()
  63. except Exception as e:
  64. logger.warning("Telegram reaction poller resync failed: %s", e)
  65. async def delete_provider(db: AsyncSession, provider: NotificationProvider) -> bool:
  66. """Delete a provider (the caller commits, then calls after_providers_deleted).
  67. A Bambuddy Notify provider is first retired and its live activities and
  68. widgets queued for remote cleanup, while the rows that name them still
  69. exist. Returns whether it was one.
  70. """
  71. notify = provider.provider_type == "notify"
  72. if notify:
  73. config = json.loads(provider.config) if isinstance(provider.config, str) else provider.config
  74. # Retire this provider and capture owned IDs before FK cascade removes
  75. # them. Queue remote cleanup; HTTP must never delay this request.
  76. provider.enabled = False
  77. await db.commit()
  78. await notify_live_activities.schedule_cleanup(provider.id, config)
  79. await notify_widgets.schedule_cleanup(provider.id, config)
  80. await db.delete(provider)
  81. return notify
  82. async def after_providers_deleted(*, notify: bool) -> None:
  83. """What deleted providers leave to do once the delete is committed."""
  84. if notify:
  85. _notify_providers_changed()
  86. await _resync_reaction_poller()
  87. def _stored_config(provider: NotificationProvider) -> dict:
  88. return json.loads(provider.config) if isinstance(provider.config, str) else provider.config
  89. def _provider_to_dict(provider: NotificationProvider) -> dict:
  90. """Convert a NotificationProvider model to a response dictionary.
  91. Secrets are write-only: a set one is shown as the placeholder.
  92. """
  93. return {
  94. "id": provider.id,
  95. "name": provider.name,
  96. "provider_type": provider.provider_type,
  97. "enabled": provider.enabled,
  98. "config": mask_config(_stored_config(provider)),
  99. "attach_photo": provider.attach_photo,
  100. # Print lifecycle events
  101. "on_print_start": provider.on_print_start,
  102. "on_print_complete": provider.on_print_complete,
  103. "on_print_failed": provider.on_print_failed,
  104. "on_print_stopped": provider.on_print_stopped,
  105. "on_print_progress": provider.on_print_progress,
  106. "on_print_missing_spool_assignment": provider.on_print_missing_spool_assignment,
  107. "on_billing_charge_failed": provider.on_billing_charge_failed,
  108. # Printer status events
  109. "on_printer_offline": provider.on_printer_offline,
  110. "on_printer_error": provider.on_printer_error,
  111. "on_ai_failure_detection": provider.on_ai_failure_detection,
  112. "on_filament_low": provider.on_filament_low,
  113. "on_maintenance_due": provider.on_maintenance_due,
  114. # Home Assistant sensor alerts (#1148, #2824). Both directions of this
  115. # file are hand-maintained field maps, so a column missing here reads
  116. # back as the schema default (False) no matter what the row holds.
  117. "on_ha_sensor_alert": provider.on_ha_sensor_alert,
  118. "on_location_ha_sensor_alert": provider.on_location_ha_sensor_alert,
  119. # AMS environmental alarms (regular AMS)
  120. "on_ams_humidity_high": provider.on_ams_humidity_high,
  121. "on_ams_temperature_high": provider.on_ams_temperature_high,
  122. "on_ams_drying_suspended": provider.on_ams_drying_suspended,
  123. # AMS-HT environmental alarms
  124. "on_ams_ht_humidity_high": provider.on_ams_ht_humidity_high,
  125. "on_ams_ht_temperature_high": provider.on_ams_ht_temperature_high,
  126. # Build plate detection
  127. "on_plate_not_empty": provider.on_plate_not_empty,
  128. "on_plate_clear_required": provider.on_plate_clear_required,
  129. # Post-print outcome confirmation (#1898)
  130. "on_print_confirm_request": provider.on_print_confirm_request,
  131. # Rows from before #3046 hold NULL here; "buttons" is what they did.
  132. "telegram_verdict_mode": provider.telegram_verdict_mode or "buttons",
  133. # Bed cooled
  134. "on_bed_cooled": provider.on_bed_cooled,
  135. # First layer complete
  136. "on_first_layer_complete": provider.on_first_layer_complete,
  137. # Messages from connected apps
  138. "on_app_message": bool(provider.on_app_message),
  139. # Inventory stock alerts. Absent here, the toggles above always read
  140. # back off no matter what the row holds — the same hand-maintained
  141. # field map the Home Assistant comment warns about.
  142. "on_stock_reorder_alert": provider.on_stock_reorder_alert,
  143. "on_stock_break_alert": provider.on_stock_break_alert,
  144. # Print queue events
  145. "on_queue_job_added": provider.on_queue_job_added,
  146. "on_queue_job_assigned": provider.on_queue_job_assigned,
  147. "on_queue_job_started": provider.on_queue_job_started,
  148. "on_queue_job_waiting": provider.on_queue_job_waiting,
  149. "on_queue_job_skipped": provider.on_queue_job_skipped,
  150. "on_queue_job_failed": provider.on_queue_job_failed,
  151. "on_queue_completed": provider.on_queue_completed,
  152. # Quiet hours
  153. "quiet_hours_enabled": provider.quiet_hours_enabled,
  154. "quiet_hours_start": provider.quiet_hours_start,
  155. "quiet_hours_end": provider.quiet_hours_end,
  156. # Daily digest
  157. "daily_digest_enabled": provider.daily_digest_enabled,
  158. "daily_digest_time": provider.daily_digest_time,
  159. # Printer filter
  160. "printer_id": provider.printer_id,
  161. # Status tracking
  162. "last_success": provider.last_success,
  163. "last_error": provider.last_error,
  164. "last_error_at": provider.last_error_at,
  165. # Timestamps
  166. "created_at": provider.created_at,
  167. "updated_at": provider.updated_at,
  168. }
  169. # ============================================================================
  170. # Provider List/Create Routes (no path parameters)
  171. # ============================================================================
  172. @router.get("/", response_model=list[NotificationProviderResponse])
  173. async def list_notification_providers(
  174. db: AsyncSession = Depends(get_db),
  175. _: User | None = RequirePermissionIfAuthEnabled(Permission.NOTIFICATIONS_READ),
  176. ):
  177. """List all notification providers."""
  178. result = await db.execute(select(NotificationProvider).order_by(NotificationProvider.created_at.desc()))
  179. providers = result.scalars().all()
  180. return [_provider_to_dict(provider) for provider in providers]
  181. @router.post("/", response_model=NotificationProviderResponse)
  182. async def create_notification_provider(
  183. provider_data: NotificationProviderCreate,
  184. db: AsyncSession = Depends(get_db),
  185. _: User | None = RequirePermissionIfAuthEnabled(Permission.NOTIFICATIONS_CREATE),
  186. ):
  187. """Create a new notification provider."""
  188. await ensure_printers_exist(db, [provider_data.printer_id])
  189. try:
  190. reject_placeholder(provider_data.config)
  191. except SecretConfigError as exc:
  192. raise HTTPException(status_code=400, detail=str(exc)) from None
  193. if provider_data.provider_type.value == "notify":
  194. try:
  195. notify_credentials(provider_data.config)
  196. except NotifyError as exc:
  197. raise HTTPException(status_code=422, detail=str(exc)) from None
  198. provider = NotificationProvider(
  199. name=provider_data.name,
  200. provider_type=provider_data.provider_type.value,
  201. enabled=provider_data.enabled,
  202. config=json.dumps(provider_data.config),
  203. attach_photo=provider_data.attach_photo,
  204. # Print lifecycle events
  205. on_print_start=provider_data.on_print_start,
  206. on_print_complete=provider_data.on_print_complete,
  207. on_print_failed=provider_data.on_print_failed,
  208. on_print_stopped=provider_data.on_print_stopped,
  209. on_print_progress=provider_data.on_print_progress,
  210. on_print_missing_spool_assignment=provider_data.on_print_missing_spool_assignment,
  211. on_billing_charge_failed=provider_data.on_billing_charge_failed,
  212. # Printer status events
  213. on_printer_offline=provider_data.on_printer_offline,
  214. on_printer_error=provider_data.on_printer_error,
  215. on_ai_failure_detection=provider_data.on_ai_failure_detection,
  216. on_filament_low=provider_data.on_filament_low,
  217. on_maintenance_due=provider_data.on_maintenance_due,
  218. # Home Assistant sensor alerts (#1148, #2824)
  219. on_ha_sensor_alert=provider_data.on_ha_sensor_alert,
  220. on_location_ha_sensor_alert=provider_data.on_location_ha_sensor_alert,
  221. # AMS environmental alarms (regular AMS)
  222. on_ams_humidity_high=provider_data.on_ams_humidity_high,
  223. on_ams_temperature_high=provider_data.on_ams_temperature_high,
  224. on_ams_drying_suspended=provider_data.on_ams_drying_suspended,
  225. # AMS-HT environmental alarms
  226. on_ams_ht_humidity_high=provider_data.on_ams_ht_humidity_high,
  227. on_ams_ht_temperature_high=provider_data.on_ams_ht_temperature_high,
  228. # Build plate detection
  229. on_plate_not_empty=provider_data.on_plate_not_empty,
  230. on_plate_clear_required=provider_data.on_plate_clear_required,
  231. # Post-print outcome confirmation (#1898)
  232. on_print_confirm_request=provider_data.on_print_confirm_request,
  233. telegram_verdict_mode=provider_data.telegram_verdict_mode,
  234. # Bed cooled
  235. on_bed_cooled=provider_data.on_bed_cooled,
  236. # First layer complete
  237. on_first_layer_complete=provider_data.on_first_layer_complete,
  238. on_app_message=provider_data.on_app_message,
  239. # Inventory stock alerts
  240. on_stock_reorder_alert=provider_data.on_stock_reorder_alert,
  241. on_stock_break_alert=provider_data.on_stock_break_alert,
  242. # Print queue events
  243. on_queue_job_added=provider_data.on_queue_job_added,
  244. on_queue_job_assigned=provider_data.on_queue_job_assigned,
  245. on_queue_job_started=provider_data.on_queue_job_started,
  246. on_queue_job_waiting=provider_data.on_queue_job_waiting,
  247. on_queue_job_skipped=provider_data.on_queue_job_skipped,
  248. on_queue_job_failed=provider_data.on_queue_job_failed,
  249. on_queue_completed=provider_data.on_queue_completed,
  250. # Quiet hours
  251. quiet_hours_enabled=provider_data.quiet_hours_enabled,
  252. quiet_hours_start=provider_data.quiet_hours_start,
  253. quiet_hours_end=provider_data.quiet_hours_end,
  254. # Daily digest
  255. daily_digest_enabled=provider_data.daily_digest_enabled,
  256. daily_digest_time=provider_data.daily_digest_time,
  257. # Printer filter
  258. printer_id=provider_data.printer_id,
  259. )
  260. db.add(provider)
  261. await db.commit()
  262. await db.refresh(provider)
  263. logger.info("Created notification provider: %s (%s)", provider.name, provider.provider_type)
  264. if provider.provider_type == "notify":
  265. _notify_providers_changed()
  266. await _resync_reaction_poller()
  267. return _provider_to_dict(provider)
  268. # ============================================================================
  269. # Static Path Routes (must come BEFORE parameterized routes)
  270. # ============================================================================
  271. @router.post("/test-config", response_model=NotificationTestResponse)
  272. async def test_notification_config(
  273. test_request: NotificationTestRequest,
  274. db: AsyncSession = Depends(get_db),
  275. _: User | None = RequirePermissionIfAuthEnabled(Permission.NOTIFICATIONS_CREATE),
  276. can_update: bool = Depends(probe_permissions_if_auth_enabled(Permission.NOTIFICATIONS_UPDATE)),
  277. ):
  278. """Test notification configuration before saving.
  279. With provider_id (an edited provider), the stored secrets fill in the
  280. placeholders, for those who may edit providers.
  281. """
  282. config = test_request.config
  283. try:
  284. if test_request.provider_id is None:
  285. reject_placeholder(config)
  286. else:
  287. if not can_update:
  288. raise HTTPException(status_code=403, detail="Testing a saved provider requires notifications:update")
  289. provider = await db.get(NotificationProvider, test_request.provider_id)
  290. if provider is None:
  291. raise HTTPException(status_code=404, detail="Notification provider not found")
  292. config = merge_config(
  293. provider.provider_type, _stored_config(provider), test_request.provider_type.value, config
  294. )
  295. except SecretConfigError as exc:
  296. raise HTTPException(status_code=400, detail=str(exc)) from None
  297. success, message = await notification_service.send_test_notification(
  298. test_request.provider_type.value, config, db, attach_photo=test_request.attach_photo
  299. )
  300. return NotificationTestResponse(success=success, message=message)
  301. @router.post("/test-all")
  302. async def test_all_notification_providers(
  303. db: AsyncSession = Depends(get_db),
  304. _: User | None = RequirePermissionIfAuthEnabled(Permission.NOTIFICATIONS_UPDATE),
  305. ):
  306. """Send a test notification to all enabled providers."""
  307. result = await db.execute(select(NotificationProvider).where(NotificationProvider.enabled.is_(True)))
  308. providers = result.scalars().all()
  309. if not providers:
  310. return {"tested": 0, "success": 0, "failed": 0, "results": []}
  311. results = []
  312. success_count = 0
  313. failed_count = 0
  314. for provider in providers:
  315. config = json.loads(provider.config) if isinstance(provider.config, str) else provider.config
  316. success, message = await notification_service.send_test_notification(
  317. provider.provider_type, config, db, attach_photo=provider.attach_photo
  318. )
  319. # Update provider status
  320. if success:
  321. provider.last_success = datetime.now(timezone.utc)
  322. success_count += 1
  323. else:
  324. provider.last_error = message
  325. provider.last_error_at = datetime.now(timezone.utc)
  326. failed_count += 1
  327. results.append(
  328. {
  329. "provider_id": provider.id,
  330. "provider_name": provider.name,
  331. "provider_type": provider.provider_type,
  332. "success": success,
  333. "message": message,
  334. }
  335. )
  336. await db.commit()
  337. return {
  338. "tested": len(providers),
  339. "success": success_count,
  340. "failed": failed_count,
  341. "results": results,
  342. }
  343. # ============================================================================
  344. # Notification Log Routes (must come BEFORE /{provider_id} routes)
  345. # ============================================================================
  346. @router.get("/logs", response_model=list[NotificationLogResponse])
  347. async def get_notification_logs(
  348. limit: int = Query(default=100, ge=1, le=500),
  349. offset: int = Query(default=0, ge=0),
  350. provider_id: int | None = Query(default=None),
  351. event_type: str | None = Query(default=None),
  352. success: bool | None = Query(default=None),
  353. days: int | None = Query(default=7, ge=1, le=90, description="Filter logs from the last N days"),
  354. db: AsyncSession = Depends(get_db),
  355. _: User | None = RequirePermissionIfAuthEnabled(Permission.NOTIFICATIONS_READ),
  356. ):
  357. """Get notification logs with optional filters."""
  358. query = select(NotificationLog).order_by(desc(NotificationLog.created_at))
  359. # Apply filters
  360. if provider_id is not None:
  361. query = query.where(NotificationLog.provider_id == provider_id)
  362. if event_type is not None:
  363. query = query.where(NotificationLog.event_type == event_type)
  364. if success is not None:
  365. query = query.where(NotificationLog.success == success)
  366. if days is not None:
  367. cutoff = datetime.now(timezone.utc) - timedelta(days=days)
  368. query = query.where(NotificationLog.created_at >= cutoff)
  369. query = query.offset(offset).limit(limit)
  370. result = await db.execute(query)
  371. logs = result.scalars().all()
  372. # Get provider info for each log
  373. response = []
  374. providers_cache: dict[int, NotificationProvider | None] = {}
  375. for log in logs:
  376. if log.provider_id not in providers_cache:
  377. provider_result = await db.execute(
  378. select(NotificationProvider).where(NotificationProvider.id == log.provider_id)
  379. )
  380. providers_cache[log.provider_id] = provider_result.scalar_one_or_none()
  381. provider = providers_cache[log.provider_id]
  382. response.append(
  383. NotificationLogResponse(
  384. id=log.id,
  385. provider_id=log.provider_id,
  386. provider_name=provider.name if provider else None,
  387. provider_type=provider.provider_type if provider else None,
  388. event_type=log.event_type,
  389. title=log.title,
  390. message=log.message,
  391. success=log.success,
  392. error_message=log.error_message,
  393. printer_id=log.printer_id,
  394. printer_name=log.printer_name,
  395. created_at=log.created_at,
  396. )
  397. )
  398. return response
  399. @router.get("/logs/stats", response_model=NotificationLogStats)
  400. async def get_notification_log_stats(
  401. days: int = Query(default=7, ge=1, le=90, description="Statistics for the last N days"),
  402. db: AsyncSession = Depends(get_db),
  403. _: User | None = RequirePermissionIfAuthEnabled(Permission.NOTIFICATIONS_READ),
  404. ):
  405. """Get notification log statistics."""
  406. cutoff = datetime.now(timezone.utc) - timedelta(days=days)
  407. # Total counts
  408. total_result = await db.execute(select(func.count(NotificationLog.id)).where(NotificationLog.created_at >= cutoff))
  409. total = total_result.scalar() or 0
  410. success_result = await db.execute(
  411. select(func.count(NotificationLog.id)).where(
  412. NotificationLog.created_at >= cutoff, NotificationLog.success.is_(True)
  413. )
  414. )
  415. success_count = success_result.scalar() or 0
  416. # By event type
  417. event_result = await db.execute(
  418. select(NotificationLog.event_type, func.count(NotificationLog.id))
  419. .where(NotificationLog.created_at >= cutoff)
  420. .group_by(NotificationLog.event_type)
  421. )
  422. by_event_type = {row[0]: row[1] for row in event_result.fetchall()}
  423. # By provider (need to join to get name)
  424. provider_result = await db.execute(
  425. select(NotificationProvider.name, func.count(NotificationLog.id))
  426. .join(NotificationProvider, NotificationLog.provider_id == NotificationProvider.id)
  427. .where(NotificationLog.created_at >= cutoff)
  428. .group_by(NotificationProvider.name)
  429. )
  430. by_provider = {row[0]: row[1] for row in provider_result.fetchall()}
  431. return NotificationLogStats(
  432. total=total,
  433. success_count=success_count,
  434. failure_count=total - success_count,
  435. by_event_type=by_event_type,
  436. by_provider=by_provider,
  437. )
  438. @router.delete("/logs")
  439. async def clear_notification_logs(
  440. older_than_days: int = Query(default=30, ge=1, description="Delete logs older than N days"),
  441. db: AsyncSession = Depends(get_db),
  442. _: User | None = RequirePermissionIfAuthEnabled(Permission.NOTIFICATIONS_DELETE),
  443. ):
  444. """Clear old notification logs."""
  445. cutoff = datetime.now(timezone.utc) - timedelta(days=older_than_days)
  446. result = await db.execute(delete(NotificationLog).where(NotificationLog.created_at < cutoff))
  447. await db.commit()
  448. deleted_count = result.rowcount
  449. logger.info("Deleted %s notification logs older than %s days", deleted_count, older_than_days)
  450. return {"deleted": deleted_count, "message": f"Deleted {deleted_count} logs older than {older_than_days} days"}
  451. @router.get("/photos/{filename}")
  452. async def get_notification_photo(filename: str):
  453. """Serve an ad-hoc notification snapshot to HA, Bark or Slack.
  454. They fetch this URL themselves with no session, so the unguessable
  455. filename is the credential and opens this one photo only -- see
  456. backend/app/utils/notification_photos.py. Anything that isn't a live
  457. photo of exactly that shape is a 404.
  458. """
  459. photo_path = find_notification_photo(filename)
  460. if photo_path is None:
  461. raise HTTPException(404, "Photo not found")
  462. return FileResponse(path=photo_path, media_type="image/jpeg", headers={"Cache-Control": "private, no-store"})
  463. # ============================================================================
  464. # Provider Instance Routes (parameterized - must come LAST)
  465. # ============================================================================
  466. # Messages from other applications -------------------------------------------
  467. # Per caller, in memory: enough for any real app (Bambuddy Orders sends a few a
  468. # day), and a buggy or hostile one can't flood the channels.
  469. APP_MESSAGE_LIMIT = 20
  470. APP_MESSAGE_WINDOW_SECONDS = 60
  471. _app_message_times: dict[str, deque[float]] = defaultdict(deque)
  472. def _app_sender(caller: ScopedCaller) -> tuple[str, str]:
  473. """(rate-limit key, name shown in the log) for whoever sends the message."""
  474. if caller.api_key is not None:
  475. return f"key:{caller.api_key.id}", caller.api_key.name
  476. if caller.user is not None:
  477. return f"user:{caller.user.id}", caller.user.username
  478. return "anonymous", "app"
  479. def forget_app_senders(api_key_ids=(), user_ids=()) -> None:
  480. """Drop deleted keys' and users' message counts: SQLite gives an id to the
  481. next key or user created, which would start out throttled."""
  482. for key_id in api_key_ids:
  483. _app_message_times.pop(f"key:{key_id}", None)
  484. for user_id in user_ids:
  485. _app_message_times.pop(f"user:{user_id}", None)
  486. def _check_app_message_rate(key: str) -> None:
  487. times = _app_message_times[key]
  488. cutoff = time.monotonic() - APP_MESSAGE_WINDOW_SECONDS
  489. while times and times[0] < cutoff:
  490. times.popleft()
  491. if len(times) >= APP_MESSAGE_LIMIT:
  492. raise HTTPException(status_code=429, detail="Too many messages; try again in a minute")
  493. times.append(time.monotonic())
  494. @router.post("/app-message", response_model=AppMessageResult)
  495. async def send_app_message(
  496. data: AppMessage,
  497. db: AsyncSession = Depends(get_db),
  498. caller: ScopedCaller = Depends(require_notification_send()),
  499. ):
  500. """Send a message through every enabled channel that has "Messages from
  501. connected apps" on. For other applications, e.g. Bambuddy Orders; an API
  502. key needs the "Send notifications" permission."""
  503. key, sender = _app_sender(caller)
  504. _check_app_message_rate(key)
  505. channels = await notification_service.on_app_message(
  506. db, sender=sender, title=data.title, message=data.message, url=data.url
  507. )
  508. return AppMessageResult(channels=channels)
  509. @router.get("/app-message/channels", response_model=list[AppMessageChannel])
  510. async def app_message_channels(
  511. db: AsyncSession = Depends(get_db),
  512. _: ScopedCaller = Depends(require_notification_send()),
  513. ):
  514. """The enabled channels that deliver app messages: names and types only,
  515. so an app can tell its user where its messages will arrive."""
  516. rows = await db.execute(
  517. select(NotificationProvider)
  518. .where(NotificationProvider.enabled.is_(True), NotificationProvider.on_app_message.is_(True))
  519. .order_by(NotificationProvider.name)
  520. )
  521. return [AppMessageChannel(name=p.name, provider_type=p.provider_type) for p in rows.scalars()]
  522. @router.get("/{provider_id}", response_model=NotificationProviderResponse)
  523. async def get_notification_provider(
  524. provider_id: int,
  525. db: AsyncSession = Depends(get_db),
  526. _: User | None = RequirePermissionIfAuthEnabled(Permission.NOTIFICATIONS_READ),
  527. ):
  528. """Get a specific notification provider."""
  529. result = await db.execute(select(NotificationProvider).where(NotificationProvider.id == provider_id))
  530. provider = result.scalar_one_or_none()
  531. if not provider:
  532. raise HTTPException(status_code=404, detail="Notification provider not found")
  533. return _provider_to_dict(provider)
  534. @router.patch("/{provider_id}", response_model=NotificationProviderResponse)
  535. async def update_notification_provider(
  536. provider_id: int,
  537. update_data: NotificationProviderUpdate,
  538. db: AsyncSession = Depends(get_db),
  539. _: User | None = RequirePermissionIfAuthEnabled(Permission.NOTIFICATIONS_UPDATE),
  540. ):
  541. """Update a notification provider."""
  542. result = await db.execute(select(NotificationProvider).where(NotificationProvider.id == provider_id))
  543. provider = result.scalar_one_or_none()
  544. if not provider:
  545. raise HTTPException(status_code=404, detail="Notification provider not found")
  546. old_config = _stored_config(provider)
  547. old_type = provider.provider_type
  548. # Update only provided fields
  549. update_dict = update_data.model_dump(exclude_unset=True)
  550. await ensure_printers_exist(db, [update_dict.get("printer_id")])
  551. effective_type = update_dict.get("provider_type") or provider.provider_type
  552. effective_type = getattr(effective_type, "value", effective_type)
  553. if effective_type != old_type and update_dict.get("config") is None:
  554. raise HTTPException(status_code=400, detail="A new provider type needs its own configuration")
  555. if update_dict.get("config") is not None:
  556. try:
  557. update_dict["config"] = merge_config(old_type, old_config, effective_type, update_dict["config"])
  558. except SecretConfigError as exc:
  559. raise HTTPException(status_code=400, detail=str(exc)) from None
  560. if effective_type == "notify":
  561. try:
  562. effective_config = update_dict.get("config", old_config)
  563. if not isinstance(effective_config, dict):
  564. raise NotifyError("Notify! configuration must be an object")
  565. notify_credentials(effective_config)
  566. except NotifyError as exc:
  567. raise HTTPException(status_code=422, detail=str(exc)) from None
  568. for key, value in update_dict.items():
  569. if key == "config" and value is not None:
  570. setattr(provider, key, json.dumps(value))
  571. elif key == "provider_type" and value is not None:
  572. setattr(provider, key, value.value)
  573. else:
  574. setattr(provider, key, value)
  575. if old_type == "notify":
  576. effective_config = _stored_config(provider)
  577. remove_widgets = (
  578. provider.provider_type != "notify"
  579. or not provider.enabled
  580. or effective_config.get("lock_screen_widgets") is not True
  581. )
  582. if remove_widgets or provider.printer_id is not None:
  583. condition = NotificationLockScreenWidget.provider_id == provider_id
  584. if not remove_widgets:
  585. condition = condition & (NotificationLockScreenWidget.printer_id != provider.printer_id)
  586. # Persist opt-out before returning, even if the user immediately
  587. # opts back in. The worker finishes cleanup before creating anew.
  588. await db.execute(
  589. update(NotificationLockScreenWidget)
  590. .where(condition)
  591. .values(
  592. state="deleting",
  593. failures=0,
  594. next_attempt_at=None,
  595. )
  596. )
  597. await db.commit()
  598. await db.refresh(provider)
  599. response = _provider_to_dict(provider)
  600. new_config = _stored_config(provider)
  601. changed = old_type != provider.provider_type or any(
  602. old_config.get(key) != new_config.get(key) for key in ("device_id", "token")
  603. )
  604. # Refresh opened a new read transaction; close it before remote cleanup.
  605. await db.commit()
  606. if old_type == "notify" and changed:
  607. await notify_live_activities.schedule_cleanup(provider_id, old_config)
  608. await notify_widgets.schedule_cleanup(provider_id, old_config)
  609. if old_type == "notify" or provider.provider_type == "notify":
  610. _notify_providers_changed()
  611. logger.info("Updated notification provider: %s", provider.name)
  612. await _resync_reaction_poller()
  613. return response
  614. @router.delete("/{provider_id}")
  615. async def delete_notification_provider(
  616. provider_id: int,
  617. db: AsyncSession = Depends(get_db),
  618. _: User | None = RequirePermissionIfAuthEnabled(Permission.NOTIFICATIONS_DELETE),
  619. ):
  620. """Delete a notification provider."""
  621. result = await db.execute(select(NotificationProvider).where(NotificationProvider.id == provider_id))
  622. provider = result.scalar_one_or_none()
  623. if not provider:
  624. raise HTTPException(status_code=404, detail="Notification provider not found")
  625. name = provider.name
  626. notify = await delete_provider(db, provider)
  627. await db.commit()
  628. await after_providers_deleted(notify=notify)
  629. logger.info("Deleted notification provider: %s", name)
  630. return {"message": f"Notification provider '{name}' deleted"}
  631. @router.post("/{provider_id}/test", response_model=NotificationTestResponse)
  632. async def test_notification_provider(
  633. provider_id: int,
  634. db: AsyncSession = Depends(get_db),
  635. _: User | None = RequirePermissionIfAuthEnabled(Permission.NOTIFICATIONS_UPDATE),
  636. ):
  637. """Send a test notification using an existing provider."""
  638. result = await db.execute(select(NotificationProvider).where(NotificationProvider.id == provider_id))
  639. provider = result.scalar_one_or_none()
  640. if not provider:
  641. raise HTTPException(status_code=404, detail="Notification provider not found")
  642. config = json.loads(provider.config) if isinstance(provider.config, str) else provider.config
  643. success, message = await notification_service.send_test_notification(
  644. provider.provider_type, config, db, attach_photo=provider.attach_photo
  645. )
  646. # Update provider status
  647. if success:
  648. provider.last_success = datetime.now(timezone.utc)
  649. else:
  650. provider.last_error = message
  651. provider.last_error_at = datetime.now(timezone.utc)
  652. await db.commit()
  653. return NotificationTestResponse(success=success, message=message)