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 |
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:
// 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
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 channeldegradedimmediately. - Recovery must restart the worker.
adapter.connect()only validates credentials; calling it alone produces a false recovery where the database saysactivewhile nothing is being received. Recovery calls the manager'srestart().
Inbound: the normalised message
Every platform payload becomes a NormalizedInboundMessage:
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 NormalizedOutboundCommands 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
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 — how an inbound message picks a workflow.
- Channel Problems — diagnosing a channel that stops receiving.
- Authentication —
chk_channel keys and the data plane they unlock.