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_text on Base are stubs that raise 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>.log from inside start. Long-polling bugs are hard to spot any other way.
  • clacky ext verify only checks whether the adapter loads — it does not actually connect to the service. Use your own validate_config to 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 a private block.