openclacky 1.0.3 → 1.0.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (47) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +34 -1
  3. data/benchmark/fixtures/sample_project/Gemfile +3 -0
  4. data/benchmark/fixtures/sample_project/lib/api_handler.rb +32 -0
  5. data/benchmark/fixtures/sample_project/lib/order_calculator.rb +23 -0
  6. data/benchmark/fixtures/sample_project/lib/user_renderer.rb +20 -0
  7. data/benchmark/fixtures/sample_project/spec/order_calculator_spec.rb +20 -0
  8. data/benchmark/results/EVALUATION_REPORT.md +165 -0
  9. data/benchmark/results/baseline_20260511_174424.json +128 -0
  10. data/benchmark/results/report_20260511_175256.json +271 -0
  11. data/benchmark/results/report_20260511_175444.json +271 -0
  12. data/benchmark/results/treatment_20260511_175103.json +130 -0
  13. data/benchmark/runner.rb +441 -0
  14. data/docs/proposals/2026-05-11-system-prompt-alignment.md +325 -0
  15. data/docs/proposals/2026-05-12-memory-mechanism-optimization.md +89 -0
  16. data/lib/clacky/agent/cost_tracker.rb +8 -2
  17. data/lib/clacky/agent/memory_updater.rb +41 -30
  18. data/lib/clacky/agent/skill_manager.rb +5 -2
  19. data/lib/clacky/agent/skill_reflector.rb +10 -1
  20. data/lib/clacky/agent.rb +4 -0
  21. data/lib/clacky/client.rb +15 -0
  22. data/lib/clacky/default_agents/base_prompt.md +20 -20
  23. data/lib/clacky/default_agents/coding/system_prompt.md +51 -1
  24. data/lib/clacky/default_skills/channel-setup/SKILL.md +190 -14
  25. data/lib/clacky/default_skills/channel-setup/discord_setup.rb +199 -0
  26. data/lib/clacky/default_skills/channel-setup/import_lark_skills.rb +97 -0
  27. data/lib/clacky/default_skills/onboard/SKILL.md +1 -1
  28. data/lib/clacky/default_skills/persist-memory/SKILL.md +59 -0
  29. data/lib/clacky/providers.rb +77 -10
  30. data/lib/clacky/server/channel/adapters/discord/adapter.rb +229 -0
  31. data/lib/clacky/server/channel/adapters/discord/api_client.rb +107 -0
  32. data/lib/clacky/server/channel/adapters/discord/gateway_client.rb +272 -0
  33. data/lib/clacky/server/channel/adapters/telegram/adapter.rb +375 -0
  34. data/lib/clacky/server/channel/adapters/telegram/api_client.rb +205 -0
  35. data/lib/clacky/server/channel/channel_config.rb +11 -0
  36. data/lib/clacky/server/channel.rb +2 -0
  37. data/lib/clacky/server/http_server.rb +69 -3
  38. data/lib/clacky/ui2/ui_controller.rb +2 -1
  39. data/lib/clacky/utils/file_processor.rb +71 -0
  40. data/lib/clacky/version.rb +1 -1
  41. data/lib/clacky/web/app.css +44 -0
  42. data/lib/clacky/web/channels.js +16 -0
  43. data/lib/clacky/web/i18n.js +24 -2
  44. data/lib/clacky/web/index.html +6 -1
  45. data/lib/clacky/web/settings.js +4 -0
  46. data/lib/clacky/web/version.js +52 -1
  47. metadata +37 -2
