openclacky 0.9.7 → 0.9.9

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 (85) hide show
  1. checksums.yaml +4 -4
  2. data/.clacky/skills/gem-release/SKILL.md +16 -1
  3. data/CHANGELOG.md +51 -0
  4. data/docs/browser-cdp-native-design.md +195 -0
  5. data/docs/session-management-redesign.md +202 -0
  6. data/docs/system-skill-authoring-guide.md +47 -0
  7. data/lib/clacky/agent/cost_tracker.rb +2 -1
  8. data/lib/clacky/agent/message_compressor_helper.rb +2 -2
  9. data/lib/clacky/agent/session_serializer.rb +36 -3
  10. data/lib/clacky/agent/skill_manager.rb +68 -5
  11. data/lib/clacky/agent/system_prompt_builder.rb +30 -13
  12. data/lib/clacky/agent/tool_executor.rb +8 -12
  13. data/lib/clacky/agent.rb +172 -30
  14. data/lib/clacky/brand_config.rb +90 -0
  15. data/lib/clacky/cli.rb +37 -8
  16. data/lib/clacky/default_parsers/doc_parser.rb +69 -0
  17. data/lib/clacky/default_parsers/docx_parser.rb +172 -0
  18. data/lib/clacky/default_parsers/pdf_parser.rb +79 -0
  19. data/lib/clacky/default_parsers/pptx_parser.rb +140 -0
  20. data/lib/clacky/default_parsers/xlsx_parser.rb +121 -0
  21. data/lib/clacky/default_skills/browser-setup/SKILL.md +158 -0
  22. data/lib/clacky/default_skills/channel-setup/SKILL.md +139 -42
  23. data/lib/clacky/default_skills/channel-setup/feishu_setup.rb +582 -0
  24. data/lib/clacky/default_skills/channel-setup/weixin_setup.rb +274 -0
  25. data/lib/clacky/default_skills/onboard/SKILL.md +132 -6
  26. data/lib/clacky/default_skills/personal-website/SKILL.md +113 -0
  27. data/lib/clacky/default_skills/personal-website/publish.rb +214 -0
  28. data/lib/clacky/default_skills/skill-add/SKILL.md +6 -7
  29. data/lib/clacky/default_skills/skill-add/scripts/install_from_zip.rb +1 -1
  30. data/lib/clacky/default_skills/skill-creator/SKILL.md +3 -3
  31. data/lib/clacky/json_ui_controller.rb +5 -0
  32. data/lib/clacky/message_format/open_ai.rb +7 -1
  33. data/lib/clacky/plain_ui_controller.rb +5 -0
  34. data/lib/clacky/server/browser_manager.rb +308 -0
  35. data/lib/clacky/server/channel/adapters/feishu/adapter.rb +80 -15
  36. data/lib/clacky/server/channel/adapters/feishu/bot.rb +216 -7
  37. data/lib/clacky/server/channel/adapters/feishu/message_parser.rb +12 -0
  38. data/lib/clacky/server/channel/adapters/wecom/adapter.rb +2 -8
  39. data/lib/clacky/server/channel/adapters/weixin/adapter.rb +391 -0
  40. data/lib/clacky/server/channel/adapters/weixin/api_client.rb +374 -0
  41. data/lib/clacky/server/channel/channel_config.rb +6 -0
  42. data/lib/clacky/server/channel/channel_manager.rb +50 -15
  43. data/lib/clacky/server/channel/channel_ui_controller.rb +18 -0
  44. data/lib/clacky/server/channel.rb +1 -0
  45. data/lib/clacky/server/http_server.rb +210 -45
  46. data/lib/clacky/server/scheduler.rb +1 -1
  47. data/lib/clacky/server/session_registry.rb +131 -40
  48. data/lib/clacky/server/web_ui_controller.rb +21 -2
  49. data/lib/clacky/session_manager.rb +43 -73
  50. data/lib/clacky/skill.rb +64 -42
  51. data/lib/clacky/tools/browser.rb +728 -183
  52. data/lib/clacky/tools/invoke_skill.rb +18 -11
  53. data/lib/clacky/tools/shell.rb +16 -7
  54. data/lib/clacky/tools/web_fetch.rb +3 -1
  55. data/lib/clacky/ui2/README.md +1 -1
  56. data/lib/clacky/ui2/components/input_area.rb +5 -2
  57. data/lib/clacky/ui2/components/welcome_banner.rb +23 -0
  58. data/lib/clacky/ui2/ui_controller.rb +16 -2
  59. data/lib/clacky/ui_interface.rb +7 -0
  60. data/lib/clacky/utils/file_processor.rb +116 -139
  61. data/lib/clacky/utils/model_pricing.rb +5 -4
  62. data/lib/clacky/utils/parser_manager.rb +93 -0
  63. data/lib/clacky/utils/workspace_rules.rb +46 -0
  64. data/lib/clacky/version.rb +1 -1
  65. data/lib/clacky/web/app.css +696 -62
  66. data/lib/clacky/web/app.js +181 -51
  67. data/lib/clacky/web/brand.js +14 -0
  68. data/lib/clacky/web/channels.js +10 -1
  69. data/lib/clacky/web/i18n.js +49 -7
  70. data/lib/clacky/web/index.html +67 -10
  71. data/lib/clacky/web/onboard.js +2 -1
  72. data/lib/clacky/web/sessions.js +255 -69
  73. data/lib/clacky/web/settings.js +113 -1
  74. data/lib/clacky/web/skills.js +16 -3
  75. data/lib/clacky/web/tasks.js +11 -6
  76. data/lib/clacky/web/weixin-qr.html +104 -0
  77. data/lib/clacky.rb +2 -0
  78. data/scripts/install.sh +41 -11
  79. metadata +48 -7
  80. data/lib/clacky/default_skills/pdf-reader/SKILL.md +0 -90
  81. data/lib/clacky/utils/file_parser/docx_parser.rb +0 -156
  82. data/lib/clacky/utils/file_parser/pptx_parser.rb +0 -116
  83. data/lib/clacky/utils/file_parser/xlsx_parser.rb +0 -95
  84. data/lib/clacky/utils/file_parser/zip_parser.rb +0 -60
  85. data/scripts/install_agent_browser.sh +0 -67
