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
|
@@ -0,0 +1,391 @@
|
|
|
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 Weixin
|
|
11
|
+
# Weixin (WeChat iLink) adapter.
|
|
12
|
+
#
|
|
13
|
+
# Protocol: HTTP long-poll via ilinkai.weixin.qq.com
|
|
14
|
+
# Auth: token obtained from QR login (stored in channels.yml as `token`)
|
|
15
|
+
#
|
|
16
|
+
# Config keys (channels.yml):
|
|
17
|
+
# token: [String] bot token from QR login
|
|
18
|
+
# base_url: [String] API base URL (default: https://ilinkai.weixin.qq.com)
|
|
19
|
+
# allowed_users: [Array<String>] optional whitelist of from_user_id values
|
|
20
|
+
#
|
|
21
|
+
# Event fields yielded to ChannelManager:
|
|
22
|
+
# platform: :weixin
|
|
23
|
+
# chat_id: String (from_user_id — used for replies)
|
|
24
|
+
# user_id: String (from_user_id)
|
|
25
|
+
# text: String
|
|
26
|
+
# files: Array<Hash>
|
|
27
|
+
# message_id: String
|
|
28
|
+
# timestamp: Time
|
|
29
|
+
# chat_type: :direct
|
|
30
|
+
# context_token: String (must be echoed in every reply)
|
|
31
|
+
class Adapter < Base
|
|
32
|
+
RECONNECT_DELAY = 5
|
|
33
|
+
|
|
34
|
+
def self.platform_id
|
|
35
|
+
:weixin
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def self.env_keys
|
|
39
|
+
%w[IM_WEIXIN_TOKEN IM_WEIXIN_BASE_URL IM_WEIXIN_ALLOWED_USERS]
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def self.platform_config(data)
|
|
43
|
+
{
|
|
44
|
+
token: data["IM_WEIXIN_TOKEN"] || data["token"],
|
|
45
|
+
base_url: data["IM_WEIXIN_BASE_URL"] || data["base_url"] || ApiClient::DEFAULT_BASE_URL,
|
|
46
|
+
allowed_users: (data["IM_WEIXIN_ALLOWED_USERS"] || data["allowed_users"] || "")
|
|
47
|
+
.then { |v| v.is_a?(Array) ? v : v.to_s.split(",").map(&:strip).reject(&:empty?) }
|
|
48
|
+
}.compact
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
def self.set_env_data(data, config)
|
|
52
|
+
data["IM_WEIXIN_TOKEN"] = config[:token]
|
|
53
|
+
data["IM_WEIXIN_BASE_URL"] = config[:base_url] if config[:base_url]
|
|
54
|
+
data["IM_WEIXIN_ALLOWED_USERS"] = Array(config[:allowed_users]).join(",")
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
def self.test_connection(fields)
|
|
58
|
+
token = fields[:token].to_s.strip
|
|
59
|
+
|
|
60
|
+
return { ok: false, error: "token is required" } if token.empty?
|
|
61
|
+
|
|
62
|
+
# Weixin iLink token is obtained via the QR scan flow and is already
|
|
63
|
+
# confirmed valid by the iLink API before we store it. There is no
|
|
64
|
+
# lightweight ping endpoint, so we just verify the token is present.
|
|
65
|
+
{ ok: true, message: "Connected to Weixin iLink" }
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
def initialize(config)
|
|
69
|
+
@config = config
|
|
70
|
+
@token = config[:token].to_s
|
|
71
|
+
@base_url = config[:base_url] || ApiClient::DEFAULT_BASE_URL
|
|
72
|
+
@allowed_users = Array(config[:allowed_users])
|
|
73
|
+
@running = false
|
|
74
|
+
@on_message = nil
|
|
75
|
+
# In-memory store: user_id → context_token (for reply threading)
|
|
76
|
+
@context_tokens = {}
|
|
77
|
+
@ctx_mutex = Mutex.new
|
|
78
|
+
@api_client = ApiClient.new(base_url: @base_url, token: @token)
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
def start(&on_message)
|
|
82
|
+
@running = true
|
|
83
|
+
@on_message = on_message
|
|
84
|
+
|
|
85
|
+
get_updates_buf = ""
|
|
86
|
+
consecutive_errors = 0
|
|
87
|
+
|
|
88
|
+
Clacky::Logger.info("[WeixinAdapter] starting long-poll (base_url=#{@base_url})")
|
|
89
|
+
|
|
90
|
+
while @running
|
|
91
|
+
begin
|
|
92
|
+
resp = @api_client.get_updates(get_updates_buf: get_updates_buf)
|
|
93
|
+
|
|
94
|
+
consecutive_errors = 0
|
|
95
|
+
new_buf = resp["get_updates_buf"].to_s
|
|
96
|
+
get_updates_buf = new_buf unless new_buf.empty?
|
|
97
|
+
|
|
98
|
+
(resp["msgs"] || []).each do |msg|
|
|
99
|
+
process_message(msg)
|
|
100
|
+
rescue => e
|
|
101
|
+
Clacky::Logger.warn("[WeixinAdapter] process_message error: #{e.message}")
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
rescue ApiClient::TimeoutError
|
|
105
|
+
# Long-poll server-side timeout is expected — just retry
|
|
106
|
+
rescue ApiClient::ApiError => e
|
|
107
|
+
if e.code == ApiClient::SESSION_EXPIRED_ERRCODE
|
|
108
|
+
Clacky::Logger.warn("[WeixinAdapter] Session expired (token may need refresh), backing off 60s")
|
|
109
|
+
sleep 60
|
|
110
|
+
else
|
|
111
|
+
consecutive_errors += 1
|
|
112
|
+
Clacky::Logger.warn("[WeixinAdapter] API error #{e.code}: #{e.message}")
|
|
113
|
+
sleep(consecutive_errors > 3 ? 30 : RECONNECT_DELAY)
|
|
114
|
+
end
|
|
115
|
+
rescue => e
|
|
116
|
+
consecutive_errors += 1
|
|
117
|
+
Clacky::Logger.error("[WeixinAdapter] poll error: #{e.message}")
|
|
118
|
+
break unless @running
|
|
119
|
+
sleep(consecutive_errors > 3 ? 30 : RECONNECT_DELAY)
|
|
120
|
+
end
|
|
121
|
+
end
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
def stop
|
|
125
|
+
@running = false
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
# Send a plain text reply to a user.
|
|
129
|
+
# The context_token from the inbound message is required by the Weixin protocol.
|
|
130
|
+
def send_text(chat_id, text, reply_to: nil)
|
|
131
|
+
ctoken = lookup_context_token(chat_id)
|
|
132
|
+
unless ctoken
|
|
133
|
+
Clacky::Logger.warn("[WeixinAdapter] send_text: no context_token for #{chat_id}, dropping message")
|
|
134
|
+
return { message_id: nil }
|
|
135
|
+
end
|
|
136
|
+
|
|
137
|
+
plain = markdown_to_plain(text)
|
|
138
|
+
split_message(plain).each do |chunk|
|
|
139
|
+
@api_client.send_text(to_user_id: chat_id, text: chunk, context_token: ctoken)
|
|
140
|
+
end
|
|
141
|
+
|
|
142
|
+
{ message_id: nil }
|
|
143
|
+
rescue => e
|
|
144
|
+
Clacky::Logger.error("[WeixinAdapter] send_text failed for #{chat_id}: #{e.message}")
|
|
145
|
+
{ message_id: nil }
|
|
146
|
+
end
|
|
147
|
+
|
|
148
|
+
# Send a file to a user.
|
|
149
|
+
# file_path: local path to the file to send
|
|
150
|
+
# file_name: optional display name (defaults to basename)
|
|
151
|
+
def send_file(chat_id, file_path, name: nil, reply_to: nil)
|
|
152
|
+
ctoken = lookup_context_token(chat_id)
|
|
153
|
+
unless ctoken
|
|
154
|
+
Clacky::Logger.warn("[WeixinAdapter] send_file: no context_token for #{chat_id}, dropping")
|
|
155
|
+
return { message_id: nil }
|
|
156
|
+
end
|
|
157
|
+
|
|
158
|
+
@api_client.send_file(
|
|
159
|
+
to_user_id: chat_id,
|
|
160
|
+
file_path: file_path,
|
|
161
|
+
file_name: name || File.basename(file_path),
|
|
162
|
+
context_token: ctoken
|
|
163
|
+
)
|
|
164
|
+
{ message_id: nil }
|
|
165
|
+
rescue => e
|
|
166
|
+
Clacky::Logger.error("[WeixinAdapter] send_file failed for #{chat_id}: #{e.message}")
|
|
167
|
+
{ message_id: nil }
|
|
168
|
+
end
|
|
169
|
+
|
|
170
|
+
def validate_config(config)
|
|
171
|
+
errors = []
|
|
172
|
+
errors << "token is required" if config[:token].nil? || config[:token].to_s.strip.empty?
|
|
173
|
+
errors
|
|
174
|
+
end
|
|
175
|
+
|
|
176
|
+
def supports_message_updates?
|
|
177
|
+
false
|
|
178
|
+
end
|
|
179
|
+
|
|
180
|
+
private
|
|
181
|
+
|
|
182
|
+
def process_message(msg)
|
|
183
|
+
# Only process inbound USER messages (message_type 1 = USER)
|
|
184
|
+
return unless msg["message_type"] == 1
|
|
185
|
+
|
|
186
|
+
from_user_id = msg["from_user_id"].to_s
|
|
187
|
+
context_token = msg["context_token"].to_s
|
|
188
|
+
return if from_user_id.empty? || context_token.empty?
|
|
189
|
+
|
|
190
|
+
if @allowed_users.any? && !@allowed_users.include?(from_user_id)
|
|
191
|
+
Clacky::Logger.debug("[WeixinAdapter] ignoring message from #{from_user_id} (not in allowed_users)")
|
|
192
|
+
return
|
|
193
|
+
end
|
|
194
|
+
|
|
195
|
+
# Cache context_token — needed when sending replies
|
|
196
|
+
store_context_token(from_user_id, context_token)
|
|
197
|
+
|
|
198
|
+
item_list = msg["item_list"] || []
|
|
199
|
+
Clacky::Logger.debug("[WeixinAdapter] item_list raw: #{item_list.to_json}")
|
|
200
|
+
text = extract_text(item_list)
|
|
201
|
+
files = extract_files(item_list)
|
|
202
|
+
|
|
203
|
+
# Require at least some content (text or files)
|
|
204
|
+
return if text.strip.empty? && files.empty?
|
|
205
|
+
|
|
206
|
+
event = {
|
|
207
|
+
type: :message,
|
|
208
|
+
platform: :weixin,
|
|
209
|
+
chat_id: from_user_id,
|
|
210
|
+
user_id: from_user_id,
|
|
211
|
+
text: text.strip,
|
|
212
|
+
files: files,
|
|
213
|
+
message_id: msg["message_id"]&.to_s,
|
|
214
|
+
timestamp: msg["create_time_ms"] ? Time.at(msg["create_time_ms"] / 1000.0) : Time.now,
|
|
215
|
+
chat_type: :direct,
|
|
216
|
+
context_token: context_token,
|
|
217
|
+
raw: msg
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
log_parts = []
|
|
221
|
+
log_parts << text.slice(0, 80) unless text.strip.empty?
|
|
222
|
+
log_parts << "#{files.size} file(s)" unless files.empty?
|
|
223
|
+
Clacky::Logger.info("[WeixinAdapter] message from #{from_user_id}: #{log_parts.join(" + ")}")
|
|
224
|
+
@on_message&.call(event)
|
|
225
|
+
end
|
|
226
|
+
|
|
227
|
+
def extract_text(item_list)
|
|
228
|
+
parts = []
|
|
229
|
+
item_list.each do |item|
|
|
230
|
+
case item["type"]
|
|
231
|
+
when 1 # TEXT
|
|
232
|
+
raw_text = item.dig("text_item", "text").to_s.strip
|
|
233
|
+
ref = item["ref_msg"]
|
|
234
|
+
if ref && !ref.empty?
|
|
235
|
+
ref_parts = []
|
|
236
|
+
ref_parts << ref["title"] if ref["title"] && !ref["title"].empty?
|
|
237
|
+
if (ri = ref["message_item"]) && ri["type"] == 1
|
|
238
|
+
rt = ri.dig("text_item", "text").to_s.strip
|
|
239
|
+
ref_parts << rt unless rt.empty?
|
|
240
|
+
end
|
|
241
|
+
parts << "[引用: #{ref_parts.join(" | ")}]" unless ref_parts.empty?
|
|
242
|
+
end
|
|
243
|
+
parts << raw_text unless raw_text.empty?
|
|
244
|
+
when 3 # VOICE — use transcription if available
|
|
245
|
+
vt = item.dig("voice_item", "text").to_s.strip
|
|
246
|
+
parts << vt unless vt.empty?
|
|
247
|
+
end
|
|
248
|
+
end
|
|
249
|
+
parts.join("\n")
|
|
250
|
+
end
|
|
251
|
+
|
|
252
|
+
# Extract file attachments from item_list for inbound messages.
|
|
253
|
+
# Returns array of hashes: { type:, name:, cdn_media: }
|
|
254
|
+
# cdn_media contains { encrypt_query_param:, aes_key: } for potential download.
|
|
255
|
+
# Extract and materialize file attachments from an inbound item_list.
|
|
256
|
+
#
|
|
257
|
+
# Images are downloaded from CDN and converted to data_url so the agent's
|
|
258
|
+
# vision pipeline (partition_files → resolve_vision_images) picks them up
|
|
259
|
+
# correctly. Other file types are returned with cdn_media metadata only
|
|
260
|
+
# (download-on-demand is not yet implemented for non-image types).
|
|
261
|
+
#
|
|
262
|
+
# Returns Array of Hashes. Image entries include:
|
|
263
|
+
# { type: :image, name: String, mime_type: String, data_url: String }
|
|
264
|
+
# Other entries include:
|
|
265
|
+
# { type: :file/:voice/:video, name: String, cdn_media: Hash }
|
|
266
|
+
def extract_files(item_list)
|
|
267
|
+
files = []
|
|
268
|
+
item_list.each do |item|
|
|
269
|
+
case item["type"]
|
|
270
|
+
when 2 # IMAGE — download + convert to data_url for agent vision
|
|
271
|
+
img = item["image_item"]
|
|
272
|
+
next unless img
|
|
273
|
+
cdn_media = img["media"]
|
|
274
|
+
next unless cdn_media
|
|
275
|
+
|
|
276
|
+
# Protocol: image_item may have a top-level aeskey field that overrides
|
|
277
|
+
# the one inside media. Use image_item.aeskey first, fall back to media.aes_key.
|
|
278
|
+
top_level_aeskey = img["aeskey"]
|
|
279
|
+
effective_cdn_media = if top_level_aeskey && !top_level_aeskey.empty?
|
|
280
|
+
cdn_media.merge("aes_key" => top_level_aeskey)
|
|
281
|
+
else
|
|
282
|
+
cdn_media
|
|
283
|
+
end
|
|
284
|
+
|
|
285
|
+
Clacky::Logger.debug("[WeixinAdapter] image cdn_media: #{effective_cdn_media.to_json}")
|
|
286
|
+
|
|
287
|
+
begin
|
|
288
|
+
raw_bytes = @api_client.download_media(effective_cdn_media, ApiClient::MEDIA_TYPE_IMAGE)
|
|
289
|
+
mime_type = detect_image_mime(raw_bytes)
|
|
290
|
+
data_url = "data:#{mime_type};base64,#{Base64.strict_encode64(raw_bytes)}"
|
|
291
|
+
files << {
|
|
292
|
+
type: :image,
|
|
293
|
+
name: "image.jpg",
|
|
294
|
+
mime_type: mime_type,
|
|
295
|
+
data_url: data_url
|
|
296
|
+
}
|
|
297
|
+
rescue => e
|
|
298
|
+
Clacky::Logger.warn("[WeixinAdapter] Failed to download image: #{e.message}\n#{e.backtrace.first(3).join("\n")}")
|
|
299
|
+
end
|
|
300
|
+
|
|
301
|
+
when 3 # VOICE
|
|
302
|
+
v = item["voice_item"]
|
|
303
|
+
next unless v
|
|
304
|
+
files << {
|
|
305
|
+
type: :voice,
|
|
306
|
+
name: "voice.amr",
|
|
307
|
+
cdn_media: v["media"]
|
|
308
|
+
}
|
|
309
|
+
when 4 # FILE
|
|
310
|
+
fi = item["file_item"]
|
|
311
|
+
next unless fi
|
|
312
|
+
files << {
|
|
313
|
+
type: :file,
|
|
314
|
+
name: fi["file_name"],
|
|
315
|
+
md5: fi["md5"],
|
|
316
|
+
len: fi["len"],
|
|
317
|
+
cdn_media: fi["media"]
|
|
318
|
+
}
|
|
319
|
+
when 5 # VIDEO
|
|
320
|
+
vi = item["video_item"]
|
|
321
|
+
next unless vi
|
|
322
|
+
files << {
|
|
323
|
+
type: :video,
|
|
324
|
+
name: "video.mp4",
|
|
325
|
+
cdn_media: vi["media"]
|
|
326
|
+
}
|
|
327
|
+
end
|
|
328
|
+
end
|
|
329
|
+
files
|
|
330
|
+
end
|
|
331
|
+
|
|
332
|
+
# Detect image MIME type from magic bytes.
|
|
333
|
+
# Falls back to image/jpeg if unknown.
|
|
334
|
+
def detect_image_mime(bytes)
|
|
335
|
+
return "image/jpeg" unless bytes && bytes.bytesize >= 4
|
|
336
|
+
head = bytes.byteslice(0, 8).bytes
|
|
337
|
+
if head[0] == 0xFF && head[1] == 0xD8
|
|
338
|
+
"image/jpeg"
|
|
339
|
+
elsif head[0] == 0x89 && head[1] == 0x50 && head[2] == 0x4E && head[3] == 0x47
|
|
340
|
+
"image/png"
|
|
341
|
+
elsif head[0] == 0x47 && head[1] == 0x49 && head[2] == 0x46
|
|
342
|
+
"image/gif"
|
|
343
|
+
elsif head[0] == 0x52 && head[1] == 0x49 && head[2] == 0x46 && head[3] == 0x46
|
|
344
|
+
"image/webp"
|
|
345
|
+
else
|
|
346
|
+
"image/jpeg"
|
|
347
|
+
end
|
|
348
|
+
end
|
|
349
|
+
|
|
350
|
+
def store_context_token(user_id, token)
|
|
351
|
+
@ctx_mutex.synchronize { @context_tokens[user_id] = token }
|
|
352
|
+
end
|
|
353
|
+
|
|
354
|
+
def lookup_context_token(user_id)
|
|
355
|
+
@ctx_mutex.synchronize { @context_tokens[user_id] }
|
|
356
|
+
end
|
|
357
|
+
|
|
358
|
+
# Split text into ≤4000-char chunks, preferring newline boundaries.
|
|
359
|
+
def split_message(text, limit: 4000)
|
|
360
|
+
return [text] if text.length <= limit
|
|
361
|
+
chunks = []
|
|
362
|
+
while text.length > limit
|
|
363
|
+
cut = text.rindex("\n", limit) || limit
|
|
364
|
+
chunks << text[0, cut].rstrip
|
|
365
|
+
text = text[cut..].lstrip
|
|
366
|
+
end
|
|
367
|
+
chunks << text unless text.empty?
|
|
368
|
+
chunks
|
|
369
|
+
end
|
|
370
|
+
|
|
371
|
+
# Strip markdown syntax for WeChat (no markdown rendering).
|
|
372
|
+
def markdown_to_plain(text)
|
|
373
|
+
r = text.dup
|
|
374
|
+
r.gsub!(/```[^\n]*\n?([\s\S]*?)```/) { Regexp.last_match(1).strip }
|
|
375
|
+
r.gsub!(/!\[[^\]]*\]\([^)]*\)/, "")
|
|
376
|
+
r.gsub!(/\[([^\]]+)\]\([^)]*\)/, '\1')
|
|
377
|
+
r.gsub!(/\*\*([^*]+)\*\*/, '\1')
|
|
378
|
+
r.gsub!(/\*([^*]+)\*/, '\1')
|
|
379
|
+
r.gsub!(/__([^_]+)__/, '\1')
|
|
380
|
+
r.gsub!(/_([^_]+)_/, '\1')
|
|
381
|
+
r.gsub!(/^#+\s+/, "")
|
|
382
|
+
r.gsub!(/^[-*_]{3,}\s*$/, "")
|
|
383
|
+
r.strip
|
|
384
|
+
end
|
|
385
|
+
end
|
|
386
|
+
|
|
387
|
+
Adapters.register(:weixin, Adapter)
|
|
388
|
+
end
|
|
389
|
+
end
|
|
390
|
+
end
|
|
391
|
+
end
|