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.expand_path("~/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
"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.expand_path("..", __FILE__)
GEM_LIB_DIR =
File.expand_path("../../../../..", DEPLOY_SCRIPT_DIR)

Instance Method Summary collapse

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

Returns:

  • (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_fingerprintObject

── 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.message
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_headersObject



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&.message || "unknown"}"
  exit 1
end


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.message}")
end

#load_token_dataObject

── 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 parse_slide(doc, slide_num)
  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 #{slide_num}#{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_uinObject


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.message}) — 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.message}"
    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.message}")
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