@@ -73,6 +73,7 @@ module Clacky
73
73
  )
74
74
  @ws_client = nil
75
75
  @running = false
76
+ @doc_retry_cache = {} # { chat_id => { doc_urls: [...], attempts: N } }
76
77
  end
77
78
 
78
79
  # Start listening for messages via WebSocket
@@ -109,6 +110,15 @@ module Clacky
109
110
  @bot.send_text(chat_id, text, reply_to: reply_to)
110
111
  end
111
112
 
113
+ # Send a file (or image) to a chat.
114
+ # @param chat_id [String] Chat ID
115
+ # @param path [String] Local file path
116
+ # @param name [String, nil] Display filename
117
+ # @param reply_to [String, nil] Message ID to reply to
118
+ def send_file(chat_id, path, name: nil, reply_to: nil)
119
+ @bot.send_file(chat_id, path, name: name, reply_to: reply_to)
120
+ end
121
+
112
122
  # Update existing message
113
123
  # @param chat_id [String] Chat ID (unused for Feishu)
114
124
  # @param message_id [String] Message ID to update
@@ -149,8 +159,8 @@ module Clacky
149
159
  # Challenge is handled by MessageParser
150
160
  end
151
161
  rescue => e
152
- warn "Error handling event: #{e.message}"
153
- warn e.backtrace.join("\n")
162
+ Clacky::Logger.warn("[feishu] Error handling event: #{e.message}")
163
+ Clacky::Logger.warn(e.backtrace.first(5).join("\n"))
154
164
  end
155
165
 
156
166
  # Handle message event
