openclacky 0.9.7 → 0.9.9

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (85) hide show
  1. checksums.yaml +4 -4
  2. data/.clacky/skills/gem-release/SKILL.md +16 -1
  3. data/CHANGELOG.md +51 -0
  4. data/docs/browser-cdp-native-design.md +195 -0
  5. data/docs/session-management-redesign.md +202 -0
  6. data/docs/system-skill-authoring-guide.md +47 -0
  7. data/lib/clacky/agent/cost_tracker.rb +2 -1
  8. data/lib/clacky/agent/message_compressor_helper.rb +2 -2
  9. data/lib/clacky/agent/session_serializer.rb +36 -3
  10. data/lib/clacky/agent/skill_manager.rb +68 -5
  11. data/lib/clacky/agent/system_prompt_builder.rb +30 -13
  12. data/lib/clacky/agent/tool_executor.rb +8 -12
  13. data/lib/clacky/agent.rb +172 -30
  14. data/lib/clacky/brand_config.rb +90 -0
  15. data/lib/clacky/cli.rb +37 -8
  16. data/lib/clacky/default_parsers/doc_parser.rb +69 -0
  17. data/lib/clacky/default_parsers/docx_parser.rb +172 -0
  18. data/lib/clacky/default_parsers/pdf_parser.rb +79 -0
  19. data/lib/clacky/default_parsers/pptx_parser.rb +140 -0
  20. data/lib/clacky/default_parsers/xlsx_parser.rb +121 -0
  21. data/lib/clacky/default_skills/browser-setup/SKILL.md +158 -0
  22. data/lib/clacky/default_skills/channel-setup/SKILL.md +139 -42
  23. data/lib/clacky/default_skills/channel-setup/feishu_setup.rb +582 -0
  24. data/lib/clacky/default_skills/channel-setup/weixin_setup.rb +274 -0
  25. data/lib/clacky/default_skills/onboard/SKILL.md +132 -6
  26. data/lib/clacky/default_skills/personal-website/SKILL.md +113 -0
  27. data/lib/clacky/default_skills/personal-website/publish.rb +214 -0
  28. data/lib/clacky/default_skills/skill-add/SKILL.md +6 -7
  29. data/lib/clacky/default_skills/skill-add/scripts/install_from_zip.rb +1 -1
  30. data/lib/clacky/default_skills/skill-creator/SKILL.md +3 -3
  31. data/lib/clacky/json_ui_controller.rb +5 -0
  32. data/lib/clacky/message_format/open_ai.rb +7 -1
  33. data/lib/clacky/plain_ui_controller.rb +5 -0
  34. data/lib/clacky/server/browser_manager.rb +308 -0
  35. data/lib/clacky/server/channel/adapters/feishu/adapter.rb +80 -15
  36. data/lib/clacky/server/channel/adapters/feishu/bot.rb +216 -7
  37. data/lib/clacky/server/channel/adapters/feishu/message_parser.rb +12 -0
  38. data/lib/clacky/server/channel/adapters/wecom/adapter.rb +2 -8
  39. data/lib/clacky/server/channel/adapters/weixin/adapter.rb +391 -0
  40. data/lib/clacky/server/channel/adapters/weixin/api_client.rb +374 -0
  41. data/lib/clacky/server/channel/channel_config.rb +6 -0
  42. data/lib/clacky/server/channel/channel_manager.rb +50 -15
  43. data/lib/clacky/server/channel/channel_ui_controller.rb +18 -0
  44. data/lib/clacky/server/channel.rb +1 -0
  45. data/lib/clacky/server/http_server.rb +210 -45
  46. data/lib/clacky/server/scheduler.rb +1 -1
  47. data/lib/clacky/server/session_registry.rb +131 -40
  48. data/lib/clacky/server/web_ui_controller.rb +21 -2
  49. data/lib/clacky/session_manager.rb +43 -73
  50. data/lib/clacky/skill.rb +64 -42
  51. data/lib/clacky/tools/browser.rb +728 -183
  52. data/lib/clacky/tools/invoke_skill.rb +18 -11
  53. data/lib/clacky/tools/shell.rb +16 -7
  54. data/lib/clacky/tools/web_fetch.rb +3 -1
  55. data/lib/clacky/ui2/README.md +1 -1
  56. data/lib/clacky/ui2/components/input_area.rb +5 -2
  57. data/lib/clacky/ui2/components/welcome_banner.rb +23 -0
  58. data/lib/clacky/ui2/ui_controller.rb +16 -2
  59. data/lib/clacky/ui_interface.rb +7 -0
  60. data/lib/clacky/utils/file_processor.rb +116 -139
  61. data/lib/clacky/utils/model_pricing.rb +5 -4
  62. data/lib/clacky/utils/parser_manager.rb +93 -0
  63. data/lib/clacky/utils/workspace_rules.rb +46 -0
  64. data/lib/clacky/version.rb +1 -1
  65. data/lib/clacky/web/app.css +696 -62
  66. data/lib/clacky/web/app.js +181 -51
  67. data/lib/clacky/web/brand.js +14 -0
  68. data/lib/clacky/web/channels.js +10 -1
  69. data/lib/clacky/web/i18n.js +49 -7
  70. data/lib/clacky/web/index.html +67 -10
  71. data/lib/clacky/web/onboard.js +2 -1
  72. data/lib/clacky/web/sessions.js +255 -69
  73. data/lib/clacky/web/settings.js +113 -1
  74. data/lib/clacky/web/skills.js +16 -3
  75. data/lib/clacky/web/tasks.js +11 -6
  76. data/lib/clacky/web/weixin-qr.html +104 -0
  77. data/lib/clacky.rb +2 -0
  78. data/scripts/install.sh +41 -11
  79. metadata +48 -7
  80. data/lib/clacky/default_skills/pdf-reader/SKILL.md +0 -90
  81. data/lib/clacky/utils/file_parser/docx_parser.rb +0 -156
  82. data/lib/clacky/utils/file_parser/pptx_parser.rb +0 -116
  83. data/lib/clacky/utils/file_parser/xlsx_parser.rb +0 -95
  84. data/lib/clacky/utils/file_parser/zip_parser.rb +0 -60
  85. data/scripts/install_agent_browser.sh +0 -67
