lib/clacky/server/channel/adapters/discord/adapter.rb
# frozen_string_literal: true
require_relative "../../adapters/base"
require_relative "api_client"
require_relative "gateway_client"
require_relative "../feishu/file_processor"
require "time"
module Clacky
module Channel
module Adapters
module Discord
# Discord adapter (bot mode).
# Receives messages via the Gateway WebSocket and sends via the REST API.
class Adapter < Base
MAX_IMAGE_BYTES = Clacky::Utils::FileProcessor::MAX_IMAGE_BYTES
def self.platform_id
:discord
end
def self.env_keys
%w[IM_DISCORD_BOT_TOKEN]
end
def self.platform_config(data)
{
bot_token: data["IM_DISCORD_BOT_TOKEN"]
}
end
def self.set_env_data(data, config)
data["IM_DISCORD_BOT_TOKEN"] = config[:bot_token]
end
def self.test_connection(fields)
bot_token = fields[:bot_token].to_s.strip
return { ok: false, error: "bot_token is required" } if bot_token.empty?
client = ApiClient.new(bot_token: bot_token)
me = client.me
if me["id"]
{ ok: true, message: "Connected as #{me["username"]}##{me["discriminator"]} (id=#{me["id"]})" }
else
{ ok: false, error: "Empty response from /users/@me" }
end
rescue ApiClient::ApiError => e
{ ok: false, error: e.message }
rescue StandardError => e
{ ok: false, error: e.message }
end
def initialize(config)
@config = config
@bot_token = config[:bot_token]
@api = ApiClient.new(bot_token: @bot_token)
@gateway = GatewayClient.new(bot_token: @bot_token)
@bot_user_id = nil
@running = false
@on_message = nil
end
def start(&on_message)
@running = true
@on_message = on_message
begin
me = @api.me
@bot_user_id = me["id"]
Clacky::Logger.info("[DiscordAdapter] authenticated as #{me["username"]} (id=#{@bot_user_id})")
rescue ApiClient::ApiError => e
Clacky::Logger.error("[DiscordAdapter] /users/@me failed, not retrying: #{e.message}")
return
end
@gateway.start do |evt|
handle_gateway_event(evt)
end
rescue GatewayClient::AuthError => e
Clacky::Logger.error("[DiscordAdapter] Authentication failed, not retrying: #{e.message}")
end
def stop
@running = false
@gateway.stop
end
def send_text(chat_id, text, reply_to: nil)
res = @api.send_message(chat_id, text, reply_to: reply_to)
{ message_id: res["id"] }
rescue ApiClient::ApiError => e
Clacky::Logger.error("[DiscordAdapter] send_text failed: #{e.message}")
{ message_id: nil }
end
def update_message(chat_id, message_id, text)
@api.edit_message(chat_id, message_id, text)
true
rescue ApiClient::ApiError => e
Clacky::Logger.warn("[DiscordAdapter] update_message failed: #{e.message}")
false
end
def supports_message_updates?
true
end
def send_file(chat_id, path, name: nil)
@api.send_file(chat_id, path, name: name)
rescue ApiClient::ApiError => e
Clacky::Logger.error("[DiscordAdapter] send_file failed: #{e.message}")
nil
end
def validate_config(config)
errors = []
errors << "bot_token is required" if config[:bot_token].nil? || config[:bot_token].empty?
errors
end
private def handle_gateway_event(evt)
return unless evt[:type] == :message
handle_message(evt[:data])
end
private def handle_message(msg)
return if msg.nil?
author = msg["author"] || {}
return if author["bot"] == true
return if @bot_user_id && author["id"] == @bot_user_id
chat_id = msg["channel_id"]
return unless chat_id
user_id = author["id"]
chat_type = msg["guild_id"] ? :group : :direct
mentioned_ids = Array(msg["mentions"]).map { |m| m["id"] }
if chat_type == :group
if @bot_user_id.nil?
Clacky::Logger.warn("[DiscordAdapter] bot_user_id unavailable; dropping group message")
return
end
return unless mentioned_ids.include?(@bot_user_id)
end
allowed_users = @config[:allowed_users]
if allowed_users && !allowed_users.empty?
return unless allowed_users.include?(user_id)
end
text = strip_bot_mention(msg["content"].to_s, @bot_user_id)
files = process_attachments(Array(msg["attachments"]), chat_id)
return if text.strip.empty? && files.empty?
event = {
type: :message,
platform: :discord,
chat_id: chat_id,
user_id: user_id,
text: text,
files: files,
message_id: msg["id"],
timestamp: parse_timestamp(msg["timestamp"]),
chat_type: chat_type,
mentioned_user_ids: mentioned_ids,
raw: msg
}
@on_message&.call(event)
rescue => e
Clacky::Logger.error("[DiscordAdapter] handle_message error: #{e.message}\n#{e.backtrace.first(3).join("\n")}")
begin
chat_id ||= msg && msg["channel_id"]
@api.send_message(chat_id, "Error processing message: #{e.message}") if chat_id
rescue
nil
end
end
private def strip_bot_mention(text, bot_id)
return text if bot_id.nil? || text.empty?
text.gsub(/<@!?#{Regexp.escape(bot_id)}>/, "").strip
end
private def process_attachments(attachments, chat_id)
files = []
attachments.each do |att|
url = att["url"]
filename = att["filename"] || "attachment"
next unless url
result = @api.download(url)
body = result[:body]
mime = att["content_type"] || result[:content_type]
if mime && mime.start_with?("image/")
if body.bytesize > MAX_IMAGE_BYTES
@api.send_message(chat_id, "Image too large (#{(body.bytesize / 1024.0).round(0).to_i}KB), max #{MAX_IMAGE_BYTES / 1024}KB")
next
end
require "base64"
data_url = "data:#{mime};base64,#{Base64.strict_encode64(body)}"
files << { name: filename, mime_type: mime, data_url: data_url }
else
files << Clacky::Utils::FileProcessor.save(body: body, filename: filename)
end
end
files
rescue => e
Clacky::Logger.warn("[DiscordAdapter] process_attachments error: #{e.message}")
files
end
private def parse_timestamp(iso)
return Time.now if iso.nil? || iso.empty?
Time.iso8601(iso)
rescue ArgumentError
Time.now
end
end
Adapters.register(:discord, Adapter)
end
end
end
end