# Messaging Channels

> Applies to: ChengOS v0.1.0+ ｜ Last verified: 2026-08-12 ｜ Source: `crates/cheng-adapters/src`, `crates/cheng-core/src/ports/channel_adapter.rs`, `crates/cheng-core/src/domain/channel`, `crates/cheng-api/src/services/adapter_health.rs`, `crates/cheng-api/src/services/channel_ingest.rs`

A **channel** connects an external messaging platform to a ChengOS workflow. A message arrives
from Telegram or Slack, the platform-specific payload is normalised into one shared message
model, a conversation is resolved, and a workflow runs. The reply travels back out through the
same adapter.

Five first-party adapters ship in the box: **Telegram**, **WhatsApp**, **Slack**, **WeCom**
(企业微信), and **DingTalk** (钉钉).

## Who does what

The split between framework and adapter is strict, and knowing it saves you looking in the
wrong place:

| The framework does | The adapter does |
|---|---|
| `chk_` API key validation | Translate platform payload → normalised message |
| Conversation resolution | Translate normalised command → platform API call |
| Message persistence | Platform connection setup (token, OAuth, QR) |
| Workflow execution | Verify webhook authenticity (signature/token) |
| WebSocket broadcasting | Answer platform verification challenges |
| Webhook URL computation | |

Adapter lookup is keyed by the raw `app_type` **string**, never an enum. That is deliberate: it
leaves room for external adapters written in other languages to register arbitrary `app_type`
values without any core change. An unknown `app_type` returns "not found" rather than an error.

## The adapters

| Adapter | Auth pattern | Transport |
|---|---|---|
| Telegram | `webhook_token` | Long polling (default) or webhook |
| WhatsApp | `webhook_signature` | Webhook, HMAC-SHA256 on `X-Hub-Signature-256` |
| Slack | `oauth` | Webhook (default) or Socket Mode |
| WeCom | `webhook_encrypted_signature` | Webhook, SHA1 signature + AES-CBC encrypted XML |
| DingTalk | `stream_connection` | Persistent WebSocket to the DingTalk Stream gateway |

**Telegram, Slack Socket Mode, and DingTalk work without a public HTTPS URL.** This is the
single most useful fact on this page: if you are running ChengOS on a laptop or inside an
intranet, choose Telegram polling, Slack Socket Mode, or DingTalk and you never have to expose
anything. WhatsApp and WeCom require a publicly reachable webhook.

Each adapter declares its capabilities (`direct_message`, `group_chat`, `reactions`,
`message_edit`, `media_upload`, `typing_indicator`, `webhooks`, `long_polling`, …), and the
frontend shows or hides features per channel accordingly. Telegram supports typing indicators
but not reactions or edits; Slack supports reactions, edits, and deletes but not typing.

## Configuration

Configuration lives in the channel record's `connection_config` JSON, which the framework treats
as opaque. Shapes:

```json
// Telegram
{ "bot_token": "…", "connection_mode": "polling",
  "webhook_secret": "only-for-webhook-mode",
  "allowed_updates": ["message", "callback_query"] }

// Slack
{ "connection_mode": "webhook", "bot_token": "xoxb-…",
  "signing_secret": "…", "app_token": "xapp-…" }

// WhatsApp
{ "phone_number_id": "…", "business_account_id": "…", "access_token": "…",
  "signing_secret": "…", "webhook_verify_token": "…", "api_version": "v21.0" }

// WeCom
{ "corp_id": "ww…", "agent_id": "1000002", "corp_secret": "…",
  "token": "…", "encoding_aes_key": "<43-char base64url>" }

// DingTalk
{ "client_id": "…", "client_secret": "…", "robot_code": "…" }
```

`connection_mode` defaults to `polling` for Telegram and `webhook` for Slack. Slack's
`app_token` (`xapp-…`) is required only for Socket Mode; `signing_secret` only for webhook mode.

Webhook URLs are **computed at runtime** from `PUBLIC_BASE_URL` plus the channel UUID — you
never store one. For Telegram webhook mode the URL is validated: it must be HTTPS and must not
be localhost, and connect fails with an explicit message if `PUBLIC_BASE_URL` is wrong.

## Connection lifecycle

