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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +12 -0
- data/lib/clacky/agent_config.rb +91 -7
- data/lib/clacky/client.rb +6 -2
- data/lib/clacky/default_skills/channel-manager/SKILL.md +33 -110
- data/lib/clacky/default_skills/media-gen/SKILL.md +128 -0
- data/lib/clacky/media/base.rb +68 -0
- data/lib/clacky/media/gemini.rb +36 -0
- data/lib/clacky/media/generator.rb +78 -0
- data/lib/clacky/media/openai_compat.rb +168 -0
- data/lib/clacky/providers.rb +82 -0
- data/lib/clacky/server/http_server.rb +210 -20
- data/lib/clacky/telemetry.rb +11 -5
- data/lib/clacky/version.rb +1 -1
- data/lib/clacky/web/app.css +172 -12
- data/lib/clacky/web/i18n.js +58 -0
- data/lib/clacky/web/index.html +14 -2
- data/lib/clacky/web/model-tester.js +58 -0
- data/lib/clacky/web/onboard.js +17 -30
- data/lib/clacky/web/settings.js +322 -97
- data/lib/clacky.rb +3 -0
- data/scripts/build/lib/network.sh +61 -30
- data/scripts/install.sh +61 -30
- data/scripts/install_browser.sh +61 -30
- data/scripts/install_full.sh +61 -30
- data/scripts/install_rails_deps.sh +61 -30
- data/scripts/install_system_deps.sh +61 -30
- metadata +7 -2
- data/lib/clacky/default_skills/channel-manager/feishu_setup.rb +0 -574
|
@@ -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)
|