@@ -0,0 +1,59 @@
1
+ ---
2
+ name: persist-memory
3
+ description: Persist information to long-term memory at ~/.clacky/memories/. Use when the user asks you to remember/note something, or when reviewing a finished conversation for facts worth keeping. Handles file naming, topic merging, frontmatter, and size limits.
4
+ fork_agent: true
5
+ user-invocable: false
6
+ auto_summarize: true
7
+ forbidden_tools:
8
+ - web_search
9
+ - web_fetch
10
+ - browser
11
+ ---
12
+
13
+ # Persist Memory Subagent
14
+
15
+ You are a **Memory Persistence Subagent** — a pure executor. The caller has already decided that something must be written. Your job is to write it correctly: pick the right file, merge with existing content, respect the size limit.
16
+
17
+ You do NOT decide whether to write. If the task description tells you to persist X, you persist X.
18
+
19
+ ## Existing Memory Files
20
+
21
+ The following memory files are pre-loaded for you — **do NOT re-scan the directory** with `terminal` or `file_reader`.
22
+
23
+ <%= memories_meta %>
24
+
25
+ Each file uses YAML frontmatter:
26
+
27
+ ```
28
+ ---
29
+ topic: <topic name>
30
+ description: <one-line description>
31
+ ---
32
+ <content in concise Markdown>
33
+ ```
34
+
35
+ ## Workflow
36
+
37
+ For each item to persist:
38
+
39
+ ### Step 1: Pick a target file
40
+
41
+ Scan the list above:
42
+
43
+ - **Matching topic exists** → read it with `file_reader(path: "~/.clacky/memories/<filename>")`, integrate the new info, drop stale parts, then `write` the updated version back.
44
+ - **No match** → create a new file at `~/.clacky/memories/<topic-slug>.md`.
45
+ - Slug: lowercase, hyphen-separated, descriptive (e.g. `deployment-target.md`, `code-style-preferences.md`).
46
+
47
+ ### Step 2: Write the file
48
+
49
+ Use the `write` tool. Always include the YAML frontmatter shown above.
50
+
51
+ ## Hard constraints (CRITICAL)
52
+
53
+ - Each file MUST stay under **4000 characters of content** (after the frontmatter).
54
+ - If merging would exceed this limit, remove the least important information — do NOT split into multiple files for the same topic.
55
+ - Write concise, factual Markdown — no fluff, no redundant headings.
56
+ - One topic per file. Don't bundle unrelated facts together.
57
+ - Do NOT use `terminal` or `file_reader` to list the memories directory — the list above is authoritative.
58
+
59
+ When done, briefly state what was written (e.g. "Updated deployment-target.md") or `No memory updates needed.` if the task description didn't actually require any writes.
@@ -29,7 +29,7 @@ module Clacky
29
29
  "name" => "OpenClacky",
30
30
  "base_url" => "https://api.openclacky.com",
31
31
  "api" => "bedrock",
