lib/clacky/server/channel/channel_config.rb



# frozen_string_literal: true

require "yaml"
require "fileutils"
require "json"

module Clacky
  # ChannelConfig manages IM platform credentials (Feishu, WeCom, etc.).
  #
  # Config is stored in ~/.clacky/channels.yml:
  #
  #   channels:
  #     feishu:
  #       enabled: true
  #       app_id: cli_xxx
  #       app_secret: xxx
  #       domain: https://open.feishu.cn
  #       allowed_users:
  #         - ou_xxx
  #     wecom:
  #       enabled: false
  #       bot_id: xxx
  #       secret: xxx
  #
  # This class is only responsible for platform credentials.
  # working_dir and permission_mode live in AgentConfig.
  class ChannelConfig
    CONFIG_DIR  = File.join(Dir.home, ".clacky")
    CONFIG_FILE = File.join(CONFIG_DIR, "channels.yml")

    # @param channels [Hash<String, Hash>] string-keyed platform configs (raw from YAML)
    def initialize(channels: {})
      @channels = channels || {}
    end

    # Load from disk. Returns an empty instance if the file does not exist.
    # @param config_file [String]
    # @return [ChannelConfig]
    def self.load(config_file = CONFIG_FILE)
      if File.exist?(config_file)
        data = YAMLCompat.safe_load(File.read(config_file), permitted_classes: [Symbol]) || {}
      else
        data = {}
      end

      new(channels: data["channels"] || {})
    end

    # Persist to disk.
    # @param config_file [String]
    def save(config_file = CONFIG_FILE)
      FileUtils.mkdir_p(File.dirname(config_file))
      File.write(config_file, to_yaml)
      FileUtils.chmod(0o600, config_file)
    end

    # Serialize to YAML string.
    # @return [String]
    def to_yaml
      YAML.dump({ "channels" => @channels })
    end

    # Returns true if at least one channel is enabled.
    def any_enabled?
      @channels.any? { |_, cfg| cfg["enabled"] }
    end

    # Returns the list of enabled platform symbols.
    # @return [Array<Symbol>]
    def enabled_platforms
      @channels
        .select { |_, cfg| cfg["enabled"] }
        .keys
        .map(&:to_sym)
    end

    # Returns true if the given platform is configured and enabled.
    # @param platform [Symbol, String]
    def enabled?(platform)
      cfg = @channels[platform.to_s]
      cfg && cfg["enabled"]
    end

    # Return the symbol-keyed config hash expected by each adapter's initializer.
    # Returns nil if the platform is not configured.
    #
    # @param platform [Symbol, String]
    # @return [Hash, nil]
    def platform_config(platform)
      raw = @channels[platform.to_s]
      return nil unless raw

      case platform.to_sym
      when :feishu
        {
          app_id:        raw["app_id"],
          app_secret:    raw["app_secret"],
          domain:        raw["domain"],
          allowed_users: raw["allowed_users"]
        }.compact
      when :wecom
        {
          bot_id: raw["bot_id"],
          secret: raw["secret"]
        }.compact
      when :weixin
        {
          token:         raw["token"],
          base_url:      raw["base_url"],
          allowed_users: raw["allowed_users"]
        }.compact
      when :discord
        {
          bot_token:     raw["bot_token"]
        }.compact
      when :dingtalk
        {
          client_id:     raw["client_id"],
          client_secret: raw["client_secret"],
          allowed_users: raw["allowed_users"]
        }.compact
      when :telegram
        {
          bot_token:     raw["bot_token"],
          base_url:      raw["base_url"],
          parse_mode:    raw.key?("parse_mode") ? raw["parse_mode"] : "Markdown",
          allowed_users: raw["allowed_users"]
        }.compact
      else
        # Unknown platform — pass all non-meta keys as symbol-keyed hash
        raw.reject { |k, _| k == "enabled" }
           .transform_keys(&:to_sym)
      end
    end

    # Set or update a platform's credentials.
    # Merges provided fields into the existing entry.
    # Automatically sets enabled: true unless explicitly provided.
    #
    # @param platform [Symbol, String]
    # @param fields [Hash] symbol-keyed credential fields
    def set_platform(platform, **fields)
      key = platform.to_s
      @channels[key] ||= {}
      fields.each { |k, v| @channels[key][k.to_s] = v }
      @channels[key]["enabled"] = true unless @channels[key].key?("enabled")
    end

    # Enable a platform (requires it to already be configured).
    # @param platform [Symbol, String]
    # @raise [ArgumentError] if the platform has no stored credentials yet.
    def enable_platform(platform)
      key = platform.to_s
      raise ArgumentError, "Platform #{platform} is not configured" unless @channels.key?(key)
      @channels[key]["enabled"] = true
    end

    # Disable a platform (keeps credentials, just sets enabled: false).
    # @param platform [Symbol, String]
    def disable_platform(platform)
      key = platform.to_s
      return unless @channels.key?(key)
      @channels[key]["enabled"] = false
    end

    # Remove a platform entry entirely.
    # @param platform [Symbol, String]
    def remove_platform(platform)
      @channels.delete(platform.to_s)
    end

    # Deep copy — prevents callers from mutating shared config state.
    # @return [ChannelConfig]
    def deep_copy
      self.class.new(channels: JSON.parse(JSON.generate(@channels)))
    end
  end
end