diff --git a/CLAUDE.md b/CLAUDE.md index e826775..f2c415a 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -34,4 +34,5 @@ Detailed context is split into focused documents under `.claude/docs/`. Read the - Components use `getDescriptor(type)` and render dynamically from the descriptor - Feature gating: check `capabilities.notification_slots` or `capabilities.commands`, not `provider.type === 'immich'` - Template variable helpers: ALL provider types must have entries in `get_template_variables()` -9. **New provider descriptor checklist** — when adding a new service provider, create a descriptor file in `frontend/src/lib/providers/{name}.ts` and register it in `index.ts`. The descriptor must define: `type`, `defaultName`, `icon`, `hasUrl`, `configFields`, `buildConfig()`, `hasConfigChanged()`, `eventFields`, `collectionMeta` (or `null`). Optional: `extraTrackingFields`, `featureSections`, `webhookUrlPattern`, `webhookBased`, `onBeforeSave`. Also add i18n keys: `providers.type{PascalName}` and `gridDesc.provider{PascalName}` in both `en.json` and `ru.json`. +9. **Nav tree & entity types** — when adding a new entity type (target type, bot type, etc.), update the sidebar nav in `frontend/src/routes/+layout.svelte`. Target types need: `{ href: '/targets?type={type}', key: 'nav.target{PascalName}', icon: '...', countKey: 'targets_{type}' }` in the `children` array under `nav.targets`, plus i18n keys `nav.target{PascalName}` in both locale files. Also add the counter entry `targets_{type}: targets.filter(t => t.type === '{type}').length` to the `counts` derived block in `+layout.svelte`. +10. **New provider descriptor checklist** — when adding a new service provider, create a descriptor file in `frontend/src/lib/providers/{name}.ts` and register it in `index.ts`. The descriptor must define: `type`, `defaultName`, `icon`, `hasUrl`, `configFields`, `buildConfig()`, `hasConfigChanged()`, `eventFields`, `collectionMeta` (or `null`). Optional: `extraTrackingFields`, `featureSections`, `webhookUrlPattern`, `webhookBased`, `onBeforeSave`. Also add i18n keys: `providers.type{PascalName}` and `gridDesc.provider{PascalName}` in both `en.json` and `ru.json`. diff --git a/frontend/src/lib/i18n/en.json b/frontend/src/lib/i18n/en.json index c6cb683..e808847 100644 --- a/frontend/src/lib/i18n/en.json +++ b/frontend/src/lib/i18n/en.json @@ -34,6 +34,7 @@ "targetSlack": "Slack", "targetNtfy": "ntfy", "targetMatrix": "Matrix", + "targetBroadcast": "Broadcast", "automation": "Automation", "actions": "Actions" }, @@ -256,6 +257,11 @@ "descSlack": "Slack channel webhooks for notifications", "descNtfy": "ntfy push notification topics", "descMatrix": "Matrix room destinations for notifications", + "descBroadcast": "Send to multiple targets at once", + "childTargets": "target(s)", + "selectChildTargets": "Select child targets", + "noChildTargets": "No child targets configured.", + "noChildTargetsAvailable": "Create other targets first, then add them here.", "addTarget": "Add Target", "cancel": "Cancel", "type": "Type", @@ -814,6 +820,8 @@ "syntaxError": "Syntax error", "undefinedVar": "Unknown variable", "line": "line", + "enable": "Enable", + "disable": "Disable", "add": "Add", "filterByName": "Filter by name...", "allTypes": "All types", diff --git a/frontend/src/lib/i18n/ru.json b/frontend/src/lib/i18n/ru.json index 0a6c9b0..dff4bc0 100644 --- a/frontend/src/lib/i18n/ru.json +++ b/frontend/src/lib/i18n/ru.json @@ -34,6 +34,7 @@ "targetSlack": "Slack", "targetNtfy": "ntfy", "targetMatrix": "Matrix", + "targetBroadcast": "Рассылка", "automation": "Автоматизация", "actions": "Действия" }, @@ -256,6 +257,11 @@ "descSlack": "Вебхуки каналов Slack для уведомлений", "descNtfy": "Топики ntfy для push-уведомлений", "descMatrix": "Комнаты Matrix для доставки уведомлений", + "descBroadcast": "Отправка сразу в несколько целей", + "childTargets": "цель(ей)", + "selectChildTargets": "Выберите дочерние цели", + "noChildTargets": "Дочерние цели не настроены.", + "noChildTargetsAvailable": "Сначала создайте другие цели, затем добавьте их сюда.", "addTarget": "Добавить получателя", "cancel": "Отмена", "type": "Тип", @@ -814,6 +820,8 @@ "syntaxError": "Ошибка синтаксиса", "undefinedVar": "Неизвестная переменная", "line": "строка", + "enable": "Включить", + "disable": "Выключить", "add": "Добавить", "filterByName": "Фильтр по имени...", "allTypes": "Все типы", diff --git a/frontend/src/lib/types.ts b/frontend/src/lib/types.ts index 6d0ac99..bfc292b 100644 --- a/frontend/src/lib/types.ts +++ b/frontend/src/lib/types.ts @@ -106,6 +106,8 @@ export interface NotificationTarget { chat_name?: string; receiver_count: number; receivers: TargetReceiver[]; + /** Broadcast targets only: resolved child target summaries. */ + child_targets?: { id: number; name: string; type: string; icon: string }[]; created_at: string; } diff --git a/frontend/src/routes/+layout.svelte b/frontend/src/routes/+layout.svelte index 7570890..00ddcd7 100644 --- a/frontend/src/routes/+layout.svelte +++ b/frontend/src/routes/+layout.svelte @@ -104,6 +104,7 @@ targets_slack: targets.filter(t => t.type === 'slack').length, targets_ntfy: targets.filter(t => t.type === 'ntfy').length, targets_matrix: targets.filter(t => t.type === 'matrix').length, + targets_broadcast: targets.filter(t => t.type === 'broadcast').length, } as Record; }); @@ -169,6 +170,7 @@ { href: '/targets?type=slack', key: 'nav.targetSlack', icon: 'mdiSlack', countKey: 'targets_slack' }, { href: '/targets?type=ntfy', key: 'nav.targetNtfy', icon: 'mdiBell', countKey: 'targets_ntfy' }, { href: '/targets?type=matrix', key: 'nav.targetMatrix', icon: 'mdiMatrix', countKey: 'targets_matrix' }, + { href: '/targets?type=broadcast', key: 'nav.targetBroadcast', icon: 'mdiBullhorn', countKey: 'targets_broadcast' }, ], }, ]; diff --git a/frontend/src/routes/+page.svelte b/frontend/src/routes/+page.svelte index 2bb4493..38463db 100644 --- a/frontend/src/routes/+page.svelte +++ b/frontend/src/routes/+page.svelte @@ -250,7 +250,7 @@