@@ -0,0 +1,582 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+
4
+ # feishu_setup.rb — Automated Feishu channel setup via internal API.
5
+ #
6
+ # Strategy: Use browser (via clacky server's /api/tool/browser) ONLY to:
7
+ # 1. Check login status and grab Cookie + CSRF token
8
+ # 2. Navigate to trigger session initialization
9
+ #
10
+ # Then call Feishu Open Platform's internal API directly (same as the web UI does):
11
+ # POST /developers/v1/app/create
12
+ # GET /developers/v1/secret/{app_id}
13
+ # POST /developers/v1/robot/switch/{app_id}
14
+ # POST /developers/v1/event/switch/{app_id}
15
+ # POST /developers/v1/event/update/{app_id}
16
+ # POST /developers/v1/callback/switch/{app_id}
17
+ # GET /developers/v1/scope/all/{app_id}
18
+ # POST /developers/v1/scope/update/{app_id}
19
+ # POST /developers/v1/app_version/create/{app_id}
20
+ # POST /developers/v1/publish/commit/{app_id}/{version_id}
21
+ # POST /developers/v1/publish/release/{app_id}/{version_id}
22
+ #
23
+ # This is far more reliable than UI automation.
24
+ #
25
+ # Environment (injected by clacky server):
26
+ # CLACKY_SERVER_PORT — port the clacky server is listening on
27
+ # CLACKY_SERVER_HOST — host the clacky server is listening on
28
+ # CLACKY_PRODUCT_NAME — product name (default: OpenClacky)
29
+
30
+ require "json"
31
+ require "yaml"
32
+ require "net/http"
33
+ require "net/https"
34
+ require "uri"
35
+
36
+ # ---------------------------------------------------------------------------
37
+ # Configuration
38
+ # ---------------------------------------------------------------------------
39
+
40
+ PRODUCT_NAME = ENV.fetch("CLACKY_PRODUCT_NAME", "OpenClacky")
41
+ DATE_SUFFIX = Time.now.strftime("%Y%m%d")
42
+ APP_NAME = "#{PRODUCT_NAME} #{DATE_SUFFIX}"
43
+ APP_DESC = "Your personal assistant powered by #{PRODUCT_NAME}"
44
+ FEISHU_BASE_URL = "https://open.feishu.cn"
45
+ FEISHU_API_BASE = "#{FEISHU_BASE_URL}/developers/v1"
46
+ CLACKY_SERVER_URL = begin
47
+ url = "http://#{ENV.fetch("CLACKY_SERVER_HOST")}:#{ENV.fetch("CLACKY_SERVER_PORT")}"
48
+ uri = URI.parse(url)
49
+ raise "Invalid CLACKY_SERVER_URL: #{url}" unless uri.is_a?(URI::HTTP) && uri.host && uri.port
50
+ url
51
+ end
52
+ WEBSOCKET_POLL_INTERVAL = 3
53
+ WEBSOCKET_POLL_TIMEOUT = 30
54
+
55
+ BOT_PERMISSIONS = %w[
56
+ im:message
57
+ im:message.p2p_msg:readonly
58
+ im:message.group_at_msg:readonly
59
+ im:message:send_as_bot
60
+ im:resource
61
+ im:message.group_msg
62
+ im:message:readonly
63
+ im:message:update
64
+ im:message:recall
65
+ im:message.reactions:read
66
+ contact:user.base:readonly
67
+ contact:contact.base:readonly
68
+ ].freeze
69
+
70
+ # ---------------------------------------------------------------------------
71
+ # Logging helpers
72
+ # ---------------------------------------------------------------------------
73
+
74
+ def step(msg) = puts("[feishu-setup] #{msg}")
75
+ def ok(msg) = puts("[feishu-setup] ✅ #{msg}")
76
+ def warn(msg) = puts("[feishu-setup] ⚠️ #{msg}")
77
+ def fail!(msg)
78
+ puts("[feishu-setup] ❌ #{msg}")
79
+ exit 1
80
+ end
81
+
82
+ # ---------------------------------------------------------------------------
83
+ # ToolClient — proxies browser calls through /api/tool/browser on clacky server
84
+ # ---------------------------------------------------------------------------
85
+
86
+ class ToolClient
87
+ def initialize(server_url)
88
+ @server_url = server_url
89
+ @http = nil # lazy init, rebuilt on connection errors
90
+ end
91
+
92
+ def call(action, **params)
93
+ uri = URI("#{@server_url}/api/tool/browser")
94
+ request = Net::HTTP::Post.new(uri, "Content-Type" => "application/json")
95
+ request.body = JSON.generate({ "action" => action.to_s }.merge(params.transform_keys(&:to_s)))
96
+ response = http.request(request)
97
+ raise "Server error #{response.code}: #{response.body}" unless response.code.to_i < 500
98
+ result = JSON.parse(response.body)
99
+ raise "Browser error: #{result["error"]}" if result["error"]
100
+ result
101
+ rescue Errno::ECONNREFUSED, Errno::ECONNRESET, EOFError, Net::ReadTimeout, IOError => e
102
+ # Connection dropped (keep-alive expired or server restarted) — rebuild and retry once
103
+ @http = nil
104
+ raise "ToolClient connection failed: #{e.message}"
105
+ end
106
+
107
+ private
108
+
109
+ def http
110
+ return @http if @http
111
+ uri = URI("#{@server_url}/api/tool/browser")
112
+ @http = Net::HTTP.new(uri.host, uri.port)
113
+ @http.open_timeout = 5
114
+ @http.read_timeout = 60
115
+ @http
116
+ end
117
+ end
118
+
119
+ # ---------------------------------------------------------------------------
120
+ # BrowserSession — minimal browser wrapper, only used for login check + cookies
121
+ # ---------------------------------------------------------------------------
122
+
123
+ class BrowserSession
124
+ attr_reader :target_id
125
+
126
+ def initialize(client)
127
+ @client = client
128
+ @target_id = nil
129
+ end
130
+
131
+ def navigate(url)
132
+ if @target_id
133
+ @client.call("navigate", target_id: @target_id, url: url)
134
+ else
135
+ result = @client.call("open", url: url)
136
+ @target_id = result["targetId"]
137
+ end
138
+ sleep 2
139
+ snapshot
140
+ end
141
+
142
+ def snapshot(interactive: true, compact: true)
143
+ result = @client.call("snapshot",
144
+ target_id: @target_id, interactive: interactive, compact: compact)
145
+ result["output"].to_s
146
+ end
147
+
148
+ # Run JavaScript in the page context and return the raw output string.
149
+ def evaluate(js)
150
+ result = @client.call("act", target_id: @target_id, kind: "evaluate", js: js)
151
+ result["output"].to_s
152
+ end
153
+
154
+ # Extract all cookies for open.feishu.cn from the browser.
155
+ # The browser evaluate output may be wrapped in MCP markdown — parse it out.
156
+ def cookies
157
+ result = @client.call("act",
158
+ target_id: @target_id,
159
+ kind: "evaluate",
160
+ js: "document.cookie")
161
+ raw = result["output"].to_s
162
+ extract_string_value(raw)
163
+ end
164
+
165
+ # Get CSRF token — try multiple sources
166
+ def csrf_token
167
+ # Try x-csrf-token from cookie
168
+ all_cookies = cookies
169
+ all_cookies.split(";").each do |pair|
170
+ k, v = pair.strip.split("=", 2)
171
+ return v.strip if k.strip =~ /csrf.token/i && v
172
+ end
173
+
174
+ # Try lark_oapi_csrf_token specifically via JS
175
+ result = @client.call("act",
176
+ target_id: @target_id,
177
+ kind: "evaluate",
178
+ js: "document.cookie.split(';').map(c=>c.trim()).find(c=>c.startsWith('lark_oapi_csrf_token='))?.split('=')[1] || document.cookie.split(';').map(c=>c.trim()).find(c=>c.startsWith('lgw_csrf_token='))?.split('=')[1] || document.cookie.split(';').map(c=>c.trim()).find(c=>c.startsWith('swp_csrf_token='))?.split('=')[1] || ''")
179
+ token = extract_string_value(result["output"].to_s)
180
+ return token unless token.empty?
181
+
182
+ # Try from window object
183
+ result = @client.call("act",
184
+ target_id: @target_id,
185
+ kind: "evaluate",
186
+ js: "window.csrfToken || ''")
187
+ extract_string_value(result["output"].to_s)
188
+ end
189
+
190
+ # The MCP tool wraps evaluate results in markdown code blocks:
191
+ # "Script ran on page and returned:\n```json\n\"value\"\n```\n"
192
+ # This extracts the actual string value.
193
+ def extract_string_value(raw)
194
+ # Try to find a JSON string value inside ```json ... ``` block
195
+ if raw =~ /```json\s*(.*?)\s*```/m
196
+ inner = $1.strip
197
+ begin
198
+ parsed = JSON.parse(inner)
199
+ return parsed.to_s if parsed.is_a?(String)
200
+ # If it's not a string (e.g. already the cookie text), return as-is
201
+ return inner
202
+ rescue JSON::ParserError
203
+ return inner
204
+ end
205
+ end
206
+ # No markdown wrapper — return raw stripped
207
+ raw.strip
208
+ end
209
+ end
210
+
211
+ # ---------------------------------------------------------------------------
212
+ # FeishuApiClient — calls Feishu internal API via the browser (fetch in page context).
213
+ # This way the browser automatically includes all cookies, CSRF tokens, and
214
+ # session headers — we never need to extract them manually.
215
+ # ---------------------------------------------------------------------------
216
+
217
+ class FeishuApiClient
218
+ def initialize(browser_session)
219
+ @browser = browser_session
220
+ end
221
+
222
+ def create_app(name, desc)
223
+ post_json("#{FEISHU_API_BASE}/app/create", {
224
+ appSceneType: 0,
225
+ name: name,
226
+ desc: desc,
227
+ avatar: "",
228
+ i18n: { zh_cn: { name: name, description: desc } },
229
+ primaryLang: "zh_cn"
230
+ })
231
+ end
232
+
233
+ def get_secret(app_id)
234
+ get_json("#{FEISHU_API_BASE}/secret/#{app_id}")
235
+ end
236
+
237
+ def enable_bot(app_id)
238
+ post_json("#{FEISHU_API_BASE}/robot/switch/#{app_id}", { enable: true })
239
+ end
240
+
241
+ def switch_event_mode(app_id, mode: 4)
242
+ post_json("#{FEISHU_API_BASE}/event/switch/#{app_id}", { eventMode: mode })
243
+ end
244
+
245
+ def get_event(app_id)
246
+ get_json("#{FEISHU_API_BASE}/event/#{app_id}")
247
+ end
248
+
249
+ def update_event(app_id, event_mode:)
250
+ post_json("#{FEISHU_API_BASE}/event/update/#{app_id}", {
251
+ operation: "add",
252
+ events: ["im.message.receive_v1"],
253
+ eventMode: event_mode
254
+ })
255
+ end
256
+
257
+ def switch_callback_mode(app_id, mode: 4)
258
+ post_json("#{FEISHU_API_BASE}/callback/switch/#{app_id}", { callbackMode: mode })
259
+ end
260
+
261
+ def get_all_scopes(app_id)
262
+ get_json("#{FEISHU_API_BASE}/scope/all/#{app_id}")
263
+ end
264
+
265
+ def update_scopes(app_id, scope_ids)
266
+ post_json("#{FEISHU_API_BASE}/scope/update/#{app_id}", {
267
+ clientId: app_id,
268
+ appScopeIDs: scope_ids,
269
+ userScopeIDs: [],
270
+ scopeIds: [],
271
+ operation: "add"
272
+ })
273
+ end
274
+
275
+ def create_version(app_id, version: "1.0.0")
276
+ post_json("#{FEISHU_API_BASE}/app_version/create/#{app_id}", {
277
+ clientId: app_id,
278
+ appVersion: version,
279
+ changeLog: "Initial release",
280
+ autoPublish: false,
281
+ pcDefaultAbility: "bot",
282
+ mobileDefaultAbility: "bot"
283
+ })
284
+ end
285
+
286
+ def commit_version(app_id, version_id)
287
+ post_json("#{FEISHU_API_BASE}/publish/commit/#{app_id}/#{version_id}", {})
288
+ end
289
+
290
+ def release_version(app_id, version_id)
291
+ post_json("#{FEISHU_API_BASE}/publish/release/#{app_id}/#{version_id}", {
292
+ clientId: app_id,
293
+ versionId: version_id
294
+ })
295
+ end
296
+
297
+ def get_app_info(app_id)
298
+ get_json("#{FEISHU_API_BASE}/app/#{app_id}")
299
+ end
300
+
301
+ private
302
+
303
+ # Execute a GET fetch in the browser page context.
304
+ # Uses window.csrfToken — required by all /developers/v1/ endpoints.
305
+ def get_json(url)
306
+ js = <<~JS
307
+ (async () => {
308
+ const csrfToken = window.csrfToken || '';
309
+ const resp = await fetch(#{url.to_json}, {
310
+ method: 'GET',
311
+ credentials: 'include',
312
+ headers: {
313
+ 'accept': '*/*',
314
+ 'x-timezone-offset': '-480',
315
+ 'x-csrf-token': csrfToken
316
+ }
317
+ });
318
+ return await resp.text();
319
+ })()
320
+ JS
321
+ run_fetch(js, url)
322
+ end
323
+
324
+ # Execute a POST fetch in the browser page context.
325
+ # Uses window.csrfToken (set by Feishu's own JS) — the correct token for /developers/v1/ APIs.
326
+ def post_json(url, payload)
327
+ js = <<~JS
328
+ (async () => {
329
+ const csrfToken = window.csrfToken || '';
330
+ const resp = await fetch(#{url.to_json}, {
331
+ method: 'POST',
332
+ credentials: 'include',
333
+ headers: {
334
+ 'accept': '*/*',
335
+ 'content-type': 'application/json',
336
+ 'origin': 'https://open.feishu.cn',
337
+ 'referer': 'https://open.feishu.cn/app',
338
+ 'x-timezone-offset': '-480',
339
+ 'x-csrf-token': csrfToken
340
+ },
341
+ body: #{JSON.generate(payload).to_json}
342
+ });
343
+ return await resp.text();
344
+ })()
345
+ JS
346
+ run_fetch(js, url)
347
+ end
348
+
349
+ def run_fetch(js, url)
350
+ raw = @browser.evaluate(js)
351
+ # The MCP tool may wrap result in markdown code blocks
352
+ json_text = @browser.send(:extract_string_value, raw)
353
+ JSON.parse(json_text)
354
+ rescue JSON::ParserError => e
355
+ raise "JSON parse error from #{url}: #{e.message} — raw: #{raw.to_s[0..300]}"
356
+ end
357
+ end
358
+
359
+ # ---------------------------------------------------------------------------
360
+ # API helpers — check code=0, return data or nil
361
+ # ---------------------------------------------------------------------------
362
+
363
+ def api_ok!(body, step_name)
364
+ code = body["code"]
365
+ return body["data"] if code == 0
366
+
367
+ fail! "#{step_name} failed: code=#{code}, msg=#{body["msg"]}"
368
+ end
369
+
370
+ def api_ok?(body)
371
+ body.is_a?(Hash) && body["code"] == 0
372
+ end
373
+
374
+ # ---------------------------------------------------------------------------
375
+ # Websocket-mode polling helper (code=10068 means WS not ready yet)
376
+ # ---------------------------------------------------------------------------
377
+
378
+ def poll_with_ws_wait(step_name, timeout: WEBSOCKET_POLL_TIMEOUT, interval: WEBSOCKET_POLL_INTERVAL)
379
+ deadline = Time.now + timeout
380
+ attempt = 0
381
+ last_body = nil
382
+ loop do
383
+ attempt += 1
384
+ body = yield
385
+ last_body = body
386
+ return body if body["code"] == 0
387
+ if body["code"] == 10068
388
+ step " #{step_name}: waiting for WebSocket connection... (#{attempt})"
389
+ if Time.now > deadline
390
+ warn "#{step_name}: WebSocket not ready after #{timeout}s — continuing anyway (will retry on reconnect)"
391
+ return body
392
+ end
393
+ sleep interval
394
+ else
395
+ fail! "#{step_name} failed: code=#{body["code"]}, msg=#{body["msg"]}"
396
+ end
397
+ end
398
+ end
399
+
400
+ # ---------------------------------------------------------------------------
401
+ # Main setup logic
402
+ # ---------------------------------------------------------------------------
403
+
404
+ def run_setup(browser, api)
405
+ app_id = nil
406
+ app_secret = nil
407
+ version_id = nil
408
+
409
+ # ── Phase 1: Verify login ────────────────────────────────────────────────
410
+ step "Phase 1 — Verifying Feishu login..."
411
+ snap = browser.navigate("https://open.feishu.cn/app")
412
+ unless snap.include?("创建企业自建") || snap.include?("Create Custom App") || snap.include?("Create Enterprise")
413
+ fail! "Not logged in to Feishu Open Platform. Please log in to open.feishu.cn in Chrome first, then re-run."
414
+ end
415
+ ok "Logged in, app console visible."
416
+
417
+ # ── Phase 2: Create app via API ──────────────────────────────────────────
418
+ step "Phase 2 — Creating app '#{APP_NAME}' via API..."
419
+ body = api.create_app(APP_NAME, APP_DESC)
420
+ data = api_ok!(body, "create_app")
421
+ app_id = data["ClientID"] || data["client_id"] || data["appId"] || data["app_id"]
422
+ fail! "create_app succeeded (code=0) but no ClientID in response: #{data.inspect}" unless app_id
423
+ ok "App created: #{APP_NAME} (#{app_id})"
424
+
425
+ # ── Phase 3: Get credentials ─────────────────────────────────────────────
426
+ step "Phase 3 — Reading App Secret..."
427
+ body = api.get_secret(app_id)
428
+ data = api_ok!(body, "get_secret")
429
+ app_secret = data["appSecret"] || data["app_secret"] || data["secret"] || data["AppSecret"]
430
+ fail! "No App Secret in response: #{data.inspect}" unless app_secret
431
+ ok "Credentials: App ID=#{app_id}, App Secret=****#{app_secret[-4..]}"
432
+
433
+ # ── Phase 4: Write credentials to clacky server and wait for WS ─────────
434
+ step "Phase 4 — Writing credentials to clacky server..."
435
+
436
+ # Helper: one-shot HTTP request to clacky server (new connection each time, no keep-alive issues)
437
+ server_request = lambda do |method, path, body_hash = nil|
438
+ uri = URI(CLACKY_SERVER_URL)
439
+ Net::HTTP.start(uri.host, uri.port, open_timeout: 3, read_timeout: 10) do |h|
440
+ req = method == :post \
441
+ ? Net::HTTP::Post.new(path, "Content-Type" => "application/json") \
442
+ : Net::HTTP::Get.new(path)
443
+ req.body = JSON.generate(body_hash) if body_hash
444
+ h.request(req)
445
+ end
446
+ end
447
+
448
+ begin
449
+ res = server_request.call(:post, "/api/channels/feishu",
450
+ { app_id: app_id, app_secret: app_secret, enabled: true })
451
+ step " Server response: #{res.code}"
452
+ rescue StandardError => e
453
+ warn "Could not reach clacky server (#{e.message}) — continuing..."
454
+ end
455
+ ok "Credentials submitted, waiting for WebSocket connection..."
456
+
457
+ # Poll GET /api/channels until feishu shows running: true (max 90s)
458
+ ws_ready = false
459
+ ws_deadline = Time.now + 90
460
+ loop do
461
+ begin
462
+ res = server_request.call(:get, "/api/channels")
463
+ channels = JSON.parse(res.body)["channels"] || []
464
+ feishu = channels.find { |c| c["platform"] == "feishu" }
465
+ if feishu && feishu["running"]
466
+ ws_ready = true
467
+ break
468
+ end
469
+ rescue StandardError => e
470
+ warn "Channel status check failed: #{e.message}"
471
+ end
472
+ break if Time.now > ws_deadline
473
+ step " Waiting for Feishu WebSocket connection..."
474
+ sleep 3
475
+ end
476
+
477
+ if ws_ready
478
+ ok "Feishu WebSocket connected."
479
+ else
480
+ warn "WebSocket not confirmed within 90s — continuing anyway."
481
+ end
482
+
483
+ # ── Phase 5: Enable Bot capability ──────────────────────────────────────
484
+ step "Phase 5 — Enabling Bot capability..."
485
+ body = api.enable_bot(app_id)
486
+ api_ok!(body, "enable_bot")
487
+ ok "Bot capability enabled."
488
+
489
+ # ── Phase 6: Switch event mode to Long Connection (WebSocket) ───────────
490
+ step "Phase 6 — Switching event mode to Long Connection (WebSocket)..."
491
+ poll_with_ws_wait("switch_event_mode") { api.switch_event_mode(app_id) }
492
+ ok "Event mode: done (WebSocket)."
493
+
494
+ # ── Phase 7: Add im.message.receive_v1 event ────────────────────────────
495
+ step "Phase 7 — Adding im.message.receive_v1 event..."
496
+ ev_body = api.get_event(app_id)
497
+ event_mode = api_ok?(ev_body) ? (ev_body.dig("data", "eventMode") || 4) : 4
498
+ body = api.update_event(app_id, event_mode: event_mode)
499
+ api_ok!(body, "update_event")
500
+ ok "Event im.message.receive_v1 added."
501
+
502
+ # ── Phase 8: Switch callback mode to Long Connection ────────────────────
503
+ step "Phase 8 — Switching callback mode to Long Connection..."
504
+ poll_with_ws_wait("switch_callback_mode") { api.switch_callback_mode(app_id) }
505
+ ok "Callback mode: done (Long Connection)."
506
+
507
+ # ── Phase 9: Add permissions ─────────────────────────────────────────────
508
+ step "Phase 9 — Adding Bot permissions..."
509
+ scope_body = api.get_all_scopes(app_id)
510
+ scope_data = api_ok!(scope_body, "get_all_scopes")
511
+ scopes = scope_data["scopes"] || []
512
+ name_to_id = {}
513
+ scopes.each do |s|
514
+ name = s["name"] || s["scopeName"] || ""
515
+ id = s["id"].to_s
516
+ name_to_id[name] = id if name && !id.empty?
517
+ end
518
+ ids = BOT_PERMISSIONS.filter_map { |n| name_to_id[n] }
519
+ missing = BOT_PERMISSIONS.reject { |n| name_to_id.key?(n) }
520
+ warn "#{missing.size} permissions not matched: #{missing.join(", ")}" unless missing.empty?
521
+ fail! "No permission IDs matched. API response keys: #{name_to_id.keys.first(5).inspect}" if ids.empty?
522
+ body = api.update_scopes(app_id, ids)
523
+ api_ok!(body, "update_scopes")
524
+ ok "#{ids.size} permissions added."
525
+
526
+ # ── Phase 10: Publish app ────────────────────────────────────────────────
527
+ step "Phase 10 — Creating version and publishing..."
528
+ body = api.create_version(app_id)
529
+ data = api_ok!(body, "create_version")
530
+ version_id = data["versionId"] || data["version_id"]
531
+ fail! "No version_id in create_version response: #{data.inspect}" unless version_id
532
+
533
+ sleep 1
534
+ body = api.commit_version(app_id, version_id)
535
+ api_ok!(body, "commit_version")
536
+
537
+ sleep 1
538
+ body = api.release_version(app_id, version_id)
539
+ release_code = body["code"]
540
+
541
+ if release_code == 0
542
+ ok "App published successfully."
543
+ elsif release_code == 10002
544
+ # Already approved or auto-published — verify actual status
545
+ sleep 1
546
+ info = api.get_app_info(app_id)
547
+ if api_ok?(info) && info.dig("data", "appStatus") == 1
548
+ ok "App published (auto-approved)."
549
+ else
550
+ warn "App submitted for review (admin approval required). App ID: #{app_id}"
551
+ end
552
+ else
553
+ warn "Publish returned code=#{release_code} (#{body["msg"]}) — app may need admin approval."
554
+ warn "You can publish manually at: #{FEISHU_BASE_URL}/app/#{app_id}"
555
+ end
556
+
557
+ # Config was already saved by the server in Phase 4 via POST /api/channels/feishu
558
+ ok "🎉 Feishu channel setup complete! App: #{APP_NAME} (#{app_id})"
559
+ ok " Manage at: #{FEISHU_BASE_URL}/app/#{app_id}"
560
+ end
561
+
562
+ # ---------------------------------------------------------------------------
563
+ # Entrypoint
564
+ # ---------------------------------------------------------------------------
565
+
566
+ tool_client = ToolClient.new(CLACKY_SERVER_URL)
567
+ browser = BrowserSession.new(tool_client)
568
+
569
+ # Navigate to Feishu to establish page context
570
+ step "Initializing browser session..."
571
+ browser.navigate("https://open.feishu.cn/app")
572
+ sleep 1
573
+
574
+ # Quick sanity check — verify we have cookies
575
+ cookie_str = browser.cookies
576
+ fail! "No cookies found. Please log in to open.feishu.cn in Chrome first." if cookie_str.strip.empty?
577
+ step "Browser session ready (cookie length: #{cookie_str.length})"
578
+
579
+ # API client uses in-browser fetch — cookies & CSRF handled by browser automatically
580
+ api = FeishuApiClient.new(browser)
581
+
582
+ run_setup(browser, api)