lib/clacky/server/channel/adapters/wecom/adapter.rb



# frozen_string_literal: true

require_relative "../../adapters/base"
require_relative "ws_client"
require_relative "media_downloader"
require_relative "../feishu/file_processor"

module Clacky
  module Channel
    module Adapters
      module Wecom
        # WeCom (Enterprise WeChat) adapter.
        # Receives messages via WebSocket long connection and sends via bot API.
        class Adapter < Base
          def self.platform_id
            :wecom
          end

          def self.env_keys
            %w[IM_WECOM_BOT_ID IM_WECOM_SECRET]
          end

          def self.platform_config(data)
            {
              bot_id: data["IM_WECOM_BOT_ID"],
              secret: data["IM_WECOM_SECRET"]
            }
          end

          def self.set_env_data(data, config)
            data["IM_WECOM_BOT_ID"] = config[:bot_id]
            data["IM_WECOM_SECRET"] = config[:secret]
          end

          def initialize(config)
            @config = config
            @ws_client = WSClient.new(
              bot_id: config[:bot_id],
              secret: config[:secret],
              ws_url: config[:ws_url] || WSClient::WS_URL
            )
            @running = false
            @on_message = nil
          end

          def start(&on_message)
            @running = true
            @on_message = on_message

            @ws_client.start do |raw|
              handle_raw_message(raw)
            end
          rescue WSClient::AuthError => e
            Clacky::Logger.error("[WecomAdapter] Authentication failed, not retrying: #{e.message}")
          end

          def stop
            @running = false
            @ws_client.stop
          end

          def send_text(chat_id, text, reply_to: nil)
            @ws_client.send_message(chat_id, text)
          end

          def send_file(chat_id, path, name: nil)
            @ws_client.send_file(chat_id, path, name: name)
          end

          def validate_config(config)
            errors = []
            errors << "bot_id is required" if config[:bot_id].nil? || config[:bot_id].empty?
            errors << "secret is required" if config[:secret].nil? || config[:secret].empty?
            errors
          end


          def handle_raw_message(raw)
            msgtype = raw["msgtype"]
            return unless %w[text image file].include?(msgtype)

            chat_id = raw["chatid"] || raw.dig("from", "userid")
            return unless chat_id

            user_id = raw.dig("from", "userid")
            chat_type = raw["chattype"] == "group" ? :group : :direct
            text  = ""
            files = []

            case msgtype
            when "text"
              text = raw.dig("text", "content").to_s.strip
              return if text.empty?
            when "image"
              url    = raw.dig("image", "url")
              aeskey = raw.dig("image", "aeskey")
              return unless url
              result = MediaDownloader.download(url, aeskey)
              mime = MediaDownloader.detect_mime(result[:body])
              if result[:body].bytesize > MAX_IMAGE_BYTES
                @ws_client.send_message(chat_id, "Image too large (#{(result[:body].bytesize / 1024.0).round(0).to_i}KB), max #{MAX_IMAGE_BYTES / 1024}KB")
                return
              end
              require "base64"
              data_url = "data:#{mime};base64,#{Base64.strict_encode64(result[:body])}"
              files = [{ name: "image.jpg", mime_type: mime, data_url: data_url }]
            when "file"
              url      = raw.dig("file", "url")
              aeskey   = raw.dig("file", "aeskey")
              return unless url
              filename = raw.dig("file", "name") || raw.dig("file", "filename") || "attachment"
              result   = MediaDownloader.download(url, aeskey)
              filename = result[:filename] || filename
              saved = Clacky::Utils::FileProcessor.save(body: result[:body], filename: filename)
              files = [saved]
            end

            event = {
              type: :message,
              platform: :wecom,
              chat_id: chat_id,
              user_id: user_id,
              text: text,
              files: files,
              message_id: raw["msgid"],
              timestamp: raw["create_time"] ? Time.at(raw["create_time"]) : Time.now,
              chat_type: chat_type,
              raw: raw
            }

            @on_message&.call(event)
          rescue => e
            Clacky::Logger.error("[WecomAdapter] handle_raw_message error: #{e.message}\n#{e.backtrace.first(3).join("\n")}")
            begin
              @ws_client.send_message(chat_id, "Error processing message: #{e.message}") if chat_id
            rescue
              nil
            end
          end

          MAX_IMAGE_BYTES = Clacky::Utils::FileProcessor::MAX_IMAGE_BYTES
        end

        Adapters.register(:wecom, Adapter)
      end
    end
  end
end