# Telegram Notification Provider — Implementation Plan **Date:** 2026-07-10 **Author:** Planning Agent **Confidence Score:** 92% (High — existing patterns well-established, Telegram Bot API straightforward) --- ## 1. Introduction ### Objective Add Telegram as a first-class notification provider in Charon, following the established architecture used by Discord, Gotify, Email, and generic Webhook providers. ### Goals - Users can configure a Telegram bot token and chat ID to receive notifications via Telegram - All existing notification event types (proxy hosts, certs, uptime, security events) work with Telegram - JSON template engine (minimal/detailed/custom) works with Telegram - Feature flag allows enabling/disabling Telegram dispatch independently - Token is treated as a secret (write-only, never exposed in API responses) - Full test coverage: Go unit tests, Vitest frontend tests, Playwright E2E tests ### Telegram Bot API Overview Telegram bots send messages via: ``` POST https://api.telegram.org/bot/sendMessage Content-Type: application/json { "chat_id": "", "text": "Hello world", "parse_mode": "HTML" // optional: "HTML" or "MarkdownV2" } ``` **Key design decisions:** - **Token storage:** The bot token is stored in `NotificationProvider.Token` (`json:"-"`, encrypted at rest) — never in the URL field. This mirrors the Gotify pattern where secrets are separated from endpoints. - **URL field:** Stores only the `chat_id` (e.g., `987654321`). At dispatch time, the full API URL is constructed dynamically: `https://api.telegram.org/bot` + decryptedToken + `/sendMessage`. The `chat_id` is passed in the POST body alongside the message text. This prevents token leakage via API responses since URL is `json:"url"`. - **SSRF mitigation:** Before dispatching, validate that the constructed URL hostname is exactly `api.telegram.org`. This prevents SSRF if stored data is tampered with. - **Dispatch path:** Uses `sendJSONPayload` → `httpWrapper.Send()` (same as Gotify), since both are token-based JSON POST providers - **No schema migration needed:** The existing `NotificationProvider` model accommodates Telegram without changes > **Supervisor Review Note:** The original design embedded the bot token in the URL field (`https://api.telegram.org/bot/sendMessage?chat_id=`). This was rejected because the URL field is `json:"url"` — exposed in every API response. The token MUST only reside in the `Token` field (`json:"-"`). --- ## 2. Research Findings ### Existing Architecture The notification system follows a provider-based architecture: | Layer | File | Role | |-------|------|------| | Feature flags | `backend/internal/notifications/feature_flags.go` | Flag constants (`FlagXxxServiceEnabled`) | | Feature flag handler | `backend/internal/api/handlers/feature_flags_handler.go` | DB-backed flags with defaults | | Router | `backend/internal/notifications/router.go` | `ShouldUseNotify()` per-type dispatch | | Service | `backend/internal/services/notification_service.go` | Core dispatch: `isSupportedNotificationProviderType()`, `isDispatchEnabled()`, `supportsJSONTemplates()`, `sendJSONPayload()`, `TestProvider()` | | Handlers | `backend/internal/api/handlers/notification_provider_handler.go` | CRUD + type validation + token preservation | | Model | `backend/internal/models/notification_provider.go` | GORM model with Token (json:"-"), HasToken | | Frontend API | `frontend/src/api/notifications.ts` | `SUPPORTED_NOTIFICATION_PROVIDER_TYPES`, sanitization | | Frontend UI | `frontend/src/pages/Notifications.tsx` | Provider form with conditional fields per type | | i18n | `frontend/src/locales/en/translation.json` | Label strings | | E2E fixtures | `tests/fixtures/notifications.ts` | `telegramProvider` **already defined** | ### Existing Provider Addition Points (Switch Statements / Type Checks) Every location that checks provider types is listed below — all require a `"telegram"` case: | # | File | Function/Line | Current Logic | |---|------|---------------|---------------| | 1 | `feature_flags.go` | Constants | Missing `FlagTelegramServiceEnabled` | | 2 | `feature_flags_handler.go` | `defaultFlags` + `defaultFlagValues` | Missing telegram entry | | 3 | `router.go` | `ShouldUseNotify()` switch | Missing `case "telegram"` | | 4 | `notification_service.go` | `isSupportedNotificationProviderType()` | `case "discord", "email", "gotify", "webhook"` | | 5 | `notification_service.go` | `isDispatchEnabled()` | switch with per-type flag checks | | 6 | `notification_service.go` | `supportsJSONTemplates()` | `case "webhook", "discord", "gotify", "slack", "generic"` | | 7 | `notification_service.go` | `sendJSONPayload()` — service-specific validation | Missing `case "telegram"` for payload validation | | 8 | `notification_service.go` | `sendJSONPayload()` — dispatch branch | Gotify/webhook use `httpWrapper.Send()`; others use `ValidateExternalURL` + `SafeHTTPClient` | | 9 | `notification_provider_handler.go` | `Create()` type guard | `providerType != "discord" && providerType != "gotify" && providerType != "webhook" && providerType != "email"` | | 10 | `notification_provider_handler.go` | `Update()` type guard | Same pattern as Create | | 11 | `notification_provider_handler.go` | `Update()` token preservation | `if providerType == "gotify" && strings.TrimSpace(req.Token) == ""` | | 12 | `notifications.ts` | `SUPPORTED_NOTIFICATION_PROVIDER_TYPES` | `['discord', 'gotify', 'webhook', 'email']` | | 13 | `notifications.ts` | `sanitizeProviderForWriteAction()` | Token handling only for `type !== 'gotify'` | | 14 | `Notifications.tsx` | Type ` {initialData?.has_token && (

