feat: add Gitea as webhook-based service provider

First webhook-based provider integration (Immich uses polling).
Gitea pushes events via POST /api/webhooks/gitea/{provider_id} with
HMAC-SHA256 signature validation.

- 9 event types: push, issue opened/closed/commented, PR opened/closed/merged/commented, release published
- Generic filters system on NotificationTracker (collections, senders, exclude_senders)
- Provider capabilities include supported_filters and webhook_based flag
- Gitea API client for connection testing and repository listing
- 18 default Jinja2 notification templates (EN + RU)
- Frontend: conditional provider forms, Gitea event toggles in tracking config
- Auto-migration for filters column and Gitea tracking flags
This commit is contained in:
2026-03-22 12:58:35 +03:00
parent 1167d138a3
commit 6d28cfb8d8
39 changed files with 1588 additions and 25 deletions
+1
View File
@@ -99,4 +99,5 @@ export const previewTargetTypeItems = (): GridItem[] => [
export const providerTypeItems = (): GridItem[] => [
{ value: 'immich', icon: 'mdiCamera', label: t('providers.typeImmich') },
{ value: 'gitea', icon: 'mdiGit', label: t('providers.typeGitea') },
];
+17
View File
@@ -106,11 +106,18 @@
"offline": "Offline",
"checking": "Checking...",
"typeImmich": "Immich",
"typeGitea": "Gitea",
"loadError": "Failed to load providers.",
"externalDomain": "External Domain",
"optional": "optional",
"urlApiKeyRequired": "URL and API Key are required",
"externalDomainHint": "Public-facing URL for notification links. Falls back to server URL.",
"webhookSecret": "Webhook Secret",
"webhookSecretKeep": "Webhook Secret (leave empty to keep current)",
"webhookSecretHint": "Shared secret for HMAC-SHA256 signature verification. Set the same secret in Gitea webhook settings.",
"webhookSecretRequired": "Webhook secret is required",
"apiToken": "API Token",
"apiTokenHint": "Optional. Needed for connection testing and repository listing.",
"testAndSave": "Test & Save",
"saveWithoutTest": "Save without testing"
},
@@ -248,6 +255,7 @@
"matrixRoomId": "Room ID",
"receivers": "Receivers",
"noReceivers": "No receivers yet",
"alreadyAdded": "already added",
"addReceiver": "Add Receiver",
"receiverAdded": "Receiver added",
"receiverDeleted": "Receiver deleted",
@@ -348,6 +356,15 @@
"albumRenamed": "Album renamed",
"albumDeleted": "Album deleted",
"sharingChanged": "Sharing changed",
"push": "Push",
"issueOpened": "Issue opened",
"issueClosed": "Issue closed",
"issueCommented": "Issue commented",
"prOpened": "PR opened",
"prClosed": "PR closed",
"prMerged": "PR merged",
"prCommented": "PR commented",
"releasePublished": "Release published",
"trackImages": "Track images",
"trackVideos": "Track videos",
"favoritesOnly": "Favorites only",
+17
View File
@@ -106,11 +106,18 @@
"offline": "Не в сети",
"checking": "Проверка...",
"typeImmich": "Immich",
"typeGitea": "Gitea",
"loadError": "Не удалось загрузить провайдеры.",
"externalDomain": "Внешний домен",
"optional": "необязательно",
"urlApiKeyRequired": "URL и API ключ обязательны",
"externalDomainHint": "Публичный URL для ссылок в уведомлениях. По умолчанию используется URL сервера.",
"webhookSecret": "Секрет вебхука",
"webhookSecretKeep": "Секрет вебхука (оставьте пустым для сохранения текущего)",
"webhookSecretHint": "Общий секрет для проверки HMAC-SHA256 подписи. Укажите тот же секрет в настройках вебхука Gitea.",
"webhookSecretRequired": "Секрет вебхука обязателен",
"apiToken": "API токен",
"apiTokenHint": "Необязательно. Нужен для проверки подключения и получения списка репозиториев.",
"testAndSave": "Проверить и сохранить",
"saveWithoutTest": "Сохранить без проверки"
},
@@ -248,6 +255,7 @@
"matrixRoomId": "ID комнаты",
"receivers": "Получатели",
"noReceivers": "Нет получателей",
"alreadyAdded": "уже добавлен",
"addReceiver": "Добавить получателя",
"receiverAdded": "Получатель добавлен",
"receiverDeleted": "Получатель удалён",
@@ -348,6 +356,15 @@
"albumRenamed": "Альбом переименован",
"albumDeleted": "Альбом удалён",
"sharingChanged": "Изменение доступа",
"push": "Push",
"issueOpened": "Задача создана",
"issueClosed": "Задача закрыта",
"issueCommented": "Комментарий к задаче",
"prOpened": "PR создан",
"prClosed": "PR закрыт",
"prMerged": "PR влит",
"prCommented": "Комментарий к PR",
"releasePublished": "Релиз опубликован",
"trackImages": "Фото",
"trackVideos": "Видео",
"favoritesOnly": "Только избранные",
+33 -7
View File
@@ -21,7 +21,7 @@
let providers = $derived(providersCache.items);
let showForm = $state(false);
let editing = $state<number | null>(null);
let form = $state({ name: 'Immich', type: 'immich', url: '', api_key: '', external_domain: '', icon: '' });
let form = $state({ name: 'Immich', type: 'immich', url: '', api_key: '', api_token: '', webhook_secret: '', external_domain: '', icon: '' });
let error = $state('');
let loadError = $state('');
let submitting = $state(false);
@@ -48,12 +48,16 @@
}
function openNew() {
form = { name: 'Immich', type: 'immich', url: '', api_key: '', external_domain: '', icon: '' };
form = { name: 'Immich', type: 'immich', url: '', api_key: '', api_token: '', webhook_secret: '', external_domain: '', icon: '' };
editing = null; showForm = true;
}
function edit(p: any) {
const cfg = p.config || {};
form = { name: p.name, type: p.type, url: cfg.url || '', api_key: '', external_domain: cfg.external_domain || '', icon: p.icon || '' };
form = {
name: p.name, type: p.type, url: cfg.url || '',
api_key: '', api_token: '', webhook_secret: '',
external_domain: cfg.external_domain || '', icon: p.icon || '',
};
editing = p.id; showForm = true;
}
@@ -61,12 +65,21 @@
e.preventDefault(); error = ''; submitting = true;
try {
const config: any = { url: form.url };
if (form.api_key) config.api_key = form.api_key;
if (form.external_domain) config.external_domain = form.external_domain;
if (form.type === 'immich') {
if (form.api_key) config.api_key = form.api_key;
if (form.external_domain) config.external_domain = form.external_domain;
if (!editing) config.api_key = form.api_key;
} else if (form.type === 'gitea') {
if (form.api_token) config.api_token = form.api_token;
if (form.webhook_secret) config.webhook_secret = form.webhook_secret;
if (!editing && !form.webhook_secret) {
error = t('providers.webhookSecretRequired');
snackError(error); submitting = false; return;
}
}
if (editing) {
await api(`/providers/${editing}`, { method: 'PUT', body: JSON.stringify({ name: form.name, icon: form.icon, config }) });
} else {
config.api_key = form.api_key; // required on create
await api('/providers', { method: 'POST', body: JSON.stringify({ type: form.type, name: form.name, icon: form.icon, config }) });
}
showForm = false; editing = null; providersCache.invalidate(); await load();
@@ -129,8 +142,9 @@
</div>
<div>
<label for="prv-url" class="block text-sm font-medium mb-1">{t('providers.url')}</label>
<input id="prv-url" bind:value={form.url} required placeholder={t('providers.urlPlaceholder')} class="w-full px-3 py-2 border border-[var(--color-border)] rounded-md text-sm bg-[var(--color-background)]" />
<input id="prv-url" bind:value={form.url} required placeholder={form.type === 'gitea' ? 'https://gitea.example.com' : t('providers.urlPlaceholder')} class="w-full px-3 py-2 border border-[var(--color-border)] rounded-md text-sm bg-[var(--color-background)]" />
</div>
{#if form.type === 'immich'}
<div>
<label for="prv-key" class="block text-sm font-medium mb-1">{editing ? t('providers.apiKeyKeep') : t('providers.apiKey')}</label>
<input id="prv-key" bind:value={form.api_key} type="password" required={!editing} class="w-full px-3 py-2 border border-[var(--color-border)] rounded-md text-sm bg-[var(--color-background)]" />
@@ -139,6 +153,18 @@
<label for="prv-ext" class="block text-sm font-medium mb-1">{t('providers.externalDomain')} <span class="text-xs text-[var(--color-muted-foreground)]">({t('providers.optional')})</span></label>
<input id="prv-ext" bind:value={form.external_domain} placeholder="https://photos.example.com" class="w-full px-3 py-2 border border-[var(--color-border)] rounded-md text-sm bg-[var(--color-background)]" />
</div>
{:else if form.type === 'gitea'}
<div>
<label for="prv-secret" class="block text-sm font-medium mb-1">{editing ? t('providers.webhookSecretKeep') : t('providers.webhookSecret')}</label>
<input id="prv-secret" bind:value={form.webhook_secret} type="password" required={!editing} class="w-full px-3 py-2 border border-[var(--color-border)] rounded-md text-sm bg-[var(--color-background)]" />
<p class="text-xs text-[var(--color-muted-foreground)] mt-1">{t('providers.webhookSecretHint')}</p>
</div>
<div>
<label for="prv-token" class="block text-sm font-medium mb-1">{t('providers.apiToken')} <span class="text-xs text-[var(--color-muted-foreground)]">({t('providers.optional')})</span></label>
<input id="prv-token" bind:value={form.api_token} type="password" class="w-full px-3 py-2 border border-[var(--color-border)] rounded-md text-sm bg-[var(--color-background)]" />
<p class="text-xs text-[var(--color-muted-foreground)] mt-1">{t('providers.apiTokenHint')}</p>
</div>
{/if}
<button type="submit" disabled={submitting}
class="px-4 py-2 bg-[var(--color-primary)] text-[var(--color-primary-foreground)] rounded-md text-sm font-medium hover:opacity-90 disabled:opacity-50">
{submitting ? t('providers.connecting') : (editing ? t('common.save') : t('providers.addProvider'))}
@@ -28,6 +28,7 @@
const defaultForm = () => ({
provider_type: 'immich', name: '', icon: '',
// Immich event tracking
track_assets_added: true, track_assets_removed: false,
track_collection_renamed: true, track_collection_deleted: true, track_sharing_changed: false,
track_images: true, track_videos: true, notify_favorites_only: false,
@@ -39,6 +40,10 @@
scheduled_min_rating: 0, scheduled_order_by: 'random', scheduled_order: 'descending',
memory_enabled: false, memory_source: 'albums', memory_times: '09:00', memory_collection_mode: 'combined',
memory_limit: 10, memory_favorite_only: false, memory_asset_type: 'all', memory_min_rating: 0,
// Gitea event tracking
track_push: true, track_issue_opened: true, track_issue_closed: true, track_issue_commented: false,
track_pr_opened: true, track_pr_closed: true, track_pr_merged: true, track_pr_commented: false,
track_release_published: true,
});
let form = $state(defaultForm());
@@ -112,6 +117,19 @@
<!-- Event tracking -->
<fieldset class="border border-[var(--color-border)] rounded-md p-3">
<legend class="text-sm font-medium px-1">{t('trackingConfig.eventTracking')}</legend>
{#if form.provider_type === 'gitea'}
<div class="grid grid-cols-2 gap-2 mt-2">
<label class="flex items-center gap-2 text-sm"><input type="checkbox" bind:checked={form.track_push} /> {t('trackingConfig.push')}</label>
<label class="flex items-center gap-2 text-sm"><input type="checkbox" bind:checked={form.track_issue_opened} /> {t('trackingConfig.issueOpened')}</label>
<label class="flex items-center gap-2 text-sm"><input type="checkbox" bind:checked={form.track_issue_closed} /> {t('trackingConfig.issueClosed')}</label>
<label class="flex items-center gap-2 text-sm"><input type="checkbox" bind:checked={form.track_issue_commented} /> {t('trackingConfig.issueCommented')}</label>
<label class="flex items-center gap-2 text-sm"><input type="checkbox" bind:checked={form.track_pr_opened} /> {t('trackingConfig.prOpened')}</label>
<label class="flex items-center gap-2 text-sm"><input type="checkbox" bind:checked={form.track_pr_closed} /> {t('trackingConfig.prClosed')}</label>
<label class="flex items-center gap-2 text-sm"><input type="checkbox" bind:checked={form.track_pr_merged} /> {t('trackingConfig.prMerged')}</label>
<label class="flex items-center gap-2 text-sm"><input type="checkbox" bind:checked={form.track_pr_commented} /> {t('trackingConfig.prCommented')}</label>
<label class="flex items-center gap-2 text-sm"><input type="checkbox" bind:checked={form.track_release_published} /> {t('trackingConfig.releasePublished')}</label>
</div>
{:else}
<div class="grid grid-cols-2 gap-2 mt-2">
<label class="flex items-center gap-2 text-sm"><input type="checkbox" bind:checked={form.track_assets_added} /> {t('trackingConfig.assetsAdded')}</label>
<label class="flex items-center gap-2 text-sm"><input type="checkbox" bind:checked={form.track_assets_removed} /> {t('trackingConfig.assetsRemoved')}</label>
@@ -124,6 +142,8 @@
<label class="flex items-center gap-2 text-sm"><input type="checkbox" bind:checked={form.include_tags} /> {t('trackingConfig.includePeople')}</label>
<label class="flex items-center gap-2 text-sm"><input type="checkbox" bind:checked={form.include_asset_details} /> {t('trackingConfig.includeDetails')}</label>
</div>
{/if}
{#if form.provider_type !== 'gitea'}
<div class="grid grid-cols-3 gap-3 mt-3">
<div>
<label for="tc-max" class="block text-xs mb-1">{t('trackingConfig.maxAssets')}<Hint text={t('hints.maxAssets')} /></label>
@@ -138,8 +158,10 @@
<IconGridSelect items={sortOrderItems()} bind:value={form.assets_order} columns={2} />
</div>
</div>
{/if}
</fieldset>
{#if form.provider_type !== 'gitea'}
<!-- Periodic summary -->
<fieldset class="border border-[var(--color-border)] rounded-md p-3">
<legend class="text-sm font-medium px-1">{t('trackingConfig.periodicSummary')}<Hint text={t('hints.periodicSummary')} /></legend>
@@ -190,6 +212,7 @@
</div>
{/if}
</fieldset>
{/if}
<button type="submit" class="px-4 py-2 bg-[var(--color-primary)] text-[var(--color-primary-foreground)] rounded-md text-sm font-medium hover:opacity-90">
{editing ? t('common.save') : t('common.create')}
@@ -14,12 +14,24 @@ from notify_bridge_core.providers.base import ServiceProviderType
class EventType(str, Enum):
"""Types of events a service provider can emit."""
# Immich events
ASSETS_ADDED = "assets_added"
ASSETS_REMOVED = "assets_removed"
COLLECTION_RENAMED = "collection_renamed"
COLLECTION_DELETED = "collection_deleted"
SHARING_CHANGED = "sharing_changed"
# Gitea events
PUSH = "push"
ISSUE_OPENED = "issue_opened"
ISSUE_CLOSED = "issue_closed"
ISSUE_COMMENTED = "issue_commented"
PR_OPENED = "pr_opened"
PR_CLOSED = "pr_closed"
PR_MERGED = "pr_merged"
PR_COMMENTED = "pr_commented"
RELEASE_PUBLISHED = "release_published"
@dataclass
class ServiceEvent:
@@ -15,6 +15,7 @@ class ServiceProviderType(str, Enum):
"""Supported service provider types."""
IMMICH = "immich"
GITEA = "gitea"
class ServiceProvider(ABC):
@@ -29,6 +29,12 @@ class ProviderCapabilities:
# Commands the provider supports
commands: list[dict[str, str]] = field(default_factory=list)
# Filter definitions for tracker UI (rendered dynamically by frontend)
supported_filters: list[dict[str, str]] = field(default_factory=list)
# Whether this provider receives webhooks (vs polling)
webhook_based: bool = False
# ---------------------------------------------------------------------------
# Immich provider capabilities
@@ -37,6 +43,9 @@ class ProviderCapabilities:
IMMICH_CAPABILITIES = ProviderCapabilities(
provider_type="immich",
display_name="Immich",
supported_filters=[
{"key": "collections", "label": "Albums", "type": "select", "source": "api"},
],
notification_slots=[
{"name": "message_assets_added", "description": "New assets added to album"},
{"name": "message_assets_removed", "description": "Assets removed from album"},
@@ -102,12 +111,67 @@ IMMICH_CAPABILITIES = ProviderCapabilities(
],
)
# ---------------------------------------------------------------------------
# Gitea provider capabilities
# ---------------------------------------------------------------------------
GITEA_CAPABILITIES = ProviderCapabilities(
provider_type="gitea",
display_name="Gitea",
webhook_based=True,
supported_filters=[
{
"key": "collections",
"label": "Repositories",
"type": "tags",
"placeholder": "owner/repo",
},
{
"key": "senders",
"label": "Only from users",
"type": "tags",
"placeholder": "username",
},
{
"key": "exclude_senders",
"label": "Exclude users",
"type": "tags",
"placeholder": "bot-name",
},
],
notification_slots=[
{"name": "message_push", "description": "Code pushed to repository"},
{"name": "message_issue_opened", "description": "Issue opened"},
{"name": "message_issue_closed", "description": "Issue closed"},
{"name": "message_issue_commented", "description": "Comment on issue"},
{"name": "message_pr_opened", "description": "Pull request opened"},
{"name": "message_pr_closed", "description": "Pull request closed"},
{"name": "message_pr_merged", "description": "Pull request merged"},
{"name": "message_pr_commented", "description": "Comment on pull request"},
{"name": "message_release_published", "description": "Release published"},
],
command_slots=[],
events=[
{"name": "push", "description": "Code pushed to repository"},
{"name": "issue_opened", "description": "Issue opened"},
{"name": "issue_closed", "description": "Issue closed"},
{"name": "issue_commented", "description": "Comment on issue"},
{"name": "pr_opened", "description": "Pull request opened"},
{"name": "pr_closed", "description": "Pull request closed"},
{"name": "pr_merged", "description": "Pull request merged"},
{"name": "pr_commented", "description": "Comment on pull request"},
{"name": "release_published", "description": "Release published"},
],
commands=[],
)
# ---------------------------------------------------------------------------
# Registry
# ---------------------------------------------------------------------------
_REGISTRY: dict[str, ProviderCapabilities] = {
"immich": IMMICH_CAPABILITIES,
"gitea": GITEA_CAPABILITIES,
}
@@ -0,0 +1,19 @@
"""Gitea service provider implementation."""
from notify_bridge_core.providers.base import ServiceProviderType
from notify_bridge_core.templates.variables import registry
from .client import GiteaClient, GiteaApiError
from .event_parser import parse_webhook
from .provider import GiteaServiceProvider, GITEA_VARIABLES
# Register Gitea variables in the global registry
registry.register_provider_variables(ServiceProviderType.GITEA, GITEA_VARIABLES)
__all__ = [
"GiteaClient",
"GiteaApiError",
"GiteaServiceProvider",
"GITEA_VARIABLES",
"parse_webhook",
]
@@ -0,0 +1,89 @@
"""Async Gitea API client for connection testing and repository listing."""
from __future__ import annotations
import logging
from typing import Any
import aiohttp
_LOGGER = logging.getLogger(__name__)
class GiteaClient:
"""Async client for the Gitea REST API."""
def __init__(
self,
session: aiohttp.ClientSession,
url: str,
api_token: str,
) -> None:
self._session = session
self._url = url.rstrip("/")
self._api_token = api_token
@property
def url(self) -> str:
return self._url
@property
def _headers(self) -> dict[str, str]:
return {"Authorization": f"token {self._api_token}"}
async def ping(self) -> bool:
"""Check connectivity via GET /api/v1/version."""
try:
async with self._session.get(
f"{self._url}/api/v1/version",
headers=self._headers,
) as response:
return response.status == 200
except aiohttp.ClientError:
return False
async def get_server_version(self) -> str | None:
"""Return Gitea version string, or None on failure."""
try:
async with self._session.get(
f"{self._url}/api/v1/version",
headers=self._headers,
) as response:
if response.status == 200:
data = await response.json()
return data.get("version")
except aiohttp.ClientError as err:
_LOGGER.warning("Failed to fetch Gitea version: %s", err)
return None
async def get_repos(self, limit: int = 50) -> list[dict[str, Any]]:
"""List repositories accessible to the authenticated user."""
repos: list[dict[str, Any]] = []
page = 1
while True:
try:
async with self._session.get(
f"{self._url}/api/v1/repos/search",
headers=self._headers,
params={"page": str(page), "limit": str(limit), "sort": "updated"},
) as response:
if response.status != 200:
_LOGGER.warning("Failed to fetch repos: HTTP %s", response.status)
break
data = await response.json()
# Gitea wraps search results in {"data": [...], "ok": true}
items = data.get("data", data) if isinstance(data, dict) else data
if not items:
break
repos.extend(items)
if len(items) < limit:
break
page += 1
except aiohttp.ClientError as err:
_LOGGER.warning("Failed to fetch repos: %s", err)
break
return repos
class GiteaApiError(Exception):
"""Raised when a Gitea API call fails."""
@@ -0,0 +1,221 @@
"""Parse Gitea webhook payloads into ServiceEvent objects."""
from __future__ import annotations
import logging
from datetime import datetime, timezone
from typing import Any
from notify_bridge_core.models.events import EventType, ServiceEvent
from notify_bridge_core.providers.base import ServiceProviderType
from .models import (
GiteaComment,
GiteaCommit,
GiteaIssue,
GiteaPullRequest,
GiteaRelease,
GiteaRepository,
GiteaUser,
)
_LOGGER = logging.getLogger(__name__)
# Map Gitea X-Gitea-Event header values to our event types.
# For issues/PRs, the action field refines the mapping further.
_GITEA_EVENT_MAP: dict[str, EventType | None] = {
"push": EventType.PUSH,
"issues": None, # refined by action
"issue_comment": None, # refined by action + is_pull
"pull_request": None, # refined by action
"release": EventType.RELEASE_PUBLISHED,
}
_ISSUE_ACTION_MAP: dict[str, EventType] = {
"opened": EventType.ISSUE_OPENED,
"closed": EventType.ISSUE_CLOSED,
}
_PR_ACTION_MAP: dict[str, EventType] = {
"opened": EventType.PR_OPENED,
"closed": EventType.PR_CLOSED,
# "closed" + merged flag → PR_MERGED, handled specially
}
def parse_webhook(
event_header: str,
payload: dict[str, Any],
provider_name: str,
) -> ServiceEvent | None:
"""Parse a Gitea webhook payload into a ServiceEvent.
Args:
event_header: Value of the X-Gitea-Event header.
payload: Parsed JSON body of the webhook.
provider_name: Display name of the ServiceProvider instance.
Returns:
A ServiceEvent, or None if the event/action is not tracked.
"""
if event_header not in _GITEA_EVENT_MAP:
_LOGGER.debug("Ignoring untracked Gitea event header: %s", event_header)
return None
repo_data = payload.get("repository", {})
repo = GiteaRepository.from_payload(repo_data)
sender = GiteaUser.from_payload(payload.get("sender", {}))
action = payload.get("action", "")
event_type = _resolve_event_type(event_header, action, payload)
if event_type is None:
_LOGGER.debug(
"Ignoring Gitea event %s with action=%s (not mapped)", event_header, action
)
return None
extra = _build_extra(event_header, event_type, payload, repo, sender)
return ServiceEvent(
event_type=event_type,
provider_type=ServiceProviderType.GITEA,
provider_name=provider_name,
collection_id=repo.full_name,
collection_name=repo.full_name,
timestamp=datetime.now(timezone.utc),
extra=extra,
)
def _resolve_event_type(
event_header: str, action: str, payload: dict[str, Any]
) -> EventType | None:
"""Determine the EventType from header + action."""
direct = _GITEA_EVENT_MAP.get(event_header)
if direct is not None:
# Release: only "published" action
if event_header == "release" and action != "published":
return None
return direct
if event_header == "issues":
return _ISSUE_ACTION_MAP.get(action)
if event_header == "pull_request":
pr_data = payload.get("pull_request", {})
if action == "closed" and pr_data.get("merged", False):
return EventType.PR_MERGED
return _PR_ACTION_MAP.get(action)
if event_header == "issue_comment":
# Gitea sends issue_comment for both issue and PR comments.
is_pull = payload.get("is_pull", False)
if action == "created":
return EventType.PR_COMMENTED if is_pull else EventType.ISSUE_COMMENTED
return None
return None
def _build_extra(
event_header: str,
event_type: EventType,
payload: dict[str, Any],
repo: GiteaRepository,
sender: GiteaUser,
) -> dict[str, Any]:
"""Build the provider-specific extra dict for template rendering."""
extra: dict[str, Any] = {
"sender": sender.login,
"sender_name": sender.full_name or sender.login,
"sender_avatar": sender.avatar_url,
"repo_name": repo.name,
"repo_full_name": repo.full_name,
"repo_url": repo.html_url,
"repo_description": repo.description,
}
if event_type == EventType.PUSH:
_enrich_push(extra, payload)
elif event_type in (EventType.ISSUE_OPENED, EventType.ISSUE_CLOSED):
_enrich_issue(extra, payload)
elif event_type == EventType.ISSUE_COMMENTED:
_enrich_issue(extra, payload)
_enrich_comment(extra, payload)
elif event_type in (EventType.PR_OPENED, EventType.PR_CLOSED, EventType.PR_MERGED):
_enrich_pr(extra, payload)
elif event_type == EventType.PR_COMMENTED:
_enrich_pr(extra, payload)
_enrich_comment(extra, payload)
elif event_type == EventType.RELEASE_PUBLISHED:
_enrich_release(extra, payload)
return extra
def _enrich_push(extra: dict[str, Any], payload: dict[str, Any]) -> None:
ref = payload.get("ref", "")
extra["ref"] = ref
extra["branch"] = ref.removeprefix("refs/heads/")
extra["before"] = payload.get("before", "")
extra["after"] = payload.get("after", "")
extra["compare_url"] = payload.get("compare_url", "")
raw_commits = payload.get("commits", [])
commits = [GiteaCommit.from_payload(c) for c in raw_commits]
extra["commits"] = [
{
"id": c.id,
"short_id": c.id[:7],
"message": c.message,
"url": c.url,
"author": c.author_name,
}
for c in commits
]
extra["commit_count"] = len(commits)
def _enrich_issue(extra: dict[str, Any], payload: dict[str, Any]) -> None:
issue_data = payload.get("issue", {})
issue = GiteaIssue.from_payload(issue_data)
extra["issue_number"] = issue.number
extra["issue_title"] = issue.title
extra["issue_url"] = issue.html_url
extra["issue_state"] = issue.state
extra["issue_body"] = issue.body
extra["issue_labels"] = issue.labels
def _enrich_pr(extra: dict[str, Any], payload: dict[str, Any]) -> None:
pr_data = payload.get("pull_request", {})
pr = GiteaPullRequest.from_payload(pr_data)
extra["pr_number"] = pr.number
extra["pr_title"] = pr.title
extra["pr_url"] = pr.html_url
extra["pr_state"] = pr.state
extra["pr_body"] = pr.body
extra["pr_merged"] = pr.merged
extra["pr_base"] = pr.base_branch
extra["pr_head"] = pr.head_branch
extra["pr_labels"] = pr.labels
def _enrich_comment(extra: dict[str, Any], payload: dict[str, Any]) -> None:
comment_data = payload.get("comment", {})
comment = GiteaComment.from_payload(comment_data)
extra["comment_body"] = comment.body
extra["comment_url"] = comment.html_url
if comment.user:
extra["comment_author"] = comment.user.login
def _enrich_release(extra: dict[str, Any], payload: dict[str, Any]) -> None:
release_data = payload.get("release", {})
release = GiteaRelease.from_payload(release_data)
extra["release_tag"] = release.tag_name
extra["release_name"] = release.name
extra["release_url"] = release.html_url
extra["release_body"] = release.body
extra["release_draft"] = release.draft
extra["release_prerelease"] = release.prerelease
@@ -0,0 +1,186 @@
"""Gitea webhook payload data models."""
from __future__ import annotations
from dataclasses import dataclass, field
from typing import Any
@dataclass
class GiteaUser:
"""Gitea user from webhook payload."""
id: int
login: str
full_name: str = ""
email: str = ""
avatar_url: str = ""
@classmethod
def from_payload(cls, data: dict[str, Any]) -> GiteaUser:
return cls(
id=data.get("id", 0),
login=data.get("login", data.get("username", "")),
full_name=data.get("full_name", ""),
email=data.get("email", ""),
avatar_url=data.get("avatar_url", ""),
)
@dataclass
class GiteaRepository:
"""Gitea repository from webhook payload."""
id: int
name: str
full_name: str
html_url: str
description: str = ""
private: bool = False
owner: GiteaUser | None = None
@classmethod
def from_payload(cls, data: dict[str, Any]) -> GiteaRepository:
owner = None
if data.get("owner"):
owner = GiteaUser.from_payload(data["owner"])
return cls(
id=data.get("id", 0),
name=data.get("name", ""),
full_name=data.get("full_name", ""),
html_url=data.get("html_url", ""),
description=data.get("description", ""),
private=data.get("private", False),
owner=owner,
)
@dataclass
class GiteaCommit:
"""Gitea commit from push payload."""
id: str
message: str
url: str
author_name: str = ""
author_email: str = ""
timestamp: str = ""
@classmethod
def from_payload(cls, data: dict[str, Any]) -> GiteaCommit:
author = data.get("author", {})
return cls(
id=data.get("id", ""),
message=data.get("message", "").strip(),
url=data.get("url", ""),
author_name=author.get("name", ""),
author_email=author.get("email", ""),
timestamp=data.get("timestamp", ""),
)
@dataclass
class GiteaIssue:
"""Gitea issue from webhook payload."""
id: int
number: int
title: str
html_url: str
state: str = ""
body: str = ""
labels: list[str] = field(default_factory=list)
@classmethod
def from_payload(cls, data: dict[str, Any]) -> GiteaIssue:
labels = [lbl.get("name", "") for lbl in data.get("labels", []) if lbl.get("name")]
return cls(
id=data.get("id", 0),
number=data.get("number", 0),
title=data.get("title", ""),
html_url=data.get("html_url", ""),
state=data.get("state", ""),
body=data.get("body", ""),
labels=labels,
)
@dataclass
class GiteaPullRequest:
"""Gitea pull request from webhook payload."""
id: int
number: int
title: str
html_url: str
state: str = ""
body: str = ""
merged: bool = False
base_branch: str = ""
head_branch: str = ""
labels: list[str] = field(default_factory=list)
@classmethod
def from_payload(cls, data: dict[str, Any]) -> GiteaPullRequest:
labels = [lbl.get("name", "") for lbl in data.get("labels", []) if lbl.get("name")]
base = data.get("base", {})
head = data.get("head", {})
return cls(
id=data.get("id", 0),
number=data.get("number", 0),
title=data.get("title", ""),
html_url=data.get("html_url", ""),
state=data.get("state", ""),
body=data.get("body", ""),
merged=data.get("merged", False),
base_branch=base.get("label", base.get("ref", "")),
head_branch=head.get("label", head.get("ref", "")),
labels=labels,
)
@dataclass
class GiteaRelease:
"""Gitea release from webhook payload."""
id: int
tag_name: str
name: str
html_url: str
body: str = ""
draft: bool = False
prerelease: bool = False
@classmethod
def from_payload(cls, data: dict[str, Any]) -> GiteaRelease:
return cls(
id=data.get("id", 0),
tag_name=data.get("tag_name", ""),
name=data.get("name", ""),
html_url=data.get("html_url", ""),
body=data.get("body", ""),
draft=data.get("draft", False),
prerelease=data.get("prerelease", False),
)
@dataclass
class GiteaComment:
"""Gitea comment from webhook payload."""
id: int
body: str
html_url: str
user: GiteaUser | None = None
@classmethod
def from_payload(cls, data: dict[str, Any]) -> GiteaComment:
user = None
if data.get("user"):
user = GiteaUser.from_payload(data["user"])
return cls(
id=data.get("id", 0),
body=data.get("body", ""),
html_url=data.get("html_url", ""),
user=user,
)
@@ -0,0 +1,270 @@
"""Gitea service provider — webhook-based implementation of ServiceProvider."""
from __future__ import annotations
import logging
from typing import Any
import aiohttp
from notify_bridge_core.models.events import ServiceEvent
from notify_bridge_core.providers.base import ServiceProvider, ServiceProviderType
from notify_bridge_core.templates.variables import TemplateVariableDefinition
from .client import GiteaClient
_LOGGER = logging.getLogger(__name__)
# Gitea-specific template variables
GITEA_VARIABLES: list[TemplateVariableDefinition] = [
TemplateVariableDefinition(
name="sender",
type="string",
description="Username of the user who triggered the event",
example="alexei",
provider_type=ServiceProviderType.GITEA,
),
TemplateVariableDefinition(
name="sender_name",
type="string",
description="Display name of the sender",
example="Alexei",
provider_type=ServiceProviderType.GITEA,
),
TemplateVariableDefinition(
name="repo_name",
type="string",
description="Repository name (without owner)",
example="my-project",
provider_type=ServiceProviderType.GITEA,
),
TemplateVariableDefinition(
name="repo_full_name",
type="string",
description="Full repository name (owner/repo)",
example="alexei/my-project",
provider_type=ServiceProviderType.GITEA,
),
TemplateVariableDefinition(
name="repo_url",
type="string",
description="URL to the repository",
example="https://gitea.example.com/alexei/my-project",
provider_type=ServiceProviderType.GITEA,
),
TemplateVariableDefinition(
name="branch",
type="string",
description="Branch name (push events)",
example="main",
provider_type=ServiceProviderType.GITEA,
),
TemplateVariableDefinition(
name="commits",
type="list",
description="List of commits (push events); each has id, short_id, message, url, author",
example='[{"short_id": "abc1234", "message": "fix bug", ...}]',
provider_type=ServiceProviderType.GITEA,
),
TemplateVariableDefinition(
name="commit_count",
type="int",
description="Number of commits in push",
example="3",
provider_type=ServiceProviderType.GITEA,
),
TemplateVariableDefinition(
name="compare_url",
type="string",
description="URL to compare changes (push events)",
example="https://gitea.example.com/alexei/my-project/compare/abc...def",
provider_type=ServiceProviderType.GITEA,
),
TemplateVariableDefinition(
name="issue_number",
type="int",
description="Issue number",
example="42",
provider_type=ServiceProviderType.GITEA,
),
TemplateVariableDefinition(
name="issue_title",
type="string",
description="Issue title",
example="Bug in login page",
provider_type=ServiceProviderType.GITEA,
),
TemplateVariableDefinition(
name="issue_url",
type="string",
description="URL to the issue",
example="https://gitea.example.com/alexei/my-project/issues/42",
provider_type=ServiceProviderType.GITEA,
),
TemplateVariableDefinition(
name="pr_number",
type="int",
description="Pull request number",
example="17",
provider_type=ServiceProviderType.GITEA,
),
TemplateVariableDefinition(
name="pr_title",
type="string",
description="Pull request title",
example="Add dark mode support",
provider_type=ServiceProviderType.GITEA,
),
TemplateVariableDefinition(
name="pr_url",
type="string",
description="URL to the pull request",
example="https://gitea.example.com/alexei/my-project/pulls/17",
provider_type=ServiceProviderType.GITEA,
),
TemplateVariableDefinition(
name="pr_merged",
type="bool",
description="Whether the PR was merged (vs closed without merge)",
example="true",
provider_type=ServiceProviderType.GITEA,
),
TemplateVariableDefinition(
name="pr_base",
type="string",
description="Base branch of the PR",
example="main",
provider_type=ServiceProviderType.GITEA,
),
TemplateVariableDefinition(
name="pr_head",
type="string",
description="Head branch of the PR",
example="feature/dark-mode",
provider_type=ServiceProviderType.GITEA,
),
TemplateVariableDefinition(
name="release_tag",
type="string",
description="Release tag name",
example="v1.2.0",
provider_type=ServiceProviderType.GITEA,
),
TemplateVariableDefinition(
name="release_name",
type="string",
description="Release title",
example="Version 1.2.0",
provider_type=ServiceProviderType.GITEA,
),
TemplateVariableDefinition(
name="release_url",
type="string",
description="URL to the release page",
example="https://gitea.example.com/alexei/my-project/releases/tag/v1.2.0",
provider_type=ServiceProviderType.GITEA,
),
TemplateVariableDefinition(
name="comment_body",
type="string",
description="Comment text (comment events)",
example="Looks good to me!",
provider_type=ServiceProviderType.GITEA,
),
TemplateVariableDefinition(
name="comment_url",
type="string",
description="URL to the comment",
example="https://gitea.example.com/.../issues/42#issuecomment-123",
provider_type=ServiceProviderType.GITEA,
),
]
class GiteaServiceProvider(ServiceProvider):
"""Gitea webhook-based provider.
Unlike Immich (polling), Gitea pushes events to us via webhooks.
The poll() method is a no-op — events are parsed from incoming
webhook payloads by event_parser.parse_webhook().
"""
provider_type = ServiceProviderType.GITEA
def __init__(
self,
session: aiohttp.ClientSession,
url: str,
api_token: str,
name: str = "Gitea",
) -> None:
self._client = GiteaClient(session, url, api_token)
self._name = name
@property
def client(self) -> GiteaClient:
return self._client
async def connect(self) -> bool:
return await self._client.ping()
async def disconnect(self) -> None:
pass # session lifecycle managed by caller
async def poll(
self,
collection_ids: list[str],
tracker_state: dict[str, Any],
) -> tuple[list[ServiceEvent], dict[str, Any]]:
# Gitea is webhook-based — poll() is not used.
# Events arrive via the /api/webhooks/gitea route.
return [], tracker_state
def get_available_variables(self) -> list[TemplateVariableDefinition]:
return list(GITEA_VARIABLES)
def get_provider_config_schema(self) -> dict[str, Any]:
return {
"type": "object",
"properties": {
"url": {
"type": "string",
"description": "Gitea server URL",
"example": "https://gitea.example.com",
},
"api_token": {
"type": "string",
"description": "Gitea API token (for connection testing and repo listing)",
"secret": True,
},
"webhook_secret": {
"type": "string",
"description": "Shared secret for HMAC-SHA256 webhook signature verification",
"secret": True,
},
},
"required": ["url", "webhook_secret"],
}
async def list_collections(self) -> list[dict[str, Any]]:
repos = await self._client.get_repos()
return [
{
"id": r.get("full_name", ""),
"name": r.get("full_name", ""),
"description": r.get("description", ""),
"updated_at": r.get("updated", ""),
}
for r in repos
]
async def test_connection(self) -> dict[str, Any]:
ok = await self._client.ping()
if ok:
version = await self._client.get_server_version()
return {
"ok": True,
"message": f"Connected to Gitea{f' v{version}' if version else ''}",
}
return {"ok": False, "message": "Failed to connect to Gitea"}
@@ -0,0 +1,2 @@
✅ <b>{{ sender_name }}</b> closed issue <a href="{{ issue_url }}">#{{ issue_number }}</a> in <a href="{{ repo_url }}">{{ repo_full_name }}</a>
<b>{{ issue_title }}</b>
@@ -0,0 +1,3 @@
💬 <b>{{ comment_author | default(sender_name) }}</b> commented on issue <a href="{{ issue_url }}">#{{ issue_number }}</a> in <a href="{{ repo_url }}">{{ repo_full_name }}</a>
<b>{{ issue_title }}</b>
{{ comment_body | truncate(200) }}
@@ -0,0 +1,5 @@
🐛 <b>{{ sender_name }}</b> opened issue <a href="{{ issue_url }}">#{{ issue_number }}</a> in <a href="{{ repo_url }}">{{ repo_full_name }}</a>
<b>{{ issue_title }}</b>
{%- if issue_labels %}
🏷 {{ issue_labels | join(", ") }}
{%- endif %}
@@ -0,0 +1,2 @@
❌ <b>{{ sender_name }}</b> closed PR <a href="{{ pr_url }}">#{{ pr_number }}</a> in <a href="{{ repo_url }}">{{ repo_full_name }}</a>
<b>{{ pr_title }}</b>
@@ -0,0 +1,3 @@
💬 <b>{{ comment_author | default(sender_name) }}</b> commented on PR <a href="{{ pr_url }}">#{{ pr_number }}</a> in <a href="{{ repo_url }}">{{ repo_full_name }}</a>
<b>{{ pr_title }}</b>
{{ comment_body | truncate(200) }}
@@ -0,0 +1,3 @@
🎉 <b>{{ sender_name }}</b> merged PR <a href="{{ pr_url }}">#{{ pr_number }}</a> in <a href="{{ repo_url }}">{{ repo_full_name }}</a>
<b>{{ pr_title }}</b>
{{ pr_head }} → {{ pr_base }}
@@ -0,0 +1,6 @@
🔃 <b>{{ sender_name }}</b> opened PR <a href="{{ pr_url }}">#{{ pr_number }}</a> in <a href="{{ repo_url }}">{{ repo_full_name }}</a>
<b>{{ pr_title }}</b>
{{ pr_head }} → {{ pr_base }}
{%- if pr_labels %}
🏷 {{ pr_labels | join(", ") }}
{%- endif %}
@@ -0,0 +1,9 @@
🔀 <b>{{ sender_name }}</b> pushed {{ commit_count }} commit(s) to <a href="{{ repo_url }}">{{ repo_full_name }}</a>/<b>{{ branch }}</b>
{%- if commits %}
{%- for c in commits %}
• <code>{{ c.short_id }}</code> {{ c.message | truncate(72) }}
{%- endfor %}
{%- endif %}
{%- if compare_url %}
<a href="{{ compare_url }}">View changes</a>
{%- endif %}
@@ -0,0 +1,7 @@
🚀 <b>{{ sender_name }}</b> published release <a href="{{ release_url }}">{{ release_tag }}</a> in <a href="{{ repo_url }}">{{ repo_full_name }}</a>
{%- if release_name and release_name != release_tag %}
<b>{{ release_name }}</b>
{%- endif %}
{%- if release_body %}
{{ release_body | truncate(300) }}
{%- endif %}
@@ -10,24 +10,44 @@ _LOGGER = logging.getLogger(__name__)
_DEFAULTS_DIR = Path(__file__).parent
# Mapping of template slot names to file names
SLOT_FILE_MAP: dict[str, str] = {
"message_assets_added": "assets_added.jinja2",
"message_assets_removed": "assets_removed.jinja2",
"message_collection_renamed": "collection_renamed.jinja2",
"message_collection_deleted": "collection_deleted.jinja2",
"message_sharing_changed": "sharing_changed.jinja2",
"periodic_summary_message": "periodic_summary.jinja2",
"scheduled_assets_message": "scheduled_assets.jinja2",
"memory_mode_message": "memory_mode.jinja2",
# Per-provider mapping of template slot names to file names
PROVIDER_SLOT_FILE_MAP: dict[str, dict[str, str]] = {
"immich": {
"message_assets_added": "assets_added.jinja2",
"message_assets_removed": "assets_removed.jinja2",
"message_collection_renamed": "collection_renamed.jinja2",
"message_collection_deleted": "collection_deleted.jinja2",
"message_sharing_changed": "sharing_changed.jinja2",
"periodic_summary_message": "periodic_summary.jinja2",
"scheduled_assets_message": "scheduled_assets.jinja2",
"memory_mode_message": "memory_mode.jinja2",
},
"gitea": {
"message_push": "gitea_push.jinja2",
"message_issue_opened": "gitea_issue_opened.jinja2",
"message_issue_closed": "gitea_issue_closed.jinja2",
"message_issue_commented": "gitea_issue_commented.jinja2",
"message_pr_opened": "gitea_pr_opened.jinja2",
"message_pr_closed": "gitea_pr_closed.jinja2",
"message_pr_merged": "gitea_pr_merged.jinja2",
"message_pr_commented": "gitea_pr_commented.jinja2",
"message_release_published": "gitea_release_published.jinja2",
},
}
# Backward-compatible alias
SLOT_FILE_MAP: dict[str, str] = PROVIDER_SLOT_FILE_MAP["immich"]
def load_default_templates(locale: str = "en") -> dict[str, str]:
"""Load default template strings for a locale.
def load_default_templates(
locale: str = "en",
provider_type: str = "immich",
) -> dict[str, str]:
"""Load default template strings for a locale and provider type.
Args:
locale: "en" or "ru"
provider_type: "immich" or "gitea"
Returns:
Dict mapping slot name -> template string content.
@@ -37,8 +57,9 @@ def load_default_templates(locale: str = "en") -> dict[str, str]:
_LOGGER.warning("No default templates for locale '%s'", locale)
return {}
slot_map = PROVIDER_SLOT_FILE_MAP.get(provider_type, {})
templates: dict[str, str] = {}
for slot_name, filename in SLOT_FILE_MAP.items():
for slot_name, filename in slot_map.items():
filepath = locale_dir / filename
if filepath.exists():
templates[slot_name] = filepath.read_text(encoding="utf-8").strip()
@@ -0,0 +1,2 @@
✅ <b>{{ sender_name }}</b> закрыл(а) задачу <a href="{{ issue_url }}">#{{ issue_number }}</a> в <a href="{{ repo_url }}">{{ repo_full_name }}</a>
<b>{{ issue_title }}</b>
@@ -0,0 +1,3 @@
💬 <b>{{ comment_author | default(sender_name) }}</b> прокомментировал(а) задачу <a href="{{ issue_url }}">#{{ issue_number }}</a> в <a href="{{ repo_url }}">{{ repo_full_name }}</a>
<b>{{ issue_title }}</b>
{{ comment_body | truncate(200) }}
@@ -0,0 +1,5 @@
🐛 <b>{{ sender_name }}</b> создал(а) задачу <a href="{{ issue_url }}">#{{ issue_number }}</a> в <a href="{{ repo_url }}">{{ repo_full_name }}</a>
<b>{{ issue_title }}</b>
{%- if issue_labels %}
🏷 {{ issue_labels | join(", ") }}
{%- endif %}
@@ -0,0 +1,2 @@
❌ <b>{{ sender_name }}</b> закрыл(а) PR <a href="{{ pr_url }}">#{{ pr_number }}</a> в <a href="{{ repo_url }}">{{ repo_full_name }}</a>
<b>{{ pr_title }}</b>
@@ -0,0 +1,3 @@
💬 <b>{{ comment_author | default(sender_name) }}</b> прокомментировал(а) PR <a href="{{ pr_url }}">#{{ pr_number }}</a> в <a href="{{ repo_url }}">{{ repo_full_name }}</a>
<b>{{ pr_title }}</b>
{{ comment_body | truncate(200) }}
@@ -0,0 +1,3 @@
🎉 <b>{{ sender_name }}</b> влил(а) PR <a href="{{ pr_url }}">#{{ pr_number }}</a> в <a href="{{ repo_url }}">{{ repo_full_name }}</a>
<b>{{ pr_title }}</b>
{{ pr_head }} → {{ pr_base }}
@@ -0,0 +1,6 @@
🔃 <b>{{ sender_name }}</b> создал(а) PR <a href="{{ pr_url }}">#{{ pr_number }}</a> в <a href="{{ repo_url }}">{{ repo_full_name }}</a>
<b>{{ pr_title }}</b>
{{ pr_head }} → {{ pr_base }}
{%- if pr_labels %}
🏷 {{ pr_labels | join(", ") }}
{%- endif %}
@@ -0,0 +1,9 @@
🔀 <b>{{ sender_name }}</b> отправил(а) {{ commit_count }} коммит(ов) в <a href="{{ repo_url }}">{{ repo_full_name }}</a>/<b>{{ branch }}</b>
{%- if commits %}
{%- for c in commits %}
• <code>{{ c.short_id }}</code> {{ c.message | truncate(72) }}
{%- endfor %}
{%- endif %}
{%- if compare_url %}
<a href="{{ compare_url }}">Просмотреть изменения</a>
{%- endif %}
@@ -0,0 +1,7 @@
🚀 <b>{{ sender_name }}</b> опубликовал(а) релиз <a href="{{ release_url }}">{{ release_tag }}</a> в <a href="{{ repo_url }}">{{ repo_full_name }}</a>
{%- if release_name and release_name != release_tag %}
<b>{{ release_name }}</b>
{%- endif %}
{%- if release_body %}
{{ release_body | truncate(300) }}
{%- endif %}
@@ -13,7 +13,7 @@ import aiohttp
from ..auth.dependencies import get_current_user
from ..database.engine import get_session
from ..database.models import ServiceProvider, User
from ..services import make_immich_provider
from ..services import make_immich_provider, make_gitea_provider
_LOGGER = logging.getLogger(__name__)
@@ -81,6 +81,22 @@ async def create_provider(
if test_result.get("external_domain"):
config["external_domain"] = test_result["external_domain"]
elif body.type == "gitea":
config = body.config
# api_token is optional (webhook_secret is required, but token only for repo listing)
if config.get("api_token"):
async with aiohttp.ClientSession() as http_session:
from notify_bridge_core.providers.gitea import GiteaServiceProvider
gitea = GiteaServiceProvider(
http_session, config.get("url", ""), config.get("api_token", ""), body.name,
)
test_result = await gitea.test_connection()
if not test_result.get("ok"):
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=test_result.get("message", "Cannot connect to Gitea"),
)
provider = ServiceProvider(
user_id=user.id,
type=body.type,
@@ -107,6 +123,8 @@ async def list_provider_capabilities():
"command_slots": caps.command_slots,
"events": caps.events,
"commands": caps.commands,
"supported_filters": caps.supported_filters,
"webhook_based": caps.webhook_based,
}
return result
@@ -125,6 +143,8 @@ async def get_provider_capabilities(provider_type: str):
"command_slots": caps.command_slots,
"events": caps.events,
"commands": caps.commands,
"supported_filters": caps.supported_filters,
"webhook_based": caps.webhook_based,
}
@@ -175,6 +195,22 @@ async def update_provider(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f"Connection error: {err}",
)
elif config_changed and provider.type == "gitea":
if provider.config.get("api_token"):
try:
async with aiohttp.ClientSession() as http_session:
gitea = make_gitea_provider(http_session, provider)
test_result = await gitea.test_connection()
if not test_result.get("ok"):
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=test_result.get("message", "Cannot connect to Gitea"),
)
except aiohttp.ClientError as err:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f"Connection error: {err}",
)
session.add(provider)
await session.commit()
@@ -210,6 +246,13 @@ async def test_provider(
immich = make_immich_provider(http_session, provider)
return await immich.test_connection()
if provider.type == "gitea":
if not provider.config.get("api_token"):
return {"ok": True, "message": "Gitea webhook-only mode (no API token for testing)"}
async with aiohttp.ClientSession() as http_session:
gitea = make_gitea_provider(http_session, provider)
return await gitea.test_connection()
return {"ok": False, "message": f"Unknown provider type: {provider.type}"}
@@ -227,6 +270,13 @@ async def list_collections(
immich = make_immich_provider(http_session, provider)
return await immich.list_collections()
if provider.type == "gitea":
if not provider.config.get("api_token"):
return []
async with aiohttp.ClientSession() as http_session:
gitea = make_gitea_provider(http_session, provider)
return await gitea.list_collections()
return []
@@ -285,9 +335,10 @@ def _provider_response(p: ServiceProvider) -> dict:
"""Build a safe response dict for a provider."""
config = dict(p.config)
# Mask sensitive fields
if "api_key" in config:
key = config["api_key"]
config["api_key"] = f"{key[:8]}...{key[-4:]}" if len(key) > 12 else "***"
for secret_field in ("api_key", "api_token", "webhook_secret"):
if secret_field in config:
key = config[secret_field]
config[secret_field] = f"{key[:8]}...{key[-4:]}" if len(key) > 12 else "***"
return {
"id": p.id,
"type": p.type,
@@ -0,0 +1,314 @@
"""Incoming webhook handlers for webhook-based providers (Gitea, etc.)."""
from __future__ import annotations
import hashlib
import hmac
import logging
from typing import Any
from fastapi import APIRouter, HTTPException, Request
from sqlmodel import select
from sqlmodel.ext.asyncio.session import AsyncSession
from notify_bridge_core.models.events import ServiceEvent
from notify_bridge_core.notifications.dispatcher import NotificationDispatcher, TargetConfig
from notify_bridge_core.providers.gitea.event_parser import parse_webhook as parse_gitea_webhook
from ..database.engine import get_engine
from ..database.models import (
EmailBot,
EventLog,
MatrixBot,
NotificationTarget,
NotificationTracker,
NotificationTrackerTarget,
ServiceProvider,
TargetReceiver,
TemplateConfig,
TemplateSlot,
TrackingConfig,
)
_LOGGER = logging.getLogger(__name__)
router = APIRouter(prefix="/api/webhooks", tags=["webhooks"])
# ---------------------------------------------------------------------------
# HMAC-SHA256 validation
# ---------------------------------------------------------------------------
def _verify_gitea_signature(secret: str, body: bytes, signature: str) -> bool:
"""Verify Gitea X-Gitea-Signature HMAC-SHA256."""
expected = hmac.new(secret.encode(), body, hashlib.sha256).hexdigest()
return hmac.compare_digest(expected, signature)
# ---------------------------------------------------------------------------
# Filter helpers
# ---------------------------------------------------------------------------
def _passes_filters(
event: ServiceEvent,
filters: dict[str, Any],
) -> bool:
"""Check if an event passes the tracker's filters."""
# Collection filter (repo full_name for Gitea)
collections = filters.get("collections", [])
if collections and event.collection_id not in collections:
return False
sender = event.extra.get("sender", "")
# Sender allowlist
senders = filters.get("senders", [])
if senders and sender not in senders:
return False
# Sender blocklist
exclude_senders = filters.get("exclude_senders", [])
if exclude_senders and sender in exclude_senders:
return False
return True
# ---------------------------------------------------------------------------
# Gitea webhook endpoint
# ---------------------------------------------------------------------------
@router.post("/gitea/{provider_id}")
async def gitea_webhook(provider_id: int, request: Request):
"""Receive a Gitea webhook, parse it, filter, and dispatch notifications."""
engine = get_engine()
# --- Load provider and validate signature ---
async with AsyncSession(engine) as session:
provider = await session.get(ServiceProvider, provider_id)
if not provider or provider.type != "gitea":
raise HTTPException(status_code=404, detail="Provider not found")
webhook_secret = (provider.config or {}).get("webhook_secret", "")
# Read raw body for HMAC check
raw_body = await request.body()
if webhook_secret:
signature = request.headers.get("X-Gitea-Signature", "")
if not signature or not _verify_gitea_signature(webhook_secret, raw_body, signature):
raise HTTPException(status_code=403, detail="Invalid signature")
# Parse event header + payload
event_header = request.headers.get("X-Gitea-Event", "")
if not event_header:
return {"ok": True, "skipped": "no event header"}
try:
payload = await request.json()
except Exception:
raise HTTPException(status_code=400, detail="Invalid JSON")
event = parse_gitea_webhook(event_header, payload, provider.name)
if event is None:
return {"ok": True, "skipped": "unmapped event"}
# --- Find trackers for this provider and dispatch ---
dispatched = 0
async with AsyncSession(engine) as session:
tracker_result = await session.exec(
select(NotificationTracker).where(
NotificationTracker.provider_id == provider_id,
NotificationTracker.enabled == True,
)
)
trackers = tracker_result.all()
for tracker in trackers:
# Apply filters
filters = tracker.filters or {}
if not _passes_filters(event, filters):
_LOGGER.debug(
"Event filtered out for tracker %d (%s)", tracker.id, tracker.name
)
continue
# Load tracker-target links
link_data = await _load_link_data(session, tracker.id)
if not link_data:
continue
# Log event
session.add(EventLog(
tracker_id=tracker.id,
tracker_name=tracker.name,
provider_id=provider_id,
provider_name=provider.name,
event_type=event.event_type.value,
collection_id=event.collection_id,
collection_name=event.collection_name,
assets_count=0,
details={
"provider_type": event.provider_type.value,
**{k: v for k, v in event.extra.items() if k in (
"sender", "branch", "commit_count",
"issue_number", "issue_title",
"pr_number", "pr_title",
"release_tag", "release_name",
)},
},
))
await session.commit()
# Dispatch to targets
dispatcher = NotificationDispatcher()
target_configs = _build_target_configs(event, link_data, provider.config or {})
if target_configs:
results = await dispatcher.dispatch(event, target_configs)
for r in results:
if r.get("success"):
dispatched += 1
else:
_LOGGER.error(
"Notification failed for tracker %d: %s",
tracker.id, r.get("error", "unknown"),
)
return {"ok": True, "dispatched": dispatched}
# ---------------------------------------------------------------------------
# Shared dispatch helpers (extracted from watcher pattern)
# ---------------------------------------------------------------------------
async def _load_link_data(
session: AsyncSession,
tracker_id: int,
) -> list[dict[str, Any]]:
"""Load tracker-target link data for dispatch (same pattern as watcher)."""
tt_result = await session.exec(
select(NotificationTrackerTarget).where(
NotificationTrackerTarget.tracker_id == tracker_id
)
)
tracker_targets = tt_result.all()
link_data: list[dict[str, Any]] = []
for tt in tracker_targets:
if not tt.enabled:
continue
target = await session.get(NotificationTarget, tt.target_id)
if not target:
continue
# Load receivers
recv_result = await session.exec(
select(TargetReceiver).where(
TargetReceiver.target_id == target.id,
TargetReceiver.enabled == True,
)
)
receivers = [dict(r.config) for r in recv_result.all()]
tracking_config = None
if tt.tracking_config_id:
tracking_config = await session.get(TrackingConfig, tt.tracking_config_id)
template_config = None
template_slots: dict[str, str] | None = None
if tt.template_config_id:
template_config = await session.get(TemplateConfig, tt.template_config_id)
if template_config:
slot_result = await session.exec(
select(TemplateSlot).where(TemplateSlot.config_id == template_config.id)
)
raw_slots = {s.slot_name: s.template for s in slot_result.all()}
template_slots = {}
for slot_name, tmpl_text in raw_slots.items():
event_key = slot_name.removeprefix("message_") if slot_name.startswith("message_") else slot_name
template_slots[event_key] = tmpl_text
target_config = dict(target.config)
# Inject bot credentials
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
link_data.append({
"target_type": target.type,
"target_config": target_config,
"receivers": receivers,
"tracking_config": tracking_config,
"template_config": template_config,
"template_slots": template_slots,
})
return link_data
def _event_allowed_by_tracking_config(event: ServiceEvent, tc: TrackingConfig) -> bool:
"""Check if an event type is allowed by tracking config flags."""
event_type = event.event_type.value
flag_map = {
"push": tc.track_push,
"issue_opened": tc.track_issue_opened,
"issue_closed": tc.track_issue_closed,
"issue_commented": tc.track_issue_commented,
"pr_opened": tc.track_pr_opened,
"pr_closed": tc.track_pr_closed,
"pr_merged": tc.track_pr_merged,
"pr_commented": tc.track_pr_commented,
"release_published": tc.track_release_published,
# Immich events
"assets_added": tc.track_assets_added,
"assets_removed": tc.track_assets_removed,
"collection_renamed": tc.track_collection_renamed,
"collection_deleted": tc.track_collection_deleted,
"sharing_changed": tc.track_sharing_changed,
}
return flag_map.get(event_type, True)
def _build_target_configs(
event: ServiceEvent,
link_data: list[dict[str, Any]],
provider_config: dict[str, Any],
) -> list[TargetConfig]:
"""Build TargetConfig objects for dispatch, applying tracking config filters."""
target_configs: list[TargetConfig] = []
for ld in link_data:
tc = ld["tracking_config"]
if tc and not _event_allowed_by_tracking_config(event, tc):
continue
tmpl = ld["template_config"]
target_configs.append(TargetConfig(
type=ld["target_type"],
config=ld["target_config"],
template_slots=ld["template_slots"],
date_format=tmpl.date_format if tmpl else "%d.%m.%Y, %H:%M UTC",
date_only_format=tmpl.date_only_format if tmpl and tmpl.date_only_format else "%d.%m.%Y",
provider_api_key=provider_config.get("api_token"),
provider_internal_url=provider_config.get("url", ""),
provider_external_url=provider_config.get("url", ""),
receivers=ld["receivers"],
))
return target_configs
@@ -130,6 +130,34 @@ async def migrate_schema(engine: AsyncEngine) -> None:
)
logger.info("Added memory_source column to tracking_config table")
# Add filters JSON column to notification_tracker if missing
if await _has_table(conn, tracker_table):
if not await _has_column(conn, tracker_table, "filters"):
await conn.execute(
text(f"ALTER TABLE {tracker_table} ADD COLUMN filters TEXT DEFAULT '{{}}'")
)
logger.info("Added filters column to %s table", tracker_table)
# Add Gitea tracking flags to tracking_config if missing
if await _has_table(conn, "tracking_config"):
gitea_flags = [
("track_push", "INTEGER DEFAULT 1"),
("track_issue_opened", "INTEGER DEFAULT 1"),
("track_issue_closed", "INTEGER DEFAULT 1"),
("track_issue_commented", "INTEGER DEFAULT 0"),
("track_pr_opened", "INTEGER DEFAULT 1"),
("track_pr_closed", "INTEGER DEFAULT 1"),
("track_pr_merged", "INTEGER DEFAULT 1"),
("track_pr_commented", "INTEGER DEFAULT 0"),
("track_release_published", "INTEGER DEFAULT 1"),
]
for col_name, col_type in gitea_flags:
if not await _has_column(conn, "tracking_config", col_name):
await conn.execute(
text(f"ALTER TABLE tracking_config ADD COLUMN {col_name} {col_type}")
)
logger.info("Added %s column to tracking_config table", col_name)
# Add collection_name and shared to tracker_state if missing
state_table = "notification_tracker_state" if await _has_table(conn, "notification_tracker_state") else "tracker_state"
if await _has_table(conn, state_table):
@@ -115,6 +115,19 @@ class TrackingConfig(SQLModel, table=True):
track_collection_renamed: bool = Field(default=True)
track_collection_deleted: bool = Field(default=True)
track_sharing_changed: bool = Field(default=False)
# Gitea event tracking
track_push: bool = Field(default=True)
track_issue_opened: bool = Field(default=True)
track_issue_closed: bool = Field(default=True)
track_issue_commented: bool = Field(default=False)
track_pr_opened: bool = Field(default=True)
track_pr_closed: bool = Field(default=True)
track_pr_merged: bool = Field(default=True)
track_pr_commented: bool = Field(default=False)
track_release_published: bool = Field(default=True)
# Immich asset display
track_images: bool = Field(default=True)
track_videos: bool = Field(default=True)
notify_favorites_only: bool = Field(default=False)
@@ -247,6 +260,7 @@ class NotificationTracker(SQLModel, table=True):
name: str
icon: str = Field(default="")
collection_ids: list[str] = Field(default_factory=list, sa_column=Column(JSON))
filters: dict[str, Any] = Field(default_factory=dict, sa_column=Column(JSON))
scan_interval: int = Field(default=60)
batch_duration: int = Field(default=0) # seconds to accumulate events before dispatch (0=immediate)
enabled: bool = Field(default=True)
@@ -32,6 +32,7 @@ from .api.command_configs import router as command_configs_router
from .api.command_trackers import router as command_trackers_router
from .api.command_template_configs import router as command_template_configs_router
from .commands.webhook import router as webhook_router, set_webhook_secret
from .api.webhooks import router as webhooks_router
@asynccontextmanager
@@ -88,6 +89,7 @@ app.include_router(command_configs_router)
app.include_router(command_trackers_router)
app.include_router(command_template_configs_router)
app.include_router(webhook_router)
app.include_router(webhooks_router)
@app.get("/api/health")
@@ -120,7 +122,7 @@ async def _seed_default_templates():
}
for locale in ("en", "ru"):
slots = load_default_templates(locale)
slots = load_default_templates(locale, provider_type="immich")
if not slots:
continue
@@ -193,6 +195,86 @@ async def _seed_default_templates():
template=template_text,
))
# --- Seed Gitea default templates ---
gitea_result = await session.exec(
select(TemplateConfig).where(
TemplateConfig.user_id == 0,
TemplateConfig.provider_type == "gitea",
)
)
gitea_configs = gitea_result.all()
gitea_existing_locales = {
(c.locale if c.locale else "en"): c for c in gitea_configs
}
for locale in ("en", "ru"):
gitea_slots = load_default_templates(locale, provider_type="gitea")
if not gitea_slots:
continue
if locale not in gitea_existing_locales:
from datetime import datetime as _dt, timezone as _tz
now = _dt.now(_tz.utc).isoformat()
name = f"Default Gitea ({locale.upper()})"
desc = f"Default Gitea templates ({locale.upper()})"
col_info = (await session.execute(
text("PRAGMA table_info(template_config)")
)).fetchall()
col_names = [c[1] for c in col_info if c[1] != "id"]
values = {}
for col in col_names:
if col == "user_id":
values[col] = 0
elif col == "provider_type":
values[col] = "gitea"
elif col == "name":
values[col] = name
elif col == "description":
values[col] = desc
elif col == "created_at":
values[col] = now
elif col == "date_format":
values[col] = "%d.%m.%Y, %H:%M UTC"
elif col == "date_only_format":
values[col] = "%d.%m.%Y"
elif col == "locale":
values[col] = locale
else:
values[col] = ""
cols_str = ", ".join(values.keys())
placeholders = ", ".join(f":{k}" for k in values.keys())
await session.execute(
text(f"INSERT INTO template_config ({cols_str}) VALUES ({placeholders})"),
values,
)
row = (await session.execute(text("SELECT last_insert_rowid()"))).scalar()
gitea_config_id = row
for slot_name, template_text in gitea_slots.items():
session.add(TemplateSlot(
config_id=gitea_config_id,
slot_name=slot_name,
template=template_text,
))
else:
config = gitea_existing_locales[locale]
for slot_name, template_text in gitea_slots.items():
slot_result = await session.exec(
select(TemplateSlot).where(
TemplateSlot.config_id == config.id,
TemplateSlot.slot_name == slot_name,
)
)
existing = slot_result.first()
if existing:
existing.template = template_text
session.add(existing)
else:
session.add(TemplateSlot(
config_id=config.id,
slot_name=slot_name,
template=template_text,
))
await session.commit()
@@ -1,6 +1,7 @@
"""Shared service utilities."""
from notify_bridge_core.providers.immich import ImmichServiceProvider
from notify_bridge_core.providers.gitea import GiteaServiceProvider
from ..database.models import ServiceProvider
@@ -15,3 +16,14 @@ def make_immich_provider(http_session, provider: ServiceProvider) -> ImmichServi
config.get("external_domain"),
provider.name,
)
def make_gitea_provider(http_session, provider: ServiceProvider) -> GiteaServiceProvider:
"""Create a GiteaServiceProvider from a DB provider model."""
config = provider.config or {}
return GiteaServiceProvider(
http_session,
config.get("url", ""),
config.get("api_token", ""),
provider.name,
)
@@ -78,11 +78,22 @@ def _event_allowed_by_config(event: ServiceEvent, tc: TrackingConfig) -> bool:
"""Check if an event type is allowed by the tracking config's flags."""
event_type = event.event_type.value
flag_map = {
# Immich events
"assets_added": tc.track_assets_added,
"assets_removed": tc.track_assets_removed,
"collection_renamed": tc.track_collection_renamed,
"collection_deleted": tc.track_collection_deleted,
"sharing_changed": tc.track_sharing_changed,
# Gitea events
"push": tc.track_push,
"issue_opened": tc.track_issue_opened,
"issue_closed": tc.track_issue_closed,
"issue_commented": tc.track_issue_commented,
"pr_opened": tc.track_pr_opened,
"pr_closed": tc.track_pr_closed,
"pr_merged": tc.track_pr_merged,
"pr_commented": tc.track_pr_commented,
"release_published": tc.track_release_published,
}
return flag_map.get(event_type, True)
@@ -220,6 +231,10 @@ async def check_tracker(tracker_id: int) -> dict[str, Any]:
return {"status": "error", "reason": "failed to connect to provider"}
events, new_state = await immich.poll(collection_ids, state_dict)
elif provider_type == "gitea":
# Gitea is webhook-based — events arrive via /api/webhooks/gitea endpoint.
# The scheduler still calls check_tracker but there's nothing to poll.
return {"status": "ok", "events_detected": 0, "collections_checked": 0}
else:
return {"status": "error", "reason": f"unsupported provider type: {provider_type}"}