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>/. Ordinary loading errors are logged and skipped. Adapters execute Ruby in the host process; error handling is not a sandbox against arbitrary side effects or resource exhaustion.
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.
The runtime loader skips adapters whose loading or interface checks fail and logs the reason. clacky ext verify checks manifest structure and file existence, not Ruby loading or adapter registration.
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 and implement all 5 methods below. The runtime loader checks class-method availability and whether the three required instance methods override Base; these checks do not prove the implementations work.
| 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. |
#send_file(chat_id, path, name: nil) |
Not defined on Base | Send a local file, optionally with a display name. Without it, the host sends a text message containing the file name and path instead of uploading the file. |
#flush_pending(chat_id) |
Not defined on Base | Flush adapter-buffered output when the host finishes a response. Without it, the host does nothing. |
The host checks for the last two methods with respond_to?; neither is required for registration. Their return values are not consumed by the host. Implement file upload and buffering using the destination platform's limits. File-send exceptions are logged and reported as text; do not claim a file was delivered when only a path was sent.
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".
Channel adapters are required during process initialization. Browser refresh does not reload adapter Ruby code; arrange a restart with the user. See Applying Changes.
CLI workflow
1. Scaffold
clacky ext new slack-channel --full
The generated adapter is channels/noop.rb, registered as a no-op demonstration platform — it is not a Slack adapter. For the Slack example below:
- Rename that generated file to
channels/slack.rb. - Replace
contributes.channelswith the manifest entry above (id: slack,adapter: channels/slack.rb). - Remove the other seven generated contributions from this new container's manifest unless your feature explicitly needs them, especially hooks and patches.
2. Implement the adapter
Replace the no-op file with the minimal class below, then implement the actual platform operations. Keep its registration aligned with its class and platform_id:
Clacky::Channel::Adapters.register(:slack, ClackyChannels::SlackAdapter)
3. Verify loading
clacky ext verify
Sample output:
[OK] slack-channel/slack (channel, local)
[ERR] broken-one channel/slack (loader.error) — adapter file not found: channels/slack.rb [/path/to/broken-one/channels/slack.rb]
The absolute path varies by machine. Errors cause a non-zero exit. An [OK] line only confirms structural resolution; runtime registration, configuration and real send/receive behavior still need testing.
Minimal example
This is an interface demonstration, not a working Slack client. Its loop receives no messages and send_text returns a simulated id without delivering anything. Replace both before claiming the integration works.
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 verifychecks structure/file existence; it neither loads/registers the adapter nor connects to the service. Use your ownvalidate_configto check config values before launch.- If you see
missing required methods: ..., double-check method names, instance vs class, and visibility. Explicitly implement both class methods too; an inherited Base stub is not a working implementation.