Top Level Namespace
Defined Under Namespace
Modules: Clacky, Enumerable, YAMLCompat Classes: BrowserSession, FeishuApiClient, RetryableError, ToolClient, ZipSkillInstaller
Constant Summary collapse
- MIN_CONTENT_BYTES =
20- PRIMARY_HOST =
Primary CDN-accelerated endpoint. Fallback bypasses EdgeOne and is used when the primary times out or errors.
ENV.fetch("CLACKY_LICENSE_SERVER", "https://www.openclacky.com")
- FALLBACK_HOST =
"https://openclacky-platform.clackyai.app"- API_HOSTS =
When the env override is set we use only that host (dev/test mode).
ENV["CLACKY_LICENSE_SERVER"] ? [PRIMARY_HOST] : [PRIMARY_HOST, FALLBACK_HOST]
- HMAC_SECRET =
ENV.fetch("CARD_HMAC_SECRET", "openclacky-card-v1-default-secret-change-me")
- TOKEN_FILE =
File.("~/clacky_workspace/personal_website/token.json")
- OPEN_TIMEOUT =
Retry / timeout config
8- READ_TIMEOUT =
15- ATTEMPTS_PER_HOST =
2- INITIAL_BACKOFF =
0.5- PRODUCT_NAME =
Configuration
ENV.fetch("CLACKY_PRODUCT_NAME", "OpenClacky")
- DATE_SUFFIX =
Time.now.strftime("%Y%m%d")
- APP_NAME =
"#{PRODUCT_NAME} #{DATE_SUFFIX}"- APP_DESC =
"Your personal assistant powered by #{PRODUCT_NAME}"- FEISHU_BASE_URL =
"https://open.feishu.cn"- FEISHU_API_BASE =
"#{FEISHU_BASE_URL}/developers/v1"- CLACKY_SERVER_URL =
begin host = ENV.fetch("CLACKY_SERVER_HOST", "127.0.0.1") port = ENV.fetch("CLACKY_SERVER_PORT", "7070") "http://#{host}:#{port}" end
- WEBSOCKET_POLL_INTERVAL =
3- WEBSOCKET_POLL_TIMEOUT =
30- BOT_PERMISSIONS =
%w[ im:message im:message.p2p_msg:readonly im:message.group_at_msg:readonly im:message:send_as_bot im:resource im:message.group_msg im:message:readonly im:message:update im:message:recall im:message.reactions:read contact:user.base:readonly contact:contact.base:readonly ].freeze
- ILINK_BASE_URL =
Config
"https://ilinkai.weixin.qq.com"- BOT_TYPE =
"3"- QR_POLL_TIMEOUT_S =
slightly above server’s 35s long-poll
37- LOGIN_DEADLINE_S =
5 * 60
- FETCH_QR_MODE =
Mode parsing
ARGV.include?("--fetch-qr")
- QRCODE_ID_IDX =
ARGV.index("--qrcode-id")
- GIVEN_QRCODE_ID =
QRCODE_ID_IDX ? ARGV[QRCODE_ID_IDX + 1] : nil
- DEPLOY_SCRIPT_DIR =
Load gem libs — resolve path relative to this script’s location
File.("..", __FILE__)
- GEM_LIB_DIR =
File.("../../../../..", DEPLOY_SCRIPT_DIR)
Instance Method Summary collapse
-
#api_ok!(body, step_name) ⇒ Object
————————————————————————— API helpers — check code=0, return data or nil —————————————————————————.
- #api_ok?(body) ⇒ Boolean
- #build_markdown_table(rows) ⇒ Object
- #cmd_delete(slug: nil) ⇒ Object
-
#cmd_publish(name:, html_file:) ⇒ Object
── Commands ──────────────────────────────────────────────────────────────────.
-
#device_fingerprint ⇒ Object
── HMAC signing ─────────────────────────────────────────────────────────────.
-
#display_qr(qrcode_url) ⇒ Object
————————————————————————— QR code display (non-fetch-qr mode only) —————————————————————————.
- #do_http_request(method, base, path, body:, extra_headers:) ⇒ Object
- #extract_runs(para_node) ⇒ Object
- #extract_text(shape_node) ⇒ Object
- #fail!(msg) ⇒ Object
- #hmac_headers ⇒ Object
-
#http_request(method, path, body: nil, extra_headers: {}) ⇒ Object
Resilient HTTP request: retries on transient errors, then fails over to the fallback host before giving up.
- #ilink_get(path, extra_headers: {}, timeout: 15) ⇒ Object
-
#load_token_data ⇒ Object
── Token storage ─────────────────────────────────────────────────────────────.
-
#log(msg) ⇒ Object
In fetch-qr mode, write to stderr so stdout stays clean JSON.
- #ok(msg) ⇒ Object
- #parse_paragraph(node, styles, numbering) ⇒ Object
- #parse_row(row_node, shared_strings) ⇒ Object
- #parse_slide(doc, slide_num) ⇒ Object
- #parse_table(tbl_node) ⇒ Object
-
#poll_until_confirmed(qrcode) ⇒ Object
————————————————————————— Long-poll loop (shared by all modes) —————————————————————————.
-
#poll_with_ws_wait(step_name, timeout: WEBSOCKET_POLL_TIMEOUT, interval: WEBSOCKET_POLL_INTERVAL) ⇒ Object
————————————————————————— Websocket-mode polling helper (code=10068 means WS not ready yet) —————————————————————————.
-
#random_wechat_uin ⇒ Object
————————————————————————— iLink HTTP helpers —————————————————————————.
- #read_document_xml(body) ⇒ Object
- #read_numbering(body) ⇒ Object
- #read_styles(body) ⇒ Object
- #read_zip_entry(body, name) ⇒ Object
-
#run_setup(browser, api) ⇒ Object
————————————————————————— Main setup logic —————————————————————————.
- #safe_utf8(str) ⇒ Object
-
#save_to_server(token:, base_url:) ⇒ Object
————————————————————————— Clacky server — save credentials —————————————————————————.
- #save_token_data(data) ⇒ Object
-
#step(msg) ⇒ Object
————————————————————————— Logging (suppress in –fetch-qr mode so stdout is clean JSON) —————————————————————————.
-
#try_antiword(path) ⇒ Object
Use antiword to extract text from .doc files (Linux/WSL).
- #try_pdfplumber(path) ⇒ Object
- #try_pdftotext(path) ⇒ Object
-
#try_textutil(path) ⇒ Object
Use macOS textutil to convert .doc → txt.
- #warn(msg) ⇒ Object
Instance Method Details
#api_ok!(body, step_name) ⇒ Object
API helpers — check code=0, return data or nil
355 356 357 358 359 360 |
# File 'lib/clacky/default_skills/channel-setup/feishu_setup.rb', line 355 def api_ok!(body, step_name) code = body["code"] return body["data"] if code == 0 fail! "#{step_name} failed: code=#{code}, msg=#{body["msg"]}" end |
#api_ok?(body) ⇒ Boolean
362 363 364 |
# File 'lib/clacky/default_skills/channel-setup/feishu_setup.rb', line 362 def api_ok?(body) body.is_a?(Hash) && body["code"] == 0 end |
#build_markdown_table(rows) ⇒ Object
33 34 35 36 37 38 39 40 41 42 |
# File 'lib/clacky/default_parsers/xlsx_parser.rb', line 33 def build_markdown_table(rows) col_count = rows.map(&:size).max lines = [] rows.each_with_index do |row, i| padded = row + [""] * [col_count - row.size, 0].max lines << "| #{padded.join(" | ")} |" lines << "|#{" --- |" * col_count}" if i == 0 end lines.join("\n") end |
#cmd_delete(slug: nil) ⇒ Object
180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 |
# File 'lib/clacky/default_skills/personal-website/publish.rb', line 180 def cmd_delete(slug: nil) token_data = load_token_data token = token_data["update_token"] slug = slug || token_data["slug"] unless token && slug warn "❌ No published website found (#{TOKEN_FILE} missing or incomplete)." warn " Nothing to delete." exit 1 end status, body = http_request("DELETE", "/api/v1/personal_websites/#{slug}", extra_headers: { "X-Card-Token" => token }) if status == 200 File.delete(TOKEN_FILE) if File.exist?(TOKEN_FILE) puts "✅ Personal website deleted: /~#{slug}" else warn "❌ Delete failed (#{status}): #{body["error"] || body.inspect}" exit 1 end end |
#cmd_publish(name:, html_file:) ⇒ Object
── Commands ──────────────────────────────────────────────────────────────────
133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 |
# File 'lib/clacky/default_skills/personal-website/publish.rb', line 133 def cmd_publish(name:, html_file:) unless File.exist?(html_file) warn "❌ HTML file not found: #{html_file}" exit 1 end html_content = File.read(html_file, encoding: "utf-8") if html_content.bytesize > 1_048_576 warn "❌ HTML file exceeds 1MB (#{html_content.bytesize / 1024}KB)" exit 1 end token_data = load_token_data # If we already have a slug + token, do an update (PATCH) instead of create if token_data["slug"] && token_data["update_token"] slug = token_data["slug"] token = token_data["update_token"] status, body = http_request("PATCH", "/api/v1/personal_websites/#{slug}", body: { html_content: html_content }, extra_headers: { "X-Card-Token" => token }) if status == 200 puts "✅ Website updated: #{body["url"]}" else warn "❌ Update failed (#{status}): #{body["error"] || body.inspect}" exit 1 end else # First publish — POST to create status, body = http_request("POST", "/api/v1/personal_websites", body: { name: name, html_content: html_content }) if status == 201 save_token_data("slug" => body["slug"], "update_token" => body["update_token"]) puts "✅ Website published: #{body["url"]}" puts " Slug: #{body["slug"]}" puts " Token saved to: #{TOKEN_FILE}" else warn "❌ Publish failed (#{status}): #{body["error"] || body.inspect}" exit 1 end end end |
#device_fingerprint ⇒ Object
── HMAC signing ─────────────────────────────────────────────────────────────
43 44 45 46 47 48 49 50 |
# File 'lib/clacky/default_skills/personal-website/publish.rb', line 43 def device_fingerprint parts = [] parts << `hostname`.strip hw = `system_profiler SPHardwareDataType 2>/dev/null | grep 'Hardware UUID'`.strip parts << hw unless hw.empty? parts << ENV["USER"].to_s Digest::SHA256.hexdigest(parts.join("|"))[0, 16] end |
#display_qr(qrcode_url) ⇒ Object
QR code display (non-fetch-qr mode only)
108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 |
# File 'lib/clacky/default_skills/channel-setup/weixin_setup.rb', line 108 def display_qr(qrcode_url) displayed = false # 1. Try ASCII via qrencode CLI if system("which qrencode > /dev/null 2>&1") ascii = `qrencode -t ANSIUTF8 -o - #{Shellwords.shellescape(qrcode_url)} 2>/dev/null` if $?.success? && !ascii.empty? puts ascii displayed = true end end # 2. Generate PNG and open in Preview unless displayed tmp_path = "/tmp/clacky-weixin-qr-#{Process.pid}.png" if system("which qrencode > /dev/null 2>&1") && system("qrencode", "-o", tmp_path, qrcode_url, exception: false) step("QR code saved to: #{tmp_path}") system("open", tmp_path, exception: false) if RUBY_PLATFORM.include?("darwin") displayed = true end end # 3. Last resort: print URL unless displayed $stderr.puts("[weixin-setup] Open this URL with WeChat to login:") puts " #{qrcode_url}" end end |
#do_http_request(method, base, path, body:, extra_headers:) ⇒ Object
92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 |
# File 'lib/clacky/default_skills/personal-website/publish.rb', line 92 def do_http_request(method, base, path, body:, extra_headers:) uri = URI.parse("#{base}#{path}") http = Net::HTTP.new(uri.host, uri.port) http.use_ssl = uri.scheme == "https" http.open_timeout = OPEN_TIMEOUT http.read_timeout = READ_TIMEOUT req_class = { "POST" => Net::HTTP::Post, "PATCH" => Net::HTTP::Patch, "DELETE" => Net::HTTP::Delete }[method] req = req_class.new(uri.path) hmac_headers.each { |k, v| req[k] = v } extra_headers.each { |k, v| req[k] = v } req.body = body.to_json if body response = http.request(req) parsed = JSON.parse(response.body) rescue { "raw" => response.body } [response.code.to_i, parsed] rescue Net::OpenTimeout, Net::ReadTimeout, Errno::ECONNREFUSED, Errno::EHOSTUNREACH, Errno::ENETUNREACH, Errno::ECONNRESET, EOFError, OpenSSL::SSL::SSLError => e raise RetryableError, e. end |
#extract_runs(para_node) ⇒ Object
90 91 92 93 94 95 96 97 98 99 100 |
# File 'lib/clacky/default_parsers/docx_parser.rb', line 90 def extract_runs(para_node) parts = [] REXML::XPath.each(para_node, "w:r") do |run| rpr = REXML::XPath.first(run, "w:rPr") bold = REXML::XPath.first(rpr, "w:b") if rpr text = REXML::XPath.match(run, "w:t").map(&:text).compact.join next if text.empty? parts << (bold ? "**#{text}**" : text) end parts.join end |
#extract_text(shape_node) ⇒ Object
25 26 27 28 29 30 31 32 |
# File 'lib/clacky/default_parsers/pptx_parser.rb', line 25 def extract_text(shape_node) paras = [] REXML::XPath.each(shape_node, ".//a:p") do |para| text = REXML::XPath.match(para, ".//a:t").map(&:text).compact.join paras << text unless text.strip.empty? end paras.join("\n") end |
#fail!(msg) ⇒ Object
77 78 79 80 |
# File 'lib/clacky/default_skills/channel-setup/feishu_setup.rb', line 77 def fail!(msg) puts("[feishu-setup] ❌ #{msg}") exit 1 end |
#hmac_headers ⇒ Object
52 53 54 55 56 57 58 59 60 61 62 63 |
# File 'lib/clacky/default_skills/personal-website/publish.rb', line 52 def hmac_headers ts = Time.now.to_i.to_s fingerprint = device_fingerprint payload = "openclacky:#{ts}:#{fingerprint}" signature = OpenSSL::HMAC.hexdigest("SHA256", HMAC_SECRET, payload) { "X-Card-Timestamp" => ts, "X-Card-Fingerprint" => fingerprint, "X-Card-Signature" => signature, "Content-Type" => "application/json" } end |
#http_request(method, path, body: nil, extra_headers: {}) ⇒ Object
Resilient HTTP request: retries on transient errors, then fails over to the fallback host before giving up.
Returns [http_code_int, parsed_body_hash]. Calls exit(1) on network failure (all hosts/attempts exhausted).
72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 |
# File 'lib/clacky/default_skills/personal-website/publish.rb', line 72 def http_request(method, path, body: nil, extra_headers: {}) last_error = nil API_HOSTS.each_with_index do |base, host_index| ATTEMPTS_PER_HOST.times do |attempt| begin result = do_http_request(method, base, path, body: body, extra_headers: extra_headers) return result rescue RetryableError => e last_error = e backoff = INITIAL_BACKOFF * (2**attempt) sleep(backoff) end end end warn "❌ Network error: #{last_error&. || "unknown"}" exit 1 end |
#ilink_get(path, extra_headers: {}, timeout: 15) ⇒ Object
82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 |
# File 'lib/clacky/default_skills/channel-setup/weixin_setup.rb', line 82 def ilink_get(path, extra_headers: {}, timeout: 15) uri = URI("#{ILINK_BASE_URL}/#{path}") http = Net::HTTP.new(uri.host, uri.port) http.use_ssl = true http.verify_mode = OpenSSL::SSL::VERIFY_PEER http.read_timeout = timeout http.open_timeout = 10 req = Net::HTTP::Get.new(uri.request_uri) req["AuthorizationType"] = "ilink_bot_token" req["X-WECHAT-UIN"] = random_wechat_uin extra_headers.each { |k, v| req[k] = v } res = http.request(req) fail!("HTTP #{res.code} from #{path}: #{res.body.slice(0, 200)}") unless res.is_a?(Net::HTTPSuccess) JSON.parse(res.body) rescue Net::ReadTimeout, Net::OpenTimeout nil # caller handles timeout rescue => e fail!("iLink request failed (#{path}): #{e.}") end |
#load_token_data ⇒ Object
── Token storage ─────────────────────────────────────────────────────────────
120 121 122 123 |
# File 'lib/clacky/default_skills/personal-website/publish.rb', line 120 def load_token_data return {} unless File.exist?(TOKEN_FILE) JSON.parse(File.read(TOKEN_FILE)) rescue {} end |
#log(msg) ⇒ Object
In fetch-qr mode, write to stderr so stdout stays clean JSON
56 57 58 59 60 61 62 |
# File 'lib/clacky/default_skills/channel-setup/weixin_setup.rb', line 56 def log(msg) if FETCH_QR_MODE $stderr.puts("[weixin-setup] #{msg}") else $stderr.puts("[weixin-setup] #{msg}") end end |
#ok(msg) ⇒ Object
75 |
# File 'lib/clacky/default_skills/channel-setup/feishu_setup.rb', line 75 def ok(msg); puts("[feishu-setup] ✅ #{msg}"); end |
#parse_paragraph(node, styles, numbering) ⇒ Object
102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 |
# File 'lib/clacky/default_parsers/docx_parser.rb', line 102 def parse_paragraph(node, styles, numbering) ppr = REXML::XPath.first(node, "w:pPr") style = REXML::XPath.first(ppr, "w:pStyle")&.attributes&.[]("w:val") if ppr num_pr = REXML::XPath.first(ppr, "w:numPr") if ppr text = extract_runs(node) return nil if text.strip.empty? if style && styles[style] level = styles[style][:heading] return "#{"#" * level} #{text}" end if num_pr ilvl = REXML::XPath.first(num_pr, "w:ilvl")&.attributes&.[]("w:val").to_i indent = " " * ilvl return "#{indent}- #{text}" end text end |
#parse_row(row_node, shared_strings) ⇒ Object
25 26 27 28 29 30 31 |
# File 'lib/clacky/default_parsers/xlsx_parser.rb', line 25 def parse_row(row_node, shared_strings) REXML::XPath.match(row_node, ".//c").map do |c| v = REXML::XPath.first(c, "v")&.text next "" unless v c.attributes["t"] == "s" ? (shared_strings[v.to_i] || "") : v end end |
#parse_slide(doc, slide_num) ⇒ Object
54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 |
# File 'lib/clacky/default_parsers/pptx_parser.rb', line 54 def (doc, ) lines = [] title_text = nil REXML::XPath.each(doc, "//p:sp") do |sp| ph = REXML::XPath.first(sp, ".//p:ph") next unless ph ph_type = ph.attributes["type"] if ph_type == "title" || ph_type == "ctrTitle" title_text = extract_text(sp).strip break end end lines << "## Slide #{}#{title_text && !title_text.empty? ? ": #{title_text}" : ""}" REXML::XPath.each(doc, "//p:sp") do |sp| ph = REXML::XPath.first(sp, ".//p:ph") if ph ph_type = ph.attributes["type"] next if %w[title ctrTitle sldNum dt ftr].include?(ph_type) end text = extract_text(sp).strip next if text.empty? next if text == title_text text.each_line do |line| lines << "- #{line.rstrip}" unless line.strip.empty? end end REXML::XPath.each(doc, "//a:tbl") do |tbl| lines << parse_table(tbl) end lines.join("\n") end |
#parse_table(tbl_node) ⇒ Object
124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 |
# File 'lib/clacky/default_parsers/docx_parser.rb', line 124 def parse_table(tbl_node) rows = [] REXML::XPath.each(tbl_node, "w:tr") do |tr| cells = REXML::XPath.match(tr, "w:tc").map do |tc| REXML::XPath.match(tc, ".//w:t").map(&:text).compact.join(" ").strip end rows << cells end return "" if rows.empty? col_count = rows.map(&:size).max lines = [] rows.each_with_index do |row, i| padded = row + [""] * [col_count - row.size, 0].max lines << "| #{padded.join(" | ")} |" lines << "|#{" --- |" * col_count}" if i == 0 end lines.join("\n") end |
#poll_until_confirmed(qrcode) ⇒ Object
Long-poll loop (shared by all modes)
169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 |
# File 'lib/clacky/default_skills/channel-setup/weixin_setup.rb', line 169 def poll_until_confirmed(qrcode) deadline = Time.now + LOGIN_DEADLINE_S scanned_once = false loop do fail!("Login timed out. Please run setup again.") if Time.now > deadline resp = ilink_get( "ilink/bot/get_qrcode_status?qrcode=#{CGI.escape(qrcode)}", extra_headers: { "iLink-App-ClientVersion" => "1" }, timeout: QR_POLL_TIMEOUT_S ) next if resp.nil? # read timeout = server-side long-poll ended, retry case resp["status"] when "wait" # still waiting when "scaned" unless scanned_once $stderr.puts("[weixin-setup] WeChat scanned! Please confirm in the app...") scanned_once = true end when "confirmed" token = resp["bot_token"].to_s.strip base_url = resp["baseurl"].to_s.strip base_url = ILINK_BASE_URL if base_url.empty? fail!("Login confirmed but no token received") if token.empty? return { token: token, base_url: base_url } when "expired" fail!("QR code expired. Please run setup again.") else $stderr.puts("[weixin-setup] Unknown status: #{resp["status"]}, continuing...") end end end |
#poll_with_ws_wait(step_name, timeout: WEBSOCKET_POLL_TIMEOUT, interval: WEBSOCKET_POLL_INTERVAL) ⇒ Object
Websocket-mode polling helper (code=10068 means WS not ready yet)
370 371 372 373 374 375 376 377 378 379 380 381 382 383 384 385 386 387 388 389 390 |
# File 'lib/clacky/default_skills/channel-setup/feishu_setup.rb', line 370 def poll_with_ws_wait(step_name, timeout: WEBSOCKET_POLL_TIMEOUT, interval: WEBSOCKET_POLL_INTERVAL) deadline = Time.now + timeout attempt = 0 last_body = nil loop do attempt += 1 body = yield last_body = body return body if body["code"] == 0 if body["code"] == 10068 step " #{step_name}: waiting for WebSocket connection... (#{attempt})" if Time.now > deadline warn "#{step_name}: WebSocket not ready after #{timeout}s — continuing anyway (will retry on reconnect)" return body end sleep interval else fail! "#{step_name} failed: code=#{body["code"]}, msg=#{body["msg"]}" end end end |
#random_wechat_uin ⇒ Object
iLink HTTP helpers
77 78 79 80 |
# File 'lib/clacky/default_skills/channel-setup/weixin_setup.rb', line 77 def random_wechat_uin uint32 = SecureRandom.random_bytes(4).unpack1("N") Base64.strict_encode64(uint32.to_s) end |
#read_document_xml(body) ⇒ Object
47 48 49 50 51 |
# File 'lib/clacky/default_parsers/docx_parser.rb', line 47 def read_document_xml(body) xml = read_zip_entry(body, "word/document.xml") raise "Could not extract content — possibly encrypted or invalid format" unless xml xml end |
#read_numbering(body) ⇒ Object
53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 |
# File 'lib/clacky/default_parsers/docx_parser.rb', line 53 def read_numbering(body) result = {} xml = read_zip_entry(body, "word/numbering.xml") return result unless xml doc = REXML::Document.new(xml) REXML::XPath.each(doc, "//w:abstractNum") do |an| id = an.attributes["w:abstractNumId"] levels = {} REXML::XPath.each(an, "w:lvl") do |lvl| ilvl = lvl.attributes["w:ilvl"].to_i fmt = REXML::XPath.first(lvl, "w:numFmt")&.attributes&.[]("w:val") levels[ilvl] = { fmt: fmt || "bullet" } end result[id] = levels end result rescue {} end |
#read_styles(body) ⇒ Object
73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 |
# File 'lib/clacky/default_parsers/docx_parser.rb', line 73 def read_styles(body) result = {} xml = read_zip_entry(body, "word/styles.xml") return result unless xml doc = REXML::Document.new(xml) REXML::XPath.each(doc, "//w:style") do |s| sid = s.attributes["w:styleId"] name = REXML::XPath.first(s, "w:name")&.attributes&.[]("w:val").to_s if name =~ /^heading (\d)/i result[sid] = { heading: $1.to_i } end end result rescue {} end |
#read_zip_entry(body, name) ⇒ Object
38 39 40 41 42 43 44 45 |
# File 'lib/clacky/default_parsers/docx_parser.rb', line 38 def read_zip_entry(body, name) xml = nil Zip::File.open_buffer(StringIO.new(body)) do |zip| entry = zip.find_entry(name) xml = safe_utf8(entry.get_input_stream.read) if entry end xml end |
#run_setup(browser, api) ⇒ Object
Main setup logic
396 397 398 399 400 401 402 403 404 405 406 407 408 409 410 411 412 413 414 415 416 417 418 419 420 421 422 423 424 425 426 427 428 429 430 431 432 433 434 435 436 437 438 439 440 441 442 443 444 445 446 447 448 449 450 451 452 453 454 455 456 457 458 459 460 461 462 463 464 465 466 467 468 469 470 471 472 473 474 475 476 477 478 479 480 481 482 483 484 485 486 487 488 489 490 491 492 493 494 495 496 497 498 499 500 501 502 503 504 505 506 507 508 509 510 511 512 513 514 515 516 517 518 519 520 521 522 523 524 525 526 527 528 529 530 531 532 533 534 535 536 537 538 539 540 541 542 543 544 545 546 547 548 549 550 551 552 |
# File 'lib/clacky/default_skills/channel-setup/feishu_setup.rb', line 396 def run_setup(browser, api) app_id = nil app_secret = nil version_id = nil # ── Phase 1: Verify login ──────────────────────────────────────────────── step "Phase 1 — Verifying Feishu login..." snap = browser.open("https://open.feishu.cn/app") unless snap.include?("创建企业自建") || snap.include?("Create Custom App") || snap.include?("Create Enterprise") fail! "Not logged in to Feishu Open Platform. Please log in to open.feishu.cn in Chrome first, then re-run." end ok "Logged in, app console visible." # ── Phase 2: Create app via API ────────────────────────────────────────── step "Phase 2 — Creating app '#{APP_NAME}' via API..." body = api.create_app(APP_NAME, APP_DESC) data = api_ok!(body, "create_app") app_id = data["ClientID"] || data["client_id"] || data["appId"] || data["app_id"] fail! "create_app succeeded (code=0) but no ClientID in response: #{data.inspect}" unless app_id ok "App created: #{APP_NAME} (#{app_id})" # ── Phase 3: Get credentials ───────────────────────────────────────────── step "Phase 3 — Reading App Secret..." body = api.get_secret(app_id) data = api_ok!(body, "get_secret") app_secret = data["appSecret"] || data["app_secret"] || data["secret"] || data["AppSecret"] fail! "No App Secret in response: #{data.inspect}" unless app_secret ok "Credentials: App ID=#{app_id}, App Secret=****#{app_secret[-4..]}" # ── Phase 4: Write credentials to clacky server and wait for WS ───────── step "Phase 4 — Writing credentials to clacky server..." # Helper: one-shot HTTP request to clacky server (new connection each time, no keep-alive issues) server_request = lambda do |method, path, body_hash = nil| uri = URI(CLACKY_SERVER_URL) Net::HTTP.start(uri.host, uri.port, open_timeout: 3, read_timeout: 10) do |h| req = method == :post \ ? Net::HTTP::Post.new(path, "Content-Type" => "application/json") \ : Net::HTTP::Get.new(path) req.body = JSON.generate(body_hash) if body_hash h.request(req) end end begin res = server_request.call(:post, "/api/channels/feishu", { app_id: app_id, app_secret: app_secret, enabled: true }) step " Server response: #{res.code}" rescue StandardError => e warn "Could not reach clacky server (#{e.}) — continuing..." end ok "Credentials submitted, waiting for WebSocket connection..." # Poll GET /api/channels until feishu shows running: true (max 90s) ws_ready = false ws_deadline = Time.now + 90 loop do begin res = server_request.call(:get, "/api/channels") channels = JSON.parse(res.body)["channels"] || [] feishu = channels.find { |c| c["platform"] == "feishu" } if feishu && feishu["running"] ws_ready = true break end rescue StandardError => e warn "Channel status check failed: #{e.}" end break if Time.now > ws_deadline step " Waiting for Feishu WebSocket connection..." sleep 3 end if ws_ready ok "Feishu WebSocket connected." else warn "WebSocket not confirmed within 90s — continuing anyway." end # ── Phase 5: Enable Bot capability ────────────────────────────────────── step "Phase 5 — Enabling Bot capability..." body = api.enable_bot(app_id) api_ok!(body, "enable_bot") ok "Bot capability enabled." # ── Phase 6: Switch event mode to Long Connection (WebSocket) ─────────── step "Phase 6 — Switching event mode to Long Connection (WebSocket)..." poll_with_ws_wait("switch_event_mode") { api.switch_event_mode(app_id) } ok "Event mode: done (WebSocket)." # ── Phase 7: Add im.message.receive_v1 event ──────────────────────────── step "Phase 7 — Adding im.message.receive_v1 event..." ev_body = api.get_event(app_id) event_mode = api_ok?(ev_body) ? (ev_body.dig("data", "eventMode") || 4) : 4 body = api.update_event(app_id, event_mode: event_mode) api_ok!(body, "update_event") ok "Event im.message.receive_v1 added." # ── Phase 8: Switch callback mode to Long Connection ──────────────────── step "Phase 8 — Switching callback mode to Long Connection..." poll_with_ws_wait("switch_callback_mode") { api.switch_callback_mode(app_id) } ok "Callback mode: done (Long Connection)." # ── Phase 9: Add permissions ───────────────────────────────────────────── step "Phase 9 — Adding Bot permissions..." scope_body = api.get_all_scopes(app_id) scope_data = api_ok!(scope_body, "get_all_scopes") scopes = scope_data["scopes"] || [] name_to_id = {} scopes.each do |s| name = s["name"] || s["scopeName"] || "" id = s["id"].to_s name_to_id[name] = id if name && !id.empty? end ids = BOT_PERMISSIONS.map { |n| name_to_id[n] }.compact missing = BOT_PERMISSIONS.reject { |n| name_to_id.key?(n) } warn "#{missing.size} permissions not matched: #{missing.join(", ")}" unless missing.empty? fail! "No permission IDs matched. API response keys: #{name_to_id.keys.first(5).inspect}" if ids.empty? body = api.update_scopes(app_id, ids) api_ok!(body, "update_scopes") ok "#{ids.size} permissions added." # ── Phase 10: Publish app ──────────────────────────────────────────────── step "Phase 10 — Creating version and publishing..." body = api.create_version(app_id) data = api_ok!(body, "create_version") version_id = data["versionId"] || data["version_id"] fail! "No version_id in create_version response: #{data.inspect}" unless version_id sleep 1 body = api.commit_version(app_id, version_id) api_ok!(body, "commit_version") sleep 1 body = api.release_version(app_id, version_id) release_code = body["code"] if release_code == 0 ok "App published successfully." elsif release_code == 10002 # Already approved or auto-published — verify actual status sleep 1 info = api.get_app_info(app_id) if api_ok?(info) && info.dig("data", "appStatus") == 1 ok "App published (auto-approved)." else warn "App submitted for review (admin approval required). App ID: #{app_id}" end else warn "Publish returned code=#{release_code} (#{body["msg"]}) — app may need admin approval." warn "You can publish manually at: #{FEISHU_BASE_URL}/app/#{app_id}" end # Config was already saved by the server in Phase 4 via POST /api/channels/feishu ok "🎉 Feishu channel setup complete! App: #{APP_NAME} (#{app_id})" ok " Manage at: #{FEISHU_BASE_URL}/app/#{app_id}" end |
#safe_utf8(str) ⇒ Object
30 31 32 33 34 35 36 |
# File 'lib/clacky/default_parsers/docx_parser.rb', line 30 def safe_utf8(str) # First try force_encoding (lossless, for content that IS valid UTF-8) utf8 = str.dup.force_encoding("UTF-8") return utf8 if utf8.valid_encoding? # Fallback: transcode with replacement for genuinely invalid bytes str.encode("UTF-8", "binary", invalid: :replace, undef: :replace, replace: "") end |
#save_to_server(token:, base_url:) ⇒ Object
Clacky server — save credentials
142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 |
# File 'lib/clacky/default_skills/channel-setup/weixin_setup.rb', line 142 def save_to_server(token:, base_url:) uri = URI("#{CLACKY_SERVER_URL}/api/channels/weixin") body = JSON.generate({ token: token, base_url: base_url }) http = Net::HTTP.new(uri.host, uri.port) http.read_timeout = 15 http.open_timeout = 5 req = Net::HTTP::Post.new(uri.path, "Content-Type" => "application/json") req.body = body res = http.request(req) data = JSON.parse(res.body) rescue {} unless res.is_a?(Net::HTTPSuccess) && data["ok"] fail!("Failed to save Weixin config: #{data["error"] || res.body.slice(0, 200)}") end ok("Credentials saved via clacky server") rescue => e fail!("Could not reach clacky server: #{e.}") end |
#save_token_data(data) ⇒ Object
125 126 127 128 129 |
# File 'lib/clacky/default_skills/personal-website/publish.rb', line 125 def save_token_data(data) FileUtils.mkdir_p(File.dirname(TOKEN_FILE)) File.write(TOKEN_FILE, JSON.pretty_generate(data)) File.chmod(0600, TOKEN_FILE) end |
#step(msg) ⇒ Object
Logging (suppress in –fetch-qr mode so stdout is clean JSON)
74 |
# File 'lib/clacky/default_skills/channel-setup/feishu_setup.rb', line 74 def step(msg); puts("[feishu-setup] #{msg}"); end |
#try_antiword(path) ⇒ Object
Use antiword to extract text from .doc files (Linux/WSL)
36 37 38 39 40 41 42 43 44 |
# File 'lib/clacky/default_parsers/doc_parser.rb', line 36 def try_antiword(path) stdout, _stderr, status = Open3.capture3("antiword", path) return nil unless status.success? text = stdout.strip return nil if text.bytesize < MIN_CONTENT_BYTES text rescue Errno::ENOENT nil # antiword not installed end |
#try_pdfplumber(path) ⇒ Object
34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 |
# File 'lib/clacky/default_parsers/pdf_parser.rb', line 34 def try_pdfplumber(path) script = <<~PYTHON import sys, pdfplumber with pdfplumber.open(sys.argv[1]) as pdf: pages = [] for i, page in enumerate(pdf.pages, 1): t = page.extract_text() if t and t.strip(): pages.append(f"--- Page {i} ---\\n{t.strip()}") print("\\n\\n".join(pages)) PYTHON stdout, _stderr, status = Open3.capture3("python3", "-c", script, path) return nil unless status.success? text = stdout.strip return nil if text.bytesize < MIN_CONTENT_BYTES text rescue Errno::ENOENT nil # python3 not available end |
#try_pdftotext(path) ⇒ Object
24 25 26 27 28 29 30 31 32 |
# File 'lib/clacky/default_parsers/pdf_parser.rb', line 24 def try_pdftotext(path) stdout, _stderr, status = Open3.capture3("pdftotext", "-layout", "-enc", "UTF-8", path, "-") return nil unless status.success? text = stdout.strip return nil if text.bytesize < MIN_CONTENT_BYTES text rescue Errno::ENOENT nil # pdftotext not installed end |
#try_textutil(path) ⇒ Object
Use macOS textutil to convert .doc → txt
25 26 27 28 29 30 31 32 33 |
# File 'lib/clacky/default_parsers/doc_parser.rb', line 25 def try_textutil(path) stdout, _stderr, status = Open3.capture3("textutil", "-convert", "txt", "-stdout", path) return nil unless status.success? text = stdout.strip return nil if text.bytesize < MIN_CONTENT_BYTES text rescue Errno::ENOENT nil # textutil not available (non-macOS) end |
#warn(msg) ⇒ Object
76 |
# File 'lib/clacky/default_skills/channel-setup/feishu_setup.rb', line 76 def warn(msg); puts("[feishu-setup] ⚠️ #{msg}"); end |