Extend

Add a channel

A channel is one Go type behind the pkg/channel contract. Email, website chat, WhatsApp, Messenger, Instagram, Telegram, SMS and Slack are built this way; a new messaging provider is a core module that contributes one and passes the conformance suite.

Last updated

The contract

pkg/channel
type Channel interface {
	Kind() string                                   // "acme_chat"
	ConfigSchema() json.RawMessage                  // JSON Schema for the inbox form; "x-secret": true marks credentials
	Validate(ctx context.Context, config json.RawMessage, secrets map[string]string) error
	HandleWebhook(ctx context.Context, inbox Inbox, r *http.Request) ([]InboundEvent, error) // push providers
	Sync(ctx context.Context, inbox Inbox, cursor Cursor) ([]InboundEvent, Cursor, error)     // pull providers
	Send(ctx context.Context, inbox Inbox, msg OutboundMessage) (ProviderRef, error)
	Capabilities() Capabilities                     // attachments, typing, read receipts, max length
}

// Optional: hold a connection and call wake when new data is likely (IMAP IDLE).
type Watcher interface{ Watch(ctx context.Context, inbox Inbox, wake func()) error }

Return channel.ErrNotSupported from the delivery mode you do not implement. Secrets marked x-secret are encrypted at rest and handed to you decrypted per call.

A push channel opts in to provider webhooks by also implementing channel.WebhookReceiver (ReceivesWebhooks() bool). The provider then posts to /api/public/channel-hooks/{inbox public key}; HandleWebhook must verify the provider's signature with the inbox secrets and return channel.ErrBadSignature when it does not match (Kommio answers 401), and each event is applied through the normal inbound path in its own transaction. Channels without it never receive requests there. A module that calls provider URLs implements plugin.EgressUser (UseEgress(plugin.HTTPClient)) and receives Kommio's SSRF-safe client at boot instead of building its own.

Optional interfaces cover what the built-in channels needed: Provisioner (register with the provider on create, update and resume; undo on pause and delete), Tester (the setup form's connection test), HealthChecker (checked every five minutes, shown on the inbox and under Marketplace › Installed), WebhookVerifier (a GET handshake on the hook address, as Meta's hub.challenge), WebhookResponder (the body a provider expects back), EventParser (a verified post queued and parsed by a job, for providers that want an answer within seconds), TypingSender, ReadMarker (the host says when an agent opened the conversation), ReplyWindowChecker with Capabilities.MessageTags (a reply window such as Meta's 24 hours, checked before a reply is queued and again on send), RecipientPicker (identities scoped to a provider account), LongPoller, Consent (per-sender opt-outs) and InboxCapabilities (capabilities that depend on the inbox, such as MMS). Inbound events may carry headers for a provider thread (one conversation per thread), an edit or deletion in place, an echo of what the business sent elsewhere, and identities the provider vouches for.

A push channel, minimal

plugins/acmechat/acmechat.go
package acmechat

type Channel struct{ Client *http.Client }

func (Channel) Kind() string { return "acme_chat" }

func (Channel) ReceivesWebhooks() bool { return true } // channel.WebhookReceiver

func (Channel) ConfigSchema() json.RawMessage {
	return json.RawMessage(`{"type":"object","required":["account_id","access_token"],"properties":{
	  "account_id":{"type":"string","title":"Acme Chat account id"},
	  "access_token":{"type":"string","title":"Access token","x-secret":true},
	  "verify_token":{"type":"string","title":"Webhook verify token","x-secret":true}}}`)
}

func (c Channel) Validate(ctx context.Context, raw json.RawMessage, secrets map[string]string) error {
	// call the provider once with the credentials; return a clear error for the inbox form
	return nil
}

func (c Channel) HandleWebhook(ctx context.Context, inbox channel.Inbox, r *http.Request) ([]channel.InboundEvent, error) {
	// verify the provider signature with secrets["verify_token"], then map each message:
	return []channel.InboundEvent{{
		Kind: channel.InboundMessage, ProviderMessageID: "acm_01J9X4K7M2P8Q3R6T0V5W9Y1Z4",
		From: channel.Identity{Kind: "phone", Value: "+5511999990000", Name: "Ana"},
		Text: "where is my order?", OccurredAt: time.Now(),
	}}, nil
}

func (Channel) Sync(context.Context, channel.Inbox, channel.Cursor) ([]channel.InboundEvent, channel.Cursor, error) {
	return nil, nil, channel.ErrNotSupported
}

func (c Channel) Send(ctx context.Context, inbox channel.Inbox, m channel.OutboundMessage) (channel.ProviderRef, error) {
	// POST to the provider with secrets["access_token"]; return its message id
	return channel.ProviderRef{ID: "acm_01J9X4N2B8D5F1G7H3J6K0L9M2"}, nil
}

func (Channel) Capabilities() channel.Capabilities {
	return channel.Capabilities{Attachments: true, Typing: false, ReadReceipts: true, MaxLength: 4096}
}

Register and test

module + conformance
func (Plugin) ContributeChannels() []channel.Channel { return []channel.Channel{acmechat.Channel{}} }

func TestConformance(t *testing.T) {
	conformance.Run(t, acmechat.Channel{}, conformance.Options{Config: cfg, Secrets: secrets})
}

The suite that every built-in channel passes: schema validity, validation of bad credentials, idempotent inbound delivery (the provider message id deduplicates), outbound send and provider references, capabilities honoured by the composer. Kommio finds or creates the contact and conversation, runs inbound hooks, stores the message and publishes events; for pull channels the syncer stores the cursor only after every event committed and quarantines a message the pipeline rejects rather than stalling the mailbox. A contributed channel appears in the add-inbox flow and, where it has a catalog listing, in the Marketplace; nothing in the app says it came from a module.

Every claim on these pages is covered by a test in the repository, and the API reference is generated from the same contract every server serves at /api/openapi. Corrections to [email protected].