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,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