@@ -181,9 +191,70 @@ module Clacky
181
191
  all_files = image_files + disk_files
182
192
  event = event.merge(files: all_files) unless all_files.empty?
183
193
 
194
+ # Merge cached doc_urls (from previous failed attempts) into current event
195
+ cached = @doc_retry_cache[event[:chat_id]]
196
+ if cached
197
+ merged_urls = ((event[:doc_urls] || []) + cached[:doc_urls]).uniq
198
+ event = event.merge(doc_urls: merged_urls)
199
+ end
200
+
201
+ # Fetch Feishu document content for any doc URLs in the message
202
+ if event[:doc_urls] && !event[:doc_urls].empty?
203
+ event = enrich_with_doc_content(event)
204
+ return if event.nil?
205
+ end
206
+
184
207
  @on_message&.call(event)
185
208
  end
186
209
 
210
+ # Fetch Feishu document content and append to event[:text].
211
+ # If the app lacks permission (91403), sends a guidance message and returns nil
212
+ # so the caller can skip forwarding the event to the agent.
213
+ # @param event [Hash]
214
+ # @return [Hash, nil] enriched event or nil if permission error
215
+ DOC_RETRY_MAX = 3
216
+
217
+ def enrich_with_doc_content(event)
218
+ doc_sections = []
219
+ failed_urls = []
220
+
221
+ event[:doc_urls].each do |url|
222
+ content = @bot.fetch_doc_content(url)
223
+ doc_sections << "📄 [Doc content from #{url}]\n#{content}" unless content.empty?
224
+ rescue Feishu::FeishuDocPermissionError
225
+ failed_urls << url
226
+ doc_sections << "#{url}\n[System Notice] Cannot read the above Feishu doc: the app has no access (error 91403). Tell user to: open the doc → top-right \"...\" → \"Add Document App\" → add this bot → just send any message to retry."
227
+ rescue Feishu::FeishuDocScopeError => e
228
+ failed_urls << url
229
+ scope_hint = e.auth_url ? "Admin can approve with one click: [点击授权](#{e.auth_url})" : "Admin needs to enable 'docx:document:readonly' scope in Feishu Open Platform."
230
+ doc_sections << "#{url}\n[System Notice] Cannot read the above Feishu doc: app is missing docx API scope (error 99991672). #{scope_hint} Tell user to just send any message to retry after approval."
231
+ rescue => e
232
+ failed_urls << url
233
+ Clacky::Logger.warn("[feishu] Failed to fetch doc #{url}: #{e.message}")
234
+ doc_sections << "#{url}\n[System Notice] Cannot read the above Feishu doc: #{e.message}. Tell user to just send any message to retry."
235
+ end
236
+
237
+ # Update retry cache
238
+ chat_id = event[:chat_id]
239
+ if failed_urls.any?
240
+ existing = @doc_retry_cache[chat_id]
241
+ attempts = (existing&.dig(:attempts) || 0) + 1
242
+ if attempts >= DOC_RETRY_MAX
243
+ @doc_retry_cache.delete(chat_id)
244
+ else
245
+ @doc_retry_cache[chat_id] = { doc_urls: failed_urls, attempts: attempts }
246
+ end
247
+ else
248
+ # All docs fetched successfully, clear cache
249
+ @doc_retry_cache.delete(chat_id)
250
+ end
251
+
252
+ return event if doc_sections.empty?
253
+
254
+ enriched_text = [event[:text], *doc_sections].reject(&:empty?).join("\n\n")
255
+ event.merge(text: enriched_text)
256
+ end
257
+
187
258
  MAX_IMAGE_BYTES = Clacky::Utils::FileProcessor::MAX_IMAGE_BYTES
188
259
 
189
260
  # Download images from Feishu and return as file hashes.
@@ -207,29 +278,23 @@ module Clacky
207
278
  data_url = "data:#{mime};base64,#{Base64.strict_encode64(result[:body])}"
208
279
  file_hashes << { name: "image.jpg", mime_type: mime, data_url: data_url }
