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,272 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "websocket"
|
|
4
|
+
require "json"
|
|
5
|
+
require "uri"
|
|
6
|
+
require "openssl"
|
|
7
|
+
require "socket"
|
|
8
|
+
|
|
9
|
+
module Clacky
|
|
10
|
+
module Channel
|
|
11
|
+
module Adapters
|
|
12
|
+
module Discord
|
|
13
|
+
# WebSocket client for the Discord Gateway (v10, JSON, no compression).
|
|
14
|
+
# Implements identify, heartbeat, resume, and intent-aware error handling.
|
|
15
|
+
class GatewayClient
|
|
16
|
+
GATEWAY_URL = "wss://gateway.discord.gg/?v=10&encoding=json"
|
|
17
|
+
|
|
18
|
+
# GUILDS | GUILD_MESSAGES | GUILD_MESSAGE_REACTIONS | DIRECT_MESSAGES | MESSAGE_CONTENT
|
|
19
|
+
INTENTS = (1 << 0) | (1 << 9) | (1 << 10) | (1 << 12) | (1 << 15)
|
|
20
|
+
|
|
21
|
+
FATAL_CLOSE_CODES = [4004, 4010, 4011, 4012, 4013, 4014].freeze
|
|
22
|
+
RECONNECT_DELAY = 5
|
|
23
|
+
READ_TIMEOUT_S = 90
|
|
24
|
+
|
|
25
|
+
class AuthError < StandardError; end
|
|
26
|
+
|
|
27
|
+
def initialize(bot_token:)
|
|
28
|
+
@bot_token = bot_token
|
|
29
|
+
@running = false
|
|
30
|
+
@on_event = nil
|
|
31
|
+
|
|
32
|
+
@session_id = nil
|
|
33
|
+
@resume_gateway_url = nil
|
|
34
|
+
@last_seq = nil
|
|
35
|
+
@heartbeat_interval = nil
|
|
36
|
+
@heartbeat_acked = true
|
|
37
|
+
@heartbeat_thread = nil
|
|
38
|
+
|
|
39
|
+
@ws_socket = nil
|
|
40
|
+
@ws_open = false
|
|
41
|
+
@ws_version = nil
|
|
42
|
+
@incoming = nil
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def start(&on_event)
|
|
46
|
+
@running = true
|
|
47
|
+
@on_event = on_event
|
|
48
|
+
|
|
49
|
+
while @running
|
|
50
|
+
begin
|
|
51
|
+
connect_and_listen
|
|
52
|
+
rescue AuthError
|
|
53
|
+
@running = false
|
|
54
|
+
raise
|
|
55
|
+
rescue => e
|
|
56
|
+
Clacky::Logger.error("[DiscordGW] error: #{e.message}")
|
|
57
|
+
sleep RECONNECT_DELAY if @running
|
|
58
|
+
end
|
|
59
|
+
end
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
def stop
|
|
63
|
+
@running = false
|
|
64
|
+
@heartbeat_thread&.kill
|
|
65
|
+
send_raw_frame(:close, "") rescue nil
|
|
66
|
+
@ws_socket&.close rescue nil
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
private def connect_and_listen
|
|
70
|
+
ssl = nil
|
|
71
|
+
url = @resume_gateway_url ? "#{@resume_gateway_url}/?v=10&encoding=json" : GATEWAY_URL
|
|
72
|
+
uri = URI.parse(url)
|
|
73
|
+
port = uri.port || 443
|
|
74
|
+
|
|
75
|
+
Clacky::Logger.info("[DiscordGW] connecting to #{uri.host}:#{port} (resume=#{!@session_id.nil?})")
|
|
76
|
+
|
|
77
|
+
tcp = TCPSocket.new(uri.host, port)
|
|
78
|
+
ctx = OpenSSL::SSL::SSLContext.new
|
|
79
|
+
ctx.set_params(verify_mode: OpenSSL::SSL::VERIFY_PEER)
|
|
80
|
+
ssl = OpenSSL::SSL::SSLSocket.new(tcp, ctx)
|
|
81
|
+
ssl.hostname = uri.host
|
|
82
|
+
ssl.sync_close = true
|
|
83
|
+
ssl.connect
|
|
84
|
+
|
|
85
|
+
handshake = WebSocket::Handshake::Client.new(url: url)
|
|
86
|
+
ssl.write(handshake.to_s)
|
|
87
|
+
handshake << ssl.readpartial(4096) until handshake.finished?
|
|
88
|
+
raise "Gateway WebSocket handshake failed" unless handshake.valid?
|
|
89
|
+
|
|
90
|
+
@ws_version = handshake.version
|
|
91
|
+
@ws_socket = ssl
|
|
92
|
+
@ws_open = true
|
|
93
|
+
@incoming = WebSocket::Frame::Incoming::Client.new(version: @ws_version)
|
|
94
|
+
@heartbeat_acked = true
|
|
95
|
+
|
|
96
|
+
loop do
|
|
97
|
+
break unless @running
|
|
98
|
+
ready = IO.select([ssl], nil, nil, READ_TIMEOUT_S)
|
|
99
|
+
unless ready
|
|
100
|
+
Clacky::Logger.warn("[DiscordGW] read timeout, reconnecting")
|
|
101
|
+
return
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
data = ssl.read_nonblock(4096)
|
|
105
|
+
@incoming << data
|
|
106
|
+
while (frame = @incoming.next)
|
|
107
|
+
case frame.type
|
|
108
|
+
when :text
|
|
109
|
+
handle_payload(JSON.parse(frame.data))
|
|
110
|
+
when :ping
|
|
111
|
+
send_raw_frame(:pong, frame.data)
|
|
112
|
+
when :close
|
|
113
|
+
handle_close_frame(frame.data)
|
|
114
|
+
return
|
|
115
|
+
end
|
|
116
|
+
end
|
|
117
|
+
end
|
|
118
|
+
rescue EOFError, IOError, Errno::ECONNRESET, Errno::EPIPE,
|
|
119
|
+
Errno::ETIMEDOUT, OpenSSL::SSL::SSLError => e
|
|
120
|
+
Clacky::Logger.info("[DiscordGW] connection lost (#{e.class}: #{e.message})")
|
|
121
|
+
ensure
|
|
122
|
+
@ws_open = false
|
|
123
|
+
@ws_socket = nil
|
|
124
|
+
@heartbeat_thread&.kill
|
|
125
|
+
ssl&.close rescue nil
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
private def handle_payload(payload)
|
|
129
|
+
op = payload["op"]
|
|
130
|
+
data = payload["d"]
|
|
131
|
+
seq = payload["s"]
|
|
132
|
+
type = payload["t"]
|
|
133
|
+
|
|
134
|
+
@last_seq = seq if seq
|
|
135
|
+
|
|
136
|
+
case op
|
|
137
|
+
when 10
|
|
138
|
+
@heartbeat_interval = data["heartbeat_interval"]
|
|
139
|
+
Clacky::Logger.info("[DiscordGW] hello, heartbeat_interval=#{@heartbeat_interval}ms")
|
|
140
|
+
start_heartbeat_thread
|
|
141
|
+
if @session_id && @resume_gateway_url
|
|
142
|
+
send_resume
|
|
143
|
+
else
|
|
144
|
+
send_identify
|
|
145
|
+
end
|
|
146
|
+
when 0
|
|
147
|
+
handle_dispatch(type, data)
|
|
148
|
+
when 1
|
|
149
|
+
send_heartbeat
|
|
150
|
+
when 7
|
|
151
|
+
Clacky::Logger.info("[DiscordGW] server requested reconnect")
|
|
152
|
+
@ws_socket&.close rescue nil
|
|
153
|
+
when 9
|
|
154
|
+
resumable = data == true
|
|
155
|
+
Clacky::Logger.warn("[DiscordGW] invalid session (resumable=#{resumable})")
|
|
156
|
+
unless resumable
|
|
157
|
+
@session_id = nil
|
|
158
|
+
@resume_gateway_url = nil
|
|
159
|
+
@last_seq = nil
|
|
160
|
+
end
|
|
161
|
+
sleep(rand(1..5))
|
|
162
|
+
@ws_socket&.close rescue nil
|
|
163
|
+
when 11
|
|
164
|
+
@heartbeat_acked = true
|
|
165
|
+
end
|
|
166
|
+
end
|
|
167
|
+
|
|
168
|
+
private def handle_dispatch(type, data)
|
|
169
|
+
case type
|
|
170
|
+
when "READY"
|
|
171
|
+
@session_id = data["session_id"]
|
|
172
|
+
@resume_gateway_url = data["resume_gateway_url"]
|
|
173
|
+
user = data["user"] || {}
|
|
174
|
+
Clacky::Logger.info("[DiscordGW] READY as #{user["username"]} (id=#{user["id"]}), session=#{@session_id}")
|
|
175
|
+
when "RESUMED"
|
|
176
|
+
Clacky::Logger.info("[DiscordGW] RESUMED session=#{@session_id}")
|
|
177
|
+
when "MESSAGE_CREATE"
|
|
178
|
+
@on_event&.call(type: :message, data: data)
|
|
179
|
+
end
|
|
180
|
+
rescue => e
|
|
181
|
+
Clacky::Logger.error("[DiscordGW] dispatch handler error (#{type}): #{e.message}\n#{e.backtrace.first(3).join("\n")}")
|
|
182
|
+
end
|
|
183
|
+
|
|
184
|
+
private def handle_close_frame(data)
|
|
185
|
+
code = data.respond_to?(:code) ? data.code : nil
|
|
186
|
+
reason = data.respond_to?(:data) ? data.data : data.to_s
|
|
187
|
+
Clacky::Logger.warn("[DiscordGW] close frame code=#{code} reason=#{reason}")
|
|
188
|
+
if code && FATAL_CLOSE_CODES.include?(code)
|
|
189
|
+
@running = false
|
|
190
|
+
raise AuthError, "Discord rejected connection (code=#{code}): #{reason}"
|
|
191
|
+
end
|
|
192
|
+
end
|
|
193
|
+
|
|
194
|
+
private def send_identify
|
|
195
|
+
Clacky::Logger.info("[DiscordGW] sending Identify (intents=#{INTENTS})")
|
|
196
|
+
client_id = self.class.client_identifier
|
|
197
|
+
send_payload(
|
|
198
|
+
op: 2,
|
|
199
|
+
d: {
|
|
200
|
+
token: @bot_token,
|
|
201
|
+
intents: INTENTS,
|
|
202
|
+
properties: {
|
|
203
|
+
os: RUBY_PLATFORM,
|
|
204
|
+
browser: client_id,
|
|
205
|
+
device: client_id
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
)
|
|
209
|
+
end
|
|
210
|
+
|
|
211
|
+
def self.client_identifier
|
|
212
|
+
name = Clacky::BrandConfig.load.product_name
|
|
213
|
+
name = "OpenClacky" if name.nil? || name.strip.empty?
|
|
214
|
+
"#{name.strip}/#{Clacky::VERSION}"
|
|
215
|
+
end
|
|
216
|
+
|
|
217
|
+
private def send_resume
|
|
218
|
+
Clacky::Logger.info("[DiscordGW] sending Resume (session=#{@session_id} seq=#{@last_seq})")
|
|
219
|
+
send_payload(
|
|
220
|
+
op: 6,
|
|
221
|
+
d: { token: @bot_token, session_id: @session_id, seq: @last_seq }
|
|
222
|
+
)
|
|
223
|
+
end
|
|
224
|
+
|
|
225
|
+
private def send_heartbeat
|
|
226
|
+
unless @heartbeat_acked
|
|
227
|
+
Clacky::Logger.warn("[DiscordGW] missed heartbeat ack, forcing reconnect")
|
|
228
|
+
@ws_socket&.close rescue nil
|
|
229
|
+
return
|
|
230
|
+
end
|
|
231
|
+
@heartbeat_acked = false
|
|
232
|
+
send_payload(op: 1, d: @last_seq)
|
|
233
|
+
end
|
|
234
|
+
|
|
235
|
+
private def start_heartbeat_thread
|
|
236
|
+
@heartbeat_thread&.kill
|
|
237
|
+
interval_s = @heartbeat_interval.to_f / 1000.0
|
|
238
|
+
jitter = rand
|
|
239
|
+
@heartbeat_thread = Thread.new do
|
|
240
|
+
sleep(interval_s * jitter)
|
|
241
|
+
loop do
|
|
242
|
+
break unless @running && @ws_open
|
|
243
|
+
begin
|
|
244
|
+
send_heartbeat
|
|
245
|
+
rescue => e
|
|
246
|
+
Clacky::Logger.warn("[DiscordGW] heartbeat send failed: #{e.message}")
|
|
247
|
+
@ws_socket&.close rescue nil
|
|
248
|
+
break
|
|
249
|
+
end
|
|
250
|
+
sleep interval_s
|
|
251
|
+
end
|
|
252
|
+
end
|
|
253
|
+
end
|
|
254
|
+
|
|
255
|
+
private def send_payload(payload)
|
|
256
|
+
send_raw_frame(:text, JSON.generate(payload))
|
|
257
|
+
end
|
|
258
|
+
|
|
259
|
+
private def send_raw_frame(type, data)
|
|
260
|
+
return unless @ws_socket && @ws_open
|
|
261
|
+
outgoing = WebSocket::Frame::Outgoing::Client.new(
|
|
262
|
+
version: @ws_version || 13,
|
|
263
|
+
data: data,
|
|
264
|
+
type: type
|
|
265
|
+
)
|
|
266
|
+
@ws_socket.write(outgoing.to_s)
|
|
267
|
+
end
|
|
268
|
+
end
|
|
269
|
+
end
|
|
270
|
+
end
|
|
271
|
+
end
|
|
272
|
+
end
|
|
@@ -0,0 +1,375 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "base64"
|
|
4
|
+
require_relative "../../adapters/base"
|
|
5
|
+
require_relative "api_client"
|
|
6
|
+
|
|
7
|
+
module Clacky
|
|
8
|
+
module Channel
|
|
9
|
+
module Adapters
|
|
10
|
+
module Telegram
|
|
11
|
+
# Telegram Bot API adapter.
|
|
12
|
+
#
|
|
13
|
+
# Transport: HTTPS long-poll via getUpdates (no public domain required).
|
|
14
|
+
# Auth: single bot token obtained from @BotFather.
|
|
15
|
+
# Group rule: bots only react when @-mentioned or replied to (matches Feishu).
|
|
16
|
+
#
|
|
17
|
+
# Config keys (channels.yml `telegram`):
|
|
18
|
+
# bot_token String required — from @BotFather
|
|
19
|
+
# base_url String default "https://api.telegram.org"
|
|
20
|
+
# (override for self-hosted Bot API / proxy)
|
|
21
|
+
# parse_mode String default "Markdown" — set "" / nil to disable
|
|
22
|
+
# allowed_users Array optional whitelist of from.id (numeric, as String)
|
|
23
|
+
class Adapter < Base
|
|
24
|
+
# Telegram messages cap at 4096 UTF-16 code units; we leave a small margin.
|
|
25
|
+
MAX_MESSAGE_CHARS = 4000
|
|
26
|
+
|
|
27
|
+
MAX_IMAGE_BYTES = Clacky::Utils::FileProcessor::MAX_IMAGE_BYTES
|
|
28
|
+
|
|
29
|
+
def self.platform_id
|
|
30
|
+
:telegram
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def self.env_keys
|
|
34
|
+
%w[IM_TELEGRAM_BOT_TOKEN IM_TELEGRAM_BASE_URL IM_TELEGRAM_PARSE_MODE IM_TELEGRAM_ALLOWED_USERS]
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def self.platform_config(data)
|
|
38
|
+
{
|
|
39
|
+
bot_token: data["IM_TELEGRAM_BOT_TOKEN"] || data["bot_token"],
|
|
40
|
+
base_url: data["IM_TELEGRAM_BASE_URL"] || data["base_url"] || ApiClient::DEFAULT_BASE_URL,
|
|
41
|
+
parse_mode: data.key?("parse_mode") ? data["parse_mode"] : (data["IM_TELEGRAM_PARSE_MODE"] || "Markdown"),
|
|
42
|
+
allowed_users: (data["IM_TELEGRAM_ALLOWED_USERS"] || data["allowed_users"] || "")
|
|
43
|
+
.then { |v| v.is_a?(Array) ? v : v.to_s.split(",").map(&:strip).reject(&:empty?) }
|
|
44
|
+
}.compact
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def self.set_env_data(data, config)
|
|
48
|
+
data["IM_TELEGRAM_BOT_TOKEN"] = config[:bot_token]
|
|
49
|
+
data["IM_TELEGRAM_BASE_URL"] = config[:base_url] if config[:base_url]
|
|
50
|
+
data["IM_TELEGRAM_PARSE_MODE"] = config[:parse_mode] if config[:parse_mode]
|
|
51
|
+
data["IM_TELEGRAM_ALLOWED_USERS"] = Array(config[:allowed_users]).join(",")
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
# Verify credentials by calling getMe.
|
|
55
|
+
# @param fields [Hash] symbol-keyed credential fields
|
|
56
|
+
# @return [Hash] { ok: Boolean, message:/error: String }
|
|
57
|
+
def self.test_connection(fields)
|
|
58
|
+
token = fields[:bot_token].to_s.strip
|
|
59
|
+
return { ok: false, error: "bot_token is required" } if token.empty?
|
|
60
|
+
|
|
61
|
+
base_url = fields[:base_url].to_s.strip
|
|
62
|
+
base_url = ApiClient::DEFAULT_BASE_URL if base_url.empty?
|
|
63
|
+
|
|
64
|
+
client = ApiClient.new(token: token, base_url: base_url)
|
|
65
|
+
me = client.post("getMe", {})
|
|
66
|
+
{ ok: true, message: "Connected — bot @#{me["username"]} (id #{me["id"]})" }
|
|
67
|
+
rescue StandardError => e
|
|
68
|
+
{ ok: false, error: e.message }
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
def initialize(config)
|
|
72
|
+
@config = config
|
|
73
|
+
@token = config[:bot_token].to_s
|
|
74
|
+
@base_url = config[:base_url] || ApiClient::DEFAULT_BASE_URL
|
|
75
|
+
@parse_mode = config.key?(:parse_mode) ? config[:parse_mode] : "Markdown"
|
|
76
|
+
@parse_mode = nil if @parse_mode.to_s.empty?
|
|
77
|
+
@allowed_users = Array(config[:allowed_users]).map(&:to_s)
|
|
78
|
+
|
|
79
|
+
@api = ApiClient.new(token: @token, base_url: @base_url)
|
|
80
|
+
@running = false
|
|
81
|
+
@on_message = nil
|
|
82
|
+
@last_offset = nil
|
|
83
|
+
|
|
84
|
+
# Cached bot identity (used for @-mention check in groups).
|
|
85
|
+
@bot_username = nil
|
|
86
|
+
@bot_id = nil
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
# ── Lifecycle ──────────────────────────────────────────────────────
|
|
90
|
+
|
|
91
|
+
def start(&on_message)
|
|
92
|
+
@running = true
|
|
93
|
+
@on_message = on_message
|
|
94
|
+
|
|
95
|
+
ensure_bot_identity
|
|
96
|
+
|
|
97
|
+
Clacky::Logger.info("[TelegramAdapter] starting long-poll (base_url=#{@base_url})")
|
|
98
|
+
|
|
99
|
+
consecutive_errors = 0
|
|
100
|
+
while @running
|
|
101
|
+
begin
|
|
102
|
+
updates = @api.get_updates(offset: @last_offset)
|
|
103
|
+
consecutive_errors = 0
|
|
104
|
+
|
|
105
|
+
updates.each do |update|
|
|
106
|
+
@last_offset = update["update_id"] + 1
|
|
107
|
+
process_update(update)
|
|
108
|
+
rescue => e
|
|
109
|
+
Clacky::Logger.warn("[TelegramAdapter] process_update error: #{e.message}\n#{e.backtrace.first(3).join("\n")}")
|
|
110
|
+
end
|
|
111
|
+
rescue ApiClient::TimeoutError
|
|
112
|
+
# Long-poll cycle ended with no updates — just loop.
|
|
113
|
+
rescue ApiClient::ApiError => e
|
|
114
|
+
consecutive_errors += 1
|
|
115
|
+
Clacky::Logger.warn("[TelegramAdapter] API #{e.code}: #{e.description}")
|
|
116
|
+
sleep(consecutive_errors > 3 ? 30 : 5)
|
|
117
|
+
rescue => e
|
|
118
|
+
consecutive_errors += 1
|
|
119
|
+
Clacky::Logger.error("[TelegramAdapter] poll error: #{e.message}")
|
|
120
|
+
break unless @running
|
|
121
|
+
sleep(consecutive_errors > 3 ? 30 : 5)
|
|
122
|
+
end
|
|
123
|
+
end
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
def stop
|
|
127
|
+
@running = false
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
# ── Outbound (called by ChannelUIController) ────────────────────────
|
|
131
|
+
|
|
132
|
+
# Send a text message. Splits content longer than Telegram's 4096-char
|
|
133
|
+
# cap into multiple consecutive messages. Returns { message_id: } of
|
|
134
|
+
# the LAST chunk (matches the contract used by the other adapters).
|
|
135
|
+
def send_text(chat_id, text, reply_to: nil)
|
|
136
|
+
chunks = split_message(text.to_s)
|
|
137
|
+
return { message_id: nil } if chunks.empty?
|
|
138
|
+
|
|
139
|
+
last_message_id = nil
|
|
140
|
+
chunks.each_with_index do |chunk, i|
|
|
141
|
+
params = {
|
|
142
|
+
chat_id: chat_id.to_s,
|
|
143
|
+
text: chunk,
|
|
144
|
+
disable_web_page_preview: true
|
|
145
|
+
}
|
|
146
|
+
params[:parse_mode] = @parse_mode if @parse_mode
|
|
147
|
+
params[:reply_to_message_id] = reply_to.to_i if reply_to && i == 0
|
|
148
|
+
msg = @api.post("sendMessage", params)
|
|
149
|
+
last_message_id = msg["message_id"]
|
|
150
|
+
end
|
|
151
|
+
{ message_id: last_message_id }
|
|
152
|
+
rescue ApiClient::ApiError => e
|
|
153
|
+
# Markdown parse failures fall back to plain text — most common cause
|
|
154
|
+
# is unescaped Markdown reserved chars in the agent's output.
|
|
155
|
+
if @parse_mode && e.description.to_s =~ /can't parse entities|markdown/i
|
|
156
|
+
Clacky::Logger.warn("[TelegramAdapter] parse_mode failed, retrying as plain text: #{e.description}")
|
|
157
|
+
fallback = {
|
|
158
|
+
chat_id: chat_id.to_s,
|
|
159
|
+
text: text.to_s,
|
|
160
|
+
disable_web_page_preview: true
|
|
161
|
+
}
|
|
162
|
+
fallback[:reply_to_message_id] = reply_to.to_i if reply_to
|
|
163
|
+
msg = @api.post("sendMessage", fallback)
|
|
164
|
+
return { message_id: msg["message_id"] }
|
|
165
|
+
end
|
|
166
|
+
Clacky::Logger.error("[TelegramAdapter] send_text failed: #{e.message}")
|
|
167
|
+
{ message_id: nil }
|
|
168
|
+
rescue => e
|
|
169
|
+
Clacky::Logger.error("[TelegramAdapter] send_text failed: #{e.message}")
|
|
170
|
+
{ message_id: nil }
|
|
171
|
+
end
|
|
172
|
+
|
|
173
|
+
def send_file(chat_id, path, name: nil, reply_to: nil)
|
|
174
|
+
return { message_id: nil } unless File.exist?(path)
|
|
175
|
+
|
|
176
|
+
is_image = path.to_s.downcase.match?(/\.(png|jpe?g|gif|webp)\z/)
|
|
177
|
+
msg = if is_image
|
|
178
|
+
@api.send_photo(
|
|
179
|
+
chat_id: chat_id.to_s,
|
|
180
|
+
photo_path: path,
|
|
181
|
+
reply_to_message_id: reply_to&.to_i
|
|
182
|
+
)
|
|
183
|
+
else
|
|
184
|
+
@api.send_document(
|
|
185
|
+
chat_id: chat_id.to_s,
|
|
186
|
+
document_path: path,
|
|
187
|
+
filename: name,
|
|
188
|
+
reply_to_message_id: reply_to&.to_i
|
|
189
|
+
)
|
|
190
|
+
end
|
|
191
|
+
{ message_id: msg["message_id"] }
|
|
192
|
+
rescue => e
|
|
193
|
+
Clacky::Logger.error("[TelegramAdapter] send_file failed for #{path}: #{e.message}")
|
|
194
|
+
{ message_id: nil }
|
|
195
|
+
end
|
|
196
|
+
|
|
197
|
+
def update_message(chat_id, message_id, text)
|
|
198
|
+
@api.edit_message_text(
|
|
199
|
+
chat_id: chat_id.to_s,
|
|
200
|
+
message_id: message_id.to_i,
|
|
201
|
+
text: text,
|
|
202
|
+
parse_mode: @parse_mode
|
|
203
|
+
)
|
|
204
|
+
true
|
|
205
|
+
rescue => e
|
|
206
|
+
Clacky::Logger.warn("[TelegramAdapter] update_message failed: #{e.message}")
|
|
207
|
+
false
|
|
208
|
+
end
|
|
209
|
+
|
|
210
|
+
def supports_message_updates?
|
|
211
|
+
true
|
|
212
|
+
end
|
|
213
|
+
|
|
214
|
+
def validate_config(config)
|
|
215
|
+
errors = []
|
|
216
|
+
errors << "bot_token is required" if config[:bot_token].nil? || config[:bot_token].to_s.strip.empty?
|
|
217
|
+
errors
|
|
218
|
+
end
|
|
219
|
+
|
|
220
|
+
# ── Inbound ─────────────────────────────────────────────────────────
|
|
221
|
+
|
|
222
|
+
def ensure_bot_identity
|
|
223
|
+
me = @api.post("getMe", {})
|
|
224
|
+
@bot_id = me["id"]
|
|
225
|
+
@bot_username = me["username"]
|
|
226
|
+
Clacky::Logger.info("[TelegramAdapter] bot identity: @#{@bot_username} (id=#{@bot_id})")
|
|
227
|
+
rescue => e
|
|
228
|
+
Clacky::Logger.warn("[TelegramAdapter] getMe failed: #{e.message} — group @-mentions will be dropped")
|
|
229
|
+
end
|
|
230
|
+
|
|
231
|
+
def process_update(update)
|
|
232
|
+
msg = update["message"]
|
|
233
|
+
return unless msg
|
|
234
|
+
|
|
235
|
+
chat = msg["chat"] || {}
|
|
236
|
+
from = msg["from"] || {}
|
|
237
|
+
chat_id = chat["id"]
|
|
238
|
+
user_id = from["id"]
|
|
239
|
+
return unless chat_id && user_id
|
|
240
|
+
|
|
241
|
+
chat_type = chat["type"].to_s
|
|
242
|
+
is_group = %w[group supergroup].include?(chat_type)
|
|
243
|
+
text = msg["text"].to_s
|
|
244
|
+
|
|
245
|
+
if is_group
|
|
246
|
+
return unless group_mention?(msg, text)
|
|
247
|
+
text = strip_bot_mention(text)
|
|
248
|
+
end
|
|
249
|
+
|
|
250
|
+
if @allowed_users.any? && !@allowed_users.include?(user_id.to_s)
|
|
251
|
+
Clacky::Logger.debug("[TelegramAdapter] ignoring message from #{user_id} (not in allowed_users)")
|
|
252
|
+
return
|
|
253
|
+
end
|
|
254
|
+
|
|
255
|
+
files = collect_files(msg)
|
|
256
|
+
caption = msg["caption"].to_s
|
|
257
|
+
text = caption if text.empty? && !caption.empty?
|
|
258
|
+
return if text.strip.empty? && files.empty?
|
|
259
|
+
|
|
260
|
+
event = {
|
|
261
|
+
type: :message,
|
|
262
|
+
platform: :telegram,
|
|
263
|
+
chat_id: chat_id.to_s,
|
|
264
|
+
user_id: user_id.to_s,
|
|
265
|
+
text: text.strip,
|
|
266
|
+
files: files,
|
|
267
|
+
message_id: msg["message_id"].to_s,
|
|
268
|
+
timestamp: msg["date"] ? Time.at(msg["date"]) : Time.now,
|
|
269
|
+
chat_type: is_group ? :group : :direct,
|
|
270
|
+
raw: msg
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
Clacky::Logger.info("[TelegramAdapter] msg from #{user_id} in #{chat_id} (#{chat_type}): #{text.slice(0, 80)}")
|
|
274
|
+
@on_message&.call(event)
|
|
275
|
+
end
|
|
276
|
+
|
|
277
|
+
# The bot reacts to a group message only if:
|
|
278
|
+
# 1. text contains @<bot_username> as a mention entity, or
|
|
279
|
+
# 2. the message is a reply to a message authored by the bot
|
|
280
|
+
# Fail closed when bot identity is unknown — drop the message rather
|
|
281
|
+
# than respond to every line and spam the group.
|
|
282
|
+
def group_mention?(msg, text)
|
|
283
|
+
return false unless @bot_id
|
|
284
|
+
|
|
285
|
+
reply = msg["reply_to_message"]
|
|
286
|
+
return true if reply && reply.dig("from", "id") == @bot_id
|
|
287
|
+
|
|
288
|
+
entities = msg["entities"] || []
|
|
289
|
+
entities.any? do |e|
|
|
290
|
+
e["type"] == "mention" &&
|
|
291
|
+
text[e["offset"], e["length"]].to_s.casecmp?("@#{@bot_username}")
|
|
292
|
+
end
|
|
293
|
+
end
|
|
294
|
+
|
|
295
|
+
def strip_bot_mention(text)
|
|
296
|
+
return text unless @bot_username
|
|
297
|
+
text.gsub(/@#{Regexp.escape(@bot_username)}\b/i, "").strip
|
|
298
|
+
end
|
|
299
|
+
|
|
300
|
+
# Build file-attachment hashes for the agent's vision / file pipeline.
|
|
301
|
+
def collect_files(msg)
|
|
302
|
+
files = []
|
|
303
|
+
|
|
304
|
+
if msg["photo"].is_a?(Array) && !msg["photo"].empty?
|
|
305
|
+
# `photo` is an array of size variants — pick the largest.
|
|
306
|
+
largest = msg["photo"].max_by { |p| p["file_size"].to_i }
|
|
307
|
+
begin
|
|
308
|
+
raw = @api.download_file(largest["file_id"])
|
|
309
|
+
if raw.bytesize > MAX_IMAGE_BYTES
|
|
310
|
+
Clacky::Logger.warn("[TelegramAdapter] image too large (#{raw.bytesize}B), dropping")
|
|
311
|
+
else
|
|
312
|
+
mime = detect_image_mime(raw)
|
|
313
|
+
files << {
|
|
314
|
+
type: :image,
|
|
315
|
+
name: "image.jpg",
|
|
316
|
+
mime_type: mime,
|
|
317
|
+
data_url: "data:#{mime};base64,#{Base64.strict_encode64(raw)}"
|
|
318
|
+
}
|
|
319
|
+
end
|
|
320
|
+
rescue => e
|
|
321
|
+
Clacky::Logger.warn("[TelegramAdapter] image download failed: #{e.message}")
|
|
322
|
+
end
|
|
323
|
+
end
|
|
324
|
+
|
|
325
|
+
if (doc = msg["document"])
|
|
326
|
+
begin
|
|
327
|
+
raw = @api.download_file(doc["file_id"])
|
|
328
|
+
filename = doc["file_name"].to_s
|
|
329
|
+
filename = "attachment" if filename.empty?
|
|
330
|
+
saved = Clacky::Utils::FileProcessor.save(body: raw, filename: filename)
|
|
331
|
+
files << { type: :file, name: saved[:name], path: saved[:path] }
|
|
332
|
+
rescue => e
|
|
333
|
+
Clacky::Logger.warn("[TelegramAdapter] document download failed: #{e.message}")
|
|
334
|
+
end
|
|
335
|
+
end
|
|
336
|
+
|
|
337
|
+
files
|
|
338
|
+
end
|
|
339
|
+
|
|
340
|
+
def detect_image_mime(bytes)
|
|
341
|
+
return "image/jpeg" unless bytes && bytes.bytesize >= 4
|
|
342
|
+
head = bytes.byteslice(0, 8).bytes
|
|
343
|
+
return "image/png" if head[0] == 0x89 && head[1] == 0x50 && head[2] == 0x4E && head[3] == 0x47
|
|
344
|
+
return "image/gif" if head[0] == 0x47 && head[1] == 0x49 && head[2] == 0x46
|
|
345
|
+
return "image/webp" if head[0] == 0x52 && head[1] == 0x49 && head[2] == 0x46 && head[3] == 0x46
|
|
346
|
+
"image/jpeg"
|
|
347
|
+
end
|
|
348
|
+
|
|
349
|
+
# ── Helpers ─────────────────────────────────────────────────────────
|
|
350
|
+
|
|
351
|
+
# Split text at Telegram's 4096-char cap (we use 4000 as a margin).
|
|
352
|
+
# Prefers paragraph / line / space boundaries; hard-cuts as a last resort.
|
|
353
|
+
def split_message(text)
|
|
354
|
+
return [] if text.nil? || text.empty?
|
|
355
|
+
return [text] if text.length <= MAX_MESSAGE_CHARS
|
|
356
|
+
|
|
357
|
+
chunks = []
|
|
358
|
+
remaining = text.dup
|
|
359
|
+
while remaining.length > MAX_MESSAGE_CHARS
|
|
360
|
+
window = remaining[0, MAX_MESSAGE_CHARS]
|
|
361
|
+
cut = window.rindex("\n\n") || window.rindex("\n") || window.rindex(" ") || MAX_MESSAGE_CHARS
|
|
362
|
+
cut = MAX_MESSAGE_CHARS if cut.zero?
|
|
363
|
+
chunks << remaining[0, cut].rstrip
|
|
364
|
+
remaining = remaining[cut..].lstrip
|
|
365
|
+
end
|
|
366
|
+
chunks << remaining unless remaining.empty?
|
|
367
|
+
chunks
|
|
368
|
+
end
|
|
369
|
+
end
|
|
370
|
+
|
|
371
|
+
Adapters.register(:telegram, Adapter)
|
|
372
|
+
end
|
|
373
|
+
end
|
|
374
|
+
end
|
|
375
|
+
end
|