# Notification Channels

Real-time outbound alerts to Slack, Discord, and Telegram via webhook delivery.

## Overview

`vapora-channels` provides a trait-based webhook notification layer. When VAPORA events occur (task completion, proposal decisions, workflow lifecycle), configured channels receive a message immediately — no polling required.

**Key properties**:

- No vendor SDKs — plain HTTP POST to webhook URLs
- Secret tokens resolved from environment variables at startup; a raw `${VAR}` placeholder never reaches the HTTP layer
- Fire-and-forget delivery: channel failures never surface as API errors

## Configuration

All channel configuration lives in `vapora.toml`.

### Declaring channels

```toml
[channels.team-slack]
type = "slack"
webhook_url = "${SLACK_WEBHOOK_URL}"

[channels.ops-discord]
type = "discord"
webhook_url = "${DISCORD_WEBHOOK_URL}"

[channels.alerts-telegram]
type = "telegram"
bot_token = "${TELEGRAM_BOT_TOKEN}"
chat_id   = "${TELEGRAM_CHAT_ID}"
```

Channel names (`team-slack`, `ops-discord`, `alerts-telegram`) are arbitrary identifiers used in event routing below.

### Routing events to channels

```toml
[notifications]
on_task_done         = ["team-slack"]
on_proposal_approved = ["team-slack", "ops-discord"]
on_proposal_rejected = ["ops-discord"]
on_agent_inactive    = ["ops-telegram"]
```

Each key is an event name; the value is a list of channel names declared in `[channels.*]`. An empty list or absent key means no notification for that event.

### Workflow lifecycle notifications

Per-workflow notification targets are set in the workflow template:

```toml
[[workflows]]
name = "nightly_analysis"
trigger = "schedule"

[workflows.nightly_analysis.notifications]
on_stage_complete = ["team-slack"]
on_stage_failed   = ["team-slack", "ops-discord"]
on_completed      = ["team-slack"]
on_cancelled      = ["ops-discord"]
```

## Secret Resolution

Token values in `[channels.*]` blocks are interpolated from the environment before any network call is made. Two syntaxes are supported:

| Syntax | Behaviour |
|--------|-----------|
| `"${VAR}"` | Replaced with `$VAR`; startup fails if the variable is unset |
| `"${VAR:-default}"` | Replaced with `$VAR` if set, otherwise `default` |

Resolution happens inside `ChannelRegistry::from_config` — the single mandatory call site. There is no way to construct a registry with an unresolved placeholder.

**Example**:

```bash
export SLACK_WEBHOOK_URL="https://hooks.slack.com/services/T.../..."
export DISCORD_WEBHOOK_URL="https://discord.com/api/webhooks/..."
export TELEGRAM_BOT_TOKEN="123456:ABC..."
export TELEGRAM_CHAT_ID="-1001234567890"
```

If a required variable is absent and no default is provided, VAPORA exits at startup with:

```text
Error: Secret reference '${SLACK_WEBHOOK_URL}' not resolved: env var not set and no default provided
```

## Supported Channel Types

### Slack