209
280
  rescue => e
210
- warn "[Feishu] Failed to download image #{image_key}: #{e.message}"
281
+ Clacky::Logger.warn("[feishu] Failed to download image #{image_key}: #{e.message}")
211
282
  errors << "Image download failed: #{e.message}"
212
283
  end
213
284
  [file_hashes, errors]
214
285
  end
215
286
 
216
- # Download and process file attachments, returning file hashes for agent.
287
+ # Download and save file attachments, returning file hashes for agent.
288
+ # Parsing happens inside agent.run, not here.
217
289
  # @param attachments [Array<Hash>] [{key:, name:}]
218
290
  # @param message_id [String]
219
- # @return [Array<Hash>] file hashes with path/preview_path/name/type/mime_type
291
+ # @return [Array<Hash>] { name:, path: }
220
292
  def process_files(attachments, message_id)
221
293
  attachments.filter_map do |attachment|
222
- result = @bot.download_message_resource(message_id, attachment[:key], type: "file")
223
- file_ref = Clacky::Utils::FileProcessor.process(body: result[:body], filename: attachment[:name])
224
- {
225
- name: file_ref.name,
226
- path: file_ref.original_path,
227
- preview_path: file_ref.preview_path,
228
- type: file_ref.type.to_s,
229
- mime_type: "application/octet-stream"
230
- }
294
+ result = @bot.download_message_resource(message_id, attachment[:key], type: "file")
295
+ Clacky::Utils::FileProcessor.save(body: result[:body], filename: attachment[:name])
231
296
  rescue => e
232
- warn "[Feishu] Failed to download file #{attachment[:name]}: #{e.message}"
297
+ Clacky::Logger.warn("[feishu] Failed to download file #{attachment[:name]}: #{e.message}")
233
298
  nil
234
299
  end.compact
235
300
  end
@@ -1,12 +1,35 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require "faraday"
4
+ require "faraday/multipart"
4
5
  require "json"
5
6
 
6
7
  module Clacky
7
8
  module Channel
8
9
  module Adapters
9
10
  module Feishu
11
+ # Raised when the app lacks read permission for a specific Feishu document (error code 91403).
12
+ # The user needs to add the app as a collaborator on the document.
13
+ class FeishuDocPermissionError < StandardError
14
+ attr_reader :doc_token
15
+
16
+ def initialize(doc_token)
17
+ @doc_token = doc_token
18
+ super("App has no permission to access document: #{doc_token}")
19
+ end
20
+ end
21
+
22
+ # Raised when the app hasn't been granted the API scope for documents (error code 99991672).
23
+ # The admin needs to approve the scope via the returned auth_url.
24
+ class FeishuDocScopeError < StandardError
25
+ attr_reader :auth_url
26
+
27
+ def initialize(auth_url)
28
+ @auth_url = auth_url
29
+ super("App is missing docx API scope")
30
+ end
31
+ end
32
+
10
33
  # Feishu Bot API client.
11
34
  # Handles authentication, message sending, and API calls.
12
35
  class Bot
@@ -53,10 +76,42 @@ module Clacky
53
76
  response = patch("/open-apis/im/v1/messages/#{message_id}", payload)
54
77
  response["code"] == 0
55
78
  rescue => e
56
- warn "Failed to update message: #{e.message}"
79
+ Clacky::Logger.warn("[feishu] Failed to update message: #{e.message}")
57
80
  false
58
81
  end
59
82
 
