openclacky 1.2.8 → 1.2.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.
@@ -1,574 +0,0 @@
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}"); end
75
- def ok(msg); puts("[feishu-setup] ✅ #{msg}"); end
76
- def warn(msg); puts("[feishu-setup] ⚠️ #{msg}"); end
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
-
108
- def http
109
- return @http if @http
110
- uri = URI("#{@server_url}/api/tool/browser")
111
- @http = Net::HTTP.new(uri.host, uri.port)
112
- @http.open_timeout = 5
113
- @http.read_timeout = 60
114
- @http
115
- end
116
- end
117
-
118
- # ---------------------------------------------------------------------------
119
- # BrowserSession — minimal browser wrapper, only used for login check + cookies
120
- # ---------------------------------------------------------------------------
121
-
122
- class BrowserSession
123
- def initialize(client)
124
- @client = client
125
- end
126
-
127
- def navigate(url)
128
- @client.call("navigate", url: url)
129
- sleep 2
130
- snapshot
131
- end
132
-
133
- def open(url)
134
- @client.call("open", url: url)
135
- sleep 2
136
- snapshot
137
- end
138
-
139
- def snapshot(interactive: true, compact: true)
140
- result = @client.call("snapshot", interactive: interactive, compact: compact)
141
- result["output"].to_s
142
- end
143
-
144
- # Run JavaScript in the page context and return the raw output string.
145
- def evaluate(js)
146
- result = @client.call("act", kind: "evaluate", js: js)
147
- result["output"].to_s
148
- end
149
-
150
- # Extract all cookies for open.feishu.cn from the browser.
151
- # The browser evaluate output may be wrapped in MCP markdown — parse it out.
152
- def cookies
153
- result = @client.call("act",
154
- kind: "evaluate",
155
- js: "document.cookie")
156
- raw = result["output"].to_s
157
- extract_string_value(raw)
158
- end
159
-
160
- # Get CSRF token — try multiple sources
161
- def csrf_token
162
- # Try x-csrf-token from cookie
163
- all_cookies = cookies
164
- all_cookies.split(";").each do |pair|
165
- k, v = pair.strip.split("=", 2)
166
- return v.strip if k.strip =~ /csrf.token/i && v
167
- end
168
-
169
- # Try lark_oapi_csrf_token specifically via JS
170
- result = @client.call("act",
171
- kind: "evaluate",
172
- 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] || ''")
173
- token = extract_string_value(result["output"].to_s)
174
- return token unless token.empty?
175
-
176
- # Try from window object
177
- result = @client.call("act",
178
- kind: "evaluate",
179
- js: "window.csrfToken || ''")
180
- extract_string_value(result["output"].to_s)
181
- end
182
-
183
- # The MCP tool wraps evaluate results in markdown code blocks:
184
- # "Script ran on page and returned:\n```json\n\"value\"\n```\n"
185
- # This extracts the actual string value.
186
- def extract_string_value(raw)
187
- # Try to find a JSON string value inside ```json ... ``` block
188
- if raw =~ /```json\s*(.*?)\s*```/m
189
- inner = $1.strip
190
- begin
191
- parsed = JSON.parse(inner)
192
- return parsed.to_s if parsed.is_a?(String)
193
- # If it's not a string (e.g. already the cookie text), return as-is
194
- return inner
195
- rescue JSON::ParserError
196
- return inner
197
- end
198
- end
199
- # No markdown wrapper — return raw stripped
200
- raw.strip
201
- end
202
- end
203
-
204
- # ---------------------------------------------------------------------------
205
- # FeishuApiClient — calls Feishu internal API via the browser (fetch in page context).
206
- # This way the browser automatically includes all cookies, CSRF tokens, and
207
- # session headers — we never need to extract them manually.
208
- # ---------------------------------------------------------------------------
209
-
210
- class FeishuApiClient
211
- def initialize(browser_session)
212
- @browser = browser_session
213
- end
214
-
215
- def create_app(name, desc)
216
- post_json("#{FEISHU_API_BASE}/app/create", {
217
- appSceneType: 0,
218
- name: name,
219
- desc: desc,
220
- avatar: "",
221
- i18n: { zh_cn: { name: name, description: desc } },
222
- primaryLang: "zh_cn"
223
- })
224
- end
225
-
226
- def get_secret(app_id)
227
- get_json("#{FEISHU_API_BASE}/secret/#{app_id}")
228
- end
229
-
230
- def enable_bot(app_id)
231
- post_json("#{FEISHU_API_BASE}/robot/switch/#{app_id}", { enable: true })
232
- end
233
-
234
- def switch_event_mode(app_id, mode: 4)
235
- post_json("#{FEISHU_API_BASE}/event/switch/#{app_id}", { eventMode: mode })
236
- end
237
-
238
- def get_event(app_id)
239
- get_json("#{FEISHU_API_BASE}/event/#{app_id}")
240
- end
241
-
242
- def update_event(app_id, event_mode:)
243
- post_json("#{FEISHU_API_BASE}/event/update/#{app_id}", {
244
- operation: "add",
245
- events: ["im.message.receive_v1"],
246
- eventMode: event_mode
247
- })
248
- end
249
-
250
- def switch_callback_mode(app_id, mode: 4)
251
- post_json("#{FEISHU_API_BASE}/callback/switch/#{app_id}", { callbackMode: mode })
252
- end
253
-
254
- def get_all_scopes(app_id)
255
- get_json("#{FEISHU_API_BASE}/scope/all/#{app_id}")
256
- end
257
-
258
- def update_scopes(app_id, scope_ids)
259
- post_json("#{FEISHU_API_BASE}/scope/update/#{app_id}", {
260
- clientId: app_id,
261
- appScopeIDs: scope_ids,
262
- userScopeIDs: [],
263
- scopeIds: [],
264
- operation: "add"
265
- })
266
- end
267
-
268
- def create_version(app_id, version: "1.0.0")
269
- post_json("#{FEISHU_API_BASE}/app_version/create/#{app_id}", {
270
- clientId: app_id,
271
- appVersion: version,
272
- changeLog: "Initial release",
273
- autoPublish: false,
274
- pcDefaultAbility: "bot",
275
- mobileDefaultAbility: "bot"
276
- })
277
- end
278
-
279
- def commit_version(app_id, version_id)
280
- post_json("#{FEISHU_API_BASE}/publish/commit/#{app_id}/#{version_id}", {})
281
- end
282
-
283
- def release_version(app_id, version_id)
284
- post_json("#{FEISHU_API_BASE}/publish/release/#{app_id}/#{version_id}", {
285
- clientId: app_id,
286
- versionId: version_id
287
- })
288
- end
289
-
290
- def get_app_info(app_id)
291
- get_json("#{FEISHU_API_BASE}/app/#{app_id}")
292
- end
293
-
294
-
295
- # Execute a GET fetch in the browser page context.
296
- # Uses window.csrfToken — required by all /developers/v1/ endpoints.
297
- def get_json(url)
298
- js = <<~JS
299
- (async () => {
300
- const csrfToken = window.csrfToken || '';
301
- const resp = await fetch(#{url.to_json}, {
302
- method: 'GET',
303
- credentials: 'include',
304
- headers: {
305
- 'accept': '*/*',
306
- 'x-timezone-offset': '-480',
307
- 'x-csrf-token': csrfToken
308
- }
309
- });
310
- return await resp.text();
311
- })()
312
- JS
313
- run_fetch(js, url)
314
- end
315
-
316
- # Execute a POST fetch in the browser page context.
317
- # Uses window.csrfToken (set by Feishu's own JS) — the correct token for /developers/v1/ APIs.
318
- def post_json(url, payload)
319
- js = <<~JS
320
- (async () => {
321
- const csrfToken = window.csrfToken || '';
322
- const resp = await fetch(#{url.to_json}, {
323
- method: 'POST',
324
- credentials: 'include',
325
- headers: {
326
- 'accept': '*/*',
327
- 'content-type': 'application/json',
328
- 'origin': 'https://open.feishu.cn',
329
- 'referer': 'https://open.feishu.cn/app',
330
- 'x-timezone-offset': '-480',
331
- 'x-csrf-token': csrfToken
332
- },
333
- body: #{JSON.generate(payload).to_json}
334
- });
335
- return await resp.text();
336
- })()
337
- JS
338
- run_fetch(js, url)
339
- end
340
-
341
- def run_fetch(js, url)
342
- raw = @browser.evaluate(js)
343
- # The MCP tool may wrap result in markdown code blocks
344
- json_text = @browser.send(:extract_string_value, raw)
345
- JSON.parse(json_text)
346
- rescue JSON::ParserError => e
347
- raise "JSON parse error from #{url}: #{e.message} — raw: #{raw.to_s[0..300]}"
348
- end
349
- end
350
-
351
- # ---------------------------------------------------------------------------
352
- # API helpers — check code=0, return data or nil
353
- # ---------------------------------------------------------------------------
354
-
355
- def api_ok!(body, step_name)
356
- code = body["code"]
357
- return body["data"] if code == 0
358
-
359
- fail! "#{step_name} failed: code=#{code}, msg=#{body["msg"]}"
360
- end
361
-
362
- def api_ok?(body)
363
- body.is_a?(Hash) && body["code"] == 0
364
- end
365
-
366
- # ---------------------------------------------------------------------------
367
- # Websocket-mode polling helper (code=10068 means WS not ready yet)
368
- # ---------------------------------------------------------------------------
369
-
370
- def poll_with_ws_wait(step_name, timeout: WEBSOCKET_POLL_TIMEOUT, interval: WEBSOCKET_POLL_INTERVAL)
371
- deadline = Time.now + timeout
372
- attempt = 0
373
- last_body = nil
374
- loop do
375
- attempt += 1
376
- body = yield
377
- last_body = body
378
- return body if body["code"] == 0
379
- if body["code"] == 10068
380
- step " #{step_name}: waiting for WebSocket connection... (#{attempt})"
381
- if Time.now > deadline
382
- warn "#{step_name}: WebSocket not ready after #{timeout}s — continuing anyway (will retry on reconnect)"
383
- return body
384
- end
385
- sleep interval
386
- else
387
- fail! "#{step_name} failed: code=#{body["code"]}, msg=#{body["msg"]}"
388
- end
389
- end
390
- end
391
-
392
- # ---------------------------------------------------------------------------
393
- # Main setup logic
394
- # ---------------------------------------------------------------------------
395
-
396
- def run_setup(browser, api)
397
- app_id = nil
398
- app_secret = nil
399
- version_id = nil
400
-
401
- # ── Phase 1: Verify login ────────────────────────────────────────────────
402
- step "Phase 1 — Verifying Feishu login..."
403
- snap = browser.open("https://open.feishu.cn/app")
404
- unless snap.include?("创建企业自建") || snap.include?("Create Custom App") || snap.include?("Create Enterprise")
405
- fail! "Not logged in to Feishu Open Platform. Please log in to open.feishu.cn in Chrome first, then re-run."
406
- end
407
- ok "Logged in, app console visible."
408
-
409
- # ── Phase 2: Create app via API ──────────────────────────────────────────
410
- step "Phase 2 — Creating app '#{APP_NAME}' via API..."
411
- body = api.create_app(APP_NAME, APP_DESC)
412
- data = api_ok!(body, "create_app")
413
- app_id = data["ClientID"] || data["client_id"] || data["appId"] || data["app_id"]
414
- fail! "create_app succeeded (code=0) but no ClientID in response: #{data.inspect}" unless app_id
415
- ok "App created: #{APP_NAME} (#{app_id})"
416
-
417
- # ── Phase 3: Get credentials ─────────────────────────────────────────────
418
- step "Phase 3 — Reading App Secret..."
419
- body = api.get_secret(app_id)
420
- data = api_ok!(body, "get_secret")
421
- app_secret = data["appSecret"] || data["app_secret"] || data["secret"] || data["AppSecret"]
422
- fail! "No App Secret in response: #{data.inspect}" unless app_secret
423
- ok "Credentials: App ID=#{app_id}, App Secret=****#{app_secret[-4..]}"
424
-
425
- # ── Phase 4: Write credentials to clacky server and wait for WS ─────────
426
- step "Phase 4 — Writing credentials to clacky server..."
427
-
428
- # Helper: one-shot HTTP request to clacky server (new connection each time, no keep-alive issues)
429
- server_request = lambda do |method, path, body_hash = nil|
430
- uri = URI(CLACKY_SERVER_URL)
431
- Net::HTTP.start(uri.host, uri.port, open_timeout: 3, read_timeout: 10) do |h|
432
- req = method == :post \
433
- ? Net::HTTP::Post.new(path, "Content-Type" => "application/json") \
434
- : Net::HTTP::Get.new(path)
435
- req.body = JSON.generate(body_hash) if body_hash
436
- h.request(req)
437
- end
438
- end
439
-
440
- begin
441
- res = server_request.call(:post, "/api/channels/feishu",
442
- { app_id: app_id, app_secret: app_secret, enabled: true })
443
- step " Server response: #{res.code}"
444
- rescue StandardError => e
445
- warn "Could not reach clacky server (#{e.message}) — continuing..."
446
- end
447
- ok "Credentials submitted, waiting for WebSocket connection..."
448
-
449
- # Poll GET /api/channels until feishu shows running: true (max 90s)
450
- ws_ready = false
451
- ws_deadline = Time.now + 90
452
- loop do
453
- begin
454
- res = server_request.call(:get, "/api/channels")
455
- channels = JSON.parse(res.body)["channels"] || []
456
- feishu = channels.find { |c| c["platform"] == "feishu" }
457
- if feishu && feishu["running"]
458
- ws_ready = true
459
- break
460
- end
461
- rescue StandardError => e
462
- warn "Channel status check failed: #{e.message}"
463
- end
464
- break if Time.now > ws_deadline
465
- step " Waiting for Feishu WebSocket connection..."
466
- sleep 3
467
- end
468
-
469
- if ws_ready
470
- ok "Feishu WebSocket connected."
471
- else
472
- warn "WebSocket not confirmed within 90s — continuing anyway."
473
- end
474
-
475
- # ── Phase 5: Enable Bot capability ──────────────────────────────────────
476
- step "Phase 5 — Enabling Bot capability..."
477
- body = api.enable_bot(app_id)
478
- api_ok!(body, "enable_bot")
479
- ok "Bot capability enabled."
480
-
481
- # ── Phase 6: Switch event mode to Long Connection (WebSocket) ───────────
482
- step "Phase 6 — Switching event mode to Long Connection (WebSocket)..."
483
- poll_with_ws_wait("switch_event_mode") { api.switch_event_mode(app_id) }
484
- ok "Event mode: done (WebSocket)."
485
-
486
- # ── Phase 7: Add im.message.receive_v1 event ────────────────────────────
487
- step "Phase 7 — Adding im.message.receive_v1 event..."
488
- ev_body = api.get_event(app_id)
489
- event_mode = api_ok?(ev_body) ? (ev_body.dig("data", "eventMode") || 4) : 4
490
- body = api.update_event(app_id, event_mode: event_mode)
491
- api_ok!(body, "update_event")
492
- ok "Event im.message.receive_v1 added."
493
-
494
- # ── Phase 8: Switch callback mode to Long Connection ────────────────────
495
- step "Phase 8 — Switching callback mode to Long Connection..."
496
- poll_with_ws_wait("switch_callback_mode") { api.switch_callback_mode(app_id) }
497
- ok "Callback mode: done (Long Connection)."
498
-
499
- # ── Phase 9: Add permissions ─────────────────────────────────────────────
500
- step "Phase 9 — Adding Bot permissions..."
501
- scope_body = api.get_all_scopes(app_id)
502
- scope_data = api_ok!(scope_body, "get_all_scopes")
503
- scopes = scope_data["scopes"] || []
504
- name_to_id = {}
505
- scopes.each do |s|
506
- name = s["name"] || s["scopeName"] || ""
507
- id = s["id"].to_s
508
- name_to_id[name] = id if name && !id.empty?
509
- end
510
- ids = BOT_PERMISSIONS.map { |n| name_to_id[n] }.compact
511
- missing = BOT_PERMISSIONS.reject { |n| name_to_id.key?(n) }
512
- warn "#{missing.size} permissions not matched: #{missing.join(", ")}" unless missing.empty?
513
- fail! "No permission IDs matched. API response keys: #{name_to_id.keys.first(5).inspect}" if ids.empty?
514
- body = api.update_scopes(app_id, ids)
515
- api_ok!(body, "update_scopes")
516
- ok "#{ids.size} permissions added."
517
-
518
- # ── Phase 10: Publish app ────────────────────────────────────────────────
519
- step "Phase 10 — Creating version and publishing..."
520
- body = api.create_version(app_id)
521
- data = api_ok!(body, "create_version")
522
- version_id = data["versionId"] || data["version_id"]
523
- fail! "No version_id in create_version response: #{data.inspect}" unless version_id
524
-
525
- sleep 1
526
- body = api.commit_version(app_id, version_id)
527
- api_ok!(body, "commit_version")
528
-
529
- sleep 1
530
- body = api.release_version(app_id, version_id)
531
- release_code = body["code"]
532
-
533
- if release_code == 0
534
- ok "App published successfully."
535
- elsif release_code == 10002
536
- # Already approved or auto-published — verify actual status
537
- sleep 1
538
- info = api.get_app_info(app_id)
539
- if api_ok?(info) && info.dig("data", "appStatus") == 1
540
- ok "App published (auto-approved)."
541
- else
542
- warn "App submitted for review (admin approval required). App ID: #{app_id}"
543
- end
544
- else
545
- warn "Publish returned code=#{release_code} (#{body["msg"]}) — app may need admin approval."
546
- warn "You can publish manually at: #{FEISHU_BASE_URL}/app/#{app_id}"
547
- end
548
-
549
- # Config was already saved by the server in Phase 4 via POST /api/channels/feishu
550
- ok "🎉 Feishu channel setup complete! App: #{APP_NAME} (#{app_id})"
551
- ok " Manage at: #{FEISHU_BASE_URL}/app/#{app_id}"
552
- end
553
-
554
- # ---------------------------------------------------------------------------
555
- # Entrypoint
556
- # ---------------------------------------------------------------------------
557
-
558
- tool_client = ToolClient.new(CLACKY_SERVER_URL)
559
- browser = BrowserSession.new(tool_client)
560
-
561
- # Navigate to Feishu to establish page context
562
- step "Initializing browser session..."
563
- browser.open("https://open.feishu.cn/app")
564
- sleep 1
565
-
566
- # Quick sanity check — verify we have cookies
567
- cookie_str = browser.cookies
568
- fail! "No cookies found. Please log in to open.feishu.cn in Chrome first." if cookie_str.strip.empty?
569
- step "Browser session ready (cookie length: #{cookie_str.length})"
570
-
571
- # API client uses in-browser fetch — cookies & CSRF handled by browser automatically
572
- api = FeishuApiClient.new(browser)
573
-
574
- run_setup(browser, api)