{card.literalLabel || t(card.label)}

-

+

{#if card.literalValue}{card.literalValue}{:else}{card.value}{/if}{#if card.suffix}{card.suffix}{/if}

@@ -362,6 +362,7 @@ .stat-card-inner { background: var(--color-card); border-radius: calc(0.75rem - 1px); padding: 1.25rem; } .stat-icon { display: flex; align-items: center; justify-content: center; width: 2.75rem; height: 2.75rem; border-radius: 0.75rem; flex-shrink: 0; } .stat-value { font-size: 1.75rem; font-weight: 600; line-height: 1.2; animation: countUp 0.5s ease-out both; } + .stat-value-text { font-size: 1rem; font-weight: 600; line-height: 1.3; animation: countUp 0.5s ease-out both; } .stat-suffix { font-size: 1rem; font-weight: 400; color: var(--color-muted-foreground); } .event-timeline { display: flex; flex-direction: column; } .event-item { display: flex; align-items: flex-start; gap: 1rem; position: relative; padding-bottom: 0.75rem; } diff --git a/frontend/src/routes/targets/+page.svelte b/frontend/src/routes/targets/+page.svelte index df74e5c..d670728 100644 --- a/frontend/src/routes/targets/+page.svelte +++ b/frontend/src/routes/targets/+page.svelte @@ -1,5 +1,5 @@
+{#if target.type === 'broadcast'} +
+

{t('targets.childTargets')}

+
+ {@const disabledIds = new Set(target.config?.disabled_child_ids || [])} + {#if target.child_targets?.length} + {#each target.child_targets as child (child.id)} + {@const isDisabled = disabledIds.has(child.id)} +
+
+ + {child.name} + {child.type} +
+ ontoggleBroadcastChild?.(target.id, child.id)} + size={16} + /> +
+ {/each} + {:else} +

{t('targets.noChildTargets')}

+ {/if} +{:else}

{t('targets.receivers')}

@@ -153,4 +181,5 @@ {t('targets.addReceiver')} {/if} +{/if}
diff --git a/frontend/src/routes/targets/TargetForm.svelte b/frontend/src/routes/targets/TargetForm.svelte index 82070b1..57074a2 100644 --- a/frontend/src/routes/targets/TargetForm.svelte +++ b/frontend/src/routes/targets/TargetForm.svelte @@ -6,6 +6,7 @@ import Hint from '$lib/components/Hint.svelte'; import IconGridSelect from '$lib/components/IconGridSelect.svelte'; import EntitySelect from '$lib/components/EntitySelect.svelte'; + import MultiEntitySelect from '$lib/components/MultiEntitySelect.svelte'; import type { EntityItem } from '$lib/components/EntitySelect.svelte'; import type { GridItem } from '$lib/components/IconGridSelect.svelte'; @@ -28,6 +29,7 @@ auth_token: string; matrix_bot_id: number; email_bot_id: number; + child_target_ids: number[]; }; formType: string; activeType: string | null; @@ -36,6 +38,7 @@ emailBotItems: EntityItem[]; matrixBotItems: EntityItem[]; chatActionItems: GridItem[]; + broadcastChildItems?: { value: number; label: string; icon: string; desc: string }[]; telegramBotCount: number; emailBotCount: number; matrixBotCount: number; @@ -56,6 +59,7 @@ emailBotItems, matrixBotItems, chatActionItems, + broadcastChildItems = [], telegramBotCount, emailBotCount, matrixBotCount, @@ -160,6 +164,20 @@

{t('matrixBot.noBots')}

{/if} + {:else if formType === 'broadcast'} + {@const childIds = (form.child_target_ids || []).map(String)} +
+ + ({ value: String(i.value), label: i.label, icon: i.icon, desc: i.desc })) ?? []} + values={childIds} + onchange={(vals) => form.child_target_ids = vals.map(Number)} + placeholder={t('targets.selectChildTargets')} + /> + {#if broadcastChildItems?.length === 0} +

{t('targets.noChildTargetsAvailable')}

+ {/if} +
{/if} {#if formType === 'telegram'} diff --git a/packages/server/src/notify_bridge_server/api/targets.py b/packages/server/src/notify_bridge_server/api/targets.py index 49c8bf5..c5ddf8f 100644 --- a/packages/server/src/notify_bridge_server/api/targets.py +++ b/packages/server/src/notify_bridge_server/api/targets.py @@ -111,8 +111,11 @@ async def list_targets( if lang: chat_languages[f"{bot_id}_{chat_id}"] = lang + # Build lookup for broadcast child target resolution + target_map = {t.id: t for t in targets} + return [ - _target_response(t, chat_names, target_receivers.get(t.id, []), chat_languages) + _target_response(t, chat_names, target_receivers.get(t.id, []), chat_languages, target_map) for t in targets ] @@ -124,15 +127,24 @@ async def create_target( session: AsyncSession = Depends(get_session), ): """Create a new notification target.""" - valid_types = ("telegram", "webhook", "email", "discord", "slack", "ntfy", "matrix") + valid_types = ("telegram", "webhook", "email", "discord", "slack", "ntfy", "matrix", "broadcast") if body.type not in valid_types: raise HTTPException( 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) + # Broadcast-specific validation + if body.type == "broadcast": + child_ids = body.config.get("child_target_ids", []) + if not isinstance(child_ids, list): + raise HTTPException(status_code=400, detail="child_target_ids must be a list") + await _validate_broadcast_children(session, child_ids, user.id, exclude_target_id=None) + clean_config = {"child_target_ids": child_ids} + receiver_cfg: dict[str, Any] = {} + else: + # 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, @@ -190,34 +202,46 @@ async def update_target( # 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 + if target.type == "broadcast": + child_ids = updates["config"].get("child_target_ids", []) + if not isinstance(child_ids, list): + raise HTTPException(status_code=400, detail="child_target_ids must be a list") + await _validate_broadcast_children(session, child_ids, user.id, exclude_target_id=target.id) + disabled_ids = updates["config"].get("disabled_child_ids", []) + updates["config"] = {"child_target_ids": child_ids, "disabled_child_ids": disabled_ids} + else: + 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, + # 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) + 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) + # Force SQLAlchemy to detect JSON column change + from sqlalchemy.orm.attributes import flag_modified + if "config" in updates: + flag_modified(target, "config") session.add(target) await session.commit() await session.refresh(target) @@ -257,11 +281,37 @@ async def test_target( return result +async def _validate_broadcast_children( + session: AsyncSession, + child_ids: list[int], + user_id: int, + *, + exclude_target_id: int | None = None, +) -> None: + """Validate broadcast child target IDs. + + - All IDs must exist and belong to the user + - No broadcast targets allowed as children (prevents circular refs) + - Cannot reference itself + """ + if not child_ids: + return + if exclude_target_id and exclude_target_id in child_ids: + raise HTTPException(status_code=400, detail="A broadcast target cannot include itself") + for child_id in child_ids: + child = await session.get(NotificationTarget, child_id) + if not child or child.user_id != user_id: + raise HTTPException(status_code=400, detail=f"Child target {child_id} not found") + if child.type == "broadcast": + raise HTTPException(status_code=400, detail="A broadcast target cannot include another broadcast") + + def _target_response( target: NotificationTarget, chat_names: dict[str, str] | None = None, receivers: list[TargetReceiver] | None = None, chat_languages: dict[str, str] | None = None, + target_map: dict[int, NotificationTarget] | None = None, ) -> dict: recv_list = receivers or [] resp = { @@ -295,6 +345,14 @@ def _target_response( recv_resp["chat_name"] = chat_names[key] if chat_languages and key in chat_languages: recv_resp["language_code"] = chat_languages[key] + # Attach child target summaries for broadcast targets + if target.type == "broadcast" and target_map: + child_ids = target.config.get("child_target_ids", []) + resp["child_targets"] = [ + {"id": cid, "name": target_map[cid].name, "type": target_map[cid].type, "icon": target_map[cid].icon} + for cid in child_ids if cid in target_map + ] + resp["receiver_count"] = len(resp["child_targets"]) return resp diff --git a/packages/server/src/notify_bridge_server/services/dispatch_helpers.py b/packages/server/src/notify_bridge_server/services/dispatch_helpers.py index f00f544..d6817da 100644 --- a/packages/server/src/notify_bridge_server/services/dispatch_helpers.py +++ b/packages/server/src/notify_bridge_server/services/dispatch_helpers.py @@ -96,6 +96,88 @@ def event_allowed_by_config(event: ServiceEvent, tc: TrackingConfig) -> bool: 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, @@ -127,45 +209,7 @@ async def load_link_data( if not target: continue - # 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)) - + # Load tracking config and template slots (shared across broadcast children) tracking_config = None if tt.tracking_config_id: tracking_config = await session.get(TrackingConfig, tt.tracking_config_id) @@ -184,37 +228,29 @@ async def load_link_data( raw_slots.setdefault(event_key, {})[s.locale] = s.template template_slots = raw_slots - 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 + # Broadcast target: expand into child targets + if target.type == "broadcast": + child_ids = target.config.get("child_target_ids", []) + disabled_ids = set(target.config.get("disabled_child_ids", [])) + for child_id in child_ids: + if child_id in disabled_ids: + continue + child_target = await session.get(NotificationTarget, 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({ - "target_type": target.type, - "target_config": target_config, - "receivers": receivers, + **resolved, "tracking_config": tracking_config, "template_config": template_config, "template_slots": template_slots, diff --git a/packages/server/src/notify_bridge_server/services/notifier.py b/packages/server/src/notify_bridge_server/services/notifier.py index 56ec596..0d50b5c 100644 --- a/packages/server/src/notify_bridge_server/services/notifier.py +++ b/packages/server/src/notify_bridge_server/services/notifier.py @@ -309,11 +309,35 @@ async def send_to_receiver(target: NotificationTarget, receiver_config: dict, me async def send_test_notification(target: NotificationTarget, locale: str = "en") -> dict: - """Send a simple test message.""" + """Send a simple test message. For broadcast targets, fans out to all children.""" + if target.type == "broadcast": + return await _send_broadcast_test(target, locale) message = _get_test_message(locale, target.type) return await send_to_target(target, message) +async def _send_broadcast_test(target: NotificationTarget, locale: str) -> dict: + """Send test notifications to all child targets of a broadcast target.""" + child_ids = target.config.get("child_target_ids", []) + if not child_ids: + return {"success": False, "error": "No child targets configured"} + + disabled_ids = set(target.config.get("disabled_child_ids", [])) + engine = get_engine() + results: list[dict] = [] + async with AsyncSession(engine) as session: + for child_id in child_ids: + if child_id in disabled_ids: + continue + child = await session.get(NotificationTarget, child_id) + if not child or child.type == "broadcast": + continue + message = _get_test_message(locale, child.type) + results.append(await send_to_target(child, message)) + + return _aggregate(results) + + async def send_test_template_notification( target: NotificationTarget, slot: str, template_str: str ) -> dict: diff --git a/scripts/restart-backend.sh b/scripts/restart-backend.sh index 990e359..f8aadaa 100644 --- a/scripts/restart-backend.sh +++ b/scripts/restart-backend.sh @@ -15,7 +15,7 @@ fi # Start backend NOTIFY_BRIDGE_DATA_DIR=./test-data \ NOTIFY_BRIDGE_SECRET_KEY=test-secret-key-minimum-32chars \ -PYTHON=$(command -v python3 2>/dev/null || command -v python 2>/dev/null || command -v py 2>/dev/null) +PYTHON=$(command -v python3 2>/dev/null || command -v python 2>/dev/null || echo "py -3.13") nohup "$PYTHON" -m uvicorn notify_bridge_server.main:app \ --host 0.0.0.0 --port 8420 > /dev/null 2>&1 &