83
+ # Upload a local file to Feishu and send it to a chat.
84
+ # Images use /im/v1/images + msg_type "image".
85
+ # All other files use /im/v1/files + msg_type "file".
86
+ # @param chat_id [String] Chat ID
87
+ # @param path [String] Local file path
88
+ # @param name [String, nil] Display filename
89
+ # @param reply_to [String, nil] Message ID to reply to
90
+ # @return [Hash] Response with :message_id
91
+ def send_file(chat_id, path, name: nil, reply_to: nil)
92
+ raise ArgumentError, "File not found: #{path}" unless File.exist?(path)
93
+
94
+ filename = name || File.basename(path)
95
+ file_data = File.binread(path)
96
+ ext = File.extname(filename).downcase
97
+
98
+ if %w[.jpg .jpeg .png .gif .webp].include?(ext)
99
+ image_key = upload_image(file_data, filename)
100
+ content = JSON.generate({ image_key: image_key })
101
+ msg_type = "image"
102
+ else
103
+ file_key = upload_file(file_data, filename)
104
+ content = JSON.generate({ file_key: file_key })
105
+ msg_type = "file"
106
+ end
107
+
108
+ payload = { receive_id: chat_id, msg_type: msg_type, content: content }
109
+ payload[:reply_to_message_id] = reply_to if reply_to
110
+
111
+ response = post("/open-apis/im/v1/messages", payload, params: { receive_id_type: "chat_id" })
112
+ { message_id: response.dig("data", "message_id") }
113
+ end
114
+
60
115
  # Download a message resource (image or file) from Feishu.
61
116
  # For message attachments, must use messageResource API — not im/v1/images.
62
117
  # @param message_id [String] Message ID containing the resource
@@ -85,6 +140,26 @@ module Clacky
85
140
  }
86
141
  end
87
142
 
143
+ # Fetch the plain-text content of a Feishu document (docx / docs / wiki).
144
+ # Raises FeishuDocPermissionError (code 91403) when the app has no access.
145
+ # @param url [String] Feishu document URL
146
+ # @return [String] Document plain text
147
+ def fetch_doc_content(url)
148
+ doc_token, doc_type = parse_doc_url(url)
149
+ raise ArgumentError, "Unsupported Feishu doc URL: #{url}" unless doc_token
150
+
151
+ if doc_type == :wiki
152
+ # Wiki: first resolve the real docToken via get_node
153
+ node = fetch_wiki_node(doc_token)
154
+ actual_token = node["obj_token"]
155
+ actual_type = node["obj_type"] # "docx" / "doc" / etc.
156
+ raise "Unsupported wiki node type: #{actual_type}" unless %w[docx doc].include?(actual_type)
157
+ fetch_docx_raw_content(actual_token)
158
+ else
159
+ fetch_docx_raw_content(doc_token)
160
+ end
161
+ end
162
+
88
163
  private
89
164
 
90
165
  # Build message content and type based on text content.
@@ -190,6 +265,140 @@ module Clacky
190
265
  parse_response(response)
191
266
  end
192
267
 