```text
unconfigured ──connect()──► configuring ──complete_connect()──► active
                    │                                             │
                    └──────────── (single-step) ──────────────────┘
                                                                  │
                       health check fails / token expires ────► degraded
                                                                  │
                                    auto-reconnect succeeds ──────┘

  disconnected  (manual)          error { message }  (needs manual intervention)
```

Token-based adapters reach `active` inside `connect()` and return `NotSupported` from
`complete_connect()`. OAuth and QR adapters return `configuring` with the redirect URL or QR
payload in `setup_data`, and finish in `complete_connect()`.

`connect()` must be **idempotent**: retrying after a timeout or a deploy restart must not create
duplicate webhook registrations or corrupt remote state.

## Health monitoring

A background service checks active and degraded channels every `ADAPTER_HEALTH_INTERVAL_SECS`
(default 300). Unhealthy active channels are marked `degraded`; degraded channels are
reconnected automatically and restored to `active`.

Worker-based platforms — Telegram polling, Slack Socket Mode, DingTalk Stream — are treated
specially, and the reason is worth reading:

- **Liveness comes from the worker, not the credential.** A valid token does not mean messages
  are arriving if the polling worker has exited. The in-process manager's `is_running()` is
  authoritative, and a dead worker marks the channel `degraded` immediately.
- **Recovery must restart the worker.** `adapter.connect()` only validates credentials; calling
  it alone produces a *false recovery* where the database says `active` while nothing is being
  received. Recovery calls the manager's `restart()`.

## Inbound: the normalised message

Every platform payload becomes a `NormalizedInboundMessage`:

```text
external_message_id   ← used for deduplication
external_user_id
external_chat_id      ← the routing key
message_type          ← text | image | file | audio | video | location | reaction | event
text, attachments, reply_to_message_id
metadata              ← sender_display_name, is_group, thread_id, mentioned_bot, …
timestamp
raw                   ← original payload, kept for audit; the core never parses it
```

One webhook delivery can contain several messages (platforms batch), so normalisation returns a
vector, and a delivery containing only status updates returns an empty one.

Non-text messages get a content placeholder — `[image]`, `[file]`, `[audio]`, `[video]`,
`[location]`, `[reaction]`, `[event:…]` — so the conversation history stays readable, while the
actual media is mapped into attachments and stored as artifacts.

The ingest pipeline is shared: webhook deliveries and polling/socket workers go through the same
code, and it owns transport concerns only. Command semantics (`~new`, `~workflow`, `~~y`,
numbered selections, cancellation) live in the conversation command service, so external
channels, the CLI, and tests share exactly one behaviour.

## Outbound

Replies are `NormalizedOutboundCommand`s naming a target chat and one of:
`send_text`, `send_media`, `reply`, `react`, `typing`. An `idempotency_key` prevents duplicate
sends on retry where the platform supports it.

## Errors

Adapter failures are classified into stable categories — `Config`, `Auth`, `PlatformApi`,
`Normalization`, `NotSupported`, `Internal`. These meanings are contractual: new variants may
be added, but existing ones are never repurposed. When a channel misbehaves, the category tells
you whether to look at your configuration, your credentials, or the platform.

## REST surface

```text
GET|POST       /workspaces/:ws/channels
GET|PUT|DELETE /workspaces/:ws/channels/:id
POST|DELETE    /workspaces/:ws/channels/:id/connect
POST           /workspaces/:ws/channels/:id/connect/complete
GET            /workspaces/:ws/channels/:id/status
GET            /workspaces/:ws/channels/:id/capabilities
GET            /workspaces/:ws/channels/:id/auth-pattern

GET|POST       /webhooks/:channel_uuid        (no auth — platforms call this)
```

Channel management is JWT-protected under a workspace. The webhook endpoints are unauthenticated
by necessity — authenticity is established by the adapter's signature check, not by a bearer
token.

## Next steps

- [Channel Routing & Session Mapping](/docs/chat-channel-routing) — how an inbound message picks a workflow.
- [Channel Problems](/docs/fix-channel-issues) — diagnosing a channel that stops receiving.
- [Authentication](/docs/api-authentication) — `chk_` channel keys and the data plane they unlock.
