diff --git a/frontend/src/lib/i18n/en.json b/frontend/src/lib/i18n/en.json index eefe3fa..da927d7 100644 --- a/frontend/src/lib/i18n/en.json +++ b/frontend/src/lib/i18n/en.json @@ -119,6 +119,8 @@ "webhookSecretRequired": "Webhook secret is required", "apiToken": "API Token", "apiTokenHint": "Optional. Needed for connection testing and repository listing.", + "webhookUrl": "Webhook URL", + "webhookUrlHint": "Set this as the Target URL in Gitea webhook settings (relative to your bridge host).", "testAndSave": "Test & Save", "saveWithoutTest": "Save without testing" }, diff --git a/frontend/src/lib/i18n/ru.json b/frontend/src/lib/i18n/ru.json index bd35f4c..e304daa 100644 --- a/frontend/src/lib/i18n/ru.json +++ b/frontend/src/lib/i18n/ru.json @@ -119,6 +119,8 @@ "webhookSecretRequired": "Секрет вебхука обязателен", "apiToken": "API токен", "apiTokenHint": "Необязательно. Нужен для проверки подключения и получения списка репозиториев.", + "webhookUrl": "URL вебхука", + "webhookUrlHint": "Укажите этот URL в настройках вебхука Gitea (относительно хоста bridge).", "testAndSave": "Проверить и сохранить", "saveWithoutTest": "Сохранить без проверки" }, diff --git a/frontend/src/routes/notification-trackers/TrackerForm.svelte b/frontend/src/routes/notification-trackers/TrackerForm.svelte index 0987021..85d2328 100644 --- a/frontend/src/routes/notification-trackers/TrackerForm.svelte +++ b/frontend/src/routes/notification-trackers/TrackerForm.svelte @@ -45,6 +45,7 @@ }: Props = $props(); let isScheduler = $derived(providerType === 'scheduler'); + let isWebhook = $derived(providerType === 'gitea'); // Custom variable management for scheduler function addVariable() { @@ -162,10 +163,12 @@ {:else}
{t('providers.apiTokenHint')}
/api/webhooks/gitea/{editing}
+ {t('providers.webhookUrlHint')}
+{{ c.short_id }}: {{ c.message }} ({{ c.author }})
+{%- endfor %}
+{%- if not commits %}
+No recent commits found.
+{%- endif %}
\ No newline at end of file
diff --git a/packages/core/src/notify_bridge_core/templates/command_defaults/en/gitea/desc_commits.jinja2 b/packages/core/src/notify_bridge_core/templates/command_defaults/en/gitea/desc_commits.jinja2
new file mode 100644
index 0000000..623b264
--- /dev/null
+++ b/packages/core/src/notify_bridge_core/templates/command_defaults/en/gitea/desc_commits.jinja2
@@ -0,0 +1 @@
+Recent commits
\ No newline at end of file
diff --git a/packages/core/src/notify_bridge_core/templates/command_defaults/en/gitea/desc_help.jinja2 b/packages/core/src/notify_bridge_core/templates/command_defaults/en/gitea/desc_help.jinja2
new file mode 100644
index 0000000..11b9c33
--- /dev/null
+++ b/packages/core/src/notify_bridge_core/templates/command_defaults/en/gitea/desc_help.jinja2
@@ -0,0 +1 @@
+Show available commands
\ No newline at end of file
diff --git a/packages/core/src/notify_bridge_core/templates/command_defaults/en/gitea/desc_issues.jinja2 b/packages/core/src/notify_bridge_core/templates/command_defaults/en/gitea/desc_issues.jinja2
new file mode 100644
index 0000000..ebe90bc
--- /dev/null
+++ b/packages/core/src/notify_bridge_core/templates/command_defaults/en/gitea/desc_issues.jinja2
@@ -0,0 +1 @@
+Recent open issues
\ No newline at end of file
diff --git a/packages/core/src/notify_bridge_core/templates/command_defaults/en/gitea/desc_prs.jinja2 b/packages/core/src/notify_bridge_core/templates/command_defaults/en/gitea/desc_prs.jinja2
new file mode 100644
index 0000000..4fc7b8b
--- /dev/null
+++ b/packages/core/src/notify_bridge_core/templates/command_defaults/en/gitea/desc_prs.jinja2
@@ -0,0 +1 @@
+Open pull requests
\ No newline at end of file
diff --git a/packages/core/src/notify_bridge_core/templates/command_defaults/en/gitea/desc_repos.jinja2 b/packages/core/src/notify_bridge_core/templates/command_defaults/en/gitea/desc_repos.jinja2
new file mode 100644
index 0000000..ed215cd
--- /dev/null
+++ b/packages/core/src/notify_bridge_core/templates/command_defaults/en/gitea/desc_repos.jinja2
@@ -0,0 +1 @@
+List tracked repositories
\ No newline at end of file
diff --git a/packages/core/src/notify_bridge_core/templates/command_defaults/en/gitea/desc_status.jinja2 b/packages/core/src/notify_bridge_core/templates/command_defaults/en/gitea/desc_status.jinja2
new file mode 100644
index 0000000..bd33199
--- /dev/null
+++ b/packages/core/src/notify_bridge_core/templates/command_defaults/en/gitea/desc_status.jinja2
@@ -0,0 +1 @@
+Show tracker status
\ No newline at end of file
diff --git a/packages/core/src/notify_bridge_core/templates/command_defaults/en/gitea/help.jinja2 b/packages/core/src/notify_bridge_core/templates/command_defaults/en/gitea/help.jinja2
new file mode 100644
index 0000000..9ac9a8d
--- /dev/null
+++ b/packages/core/src/notify_bridge_core/templates/command_defaults/en/gitea/help.jinja2
@@ -0,0 +1,4 @@
+📋 Available commands:
+{%- for cmd in commands %}
+/{{ cmd.name }} — {{ cmd.description }}
+{%- endfor %}
\ No newline at end of file
diff --git a/packages/core/src/notify_bridge_core/templates/command_defaults/en/gitea/issues.jinja2 b/packages/core/src/notify_bridge_core/templates/command_defaults/en/gitea/issues.jinja2
new file mode 100644
index 0000000..cbdc303
--- /dev/null
+++ b/packages/core/src/notify_bridge_core/templates/command_defaults/en/gitea/issues.jinja2
@@ -0,0 +1,7 @@
+🐛 Open Issues
+{%- for issue in issues %}
+• {{ issue.repo }} #{{ issue.number }}: {{ issue.title }} ({{ issue.user }})
+{%- endfor %}
+{%- if not issues %}
+No open issues found.
+{%- endif %}
\ No newline at end of file
diff --git a/packages/core/src/notify_bridge_core/templates/command_defaults/en/gitea/no_results.jinja2 b/packages/core/src/notify_bridge_core/templates/command_defaults/en/gitea/no_results.jinja2
new file mode 100644
index 0000000..bb280ad
--- /dev/null
+++ b/packages/core/src/notify_bridge_core/templates/command_defaults/en/gitea/no_results.jinja2
@@ -0,0 +1 @@
+No results found.
\ No newline at end of file
diff --git a/packages/core/src/notify_bridge_core/templates/command_defaults/en/gitea/prs.jinja2 b/packages/core/src/notify_bridge_core/templates/command_defaults/en/gitea/prs.jinja2
new file mode 100644
index 0000000..af5e5e0
--- /dev/null
+++ b/packages/core/src/notify_bridge_core/templates/command_defaults/en/gitea/prs.jinja2
@@ -0,0 +1,7 @@
+🔀 Open Pull Requests
+{%- for pr in prs %}
+• {{ pr.repo }} #{{ pr.number }}: {{ pr.title }} ({{ pr.user }})
+{%- endfor %}
+{%- if not prs %}
+No open pull requests found.
+{%- endif %}
\ No newline at end of file
diff --git a/packages/core/src/notify_bridge_core/templates/command_defaults/en/gitea/rate_limited.jinja2 b/packages/core/src/notify_bridge_core/templates/command_defaults/en/gitea/rate_limited.jinja2
new file mode 100644
index 0000000..b09d00d
--- /dev/null
+++ b/packages/core/src/notify_bridge_core/templates/command_defaults/en/gitea/rate_limited.jinja2
@@ -0,0 +1 @@
+⏳ Please wait {{ wait }}s before using this command again.
\ No newline at end of file
diff --git a/packages/core/src/notify_bridge_core/templates/command_defaults/en/gitea/repos.jinja2 b/packages/core/src/notify_bridge_core/templates/command_defaults/en/gitea/repos.jinja2
new file mode 100644
index 0000000..abf1281
--- /dev/null
+++ b/packages/core/src/notify_bridge_core/templates/command_defaults/en/gitea/repos.jinja2
@@ -0,0 +1,7 @@
+📦 Tracked Repositories
+{%- for repo in repos %}
+• {{ repo.full_name }}{% if repo.description %} — {{ repo.description }}{% endif %}
+{%- endfor %}
+{%- if not repos %}
+No repositories tracked.
+{%- endif %}
\ No newline at end of file
diff --git a/packages/core/src/notify_bridge_core/templates/command_defaults/en/gitea/start.jinja2 b/packages/core/src/notify_bridge_core/templates/command_defaults/en/gitea/start.jinja2
new file mode 100644
index 0000000..1de1222
--- /dev/null
+++ b/packages/core/src/notify_bridge_core/templates/command_defaults/en/gitea/start.jinja2
@@ -0,0 +1,2 @@
+👋 Hi! I'm your Notify Bridge bot for Gitea.
+Use /help to see available commands.
\ No newline at end of file
diff --git a/packages/core/src/notify_bridge_core/templates/command_defaults/en/gitea/status.jinja2 b/packages/core/src/notify_bridge_core/templates/command_defaults/en/gitea/status.jinja2
new file mode 100644
index 0000000..b0f1fff
--- /dev/null
+++ b/packages/core/src/notify_bridge_core/templates/command_defaults/en/gitea/status.jinja2
@@ -0,0 +1,4 @@
+📊 Gitea Status
+Repositories tracked: {{ repos_count }}
+Server: Gitea v{{ server_version }}
+Last event: {{ last_event }}
\ No newline at end of file
diff --git a/packages/core/src/notify_bridge_core/templates/command_defaults/loader.py b/packages/core/src/notify_bridge_core/templates/command_defaults/loader.py
index b4ea294..51ee1b4 100644
--- a/packages/core/src/notify_bridge_core/templates/command_defaults/loader.py
+++ b/packages/core/src/notify_bridge_core/templates/command_defaults/loader.py
@@ -7,41 +7,71 @@ _LOGGER = logging.getLogger(__name__)
_DEFAULTS_DIR = Path(__file__).parent
-# Response template slot names (file stem = slot name)
+# Per-provider slot names
+PROVIDER_COMMAND_SLOTS: dict[str, list[str]] = {
+ "immich": [
+ # Response templates
+ "start", "help", "status", "albums", "events", "people",
+ "search", "latest", "favorites", "random", "summary", "memory",
+ "rate_limited", "no_results",
+ # Description slots
+ "desc_status", "desc_albums", "desc_events", "desc_summary",
+ "desc_latest", "desc_memory", "desc_random", "desc_search",
+ "desc_find", "desc_person", "desc_place", "desc_favorites",
+ "desc_people", "desc_help",
+ ],
+ "gitea": [
+ # Response templates
+ "start", "help", "status", "repos", "issues", "prs", "commits",
+ "rate_limited", "no_results",
+ # Description slots
+ "desc_help", "desc_status", "desc_repos", "desc_issues",
+ "desc_prs", "desc_commits",
+ ],
+}
+
+# Backward-compatible aliases
COMMAND_SLOT_NAMES = [
"start", "help", "status", "albums", "events", "people",
"search", "latest", "favorites", "random", "summary", "memory",
"rate_limited", "no_results",
]
-
-# Description slots for Telegram command menu (desc_{cmd} -> short text)
COMMAND_DESC_SLOT_NAMES = [
"desc_status", "desc_albums", "desc_events", "desc_summary",
"desc_latest", "desc_memory", "desc_random", "desc_search",
"desc_find", "desc_person", "desc_place", "desc_favorites",
"desc_people", "desc_help",
]
-
-# All slot names (response + description)
ALL_SLOT_NAMES = COMMAND_SLOT_NAMES + COMMAND_DESC_SLOT_NAMES
-def load_default_command_templates(locale: str = "en") -> dict[str, str]:
- """Load default command template strings for a locale.
+def load_default_command_templates(
+ locale: str = "en",
+ provider_type: str = "immich",
+) -> dict[str, str]:
+ """Load default command template strings for a locale and provider type.
+
+ For "immich", templates are in {locale}/ (root, backward compat).
+ For other providers, templates are in {locale}/{provider_type}/.
Returns dict mapping slot_name -> template string.
"""
- locale_dir = _DEFAULTS_DIR / locale
+ if provider_type == "immich":
+ locale_dir = _DEFAULTS_DIR / locale
+ else:
+ locale_dir = _DEFAULTS_DIR / locale / provider_type
+
if not locale_dir.is_dir():
- _LOGGER.warning("No default command templates for locale '%s'", locale)
+ _LOGGER.warning("No default command templates for locale '%s' provider '%s'", locale, provider_type)
return {}
+ slot_names = PROVIDER_COMMAND_SLOTS.get(provider_type, ALL_SLOT_NAMES)
templates: dict[str, str] = {}
- for slot_name in ALL_SLOT_NAMES:
+ for slot_name in slot_names:
filepath = locale_dir / f"{slot_name}.jinja2"
if filepath.exists():
templates[slot_name] = filepath.read_text(encoding="utf-8").strip()
else:
- _LOGGER.debug("Missing default command template: %s/%s.jinja2", locale, slot_name)
+ _LOGGER.debug("Missing default command template: %s/%s.jinja2", locale_dir.name, slot_name)
return templates
diff --git a/packages/core/src/notify_bridge_core/templates/command_defaults/ru/gitea/commits.jinja2 b/packages/core/src/notify_bridge_core/templates/command_defaults/ru/gitea/commits.jinja2
new file mode 100644
index 0000000..c5bef65
--- /dev/null
+++ b/packages/core/src/notify_bridge_core/templates/command_defaults/ru/gitea/commits.jinja2
@@ -0,0 +1,7 @@
+📝 Последние коммиты
+{%- for c in commits %}
+• {{ c.repo }} {{ c.short_id }}: {{ c.message }} ({{ c.author }})
+{%- endfor %}
+{%- if not commits %}
+Коммитов не найдено.
+{%- endif %}
\ No newline at end of file
diff --git a/packages/core/src/notify_bridge_core/templates/command_defaults/ru/gitea/desc_commits.jinja2 b/packages/core/src/notify_bridge_core/templates/command_defaults/ru/gitea/desc_commits.jinja2
new file mode 100644
index 0000000..07ee9a0
--- /dev/null
+++ b/packages/core/src/notify_bridge_core/templates/command_defaults/ru/gitea/desc_commits.jinja2
@@ -0,0 +1 @@
+Последние коммиты
\ No newline at end of file
diff --git a/packages/core/src/notify_bridge_core/templates/command_defaults/ru/gitea/desc_help.jinja2 b/packages/core/src/notify_bridge_core/templates/command_defaults/ru/gitea/desc_help.jinja2
new file mode 100644
index 0000000..3e8f915
--- /dev/null
+++ b/packages/core/src/notify_bridge_core/templates/command_defaults/ru/gitea/desc_help.jinja2
@@ -0,0 +1 @@
+Показать доступные команды
\ No newline at end of file
diff --git a/packages/core/src/notify_bridge_core/templates/command_defaults/ru/gitea/desc_issues.jinja2 b/packages/core/src/notify_bridge_core/templates/command_defaults/ru/gitea/desc_issues.jinja2
new file mode 100644
index 0000000..577efcf
--- /dev/null
+++ b/packages/core/src/notify_bridge_core/templates/command_defaults/ru/gitea/desc_issues.jinja2
@@ -0,0 +1 @@
+Открытые задачи
\ No newline at end of file
diff --git a/packages/core/src/notify_bridge_core/templates/command_defaults/ru/gitea/desc_prs.jinja2 b/packages/core/src/notify_bridge_core/templates/command_defaults/ru/gitea/desc_prs.jinja2
new file mode 100644
index 0000000..d35fed6
--- /dev/null
+++ b/packages/core/src/notify_bridge_core/templates/command_defaults/ru/gitea/desc_prs.jinja2
@@ -0,0 +1 @@
+Открытые пулл-реквесты
\ No newline at end of file
diff --git a/packages/core/src/notify_bridge_core/templates/command_defaults/ru/gitea/desc_repos.jinja2 b/packages/core/src/notify_bridge_core/templates/command_defaults/ru/gitea/desc_repos.jinja2
new file mode 100644
index 0000000..055cd84
--- /dev/null
+++ b/packages/core/src/notify_bridge_core/templates/command_defaults/ru/gitea/desc_repos.jinja2
@@ -0,0 +1 @@
+Список репозиториев
\ No newline at end of file
diff --git a/packages/core/src/notify_bridge_core/templates/command_defaults/ru/gitea/desc_status.jinja2 b/packages/core/src/notify_bridge_core/templates/command_defaults/ru/gitea/desc_status.jinja2
new file mode 100644
index 0000000..44e1bc4
--- /dev/null
+++ b/packages/core/src/notify_bridge_core/templates/command_defaults/ru/gitea/desc_status.jinja2
@@ -0,0 +1 @@
+Статус трекера
\ No newline at end of file
diff --git a/packages/core/src/notify_bridge_core/templates/command_defaults/ru/gitea/help.jinja2 b/packages/core/src/notify_bridge_core/templates/command_defaults/ru/gitea/help.jinja2
new file mode 100644
index 0000000..26b439e
--- /dev/null
+++ b/packages/core/src/notify_bridge_core/templates/command_defaults/ru/gitea/help.jinja2
@@ -0,0 +1,4 @@
+📋 Доступные команды:
+{%- for cmd in commands %}
+/{{ cmd.name }} — {{ cmd.description }}
+{%- endfor %}
\ No newline at end of file
diff --git a/packages/core/src/notify_bridge_core/templates/command_defaults/ru/gitea/issues.jinja2 b/packages/core/src/notify_bridge_core/templates/command_defaults/ru/gitea/issues.jinja2
new file mode 100644
index 0000000..1b2a829
--- /dev/null
+++ b/packages/core/src/notify_bridge_core/templates/command_defaults/ru/gitea/issues.jinja2
@@ -0,0 +1,7 @@
+🐛 Открытые задачи
+{%- for issue in issues %}
+• {{ issue.repo }} #{{ issue.number }}: {{ issue.title }} ({{ issue.user }})
+{%- endfor %}
+{%- if not issues %}
+Открытых задач не найдено.
+{%- endif %}
\ No newline at end of file
diff --git a/packages/core/src/notify_bridge_core/templates/command_defaults/ru/gitea/no_results.jinja2 b/packages/core/src/notify_bridge_core/templates/command_defaults/ru/gitea/no_results.jinja2
new file mode 100644
index 0000000..fc8f62c
--- /dev/null
+++ b/packages/core/src/notify_bridge_core/templates/command_defaults/ru/gitea/no_results.jinja2
@@ -0,0 +1 @@
+Результатов не найдено.
\ No newline at end of file
diff --git a/packages/core/src/notify_bridge_core/templates/command_defaults/ru/gitea/prs.jinja2 b/packages/core/src/notify_bridge_core/templates/command_defaults/ru/gitea/prs.jinja2
new file mode 100644
index 0000000..e8b1e5c
--- /dev/null
+++ b/packages/core/src/notify_bridge_core/templates/command_defaults/ru/gitea/prs.jinja2
@@ -0,0 +1,7 @@
+🔀 Открытые пулл-реквесты
+{%- for pr in prs %}
+• {{ pr.repo }} #{{ pr.number }}: {{ pr.title }} ({{ pr.user }})
+{%- endfor %}
+{%- if not prs %}
+Открытых пулл-реквестов не найдено.
+{%- endif %}
\ No newline at end of file
diff --git a/packages/core/src/notify_bridge_core/templates/command_defaults/ru/gitea/rate_limited.jinja2 b/packages/core/src/notify_bridge_core/templates/command_defaults/ru/gitea/rate_limited.jinja2
new file mode 100644
index 0000000..992e551
--- /dev/null
+++ b/packages/core/src/notify_bridge_core/templates/command_defaults/ru/gitea/rate_limited.jinja2
@@ -0,0 +1 @@
+⏳ Подождите {{ wait }} сек. перед повторным использованием команды.
\ No newline at end of file
diff --git a/packages/core/src/notify_bridge_core/templates/command_defaults/ru/gitea/repos.jinja2 b/packages/core/src/notify_bridge_core/templates/command_defaults/ru/gitea/repos.jinja2
new file mode 100644
index 0000000..f1c03b5
--- /dev/null
+++ b/packages/core/src/notify_bridge_core/templates/command_defaults/ru/gitea/repos.jinja2
@@ -0,0 +1,7 @@
+📦 Отслеживаемые репозитории
+{%- for repo in repos %}
+• {{ repo.full_name }}{% if repo.description %} — {{ repo.description }}{% endif %}
+{%- endfor %}
+{%- if not repos %}
+Репозитории не отслеживаются.
+{%- endif %}
\ No newline at end of file
diff --git a/packages/core/src/notify_bridge_core/templates/command_defaults/ru/gitea/start.jinja2 b/packages/core/src/notify_bridge_core/templates/command_defaults/ru/gitea/start.jinja2
new file mode 100644
index 0000000..8bec248
--- /dev/null
+++ b/packages/core/src/notify_bridge_core/templates/command_defaults/ru/gitea/start.jinja2
@@ -0,0 +1,2 @@
+👋 Привет! Я ваш бот Notify Bridge для Gitea.
+Используйте /help для списка команд.
\ No newline at end of file
diff --git a/packages/core/src/notify_bridge_core/templates/command_defaults/ru/gitea/status.jinja2 b/packages/core/src/notify_bridge_core/templates/command_defaults/ru/gitea/status.jinja2
new file mode 100644
index 0000000..43ed678
--- /dev/null
+++ b/packages/core/src/notify_bridge_core/templates/command_defaults/ru/gitea/status.jinja2
@@ -0,0 +1,4 @@
+📊 Статус Gitea
+Отслеживаемые репозитории: {{ repos_count }}
+Сервер: Gitea v{{ server_version }}
+Последнее событие: {{ last_event }}
\ No newline at end of file
diff --git a/packages/server/src/notify_bridge_server/api/command_configs.py b/packages/server/src/notify_bridge_server/api/command_configs.py
index 1bc176d..5c5ee43 100644
--- a/packages/server/src/notify_bridge_server/api/command_configs.py
+++ b/packages/server/src/notify_bridge_server/api/command_configs.py
@@ -43,9 +43,12 @@ async def list_command_configs(
user: User = Depends(get_current_user),
session: AsyncSession = Depends(get_session),
):
- """List all command configs for the current user."""
+ """List all command configs for the current user (including system defaults)."""
+ from sqlmodel import or_
result = await session.exec(
- select(CommandConfig).where(CommandConfig.user_id == user.id)
+ select(CommandConfig).where(
+ or_(CommandConfig.user_id == user.id, CommandConfig.user_id == 0)
+ )
)
return [_config_response(c) for c in result.all()]
@@ -58,7 +61,7 @@ async def create_command_config(
):
"""Create a new command config."""
# Validate provider_type
- valid_types = ("immich",)
+ valid_types = ("immich", "gitea")
if body.provider_type not in valid_types:
raise HTTPException(
status_code=400,
@@ -159,7 +162,7 @@ async def _get_user_config(
session: AsyncSession, config_id: int, user_id: int
) -> CommandConfig:
config = await session.get(CommandConfig, config_id)
- if not config or config.user_id != user_id:
+ if not config or (config.user_id != user_id and config.user_id != 0):
raise HTTPException(status_code=404, detail="Command config not found")
return config
diff --git a/packages/server/src/notify_bridge_server/commands/base.py b/packages/server/src/notify_bridge_server/commands/base.py
new file mode 100644
index 0000000..915e86e
--- /dev/null
+++ b/packages/server/src/notify_bridge_server/commands/base.py
@@ -0,0 +1,66 @@
+"""Abstract provider command handler interface."""
+
+from __future__ import annotations
+
+from abc import ABC, abstractmethod
+from typing import Any
+
+from ..database.models import CommandTracker, CommandConfig, ServiceProvider, TelegramBot
+
+
+class ProviderCommandHandler(ABC):
+ """Base class for provider-specific bot command handlers.
+
+ Each provider (Immich, Gitea, etc.) implements this interface to handle
+ its own set of commands. The dispatch layer routes commands to the
+ correct handler based on the provider type.
+ """
+
+ provider_type: str
+
+ @abstractmethod
+ def get_provider_commands(self) -> set[str]:
+ """Return the set of command names this handler owns.
+
+ These are provider-specific commands (e.g., 'albums' for Immich,
+ 'repos' for Gitea). Universal commands like 'help' and 'start'
+ are handled by the main dispatcher.
+ """
+
+ @abstractmethod
+ async def handle(
+ self,
+ cmd: str,
+ args: str,
+ count: int,
+ locale: str,
+ response_mode: str,
+ providers_map: dict[int, ServiceProvider],
+ cmd_templates: dict[str, dict[str, str]],
+ bot: TelegramBot,
+ ctx_tuples: list[tuple[CommandTracker, CommandConfig, ServiceProvider]],
+ ) -> str | list[dict[str, Any]] | None:
+ """Handle a provider-specific command.
+
+ Args:
+ cmd: The command name (without '/').
+ args: Arguments after the command.
+ count: Number of results to return.
+ locale: User's locale ('en', 'ru').
+ response_mode: 'media' or 'text'.
+ providers_map: Provider instances keyed by ID.
+ cmd_templates: Template slots {slot_name: {locale: template}}.
+ bot: The Telegram bot instance.
+ ctx_tuples: Command context tuples for this provider type.
+
+ Returns:
+ Text response, media list, or None if unhandled.
+ """
+
+ def get_rate_categories(self) -> dict[str, str]:
+ """Return rate limit category mapping for this provider's commands.
+
+ Keys are command names, values are category strings.
+ Commands not listed default to 'default' category.
+ """
+ return {}
diff --git a/packages/server/src/notify_bridge_server/commands/dispatch.py b/packages/server/src/notify_bridge_server/commands/dispatch.py
new file mode 100644
index 0000000..b35981c
--- /dev/null
+++ b/packages/server/src/notify_bridge_server/commands/dispatch.py
@@ -0,0 +1,40 @@
+"""Provider command handler registry and routing."""
+
+from __future__ import annotations
+
+import logging
+
+from .base import ProviderCommandHandler
+
+_LOGGER = logging.getLogger(__name__)
+
+_REGISTRY: dict[str, ProviderCommandHandler] = {}
+
+
+def register_handler(handler: ProviderCommandHandler) -> None:
+ """Register a provider command handler."""
+ _REGISTRY[handler.provider_type] = handler
+ _LOGGER.debug("Registered command handler for provider type: %s", handler.provider_type)
+
+
+def get_handler(provider_type: str) -> ProviderCommandHandler | None:
+ """Get the command handler for a provider type."""
+ return _REGISTRY.get(provider_type)
+
+
+def get_all_handlers() -> dict[str, ProviderCommandHandler]:
+ """Get all registered handlers."""
+ return dict(_REGISTRY)
+
+
+def _auto_register() -> None:
+ """Auto-register all built-in handlers."""
+ from .immich_handler import ImmichCommandHandler
+ from .gitea_handler import GiteaCommandHandler
+
+ register_handler(ImmichCommandHandler())
+ register_handler(GiteaCommandHandler())
+
+
+# Auto-register on import
+_auto_register()
diff --git a/packages/server/src/notify_bridge_server/commands/gitea_handler.py b/packages/server/src/notify_bridge_server/commands/gitea_handler.py
new file mode 100644
index 0000000..0ccc1e1
--- /dev/null
+++ b/packages/server/src/notify_bridge_server/commands/gitea_handler.py
@@ -0,0 +1,252 @@
+"""Gitea-specific bot command handler."""
+
+from __future__ import annotations
+
+import logging
+from typing import Any
+
+import aiohttp
+from sqlmodel import select
+from sqlmodel.ext.asyncio.session import AsyncSession
+
+from ..database.engine import get_engine
+from ..database.models import (
+ CommandConfig, CommandTracker, EventLog,
+ NotificationTracker, ServiceProvider, TelegramBot,
+)
+from ..services import make_gitea_provider
+from .base import ProviderCommandHandler
+from .handler import _render_cmd_template, _get_notification_trackers_for_providers
+
+_LOGGER = logging.getLogger(__name__)
+
+_GITEA_COMMANDS = {"status", "repos", "issues", "prs", "commits"}
+
+
+class GiteaCommandHandler(ProviderCommandHandler):
+ """Handles Gitea-specific bot commands."""
+
+ provider_type = "gitea"
+
+ def get_provider_commands(self) -> set[str]:
+ return _GITEA_COMMANDS
+
+ def get_rate_categories(self) -> dict[str, str]:
+ return {
+ "repos": "api", "issues": "api",
+ "prs": "api", "commits": "api",
+ }
+
+ async def handle(
+ self,
+ cmd: str,
+ args: str,
+ count: int,
+ locale: str,
+ response_mode: str,
+ providers_map: dict[int, ServiceProvider],
+ cmd_templates: dict[str, dict[str, str]],
+ bot: TelegramBot,
+ ctx_tuples: list[tuple[CommandTracker, CommandConfig, ServiceProvider]],
+ ) -> str | list[dict[str, Any]] | None:
+ if cmd == "status":
+ ctx = await _cmd_status(providers_map)
+ return _render_cmd_template(cmd_templates, "status", locale, ctx)
+ if cmd == "repos":
+ ctx = await _cmd_repos(providers_map)
+ return _render_cmd_template(cmd_templates, "repos", locale, ctx)
+ if cmd == "issues":
+ ctx = await _cmd_issues(providers_map, count)
+ return _render_cmd_template(cmd_templates, "issues", locale, ctx)
+ if cmd == "prs":
+ ctx = await _cmd_prs(providers_map, count)
+ return _render_cmd_template(cmd_templates, "prs", locale, ctx)
+ if cmd == "commits":
+ ctx = await _cmd_commits(providers_map, count)
+ return _render_cmd_template(cmd_templates, "commits", locale, ctx)
+ return None
+
+
+def _get_tracked_repos(
+ providers_map: dict[int, ServiceProvider],
+ trackers: list[NotificationTracker],
+) -> list[tuple[ServiceProvider, str, str]]:
+ """Get (provider, owner, repo) tuples from tracked collection_ids."""
+ repos: list[tuple[ServiceProvider, str, str]] = []
+ for tracker in trackers:
+ provider = providers_map.get(tracker.provider_id)
+ if not provider or provider.type != "gitea":
+ continue
+ if not provider.config.get("api_token"):
+ continue
+ for full_name in (tracker.collection_ids or []):
+ parts = full_name.split("/", 1)
+ if len(parts) == 2:
+ repos.append((provider, parts[0], parts[1]))
+ # Also check filters.collections
+ for tracker in trackers:
+ provider = providers_map.get(tracker.provider_id)
+ if not provider or provider.type != "gitea":
+ continue
+ if not provider.config.get("api_token"):
+ continue
+ for full_name in (tracker.filters or {}).get("collections", []):
+ parts = full_name.split("/", 1)
+ if len(parts) == 2:
+ entry = (provider, parts[0], parts[1])
+ if entry not in repos:
+ repos.append(entry)
+ return repos[:20] # Cap to prevent API hammering
+
+
+async def _cmd_status(providers_map: dict[int, ServiceProvider]) -> dict[str, Any]:
+ provider_ids = set(providers_map.keys())
+ trackers = await _get_notification_trackers_for_providers(provider_ids)
+ tracked_repos = _get_tracked_repos(providers_map, trackers)
+
+ # Get server version from first Gitea provider with token
+ server_version = "unknown"
+ async with aiohttp.ClientSession() as http:
+ for provider in providers_map.values():
+ if provider.type == "gitea" and provider.config.get("api_token"):
+ gitea = make_gitea_provider(http, provider)
+ version = await gitea.client.get_server_version()
+ if version:
+ server_version = version
+ break
+
+ # Last event
+ engine = get_engine()
+ async with AsyncSession(engine) as session:
+ tracker_ids = [t.id for t in trackers]
+ if tracker_ids:
+ result = await session.exec(
+ select(EventLog)
+ .where(EventLog.tracker_id.in_(tracker_ids))
+ .order_by(EventLog.created_at.desc()).limit(1)
+ )
+ last_event = result.first()
+ else:
+ last_event = None
+ last_str = last_event.created_at.strftime("%Y-%m-%d %H:%M") if last_event else "-"
+
+ return {
+ "repos_count": len(tracked_repos),
+ "server_version": server_version,
+ "last_event": last_str,
+ }
+
+
+async def _cmd_repos(providers_map: dict[int, ServiceProvider]) -> dict[str, Any]:
+ provider_ids = set(providers_map.keys())
+ trackers = await _get_notification_trackers_for_providers(provider_ids)
+ tracked_repos = _get_tracked_repos(providers_map, trackers)
+
+ repos_data: list[dict[str, Any]] = []
+ async with aiohttp.ClientSession() as http:
+ for provider, owner, repo in tracked_repos:
+ gitea = make_gitea_provider(http, provider)
+ try:
+ all_repos = await gitea.client.get_repos(limit=50)
+ for r in all_repos:
+ if r.get("full_name") == f"{owner}/{repo}":
+ repos_data.append({
+ "full_name": r.get("full_name", ""),
+ "description": r.get("description", ""),
+ "stars": r.get("stars_count", 0),
+ "url": r.get("html_url", ""),
+ })
+ break
+ else:
+ repos_data.append({
+ "full_name": f"{owner}/{repo}",
+ "description": "",
+ "stars": 0,
+ "url": "",
+ })
+ except Exception:
+ repos_data.append({
+ "full_name": f"{owner}/{repo}",
+ "description": "?",
+ "stars": 0,
+ "url": "",
+ })
+
+ return {"repos": repos_data}
+
+
+async def _cmd_issues(
+ providers_map: dict[int, ServiceProvider], count: int,
+) -> dict[str, Any]:
+ provider_ids = set(providers_map.keys())
+ trackers = await _get_notification_trackers_for_providers(provider_ids)
+ tracked_repos = _get_tracked_repos(providers_map, trackers)
+
+ all_issues: list[dict[str, Any]] = []
+ async with aiohttp.ClientSession() as http:
+ for provider, owner, repo in tracked_repos:
+ gitea = make_gitea_provider(http, provider)
+ issues = await gitea.client.get_repo_issues(owner, repo, limit=count)
+ for issue in issues:
+ all_issues.append({
+ "repo": f"{owner}/{repo}",
+ "number": issue.get("number", 0),
+ "title": issue.get("title", ""),
+ "url": issue.get("html_url", ""),
+ "user": issue.get("user", {}).get("login", ""),
+ "state": issue.get("state", ""),
+ })
+
+ all_issues.sort(key=lambda i: i.get("number", 0), reverse=True)
+ return {"issues": all_issues[:count]}
+
+
+async def _cmd_prs(
+ providers_map: dict[int, ServiceProvider], count: int,
+) -> dict[str, Any]:
+ provider_ids = set(providers_map.keys())
+ trackers = await _get_notification_trackers_for_providers(provider_ids)
+ tracked_repos = _get_tracked_repos(providers_map, trackers)
+
+ all_prs: list[dict[str, Any]] = []
+ async with aiohttp.ClientSession() as http:
+ for provider, owner, repo in tracked_repos:
+ gitea = make_gitea_provider(http, provider)
+ prs = await gitea.client.get_repo_pulls(owner, repo, limit=count)
+ for pr in prs:
+ all_prs.append({
+ "repo": f"{owner}/{repo}",
+ "number": pr.get("number", 0),
+ "title": pr.get("title", ""),
+ "url": pr.get("html_url", ""),
+ "user": pr.get("user", {}).get("login", ""),
+ "state": pr.get("state", ""),
+ })
+
+ all_prs.sort(key=lambda p: p.get("number", 0), reverse=True)
+ return {"prs": all_prs[:count]}
+
+
+async def _cmd_commits(
+ providers_map: dict[int, ServiceProvider], count: int,
+) -> dict[str, Any]:
+ provider_ids = set(providers_map.keys())
+ trackers = await _get_notification_trackers_for_providers(provider_ids)
+ tracked_repos = _get_tracked_repos(providers_map, trackers)
+
+ all_commits: list[dict[str, Any]] = []
+ async with aiohttp.ClientSession() as http:
+ for provider, owner, repo in tracked_repos:
+ gitea = make_gitea_provider(http, provider)
+ commits = await gitea.client.get_repo_commits(owner, repo, limit=count)
+ for c in commits:
+ commit_data = c.get("commit", {})
+ all_commits.append({
+ "repo": f"{owner}/{repo}",
+ "short_id": c.get("sha", "")[:7],
+ "message": commit_data.get("message", "").split("\n")[0][:80],
+ "author": commit_data.get("author", {}).get("name", ""),
+ "url": c.get("html_url", ""),
+ })
+
+ return {"commits": all_commits[:count]}
diff --git a/packages/server/src/notify_bridge_server/commands/handler.py b/packages/server/src/notify_bridge_server/commands/handler.py
index e32e972..0b76a6a 100644
--- a/packages/server/src/notify_bridge_server/commands/handler.py
+++ b/packages/server/src/notify_bridge_server/commands/handler.py
@@ -1,11 +1,9 @@
-"""Telegram bot command handler — implements all /commands."""
+"""Telegram bot command handler — provider-agnostic dispatcher."""
from __future__ import annotations
import logging
-import random as rng
import time
-from datetime import datetime, timezone
from typing import Any
import aiohttp
@@ -14,7 +12,6 @@ from sqlmodel.ext.asyncio.session import AsyncSession
from notify_bridge_core.notifications.telegram.media import TELEGRAM_API_BASE_URL
from ..database.engine import get_engine
-from ..services import make_immich_provider
from ..database.models import (
CommandConfig,
CommandTemplateConfig,
@@ -22,12 +19,9 @@ from ..database.models import (
CommandTracker,
CommandTrackerListener,
EventLog,
- NotificationTarget,
NotificationTracker,
- NotificationTrackerTarget,
ServiceProvider,
TelegramBot,
- TrackingConfig,
)
from .parser import parse_command
from .registry import get_rate_category
@@ -90,7 +84,6 @@ async def _resolve_command_context(
"""
engine = get_engine()
async with AsyncSession(engine) as session:
- # Find all listeners for this bot
result = await session.exec(
select(CommandTrackerListener).where(
CommandTrackerListener.listener_type == "telegram_bot",
@@ -115,19 +108,20 @@ async def _resolve_command_context(
continue
tuples.append((tracker, config, provider))
- # Load command template slots from the first config that has one
+ # Load command template slots — merge from all configs
cmd_template_slots: dict[str, dict[str, str]] = {}
+ seen_config_ids: set[int] = set()
for _, config, _ in tuples:
- if config.command_template_config_id:
+ cfg_id = config.command_template_config_id
+ if cfg_id and cfg_id not in seen_config_ids:
+ seen_config_ids.add(cfg_id)
slot_result = await session.exec(
select(CommandTemplateSlot).where(
- CommandTemplateSlot.config_id == config.command_template_config_id
+ CommandTemplateSlot.config_id == cfg_id
)
)
for s in slot_result.all():
cmd_template_slots.setdefault(s.slot_name, {})[s.locale] = s.template
- if cmd_template_slots:
- break
return tuples, cmd_template_slots
@@ -135,19 +129,14 @@ async def _resolve_command_context(
def _merge_command_context(
ctx: list[tuple[CommandTracker, CommandConfig, ServiceProvider]],
) -> tuple[list[str], str, int, dict[str, Any]]:
- """Merge enabled_commands from all configs and pick defaults from first config.
-
- Returns (enabled_commands, response_mode, default_count, rate_limits).
- """
+ """Merge enabled_commands from all configs and pick defaults from first config."""
if not ctx:
return [], "media", 5, {}
- # Union of all enabled commands across configs
enabled: set[str] = set()
for _, config, _ in ctx:
enabled.update(config.enabled_commands or [])
- # Use first config's settings as defaults
first_config = ctx[0][1]
response_mode = first_config.response_mode or "media"
default_count = first_config.default_count or 5
@@ -162,10 +151,9 @@ async def handle_command(
text: str,
language_code: str = "",
) -> str | list[dict[str, Any]] | None:
- """Handle a bot command. Returns text response, media list, or None.
+ """Handle a bot command. Routes to provider-specific handlers.
- language_code is the Telegram user's language (from message.from.language_code).
- Used to pick the right locale for template rendering.
+ Returns text response, media list, or None.
"""
cmd, args, count_override = parse_command(text)
if not cmd:
@@ -174,10 +162,7 @@ async def handle_command(
ctx_tuples, cmd_templates = await _resolve_command_context(bot)
enabled, response_mode, default_count, rate_limits = _merge_command_context(ctx_tuples)
- # Derive locale from Telegram user language, falling back to "en"
locale = language_code[:2].lower() if language_code else "en"
- # Only use locale if we actually have templates for it, otherwise fall back
- # (_render_cmd_template handles per-slot fallback, but let's normalize)
if locale not in ("en", "ru"):
locale = "en"
@@ -185,7 +170,7 @@ async def handle_command(
return _render_cmd_template(cmd_templates, "start", locale, {"bot_name": bot.name})
if cmd not in enabled and cmd != "start":
- return None # Silently ignore disabled commands
+ return None
# Rate limit check
wait = _check_rate_limit(bot.id, chat_id, cmd, rate_limits)
@@ -199,24 +184,34 @@ async def handle_command(
for _, _, provider in ctx_tuples:
providers_map[provider.id] = provider
- # Dispatch — each handler returns template context dict
+ # Universal commands
if cmd == "help":
ctx = _cmd_help(enabled, locale, cmd_templates)
- elif cmd == "status":
- ctx = await _cmd_status(bot, providers_map, locale)
- elif cmd == "albums":
- ctx = await _cmd_albums(bot, providers_map, locale)
- elif cmd == "events":
- ctx = await _cmd_events(bot, providers_map, count, locale)
- elif cmd == "people":
- ctx = await _cmd_people(providers_map, locale)
- elif cmd in ("search", "find", "person", "place", "latest", "random",
- "favorites", "summary", "memory"):
- return await _cmd_immich(bot, cmd, args, count, locale, response_mode, providers_map, cmd_templates)
- else:
- return None
+ return _render_cmd_template(cmd_templates, "help", locale, ctx)
- return _render_cmd_template(cmd_templates, cmd, locale, {**ctx})
+ # Provider-specific dispatch
+ from .dispatch import get_handler
+
+ # Group ctx_tuples by provider type
+ by_type: dict[str, list[tuple[CommandTracker, CommandConfig, ServiceProvider]]] = {}
+ for t in ctx_tuples:
+ ptype = t[2].type
+ by_type.setdefault(ptype, []).append(t)
+
+ # Find which handler claims this command
+ for ptype, ptuples in by_type.items():
+ handler = get_handler(ptype)
+ if handler and cmd in handler.get_provider_commands():
+ # Build provider map filtered to this provider type
+ pmap = {p.id: p for _, _, p in ptuples}
+ result = await handler.handle(
+ cmd, args, count, locale, response_mode,
+ pmap, cmd_templates, bot, ptuples,
+ )
+ if result is not None:
+ return result
+
+ return None
def _cmd_help(
@@ -245,325 +240,6 @@ async def _get_notification_trackers_for_providers(
return list(result.all())
-async def _check_native_memory(bot: TelegramBot) -> bool:
- """Check if any tracker-target linked to this bot uses native memory source."""
- engine = get_engine()
- async with AsyncSession(engine) as session:
- result = await session.exec(
- select(NotificationTarget).where(
- NotificationTarget.type == "telegram",
- NotificationTarget.user_id == bot.user_id,
- )
- )
- targets = result.all()
- bot_target_ids = {t.id for t in targets if t.config.get("bot_token") == bot.token}
- if not bot_target_ids:
- return False
- tt_result = await session.exec(
- select(NotificationTrackerTarget).where(NotificationTrackerTarget.target_id.in_(bot_target_ids))
- )
- for tt in tt_result.all():
- if tt.tracking_config_id:
- tc = await session.get(TrackingConfig, tt.tracking_config_id)
- if tc and tc.memory_source == "native":
- return True
- return False
-
-
-async def _cmd_status(bot: TelegramBot, providers_map: dict[int, ServiceProvider], locale: str) -> dict[str, Any]:
- provider_ids = set(providers_map.keys())
- trackers = await _get_notification_trackers_for_providers(provider_ids)
- active = sum(1 for t in trackers if t.enabled)
- total = len(trackers)
- total_albums = sum(len(t.collection_ids or []) for t in trackers)
-
- engine = get_engine()
- async with AsyncSession(engine) as session:
- result = await session.exec(
- select(EventLog).order_by(EventLog.created_at.desc()).limit(1)
- )
- last_event = result.first()
- last_str = last_event.created_at.strftime("%Y-%m-%d %H:%M") if last_event else "-"
-
- return {"trackers_active": active, "trackers_total": total, "total_albums": total_albums, "last_event": last_str}
-
-
-async def _cmd_albums(bot: TelegramBot, providers_map: dict[int, ServiceProvider], locale: str) -> dict[str, Any]:
- provider_ids = set(providers_map.keys())
- trackers = await _get_notification_trackers_for_providers(provider_ids)
- if not trackers:
- return {"albums": []}
-
- albums_data: list[dict] = []
- async with aiohttp.ClientSession() as http:
- for tracker in trackers:
- provider = providers_map.get(tracker.provider_id)
- if not provider or provider.type != "immich":
- continue
- immich = make_immich_provider(http, provider)
- for album_id in (tracker.collection_ids or []):
- try:
- album = await immich.client.get_album(album_id)
- if album:
- albums_data.append({"name": album.name, "asset_count": album.asset_count, "id": album_id})
- except Exception:
- albums_data.append({"name": f"{album_id[:8]}...", "asset_count": "?", "id": album_id})
-
- return {"albums": albums_data}
-
-
-async def _cmd_events(bot: TelegramBot, providers_map: dict[int, ServiceProvider], count: int, locale: str) -> dict[str, Any]:
- provider_ids = set(providers_map.keys())
- trackers = await _get_notification_trackers_for_providers(provider_ids)
- tracker_ids = [t.id for t in trackers]
- if not tracker_ids:
- return {"events": []}
-
- engine = get_engine()
- async with AsyncSession(engine) as session:
- result = await session.exec(
- select(EventLog)
- .where(EventLog.tracker_id.in_(tracker_ids))
- .order_by(EventLog.created_at.desc())
- .limit(count)
- )
- events = result.all()
-
- events_data = [{"type": e.event_type, "album": e.collection_name, "count": e.assets_count,
- "date": e.created_at.strftime("%m/%d %H:%M")} for e in events]
-
- return {"events": events_data}
-
-
-async def _cmd_people(providers_map: dict[int, ServiceProvider], locale: str) -> dict[str, Any]:
- all_people: dict[str, str] = {}
-
- async with aiohttp.ClientSession() as http:
- for provider in providers_map.values():
- if provider.type != "immich":
- continue
- immich = make_immich_provider(http, provider)
- people = await immich.client.get_people()
- all_people.update(people)
-
- names = sorted(all_people.values())
- return {"people": names}
-
-
-async def _cmd_immich(
- bot: TelegramBot, cmd: str, args: str, count: int, locale: str,
- response_mode: str, providers_map: dict[int, ServiceProvider],
- cmd_templates: dict[str, dict[str, str]],
-) -> str | list[dict[str, Any]]:
- """Handle commands that need Immich API access and may return media."""
- if not providers_map:
- return _render_cmd_template(cmd_templates, "no_results", locale, {"command": cmd, "query": args})
-
- # Get notification trackers for album data
- provider_ids = set(providers_map.keys())
- notification_trackers = await _get_notification_trackers_for_providers(provider_ids)
-
- all_album_ids: list[str] = []
- for t in notification_trackers:
- all_album_ids.extend(t.collection_ids or [])
-
- # Pick the first immich provider
- provider: ServiceProvider | None = None
- for p in providers_map.values():
- if p.type == "immich":
- provider = p
- break
- if not provider:
- return _render_cmd_template(cmd_templates, "no_results", locale, {"command": cmd, "query": args})
-
- async with aiohttp.ClientSession() as http:
- immich = make_immich_provider(http, provider)
- client = immich.client
-
- if cmd == "search":
- if not args:
- return _render_cmd_template(cmd_templates, "no_results", locale, {"command": cmd, "query": ""})
- assets = await client.search_smart(args, album_ids=all_album_ids, limit=count)
- return _format_assets(assets, cmd, args, locale, response_mode, client, cmd_templates)
-
- if cmd == "find":
- if not args:
- return _render_cmd_template(cmd_templates, "no_results", locale, {"command": cmd, "query": ""})
- assets = await client.search_metadata(args, album_ids=all_album_ids, limit=count)
- return _format_assets(assets, cmd, args, locale, response_mode, client, cmd_templates)
-
- if cmd == "person":
- if not args:
- return _render_cmd_template(cmd_templates, "no_results", locale, {"command": "person", "query": ""})
- people = await client.get_people()
- person_id = None
- for pid, pname in people.items():
- if args.lower() in pname.lower():
- person_id = pid
- break
- if not person_id:
- return _render_cmd_template(cmd_templates, "no_results", locale, {"command": "person", "query": args})
- assets = await client.search_by_person(person_id, limit=count)
- return _format_assets(assets, cmd, args, locale, response_mode, client, cmd_templates)
-
- if cmd == "place":
- if not args:
- return _render_cmd_template(cmd_templates, "no_results", locale, {"command": "place", "query": ""})
- assets = await client.search_smart(
- f"photos taken in {args}", album_ids=all_album_ids, limit=count
- )
- return _format_assets(assets, cmd, args, locale, response_mode, client, cmd_templates)
-
- if cmd == "favorites":
- fav_assets: list[dict[str, Any]] = []
- for album_id in all_album_ids[:10]:
- try:
- album = await client.get_album(album_id)
- if album:
- for aid, asset in list(album.assets.items())[:50]:
- if asset.is_favorite and len(fav_assets) < count:
- fav_assets.append({
- "id": asset.id, "originalFileName": asset.filename,
- "type": asset.type,
- })
- except Exception:
- pass
- if len(fav_assets) >= count:
- break
- return _format_assets(fav_assets, cmd, "", locale, response_mode, client, cmd_templates)
-
- if cmd == "latest":
- latest_assets: list[dict[str, Any]] = []
- for album_id in all_album_ids[:10]:
- try:
- album = await client.get_album(album_id)
- if album:
- for aid, asset in list(album.assets.items())[:count]:
- latest_assets.append({
- "id": asset.id, "originalFileName": asset.filename,
- "type": asset.type, "createdAt": asset.created_at,
- })
- except Exception:
- pass
- latest_assets.sort(key=lambda a: a.get("createdAt", ""), reverse=True)
- return _format_assets(latest_assets[:count], cmd, "", locale, response_mode, client, cmd_templates)
-
- if cmd == "random":
- random_assets: list[dict[str, Any]] = []
- for album_id in all_album_ids[:10]:
- try:
- album = await client.get_album(album_id)
- if album:
- asset_list = list(album.assets.values())
- sampled = rng.sample(asset_list, min(count, len(asset_list)))
- for asset in sampled:
- random_assets.append({
- "id": asset.id, "originalFileName": asset.filename,
- "type": asset.type,
- })
- except Exception:
- pass
- rng.shuffle(random_assets)
- return _format_assets(random_assets[:count], cmd, "", locale, response_mode, client, cmd_templates)
-
- if cmd == "summary":
- albums_data: list[dict] = []
- for album_id in all_album_ids:
- try:
- album = await client.get_album(album_id)
- if album:
- albums_data.append({"name": album.name, "asset_count": album.asset_count, "id": album_id})
- except Exception:
- pass
- return _render_cmd_template(cmd_templates, "summary", locale, {"albums": albums_data})
-
- if cmd == "memory":
- # Check if any linked tracking config uses native memories
- use_native = await _check_native_memory(bot)
-
- today = datetime.now(timezone.utc)
- memory_assets: list[dict[str, Any]] = []
-
- if use_native:
- # Use Immich native memories API
- memories = await client.get_memories()
- tracked_ids = set(all_album_ids) if all_album_ids else None
- for mem in memories:
- year = mem.get("data", {}).get("year")
- for raw_asset in mem.get("assets", []):
- if tracked_ids:
- asset_albums = raw_asset.get("albums", [])
- if not any(a.get("id") in tracked_ids for a in asset_albums):
- continue
- memory_assets.append({
- "id": raw_asset.get("id", ""),
- "originalFileName": raw_asset.get("originalFileName", ""),
- "type": raw_asset.get("type", "IMAGE"),
- "createdAt": raw_asset.get("fileCreatedAt", raw_asset.get("createdAt", "")),
- "year": year,
- })
- else:
- # Album-scanning fallback
- month_day = (today.month, today.day)
- for album_id in all_album_ids[:10]:
- try:
- album = await client.get_album(album_id)
- if album:
- for aid, asset in album.assets.items():
- try:
- dt = datetime.fromisoformat(asset.created_at.replace("Z", "+00:00"))
- if (dt.month, dt.day) == month_day and dt.year != today.year:
- memory_assets.append({
- "id": asset.id, "originalFileName": asset.filename,
- "type": asset.type, "createdAt": asset.created_at,
- "year": dt.year,
- })
- except (ValueError, AttributeError):
- pass
- except Exception:
- pass
-
- memory_assets = memory_assets[:count]
- if not memory_assets:
- return _render_cmd_template(cmd_templates, "no_results", locale, {"command": "memory", "query": ""})
- return _format_assets(memory_assets, cmd, "", locale, response_mode, client, cmd_templates)
-
- return None
-
-
-def _format_assets(
- assets: list[dict[str, Any]], cmd: str, query: str,
- locale: str, response_mode: str, client: Any,
- cmd_templates: dict[str, dict[str, str]],
-) -> str | list[dict[str, Any]]:
- """Format asset results as text or media payload."""
- if not assets:
- return _render_cmd_template(cmd_templates, "no_results", locale, {"command": cmd, "query": query})
-
- if response_mode == "media":
- media_items = []
- for asset in assets:
- asset_id = asset.get("id", "")
- filename = asset.get("originalFileName", "")
- year = asset.get("year", "")
- caption = f"{filename} ({year})" if year else filename
- media_items.append({
- "type": "photo",
- "asset_id": asset_id,
- "caption": caption,
- "thumbnail_url": f"{client.url}/api/assets/{asset_id}/thumbnail?size=preview",
- "api_key": client.api_key,
- })
- return media_items
-
- # Text mode — render via template
- slot_map = {"find": "search", "person": "search", "place": "search"}
- slot_name = slot_map.get(cmd, cmd)
- return _render_cmd_template(cmd_templates, slot_name, locale, {
- "assets": assets, "query": query, "command": cmd, "count": len(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:
@@ -586,17 +262,12 @@ async def send_reply(bot_token: str, chat_id: str, text: str) -> None:
async def send_media_group(
bot_token: str, chat_id: str, media_items: list[dict[str, Any]],
) -> None:
- """Send media items as a Telegram media group (album).
-
- Falls back to individual sendPhoto calls if sendMediaGroup fails.
- Telegram allows max 10 items per media group.
- """
+ """Send media items as a Telegram media group (album)."""
if not media_items:
return
async with aiohttp.ClientSession() as http:
- # Download all thumbnails first
- downloaded: list[tuple[bytes, str, str]] = [] # (photo_bytes, asset_id, caption)
+ downloaded: list[tuple[bytes, str, str]] = []
for item in media_items:
asset_id = item.get("asset_id", "")
caption = item.get("caption", "")
@@ -615,12 +286,10 @@ async def send_media_group(
if not downloaded:
return
- # Send in groups of 10 (Telegram limit)
for i in range(0, len(downloaded), 10):
chunk = downloaded[i:i + 10]
if len(chunk) == 1:
- # Single photo — use sendPhoto
photo_bytes, asset_id, caption = chunk[0]
data = aiohttp.FormData()
data.add_field("chat_id", chat_id)
@@ -635,7 +304,6 @@ async def send_media_group(
except aiohttp.ClientError as err:
_LOGGER.warning("Failed to send photo: %s", err)
else:
- # Multiple photos — use sendMediaGroup
import json as _json
data = aiohttp.FormData()
data.add_field("chat_id", chat_id)
@@ -658,12 +326,7 @@ async def send_media_group(
async def register_commands_with_telegram(bot: TelegramBot) -> bool:
- """Register enabled commands with Telegram BotFather API.
-
- Registers all supported locales explicitly with language_code,
- plus English as the default fallback (no language_code).
- Descriptions are read from desc_* template slots.
- """
+ """Register enabled commands with Telegram BotFather API."""
ctx_tuples, templates = await _resolve_command_context(bot)
enabled, _, _, _ = _merge_command_context(ctx_tuples)
@@ -677,7 +340,6 @@ async def register_commands_with_telegram(bot: TelegramBot) -> bool:
desc = _resolve_template(templates, f"desc_{cmd}", locale) or cmd
commands.append({"command": cmd, "description": desc})
- # Register with explicit language_code
payload: dict[str, Any] = {"commands": commands, "language_code": locale}
try:
async with http.post(url, json=payload) as resp:
@@ -689,7 +351,6 @@ async def register_commands_with_telegram(bot: TelegramBot) -> bool:
except aiohttp.ClientError as err:
_LOGGER.error("Failed to register commands for locale '%s': %s", locale, err)
- # Also register English as the default (no language_code) for unsupported langs
en_commands = []
for cmd in enabled:
desc = _resolve_template(templates, f"desc_{cmd}", "en") or cmd
diff --git a/packages/server/src/notify_bridge_server/commands/immich_handler.py b/packages/server/src/notify_bridge_server/commands/immich_handler.py
new file mode 100644
index 0000000..ab42aef
--- /dev/null
+++ b/packages/server/src/notify_bridge_server/commands/immich_handler.py
@@ -0,0 +1,414 @@
+"""Immich-specific bot command handler."""
+
+from __future__ import annotations
+
+import logging
+import random as rng
+from datetime import datetime, timezone
+from typing import Any
+
+import aiohttp
+from sqlmodel import select
+from sqlmodel.ext.asyncio.session import AsyncSession
+
+from ..database.engine import get_engine
+from ..database.models import (
+ CommandConfig, CommandTracker, NotificationTarget,
+ NotificationTracker, NotificationTrackerTarget,
+ ServiceProvider, TelegramBot, TrackingConfig,
+)
+from ..services import make_immich_provider
+from .base import ProviderCommandHandler
+from .handler import _render_cmd_template, _get_notification_trackers_for_providers
+
+_LOGGER = logging.getLogger(__name__)
+
+_IMMICH_COMMANDS = {
+ "status", "albums", "events", "people",
+ "search", "find", "person", "place",
+ "latest", "random", "favorites", "summary", "memory",
+}
+
+
+class ImmichCommandHandler(ProviderCommandHandler):
+ """Handles all Immich-specific bot commands."""
+
+ provider_type = "immich"
+
+ def get_provider_commands(self) -> set[str]:
+ return _IMMICH_COMMANDS
+
+ def get_rate_categories(self) -> dict[str, str]:
+ return {
+ "search": "search", "find": "search", "person": "search",
+ "place": "search", "favorites": "search", "people": "search",
+ }
+
+ async def handle(
+ self,
+ cmd: str,
+ args: str,
+ count: int,
+ locale: str,
+ response_mode: str,
+ providers_map: dict[int, ServiceProvider],
+ cmd_templates: dict[str, dict[str, str]],
+ bot: TelegramBot,
+ ctx_tuples: list[tuple[CommandTracker, CommandConfig, ServiceProvider]],
+ ) -> str | list[dict[str, Any]] | None:
+ if cmd == "status":
+ ctx = await _cmd_status(bot, providers_map, locale)
+ return _render_cmd_template(cmd_templates, "status", locale, ctx)
+ if cmd == "albums":
+ ctx = await _cmd_albums(bot, providers_map, locale)
+ return _render_cmd_template(cmd_templates, "albums", locale, ctx)
+ if cmd == "events":
+ ctx = await _cmd_events(bot, providers_map, count, locale)
+ return _render_cmd_template(cmd_templates, "events", locale, ctx)
+ if cmd == "people":
+ ctx = await _cmd_people(providers_map, locale)
+ return _render_cmd_template(cmd_templates, "people", locale, ctx)
+ if cmd in ("search", "find", "person", "place", "latest",
+ "random", "favorites", "summary", "memory"):
+ return await _cmd_immich(
+ bot, cmd, args, count, locale, response_mode,
+ providers_map, cmd_templates,
+ )
+ return None
+
+
+# --- Immich command implementations (moved from handler.py) ---
+
+
+async def _cmd_status(
+ bot: TelegramBot, providers_map: dict[int, ServiceProvider], locale: str,
+) -> dict[str, Any]:
+ from ..database.models import EventLog
+ provider_ids = set(providers_map.keys())
+ trackers = await _get_notification_trackers_for_providers(provider_ids)
+ active = sum(1 for t in trackers if t.enabled)
+ total = len(trackers)
+ total_albums = sum(len(t.collection_ids or []) for t in trackers)
+
+ engine = get_engine()
+ async with AsyncSession(engine) as session:
+ result = await session.exec(
+ select(EventLog).order_by(EventLog.created_at.desc()).limit(1)
+ )
+ last_event = result.first()
+ last_str = last_event.created_at.strftime("%Y-%m-%d %H:%M") if last_event else "-"
+
+ return {
+ "trackers_active": active, "trackers_total": total,
+ "total_albums": total_albums, "last_event": last_str,
+ }
+
+
+async def _cmd_albums(
+ bot: TelegramBot, providers_map: dict[int, ServiceProvider], locale: str,
+) -> dict[str, Any]:
+ provider_ids = set(providers_map.keys())
+ trackers = await _get_notification_trackers_for_providers(provider_ids)
+ if not trackers:
+ return {"albums": []}
+
+ albums_data: list[dict] = []
+ async with aiohttp.ClientSession() as http:
+ for tracker in trackers:
+ provider = providers_map.get(tracker.provider_id)
+ if not provider or provider.type != "immich":
+ continue
+ immich = make_immich_provider(http, provider)
+ for album_id in (tracker.collection_ids or []):
+ try:
+ album = await immich.client.get_album(album_id)
+ if album:
+ albums_data.append({
+ "name": album.name, "asset_count": album.asset_count, "id": album_id,
+ })
+ except Exception:
+ albums_data.append({
+ "name": f"{album_id[:8]}...", "asset_count": "?", "id": album_id,
+ })
+
+ return {"albums": albums_data}
+
+
+async def _cmd_events(
+ bot: TelegramBot, providers_map: dict[int, ServiceProvider],
+ count: int, locale: str,
+) -> dict[str, Any]:
+ from ..database.models import EventLog
+ provider_ids = set(providers_map.keys())
+ trackers = await _get_notification_trackers_for_providers(provider_ids)
+ tracker_ids = [t.id for t in trackers]
+ if not tracker_ids:
+ return {"events": []}
+
+ engine = get_engine()
+ async with AsyncSession(engine) as session:
+ result = await session.exec(
+ select(EventLog)
+ .where(EventLog.tracker_id.in_(tracker_ids))
+ .order_by(EventLog.created_at.desc())
+ .limit(count)
+ )
+ events = result.all()
+
+ events_data = [
+ {"type": e.event_type, "album": e.collection_name,
+ "count": e.assets_count, "date": e.created_at.strftime("%m/%d %H:%M")}
+ for e in events
+ ]
+ return {"events": events_data}
+
+
+async def _cmd_people(
+ providers_map: dict[int, ServiceProvider], locale: str,
+) -> dict[str, Any]:
+ all_people: dict[str, str] = {}
+ async with aiohttp.ClientSession() as http:
+ for provider in providers_map.values():
+ if provider.type != "immich":
+ continue
+ immich = make_immich_provider(http, provider)
+ people = await immich.client.get_people()
+ all_people.update(people)
+ names = sorted(all_people.values())
+ return {"people": names}
+
+
+async def _check_native_memory(bot: TelegramBot) -> bool:
+ """Check if any tracker-target linked to this bot uses native memory source."""
+ engine = get_engine()
+ async with AsyncSession(engine) as session:
+ result = await session.exec(
+ select(NotificationTarget).where(
+ NotificationTarget.type == "telegram",
+ NotificationTarget.user_id == bot.user_id,
+ )
+ )
+ targets = result.all()
+ bot_target_ids = {t.id for t in targets if t.config.get("bot_token") == bot.token}
+ if not bot_target_ids:
+ return False
+ tt_result = await session.exec(
+ select(NotificationTrackerTarget).where(
+ NotificationTrackerTarget.target_id.in_(bot_target_ids)
+ )
+ )
+ for tt in tt_result.all():
+ if tt.tracking_config_id:
+ tc = await session.get(TrackingConfig, tt.tracking_config_id)
+ if tc and tc.memory_source == "native":
+ return True
+ return False
+
+
+async def _cmd_immich(
+ bot: TelegramBot, cmd: str, args: str, count: int, locale: str,
+ response_mode: str, providers_map: dict[int, ServiceProvider],
+ cmd_templates: dict[str, dict[str, str]],
+) -> str | list[dict[str, Any]]:
+ """Handle commands that need Immich API access and may return media."""
+ if not providers_map:
+ return _render_cmd_template(cmd_templates, "no_results", locale, {"command": cmd, "query": args})
+
+ provider_ids = set(providers_map.keys())
+ notification_trackers = await _get_notification_trackers_for_providers(provider_ids)
+
+ all_album_ids: list[str] = []
+ for t in notification_trackers:
+ all_album_ids.extend(t.collection_ids or [])
+
+ provider: ServiceProvider | None = None
+ for p in providers_map.values():
+ if p.type == "immich":
+ provider = p
+ break
+ if not provider:
+ return _render_cmd_template(cmd_templates, "no_results", locale, {"command": cmd, "query": args})
+
+ async with aiohttp.ClientSession() as http:
+ immich = make_immich_provider(http, provider)
+ client = immich.client
+
+ if cmd == "search":
+ if not args:
+ return _render_cmd_template(cmd_templates, "no_results", locale, {"command": cmd, "query": ""})
+ assets = await client.search_smart(args, album_ids=all_album_ids, limit=count)
+ return _format_assets(assets, cmd, args, locale, response_mode, client, cmd_templates)
+
+ if cmd == "find":
+ if not args:
+ return _render_cmd_template(cmd_templates, "no_results", locale, {"command": cmd, "query": ""})
+ assets = await client.search_metadata(args, album_ids=all_album_ids, limit=count)
+ return _format_assets(assets, cmd, args, locale, response_mode, client, cmd_templates)
+
+ if cmd == "person":
+ if not args:
+ return _render_cmd_template(cmd_templates, "no_results", locale, {"command": "person", "query": ""})
+ people = await client.get_people()
+ person_id = None
+ for pid, pname in people.items():
+ if args.lower() in pname.lower():
+ person_id = pid
+ break
+ if not person_id:
+ return _render_cmd_template(cmd_templates, "no_results", locale, {"command": "person", "query": args})
+ assets = await client.search_by_person(person_id, limit=count)
+ return _format_assets(assets, cmd, args, locale, response_mode, client, cmd_templates)
+
+ if cmd == "place":
+ if not args:
+ return _render_cmd_template(cmd_templates, "no_results", locale, {"command": "place", "query": ""})
+ assets = await client.search_smart(
+ f"photos taken in {args}", album_ids=all_album_ids, limit=count
+ )
+ return _format_assets(assets, cmd, args, locale, response_mode, client, cmd_templates)
+
+ if cmd == "favorites":
+ fav_assets: list[dict[str, Any]] = []
+ for album_id in all_album_ids[:10]:
+ try:
+ album = await client.get_album(album_id)
+ if album:
+ for aid, asset in list(album.assets.items())[:50]:
+ if asset.is_favorite and len(fav_assets) < count:
+ fav_assets.append({
+ "id": asset.id, "originalFileName": asset.filename,
+ "type": asset.type,
+ })
+ except Exception:
+ pass
+ if len(fav_assets) >= count:
+ break
+ return _format_assets(fav_assets, cmd, "", locale, response_mode, client, cmd_templates)
+
+ if cmd == "latest":
+ latest_assets: list[dict[str, Any]] = []
+ for album_id in all_album_ids[:10]:
+ try:
+ album = await client.get_album(album_id)
+ if album:
+ for aid, asset in list(album.assets.items())[:count]:
+ latest_assets.append({
+ "id": asset.id, "originalFileName": asset.filename,
+ "type": asset.type, "createdAt": asset.created_at,
+ })
+ except Exception:
+ pass
+ latest_assets.sort(key=lambda a: a.get("createdAt", ""), reverse=True)
+ return _format_assets(latest_assets[:count], cmd, "", locale, response_mode, client, cmd_templates)
+
+ if cmd == "random":
+ random_assets: list[dict[str, Any]] = []
+ for album_id in all_album_ids[:10]:
+ try:
+ album = await client.get_album(album_id)
+ if album:
+ asset_list = list(album.assets.values())
+ sampled = rng.sample(asset_list, min(count, len(asset_list)))
+ for asset in sampled:
+ random_assets.append({
+ "id": asset.id, "originalFileName": asset.filename,
+ "type": asset.type,
+ })
+ except Exception:
+ pass
+ rng.shuffle(random_assets)
+ return _format_assets(random_assets[:count], cmd, "", locale, response_mode, client, cmd_templates)
+
+ if cmd == "summary":
+ albums_data: list[dict] = []
+ for album_id in all_album_ids:
+ try:
+ album = await client.get_album(album_id)
+ if album:
+ albums_data.append({
+ "name": album.name, "asset_count": album.asset_count, "id": album_id,
+ })
+ except Exception:
+ pass
+ return _render_cmd_template(cmd_templates, "summary", locale, {"albums": albums_data})
+
+ if cmd == "memory":
+ use_native = await _check_native_memory(bot)
+ today = datetime.now(timezone.utc)
+ memory_assets: list[dict[str, Any]] = []
+
+ if use_native:
+ memories = await client.get_memories()
+ tracked_ids = set(all_album_ids) if all_album_ids else None
+ for mem in memories:
+ year = mem.get("data", {}).get("year")
+ for raw_asset in mem.get("assets", []):
+ if tracked_ids:
+ asset_albums = raw_asset.get("albums", [])
+ if not any(a.get("id") in tracked_ids for a in asset_albums):
+ continue
+ memory_assets.append({
+ "id": raw_asset.get("id", ""),
+ "originalFileName": raw_asset.get("originalFileName", ""),
+ "type": raw_asset.get("type", "IMAGE"),
+ "createdAt": raw_asset.get("fileCreatedAt", raw_asset.get("createdAt", "")),
+ "year": year,
+ })
+ else:
+ month_day = (today.month, today.day)
+ for album_id in all_album_ids[:10]:
+ try:
+ album = await client.get_album(album_id)
+ if album:
+ for aid, asset in album.assets.items():
+ try:
+ dt = datetime.fromisoformat(asset.created_at.replace("Z", "+00:00"))
+ if (dt.month, dt.day) == month_day and dt.year != today.year:
+ memory_assets.append({
+ "id": asset.id, "originalFileName": asset.filename,
+ "type": asset.type, "createdAt": asset.created_at,
+ "year": dt.year,
+ })
+ except (ValueError, AttributeError):
+ pass
+ except Exception:
+ pass
+
+ memory_assets = memory_assets[:count]
+ if not memory_assets:
+ return _render_cmd_template(cmd_templates, "no_results", locale, {"command": "memory", "query": ""})
+ return _format_assets(memory_assets, cmd, "", locale, response_mode, client, cmd_templates)
+
+ return None
+
+
+def _format_assets(
+ assets: list[dict[str, Any]], cmd: str, query: str,
+ locale: str, response_mode: str, client: Any,
+ cmd_templates: dict[str, dict[str, str]],
+) -> str | list[dict[str, Any]]:
+ """Format asset results as text or media payload."""
+ if not assets:
+ return _render_cmd_template(cmd_templates, "no_results", locale, {"command": cmd, "query": query})
+
+ if response_mode == "media":
+ media_items = []
+ for asset in assets:
+ asset_id = asset.get("id", "")
+ filename = asset.get("originalFileName", "")
+ year = asset.get("year", "")
+ caption = f"{filename} ({year})" if year else filename
+ media_items.append({
+ "type": "photo",
+ "asset_id": asset_id,
+ "caption": caption,
+ "thumbnail_url": f"{client.url}/api/assets/{asset_id}/thumbnail?size=preview",
+ "api_key": client.api_key,
+ })
+ return media_items
+
+ slot_map = {"find": "search", "person": "search", "place": "search"}
+ slot_name = slot_map.get(cmd, cmd)
+ return _render_cmd_template(cmd_templates, slot_name, locale, {
+ "assets": assets, "query": query, "command": cmd, "count": len(assets),
+ })
diff --git a/packages/server/src/notify_bridge_server/commands/registry.py b/packages/server/src/notify_bridge_server/commands/registry.py
index 79af838..e1e6427 100644
--- a/packages/server/src/notify_bridge_server/commands/registry.py
+++ b/packages/server/src/notify_bridge_server/commands/registry.py
@@ -4,8 +4,11 @@ from __future__ import annotations
# Map commands to rate limit categories
_RATE_CATEGORY: dict[str, str] = {
+ # Immich
"search": "search", "find": "search", "person": "search",
"place": "search", "favorites": "search", "people": "search",
+ # Gitea (API calls share a category)
+ "repos": "api", "issues": "api", "prs": "api", "commits": "api",
}
@@ -17,5 +20,5 @@ DEFAULT_COMMANDS_CONFIG = {
"enabled": ["help", "status", "albums", "events", "latest", "random", "favorites", "summary", "memory"],
"response_mode": "media",
"default_count": 5,
- "rate_limits": {"search": 30, "default": 10},
+ "rate_limits": {"search": 30, "api": 15, "default": 10},
}
diff --git a/packages/server/src/notify_bridge_server/main.py b/packages/server/src/notify_bridge_server/main.py
index bec6802..3394d82 100644
--- a/packages/server/src/notify_bridge_server/main.py
+++ b/packages/server/src/notify_bridge_server/main.py
@@ -53,6 +53,7 @@ async def lifespan(app: FastAPI):
await _seed_default_templates()
await _seed_default_command_templates()
await _seed_default_tracking_configs()
+ await _seed_default_command_configs()
# 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
@@ -393,7 +394,7 @@ async def _seed_default_command_templates():
# Upsert slots for each locale
for locale in ("en", "ru"):
- slots = load_default_command_templates(locale)
+ slots = load_default_command_templates(locale, provider_type="immich")
if not slots:
continue
for slot_name, template_text in slots.items():
@@ -416,6 +417,51 @@ async def _seed_default_command_templates():
template=template_text,
))
+ # --- Seed Gitea default command templates ---
+ gitea_cmd_result = await session.exec(
+ select(CommandTemplateConfig).where(
+ CommandTemplateConfig.user_id == 0,
+ CommandTemplateConfig.provider_type == "gitea",
+ )
+ )
+ gitea_cmd_configs = gitea_cmd_result.all()
+
+ if not gitea_cmd_configs:
+ gitea_cmd_config = CommandTemplateConfig(
+ user_id=0,
+ provider_type="gitea",
+ name="Default Gitea Commands",
+ description="Default Gitea command templates",
+ )
+ session.add(gitea_cmd_config)
+ await session.flush()
+ else:
+ gitea_cmd_config = gitea_cmd_configs[0]
+
+ for locale in ("en", "ru"):
+ gitea_cmd_slots = load_default_command_templates(locale, provider_type="gitea")
+ if not gitea_cmd_slots:
+ continue
+ for slot_name, template_text in gitea_cmd_slots.items():
+ slot_result = await session.exec(
+ select(CommandTemplateSlot).where(
+ CommandTemplateSlot.config_id == gitea_cmd_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=gitea_cmd_config.id,
+ slot_name=slot_name,
+ locale=locale,
+ template=template_text,
+ ))
+
await session.commit()
@@ -464,6 +510,85 @@ async def _seed_default_tracking_configs():
await session.commit()
+async def _seed_default_command_configs():
+ """Seed system-owned default command configs for each provider type."""
+ from sqlmodel import select
+ from sqlmodel.ext.asyncio.session import AsyncSession
+ from .database.engine import get_engine
+ from .database.models import CommandConfig, CommandTemplateConfig
+
+ engine = get_engine()
+ async with AsyncSession(engine) as session:
+ # Find existing system-owned command configs
+ result = await session.exec(
+ select(CommandConfig).where(CommandConfig.user_id == 0)
+ )
+ existing = {c.provider_type: c for c in result.all()}
+
+ # Find system command template configs to link
+ tmpl_result = await session.exec(
+ select(CommandTemplateConfig).where(CommandTemplateConfig.user_id == 0)
+ )
+ tmpl_by_type = {t.provider_type: t.id for t in tmpl_result.all()}
+
+ defaults = [
+ {
+ "provider_type": "immich",
+ "name": "Default Immich",
+ "enabled_commands": [
+ "help", "status", "albums", "events", "latest",
+ "random", "favorites", "summary", "memory",
+ ],
+ "response_mode": "media",
+ "default_count": 5,
+ "rate_limits": {"search": 30, "default": 10},
+ },
+ {
+ "provider_type": "gitea",
+ "name": "Default Gitea",
+ "enabled_commands": [
+ "help", "status", "repos", "issues", "prs", "commits",
+ ],
+ "response_mode": "text",
+ "default_count": 10,
+ "rate_limits": {"api": 15, "default": 10},
+ },
+ ]
+
+ for cfg in defaults:
+ ptype = cfg["provider_type"]
+ if ptype in existing:
+ continue
+ cmd_tmpl_id = tmpl_by_type.get(ptype)
+ # Use raw SQL to handle legacy NOT NULL columns
+ import json as _json2
+ from sqlalchemy import text as _text2
+ from datetime import datetime as _dt3, timezone as _tz3
+ await session.execute(
+ _text2(
+ "INSERT INTO command_config "
+ "(user_id, provider_type, name, icon, enabled_commands, locale, "
+ "response_mode, default_count, rate_limits, command_template_config_id, created_at) "
+ "VALUES (:uid, :pt, :name, :icon, :cmds, :locale, :rm, :dc, :rl, :ctid, :ca)"
+ ),
+ {
+ "uid": 0,
+ "pt": ptype,
+ "name": cfg["name"],
+ "icon": "",
+ "cmds": _json2.dumps(cfg["enabled_commands"]),
+ "locale": "en",
+ "rm": cfg["response_mode"],
+ "dc": cfg["default_count"],
+ "rl": _json2.dumps(cfg["rate_limits"]),
+ "ctid": cmd_tmpl_id,
+ "ca": _dt3.now(_tz3.utc).isoformat(),
+ },
+ )
+
+ await session.commit()
+
+
def run():
import uvicorn
uvicorn.run(app, host="0.0.0.0", port=8420)