268
+ # Upload an image to Feishu and return image_key.
269
+ # @param data [String] Binary file content
270
+ # @param filename [String] Display filename
271
+ # @return [String] image_key
272
+ def upload_image(data, filename)
273
+ conn = Faraday.new(url: @domain) do |f|
274
+ f.options.timeout = DOWNLOAD_TIMEOUT
275
+ f.options.open_timeout = API_TIMEOUT
276
+ f.ssl.verify = false
277
+ f.request :multipart
278
+ f.adapter Faraday.default_adapter
279
+ end
280
+
281
+ response = conn.post("/open-apis/im/v1/images") do |req|
282
+ req.headers["Authorization"] = "Bearer #{tenant_access_token}"
283
+ req.body = {
284
+ image_type: "message",
285
+ image: Faraday::Multipart::FilePart.new(
286
+ StringIO.new(data), detect_mime(filename), filename
287
+ )
288
+ }
289
+ end
290
+
291
+ result = JSON.parse(response.body)
292
+ raise "Failed to upload image: code=#{result["code"]} msg=#{result["msg"]}" if result["code"] != 0
293
+
294
+ result.dig("data", "image_key") or raise "No image_key returned"
295
+ end
296
+
297
+ # Upload a file to Feishu and return file_key.
298
+ # @param data [String] Binary file content
299
+ # @param filename [String] Display filename
300
+ # @return [String] file_key
301
+ def upload_file(data, filename)
302
+ conn = Faraday.new(url: @domain) do |f|
303
+ f.options.timeout = DOWNLOAD_TIMEOUT
304
+ f.options.open_timeout = API_TIMEOUT
305
+ f.ssl.verify = false
306
+ f.request :multipart
307
+ f.adapter Faraday.default_adapter
308
+ end
309
+
310
+ response = conn.post("/open-apis/im/v1/files") do |req|
311
+ req.headers["Authorization"] = "Bearer #{tenant_access_token}"
312
+ req.body = {
313
+ file_type: feishu_file_type(filename),
314
+ file_name: filename,
315
+ file: Faraday::Multipart::FilePart.new(
316
+ StringIO.new(data), detect_mime(filename), filename
317
+ )
318
+ }
319
+ end
320
+
321
+ result = JSON.parse(response.body)
322
+ raise "Failed to upload file: code=#{result["code"]} msg=#{result["msg"]}" if result["code"] != 0
323
+
324
+ result.dig("data", "file_key") or raise "No file_key returned"
325
+ end
326
+
327
+ # Map file extension to Feishu file_type enum.
328
+ # Feishu accepts: opus, mp4, pdf, doc, xls, ppt, stream (others)
329
+ def feishu_file_type(filename)
330
+ case File.extname(filename).downcase
331
+ when ".pdf" then "pdf"
332
+ when ".doc", ".docx" then "doc"
333
+ when ".xls", ".xlsx" then "xls"
334
+ when ".ppt", ".pptx" then "ppt"
335
+ when ".mp4" then "mp4"
336
+ when ".opus" then "opus"
337
+ else "stream"
338
+ end
339
+ end
340
+
341
+ # Detect MIME type from filename extension.
342
+ def detect_mime(filename)
343
+ case File.extname(filename).downcase
344
+ when ".jpg", ".jpeg" then "image/jpeg"
345
+ when ".png" then "image/png"
346
+ when ".gif" then "image/gif"
347
+ when ".webp" then "image/webp"
348
+ when ".pdf" then "application/pdf"
349
+ when ".mp4" then "video/mp4"
350
+ else "application/octet-stream"
351
+ end
352
+ end
353
+
354
+ # Parse Feishu doc URL and return [doc_token, type]
355
+ # type is :docx, :docs, or :wiki
356
+ # @param url [String]
357
+ # @return [Array<String, Symbol>, nil]
358
+ def parse_doc_url(url)
359
+ if (m = url.match(%r{/(?:docx|docs)/([A-Za-z0-9_-]+)}))
360
+ [m[1], :docx]
361
+ elsif (m = url.match(%r{/wiki/([A-Za-z0-9_-]+)}))
362
+ [m[1], :wiki]
363
+ end
364
+ end
365
+
366
+ # Fetch raw text content of a docx document.
367
+ # Raises FeishuDocPermissionError on 91403.
368
+ # @param doc_token [String]
369
+ # @return [String]
370
+ def fetch_docx_raw_content(doc_token)
371
+ response = get("/open-apis/docx/v1/documents/#{doc_token}/raw_content")
372
+ check_doc_error!(response, doc_token)
373
+ response.dig("data", "content").to_s.strip
374
+ end
375
+
376
+ # Resolve wiki node to get real obj_token and obj_type.
377
+ # @param wiki_token [String]
378
+ # @return [Hash] node data with "obj_token" and "obj_type"
379
+ def fetch_wiki_node(wiki_token)
380
+ response = get("/open-apis/wiki/v2/spaces/get_node", params: { token: wiki_token, obj_type: "wiki" })
381
+ check_doc_error!(response, wiki_token)
382
+ response.dig("data", "node") or raise "No node in wiki response"
383
+ end
384
+
385
+ # Check doc API response for known permission errors and raise accordingly.
386
+ def check_doc_error!(response, token)
387
+ code = response["code"].to_i
388
+ return if code == 0
389
+
390
+ if code == 91403
391
+ raise FeishuDocPermissionError, token
392
+ elsif code == 99991672
393
+ # Extract auth URL from the error message if present
394
+ auth_url = response.dig("error", "permission_violations", 0, "attach_url") ||
395
+ response["msg"].to_s[/https:\/\/open\.feishu\.cn\/app\/[^\s"]+/]
396
+ raise FeishuDocScopeError.new(auth_url)
397
+ else
398
+ raise "Failed to fetch doc: code=#{code} msg=#{response["msg"]}"
399
+ end
400
+ end
401
+
193
402
  # Build Faraday connection
194
403
  # @return [Faraday::Connection]
195
404
  def build_connection
@@ -205,13 +414,13 @@ module Clacky
205
414
  # @param response [Faraday::Response]
206
415
  # @return [Hash] Parsed JSON
207
416
  def parse_response(response)
208
- unless response.success?
209
- raise "API request failed: HTTP #{response.status}"
210
- end
417
+ # Feishu returns JSON even on 4xx — parse it so callers can inspect error codes
418
+ parsed = JSON.parse(response.body)
419
+ return parsed if response.success? || parsed.key?("code")
211
420
 
212
- JSON.parse(response.body)
213
- rescue JSON::ParserError => e
214
- raise "Failed to parse API response: #{e.message}"
421
+ raise "API request failed: HTTP #{response.status} body=#{response.body.to_s[0..300]}"
422
+ rescue JSON::ParserError
423
+ raise "API request failed: HTTP #{response.status} body=#{response.body.to_s[0..300]}"
215
424
  end
216
425
  end
217
426
  end
@@ -55,6 +55,7 @@ module Clacky
55
55
  return nil unless message && sender
56
56
 
57
57
  msg_type = message["message_type"]
58
+ Clacky::Logger.info("[feishu] msg_type=#{msg_type} content=#{message["content"].to_s[0..300]}")
58
59
  return nil unless %w[text image file].include?(msg_type)
59
60
 
60
61
  content_raw = message["content"]
@@ -94,6 +95,7 @@ module Clacky
94
95
  text: text,
95
96
  image_keys: image_keys,
96
97
  file_attachments: file_attachments,
98
+ doc_urls: extract_doc_urls(text),
97
99
  message_id: message_id,
98
100
  timestamp: timestamp,
99
101
  chat_type: chat_type,
@@ -103,6 +105,16 @@ module Clacky
103
105
  nil
104
106
  end
105
107
 
108
+ # Extract Feishu document URLs from text.
109
+ # Matches: /docx/TOKEN, /docs/TOKEN, /wiki/TOKEN
110
+ # @param text [String]
111
+ # @return [Array<String>] matched URLs
112
+ def extract_doc_urls(text)
113
+ return [] if text.nil? || text.empty?
114
+
115
+ text.scan(%r{https?://[a-zA-Z0-9._-]+\.(?:feishu\.cn|larksuite\.com)/(?:docx|docs|wiki)/[A-Za-z0-9_-]+(?:\?[^\s]*)?})
116
+ end
117
+
106
118
  # Strip bot @mentions from message text
107
119
  # @param text [String]
108
120
  # @return [String]
@@ -112,14 +112,8 @@ module Clacky
112
112
  filename = raw.dig("file", "name") || raw.dig("file", "filename") || "attachment"
113
113
  result = MediaDownloader.download(url, aeskey)
114
114
  filename = result[:filename] || filename
115
- file_ref = Clacky::Utils::FileProcessor.process(body: result[:body], filename: filename)
116
- files = [{
117
- name: file_ref.name,
118
- path: file_ref.original_path,
119
- preview_path: file_ref.preview_path,
120
- type: file_ref.type.to_s,
121
- mime_type: "application/octet-stream"
122
- }]
115
+ saved = Clacky::Utils::FileProcessor.save(body: result[:body], filename: filename)
116
+ files = [saved]
123
117
  end
124
118
 
125
119
  event = {