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.
- checksums.yaml +4 -4
- data/.clacky/skills/gem-release/SKILL.md +16 -1
- data/CHANGELOG.md +51 -0
- data/docs/browser-cdp-native-design.md +195 -0
- data/docs/session-management-redesign.md +202 -0
- data/docs/system-skill-authoring-guide.md +47 -0
- data/lib/clacky/agent/cost_tracker.rb +2 -1
- data/lib/clacky/agent/message_compressor_helper.rb +2 -2
- data/lib/clacky/agent/session_serializer.rb +36 -3
- data/lib/clacky/agent/skill_manager.rb +68 -5
- data/lib/clacky/agent/system_prompt_builder.rb +30 -13
- data/lib/clacky/agent/tool_executor.rb +8 -12
- data/lib/clacky/agent.rb +172 -30
- data/lib/clacky/brand_config.rb +90 -0
- data/lib/clacky/cli.rb +37 -8
- data/lib/clacky/default_parsers/doc_parser.rb +69 -0
- data/lib/clacky/default_parsers/docx_parser.rb +172 -0
- data/lib/clacky/default_parsers/pdf_parser.rb +79 -0
- data/lib/clacky/default_parsers/pptx_parser.rb +140 -0
- data/lib/clacky/default_parsers/xlsx_parser.rb +121 -0
- data/lib/clacky/default_skills/browser-setup/SKILL.md +158 -0
- data/lib/clacky/default_skills/channel-setup/SKILL.md +139 -42
- data/lib/clacky/default_skills/channel-setup/feishu_setup.rb +582 -0
- data/lib/clacky/default_skills/channel-setup/weixin_setup.rb +274 -0
- data/lib/clacky/default_skills/onboard/SKILL.md +132 -6
- data/lib/clacky/default_skills/personal-website/SKILL.md +113 -0
- data/lib/clacky/default_skills/personal-website/publish.rb +214 -0
- data/lib/clacky/default_skills/skill-add/SKILL.md +6 -7
- data/lib/clacky/default_skills/skill-add/scripts/install_from_zip.rb +1 -1
- data/lib/clacky/default_skills/skill-creator/SKILL.md +3 -3
- data/lib/clacky/json_ui_controller.rb +5 -0
- data/lib/clacky/message_format/open_ai.rb +7 -1
- data/lib/clacky/plain_ui_controller.rb +5 -0
- data/lib/clacky/server/browser_manager.rb +308 -0
- data/lib/clacky/server/channel/adapters/feishu/adapter.rb +80 -15
- data/lib/clacky/server/channel/adapters/feishu/bot.rb +216 -7
- data/lib/clacky/server/channel/adapters/feishu/message_parser.rb +12 -0
- data/lib/clacky/server/channel/adapters/wecom/adapter.rb +2 -8
- data/lib/clacky/server/channel/adapters/weixin/adapter.rb +391 -0
- data/lib/clacky/server/channel/adapters/weixin/api_client.rb +374 -0
- data/lib/clacky/server/channel/channel_config.rb +6 -0
- data/lib/clacky/server/channel/channel_manager.rb +50 -15
- data/lib/clacky/server/channel/channel_ui_controller.rb +18 -0
- data/lib/clacky/server/channel.rb +1 -0
- data/lib/clacky/server/http_server.rb +210 -45
- data/lib/clacky/server/scheduler.rb +1 -1
- data/lib/clacky/server/session_registry.rb +131 -40
- data/lib/clacky/server/web_ui_controller.rb +21 -2
- data/lib/clacky/session_manager.rb +43 -73
- data/lib/clacky/skill.rb +64 -42
- data/lib/clacky/tools/browser.rb +728 -183
- data/lib/clacky/tools/invoke_skill.rb +18 -11
- data/lib/clacky/tools/shell.rb +16 -7
- data/lib/clacky/tools/web_fetch.rb +3 -1
- data/lib/clacky/ui2/README.md +1 -1
- data/lib/clacky/ui2/components/input_area.rb +5 -2
- data/lib/clacky/ui2/components/welcome_banner.rb +23 -0
- data/lib/clacky/ui2/ui_controller.rb +16 -2
- data/lib/clacky/ui_interface.rb +7 -0
- data/lib/clacky/utils/file_processor.rb +116 -139
- data/lib/clacky/utils/model_pricing.rb +5 -4
- data/lib/clacky/utils/parser_manager.rb +93 -0
- data/lib/clacky/utils/workspace_rules.rb +46 -0
- data/lib/clacky/version.rb +1 -1
- data/lib/clacky/web/app.css +696 -62
- data/lib/clacky/web/app.js +181 -51
- data/lib/clacky/web/brand.js +14 -0
- data/lib/clacky/web/channels.js +10 -1
- data/lib/clacky/web/i18n.js +49 -7
- data/lib/clacky/web/index.html +67 -10
- data/lib/clacky/web/onboard.js +2 -1
- data/lib/clacky/web/sessions.js +255 -69
- data/lib/clacky/web/settings.js +113 -1
- data/lib/clacky/web/skills.js +16 -3
- data/lib/clacky/web/tasks.js +11 -6
- data/lib/clacky/web/weixin-qr.html +104 -0
- data/lib/clacky.rb +2 -0
- data/scripts/install.sh +41 -11
- metadata +48 -7
- data/lib/clacky/default_skills/pdf-reader/SKILL.md +0 -90
- data/lib/clacky/utils/file_parser/docx_parser.rb +0 -156
- data/lib/clacky/utils/file_parser/pptx_parser.rb +0 -116
- data/lib/clacky/utils/file_parser/xlsx_parser.rb +0 -95
- data/lib/clacky/utils/file_parser/zip_parser.rb +0 -60
- 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
|
|
153
|
-
warn
|
|
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
|
|
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
|
|
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>]
|
|
291
|
+
# @return [Array<Hash>] { name:, path: }
|
|
220
292
|
def process_files(attachments, message_id)
|
|
221
293
|
attachments.filter_map do |attachment|
|
|
222
|
-
result
|
|
223
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
209
|
-
|
|
210
|
-
|
|
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
|
-
|
|
213
|
-
rescue JSON::ParserError
|
|
214
|
-
raise "
|
|
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
|
-
|
|
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 = {
|