openclacky 1.0.4 → 1.1.0
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/.clacky/skills/gem-release/SKILL.md +99 -356
- data/.clacky/skills/gem-release/scripts/release.sh +304 -0
- data/CHANGELOG.md +42 -0
- data/docs/system-skill-authoring-guide.md +1 -1
- data/lib/clacky/agent/tool_executor.rb +3 -1
- data/lib/clacky/agent.rb +12 -7
- data/lib/clacky/agent_config.rb +9 -3
- data/lib/clacky/brand_config.rb +19 -4
- data/lib/clacky/cli.rb +1 -1
- data/lib/clacky/default_skills/{channel-setup → channel-manager}/SKILL.md +180 -18
- data/lib/clacky/default_skills/channel-manager/dingtalk_setup.rb +191 -0
- data/lib/clacky/default_skills/channel-manager/discord_setup.rb +199 -0
- data/lib/clacky/default_skills/channel-manager/install_feishu_skills.rb +105 -0
- data/lib/clacky/default_skills/onboard/SKILL.md +2 -2
- data/lib/clacky/default_skills/onboard/scripts/import_external_skills.rb +2 -4
- data/lib/clacky/default_skills/onboard/scripts/install_builtin_skills.rb +18 -96
- data/lib/clacky/default_skills/product-help/SKILL.md +10 -2
- data/lib/clacky/message_history.rb +26 -1
- data/lib/clacky/providers.rb +29 -4
- data/lib/clacky/server/channel/adapters/dingtalk/adapter.rb +177 -0
- data/lib/clacky/server/channel/adapters/dingtalk/api_client.rb +82 -0
- data/lib/clacky/server/channel/adapters/dingtalk/stream_client.rb +205 -0
- data/lib/clacky/server/channel/adapters/discord/adapter.rb +229 -0
- data/lib/clacky/server/channel/adapters/discord/api_client.rb +108 -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 +26 -0
- data/lib/clacky/server/channel.rb +3 -0
- data/lib/clacky/server/http_server.rb +75 -4
- data/lib/clacky/server/server_master.rb +35 -13
- data/lib/clacky/server/session_registry.rb +54 -3
- data/lib/clacky/server/web_ui_controller.rb +7 -1
- data/lib/clacky/telemetry.rb +1 -16
- data/lib/clacky/tools/browser.rb +8 -5
- data/lib/clacky/tools/glob.rb +11 -38
- data/lib/clacky/tools/grep.rb +7 -16
- data/lib/clacky/ui2/markdown_renderer.rb +1 -1
- data/lib/clacky/ui2/ui_controller.rb +2 -1
- data/lib/clacky/utils/file_ignore_helper.rb +49 -0
- data/lib/clacky/utils/gitignore_parser.rb +27 -0
- data/lib/clacky/version.rb +1 -1
- data/lib/clacky/web/app.css +248 -31
- data/lib/clacky/web/app.js +51 -1
- data/lib/clacky/web/channels.js +98 -28
- data/lib/clacky/web/datepicker.js +205 -0
- data/lib/clacky/web/i18n.js +48 -9
- data/lib/clacky/web/index.html +33 -6
- data/lib/clacky/web/onboard.js +46 -4
- data/lib/clacky/web/sessions.js +33 -72
- data/lib/clacky/web/settings.js +42 -4
- data/lib/clacky/web/version.js +52 -1
- metadata +21 -10
- data/docs/proposals/2026-05-11-system-prompt-alignment.md +0 -325
- data/docs/proposals/2026-05-12-memory-mechanism-optimization.md +0 -89
- /data/lib/clacky/default_skills/{channel-setup → channel-manager}/feishu_setup.rb +0 -0
- /data/lib/clacky/default_skills/{channel-setup → channel-manager}/import_lark_skills.rb +0 -0
- /data/lib/clacky/default_skills/{channel-setup → channel-manager}/weixin_setup.rb +0 -0
|
@@ -0,0 +1,177 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "../../adapters/base"
|
|
4
|
+
require_relative "stream_client"
|
|
5
|
+
require_relative "api_client"
|
|
6
|
+
|
|
7
|
+
module Clacky
|
|
8
|
+
module Channel
|
|
9
|
+
module Adapters
|
|
10
|
+
module DingTalk
|
|
11
|
+
class Adapter < Base
|
|
12
|
+
def self.platform_id
|
|
13
|
+
:dingtalk
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def self.env_keys
|
|
17
|
+
%w[IM_DINGTALK_CLIENT_ID IM_DINGTALK_CLIENT_SECRET IM_DINGTALK_ALLOWED_USERS]
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def self.platform_config(data)
|
|
21
|
+
{
|
|
22
|
+
client_id: data["IM_DINGTALK_CLIENT_ID"],
|
|
23
|
+
client_secret: data["IM_DINGTALK_CLIENT_SECRET"],
|
|
24
|
+
allowed_users: data["IM_DINGTALK_ALLOWED_USERS"]&.split(",")&.map(&:strip)&.reject(&:empty?)
|
|
25
|
+
}
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def self.set_env_data(data, config)
|
|
29
|
+
data["IM_DINGTALK_CLIENT_ID"] = config[:client_id]
|
|
30
|
+
data["IM_DINGTALK_CLIENT_SECRET"] = config[:client_secret]
|
|
31
|
+
data["IM_DINGTALK_ALLOWED_USERS"] = Array(config[:allowed_users]).join(",")
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def self.test_connection(fields)
|
|
35
|
+
client = ApiClient.new(
|
|
36
|
+
client_id: fields[:client_id].to_s.strip,
|
|
37
|
+
client_secret: fields[:client_secret].to_s.strip
|
|
38
|
+
)
|
|
39
|
+
client.test_connection
|
|
40
|
+
rescue => e
|
|
41
|
+
{ ok: false, error: e.message }
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def initialize(config)
|
|
45
|
+
@config = config
|
|
46
|
+
@api_client = ApiClient.new(
|
|
47
|
+
client_id: config[:client_id],
|
|
48
|
+
client_secret: config[:client_secret]
|
|
49
|
+
)
|
|
50
|
+
@stream_client = nil
|
|
51
|
+
@running = false
|
|
52
|
+
# chat_id => { url:, expires_at_ms: } — sessionWebhook is per-message
|
|
53
|
+
# and expires (~2h). We cache it from inbound events and validate on send.
|
|
54
|
+
@webhook_urls = {}
|
|
55
|
+
@webhook_mutex = Mutex.new
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
WEBHOOK_SAFETY_MARGIN_MS = 5 * 60 * 1000
|
|
59
|
+
|
|
60
|
+
def start(&on_message)
|
|
61
|
+
@running = true
|
|
62
|
+
@on_message = on_message
|
|
63
|
+
|
|
64
|
+
@stream_client = StreamClient.new(
|
|
65
|
+
client_id: @config[:client_id],
|
|
66
|
+
client_secret: @config[:client_secret]
|
|
67
|
+
)
|
|
68
|
+
@stream_client.start { |frame| handle_frame(frame) }
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
def stop
|
|
72
|
+
@running = false
|
|
73
|
+
@stream_client&.stop
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
# @param chat_id [String] — for DingTalk Stream Mode, chat_id == webhook URL
|
|
77
|
+
def send_text(chat_id, text, reply_to: nil)
|
|
78
|
+
webhook_url = resolve_webhook(chat_id)
|
|
79
|
+
unless webhook_url
|
|
80
|
+
Clacky::Logger.warn("[dingtalk] no valid sessionWebhook for chat #{chat_id} (expired or never received)")
|
|
81
|
+
return { ok: false, error: "session_webhook_expired" }
|
|
82
|
+
end
|
|
83
|
+
@api_client.send_via_webhook(webhook_url, text)
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
def validate_config(config)
|
|
87
|
+
errors = []
|
|
88
|
+
errors << "client_id is required" if config[:client_id].to_s.strip.empty?
|
|
89
|
+
errors << "client_secret is required" if config[:client_secret].to_s.strip.empty?
|
|
90
|
+
errors
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
private def handle_frame(frame)
|
|
94
|
+
topic = frame.dig("headers", "topic").to_s
|
|
95
|
+
return unless topic == "/v1.0/im/bot/messages/get"
|
|
96
|
+
|
|
97
|
+
data = begin
|
|
98
|
+
raw = frame["data"]
|
|
99
|
+
raw.is_a?(String) ? JSON.parse(raw) : raw
|
|
100
|
+
rescue JSON::ParserError
|
|
101
|
+
Clacky::Logger.warn("[dingtalk] failed to parse event data")
|
|
102
|
+
return
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
sender_id = data.dig("senderStaffId") || data.dig("senderId") || ""
|
|
106
|
+
chat_id = data.dig("conversationId") || sender_id
|
|
107
|
+
webhook_url = data.dig("sessionWebhook") || ""
|
|
108
|
+
expired_ms = (data.dig("sessionWebhookExpiredTime") || 0).to_i
|
|
109
|
+
text = extract_text(data)
|
|
110
|
+
conv_type = data.dig("conversationType").to_s # "1"=DM, "2"=group
|
|
111
|
+
|
|
112
|
+
cache_webhook(chat_id, webhook_url, expired_ms) unless webhook_url.empty?
|
|
113
|
+
|
|
114
|
+
return if sender_id.empty?
|
|
115
|
+
|
|
116
|
+
# Group chats: only respond when @-mentioned
|
|
117
|
+
if conv_type == "2"
|
|
118
|
+
content = data.dig("text", "content").to_s
|
|
119
|
+
at_users = Array(data.dig("atUsers")).map { |u| u.dig("dingtalkId") || u.dig("staffId") || "" }
|
|
120
|
+
bot_id = data.dig("chatbotUserId").to_s
|
|
121
|
+
unless at_users.include?(bot_id) || content.include?("@")
|
|
122
|
+
return
|
|
123
|
+
end
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
allowed = @config[:allowed_users]
|
|
127
|
+
return if allowed && !allowed.empty? && !allowed.include?(sender_id)
|
|
128
|
+
|
|
129
|
+
event = {
|
|
130
|
+
platform: :dingtalk,
|
|
131
|
+
user_id: sender_id,
|
|
132
|
+
chat_id: chat_id,
|
|
133
|
+
message_id: data.dig("msgId") || "",
|
|
134
|
+
text: text,
|
|
135
|
+
files: [],
|
|
136
|
+
chat_type: conv_type == "2" ? :group : :direct
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
Clacky::Logger.info("[dingtalk] message from #{sender_id}: #{text.to_s[0, 80]}")
|
|
140
|
+
@on_message&.call(event)
|
|
141
|
+
rescue => e
|
|
142
|
+
Clacky::Logger.warn("[dingtalk] handle_frame error: #{e.message}")
|
|
143
|
+
end
|
|
144
|
+
|
|
145
|
+
private def extract_text(data)
|
|
146
|
+
content = data.dig("text", "content").to_s.strip
|
|
147
|
+
# Strip leading @bot mention if present
|
|
148
|
+
content.gsub(/^@\S+\s*/, "").strip
|
|
149
|
+
end
|
|
150
|
+
|
|
151
|
+
private def cache_webhook(chat_id, url, expired_ms)
|
|
152
|
+
@webhook_mutex.synchronize do
|
|
153
|
+
@webhook_urls[chat_id] = { url: url, expires_at_ms: expired_ms }
|
|
154
|
+
end
|
|
155
|
+
end
|
|
156
|
+
|
|
157
|
+
private def resolve_webhook(chat_id)
|
|
158
|
+
entry = @webhook_mutex.synchronize { @webhook_urls[chat_id] }
|
|
159
|
+
return nil unless entry
|
|
160
|
+
|
|
161
|
+
expires_at = entry[:expires_at_ms].to_i
|
|
162
|
+
if expires_at > 0
|
|
163
|
+
now_ms = (Time.now.to_f * 1000).to_i
|
|
164
|
+
if now_ms + WEBHOOK_SAFETY_MARGIN_MS >= expires_at
|
|
165
|
+
@webhook_mutex.synchronize { @webhook_urls.delete(chat_id) }
|
|
166
|
+
return nil
|
|
167
|
+
end
|
|
168
|
+
end
|
|
169
|
+
entry[:url]
|
|
170
|
+
end
|
|
171
|
+
end
|
|
172
|
+
|
|
173
|
+
Adapters.register(:dingtalk, Adapter)
|
|
174
|
+
end
|
|
175
|
+
end
|
|
176
|
+
end
|
|
177
|
+
end
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "net/http"
|
|
4
|
+
require "uri"
|
|
5
|
+
require "json"
|
|
6
|
+
|
|
7
|
+
module Clacky
|
|
8
|
+
module Channel
|
|
9
|
+
module Adapters
|
|
10
|
+
module DingTalk
|
|
11
|
+
# DingTalk Bot API client — sends messages via session webhook (Stream Mode).
|
|
12
|
+
class ApiClient
|
|
13
|
+
OPENAPI_BASE = "https://api.dingtalk.com"
|
|
14
|
+
|
|
15
|
+
def initialize(client_id:, client_secret:)
|
|
16
|
+
@client_id = client_id
|
|
17
|
+
@client_secret = client_secret
|
|
18
|
+
@token = nil
|
|
19
|
+
@token_expires_at = 0
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
# Send a text (or Markdown) message via the session webhook URL.
|
|
23
|
+
# In Stream Mode, inbound events carry a `sessionWebhook` — use that directly.
|
|
24
|
+
# @param webhook_url [String]
|
|
25
|
+
# @param text [String]
|
|
26
|
+
# @param msg_type [:text, :markdown] (default :text)
|
|
27
|
+
def send_via_webhook(webhook_url, text, msg_type: :text)
|
|
28
|
+
body = if msg_type == :markdown
|
|
29
|
+
{ msgtype: "markdown", markdown: { title: "Reply", text: text } }
|
|
30
|
+
else
|
|
31
|
+
{ msgtype: "text", text: { content: text } }
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
uri = URI.parse(webhook_url)
|
|
35
|
+
http = Net::HTTP.new(uri.host, uri.port)
|
|
36
|
+
http.use_ssl = uri.scheme == "https"
|
|
37
|
+
req = Net::HTTP::Post.new(uri.request_uri, "Content-Type" => "application/json")
|
|
38
|
+
req.body = JSON.generate(body)
|
|
39
|
+
resp = http.request(req)
|
|
40
|
+
data = JSON.parse(resp.body) rescue {}
|
|
41
|
+
if resp.code.to_i != 200 || (data["errcode"] && data["errcode"] != 0)
|
|
42
|
+
Clacky::Logger.warn("[dingtalk] webhook send rejected (#{resp.code}): #{resp.body}")
|
|
43
|
+
end
|
|
44
|
+
data
|
|
45
|
+
rescue => e
|
|
46
|
+
Clacky::Logger.warn("[dingtalk] webhook send failed: #{e.message}")
|
|
47
|
+
{}
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
# Fetch a short-lived access token (cached for its lifetime).
|
|
51
|
+
def access_token
|
|
52
|
+
return @token if @token && Time.now.to_i < @token_expires_at - 60
|
|
53
|
+
|
|
54
|
+
uri = URI.parse("#{OPENAPI_BASE}/v1.0/oauth2/accessToken")
|
|
55
|
+
http = Net::HTTP.new(uri.host, uri.port)
|
|
56
|
+
http.use_ssl = true
|
|
57
|
+
req = Net::HTTP::Post.new(uri.path, "Content-Type" => "application/json")
|
|
58
|
+
req.body = JSON.generate({ appKey: @client_id, appSecret: @client_secret })
|
|
59
|
+
|
|
60
|
+
resp = http.request(req)
|
|
61
|
+
data = JSON.parse(resp.body)
|
|
62
|
+
|
|
63
|
+
raise "DingTalk token error (#{resp.code}): #{data["message"] || resp.body}" unless resp.code.to_i == 200
|
|
64
|
+
|
|
65
|
+
@token = data["accessToken"] || raise("Missing accessToken in response")
|
|
66
|
+
@token_expires_at = Time.now.to_i + (data["expireIn"] || 7200).to_i
|
|
67
|
+
@token
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
# Validate credentials by fetching a token.
|
|
71
|
+
# @return [Hash] { ok: Boolean, error: String? }
|
|
72
|
+
def test_connection
|
|
73
|
+
access_token
|
|
74
|
+
{ ok: true, message: "DingTalk access token obtained" }
|
|
75
|
+
rescue => e
|
|
76
|
+
{ ok: false, error: e.message }
|
|
77
|
+
end
|
|
78
|
+
end
|
|
79
|
+
end
|
|
80
|
+
end
|
|
81
|
+
end
|
|
82
|
+
end
|
|
@@ -0,0 +1,205 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "websocket"
|
|
4
|
+
require "json"
|
|
5
|
+
require "net/http"
|
|
6
|
+
require "uri"
|
|
7
|
+
|
|
8
|
+
module Clacky
|
|
9
|
+
module Channel
|
|
10
|
+
module Adapters
|
|
11
|
+
module DingTalk
|
|
12
|
+
DINGTALK_API_BASE = "https://api.dingtalk.com"
|
|
13
|
+
RECONNECT_DELAY = 5
|
|
14
|
+
|
|
15
|
+
# WebSocket Stream Mode client for DingTalk.
|
|
16
|
+
# DingTalk Stream Mode uses JSON frames (unlike Feishu's protobuf).
|
|
17
|
+
# Frame shape:
|
|
18
|
+
# { "specVersion": "1.0", "type": "SYSTEM"|"EVENT"|"CALLBACK",
|
|
19
|
+
# "headers": { "messageId": "...", "time": "...", "topic": "...", ... },
|
|
20
|
+
# "data": "..." }
|
|
21
|
+
class StreamClient
|
|
22
|
+
def initialize(client_id:, client_secret:)
|
|
23
|
+
@client_id = client_id
|
|
24
|
+
@client_secret = client_secret
|
|
25
|
+
@running = false
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def start(&on_event)
|
|
29
|
+
@running = true
|
|
30
|
+
@on_event = on_event
|
|
31
|
+
Clacky::Logger.info("[dingtalk-ws] Starting stream client (client_id=#{@client_id})")
|
|
32
|
+
|
|
33
|
+
while @running
|
|
34
|
+
begin
|
|
35
|
+
connect_and_listen
|
|
36
|
+
rescue => e
|
|
37
|
+
Clacky::Logger.warn("[dingtalk-ws] Connection error: #{e.class}: #{e.message}")
|
|
38
|
+
sleep RECONNECT_DELAY if @running
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def stop
|
|
44
|
+
@running = false
|
|
45
|
+
@ws_socket&.close rescue nil
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
private def connect_and_listen
|
|
49
|
+
Clacky::Logger.info("[dingtalk-ws] Fetching stream endpoint...")
|
|
50
|
+
endpoint, ticket = fetch_stream_endpoint
|
|
51
|
+
full_url = "#{endpoint}?ticket=#{ticket}"
|
|
52
|
+
Clacky::Logger.info("[dingtalk-ws] Connecting to #{endpoint}")
|
|
53
|
+
|
|
54
|
+
uri = URI.parse(full_url)
|
|
55
|
+
port = uri.port || 443
|
|
56
|
+
tcp = TCPSocket.new(uri.host, port)
|
|
57
|
+
|
|
58
|
+
socket = begin
|
|
59
|
+
require "openssl"
|
|
60
|
+
ctx = OpenSSL::SSL::SSLContext.new
|
|
61
|
+
ctx.set_params(verify_mode: OpenSSL::SSL::VERIFY_PEER)
|
|
62
|
+
ssl = OpenSSL::SSL::SSLSocket.new(tcp, ctx)
|
|
63
|
+
ssl.hostname = uri.host
|
|
64
|
+
ssl.sync_close = true
|
|
65
|
+
ssl.connect
|
|
66
|
+
ssl
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
handshake = WebSocket::Handshake::Client.new(url: full_url)
|
|
70
|
+
socket.write(handshake.to_s)
|
|
71
|
+
until handshake.finished?
|
|
72
|
+
handshake << socket.readpartial(4096)
|
|
73
|
+
end
|
|
74
|
+
raise "WebSocket handshake failed" unless handshake.valid?
|
|
75
|
+
|
|
76
|
+
Clacky::Logger.info("[dingtalk-ws] WebSocket connected")
|
|
77
|
+
@ws_version = handshake.version
|
|
78
|
+
@ws_socket = socket
|
|
79
|
+
@incoming = WebSocket::Frame::Incoming::Client.new(version: @ws_version)
|
|
80
|
+
|
|
81
|
+
loop do
|
|
82
|
+
break unless @running
|
|
83
|
+
|
|
84
|
+
ready = IO.select([socket], nil, nil, 120)
|
|
85
|
+
unless ready
|
|
86
|
+
Clacky::Logger.warn("[dingtalk-ws] read timeout, reconnecting...")
|
|
87
|
+
return
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
data = socket.read_nonblock(4096)
|
|
91
|
+
@incoming << data
|
|
92
|
+
while (frame = @incoming.next)
|
|
93
|
+
case frame.type
|
|
94
|
+
when :text then handle_frame(frame.data)
|
|
95
|
+
when :ping then send_frame(:pong, frame.data)
|
|
96
|
+
when :close
|
|
97
|
+
Clacky::Logger.info("[dingtalk-ws] Server closed connection, will reconnect")
|
|
98
|
+
return
|
|
99
|
+
end
|
|
100
|
+
end
|
|
101
|
+
end
|
|
102
|
+
rescue EOFError, IOError, Errno::ECONNRESET, Errno::EPIPE,
|
|
103
|
+
Errno::ETIMEDOUT, OpenSSL::SSL::SSLError => e
|
|
104
|
+
raise
|
|
105
|
+
ensure
|
|
106
|
+
@ws_socket = nil
|
|
107
|
+
socket&.close rescue nil
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
private def fetch_stream_endpoint
|
|
111
|
+
uri = URI.parse("#{DINGTALK_API_BASE}/v1.0/gateway/connections/open")
|
|
112
|
+
http = Net::HTTP.new(uri.host, uri.port)
|
|
113
|
+
http.use_ssl = true
|
|
114
|
+
|
|
115
|
+
req = Net::HTTP::Post.new(uri.path)
|
|
116
|
+
req["Content-Type"] = "application/json"
|
|
117
|
+
req["Accept"] = "application/json"
|
|
118
|
+
req.body = JSON.generate({
|
|
119
|
+
clientId: @client_id,
|
|
120
|
+
clientSecret: @client_secret,
|
|
121
|
+
subscriptions: [
|
|
122
|
+
{ type: "CALLBACK", topic: "/v1.0/im/bot/messages/get" }
|
|
123
|
+
],
|
|
124
|
+
ua: self.class.client_identifier,
|
|
125
|
+
localIp: "127.0.0.1"
|
|
126
|
+
})
|
|
127
|
+
|
|
128
|
+
resp = http.request(req)
|
|
129
|
+
data = JSON.parse(resp.body)
|
|
130
|
+
|
|
131
|
+
unless resp.code.to_i == 200
|
|
132
|
+
raise "Stream endpoint error (#{resp.code}): #{data["message"] || resp.body}"
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
endpoint = data.dig("endpoint") || raise("Missing endpoint in response")
|
|
136
|
+
ticket = data.dig("ticket") || ""
|
|
137
|
+
[endpoint, ticket]
|
|
138
|
+
end
|
|
139
|
+
|
|
140
|
+
def self.client_identifier
|
|
141
|
+
name = Clacky::BrandConfig.load.product_name
|
|
142
|
+
name = "OpenClacky" if name.nil? || name.strip.empty?
|
|
143
|
+
"#{name.strip}/#{Clacky::VERSION}"
|
|
144
|
+
end
|
|
145
|
+
|
|
146
|
+
private def handle_frame(json_text)
|
|
147
|
+
frame = JSON.parse(json_text)
|
|
148
|
+
type = frame["type"]
|
|
149
|
+
|
|
150
|
+
case type
|
|
151
|
+
when "SYSTEM"
|
|
152
|
+
handle_system(frame)
|
|
153
|
+
when "EVENT", "CALLBACK"
|
|
154
|
+
send_ack(frame)
|
|
155
|
+
@on_event&.call(frame)
|
|
156
|
+
end
|
|
157
|
+
rescue JSON::ParserError => e
|
|
158
|
+
Clacky::Logger.warn("[dingtalk-ws] JSON parse error: #{e.message}")
|
|
159
|
+
end
|
|
160
|
+
|
|
161
|
+
private def handle_system(frame)
|
|
162
|
+
topic = frame.dig("headers", "topic").to_s
|
|
163
|
+
case topic
|
|
164
|
+
when "ping"
|
|
165
|
+
pong = frame.merge("type" => "SYSTEM",
|
|
166
|
+
"headers" => frame["headers"].merge("topic" => "pong"))
|
|
167
|
+
send_text(JSON.generate(pong))
|
|
168
|
+
Clacky::Logger.info("[dingtalk-ws] pong sent")
|
|
169
|
+
when "disconnect"
|
|
170
|
+
Clacky::Logger.warn("[dingtalk-ws] Server requested disconnect, will reconnect")
|
|
171
|
+
@ws_socket&.close rescue nil
|
|
172
|
+
end
|
|
173
|
+
end
|
|
174
|
+
|
|
175
|
+
private def send_ack(frame)
|
|
176
|
+
ack = {
|
|
177
|
+
"code" => 200,
|
|
178
|
+
"headers" => {
|
|
179
|
+
"messageId" => frame.dig("headers", "messageId"),
|
|
180
|
+
"topic" => "ack"
|
|
181
|
+
},
|
|
182
|
+
"message" => "OK",
|
|
183
|
+
"data" => ""
|
|
184
|
+
}
|
|
185
|
+
send_text(JSON.generate(ack))
|
|
186
|
+
end
|
|
187
|
+
|
|
188
|
+
private def send_frame(type, data)
|
|
189
|
+
return unless @ws_socket
|
|
190
|
+
outgoing = WebSocket::Frame::Outgoing::Client.new(
|
|
191
|
+
version: @ws_version || 13,
|
|
192
|
+
data: data,
|
|
193
|
+
type: type
|
|
194
|
+
)
|
|
195
|
+
@ws_socket.write(outgoing.to_s)
|
|
196
|
+
end
|
|
197
|
+
|
|
198
|
+
private def send_text(text)
|
|
199
|
+
send_frame(:text, text)
|
|
200
|
+
end
|
|
201
|
+
end
|
|
202
|
+
end
|
|
203
|
+
end
|
|
204
|
+
end
|
|
205
|
+
end
|
|
@@ -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
|