{t('notificationProviders.gotifyTokenStored')}

)}

{t('notificationProviders.gotifyTokenWriteOnlyHint')}

)} ``` #### URL Field Placeholder For Telegram, the URL field stores the chat_id (not a full URL). Update the placeholder and label accordingly: ```typescript placeholder={ isEmail ? 'user@example.com, admin@example.com' : type === 'discord' ? 'https://discord.com/api/webhooks/...' : type === 'gotify' ? 'https://gotify.example.com/message' : isTelegram ? '987654321' : 'https://example.com/webhook' } ``` Update the label for the URL field when type is telegram: ```typescript label={isTelegram ? t('notificationProviders.telegramChatId') : t('notificationProviders.url')} ``` #### Clear Token on Type Change Update the existing `useEffect` that clears `gotify_token`: ```typescript useEffect(() => { if (type !== 'gotify' && type !== 'telegram') { setValue('gotify_token', '', { shouldDirty: false, shouldTouch: false }); } }, [type, setValue]); ``` ### 3.8 Frontend — i18n Strings **File:** `frontend/src/locales/en/translation.json` Add to the `notificationProviders` section: ```json "telegram": "Telegram", "telegramBotToken": "Bot Token", "telegramBotTokenPlaceholder": "Enter your Telegram Bot Token", "telegramChatId": "Chat ID", "telegramChatIdPlaceholder": "987654321", "telegramChatIdHelp": "Your Telegram chat, group, or channel ID. The bot token is stored securely and separately." ``` ### 3.9 API Contract (No Changes) The existing REST endpoints remain unchanged: | Method | Endpoint | Notes | |--------|----------|-------| | `GET` | `/api/notification-providers` | Returns all providers (token stripped) | | `POST` | `/api/notification-providers` | Create — now accepts `type: "telegram"` | | `PUT` | `/api/notification-providers/:id` | Update — token preserved if omitted | | `DELETE` | `/api/notification-providers/:id` | Delete — no type-specific logic | | `POST` | `/api/notification-providers/test` | Test — routes through `sendJSONPayload` | Request/response schemas are unchanged. The `type` field now accepts `"telegram"` in addition to existing values. --- ## 4. Implementation Plan ### Phase 1: Playwright E2E Tests (Test-First) **Rationale:** Per project conventions — write feature behaviour tests first. **New file:** `tests/settings/telegram-notification-provider.spec.ts` Modeled after `tests/settings/email-notification-provider.spec.ts`. Test scenarios: 1. Create a Telegram provider (name, chat_id in URL field, bot token in token field, enable events) 2. Verify provider appears in the list 3. Edit the Telegram provider (change name, verify token preservation) 4. Test the Telegram provider (mock API returns 200) 5. Delete the Telegram provider 6. **Negative security test:** Verify `GET /api/notification-providers` does NOT expose the bot token in any response field 7. **Negative security test:** Verify bot token is NOT present in the URL field of the API response **Update file:** `tests/settings/notifications-payload.spec.ts` Add telegram to the payload matrix test scenarios. **E2E fixtures:** Update `telegramProvider` in `tests/fixtures/notifications.ts` — URL must contain only `chat_id`, token goes in the `token` field (see Research Findings section for updated fixture). ### Phase 2: Backend Implementation **2A — Feature Flags (3 files)** | File | Change | |------|--------| | `backend/internal/notifications/feature_flags.go` | Add `FlagTelegramServiceEnabled` constant | | `backend/internal/api/handlers/feature_flags_handler.go` | Add to `defaultFlags` + `defaultFlagValues` | | `backend/internal/notifications/router.go` | Add `case "telegram"` to `ShouldUseNotify()` | **2B — Service Layer (1 file, 4 function changes)** | File | Function | Change | |------|----------|--------| | `notification_service.go` | `isSupportedNotificationProviderType()` | Add `"telegram"` to case | | `notification_service.go` | `isDispatchEnabled()` | Add `case "telegram"` with flag check | | `notification_service.go` | `supportsJSONTemplates()` | Add `"telegram"` to case | | `notification_service.go` | `sendJSONPayload()` | Add telegram validation + dispatch branch | **2C — Handler Layer (1 file, 3 locations)** | File | Location | Change | |------|----------|--------| | `notification_provider_handler.go` | `Create()` type guard | Add `&& providerType != "telegram"` | | `notification_provider_handler.go` | `Update()` type guard | Same | | `notification_provider_handler.go` | `Update()` token preservation | Add `|| providerType == "telegram"` | ### Phase 3: Frontend Implementation **3A — API Client (1 file)** | File | Change | |------|--------| | `frontend/src/api/notifications.ts` | Add `'telegram'` to `SUPPORTED_NOTIFICATION_PROVIDER_TYPES`, update token sanitization logic | **3B — Notifications Page (1 file)** | File | Change | |------|--------| | `frontend/src/pages/Notifications.tsx` | Add telegram to type select, token field conditional, URL placeholder, `normalizeProviderPayloadForSubmit()`, type-change useEffect | **3C — Localization (1 file)** | File | Change | |------|--------| | `frontend/src/locales/en/translation.json` | Add telegram-specific label strings | ### Phase 4: Backend Tests | Test File | Changes | |-----------|---------| | `notification_service_test.go` | Update "rejects unsupported provider" test (remove telegram from unsupported list). Add telegram dispatch/integration tests. | | `notification_service_json_test.go` | Add `TestSendJSONPayload_Telegram_*` tests: valid payload, missing text with message auto-map, missing both text and message, dispatch via httpWrapper, **SSRF hostname validation**, **401/403 error message** | | `notification_provider_handler_test.go` | Add telegram to Create/Update happy path tests, token preservation test. **Add negative test: verify GET response does not contain bot token in URL field or response body** | | `enhanced_security_notification_service_test.go` | Change telegram from "filtered" to "valid provider" in security dispatch tests | | Router test (if exists) | Add telegram to `ShouldUseNotify()` tests | ### Phase 5: Frontend Tests | Test File | Changes | |-----------|---------| | `frontend/src/api/notifications.test.ts` | Remove telegram rejection test, add telegram CRUD sanitization tests | | `frontend/src/api/__tests__/notifications.test.ts` | Same changes (duplicate test location) | | `frontend/src/pages/Notifications.test.tsx` | Add telegram form rendering tests (token field visibility, placeholder text) | ### Phase 6: Integration, Documentation & Deployment - Verify E2E tests pass with Docker container - Update `docs/features.md` with Telegram provider mention - No `ARCHITECTURE.md` changes needed (same provider pattern) - No database migration needed --- ## 5. Acceptance Criteria ### EARS Requirements | ID | Requirement | |----|-------------| | T-01 | WHEN a user creates a notification provider with type "telegram", THE SYSTEM SHALL accept the provider and store it in the database | | T-02 | WHEN a user provides a bot token for a Telegram provider, THE SYSTEM SHALL store it securely and never expose it in API responses | | T-03 | WHEN a Telegram provider is enabled and a notification event fires, THE SYSTEM SHALL construct the Telegram API URL dynamically from the stored token (`https://api.telegram.org/bot` + token + `/sendMessage`), inject `chat_id` from the URL field into the POST body, and send the rendered template payload | | T-04 | WHEN the rendered JSON payload contains a "message" field but not a "text" field, THE SYSTEM SHALL auto-map "message" to "text" for Telegram compatibility | | T-05 | WHEN the Telegram feature flag is disabled, THE SYSTEM SHALL skip dispatch for all Telegram providers | | T-06 | WHEN a user updates a Telegram provider without providing a token, THE SYSTEM SHALL preserve the existing stored token | | T-07 | WHEN a user tests a Telegram provider, THE SYSTEM SHALL send a test notification through the standard sendJSONPayload path | | T-08 | WHEN the frontend renders the provider form with type "telegram", THE SYSTEM SHALL display a bot token input field and a chat_id input field (with appropriate placeholder) | | T-09 | WHEN dispatching a Telegram notification, THE SYSTEM SHALL validate that the constructed URL hostname is exactly `api.telegram.org` before sending (SSRF mitigation) | | T-10 | WHEN a Telegram test request receives HTTP 401 or 403, THE SYSTEM SHALL return the error message "Provider rejected authentication. Verify your Telegram Bot Token" | | T-11 | WHEN the API returns notification providers via GET, THE SYSTEM SHALL NOT include the bot token in the URL field or any other exposed response field | ### Definition of Done - [ ] All 16 code touchpoints updated (see section 2 table) - [ ] E2E Playwright tests pass for Telegram CRUD + test send - [ ] Backend unit tests cover: type registration, dispatch routing, payload validation (text field), token preservation, feature flag gating - [ ] Frontend unit tests cover: type array acceptance, sanitization, form rendering - [ ] `go test ./...` passes - [ ] `npm test` passes - [ ] `npx playwright test --project=firefox` passes - [ ] `make lint-fast` passes (staticcheck) - [ ] Coverage threshold maintained (85%+) - [ ] GORM security scan passes (no model changes, but verify) - [ ] Token never appears in API responses, logs, or frontend state - [ ] Negative security tests pass (bot token not in GET response body or URL field) - [ ] SSRF hostname validation test passes (only `api.telegram.org` allowed) - [ ] Telegram 401/403 returns specific auth error message --- ## 6. Commit Slicing Strategy ### Decision: 2 PRs **Trigger reasons:** Changes span backend + frontend + E2E tests with independent functionality per layer. Splitting improves review quality and rollback safety. ### PR-1: Backend — Telegram Provider Support **Scope:** Feature flags, service layer, handler layer, all Go unit tests **Files changed:** - `backend/internal/notifications/feature_flags.go` - `backend/internal/api/handlers/feature_flags_handler.go` - `backend/internal/notifications/router.go` - `backend/internal/services/notification_service.go` - `backend/internal/api/handlers/notification_provider_handler.go` - `backend/internal/services/notification_service_test.go` - `backend/internal/services/notification_service_json_test.go` - `backend/internal/api/handlers/notification_provider_handler_test.go` - `backend/internal/services/enhanced_security_notification_service_test.go` **Dependencies:** None (self-contained backend change) **Validation gates:** - `go test ./...` passes - `make lint-fast` passes - Coverage ≥ 85% - GORM security scan passes **Rollback:** Revert PR — no DB migration to undo. ### PR-2: Frontend + E2E — Telegram Provider UI **Scope:** Frontend API client, Notifications page, i18n strings, frontend unit tests, Playwright E2E tests **Files changed:** - `frontend/src/api/notifications.ts` - `frontend/src/pages/Notifications.tsx` - `frontend/src/locales/en/translation.json` - `frontend/src/api/notifications.test.ts` - `frontend/src/api/__tests__/notifications.test.ts` - `frontend/src/pages/Notifications.test.tsx` - `tests/settings/telegram-notification-provider.spec.ts` (new) - `tests/settings/notifications-payload.spec.ts` **Dependencies:** PR-1 must be merged first (backend must accept `type: "telegram"`) **Validation gates:** - `npm test` passes - `npm run type-check` passes - `npx playwright test --project=firefox` passes - Coverage ≥ 85% **Rollback:** Revert PR — frontend-only, no cascading effects. --- ## 7. Risk Assessment | Risk | Likelihood | Impact | Mitigation | |------|-----------|--------|------------| | Telegram API rate limiting | Low | Medium | Use existing retry/timeout patterns from httpWrapper | | Bot token exposure in responses/logs | Low | Critical | Token stored ONLY in `Token` field (`json:"-"`), never in URL field. URL field contains only `chat_id`. Negative security tests verify this invariant. | | Template auto-mapping edge cases | Low | Low | Test with all three template types (minimal, detailed, custom) | | URL validation rejects chat_id format | Low | Low | URL field now stores a chat_id string (not a full URL). Validation may need adjustment to accept non-URL values for telegram type. | | SSRF via tampered stored data | Low | High | Dispatch-time validation ensures hostname is exactly `api.telegram.org`. Dedicated test covers this. | | E2E test flakiness with mocked API | Low | Low | Existing route-mocking patterns are stable | --- ## 8. Complexity Estimates | Component | Estimate | Notes | |-----------|----------|-------| | Backend feature flags | S | 3 files, ~5 lines each | | Backend service layer | M | 4 function changes + telegram validation block | | Backend handler layer | S | 3 string-level changes | | Frontend API client | S | 2 lines + sanitization tweak | | Frontend UI | M | Template conditional, placeholder, useEffect updates | | Frontend i18n | S | 4 strings | | Backend tests | L | Multiple test files, new test functions, update existing assertions | | Frontend tests | M | Update rejection tests, add rendering tests | | E2E tests | M | New spec file modeled on existing email spec | | **Total** | **M-L** | ~2-3 days of focused implementation |