Uses the [Incoming Webhooks](https://api.slack.com/messaging/webhooks) API. The webhook URL is obtained from Slack's app configuration.

```toml
[channels.my-slack]
type        = "slack"
webhook_url = "${SLACK_WEBHOOK_URL}"
```

Payload format: `{ "text": "**Title**\nBody" }`. No SDK dependency.

### Discord

Uses the [Discord Webhook](https://discord.com/developers/docs/resources/webhook) endpoint. The webhook URL includes the token — obtain it from the channel's Integrations settings.

```toml
[channels.my-discord]
type        = "discord"
webhook_url = "${DISCORD_WEBHOOK_URL}"
```

Payload format: `{ "embeds": [{ "title": "...", "description": "...", "color": <level-color> }] }`.

### Telegram

Uses the [Bot API](https://core.telegram.org/bots/api#sendmessage) `sendMessage` endpoint. Requires a bot token from `@BotFather` and the numeric chat ID of the target group or channel.

```toml
[channels.my-telegram]
type      = "telegram"
bot_token = "${TELEGRAM_BOT_TOKEN}"
chat_id   = "${TELEGRAM_CHAT_ID}"
```

Payload format: `{ "chat_id": "...", "text": "**Title**\nBody", "parse_mode": "Markdown" }`.

## Message Levels

Every notification carries a level that controls colour and emoji in the rendered message:

| Level | Constructor | Use case |
|-------|-------------|----------|
| `Info` | `Message::info(title, body)` | General status updates |
| `Success` | `Message::success(title, body)` | Task done, workflow completed |
| `Warning` | `Message::warning(title, body)` | Proposal rejected, stage failed |
| `Error` | `Message::error(title, body)` | Unrecoverable failure |

## REST API

Two endpoints are available under `/api/v1/channels`:

### List channels

```http
GET /api/v1/channels
```

Returns the names of all registered channels (sorted alphabetically). Returns an empty list when no channels are configured.

**Response**:

```json
{
  "channels": ["ops-discord", "team-slack"]
}
```

### Test a channel

```http
POST /api/v1/channels/:name/test
```

Sends a connectivity test message to the named channel and returns synchronously.

| Status | Meaning |
|--------|---------|
| `200 OK` | Message delivered successfully |
| `404 Not Found` | Channel name unknown or no channels configured |
| `502 Bad Gateway` | Delivery attempt failed at the remote platform |

**Example**:

```bash
curl -X POST http://localhost:8001/api/v1/channels/team-slack/test
```

Expected Slack message: `Test notification — Connectivity test from VAPORA backend for channel 'team-slack'`

## Delivery Semantics

Delivery is **fire-and-forget**: `AppState::notify` spawns a background Tokio task and returns immediately. The API response does not wait for webhook delivery to complete.

Behaviour on failure:

- Unknown channel name: `warn!` log, delivery to other targets continues
- HTTP error from the remote platform: `warn!` log, delivery to other targets continues
- No channels configured (`channel_registry = None`): silent no-op

There is no built-in retry. A channel that is consistently unreachable produces `warn!` log lines but no escalation. Use the `/test` endpoint to confirm connectivity after configuration changes.

## Events Reference

| Event key | Trigger | Default level |
|-----------|---------|---------------|
| `on_task_done` | Task moved to `Done` status | `Success` |
| `on_proposal_approved` | Proposal approved via API | `Success` |
| `on_proposal_rejected` | Proposal rejected via API | `Warning` |
| `on_agent_inactive` | Agent status transitions to `Inactive` | `Error` |
| `on_stage_complete` | Workflow stage finished | `Info` |
| `on_stage_failed` | Workflow stage failed | `Warning` |
| `on_completed` | Workflow reached terminal `Completed` state | `Success` |
| `on_cancelled` | Workflow cancelled | `Warning` |

## Troubleshooting

### Channel not receiving messages

1. Verify the channel name in `[notifications]` matches the name in `[channels.*]` exactly (case-sensitive).
2. Confirm the env variable is set: `echo $SLACK_WEBHOOK_URL`.
3. Send a test message: `POST /api/v1/channels/<name>/test`.
4. Check backend logs for `warn` entries with `channel = "<name>"`.

### Startup fails with `SecretNotFound`

The env variable referenced in `webhook_url` or `bot_token`/`chat_id` is not set. Either export the variable or add a default value:

```toml
webhook_url = "${SLACK_WEBHOOK_URL:-https://hooks.slack.com/...}"
```

### Discord returns 400

The webhook URL must end with `/slack` for Slack-compatible mode, or be the raw Discord webhook URL. Ensure the URL copied from Discord's channel settings is used without modification.

### Telegram chat_id not found

The bot must be a member of the target group or channel. For groups, prefix the numeric ID with `-` (e.g. `-1001234567890`). Use `@userinfobot` in Telegram to retrieve your chat ID.

## Related Documentation

- [Workflow Orchestrator](./workflow-orchestrator.md) — workflow lifecycle events and notification config
- [ADR-0035: Notification Channels](../adrs/0035-notification-channels.md) — design rationale
- [ADR-0011: SecretumVault](../adrs/0011-secretumvault.md) — secret management philosophy
