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:
@@ -99,4 +99,5 @@ export const previewTargetTypeItems = (): GridItem[] => [
|
|||||||
|
|
||||||
export const providerTypeItems = (): GridItem[] => [
|
export const providerTypeItems = (): GridItem[] => [
|
||||||
{ value: 'immich', icon: 'mdiCamera', label: t('providers.typeImmich') },
|
{ value: 'immich', icon: 'mdiCamera', label: t('providers.typeImmich') },
|
||||||
|
{ value: 'gitea', icon: 'mdiGit', label: t('providers.typeGitea') },
|
||||||
];
|
];
|
||||||
|
|||||||
@@ -106,11 +106,18 @@
|
|||||||
"offline": "Offline",
|
"offline": "Offline",
|
||||||
"checking": "Checking...",
|
"checking": "Checking...",
|
||||||
"typeImmich": "Immich",
|
"typeImmich": "Immich",
|
||||||
|
"typeGitea": "Gitea",
|
||||||
"loadError": "Failed to load providers.",
|
"loadError": "Failed to load providers.",
|
||||||
"externalDomain": "External Domain",
|
"externalDomain": "External Domain",
|
||||||
"optional": "optional",
|
"optional": "optional",
|
||||||
"urlApiKeyRequired": "URL and API Key are required",
|
"urlApiKeyRequired": "URL and API Key are required",
|
||||||
"externalDomainHint": "Public-facing URL for notification links. Falls back to server URL.",
|
"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",
|
"testAndSave": "Test & Save",
|
||||||
"saveWithoutTest": "Save without testing"
|
"saveWithoutTest": "Save without testing"
|
||||||
},
|
},
|
||||||
@@ -248,6 +255,7 @@
|
|||||||
"matrixRoomId": "Room ID",
|
"matrixRoomId": "Room ID",
|
||||||
"receivers": "Receivers",
|
"receivers": "Receivers",
|
||||||
"noReceivers": "No receivers yet",
|
"noReceivers": "No receivers yet",
|
||||||
|
"alreadyAdded": "already added",
|
||||||
"addReceiver": "Add Receiver",
|
"addReceiver": "Add Receiver",
|
||||||
"receiverAdded": "Receiver added",
|
"receiverAdded": "Receiver added",
|
||||||
"receiverDeleted": "Receiver deleted",
|
"receiverDeleted": "Receiver deleted",
|
||||||
@@ -348,6 +356,15 @@
|
|||||||
"albumRenamed": "Album renamed",
|
"albumRenamed": "Album renamed",
|
||||||
"albumDeleted": "Album deleted",
|
"albumDeleted": "Album deleted",
|
||||||
"sharingChanged": "Sharing changed",
|
"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",
|
"trackImages": "Track images",
|
||||||
"trackVideos": "Track videos",
|
"trackVideos": "Track videos",
|
||||||
"favoritesOnly": "Favorites only",
|
"favoritesOnly": "Favorites only",
|
||||||
|
|||||||
@@ -106,11 +106,18 @@
|
|||||||
"offline": "Не в сети",
|
"offline": "Не в сети",
|
||||||
"checking": "Проверка...",
|
"checking": "Проверка...",
|
||||||
"typeImmich": "Immich",
|
"typeImmich": "Immich",
|
||||||
|
"typeGitea": "Gitea",
|
||||||
"loadError": "Не удалось загрузить провайдеры.",
|
"loadError": "Не удалось загрузить провайдеры.",
|
||||||
"externalDomain": "Внешний домен",
|
"externalDomain": "Внешний домен",
|
||||||
"optional": "необязательно",
|
"optional": "необязательно",
|
||||||
"urlApiKeyRequired": "URL и API ключ обязательны",
|
"urlApiKeyRequired": "URL и API ключ обязательны",
|
||||||
"externalDomainHint": "Публичный URL для ссылок в уведомлениях. По умолчанию используется URL сервера.",
|
"externalDomainHint": "Публичный URL для ссылок в уведомлениях. По умолчанию используется URL сервера.",
|
||||||
|
"webhookSecret": "Секрет вебхука",
|
||||||
|
"webhookSecretKeep": "Секрет вебхука (оставьте пустым для сохранения текущего)",
|
||||||
|
"webhookSecretHint": "Общий секрет для проверки HMAC-SHA256 подписи. Укажите тот же секрет в настройках вебхука Gitea.",
|
||||||
|
"webhookSecretRequired": "Секрет вебхука обязателен",
|
||||||
|
"apiToken": "API токен",
|
||||||
|
"apiTokenHint": "Необязательно. Нужен для проверки подключения и получения списка репозиториев.",
|
||||||
"testAndSave": "Проверить и сохранить",
|
"testAndSave": "Проверить и сохранить",
|
||||||
"saveWithoutTest": "Сохранить без проверки"
|
"saveWithoutTest": "Сохранить без проверки"
|
||||||
},
|
},
|
||||||
@@ -248,6 +255,7 @@
|
|||||||
"matrixRoomId": "ID комнаты",
|
"matrixRoomId": "ID комнаты",
|
||||||
"receivers": "Получатели",
|
"receivers": "Получатели",
|
||||||
"noReceivers": "Нет получателей",
|
"noReceivers": "Нет получателей",
|
||||||
|
"alreadyAdded": "уже добавлен",
|
||||||
"addReceiver": "Добавить получателя",
|
"addReceiver": "Добавить получателя",
|
||||||
"receiverAdded": "Получатель добавлен",
|
"receiverAdded": "Получатель добавлен",
|
||||||
"receiverDeleted": "Получатель удалён",
|
"receiverDeleted": "Получатель удалён",
|
||||||
@@ -348,6 +356,15 @@
|
|||||||
"albumRenamed": "Альбом переименован",
|
"albumRenamed": "Альбом переименован",
|
||||||
"albumDeleted": "Альбом удалён",
|
"albumDeleted": "Альбом удалён",
|
||||||
"sharingChanged": "Изменение доступа",
|
"sharingChanged": "Изменение доступа",
|
||||||
|
"push": "Push",
|
||||||
|
"issueOpened": "Задача создана",
|
||||||
|
"issueClosed": "Задача закрыта",
|
||||||
|
"issueCommented": "Комментарий к задаче",
|
||||||
|
"prOpened": "PR создан",
|
||||||
|
"prClosed": "PR закрыт",
|
||||||
|
"prMerged": "PR влит",
|
||||||
|
"prCommented": "Комментарий к PR",
|
||||||
|
"releasePublished": "Релиз опубликован",
|
||||||
"trackImages": "Фото",
|
"trackImages": "Фото",
|
||||||
"trackVideos": "Видео",
|
"trackVideos": "Видео",
|
||||||
"favoritesOnly": "Только избранные",
|
"favoritesOnly": "Только избранные",
|
||||||
|
|||||||
@@ -21,7 +21,7 @@
|
|||||||
let providers = $derived(providersCache.items);
|
let providers = $derived(providersCache.items);
|
||||||
let showForm = $state(false);
|
let showForm = $state(false);
|
||||||
let editing = $state<number | null>(null);
|
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 error = $state('');
|
||||||
let loadError = $state('');
|
let loadError = $state('');
|
||||||
let submitting = $state(false);
|
let submitting = $state(false);
|
||||||
@@ -48,12 +48,16 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
function openNew() {
|
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;
|
editing = null; showForm = true;
|
||||||
}
|
}
|
||||||
function edit(p: any) {
|
function edit(p: any) {
|
||||||
const cfg = p.config || {};
|
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;
|
editing = p.id; showForm = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -61,12 +65,21 @@
|
|||||||
e.preventDefault(); error = ''; submitting = true;
|
e.preventDefault(); error = ''; submitting = true;
|
||||||
try {
|
try {
|
||||||
const config: any = { url: form.url };
|
const config: any = { url: form.url };
|
||||||
if (form.api_key) config.api_key = form.api_key;
|
if (form.type === 'immich') {
|
||||||
if (form.external_domain) config.external_domain = form.external_domain;
|
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) {
|
if (editing) {
|
||||||
await api(`/providers/${editing}`, { method: 'PUT', body: JSON.stringify({ name: form.name, icon: form.icon, config }) });
|
await api(`/providers/${editing}`, { method: 'PUT', body: JSON.stringify({ name: form.name, icon: form.icon, config }) });
|
||||||
} else {
|
} 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 }) });
|
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();
|
showForm = false; editing = null; providersCache.invalidate(); await load();
|
||||||
@@ -129,8 +142,9 @@
|
|||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label for="prv-url" class="block text-sm font-medium mb-1">{t('providers.url')}</label>
|
<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>
|
</div>
|
||||||
|
{#if form.type === 'immich'}
|
||||||
<div>
|
<div>
|
||||||
<label for="prv-key" class="block text-sm font-medium mb-1">{editing ? t('providers.apiKeyKeep') : t('providers.apiKey')}</label>
|
<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)]" />
|
<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>
|
<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)]" />
|
<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>
|
</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}
|
<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">
|
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'))}
|
{submitting ? t('providers.connecting') : (editing ? t('common.save') : t('providers.addProvider'))}
|
||||||
|
|||||||
@@ -28,6 +28,7 @@
|
|||||||
|
|
||||||
const defaultForm = () => ({
|
const defaultForm = () => ({
|
||||||
provider_type: 'immich', name: '', icon: '',
|
provider_type: 'immich', name: '', icon: '',
|
||||||
|
// Immich event tracking
|
||||||
track_assets_added: true, track_assets_removed: false,
|
track_assets_added: true, track_assets_removed: false,
|
||||||
track_collection_renamed: true, track_collection_deleted: true, track_sharing_changed: false,
|
track_collection_renamed: true, track_collection_deleted: true, track_sharing_changed: false,
|
||||||
track_images: true, track_videos: true, notify_favorites_only: 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',
|
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_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,
|
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());
|
let form = $state(defaultForm());
|
||||||
|
|
||||||
@@ -112,6 +117,19 @@
|
|||||||
<!-- Event tracking -->
|
<!-- Event tracking -->
|
||||||
<fieldset class="border border-[var(--color-border)] rounded-md p-3">
|
<fieldset class="border border-[var(--color-border)] rounded-md p-3">
|
||||||
<legend class="text-sm font-medium px-1">{t('trackingConfig.eventTracking')}</legend>
|
<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">
|
<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_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>
|
<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_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>
|
<label class="flex items-center gap-2 text-sm"><input type="checkbox" bind:checked={form.include_asset_details} /> {t('trackingConfig.includeDetails')}</label>
|
||||||
</div>
|
</div>
|
||||||
|
{/if}
|
||||||
|
{#if form.provider_type !== 'gitea'}
|
||||||
<div class="grid grid-cols-3 gap-3 mt-3">
|
<div class="grid grid-cols-3 gap-3 mt-3">
|
||||||
<div>
|
<div>
|
||||||
<label for="tc-max" class="block text-xs mb-1">{t('trackingConfig.maxAssets')}<Hint text={t('hints.maxAssets')} /></label>
|
<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} />
|
<IconGridSelect items={sortOrderItems()} bind:value={form.assets_order} columns={2} />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
{/if}
|
||||||
</fieldset>
|
</fieldset>
|
||||||
|
|
||||||
|
{#if form.provider_type !== 'gitea'}
|
||||||
<!-- Periodic summary -->
|
<!-- Periodic summary -->
|
||||||
<fieldset class="border border-[var(--color-border)] rounded-md p-3">
|
<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>
|
<legend class="text-sm font-medium px-1">{t('trackingConfig.periodicSummary')}<Hint text={t('hints.periodicSummary')} /></legend>
|
||||||
@@ -190,6 +212,7 @@
|
|||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
</fieldset>
|
</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">
|
<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')}
|
{editing ? t('common.save') : t('common.create')}
|
||||||
|
|||||||
@@ -14,12 +14,24 @@ from notify_bridge_core.providers.base import ServiceProviderType
|
|||||||
class EventType(str, Enum):
|
class EventType(str, Enum):
|
||||||
"""Types of events a service provider can emit."""
|
"""Types of events a service provider can emit."""
|
||||||
|
|
||||||
|
# Immich events
|
||||||
ASSETS_ADDED = "assets_added"
|
ASSETS_ADDED = "assets_added"
|
||||||
ASSETS_REMOVED = "assets_removed"
|
ASSETS_REMOVED = "assets_removed"
|
||||||
COLLECTION_RENAMED = "collection_renamed"
|
COLLECTION_RENAMED = "collection_renamed"
|
||||||
COLLECTION_DELETED = "collection_deleted"
|
COLLECTION_DELETED = "collection_deleted"
|
||||||
SHARING_CHANGED = "sharing_changed"
|
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
|
@dataclass
|
||||||
class ServiceEvent:
|
class ServiceEvent:
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ class ServiceProviderType(str, Enum):
|
|||||||
"""Supported service provider types."""
|
"""Supported service provider types."""
|
||||||
|
|
||||||
IMMICH = "immich"
|
IMMICH = "immich"
|
||||||
|
GITEA = "gitea"
|
||||||
|
|
||||||
|
|
||||||
class ServiceProvider(ABC):
|
class ServiceProvider(ABC):
|
||||||
|
|||||||
@@ -29,6 +29,12 @@ class ProviderCapabilities:
|
|||||||
# Commands the provider supports
|
# Commands the provider supports
|
||||||
commands: list[dict[str, str]] = field(default_factory=list)
|
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
|
# Immich provider capabilities
|
||||||
@@ -37,6 +43,9 @@ class ProviderCapabilities:
|
|||||||
IMMICH_CAPABILITIES = ProviderCapabilities(
|
IMMICH_CAPABILITIES = ProviderCapabilities(
|
||||||
provider_type="immich",
|
provider_type="immich",
|
||||||
display_name="Immich",
|
display_name="Immich",
|
||||||
|
supported_filters=[
|
||||||
|
{"key": "collections", "label": "Albums", "type": "select", "source": "api"},
|
||||||
|
],
|
||||||
notification_slots=[
|
notification_slots=[
|
||||||
{"name": "message_assets_added", "description": "New assets added to album"},
|
{"name": "message_assets_added", "description": "New assets added to album"},
|
||||||
{"name": "message_assets_removed", "description": "Assets removed from 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
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
_REGISTRY: dict[str, ProviderCapabilities] = {
|
_REGISTRY: dict[str, ProviderCapabilities] = {
|
||||||
"immich": IMMICH_CAPABILITIES,
|
"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>
|
||||||
+3
@@ -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 %}
|
||||||
+7
@@ -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
|
_DEFAULTS_DIR = Path(__file__).parent
|
||||||
|
|
||||||
# Mapping of template slot names to file names
|
# Per-provider mapping of template slot names to file names
|
||||||
SLOT_FILE_MAP: dict[str, str] = {
|
PROVIDER_SLOT_FILE_MAP: dict[str, dict[str, str]] = {
|
||||||
"message_assets_added": "assets_added.jinja2",
|
"immich": {
|
||||||
"message_assets_removed": "assets_removed.jinja2",
|
"message_assets_added": "assets_added.jinja2",
|
||||||
"message_collection_renamed": "collection_renamed.jinja2",
|
"message_assets_removed": "assets_removed.jinja2",
|
||||||
"message_collection_deleted": "collection_deleted.jinja2",
|
"message_collection_renamed": "collection_renamed.jinja2",
|
||||||
"message_sharing_changed": "sharing_changed.jinja2",
|
"message_collection_deleted": "collection_deleted.jinja2",
|
||||||
"periodic_summary_message": "periodic_summary.jinja2",
|
"message_sharing_changed": "sharing_changed.jinja2",
|
||||||
"scheduled_assets_message": "scheduled_assets.jinja2",
|
"periodic_summary_message": "periodic_summary.jinja2",
|
||||||
"memory_mode_message": "memory_mode.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:
|
Args:
|
||||||
locale: "en" or "ru"
|
locale: "en" or "ru"
|
||||||
|
provider_type: "immich" or "gitea"
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
Dict mapping slot name -> template string content.
|
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)
|
_LOGGER.warning("No default templates for locale '%s'", locale)
|
||||||
return {}
|
return {}
|
||||||
|
|
||||||
|
slot_map = PROVIDER_SLOT_FILE_MAP.get(provider_type, {})
|
||||||
templates: dict[str, str] = {}
|
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
|
filepath = locale_dir / filename
|
||||||
if filepath.exists():
|
if filepath.exists():
|
||||||
templates[slot_name] = filepath.read_text(encoding="utf-8").strip()
|
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>
|
||||||
+3
@@ -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 %}
|
||||||
+7
@@ -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 ..auth.dependencies import get_current_user
|
||||||
from ..database.engine import get_session
|
from ..database.engine import get_session
|
||||||
from ..database.models import ServiceProvider, User
|
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__)
|
_LOGGER = logging.getLogger(__name__)
|
||||||
|
|
||||||
@@ -81,6 +81,22 @@ async def create_provider(
|
|||||||
if test_result.get("external_domain"):
|
if test_result.get("external_domain"):
|
||||||
config["external_domain"] = test_result["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(
|
provider = ServiceProvider(
|
||||||
user_id=user.id,
|
user_id=user.id,
|
||||||
type=body.type,
|
type=body.type,
|
||||||
@@ -107,6 +123,8 @@ async def list_provider_capabilities():
|
|||||||
"command_slots": caps.command_slots,
|
"command_slots": caps.command_slots,
|
||||||
"events": caps.events,
|
"events": caps.events,
|
||||||
"commands": caps.commands,
|
"commands": caps.commands,
|
||||||
|
"supported_filters": caps.supported_filters,
|
||||||
|
"webhook_based": caps.webhook_based,
|
||||||
}
|
}
|
||||||
return result
|
return result
|
||||||
|
|
||||||
@@ -125,6 +143,8 @@ async def get_provider_capabilities(provider_type: str):
|
|||||||
"command_slots": caps.command_slots,
|
"command_slots": caps.command_slots,
|
||||||
"events": caps.events,
|
"events": caps.events,
|
||||||
"commands": caps.commands,
|
"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,
|
status_code=status.HTTP_400_BAD_REQUEST,
|
||||||
detail=f"Connection error: {err}",
|
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)
|
session.add(provider)
|
||||||
await session.commit()
|
await session.commit()
|
||||||
@@ -210,6 +246,13 @@ async def test_provider(
|
|||||||
immich = make_immich_provider(http_session, provider)
|
immich = make_immich_provider(http_session, provider)
|
||||||
return await immich.test_connection()
|
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}"}
|
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)
|
immich = make_immich_provider(http_session, provider)
|
||||||
return await immich.list_collections()
|
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 []
|
return []
|
||||||
|
|
||||||
|
|
||||||
@@ -285,9 +335,10 @@ def _provider_response(p: ServiceProvider) -> dict:
|
|||||||
"""Build a safe response dict for a provider."""
|
"""Build a safe response dict for a provider."""
|
||||||
config = dict(p.config)
|
config = dict(p.config)
|
||||||
# Mask sensitive fields
|
# Mask sensitive fields
|
||||||
if "api_key" in config:
|
for secret_field in ("api_key", "api_token", "webhook_secret"):
|
||||||
key = config["api_key"]
|
if secret_field in config:
|
||||||
config["api_key"] = f"{key[:8]}...{key[-4:]}" if len(key) > 12 else "***"
|
key = config[secret_field]
|
||||||
|
config[secret_field] = f"{key[:8]}...{key[-4:]}" if len(key) > 12 else "***"
|
||||||
return {
|
return {
|
||||||
"id": p.id,
|
"id": p.id,
|
||||||
"type": p.type,
|
"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")
|
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
|
# 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"
|
state_table = "notification_tracker_state" if await _has_table(conn, "notification_tracker_state") else "tracker_state"
|
||||||
if await _has_table(conn, state_table):
|
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_renamed: bool = Field(default=True)
|
||||||
track_collection_deleted: bool = Field(default=True)
|
track_collection_deleted: bool = Field(default=True)
|
||||||
track_sharing_changed: bool = Field(default=False)
|
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_images: bool = Field(default=True)
|
||||||
track_videos: bool = Field(default=True)
|
track_videos: bool = Field(default=True)
|
||||||
notify_favorites_only: bool = Field(default=False)
|
notify_favorites_only: bool = Field(default=False)
|
||||||
@@ -247,6 +260,7 @@ class NotificationTracker(SQLModel, table=True):
|
|||||||
name: str
|
name: str
|
||||||
icon: str = Field(default="")
|
icon: str = Field(default="")
|
||||||
collection_ids: list[str] = Field(default_factory=list, sa_column=Column(JSON))
|
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)
|
scan_interval: int = Field(default=60)
|
||||||
batch_duration: int = Field(default=0) # seconds to accumulate events before dispatch (0=immediate)
|
batch_duration: int = Field(default=0) # seconds to accumulate events before dispatch (0=immediate)
|
||||||
enabled: bool = Field(default=True)
|
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_trackers import router as command_trackers_router
|
||||||
from .api.command_template_configs import router as command_template_configs_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 .commands.webhook import router as webhook_router, set_webhook_secret
|
||||||
|
from .api.webhooks import router as webhooks_router
|
||||||
|
|
||||||
|
|
||||||
@asynccontextmanager
|
@asynccontextmanager
|
||||||
@@ -88,6 +89,7 @@ app.include_router(command_configs_router)
|
|||||||
app.include_router(command_trackers_router)
|
app.include_router(command_trackers_router)
|
||||||
app.include_router(command_template_configs_router)
|
app.include_router(command_template_configs_router)
|
||||||
app.include_router(webhook_router)
|
app.include_router(webhook_router)
|
||||||
|
app.include_router(webhooks_router)
|
||||||
|
|
||||||
|
|
||||||
@app.get("/api/health")
|
@app.get("/api/health")
|
||||||
@@ -120,7 +122,7 @@ async def _seed_default_templates():
|
|||||||
}
|
}
|
||||||
|
|
||||||
for locale in ("en", "ru"):
|
for locale in ("en", "ru"):
|
||||||
slots = load_default_templates(locale)
|
slots = load_default_templates(locale, provider_type="immich")
|
||||||
if not slots:
|
if not slots:
|
||||||
continue
|
continue
|
||||||
|
|
||||||
@@ -193,6 +195,86 @@ async def _seed_default_templates():
|
|||||||
template=template_text,
|
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()
|
await session.commit()
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
"""Shared service utilities."""
|
"""Shared service utilities."""
|
||||||
|
|
||||||
from notify_bridge_core.providers.immich import ImmichServiceProvider
|
from notify_bridge_core.providers.immich import ImmichServiceProvider
|
||||||
|
from notify_bridge_core.providers.gitea import GiteaServiceProvider
|
||||||
|
|
||||||
from ..database.models import ServiceProvider
|
from ..database.models import ServiceProvider
|
||||||
|
|
||||||
@@ -15,3 +16,14 @@ def make_immich_provider(http_session, provider: ServiceProvider) -> ImmichServi
|
|||||||
config.get("external_domain"),
|
config.get("external_domain"),
|
||||||
provider.name,
|
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."""
|
"""Check if an event type is allowed by the tracking config's flags."""
|
||||||
event_type = event.event_type.value
|
event_type = event.event_type.value
|
||||||
flag_map = {
|
flag_map = {
|
||||||
|
# Immich events
|
||||||
"assets_added": tc.track_assets_added,
|
"assets_added": tc.track_assets_added,
|
||||||
"assets_removed": tc.track_assets_removed,
|
"assets_removed": tc.track_assets_removed,
|
||||||
"collection_renamed": tc.track_collection_renamed,
|
"collection_renamed": tc.track_collection_renamed,
|
||||||
"collection_deleted": tc.track_collection_deleted,
|
"collection_deleted": tc.track_collection_deleted,
|
||||||
"sharing_changed": tc.track_sharing_changed,
|
"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)
|
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"}
|
return {"status": "error", "reason": "failed to connect to provider"}
|
||||||
|
|
||||||
events, new_state = await immich.poll(collection_ids, state_dict)
|
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:
|
else:
|
||||||
return {"status": "error", "reason": f"unsupported provider type: {provider_type}"}
|
return {"status": "error", "reason": f"unsupported provider type: {provider_type}"}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user