From 0fde3c6b3d67f47e36ecf951c314515eea717b32 Mon Sep 17 00:00:00 2001 From: "alexei.dolgolyov" Date: Mon, 23 Mar 2026 15:54:00 +0300 Subject: [PATCH] feat: add Planka service provider with full notification and command support Webhook-based provider for Planka (self-hosted Kanban board) with: - 15 event types (cards, boards, lists, comments, tasks, attachments, labels) - Bearer token webhook authentication - Async API client for boards/cards/lists - 30 notification templates (en/ru) + 26 command templates (en/ru) - Bot commands: /status, /boards, /cards, /lists - Default tracking config, template config, command config seeded on startup - DB migration for 15 new tracking_config columns - Frontend: provider config UI with auto-name, Planka-specific hints - Frontend: tracking config event toggles for all 15 Planka events --- frontend/src/lib/grid-items.ts | 2 + frontend/src/lib/i18n/en.json | 20 + frontend/src/lib/i18n/ru.json | 20 + frontend/src/routes/providers/+page.svelte | 46 ++- .../src/routes/tracking-configs/+page.svelte | 24 ++ .../src/notify_bridge_core/models/events.py | 17 + .../src/notify_bridge_core/providers/base.py | 1 + .../providers/capabilities.py | 75 ++++ .../providers/planka/__init__.py | 19 + .../providers/planka/client.py | 101 +++++ .../providers/planka/event_parser.py | 373 ++++++++++++++++++ .../providers/planka/models.py | 164 ++++++++ .../providers/planka/provider.py | 236 +++++++++++ .../command_defaults/en/planka/boards.jinja2 | 7 + .../command_defaults/en/planka/cards.jinja2 | 7 + .../en/planka/desc_boards.jinja2 | 1 + .../en/planka/desc_cards.jinja2 | 1 + .../en/planka/desc_help.jinja2 | 1 + .../en/planka/desc_lists.jinja2 | 1 + .../en/planka/desc_status.jinja2 | 1 + .../command_defaults/en/planka/help.jinja2 | 4 + .../command_defaults/en/planka/lists.jinja2 | 7 + .../en/planka/no_results.jinja2 | 1 + .../en/planka/rate_limited.jinja2 | 1 + .../command_defaults/en/planka/start.jinja2 | 2 + .../command_defaults/en/planka/status.jinja2 | 3 + .../templates/command_defaults/loader.py | 8 + .../command_defaults/ru/planka/boards.jinja2 | 7 + .../command_defaults/ru/planka/cards.jinja2 | 7 + .../ru/planka/desc_boards.jinja2 | 1 + .../ru/planka/desc_cards.jinja2 | 1 + .../ru/planka/desc_help.jinja2 | 1 + .../ru/planka/desc_lists.jinja2 | 1 + .../ru/planka/desc_status.jinja2 | 1 + .../command_defaults/ru/planka/help.jinja2 | 4 + .../command_defaults/ru/planka/lists.jinja2 | 7 + .../ru/planka/no_results.jinja2 | 1 + .../ru/planka/rate_limited.jinja2 | 1 + .../command_defaults/ru/planka/start.jinja2 | 2 + .../command_defaults/ru/planka/status.jinja2 | 3 + .../en/planka_attachment_created.jinja2 | 5 + .../defaults/en/planka_board_created.jinja2 | 4 + .../defaults/en/planka_board_deleted.jinja2 | 1 + .../defaults/en/planka_board_updated.jinja2 | 4 + .../defaults/en/planka_card_commented.jinja2 | 8 + .../defaults/en/planka_card_created.jinja2 | 9 + .../defaults/en/planka_card_deleted.jinja2 | 2 + .../en/planka_card_label_added.jinja2 | 5 + .../defaults/en/planka_card_moved.jinja2 | 6 + .../defaults/en/planka_card_updated.jinja2 | 5 + .../defaults/en/planka_comment_updated.jinja2 | 8 + .../defaults/en/planka_list_created.jinja2 | 2 + .../defaults/en/planka_list_deleted.jinja2 | 2 + .../defaults/en/planka_list_updated.jinja2 | 2 + .../defaults/en/planka_task_completed.jinja2 | 6 + .../templates/defaults/loader.py | 17 + .../ru/planka_attachment_created.jinja2 | 5 + .../defaults/ru/planka_board_created.jinja2 | 4 + .../defaults/ru/planka_board_deleted.jinja2 | 1 + .../defaults/ru/planka_board_updated.jinja2 | 4 + .../defaults/ru/planka_card_commented.jinja2 | 8 + .../defaults/ru/planka_card_created.jinja2 | 9 + .../defaults/ru/planka_card_deleted.jinja2 | 2 + .../ru/planka_card_label_added.jinja2 | 5 + .../defaults/ru/planka_card_moved.jinja2 | 6 + .../defaults/ru/planka_card_updated.jinja2 | 5 + .../defaults/ru/planka_comment_updated.jinja2 | 8 + .../defaults/ru/planka_list_created.jinja2 | 2 + .../defaults/ru/planka_list_deleted.jinja2 | 2 + .../defaults/ru/planka_list_updated.jinja2 | 2 + .../defaults/ru/planka_task_completed.jinja2 | 6 + .../src/notify_bridge_server/api/providers.py | 54 ++- .../src/notify_bridge_server/api/webhooks.py | 113 ++++++ .../notify_bridge_server/commands/dispatch.py | 2 + .../commands/planka_handler.py | 193 +++++++++ .../notify_bridge_server/commands/registry.py | 2 + .../database/migrations.py | 26 ++ .../notify_bridge_server/database/models.py | 17 + .../notify_bridge_server/database/seeds.py | 33 ++ .../notify_bridge_server/services/__init__.py | 12 + .../services/dispatch_helpers.py | 16 + .../services/sample_context.py | 24 ++ .../notify_bridge_server/services/watcher.py | 3 + 83 files changed, 1827 insertions(+), 3 deletions(-) create mode 100644 packages/core/src/notify_bridge_core/providers/planka/__init__.py create mode 100644 packages/core/src/notify_bridge_core/providers/planka/client.py create mode 100644 packages/core/src/notify_bridge_core/providers/planka/event_parser.py create mode 100644 packages/core/src/notify_bridge_core/providers/planka/models.py create mode 100644 packages/core/src/notify_bridge_core/providers/planka/provider.py create mode 100644 packages/core/src/notify_bridge_core/templates/command_defaults/en/planka/boards.jinja2 create mode 100644 packages/core/src/notify_bridge_core/templates/command_defaults/en/planka/cards.jinja2 create mode 100644 packages/core/src/notify_bridge_core/templates/command_defaults/en/planka/desc_boards.jinja2 create mode 100644 packages/core/src/notify_bridge_core/templates/command_defaults/en/planka/desc_cards.jinja2 create mode 100644 packages/core/src/notify_bridge_core/templates/command_defaults/en/planka/desc_help.jinja2 create mode 100644 packages/core/src/notify_bridge_core/templates/command_defaults/en/planka/desc_lists.jinja2 create mode 100644 packages/core/src/notify_bridge_core/templates/command_defaults/en/planka/desc_status.jinja2 create mode 100644 packages/core/src/notify_bridge_core/templates/command_defaults/en/planka/help.jinja2 create mode 100644 packages/core/src/notify_bridge_core/templates/command_defaults/en/planka/lists.jinja2 create mode 100644 packages/core/src/notify_bridge_core/templates/command_defaults/en/planka/no_results.jinja2 create mode 100644 packages/core/src/notify_bridge_core/templates/command_defaults/en/planka/rate_limited.jinja2 create mode 100644 packages/core/src/notify_bridge_core/templates/command_defaults/en/planka/start.jinja2 create mode 100644 packages/core/src/notify_bridge_core/templates/command_defaults/en/planka/status.jinja2 create mode 100644 packages/core/src/notify_bridge_core/templates/command_defaults/ru/planka/boards.jinja2 create mode 100644 packages/core/src/notify_bridge_core/templates/command_defaults/ru/planka/cards.jinja2 create mode 100644 packages/core/src/notify_bridge_core/templates/command_defaults/ru/planka/desc_boards.jinja2 create mode 100644 packages/core/src/notify_bridge_core/templates/command_defaults/ru/planka/desc_cards.jinja2 create mode 100644 packages/core/src/notify_bridge_core/templates/command_defaults/ru/planka/desc_help.jinja2 create mode 100644 packages/core/src/notify_bridge_core/templates/command_defaults/ru/planka/desc_lists.jinja2 create mode 100644 packages/core/src/notify_bridge_core/templates/command_defaults/ru/planka/desc_status.jinja2 create mode 100644 packages/core/src/notify_bridge_core/templates/command_defaults/ru/planka/help.jinja2 create mode 100644 packages/core/src/notify_bridge_core/templates/command_defaults/ru/planka/lists.jinja2 create mode 100644 packages/core/src/notify_bridge_core/templates/command_defaults/ru/planka/no_results.jinja2 create mode 100644 packages/core/src/notify_bridge_core/templates/command_defaults/ru/planka/rate_limited.jinja2 create mode 100644 packages/core/src/notify_bridge_core/templates/command_defaults/ru/planka/start.jinja2 create mode 100644 packages/core/src/notify_bridge_core/templates/command_defaults/ru/planka/status.jinja2 create mode 100644 packages/core/src/notify_bridge_core/templates/defaults/en/planka_attachment_created.jinja2 create mode 100644 packages/core/src/notify_bridge_core/templates/defaults/en/planka_board_created.jinja2 create mode 100644 packages/core/src/notify_bridge_core/templates/defaults/en/planka_board_deleted.jinja2 create mode 100644 packages/core/src/notify_bridge_core/templates/defaults/en/planka_board_updated.jinja2 create mode 100644 packages/core/src/notify_bridge_core/templates/defaults/en/planka_card_commented.jinja2 create mode 100644 packages/core/src/notify_bridge_core/templates/defaults/en/planka_card_created.jinja2 create mode 100644 packages/core/src/notify_bridge_core/templates/defaults/en/planka_card_deleted.jinja2 create mode 100644 packages/core/src/notify_bridge_core/templates/defaults/en/planka_card_label_added.jinja2 create mode 100644 packages/core/src/notify_bridge_core/templates/defaults/en/planka_card_moved.jinja2 create mode 100644 packages/core/src/notify_bridge_core/templates/defaults/en/planka_card_updated.jinja2 create mode 100644 packages/core/src/notify_bridge_core/templates/defaults/en/planka_comment_updated.jinja2 create mode 100644 packages/core/src/notify_bridge_core/templates/defaults/en/planka_list_created.jinja2 create mode 100644 packages/core/src/notify_bridge_core/templates/defaults/en/planka_list_deleted.jinja2 create mode 100644 packages/core/src/notify_bridge_core/templates/defaults/en/planka_list_updated.jinja2 create mode 100644 packages/core/src/notify_bridge_core/templates/defaults/en/planka_task_completed.jinja2 create mode 100644 packages/core/src/notify_bridge_core/templates/defaults/ru/planka_attachment_created.jinja2 create mode 100644 packages/core/src/notify_bridge_core/templates/defaults/ru/planka_board_created.jinja2 create mode 100644 packages/core/src/notify_bridge_core/templates/defaults/ru/planka_board_deleted.jinja2 create mode 100644 packages/core/src/notify_bridge_core/templates/defaults/ru/planka_board_updated.jinja2 create mode 100644 packages/core/src/notify_bridge_core/templates/defaults/ru/planka_card_commented.jinja2 create mode 100644 packages/core/src/notify_bridge_core/templates/defaults/ru/planka_card_created.jinja2 create mode 100644 packages/core/src/notify_bridge_core/templates/defaults/ru/planka_card_deleted.jinja2 create mode 100644 packages/core/src/notify_bridge_core/templates/defaults/ru/planka_card_label_added.jinja2 create mode 100644 packages/core/src/notify_bridge_core/templates/defaults/ru/planka_card_moved.jinja2 create mode 100644 packages/core/src/notify_bridge_core/templates/defaults/ru/planka_card_updated.jinja2 create mode 100644 packages/core/src/notify_bridge_core/templates/defaults/ru/planka_comment_updated.jinja2 create mode 100644 packages/core/src/notify_bridge_core/templates/defaults/ru/planka_list_created.jinja2 create mode 100644 packages/core/src/notify_bridge_core/templates/defaults/ru/planka_list_deleted.jinja2 create mode 100644 packages/core/src/notify_bridge_core/templates/defaults/ru/planka_list_updated.jinja2 create mode 100644 packages/core/src/notify_bridge_core/templates/defaults/ru/planka_task_completed.jinja2 create mode 100644 packages/server/src/notify_bridge_server/commands/planka_handler.py diff --git a/frontend/src/lib/grid-items.ts b/frontend/src/lib/grid-items.ts index b6715be..9685646 100644 --- a/frontend/src/lib/grid-items.ts +++ b/frontend/src/lib/grid-items.ts @@ -101,6 +101,7 @@ export const providerTypeFilterItems = (): GridItem[] => [ { value: '', icon: 'mdiFilterOff', label: t('common.allTypes'), desc: t('gridDesc.allEvents') }, { value: 'immich', icon: 'mdiCamera', label: t('providers.typeImmich'), desc: t('gridDesc.providerImmich') }, { value: 'gitea', icon: 'mdiGit', label: t('providers.typeGitea'), desc: t('gridDesc.providerGitea') }, + { value: 'planka', icon: 'mdiViewDashboard', label: t('providers.typePlanka'), desc: t('gridDesc.providerPlanka') }, { value: 'scheduler', icon: 'mdiClockOutline', label: t('providers.typeScheduler'), desc: t('gridDesc.providerScheduler') }, ]; @@ -109,5 +110,6 @@ export const providerTypeFilterItems = (): GridItem[] => [ export const providerTypeItems = (): GridItem[] => [ { value: 'immich', icon: 'mdiCamera', label: t('providers.typeImmich'), desc: t('gridDesc.providerImmich') }, { value: 'gitea', icon: 'mdiGit', label: t('providers.typeGitea'), desc: t('gridDesc.providerGitea') }, + { value: 'planka', icon: 'mdiViewDashboard', label: t('providers.typePlanka'), desc: t('gridDesc.providerPlanka') }, { value: 'scheduler', icon: 'mdiClockOutline', label: t('providers.typeScheduler'), desc: t('gridDesc.providerScheduler') }, ]; diff --git a/frontend/src/lib/i18n/en.json b/frontend/src/lib/i18n/en.json index 9dd665f..79680b8 100644 --- a/frontend/src/lib/i18n/en.json +++ b/frontend/src/lib/i18n/en.json @@ -107,6 +107,7 @@ "checking": "Checking...", "typeImmich": "Immich", "typeGitea": "Gitea", + "typePlanka": "Planka", "typeScheduler": "Scheduler", "loadError": "Failed to load providers.", "externalDomain": "External Domain", @@ -116,6 +117,9 @@ "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.", + "plankaWebhookSecretHint": "Bearer token for webhook authentication. Set the same token as WEBHOOK_ACCESS_TOKEN in Planka.", + "plankaApiKeyHint": "Optional. Needed for connection testing and board listing.", + "plankaWebhookUrlHint": "Set this as the Webhook URL in Planka environment config (relative to your bridge host).", "webhookSecretRequired": "Webhook secret is required", "apiToken": "API Token", "apiTokenHint": "Optional. Needed for connection testing and repository listing.", @@ -378,6 +382,21 @@ "prMerged": "PR merged", "prCommented": "PR commented", "releasePublished": "Release published", + "cardCreated": "Card created", + "cardUpdated": "Card updated", + "cardMoved": "Card moved", + "cardDeleted": "Card deleted", + "cardCommented": "Card commented", + "commentUpdated": "Comment updated", + "boardCreated": "Board created", + "boardUpdated": "Board updated", + "boardDeleted": "Board deleted", + "listCreated": "List created", + "listUpdated": "List updated", + "listDeleted": "List deleted", + "attachmentCreated": "Attachment added", + "cardLabelAdded": "Label added", + "taskCompleted": "Task completed", "scheduledMessage": "Scheduled message", "trackImages": "Track images", "trackVideos": "Track videos", @@ -795,6 +814,7 @@ "previewWebhook": "Preview as plain text", "providerImmich": "Self-hosted photo server", "providerGitea": "Self-hosted Git service", + "providerPlanka": "Self-hosted Kanban board", "providerScheduler": "Time-based scheduled messages" }, "error": { diff --git a/frontend/src/lib/i18n/ru.json b/frontend/src/lib/i18n/ru.json index c62567c..1370524 100644 --- a/frontend/src/lib/i18n/ru.json +++ b/frontend/src/lib/i18n/ru.json @@ -107,6 +107,7 @@ "checking": "Проверка...", "typeImmich": "Immich", "typeGitea": "Gitea", + "typePlanka": "Planka", "typeScheduler": "Планировщик", "loadError": "Не удалось загрузить провайдеры.", "externalDomain": "Внешний домен", @@ -116,6 +117,9 @@ "webhookSecret": "Секрет вебхука", "webhookSecretKeep": "Секрет вебхука (оставьте пустым для сохранения текущего)", "webhookSecretHint": "Общий секрет для проверки HMAC-SHA256 подписи. Укажите тот же секрет в настройках вебхука Gitea.", + "plankaWebhookSecretHint": "Bearer-токен для аутентификации вебхуков. Укажите тот же токен как WEBHOOK_ACCESS_TOKEN в Planka.", + "plankaApiKeyHint": "Необязательно. Нужен для проверки подключения и получения списка досок.", + "plankaWebhookUrlHint": "Укажите этот URL в конфигурации Planka (относительно хоста bridge).", "webhookSecretRequired": "Секрет вебхука обязателен", "apiToken": "API токен", "apiTokenHint": "Необязательно. Нужен для проверки подключения и получения списка репозиториев.", @@ -378,6 +382,21 @@ "prMerged": "PR влит", "prCommented": "Комментарий к PR", "releasePublished": "Релиз опубликован", + "cardCreated": "Карточка создана", + "cardUpdated": "Карточка обновлена", + "cardMoved": "Карточка перемещена", + "cardDeleted": "Карточка удалена", + "cardCommented": "Комментарий к карточке", + "commentUpdated": "Комментарий обновлён", + "boardCreated": "Доска создана", + "boardUpdated": "Доска обновлена", + "boardDeleted": "Доска удалена", + "listCreated": "Список создан", + "listUpdated": "Список обновлён", + "listDeleted": "Список удалён", + "attachmentCreated": "Вложение добавлено", + "cardLabelAdded": "Метка добавлена", + "taskCompleted": "Задача завершена", "scheduledMessage": "Запланированное сообщение", "trackImages": "Фото", "trackVideos": "Видео", @@ -795,6 +814,7 @@ "previewWebhook": "Предпросмотр как текст", "providerImmich": "Фотосервер для самостоятельного размещения", "providerGitea": "Git-сервер для самостоятельного размещения", + "providerPlanka": "Канбан-доска для самостоятельного размещения", "providerScheduler": "Запланированные сообщения по расписанию" }, "error": { diff --git a/frontend/src/routes/providers/+page.svelte b/frontend/src/routes/providers/+page.svelte index f97763a..b004d43 100644 --- a/frontend/src/routes/providers/+page.svelte +++ b/frontend/src/routes/providers/+page.svelte @@ -26,12 +26,25 @@ let showForm = $state(false); let editing = $state(null); let form = $state({ name: 'Immich', type: 'immich', url: '', api_key: '', api_token: '', webhook_secret: '', external_domain: '', icon: '' }); + let nameManuallyEdited = $state(false); let error = $state(''); let loadError = $state(''); let submitting = $state(false); let loaded = $state(false); let confirmDelete = $state(null); + const providerDefaultNames: Record = { + immich: 'Immich', gitea: 'Gitea', planka: 'Planka', scheduler: 'Scheduler', + }; + + // Auto-update name when provider type changes (unless user manually edited) + $effect(() => { + const type = form.type; + if (!nameManuallyEdited && !editing) { + form.name = providerDefaultNames[type] || type; + } + }); + let health = $state>({}); onMount(load); @@ -53,6 +66,7 @@ function openNew() { form = { name: 'Immich', type: 'immich', url: '', api_key: '', api_token: '', webhook_secret: '', external_domain: '', icon: '' }; + nameManuallyEdited = false; editing = null; showForm = true; } function edit(p: any) { @@ -62,6 +76,7 @@ api_key: '', api_token: '', webhook_secret: '', external_domain: cfg.external_domain || '', icon: p.icon || '', }; + nameManuallyEdited = true; editing = p.id; showForm = true; } @@ -80,6 +95,13 @@ error = t('providers.webhookSecretRequired'); snackError(error); submitting = false; return; } + } else if (form.type === 'planka') { + if (form.api_key) config.api_key = form.api_key; + if (form.webhook_secret) config.webhook_secret = form.webhook_secret; + if (!editing && !form.webhook_secret) { + error = t('providers.webhookSecretRequired'); + snackError(error); submitting = false; return; + } } if (editing) { await api(`/providers/${editing}`, { method: 'PUT', body: JSON.stringify({ name: form.name, icon: form.icon, config }) }); @@ -141,13 +163,13 @@
form.icon = v} /> - + nameManuallyEdited = true} required class="flex-1 px-3 py-2 border border-[var(--color-border)] rounded-md text-sm bg-[var(--color-background)]" />
{#if form.type !== 'scheduler'}
- +
{/if} {#if form.type === 'immich'} @@ -177,6 +199,24 @@

{t('providers.webhookUrlHint')}

{/if} + {:else if form.type === 'planka'} +
+ + +

{t('providers.plankaWebhookSecretHint')}

+
+
+ + +

{t('providers.plankaApiKeyHint')}

+
+ {#if editing} +
+ + /api/webhooks/planka/{editing} +

{t('providers.plankaWebhookUrlHint')}

+
+ {/if} {/if}