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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +34 -1
- data/benchmark/fixtures/sample_project/Gemfile +3 -0
- data/benchmark/fixtures/sample_project/lib/api_handler.rb +32 -0
- data/benchmark/fixtures/sample_project/lib/order_calculator.rb +23 -0
- data/benchmark/fixtures/sample_project/lib/user_renderer.rb +20 -0
- data/benchmark/fixtures/sample_project/spec/order_calculator_spec.rb +20 -0
- data/benchmark/results/EVALUATION_REPORT.md +165 -0
- data/benchmark/results/baseline_20260511_174424.json +128 -0
- data/benchmark/results/report_20260511_175256.json +271 -0
- data/benchmark/results/report_20260511_175444.json +271 -0
- data/benchmark/results/treatment_20260511_175103.json +130 -0
- data/benchmark/runner.rb +441 -0
- data/docs/proposals/2026-05-11-system-prompt-alignment.md +325 -0
- data/docs/proposals/2026-05-12-memory-mechanism-optimization.md +89 -0
- data/lib/clacky/agent/cost_tracker.rb +8 -2
- data/lib/clacky/agent/memory_updater.rb +41 -30
- data/lib/clacky/agent/skill_manager.rb +5 -2
- data/lib/clacky/agent/skill_reflector.rb +10 -1
- data/lib/clacky/agent.rb +4 -0
- data/lib/clacky/client.rb +15 -0
- data/lib/clacky/default_agents/base_prompt.md +20 -20
- data/lib/clacky/default_agents/coding/system_prompt.md +51 -1
- data/lib/clacky/default_skills/channel-setup/SKILL.md +190 -14
- data/lib/clacky/default_skills/channel-setup/discord_setup.rb +199 -0
- data/lib/clacky/default_skills/channel-setup/import_lark_skills.rb +97 -0
- data/lib/clacky/default_skills/onboard/SKILL.md +1 -1
- data/lib/clacky/default_skills/persist-memory/SKILL.md +59 -0
- data/lib/clacky/providers.rb +77 -10
- data/lib/clacky/server/channel/adapters/discord/adapter.rb +229 -0
- data/lib/clacky/server/channel/adapters/discord/api_client.rb +107 -0
- data/lib/clacky/server/channel/adapters/discord/gateway_client.rb +272 -0
- data/lib/clacky/server/channel/adapters/telegram/adapter.rb +375 -0
- data/lib/clacky/server/channel/adapters/telegram/api_client.rb +205 -0
- data/lib/clacky/server/channel/channel_config.rb +11 -0
- data/lib/clacky/server/channel.rb +2 -0
- data/lib/clacky/server/http_server.rb +69 -3
- data/lib/clacky/ui2/ui_controller.rb +2 -1
- data/lib/clacky/utils/file_processor.rb +71 -0
- data/lib/clacky/version.rb +1 -1
- data/lib/clacky/web/app.css +44 -0
- data/lib/clacky/web/channels.js +16 -0
- data/lib/clacky/web/i18n.js +24 -2
- data/lib/clacky/web/index.html +6 -1
- data/lib/clacky/web/settings.js +4 -0
- data/lib/clacky/web/version.js +52 -1
- 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.
|
data/lib/clacky/providers.rb
CHANGED
|
@@ -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-
|
|
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
|
-
|
|
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).
|
|
150
|
-
#
|
|
151
|
-
#
|
|
152
|
-
#
|
|
153
|
-
#
|
|
154
|
-
#
|
|
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
|