32
- "default_model" => "abs-claude-sonnet-4-5",
32
+ "default_model" => "abs-claude-sonnet-4-6",
33
33
  "models" => [
34
34
  "abs-claude-opus-4-7",
35
35
  "abs-claude-opus-4-6",
@@ -80,7 +80,32 @@ module Clacky
80
80
  "base_url" => "https://openrouter.ai/api/v1",
81
81
  "api" => "openai-responses",
82
82
  "default_model" => "anthropic/claude-sonnet-4-6",
83
- "models" => [], # Dynamic - fetched from API
83
+ # Curated default lineup. OpenRouter's full catalogue is enormous
84
+ # (hundreds of models) and the live /models endpoint isn't always
85
+ # reachable from every region — shipping a small list of the
86
+ # mainstream Claude + GPT entries gives users a working dropdown
87
+ # out of the box. Users can still type any other OpenRouter model
88
+ # ID manually; this list only seeds the picker.
89
+ "models" => [
90
+ "anthropic/claude-sonnet-4-6",
91
+ "anthropic/claude-opus-4-7",
92
+ "anthropic/claude-opus-4-6",
93
+ "anthropic/claude-haiku-4-5",
94
+ "openai/gpt-5.5",
95
+ "openai/gpt-5.4",
96
+ "openai/gpt-5.4-mini"
97
+ ],
98
+ # Per-primary lite pairing — Claude family pairs with Haiku, GPT
99
+ # family pairs with the mini variant. Mirrors the openclacky and
100
+ # openai presets above so subagents on OpenRouter get a sensible
101
+ # cheap/fast sidekick automatically.
102
+ "lite_models" => {
103
+ "anthropic/claude-sonnet-4-6" => "anthropic/claude-haiku-4-5",
104
+ "anthropic/claude-opus-4-7" => "anthropic/claude-haiku-4-5",
105
+ "anthropic/claude-opus-4-6" => "anthropic/claude-haiku-4-5",
106
+ "openai/gpt-5.5" => "openai/gpt-5.4-mini",
107
+ "openai/gpt-5.4" => "openai/gpt-5.4-mini"
108
+ },
84
109
  # Per-model API type overrides. Matched by Regexp against the model name.
85
110
  # Why this exists: OpenRouter proxies Claude via both its OpenAI-compatible
86
111
  # /chat/completions endpoint AND a native Anthropic /v1/messages endpoint.
@@ -146,12 +171,16 @@ module Clacky
146
171
  "default_model" => "kimi-k2.6",
147
172
  "models" => ["kimi-k2.6", "kimi-k2.5"],
148
173
  # Moonshot operates two regional endpoints with identical APIs & model
149
- # lineup — mainland China (.cn) and international (.ai). Kimi does not
150
- # distinguish pay-as-you-go vs coding-plan at the base_url level, so
151
- # only two variants are needed. Listing both here lets find_by_base_url
152
- # identify either one as provider "kimi", so downstream capability
153
- # checks, fallback chains, and provider-specific behaviours work
154
- # regardless of which endpoint the user configured.
174
+ # lineup — mainland China (.cn) and international (.ai). These are the
175
+ # pay-as-you-go Open Platform endpoints; the subscription-billed
176
+ # Coding Plan lives at api.kimi.com/coding with the unified
177
+ # `kimi-for-coding` model alias and is exposed as a separate
178
+ # top-level "kimi-coding" preset (different domain, distinct billing
179
+ # model, marketed by Moonshot as the standalone Kimi Code product).
180
+ # Listing both PAYG variants here lets find_by_base_url identify
181
+ # either one as provider "kimi", so downstream capability checks,
182
+ # fallback chains, and provider-specific behaviours work regardless
183
+ # of which endpoint the user configured.
155
184
  "endpoint_variants" => [
156
185
  { "label" => "Mainland China", "label_key" => "settings.models.baseurl.variant.mainland_cn", "base_url" => "https://api.moonshot.cn/v1", "region" => "cn" }.freeze,
157
186
  { "label" => "International", "label_key" => "settings.models.baseurl.variant.international", "base_url" => "https://api.moonshot.ai/v1", "region" => "intl" }.freeze
@@ -161,6 +190,44 @@ module Clacky
161
190
  "website_url" => "https://platform.moonshot.cn/console/api-keys"
162
191
  }.freeze,
163
192
 
193
+ "kimi-coding" => {
194
+ "name" => "Kimi Code (Coding Plan)",
195
+ # Subscription-billed Kimi Code endpoint — separate product from the
196
+ # PAYG Moonshot Open Platform (api.moonshot.cn/v1 / .ai/v1). Uses the
197
+ # unified `kimi-for-coding` model alias which the Coding Plan backend
198
+ # routes to the appropriate K2 variant (Kimi-k2.6 today; 262K context,
199
+ # 32K max output, supports vision/video/reasoning).
200
+ #
201
+ # Why anthropic-messages: Moonshot exposes the Coding Plan via two
202
+ # URLs on the same domain — an Anthropic-format endpoint at
203
+ # api.kimi.com/coding/ (used by Claude Code via ANTHROPIC_BASE_URL)
204
+ # and an OpenAI-compatible endpoint at api.kimi.com/coding/v1 (used
205
+ # by Roo Code etc.). We route through anthropic-messages so
206
+ # cache_control fields round-trip byte-for-byte (the OpenAI shim is
207
+ # lossy for cache_control semantics — see OpenRouter preset above
208
+ # for the same reason). Verified against the live endpoint: response
209
+ # payload includes cache_creation_input_tokens / cache_read_input_tokens,
210
+ # so the cache layer is real on this backend.
211
+ #
212
+ # User-Agent gate: this endpoint enforces a UA-prefix whitelist
213
+ # limited to first-party coding agents (Kimi CLI, Claude Code, Roo
214
+ # Code, Kilo Code, ...). Requests carrying openclacky's default
215
+ # Faraday UA are rejected with HTTP 403 access_terminated_error.
216
+ # Client#anthropic_connection injects a Claude Code-shaped UA when
217
+ # @provider_id == "kimi-coding" — see the comment in client.rb for
218
+ # the policy rationale.
219
+ #
220
+ # Source: https://www.kimi.com/code/docs/third-party-tools/other-coding-agents.html
221
+ "base_url" => "https://api.kimi.com/coding",
222
+ "api" => "anthropic-messages",
223
+ "default_model" => "kimi-for-coding",
224
+ "models" => ["kimi-for-coding"],
225
+ # K2.6 backend behind the alias is multimodal (image + video input,
226
+ # reasoning). Same vision capability as the PAYG kimi preset.
227
+ "capabilities" => { "vision" => true }.freeze,
228
+ "website_url" => "https://www.kimi.com/code"
229
+ }.freeze,
230
+
164
231
  "anthropic" => {
165
232
  "name" => "Anthropic (Claude)",
166
233
  "base_url" => "https://api.anthropic.com",
@@ -201,8 +268,8 @@ module Clacky
201
268
  "name" => "MiMo (Xiaomi)",
202
269
  "base_url" => "https://api.xiaomimimo.com/v1",
203
270
  "api" => "openai-completions",
204
- "default_model" => "mimo-v2-pro",
205
- "models" => ["mimo-v2-pro", "mimo-v2-omni"],
271
+ "default_model" => "mimo-v2.5-pro",
272
+ "models" => ["mimo-v2.5-pro", "mimo-v2-pro", "mimo-v2-omni"],
206
273
  # MiMo-V2-Pro is text-only; MiMo-V2-Omni supports vision (omni = multimodal).
207
274
  "capabilities" => { "vision" => false }.freeze,
208
275
  "model_capabilities" => {
@@ -0,0 +1,229 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../../adapters/base"
4
+ require_relative "api_client"
5
+ require_relative "gateway_client"
6
+ require_relative "../feishu/file_processor"
7
+ require "time"
8
+
9
+ module Clacky
10
+ module Channel
11
+ module Adapters
12
+ module Discord
13
+ # Discord adapter (bot mode).
14
+ # Receives messages via the Gateway WebSocket and sends via the REST API.
15
+ class Adapter < Base
16
+ MAX_IMAGE_BYTES = Clacky::Utils::FileProcessor::MAX_IMAGE_BYTES
17
+
18
+ def self.platform_id
19
+ :discord
20
+ end
21
+
22
+ def self.env_keys
23
+ %w[IM_DISCORD_BOT_TOKEN]
24
+ end
25
+
26
+ def self.platform_config(data)
27
+ {
28
+ bot_token: data["IM_DISCORD_BOT_TOKEN"]
29
+ }
30
+ end
31
+
32
+ def self.set_env_data(data, config)
33
+ data["IM_DISCORD_BOT_TOKEN"] = config[:bot_token]
34
+ end
35
+
36
+ def self.test_connection(fields)
37
+ bot_token = fields[:bot_token].to_s.strip
38
+ return { ok: false, error: "bot_token is required" } if bot_token.empty?
39
+
40
+ client = ApiClient.new(bot_token: bot_token)
41
+ me = client.me
42
+ if me["id"]
43
+ { ok: true, message: "Connected as #{me["username"]}##{me["discriminator"]} (id=#{me["id"]})" }
44
+ else
45
+ { ok: false, error: "Empty response from /users/@me" }
46
+ end
47
+ rescue ApiClient::ApiError => e
48
+ { ok: false, error: e.message }
49
+ rescue StandardError => e
50
+ { ok: false, error: e.message }
51
+ end
52
+
53
+ def initialize(config)
54
+ @config = config
55
+ @bot_token = config[:bot_token]
56
+ @api = ApiClient.new(bot_token: @bot_token)
57
+ @gateway = GatewayClient.new(bot_token: @bot_token)
58
+ @bot_user_id = nil
59
+ @running = false
60
+ @on_message = nil
61
+ end
62
+
63
+ def start(&on_message)
64
+ @running = true
65
+ @on_message = on_message
66
+
67
+ begin
68
+ me = @api.me
69
+ @bot_user_id = me["id"]
70
+ Clacky::Logger.info("[DiscordAdapter] authenticated as #{me["username"]} (id=#{@bot_user_id})")
71
+ rescue ApiClient::ApiError => e
72
+ Clacky::Logger.error("[DiscordAdapter] /users/@me failed, not retrying: #{e.message}")
73
+ return
74
+ end
75
+
76
+ @gateway.start do |evt|
77
+ handle_gateway_event(evt)
78
+ end
79
+ rescue GatewayClient::AuthError => e
80
+ Clacky::Logger.error("[DiscordAdapter] Authentication failed, not retrying: #{e.message}")
81
+ end
82
+
83
+ def stop
84
+ @running = false
85
+ @gateway.stop
86
+ end
87
+
88
+ def send_text(chat_id, text, reply_to: nil)
89
+ res = @api.send_message(chat_id, text, reply_to: reply_to)
90
+ { message_id: res["id"] }
91
+ rescue ApiClient::ApiError => e
92
+ Clacky::Logger.error("[DiscordAdapter] send_text failed: #{e.message}")
93
+ { message_id: nil }
94
+ end
95
+
96
+ def update_message(chat_id, message_id, text)
97
+ @api.edit_message(chat_id, message_id, text)
98
+ true
99
+ rescue ApiClient::ApiError => e
100
+ Clacky::Logger.warn("[DiscordAdapter] update_message failed: #{e.message}")
101
+ false
102
+ end
103
+
104
+ def supports_message_updates?
105
+ true
106
+ end
107
+
108
+ def send_file(chat_id, path, name: nil)
109
+ @api.send_file(chat_id, path, name: name)
110
+ rescue ApiClient::ApiError => e
111
+ Clacky::Logger.error("[DiscordAdapter] send_file failed: #{e.message}")
112
+ nil
113
+ end
114
+
115
+ def validate_config(config)
116
+ errors = []
117
+ errors << "bot_token is required" if config[:bot_token].nil? || config[:bot_token].empty?
118
+ errors
119
+ end
120
+
121
+ private def handle_gateway_event(evt)
122
+ return unless evt[:type] == :message
123
+ handle_message(evt[:data])
124
+ end
125
+
126
+ private def handle_message(msg)
127
+ return if msg.nil?
128
+ author = msg["author"] || {}
129
+
130
+ return if author["bot"] == true
131
+ return if @bot_user_id && author["id"] == @bot_user_id
132
+
133
+ chat_id = msg["channel_id"]
134
+ return unless chat_id
135
+
136
+ user_id = author["id"]
137
+ chat_type = msg["guild_id"] ? :group : :direct
138
+ mentioned_ids = Array(msg["mentions"]).map { |m| m["id"] }
139
+
140
+ if chat_type == :group
141
+ if @bot_user_id.nil?
142
+ Clacky::Logger.warn("[DiscordAdapter] bot_user_id unavailable; dropping group message")
143
+ return
144
+ end
145
+ return unless mentioned_ids.include?(@bot_user_id)
146
+ end
147
+
148
+ allowed_users = @config[:allowed_users]
149
+ if allowed_users && !allowed_users.empty?
150
+ return unless allowed_users.include?(user_id)
151
+ end
152
+
153
+ text = strip_bot_mention(msg["content"].to_s, @bot_user_id)
154
+ files = process_attachments(Array(msg["attachments"]), chat_id)
155
+
156
+ return if text.strip.empty? && files.empty?
157
+
158
+ event = {
159
+ type: :message,
160
+ platform: :discord,
161
+ chat_id: chat_id,
162
+ user_id: user_id,
163
+ text: text,
164
+ files: files,
165
+ message_id: msg["id"],
166
+ timestamp: parse_timestamp(msg["timestamp"]),
167
+ chat_type: chat_type,
168
+ mentioned_user_ids: mentioned_ids,
169
+ raw: msg
170
+ }
171
+
172
+ @on_message&.call(event)
173
+ rescue => e
174
+ Clacky::Logger.error("[DiscordAdapter] handle_message error: #{e.message}\n#{e.backtrace.first(3).join("\n")}")
175
+ begin
176
+ chat_id ||= msg && msg["channel_id"]
177
+ @api.send_message(chat_id, "Error processing message: #{e.message}") if chat_id
178
+ rescue
179
+ nil
180
+ end
181
+ end
182
+
183
+ private def strip_bot_mention(text, bot_id)
184
+ return text if bot_id.nil? || text.empty?
185
+ text.gsub(/<@!?#{Regexp.escape(bot_id)}>/, "").strip
186
+ end
187
+
188
+ private def process_attachments(attachments, chat_id)
189
+ files = []
190
+ attachments.each do |att|
191
+ url = att["url"]
192
+ filename = att["filename"] || "attachment"
193
+ next unless url
194
+
195
+ result = @api.download(url)
196
+ body = result[:body]
197
+ mime = att["content_type"] || result[:content_type]
198
+
199
+ if mime && mime.start_with?("image/")
200
+ if body.bytesize > MAX_IMAGE_BYTES
201
+ @api.send_message(chat_id, "Image too large (#{(body.bytesize / 1024.0).round(0).to_i}KB), max #{MAX_IMAGE_BYTES / 1024}KB")
202
+ next
203
+ end
204
+ require "base64"
205
+ data_url = "data:#{mime};base64,#{Base64.strict_encode64(body)}"
206
+ files << { name: filename, mime_type: mime, data_url: data_url }
207
+ else
208
+ files << Clacky::Utils::FileProcessor.save(body: body, filename: filename)
209
+ end
210
+ end
211
+ files
212
+ rescue => e
213
+ Clacky::Logger.warn("[DiscordAdapter] process_attachments error: #{e.message}")
214
+ files
215
+ end
216
+
217
+ private def parse_timestamp(iso)
218
+ return Time.now if iso.nil? || iso.empty?
219
+ Time.iso8601(iso)
220
+ rescue ArgumentError
221
+ Time.now
222
+ end
223
+ end
224
+
225
+ Adapters.register(:discord, Adapter)
226
+ end
227
+ end
228
+ end
229
+ end
@@ -0,0 +1,107 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "faraday"
4
+ require "json"
5
+
6
+ module Clacky
7
+ module Channel
8
+ module Adapters
9
+ module Discord
10
+ class ApiClient
11
+ DEFAULT_HOMEPAGE_URL = "https://discord.com"
12
+ BASE_URL = "#{DEFAULT_HOMEPAGE_URL}/api/v10/"
13
+
14
+ class ApiError < StandardError; end
15
+
16
+ def initialize(bot_token:)
17
+ @bot_token = bot_token
18
+ @conn = Faraday.new(url: BASE_URL) do |f|
19
+ f.headers["Authorization"] = "Bot #{@bot_token}"
20
+ f.headers["User-Agent"] = self.class.user_agent
21
+ f.response :raise_error
22
+ f.adapter Faraday.default_adapter
23
+ end
24
+ end
25
+
26
+ def me
27
+ request(:get, "users/@me")
28
+ end
29
+
30
+ def send_message(channel_id, content, reply_to: nil)
31
+ payload = { content: content.to_s }
32
+ payload[:message_reference] = { message_id: reply_to.to_s } if reply_to
33
+ request(:post, "channels/#{channel_id}/messages", payload)
34
+ end
35
+
36
+ def edit_message(channel_id, message_id, content)
37
+ request(:patch, "channels/#{channel_id}/messages/#{message_id}", { content: content.to_s })
38
+ end
39
+
40
+ def send_file(channel_id, path, name: nil)
41
+ raise ArgumentError, "File not found: #{path}" unless File.exist?(path)
42
+ filename = name || File.basename(path)
43
+ payload = { attachments: [{ id: 0, filename: filename }] }
44
+ io = Faraday::UploadIO.new(path, detect_mime(path), filename)
45
+
46
+ res = @conn.post("channels/#{channel_id}/messages") do |req|
47
+ req.body = { "payload_json" => JSON.generate(payload), "files[0]" => io }
48
+ end
49
+ parse_json(res.body)
50
+ rescue Faraday::Error => e
51
+ raise_api_error(e)
52
+ end
53
+
54
+ def download(url)
55
+ res = Faraday.get(url)
56
+ { body: res.body, content_type: res.headers["content-type"] }
57
+ end
58
+
59
+ # Discord requires User-Agent of the form "DiscordBot ($url, $versionNumber)".
60
+ # Requests with an invalid UA may be blocked at Cloudflare.
61
+ def self.user_agent
62
+ url = (Clacky::BrandConfig.load.homepage_url rescue nil) || DEFAULT_HOMEPAGE_URL
63
+ "DiscordBot (#{url}, #{Clacky::VERSION})"
64
+ end
65
+
66
+ private def request(verb, path, body = nil)
67
+ res = @conn.run_request(verb, path, body ? JSON.generate(body) : nil,
68
+ { "Content-Type" => "application/json" })
69
+ parse_json(res.body)
70
+ rescue Faraday::Error => e
71
+ raise_api_error(e)
72
+ end
73
+
74
+ private def raise_api_error(err)
75
+ status = err.response&.dig(:status)
76
+ body = err.response&.dig(:body).to_s
77
+ parsed = (JSON.parse(body) rescue nil)
78
+ msg = (parsed.is_a?(Hash) && parsed["message"]) || err.message
79
+ raise ApiError, "Discord API #{status}: #{msg}"
80
+ end
81
+
82
+ private def parse_json(body)
83
+ return {} if body.to_s.empty?
84
+ JSON.parse(body)
85
+ rescue JSON::ParserError => e
86
+ raise ApiError, "Invalid JSON response: #{e.message}"
87
+ end
88
+
89
+ private def detect_mime(path)
90
+ case File.extname(path).downcase
91
+ when ".jpg", ".jpeg" then "image/jpeg"
92
+ when ".png" then "image/png"
93
+ when ".gif" then "image/gif"
94
+ when ".webp" then "image/webp"
95
+ when ".mp4" then "video/mp4"
96
+ when ".mp3" then "audio/mpeg"
97
+ when ".pdf" then "application/pdf"
98
+ when ".txt" then "text/plain"
99
+ when ".json" then "application/json"
100
+ else "application/octet-stream"
101
+ end
102
+ end
103
+ end
104
+ end
105
+ end
106
+ end
107
+ end