- Added aria-label attributes to buttons in Notifications component for better accessibility. - Updated Notifications tests to use new button interactions and ensure proper functionality. - Refactored notifications payload tests to mock API responses and validate payload transformations. - Improved error handling and feedback in notification provider tests. - Adjusted Telegram notification provider tests to streamline edit interactions.
687 lines
29 KiB
Markdown
687 lines
29 KiB
Markdown
# 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<BOT_TOKEN>/sendMessage
|
|
Content-Type: application/json
|
|
|
|
{
|
|
"chat_id": "<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<TOKEN>/sendMessage?chat_id=<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 `<select>` options | discord/gotify/webhook/email |
|
|
| 15 | `Notifications.tsx` | `normalizeProviderPayloadForSubmit()` | Token mapping only for `type === 'gotify'` |
|
|
| 16 | `Notifications.tsx` | Conditional form fields | `isGotify` shows token input |
|
|
|
|
### Test Files Requiring Updates
|
|
|
|
| File | Current Behavior | Required Change |
|
|
|------|-----------------|-----------------|
|
|
| `notification_service_test.go` (~L1819) | `TestTestProvider_NotifyOnlyRejectsUnsupportedProvider` tests `"telegram"` as **unsupported** | Change: telegram is now supported |
|
|
| `notification_service_json_test.go` | Discord/Slack/Gotify/Webhook JSON tests | Add telegram payload validation tests |
|
|
| `notification_provider_handler_test.go` | CRUD tests with supported types | Add telegram to supported type lists |
|
|
| `enhanced_security_notification_service_test.go` (~L139) | `Type: "telegram"` marked `// Should be filtered` | Change: telegram is now valid |
|
|
| `frontend/src/api/notifications.test.ts` | Rejects `"telegram"` as unsupported | Accept telegram, add CRUD tests |
|
|
| `frontend/src/api/__tests__/notifications.test.ts` | Same rejection | Same fix |
|
|
| `tests/settings/notifications.spec.ts` | CRUD E2E for discord/gotify/webhook/email | Add telegram scenarios |
|
|
|
|
### E2E Fixture Already Defined
|
|
|
|
```typescript
|
|
// tests/fixtures/notifications.ts
|
|
// NOTE: Fixture must be updated — URL should contain only the chat_id, token goes in the token field
|
|
export const telegramProvider: NotificationProviderConfig = {
|
|
name: generateProviderName('telegram'),
|
|
type: 'telegram',
|
|
url: '987654321', // chat_id only — bot token is stored in the Token field
|
|
token: 'bot123456789:ABCdefGHIjklMNOpqrSTUvwxYZ', // stored encrypted, never in API responses
|
|
enabled: true,
|
|
notify_proxy_hosts: true,
|
|
notify_certs: true,
|
|
notify_uptime: true,
|
|
};
|
|
```
|
|
|
|
---
|
|
|
|
## 3. Technical Specifications
|
|
|
|
### 3.1 Backend — Feature Flags
|
|
|
|
**File:** `backend/internal/notifications/feature_flags.go`
|
|
|
|
Add constant:
|
|
|
|
```go
|
|
FlagTelegramServiceEnabled = "feature.notifications.service.telegram.enabled"
|
|
```
|
|
|
|
**File:** `backend/internal/api/handlers/feature_flags_handler.go`
|
|
|
|
Add to `defaultFlags` slice:
|
|
|
|
```go
|
|
notifications.FlagTelegramServiceEnabled,
|
|
```
|
|
|
|
Add to `defaultFlagValues` map:
|
|
|
|
```go
|
|
notifications.FlagTelegramServiceEnabled: true,
|
|
```
|
|
|
|
> **Note:** Telegram is **enabled by default** once the provider is toggled on in the UI, matching Gotify/Webhook behavior. The feature flag exists as an admin-level kill switch, not a setup gate.
|
|
|
|
### 3.2 Backend — Router
|
|
|
|
**File:** `backend/internal/notifications/router.go`
|
|
|
|
Add to `ShouldUseNotify()` switch:
|
|
|
|
```go
|
|
case "telegram":
|
|
return flags[FlagTelegramServiceEnabled]
|
|
```
|
|
|
|
### 3.3 Backend — Notification Service
|
|
|
|
**File:** `backend/internal/services/notification_service.go`
|
|
|
|
#### `isSupportedNotificationProviderType()`
|
|
|
|
```go
|
|
case "discord", "email", "gotify", "webhook", "telegram":
|
|
return true
|
|
```
|
|
|
|
#### `isDispatchEnabled()`
|
|
|
|
```go
|
|
case "telegram":
|
|
return s.getFeatureFlagValue(notifications.FlagTelegramServiceEnabled, true)
|
|
```
|
|
|
|
Both `defaultFlagValues` (initial DB seed) and the `isDispatchEnabled()` fallback are `true` — Telegram is enabled by default once the provider is created in the UI. This matches Gotify/Webhook behavior (enabled-by-default, admin kill-switch via feature flag).
|
|
|
|
#### `supportsJSONTemplates()`
|
|
|
|
```go
|
|
case "webhook", "discord", "gotify", "slack", "generic", "telegram":
|
|
return true
|
|
```
|
|
|
|
#### `sendJSONPayload()` — Service-Specific Validation
|
|
|
|
Add after the `case "gotify":` block:
|
|
|
|
```go
|
|
case "telegram":
|
|
// Telegram requires 'text' field for the message body
|
|
if _, hasText := jsonPayload["text"]; !hasText {
|
|
// Auto-map 'message' to 'text' if present (template compatibility)
|
|
if messageValue, hasMessage := jsonPayload["message"]; hasMessage {
|
|
jsonPayload["text"] = messageValue
|
|
normalizedBody, marshalErr := json.Marshal(jsonPayload)
|
|
if marshalErr != nil {
|
|
return fmt.Errorf("failed to normalize telegram payload: %w", marshalErr)
|
|
}
|
|
body.Reset()
|
|
if _, writeErr := body.Write(normalizedBody); writeErr != nil {
|
|
return fmt.Errorf("failed to write normalized telegram payload: %w", writeErr)
|
|
}
|
|
} else {
|
|
return fmt.Errorf("telegram payload requires 'text' field")
|
|
}
|
|
}
|
|
```
|
|
|
|
This auto-mapping mirrors the Discord pattern (`message` → `content`) so that the built-in `minimal` and `detailed` templates (which use `"message"` as a field) work out of the box with Telegram.
|
|
|
|
#### `sendJSONPayload()` — Dispatch Branch
|
|
|
|
Add `"telegram"` to the `httpWrapper.Send()` dispatch branch alongside gotify/webhook:
|
|
|
|
```go
|
|
if providerType == "gotify" || providerType == "webhook" || providerType == "telegram" {
|
|
```
|
|
|
|
For telegram, the dispatch URL must be **constructed at send time** from the stored token and chat_id:
|
|
|
|
```go
|
|
case "telegram":
|
|
// Construct the API URL dynamically — token is NEVER stored in the URL field
|
|
decryptedToken := provider.Token // already decrypted by service layer
|
|
dispatchURL = "https://api.telegram.org/bot" + decryptedToken + "/sendMessage"
|
|
|
|
// SSRF mitigation: validate hostname before dispatch
|
|
parsedURL, err := url.Parse(dispatchURL)
|
|
if err != nil || parsedURL.Hostname() != "api.telegram.org" {
|
|
return fmt.Errorf("telegram dispatch URL validation failed: invalid hostname")
|
|
}
|
|
|
|
// Inject chat_id into the JSON payload body (URL field stores the chat_id)
|
|
jsonPayload["chat_id"] = provider.URL
|
|
// Re-marshal the payload with chat_id included
|
|
updatedBody, marshalErr := json.Marshal(jsonPayload)
|
|
if marshalErr != nil {
|
|
return fmt.Errorf("failed to marshal telegram payload with chat_id: %w", marshalErr)
|
|
}
|
|
body.Reset()
|
|
body.Write(updatedBody)
|
|
```
|
|
|
|
The `X-Gotify-Key` header is only set when `providerType == "gotify"` — no header changes needed for telegram.
|
|
|
|
#### `TestProvider()` — Telegram-Specific Error Message
|
|
|
|
When testing a Telegram provider and the API returns HTTP 401 or 403, return a specific error message:
|
|
|
|
```go
|
|
case "telegram":
|
|
if resp.StatusCode == http.StatusUnauthorized || resp.StatusCode == http.StatusForbidden {
|
|
return fmt.Errorf("provider rejected authentication. Verify your Telegram Bot Token")
|
|
}
|
|
```
|
|
|
|
This gives users actionable guidance instead of a generic HTTP status error.
|
|
|
|
### 3.4 Backend — Handler Layer
|
|
|
|
**File:** `backend/internal/api/handlers/notification_provider_handler.go`
|
|
|
|
#### `Create()` Type Guard
|
|
|
|
```go
|
|
if providerType != "discord" && providerType != "gotify" && providerType != "webhook" && providerType != "email" && providerType != "telegram" {
|
|
```
|
|
|
|
#### `Update()` Type Guard
|
|
|
|
Same change as Create:
|
|
|
|
```go
|
|
if providerType != "discord" && providerType != "gotify" && providerType != "webhook" && providerType != "email" && providerType != "telegram" {
|
|
```
|
|
|
|
#### `Update()` Token Preservation
|
|
|
|
Telegram bot tokens should be preserved on update when the user omits them (same UX as Gotify):
|
|
|
|
```go
|
|
if (providerType == "gotify" || providerType == "telegram") && strings.TrimSpace(req.Token) == "" {
|
|
req.Token = existing.Token
|
|
}
|
|
```
|
|
|
|
### 3.5 Backend — Model (No Changes)
|
|
|
|
The `NotificationProvider` model already has:
|
|
|
|
- `Token string` with `json:"-"` (write-only, never exposed)
|
|
- `HasToken bool` with `gorm:"-"` (computed field for frontend)
|
|
- `URL string` for the endpoint
|
|
- `ServiceConfig string` for extra JSON config (available for `parse_mode` if needed)
|
|
|
|
No schema migration is required.
|
|
|
|
### 3.6 Frontend — API Client
|
|
|
|
**File:** `frontend/src/api/notifications.ts`
|
|
|
|
#### Supported Types Array
|
|
|
|
```typescript
|
|
export const SUPPORTED_NOTIFICATION_PROVIDER_TYPES = ['discord', 'gotify', 'webhook', 'email', 'telegram'] as const;
|
|
```
|
|
|
|
#### `sanitizeProviderForWriteAction()`
|
|
|
|
**Minimal diff only.** Change only the type guard condition from:
|
|
|
|
```typescript
|
|
if (type !== 'gotify') {
|
|
```
|
|
|
|
to:
|
|
|
|
```typescript
|
|
if (type !== 'gotify' && type !== 'telegram') {
|
|
```
|
|
|
|
The surrounding normalization logic (token stripping, payload return) MUST remain untouched. No other lines in this function change.
|
|
|
|
#### `sanitizeProviderForReadLikeAction()`
|
|
|
|
No changes — already calls `sanitizeProviderForWriteAction()` then strips token.
|
|
|
|
### 3.7 Frontend — Notifications Page
|
|
|
|
**File:** `frontend/src/pages/Notifications.tsx`
|
|
|
|
#### Type Select Options
|
|
|
|
Add after the email option:
|
|
|
|
```tsx
|
|
<option value="telegram">Telegram</option>
|
|
```
|
|
|
|
#### Computed Flags
|
|
|
|
```typescript
|
|
const isTelegram = type === 'telegram';
|
|
```
|
|
|
|
#### `normalizeProviderPayloadForSubmit()`
|
|
|
|
Add telegram branch alongside gotify:
|
|
|
|
```typescript
|
|
if (type === 'gotify' || type === 'telegram') {
|
|
const normalizedToken = typeof payload.gotify_token === 'string' ? payload.gotify_token.trim() : '';
|
|
if (normalizedToken.length > 0) {
|
|
payload.token = normalizedToken;
|
|
} else {
|
|
delete payload.token;
|
|
}
|
|
} else {
|
|
delete payload.token;
|
|
}
|
|
```
|
|
|
|
Note: Reuses the `gotify_token` form field for both Gotify and Telegram since both need a token input. This minimizes UI changes. The field label changes based on provider type.
|
|
|
|
#### Token Input Field
|
|
|
|
Expand the conditional from `{isGotify && (` to `{(isGotify || isTelegram) && (`:
|
|
|
|
```tsx
|
|
{(isGotify || isTelegram) && (
|
|
<div>
|
|
<label htmlFor="provider-gotify-token" className="block text-sm font-medium text-gray-700 dark:text-gray-300">
|
|
{isTelegram ? t('notificationProviders.telegramBotToken') : t('notificationProviders.gotifyToken')}
|
|
</label>
|
|
<input
|
|
id="provider-gotify-token"
|
|
type="password"
|
|
autoComplete="new-password"
|
|
{...register('gotify_token')}
|
|
data-testid="provider-gotify-token"
|
|
placeholder={initialData?.has_token
|
|
? t('notificationProviders.gotifyTokenKeepPlaceholder')
|
|
: isTelegram
|
|
? t('notificationProviders.telegramBotTokenPlaceholder')
|
|
: t('notificationProviders.gotifyTokenPlaceholder')}
|
|
className="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 dark:bg-gray-700 dark:border-gray-600 dark:text-white sm:text-sm"
|
|
aria-describedby={initialData?.has_token ? 'gotify-token-stored-hint' : undefined}
|
|
/>
|
|
{initialData?.has_token && (
|
|
<p id="gotify-token-stored-hint" data-testid="gotify-token-stored-indicator" className="text-xs text-green-600 dark:text-green-400 mt-1">
|
|
{t('notificationProviders.gotifyTokenStored')}
|
|
</p>
|
|
)}
|
|
<p className="text-xs text-gray-500 mt-1">{t('notificationProviders.gotifyTokenWriteOnlyHint')}</p>
|
|
</div>
|
|
)}
|
|
```
|
|
|
|
#### 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 |
|