Custom Channel Adapter
OpenClacky's IM integrations (Feishu / WeCom / Discord, etc.) use a self-registering adapter architecture. You can plug in your own channels (Slack, in-house IM, email, SMS, …) without touching the gem source. A channel adapter is now one kind of extension-container contribution — declare contributes.channels in ext.yml and ship the adapter script inside ~/.clacky/ext/local/<id>/. A broken adapter is automatically isolated at startup so it can never bring down the main process.
To understand the overall model first, read the Extension System Overview.
How it works
An extension container declares the channels it contributes in ext.yml:
# ~/.clacky/ext/local/slack-channel/ext.yml
id: slack-channel
name: Slack Channel
version: "0.1.0"
contributes:
channels:
- id: slack
adapter: channels/slack.rb # adapter script (relative to container root)
At startup OpenClacky resolves every container and loads the adapter.rb each channels contribution declares. Each file should define a class that inherits from Clacky::Channel::Adapters::Base and self-registers via Adapters.register at the bottom of the file. Once registered, the built-in channel router can dispatch to it just like a built-in adapter.
Broken adapters are skipped (never abort startup); clacky ext verify shows the reason.
Directory layout
~/.clacky/ext/local/slack-channel/
├── ext.yml # manifest: declares contributes.channels
└── channels/
└── slack.rb # adapter: inherits Base + self-registers at the bottom
The channel id in the manifest is just for organization — the real platform identifier comes from platform_id in the adapter class.
Required interface
Inherit Clacky::Channel::Adapters::Base. The following 5 methods must be implemented (otherwise the loader will treat the adapter as "not really implemented" and skip it):
| Method | Type | Purpose |
|---|---|---|
self.platform_id |
class | Returns a Symbol like :slack. The whole system uses it to locate the adapter. |
self.platform_config(raw) |
class | Maps the raw Hash from ChannelConfig to a symbol-keyed runtime config. |
#start(&on_message) |
instance | Starts listening and blocks; yields one standardized event per inbound message. |
#stop |
instance | Stops listening and releases resources. |
#send_text(chat_id, text, reply_to: nil) |
instance | Sends text/Markdown to a chat. Returns { message_id: String }. |
Optional (override to enhance):
| Method | Default | Purpose |
|---|---|---|
#update_message(chat_id, message_id, text) |
false |
Edit a sent message in place (for streaming progress). |
#supports_message_updates? |
false |
Whether the platform supports message edits. |
#validate_config(config) |
[] |
Returns an array of error strings; empty means valid. |
Note:
start/stop/send_textonBaseare stubs thatraise NotImplementedError. If your subclass does not actually override them, the loader detects the missing implementation and skips it — it cannot silently "pretend to implement".
CLI workflow
1. Scaffold
clacky ext new slack-channel --full
The --full reference container includes a channels/ adapter stub (plus samples of the other six contribution types). You can also build a channel-only container: run clacky ext new slack-channel, trim ext.yml down to just contributes.channels, and write the inheritance, self-registration, and required methods in channels/slack.rb.
2. Fill in the TODOs
Open channels/slack.rb and replace the # TODO markers with real logic. Do not remove the self-registration line at the bottom:
Clacky::Channel::Adapters.register(:slack, SlackAdapter)
3. Verify loading
clacky ext verify
Sample output:
[OK] slack-channel/slack (channel, local)
[ERR] broken-one/slack (channel.adapter.missing) — adapter file not found [channels/slack.rb]
The command exits non-zero on any error — handy for CI.
Minimal example
require "clacky"
module ClackyChannels
class SlackAdapter < Clacky::Channel::Adapters::Base
def self.platform_id
:slack
end
def self.platform_config(raw)
{
bot_token: raw[:bot_token],
signing_secret: raw[:signing_secret]
}
end
def initialize(config)
@config = config
@running = false
end
def start(&on_message)
@running = true
while @running
# replace with a real long-poll / socket loop
sleep 1
end
end
def stop
@running = false
end
def send_text(chat_id, text, reply_to: nil)
# call Slack API; demo only
{ message_id: "demo-#{Time.now.to_i}" }
end
def validate_config(config)
errors = []
errors << "bot_token is required" if config[:bot_token].to_s.empty?
errors
end
end
end
Clacky::Channel::Adapters.register(:slack, ClackyChannels::SlackAdapter)
Inbound event format
Events yielded from start should follow the convention used by built-in adapters (see Feishu / WeCom): include at minimum chat_id, message_id, user_id, text, platform, so upper-layer routing can handle them uniformly across platforms.
Debugging tips
- Log to
~/.clacky/logs/<name>.logfrom insidestart. Long-polling bugs are hard to spot any other way. clacky ext verifyonly checks whether the adapter loads — it does not actually connect to the service. Use your ownvalidate_configto check config values before launch.- If you keep seeing
unimplemented methods: ..., double-check method names, instance vs class, and that the methods are not under aprivateblock.