@dhfpub/clawpool 0.1.3 → 0.2.0

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.
@@ -0,0 +1,1538 @@
1
+ #!/usr/bin/env python3
2
+ import argparse
3
+ import base64
4
+ import json
5
+ import os
6
+ import shlex
7
+ import subprocess
8
+ import sys
9
+ import tempfile
10
+ import urllib.error
11
+ import urllib.parse
12
+ import urllib.request
13
+ import uuid
14
+
15
+
16
+ DEFAULT_BASE_URL = "https://clawpool.dhf.pub"
17
+ DEFAULT_TIMEOUT_SECONDS = 15
18
+ DEFAULT_OPENCLAW_CONFIG_PATH = "~/.openclaw/openclaw.json"
19
+ DEFAULT_PORTAL_URL = "https://clawpool.dhf.pub/"
20
+ DEFAULT_OPENCLAW_TOOLS_PROFILE = "coding"
21
+ DEFAULT_OPENCLAW_TOOLS_VISIBILITY = "agent"
22
+ REQUIRED_OPENCLAW_TOOLS = [
23
+ "message",
24
+ "clawpool_group",
25
+ "clawpool_agent_admin",
26
+ ]
27
+ REQUIRED_ADMIN_PLUGIN_TOOLS = [
28
+ "clawpool_group",
29
+ "clawpool_agent_admin",
30
+ ]
31
+
32
+
33
+ class ClawpoolAuthError(RuntimeError):
34
+ def __init__(self, message, status=0, code=-1, payload=None):
35
+ super().__init__(message)
36
+ self.status = status
37
+ self.code = code
38
+ self.payload = payload
39
+
40
+
41
+ def normalize_base_url(raw_base_url: str) -> str:
42
+ base = (raw_base_url or "").strip() or DEFAULT_BASE_URL
43
+ parsed = urllib.parse.urlparse(base)
44
+ if not parsed.scheme or not parsed.netloc:
45
+ raise ValueError(f"Invalid base URL: {base}")
46
+
47
+ path = parsed.path.rstrip("/")
48
+ if not path:
49
+ path = "/v1"
50
+ elif not path.endswith("/v1"):
51
+ path = f"{path}/v1"
52
+
53
+ normalized = parsed._replace(path=path, params="", query="", fragment="")
54
+ return urllib.parse.urlunparse(normalized).rstrip("/")
55
+
56
+
57
+ def request_json(method: str, path: str, base_url: str, body=None, headers=None):
58
+ api_base_url = normalize_base_url(base_url)
59
+ url = f"{api_base_url}{path if path.startswith('/') else '/' + path}"
60
+ data = None
61
+ final_headers = dict(headers or {})
62
+ if body is not None:
63
+ data = json.dumps(body).encode("utf-8")
64
+ final_headers["Content-Type"] = "application/json"
65
+
66
+ req = urllib.request.Request(url=url, data=data, headers=final_headers, method=method)
67
+ try:
68
+ with urllib.request.urlopen(req, timeout=DEFAULT_TIMEOUT_SECONDS) as resp:
69
+ status = getattr(resp, "status", 200)
70
+ raw = resp.read().decode("utf-8")
71
+ except urllib.error.HTTPError as exc:
72
+ status = exc.code
73
+ raw = exc.read().decode("utf-8", errors="replace")
74
+ except urllib.error.URLError as exc:
75
+ raise ClawpoolAuthError(f"network error: {exc.reason}") from exc
76
+
77
+ try:
78
+ payload = json.loads(raw)
79
+ except json.JSONDecodeError as exc:
80
+ raise ClawpoolAuthError(f"invalid json response: {raw[:256]}", status=status) from exc
81
+
82
+ code = int(payload.get("code", -1))
83
+ msg = str(payload.get("msg", "")).strip() or "unknown error"
84
+ if status >= 400 or code != 0:
85
+ raise ClawpoolAuthError(msg, status=status, code=code, payload=payload)
86
+
87
+ return {
88
+ "api_base_url": api_base_url,
89
+ "status": status,
90
+ "data": payload.get("data"),
91
+ "payload": payload,
92
+ }
93
+
94
+
95
+ def maybe_write_captcha_image(b64s: str):
96
+ text = (b64s or "").strip()
97
+ if not text.startswith("data:image/"):
98
+ return ""
99
+ marker = ";base64,"
100
+ idx = text.find(marker)
101
+ if idx < 0:
102
+ return ""
103
+ encoded = text[idx + len(marker) :]
104
+ try:
105
+ content = base64.b64decode(encoded)
106
+ except Exception:
107
+ return ""
108
+
109
+ fd, path = tempfile.mkstemp(prefix="clawpool-captcha-", suffix=".png")
110
+ try:
111
+ with os.fdopen(fd, "wb") as handle:
112
+ handle.write(content)
113
+ except Exception:
114
+ try:
115
+ os.unlink(path)
116
+ except OSError:
117
+ pass
118
+ return ""
119
+ return path
120
+
121
+
122
+ def print_json(payload):
123
+ json.dump(payload, sys.stdout, ensure_ascii=False, indent=2)
124
+ sys.stdout.write("\n")
125
+
126
+
127
+ def build_auth_result(action: str, result: dict):
128
+ data = result.get("data") or {}
129
+ user = data.get("user") or {}
130
+ user_id = user.get("id", "")
131
+ payload = {
132
+ "ok": True,
133
+ "action": action,
134
+ "api_base_url": result["api_base_url"],
135
+ "access_token": data.get("access_token", ""),
136
+ "refresh_token": data.get("refresh_token", ""),
137
+ "expires_in": data.get("expires_in", 0),
138
+ "user_id": user_id,
139
+ "data": data,
140
+ }
141
+ payload.update(
142
+ build_portal_guidance(
143
+ True,
144
+ f"账号已可用,可直接登录 {DEFAULT_PORTAL_URL} 体验。",
145
+ )
146
+ )
147
+ payload.update(build_user_reply_templates("login_ready"))
148
+ return payload
149
+
150
+
151
+ def build_agent_result(result: dict):
152
+ data = result.get("data") or {}
153
+ return {
154
+ "ok": True,
155
+ "action": "create-api-agent",
156
+ "api_base_url": result["api_base_url"],
157
+ "agent_id": data.get("id", ""),
158
+ "agent_name": data.get("agent_name", ""),
159
+ "provider_type": data.get("provider_type", 0),
160
+ "api_endpoint": data.get("api_endpoint", ""),
161
+ "api_key": data.get("api_key", ""),
162
+ "api_key_hint": data.get("api_key_hint", ""),
163
+ "session_id": data.get("session_id", ""),
164
+ "data": data,
165
+ }
166
+
167
+
168
+ def login_with_credentials(base_url: str, account: str, password: str, device_id: str, platform: str):
169
+ result = request_json(
170
+ "POST",
171
+ "/auth/login",
172
+ base_url,
173
+ body={
174
+ "account": account,
175
+ "password": password,
176
+ "device_id": device_id,
177
+ "platform": platform,
178
+ },
179
+ )
180
+ return build_auth_result("login", result)
181
+
182
+
183
+ def create_api_agent(base_url: str, access_token: str, agent_name: str, avatar_url: str):
184
+ request_body = {
185
+ "agent_name": agent_name.strip(),
186
+ "provider_type": 3,
187
+ }
188
+ normalized_avatar_url = (avatar_url or "").strip()
189
+ if normalized_avatar_url:
190
+ request_body["avatar_url"] = normalized_avatar_url
191
+
192
+ result = request_json(
193
+ "POST",
194
+ "/agents/create",
195
+ base_url,
196
+ body=request_body,
197
+ headers={
198
+ "Authorization": f"Bearer {access_token.strip()}",
199
+ },
200
+ )
201
+ return build_agent_result(result)
202
+
203
+
204
+ def list_agents(base_url: str, access_token: str):
205
+ result = request_json(
206
+ "GET",
207
+ "/agents/list",
208
+ base_url,
209
+ headers={
210
+ "Authorization": f"Bearer {access_token.strip()}",
211
+ },
212
+ )
213
+ data = result.get("data") or {}
214
+ items = data.get("list") or []
215
+ if not isinstance(items, list):
216
+ items = []
217
+ return items
218
+
219
+
220
+ def rotate_api_agent_key(base_url: str, access_token: str, agent_id: str):
221
+ result = request_json(
222
+ "POST",
223
+ f"/agents/{str(agent_id).strip()}/api/key/rotate",
224
+ base_url,
225
+ body={},
226
+ headers={
227
+ "Authorization": f"Bearer {access_token.strip()}",
228
+ },
229
+ )
230
+ payload = build_agent_result(result)
231
+ payload["action"] = "rotate-api-agent-key"
232
+ return payload
233
+
234
+
235
+ def find_existing_api_agent(agents, agent_name: str):
236
+ normalized_name = (agent_name or "").strip()
237
+ if not normalized_name:
238
+ return None
239
+ for item in agents:
240
+ if not isinstance(item, dict):
241
+ continue
242
+ if str(item.get("agent_name", "")).strip() != normalized_name:
243
+ continue
244
+ if int(item.get("provider_type", 0) or 0) != 3:
245
+ continue
246
+ if int(item.get("status", 0) or 0) == 3:
247
+ continue
248
+ return item
249
+ return None
250
+
251
+
252
+ def create_or_reuse_api_agent(
253
+ base_url: str,
254
+ access_token: str,
255
+ agent_name: str,
256
+ avatar_url: str,
257
+ prefer_existing: bool,
258
+ rotate_on_reuse: bool,
259
+ ):
260
+ if prefer_existing:
261
+ agents = list_agents(base_url, access_token)
262
+ existing = find_existing_api_agent(agents, agent_name)
263
+ if existing is not None:
264
+ if not rotate_on_reuse:
265
+ raise ClawpoolAuthError(
266
+ "existing provider_type=3 agent found but rotate-on-reuse is disabled; cannot obtain api_key safely",
267
+ payload={"existing_agent": existing},
268
+ )
269
+ rotated = rotate_api_agent_key(base_url, access_token, str(existing.get("id", "")).strip())
270
+ rotated["source"] = "reused_existing_agent_with_rotated_key"
271
+ rotated["existing_agent"] = existing
272
+ return rotated
273
+
274
+ created = create_api_agent(base_url, access_token, agent_name, avatar_url)
275
+ created["source"] = "created_new_agent"
276
+ return created
277
+
278
+
279
+ def shell_command(cmd):
280
+ return " ".join(shlex.quote(part) for part in cmd)
281
+
282
+
283
+ def run_command_capture(cmd):
284
+ proc = subprocess.run(cmd, capture_output=True, text=True)
285
+ return {
286
+ "command": shell_command(cmd),
287
+ "returncode": proc.returncode,
288
+ "stdout": proc.stdout.strip(),
289
+ "stderr": proc.stderr.strip(),
290
+ }
291
+
292
+
293
+ def parse_json_fragment(raw: str):
294
+ text = (raw or "").strip()
295
+ if not text:
296
+ return None
297
+ for idx, char in enumerate(text):
298
+ if char not in "[{":
299
+ continue
300
+ fragment = text[idx:]
301
+ try:
302
+ return json.loads(fragment)
303
+ except json.JSONDecodeError:
304
+ continue
305
+ return None
306
+
307
+
308
+ def build_openclaw_base_cmd(args):
309
+ base_cmd = [(args.openclaw_bin or "").strip() or "openclaw"]
310
+ profile = str(getattr(args, "openclaw_profile", "") or "").strip()
311
+ if profile:
312
+ base_cmd.extend(["--profile", profile])
313
+ return base_cmd
314
+
315
+
316
+ def build_gateway_restart_command(args):
317
+ return build_openclaw_base_cmd(args) + ["gateway", "restart"]
318
+
319
+
320
+ def normalize_string_list(values):
321
+ if not isinstance(values, list):
322
+ return []
323
+ normalized = []
324
+ seen = set()
325
+ for item in values:
326
+ text = str(item or "").strip()
327
+ if not text or text in seen:
328
+ continue
329
+ seen.add(text)
330
+ normalized.append(text)
331
+ return normalized
332
+
333
+
334
+ def build_reference_commands(args, agent_id: str, api_endpoint: str, api_key: str):
335
+ commands = []
336
+ openclaw_cmd = build_openclaw_base_cmd(args)
337
+ if not args.skip_plugin_install:
338
+ commands.append(openclaw_cmd + ["plugins", "install", "@dhfpub/clawpool"])
339
+ if not args.skip_plugin_enable:
340
+ commands.append(openclaw_cmd + ["plugins", "enable", "clawpool"])
341
+ if not bool(getattr(args, "skip_admin_plugin_install", False)):
342
+ commands.append(openclaw_cmd + ["plugins", "install", "@dhfpub/clawpool-admin"])
343
+ if not bool(getattr(args, "skip_admin_plugin_enable", False)):
344
+ commands.append(openclaw_cmd + ["plugins", "enable", "clawpool-admin"])
345
+ commands.append(
346
+ openclaw_cmd
347
+ + [
348
+ "channels",
349
+ "add",
350
+ "--channel",
351
+ "clawpool",
352
+ "--name",
353
+ args.channel_name.strip(),
354
+ "--http-url",
355
+ api_endpoint,
356
+ "--user-id",
357
+ agent_id,
358
+ "--token",
359
+ api_key,
360
+ ]
361
+ )
362
+ if not args.skip_gateway_restart:
363
+ commands.append(build_gateway_restart_command(args))
364
+ return commands
365
+
366
+
367
+ def inspect_plugin_state(args, plugin_id: str, required_channel_ids=None, required_tool_names=None, skip_install=False, skip_enable=False):
368
+ required_channels = list(required_channel_ids or [])
369
+ required_tools = list(required_tool_names or [])
370
+ entry = run_command_capture(build_openclaw_base_cmd(args) + ["plugins", "info", plugin_id, "--json"])
371
+ parsed = parse_json_fragment(entry["stdout"])
372
+ payload = {
373
+ "plugin_id": plugin_id,
374
+ "inspection_command": entry["command"],
375
+ "inspection_returncode": entry["returncode"],
376
+ "detected": False,
377
+ "enabled": False,
378
+ "status": "missing",
379
+ "source": "",
380
+ "origin": "",
381
+ "channel_ids": [],
382
+ "tool_names": [],
383
+ "needs_install": not skip_install,
384
+ "needs_enable": False,
385
+ "ready": False,
386
+ }
387
+ if entry["returncode"] != 0:
388
+ payload["inspection_error"] = entry["stderr"] or entry["stdout"] or "plugin inspection failed"
389
+ payload["inspection_stdout"] = entry["stdout"]
390
+ payload["inspection_stderr"] = entry["stderr"]
391
+ payload["needs_enable"] = not skip_enable
392
+ return payload
393
+ if not isinstance(parsed, dict):
394
+ payload["status"] = "unknown"
395
+ payload["needs_enable"] = not skip_enable
396
+ payload["inspection_error"] = "failed to parse openclaw plugin json output"
397
+ payload["inspection_stdout"] = entry["stdout"]
398
+ payload["inspection_stderr"] = entry["stderr"]
399
+ return payload
400
+
401
+ enabled = bool(parsed.get("enabled", False))
402
+ status = str(parsed.get("status", "")).strip() or "unknown"
403
+ channel_ids = parsed.get("channelIds")
404
+ tool_names = parsed.get("toolNames")
405
+ normalized_channel_ids = channel_ids if isinstance(channel_ids, list) else []
406
+ normalized_tool_names = tool_names if isinstance(tool_names, list) else []
407
+ ready = (
408
+ enabled
409
+ and status == "loaded"
410
+ and all(item in normalized_channel_ids for item in required_channels)
411
+ and all(item in normalized_tool_names for item in required_tools)
412
+ )
413
+ payload.update(
414
+ {
415
+ "detected": True,
416
+ "enabled": enabled,
417
+ "status": status,
418
+ "source": str(parsed.get("source", "")).strip(),
419
+ "origin": str(parsed.get("origin", "")).strip(),
420
+ "channel_ids": normalized_channel_ids,
421
+ "tool_names": normalized_tool_names,
422
+ "needs_install": False,
423
+ "needs_enable": (not skip_enable) and (not ready),
424
+ "ready": ready,
425
+ }
426
+ )
427
+ return payload
428
+
429
+
430
+ def inspect_openclaw_plugin(args):
431
+ return inspect_plugin_state(
432
+ args,
433
+ "clawpool",
434
+ required_channel_ids=["clawpool"],
435
+ skip_install=bool(getattr(args, "skip_plugin_install", False)),
436
+ skip_enable=bool(getattr(args, "skip_plugin_enable", False)),
437
+ )
438
+
439
+
440
+ def inspect_openclaw_admin_plugin(args):
441
+ return inspect_plugin_state(
442
+ args,
443
+ "clawpool-admin",
444
+ required_tool_names=REQUIRED_ADMIN_PLUGIN_TOOLS,
445
+ skip_install=bool(getattr(args, "skip_admin_plugin_install", False)),
446
+ skip_enable=bool(getattr(args, "skip_admin_plugin_enable", False)),
447
+ )
448
+
449
+
450
+ def build_plugin_commands(args, plugin_status=None):
451
+ commands = []
452
+ openclaw_cmd = build_openclaw_base_cmd(args)
453
+ if isinstance(plugin_status, dict):
454
+ if bool(plugin_status.get("needs_install", False)):
455
+ commands.append(openclaw_cmd + ["plugins", "install", "@dhfpub/clawpool"])
456
+ if bool(plugin_status.get("needs_enable", False)):
457
+ commands.append(openclaw_cmd + ["plugins", "enable", "clawpool"])
458
+ return commands
459
+
460
+ if not args.skip_plugin_install:
461
+ commands.append(openclaw_cmd + ["plugins", "install", "@dhfpub/clawpool"])
462
+ if not args.skip_plugin_enable:
463
+ commands.append(openclaw_cmd + ["plugins", "enable", "clawpool"])
464
+ return commands
465
+
466
+
467
+ def build_admin_plugin_commands(args, plugin_status=None):
468
+ commands = []
469
+ openclaw_cmd = build_openclaw_base_cmd(args)
470
+ if isinstance(plugin_status, dict):
471
+ if bool(plugin_status.get("needs_install", False)):
472
+ commands.append(openclaw_cmd + ["plugins", "install", "@dhfpub/clawpool-admin"])
473
+ if bool(plugin_status.get("needs_enable", False)):
474
+ commands.append(openclaw_cmd + ["plugins", "enable", "clawpool-admin"])
475
+ return commands
476
+
477
+ if not bool(getattr(args, "skip_admin_plugin_install", False)):
478
+ commands.append(openclaw_cmd + ["plugins", "install", "@dhfpub/clawpool-admin"])
479
+ if not bool(getattr(args, "skip_admin_plugin_enable", False)):
480
+ commands.append(openclaw_cmd + ["plugins", "enable", "clawpool-admin"])
481
+ return commands
482
+
483
+
484
+ def build_direct_config(agent_id: str, api_endpoint: str, api_key: str):
485
+ return {
486
+ "channels": {
487
+ "clawpool": {
488
+ "enabled": True,
489
+ "wsUrl": api_endpoint,
490
+ "agentId": agent_id,
491
+ "apiKey": api_key,
492
+ }
493
+ },
494
+ "tools": {
495
+ "profile": DEFAULT_OPENCLAW_TOOLS_PROFILE,
496
+ "alsoAllow": list(REQUIRED_OPENCLAW_TOOLS),
497
+ "sessions": {
498
+ "visibility": DEFAULT_OPENCLAW_TOOLS_VISIBILITY,
499
+ },
500
+ },
501
+ }
502
+
503
+
504
+ def expand_path(path: str) -> str:
505
+ return os.path.abspath(os.path.expanduser((path or "").strip() or DEFAULT_OPENCLAW_CONFIG_PATH))
506
+
507
+
508
+ def resolve_config_path(args) -> str:
509
+ raw_path = str(getattr(args, "config_path", "") or "").strip()
510
+ if raw_path and raw_path != DEFAULT_OPENCLAW_CONFIG_PATH:
511
+ return expand_path(raw_path)
512
+
513
+ profile = str(getattr(args, "openclaw_profile", "") or "").strip()
514
+ if profile:
515
+ return expand_path(f"~/.openclaw-{profile}/openclaw.json")
516
+
517
+ return expand_path(DEFAULT_OPENCLAW_CONFIG_PATH)
518
+
519
+
520
+ def load_json_file(path: str):
521
+ if not os.path.exists(path):
522
+ return {}
523
+ with open(path, "r", encoding="utf-8") as handle:
524
+ raw = handle.read().strip()
525
+ if not raw:
526
+ return {}
527
+ return json.loads(raw)
528
+
529
+
530
+ def extract_main_clawpool_config(cfg):
531
+ channels = cfg.get("channels") if isinstance(cfg, dict) else None
532
+ clawpool = channels.get("clawpool") if isinstance(channels, dict) else None
533
+ if not isinstance(clawpool, dict):
534
+ return {}
535
+ return {
536
+ "enabled": bool(clawpool.get("enabled", False)),
537
+ "wsUrl": str(clawpool.get("wsUrl", "")).strip(),
538
+ "agentId": str(clawpool.get("agentId", "")).strip(),
539
+ "apiKey": str(clawpool.get("apiKey", "")).strip(),
540
+ }
541
+
542
+
543
+ def extract_openclaw_tools_config(cfg):
544
+ tools = cfg.get("tools") if isinstance(cfg, dict) else None
545
+ if not isinstance(tools, dict):
546
+ return {}
547
+
548
+ sessions = dict(tools.get("sessions") or {})
549
+ if not isinstance(sessions, dict):
550
+ sessions = {}
551
+
552
+ payload = dict(tools)
553
+ payload["profile"] = str(payload.get("profile", "")).strip()
554
+ payload["alsoAllow"] = normalize_string_list(payload.get("alsoAllow"))
555
+ payload["sessions"] = sessions
556
+ payload["sessions"]["visibility"] = str(sessions.get("visibility", "")).strip()
557
+ return payload
558
+
559
+
560
+ def build_required_tools_config():
561
+ return {
562
+ "profile": DEFAULT_OPENCLAW_TOOLS_PROFILE,
563
+ "alsoAllow": list(REQUIRED_OPENCLAW_TOOLS),
564
+ "sessions": {
565
+ "visibility": DEFAULT_OPENCLAW_TOOLS_VISIBILITY,
566
+ },
567
+ }
568
+
569
+
570
+ def mask_secret(value: str):
571
+ text = str(value or "").strip()
572
+ if not text:
573
+ return ""
574
+ if len(text) <= 8:
575
+ return "*" * len(text)
576
+ return f"{text[:4]}...{text[-4:]}"
577
+
578
+
579
+ def redact_current_main_channel(channel):
580
+ payload = dict(channel or {})
581
+ api_key = str(payload.get("apiKey", "")).strip()
582
+ if api_key:
583
+ payload["apiKey"] = "<redacted>"
584
+ payload["apiKeyMasked"] = mask_secret(api_key)
585
+ return payload
586
+
587
+
588
+ def build_portal_guidance(portal_ready: bool, portal_hint: str):
589
+ payload = {
590
+ "portal_url": DEFAULT_PORTAL_URL,
591
+ "portal_ready": bool(portal_ready),
592
+ }
593
+ normalized_hint = str(portal_hint or "").strip()
594
+ if normalized_hint:
595
+ payload["portal_hint"] = normalized_hint
596
+ return payload
597
+
598
+
599
+ def build_clawpool_explainer():
600
+ return {
601
+ "clawpool_intro": "ClawPool 完全适配 OpenClaw 的通讯协议,实现 OpenClaw 交互完美打通。",
602
+ "clawpool_highlights": [
603
+ "支持多 agent 建群互聊",
604
+ "支持 agent 私聊",
605
+ "agent 之间通信链路完美打通",
606
+ "配置简单易用,只需要将这个技能交给 OpenClaw、Claude、Codex,即可完成检查与配置",
607
+ "如需群管理能力,还需要安装启用 @dhfpub/clawpool-admin,并在 tools 中允许 message、clawpool_group、clawpool_agent_admin",
608
+ ],
609
+ }
610
+
611
+
612
+ def build_user_reply_templates(scenario: str):
613
+ one_liner = "ClawPool 完全适配 OpenClaw 通讯协议,支持多 agent 群聊、agent 私聊和 agent 间互通。"
614
+ short_intro = (
615
+ "ClawPool 把 OpenClaw 的渠道交互和 agent 通信直接打通,"
616
+ "配置好后,多 agent 建群互聊、agent 私聊都可以直接用起来。"
617
+ )
618
+ messages = {
619
+ "already_configured": f"{one_liner}当前主通道和群管理能力都已配置完成,你现在可以直接登录 {DEFAULT_PORTAL_URL} 体验。",
620
+ "configured_now": f"{one_liner}现在已经配置完成,你可以直接登录 {DEFAULT_PORTAL_URL} 体验。",
621
+ "main_ready_admin_pending": (
622
+ f"{one_liner}当前主通道已经可用,你可以先直接登录 {DEFAULT_PORTAL_URL} 体验;"
623
+ "如果还要在 OpenClaw 里使用群管理能力,我可以继续安装启用 @dhfpub/clawpool-admin,"
624
+ "并补齐 message、clawpool_group、clawpool_agent_admin 这三个工具权限。"
625
+ ),
626
+ "needs_setup": f"{one_liner}当前还没有完全配置好,我可以继续帮你完成检查和配置。",
627
+ "login_ready": f"{one_liner}账号已经可用,你可以直接登录 {DEFAULT_PORTAL_URL} 体验;如果需要,我也可以继续帮你把 OpenClaw 主通道配好。",
628
+ }
629
+ normalized_scenario = str(scenario or "").strip() or "needs_setup"
630
+ return {
631
+ "user_reply_templates": {
632
+ "scenario": normalized_scenario,
633
+ "one_liner": one_liner,
634
+ "short_intro": short_intro,
635
+ "recommended_message": messages.get(normalized_scenario, messages["needs_setup"]),
636
+ }
637
+ }
638
+
639
+
640
+ def extract_ws_agent_id(ws_url: str):
641
+ text = str(ws_url or "").strip()
642
+ if not text:
643
+ return ""
644
+ try:
645
+ parsed = urllib.parse.urlparse(text)
646
+ except ValueError:
647
+ return ""
648
+ if parsed.scheme not in ("ws", "wss"):
649
+ return ""
650
+ values = urllib.parse.parse_qs(parsed.query)
651
+ candidates = values.get("agent_id") or values.get("agentId") or []
652
+ if not candidates:
653
+ return ""
654
+ return str(candidates[0]).strip()
655
+
656
+
657
+ def inspect_main_clawpool_channel(channel):
658
+ current = dict(channel or {})
659
+ issues = []
660
+ ws_url = str(current.get("wsUrl", "")).strip()
661
+ agent_id = str(current.get("agentId", "")).strip()
662
+ api_key = str(current.get("apiKey", "")).strip()
663
+ ws_agent_id = extract_ws_agent_id(ws_url)
664
+
665
+ if not current:
666
+ issues.append(
667
+ {
668
+ "code": "main_channel_missing",
669
+ "message": "channels.clawpool is not configured for the main OpenClaw agent",
670
+ }
671
+ )
672
+ return {
673
+ "configured": False,
674
+ "issues": issues,
675
+ "ws_agent_id": "",
676
+ "agent_id_matches_ws_url": False,
677
+ "ready": False,
678
+ }
679
+
680
+ if not bool(current.get("enabled", False)):
681
+ issues.append(
682
+ {
683
+ "code": "main_channel_disabled",
684
+ "message": "channels.clawpool exists but is disabled",
685
+ }
686
+ )
687
+
688
+ if not ws_url:
689
+ issues.append(
690
+ {
691
+ "code": "main_channel_missing_ws_url",
692
+ "message": "channels.clawpool.wsUrl is empty",
693
+ }
694
+ )
695
+ else:
696
+ try:
697
+ parsed = urllib.parse.urlparse(ws_url)
698
+ except ValueError:
699
+ parsed = None
700
+ if parsed is None or parsed.scheme not in ("ws", "wss"):
701
+ issues.append(
702
+ {
703
+ "code": "main_channel_invalid_ws_url",
704
+ "message": "channels.clawpool.wsUrl must be a ws:// or wss:// URL",
705
+ }
706
+ )
707
+ if not ws_agent_id:
708
+ issues.append(
709
+ {
710
+ "code": "main_channel_missing_ws_agent_id",
711
+ "message": "channels.clawpool.wsUrl does not contain agent_id query parameter",
712
+ }
713
+ )
714
+ elif agent_id and ws_agent_id != agent_id:
715
+ issues.append(
716
+ {
717
+ "code": "main_channel_agent_id_mismatch",
718
+ "message": "channels.clawpool.agentId does not match the wsUrl agent_id",
719
+ "ws_agent_id": ws_agent_id,
720
+ "agent_id": agent_id,
721
+ }
722
+ )
723
+
724
+ if not agent_id:
725
+ issues.append(
726
+ {
727
+ "code": "main_channel_missing_agent_id",
728
+ "message": "channels.clawpool.agentId is empty",
729
+ }
730
+ )
731
+
732
+ if not api_key:
733
+ issues.append(
734
+ {
735
+ "code": "main_channel_missing_api_key",
736
+ "message": "channels.clawpool.apiKey is empty",
737
+ }
738
+ )
739
+
740
+ return {
741
+ "configured": True,
742
+ "issues": issues,
743
+ "ws_agent_id": ws_agent_id,
744
+ "agent_id_matches_ws_url": bool(agent_id) and bool(ws_agent_id) and ws_agent_id == agent_id,
745
+ "ready": len(issues) == 0,
746
+ }
747
+
748
+
749
+ def apply_main_clawpool_config(cfg, agent_id: str, api_endpoint: str, api_key: str):
750
+ next_cfg = dict(cfg or {})
751
+ channels = dict(next_cfg.get("channels") or {})
752
+ clawpool = dict(channels.get("clawpool") or {})
753
+ clawpool["enabled"] = True
754
+ clawpool["wsUrl"] = api_endpoint
755
+ clawpool["agentId"] = agent_id
756
+ clawpool["apiKey"] = api_key
757
+ channels["clawpool"] = clawpool
758
+ next_cfg["channels"] = channels
759
+ return next_cfg
760
+
761
+
762
+ def inspect_openclaw_tools_config(cfg):
763
+ current = extract_openclaw_tools_config(cfg)
764
+ issues = []
765
+ missing_tools = []
766
+
767
+ if not current:
768
+ issues.append(
769
+ {
770
+ "code": "tools_config_missing",
771
+ "message": "tools config is missing",
772
+ }
773
+ )
774
+ missing_tools = list(REQUIRED_OPENCLAW_TOOLS)
775
+ return {
776
+ "configured": False,
777
+ "issues": issues,
778
+ "missing_required_tools": missing_tools,
779
+ "required_tools": list(REQUIRED_OPENCLAW_TOOLS),
780
+ "ready": False,
781
+ }
782
+
783
+ if current.get("profile", "") != DEFAULT_OPENCLAW_TOOLS_PROFILE:
784
+ issues.append(
785
+ {
786
+ "code": "tools_profile_invalid",
787
+ "message": f"tools.profile must be {DEFAULT_OPENCLAW_TOOLS_PROFILE}",
788
+ "current": current.get("profile", ""),
789
+ "expected": DEFAULT_OPENCLAW_TOOLS_PROFILE,
790
+ }
791
+ )
792
+
793
+ current_also_allow = normalize_string_list(current.get("alsoAllow"))
794
+ missing_tools = [tool for tool in REQUIRED_OPENCLAW_TOOLS if tool not in current_also_allow]
795
+ if missing_tools:
796
+ issues.append(
797
+ {
798
+ "code": "tools_required_tools_missing",
799
+ "message": "tools.alsoAllow is missing required ClawPool tool ids",
800
+ "missing_tools": missing_tools,
801
+ "expected_tools": list(REQUIRED_OPENCLAW_TOOLS),
802
+ }
803
+ )
804
+
805
+ sessions = current.get("sessions") if isinstance(current.get("sessions"), dict) else {}
806
+ visibility = str(sessions.get("visibility", "")).strip()
807
+ if visibility != DEFAULT_OPENCLAW_TOOLS_VISIBILITY:
808
+ issues.append(
809
+ {
810
+ "code": "tools_sessions_visibility_invalid",
811
+ "message": f"tools.sessions.visibility must be {DEFAULT_OPENCLAW_TOOLS_VISIBILITY}",
812
+ "current": visibility,
813
+ "expected": DEFAULT_OPENCLAW_TOOLS_VISIBILITY,
814
+ }
815
+ )
816
+
817
+ return {
818
+ "configured": True,
819
+ "issues": issues,
820
+ "missing_required_tools": missing_tools,
821
+ "required_tools": list(REQUIRED_OPENCLAW_TOOLS),
822
+ "ready": len(issues) == 0,
823
+ }
824
+
825
+
826
+ def apply_required_openclaw_tools_config(cfg):
827
+ next_cfg = dict(cfg or {})
828
+ tools = dict(next_cfg.get("tools") or {})
829
+ existing_also_allow = normalize_string_list(tools.get("alsoAllow"))
830
+ next_also_allow = list(REQUIRED_OPENCLAW_TOOLS)
831
+ for item in existing_also_allow:
832
+ if item not in next_also_allow:
833
+ next_also_allow.append(item)
834
+
835
+ sessions = dict(tools.get("sessions") or {})
836
+ if not isinstance(sessions, dict):
837
+ sessions = {}
838
+
839
+ tools["profile"] = DEFAULT_OPENCLAW_TOOLS_PROFILE
840
+ tools["alsoAllow"] = next_also_allow
841
+ sessions["visibility"] = DEFAULT_OPENCLAW_TOOLS_VISIBILITY
842
+ tools["sessions"] = sessions
843
+ next_cfg["tools"] = tools
844
+ return next_cfg
845
+
846
+
847
+ def write_json_file_with_backup(path: str, payload):
848
+ os.makedirs(os.path.dirname(path), exist_ok=True)
849
+ backup_path = ""
850
+ if os.path.exists(path):
851
+ backup_path = f"{path}.bak.{uuid.uuid4().hex[:8]}"
852
+ with open(path, "rb") as src, open(backup_path, "wb") as dst:
853
+ dst.write(src.read())
854
+ with open(path, "w", encoding="utf-8") as handle:
855
+ json.dump(payload, handle, ensure_ascii=False, indent=2)
856
+ handle.write("\n")
857
+ return backup_path
858
+
859
+
860
+ def run_commands(commands):
861
+ results = []
862
+ for cmd in commands:
863
+ entry = run_command_capture(cmd)
864
+ results.append(entry)
865
+ if entry["returncode"] != 0:
866
+ raise ClawpoolAuthError(
867
+ f"openclaw command failed: {entry['command']}",
868
+ payload={"command_results": results},
869
+ )
870
+ return results
871
+
872
+
873
+ def build_onboard_values(agent_id: str, api_endpoint: str, api_key: str):
874
+ return {
875
+ "channel": "Clawpool",
876
+ "wsUrl": api_endpoint,
877
+ "agentId": agent_id,
878
+ "apiKey": api_key,
879
+ }
880
+
881
+
882
+ def build_channel_environment_variables(agent_id: str, api_endpoint: str, api_key: str):
883
+ return {
884
+ "CLAWPOOL_WS_URL": api_endpoint,
885
+ "CLAWPOOL_AGENT_ID": agent_id,
886
+ "CLAWPOOL_API_KEY": api_key,
887
+ }
888
+
889
+
890
+ def is_ready(payload):
891
+ return isinstance(payload, dict) and bool(payload.get("ready", False))
892
+
893
+
894
+ def is_configured(payload):
895
+ return isinstance(payload, dict) and bool(payload.get("configured", False))
896
+
897
+
898
+ def ready_for_main_agent(plugin_status, channel_inspection):
899
+ return is_ready(plugin_status) and is_ready(channel_inspection)
900
+
901
+
902
+ def ready_for_group_governance(plugin_status, channel_inspection, admin_plugin_status, tools_inspection):
903
+ return (
904
+ ready_for_main_agent(plugin_status, channel_inspection)
905
+ and is_ready(admin_plugin_status)
906
+ and is_ready(tools_inspection)
907
+ )
908
+
909
+
910
+ def collect_inspection_gaps(plugin_status, channel_inspection, admin_plugin_status, tools_inspection):
911
+ gaps = []
912
+ if not isinstance(plugin_status, dict):
913
+ gaps.append("plugin_verification_failed")
914
+ elif not bool(plugin_status.get("detected")):
915
+ gaps.append("plugin_missing")
916
+ elif not bool(plugin_status.get("ready")):
917
+ gaps.append("plugin_not_ready")
918
+
919
+ if not is_configured(channel_inspection):
920
+ gaps.append("main_channel_missing")
921
+ elif not is_ready(channel_inspection):
922
+ gaps.append("main_channel_invalid")
923
+
924
+ if not isinstance(admin_plugin_status, dict):
925
+ gaps.append("admin_plugin_verification_failed")
926
+ elif not bool(admin_plugin_status.get("detected")):
927
+ gaps.append("admin_plugin_missing")
928
+ elif not bool(admin_plugin_status.get("ready")):
929
+ gaps.append("admin_plugin_not_ready")
930
+
931
+ if not is_configured(tools_inspection):
932
+ gaps.append("tools_config_missing")
933
+ elif not is_ready(tools_inspection):
934
+ gaps.append("tools_not_ready")
935
+
936
+ return gaps
937
+
938
+
939
+ def collect_setup_gaps(plugin_status, needs_main_update: bool, admin_plugin_status, needs_tools_update: bool):
940
+ gaps = []
941
+ if not isinstance(plugin_status, dict):
942
+ gaps.append("plugin_verification_failed")
943
+ elif not bool(plugin_status.get("detected")):
944
+ gaps.append("plugin_missing")
945
+ elif not bool(plugin_status.get("ready")):
946
+ gaps.append("plugin_not_ready")
947
+
948
+ if needs_main_update:
949
+ gaps.append("needs_main_config_update")
950
+
951
+ if not isinstance(admin_plugin_status, dict):
952
+ gaps.append("admin_plugin_verification_failed")
953
+ elif not bool(admin_plugin_status.get("detected")):
954
+ gaps.append("admin_plugin_missing")
955
+ elif not bool(admin_plugin_status.get("ready")):
956
+ gaps.append("admin_plugin_not_ready")
957
+
958
+ if needs_tools_update:
959
+ gaps.append("needs_tools_config_update")
960
+
961
+ return gaps
962
+
963
+
964
+ def classify_gap_state(gaps):
965
+ if not gaps:
966
+ return "already_configured"
967
+ return str(gaps[0]).strip() or "needs_verification"
968
+
969
+
970
+ def build_recommended_next_steps(gaps):
971
+ mapping = {
972
+ "plugin_verification_failed": "verify_clawpool_plugin_state",
973
+ "plugin_missing": "install_or_enable_clawpool_plugin",
974
+ "plugin_not_ready": "repair_or_enable_clawpool_plugin",
975
+ "main_channel_missing": "configure_main_clawpool_channel",
976
+ "main_channel_invalid": "repair_main_clawpool_channel",
977
+ "needs_main_config_update": "update_main_clawpool_channel",
978
+ "admin_plugin_verification_failed": "verify_clawpool_admin_plugin_state",
979
+ "admin_plugin_missing": "install_or_enable_clawpool_admin_plugin",
980
+ "admin_plugin_not_ready": "repair_or_enable_clawpool_admin_plugin",
981
+ "tools_config_missing": "configure_required_clawpool_tools",
982
+ "tools_not_ready": "repair_required_clawpool_tools",
983
+ "needs_tools_config_update": "update_required_clawpool_tools",
984
+ }
985
+ steps = []
986
+ for gap in gaps:
987
+ step = mapping.get(str(gap).strip())
988
+ if step and step not in steps:
989
+ steps.append(step)
990
+ return steps
991
+
992
+
993
+ def build_openclaw_inspection_result(args):
994
+ config_path = resolve_config_path(args)
995
+ current_cfg = load_json_file(config_path)
996
+ current_main = extract_main_clawpool_config(current_cfg)
997
+ current_tools = extract_openclaw_tools_config(current_cfg)
998
+ plugin_status = inspect_openclaw_plugin(args)
999
+ admin_plugin_status = inspect_openclaw_admin_plugin(args)
1000
+ channel_inspection = inspect_main_clawpool_channel(current_main)
1001
+ tools_inspection = inspect_openclaw_tools_config(current_cfg)
1002
+ gaps = collect_inspection_gaps(plugin_status, channel_inspection, admin_plugin_status, tools_inspection)
1003
+ inspection_state = classify_gap_state(gaps)
1004
+ main_ready = ready_for_main_agent(plugin_status, channel_inspection)
1005
+ governance_ready = ready_for_group_governance(
1006
+ plugin_status,
1007
+ channel_inspection,
1008
+ admin_plugin_status,
1009
+ tools_inspection,
1010
+ )
1011
+
1012
+ payload = {
1013
+ "ok": True,
1014
+ "action": "inspect-openclaw",
1015
+ "inspection_state": inspection_state,
1016
+ "ready_for_main_agent": main_ready,
1017
+ "ready_for_group_governance": governance_ready,
1018
+ "config_path": config_path,
1019
+ "plugin_status": plugin_status,
1020
+ "admin_plugin_status": admin_plugin_status,
1021
+ "current_main_channel": redact_current_main_channel(current_main),
1022
+ "current_tools_config": current_tools,
1023
+ "main_channel_checks": channel_inspection,
1024
+ "tools_checks": tools_inspection,
1025
+ "required_tools_config": build_required_tools_config(),
1026
+ "recommended_next_steps": build_recommended_next_steps(gaps),
1027
+ }
1028
+ if governance_ready:
1029
+ payload.update(
1030
+ build_portal_guidance(
1031
+ True,
1032
+ f"主通道和群管理能力已配置完成,可直接登录 {DEFAULT_PORTAL_URL} 体验。",
1033
+ )
1034
+ )
1035
+ payload.update(build_user_reply_templates("already_configured"))
1036
+ elif main_ready:
1037
+ payload.update(
1038
+ build_portal_guidance(
1039
+ True,
1040
+ (
1041
+ f"主通道已配置完成,可直接登录 {DEFAULT_PORTAL_URL} 体验;"
1042
+ "如需群管理能力,还需安装启用 @dhfpub/clawpool-admin 并补齐 required tools 配置。"
1043
+ ),
1044
+ )
1045
+ )
1046
+ payload.update(build_user_reply_templates("main_ready_admin_pending"))
1047
+ else:
1048
+ payload.update(build_portal_guidance(False, ""))
1049
+ payload.update(build_user_reply_templates("needs_setup"))
1050
+ payload.update(build_clawpool_explainer())
1051
+ return payload
1052
+
1053
+
1054
+ def build_openclaw_setup_result(args, agent_id: str, api_endpoint: str, api_key: str):
1055
+ config_path = resolve_config_path(args)
1056
+ current_cfg = load_json_file(config_path)
1057
+ current_main = extract_main_clawpool_config(current_cfg)
1058
+ current_tools = extract_openclaw_tools_config(current_cfg)
1059
+ next_cfg = apply_required_openclaw_tools_config(
1060
+ apply_main_clawpool_config(current_cfg, agent_id, api_endpoint, api_key)
1061
+ )
1062
+ next_main = extract_main_clawpool_config(next_cfg)
1063
+ next_tools = extract_openclaw_tools_config(next_cfg)
1064
+ needs_main_update = current_main != next_main
1065
+ needs_tools_update = current_tools != next_tools
1066
+ needs_update = needs_main_update or needs_tools_update
1067
+ plugin_status = inspect_openclaw_plugin(args)
1068
+ admin_plugin_status = inspect_openclaw_admin_plugin(args)
1069
+ plugin_commands = build_plugin_commands(args, plugin_status)
1070
+ admin_plugin_commands = build_admin_plugin_commands(args, admin_plugin_status)
1071
+ reference_commands = build_reference_commands(args, agent_id, api_endpoint, api_key)
1072
+ channel_inspection = inspect_main_clawpool_channel(current_main)
1073
+ tools_inspection = inspect_openclaw_tools_config(current_cfg)
1074
+ planned_apply_commands = list(plugin_commands) + list(admin_plugin_commands)
1075
+ restart_needed = (not args.skip_gateway_restart) and (
1076
+ bool(plugin_commands) or bool(admin_plugin_commands) or needs_update
1077
+ )
1078
+ if restart_needed:
1079
+ planned_apply_commands.append(build_gateway_restart_command(args))
1080
+ setup_gaps = collect_setup_gaps(plugin_status, needs_main_update, admin_plugin_status, needs_tools_update)
1081
+ main_ready = ready_for_main_agent(plugin_status, channel_inspection) and not needs_main_update
1082
+ governance_ready = main_ready and is_ready(admin_plugin_status) and not needs_tools_update
1083
+ payload = {
1084
+ "ok": True,
1085
+ "action": "configure-openclaw",
1086
+ "apply": bool(args.apply),
1087
+ "apply_strategy": "direct_config_for_main_agent",
1088
+ "setup_state": classify_gap_state(setup_gaps),
1089
+ "ready_for_main_agent": main_ready,
1090
+ "ready_for_group_governance": governance_ready,
1091
+ "config_path": config_path,
1092
+ "channel_name": args.channel_name.strip(),
1093
+ "needs_update": needs_update,
1094
+ "needs_main_channel_update": needs_main_update,
1095
+ "needs_tools_update": needs_tools_update,
1096
+ "setup_gaps": setup_gaps,
1097
+ "current_main_channel": redact_current_main_channel(current_main),
1098
+ "next_main_channel": next_main,
1099
+ "current_tools_config": current_tools,
1100
+ "next_tools_config": next_tools,
1101
+ "main_channel_checks": channel_inspection,
1102
+ "tools_checks": tools_inspection,
1103
+ "required_tools_config": build_required_tools_config(),
1104
+ "recommended_next_steps": build_recommended_next_steps(setup_gaps),
1105
+ "plugin_status": plugin_status,
1106
+ "admin_plugin_status": admin_plugin_status,
1107
+ "plugin_commands": [shell_command(cmd) for cmd in plugin_commands],
1108
+ "admin_plugin_commands": [shell_command(cmd) for cmd in admin_plugin_commands],
1109
+ "planned_apply_commands": [shell_command(cmd) for cmd in planned_apply_commands],
1110
+ "reference_commands": [shell_command(cmd) for cmd in reference_commands],
1111
+ "direct_config": build_direct_config(agent_id, api_endpoint, api_key),
1112
+ "onboard_values": build_onboard_values(agent_id, api_endpoint, api_key),
1113
+ "environment_variables": build_channel_environment_variables(agent_id, api_endpoint, api_key),
1114
+ }
1115
+ if governance_ready:
1116
+ payload.update(
1117
+ build_portal_guidance(
1118
+ True,
1119
+ f"主通道和群管理能力已配置完成,可直接登录 {DEFAULT_PORTAL_URL} 体验。",
1120
+ )
1121
+ )
1122
+ payload.update(build_user_reply_templates("already_configured"))
1123
+ elif main_ready:
1124
+ payload.update(
1125
+ build_portal_guidance(
1126
+ True,
1127
+ (
1128
+ f"主通道已配置完成,可直接登录 {DEFAULT_PORTAL_URL} 体验;"
1129
+ "如需群管理能力,还需安装启用 @dhfpub/clawpool-admin 并补齐 required tools 配置。"
1130
+ ),
1131
+ )
1132
+ )
1133
+ payload.update(build_user_reply_templates("main_ready_admin_pending"))
1134
+ else:
1135
+ payload.update(build_portal_guidance(False, ""))
1136
+ payload.update(build_user_reply_templates("needs_setup"))
1137
+ payload.update(build_clawpool_explainer())
1138
+ if args.apply:
1139
+ command_results = []
1140
+ if plugin_commands:
1141
+ command_results.extend(run_commands(plugin_commands))
1142
+ if admin_plugin_commands:
1143
+ command_results.extend(run_commands(admin_plugin_commands))
1144
+ backup_path = ""
1145
+ if needs_update:
1146
+ backup_path = write_json_file_with_backup(config_path, next_cfg)
1147
+ payload["config_write"] = {
1148
+ "changed": needs_update,
1149
+ "backup_path": backup_path,
1150
+ }
1151
+ if restart_needed:
1152
+ command_results.extend(run_commands([build_gateway_restart_command(args)]))
1153
+ payload["command_results"] = command_results
1154
+ applied_cfg = load_json_file(config_path)
1155
+ applied_main = extract_main_clawpool_config(applied_cfg)
1156
+ applied_tools = extract_openclaw_tools_config(applied_cfg)
1157
+ applied_plugin_status = inspect_openclaw_plugin(args)
1158
+ applied_admin_plugin_status = inspect_openclaw_admin_plugin(args)
1159
+ applied_channel_checks = inspect_main_clawpool_channel(applied_main)
1160
+ applied_tools_checks = inspect_openclaw_tools_config(applied_cfg)
1161
+ payload["applied_state"] = {
1162
+ "plugin_status": applied_plugin_status,
1163
+ "admin_plugin_status": applied_admin_plugin_status,
1164
+ "main_channel_checks": applied_channel_checks,
1165
+ "tools_checks": applied_tools_checks,
1166
+ "current_main_channel": redact_current_main_channel(applied_main),
1167
+ "current_tools_config": applied_tools,
1168
+ }
1169
+ payload["ready_for_main_agent"] = ready_for_main_agent(applied_plugin_status, applied_channel_checks)
1170
+ payload["ready_for_group_governance"] = ready_for_group_governance(
1171
+ applied_plugin_status,
1172
+ applied_channel_checks,
1173
+ applied_admin_plugin_status,
1174
+ applied_tools_checks,
1175
+ )
1176
+ payload["setup_state"] = classify_gap_state(
1177
+ collect_inspection_gaps(
1178
+ applied_plugin_status,
1179
+ applied_channel_checks,
1180
+ applied_admin_plugin_status,
1181
+ applied_tools_checks,
1182
+ )
1183
+ )
1184
+ payload["recommended_next_steps"] = build_recommended_next_steps(
1185
+ collect_inspection_gaps(
1186
+ applied_plugin_status,
1187
+ applied_channel_checks,
1188
+ applied_admin_plugin_status,
1189
+ applied_tools_checks,
1190
+ )
1191
+ )
1192
+ if payload["ready_for_group_governance"]:
1193
+ payload.update(
1194
+ build_portal_guidance(
1195
+ True,
1196
+ f"配置已完成,可直接登录 {DEFAULT_PORTAL_URL} 体验。",
1197
+ )
1198
+ )
1199
+ payload.update(build_user_reply_templates("configured_now"))
1200
+ elif payload["ready_for_main_agent"]:
1201
+ payload.update(
1202
+ build_portal_guidance(
1203
+ True,
1204
+ (
1205
+ f"主通道已完成配置,可直接登录 {DEFAULT_PORTAL_URL} 体验;"
1206
+ "如需群管理能力,还需继续补齐 @dhfpub/clawpool-admin 或 required tools 配置。"
1207
+ ),
1208
+ )
1209
+ )
1210
+ payload.update(build_user_reply_templates("main_ready_admin_pending"))
1211
+ else:
1212
+ payload.update(build_portal_guidance(False, ""))
1213
+ payload.update(build_user_reply_templates("needs_setup"))
1214
+ return payload
1215
+
1216
+
1217
+ def handle_fetch_captcha(args):
1218
+ result = request_json("GET", "/auth/captcha", args.base_url)
1219
+ data = result.get("data") or {}
1220
+ image_path = maybe_write_captcha_image(str(data.get("b64s", "")))
1221
+ payload = {
1222
+ "ok": True,
1223
+ "action": "fetch-captcha",
1224
+ "api_base_url": result["api_base_url"],
1225
+ "captcha_id": data.get("captcha_id", ""),
1226
+ "b64s": data.get("b64s", ""),
1227
+ }
1228
+ if image_path:
1229
+ payload["captcha_image_path"] = image_path
1230
+ print_json(payload)
1231
+
1232
+
1233
+ def handle_send_email_code(args):
1234
+ result = request_json(
1235
+ "POST",
1236
+ "/auth/send-code",
1237
+ args.base_url,
1238
+ body={
1239
+ "email": args.email.strip(),
1240
+ "scene": args.scene.strip(),
1241
+ "captcha_id": args.captcha_id.strip(),
1242
+ "captcha_value": args.captcha_value.strip(),
1243
+ },
1244
+ )
1245
+ print_json(
1246
+ {
1247
+ "ok": True,
1248
+ "action": "send-email-code",
1249
+ "api_base_url": result["api_base_url"],
1250
+ "data": result.get("data"),
1251
+ }
1252
+ )
1253
+
1254
+
1255
+ def default_device_id(platform: str) -> str:
1256
+ normalized_platform = (platform or "").strip() or "web"
1257
+ return f"{normalized_platform}_{uuid.uuid4()}"
1258
+
1259
+
1260
+ def handle_register(args):
1261
+ platform = (args.platform or "").strip() or "web"
1262
+ device_id = (args.device_id or "").strip() or default_device_id(platform)
1263
+ result = request_json(
1264
+ "POST",
1265
+ "/auth/register",
1266
+ args.base_url,
1267
+ body={
1268
+ "email": args.email.strip(),
1269
+ "password": args.password.strip(),
1270
+ "email_code": args.email_code.strip(),
1271
+ "device_id": device_id,
1272
+ "platform": platform,
1273
+ },
1274
+ )
1275
+ print_json(build_auth_result("register", result))
1276
+
1277
+
1278
+ def handle_login(args):
1279
+ account = (args.email or args.account or "").strip()
1280
+ if not account:
1281
+ raise ClawpoolAuthError("either --email or --account is required")
1282
+ platform = (args.platform or "").strip() or "web"
1283
+ device_id = (args.device_id or "").strip() or default_device_id(platform)
1284
+ print_json(
1285
+ login_with_credentials(
1286
+ args.base_url,
1287
+ account,
1288
+ args.password.strip(),
1289
+ device_id,
1290
+ platform,
1291
+ )
1292
+ )
1293
+
1294
+
1295
+ def handle_create_api_agent(args):
1296
+ print_json(
1297
+ create_or_reuse_api_agent(
1298
+ args.base_url,
1299
+ args.access_token.strip(),
1300
+ args.agent_name.strip(),
1301
+ args.avatar_url,
1302
+ not bool(args.no_reuse_existing_agent),
1303
+ not bool(args.no_rotate_key_on_reuse),
1304
+ )
1305
+ )
1306
+
1307
+
1308
+ def handle_inspect_openclaw(args):
1309
+ print_json(build_openclaw_inspection_result(args))
1310
+
1311
+
1312
+ def handle_configure_openclaw(args):
1313
+ print_json(
1314
+ build_openclaw_setup_result(
1315
+ args,
1316
+ args.agent_id.strip(),
1317
+ args.api_endpoint.strip(),
1318
+ args.api_key.strip(),
1319
+ )
1320
+ )
1321
+
1322
+
1323
+ def handle_bootstrap_openclaw(args):
1324
+ access_token = (args.access_token or "").strip()
1325
+ login_result = None
1326
+ if not access_token:
1327
+ account = (args.email or args.account or "").strip()
1328
+ if not account:
1329
+ raise ClawpoolAuthError("bootstrap-openclaw requires --access-token or login identity")
1330
+ if not (args.password or "").strip():
1331
+ raise ClawpoolAuthError("bootstrap-openclaw requires --password when access token is not provided")
1332
+ platform = (args.platform or "").strip() or "web"
1333
+ device_id = (args.device_id or "").strip() or default_device_id(platform)
1334
+ login_result = login_with_credentials(
1335
+ args.base_url,
1336
+ account,
1337
+ args.password.strip(),
1338
+ device_id,
1339
+ platform,
1340
+ )
1341
+ access_token = str(login_result.get("access_token", "")).strip()
1342
+ if not access_token:
1343
+ raise ClawpoolAuthError("login did not return access_token")
1344
+
1345
+ create_result = create_or_reuse_api_agent(
1346
+ args.base_url,
1347
+ access_token,
1348
+ args.agent_name.strip(),
1349
+ args.avatar_url,
1350
+ not bool(args.no_reuse_existing_agent),
1351
+ not bool(args.no_rotate_key_on_reuse),
1352
+ )
1353
+
1354
+ api_endpoint = str(create_result.get("api_endpoint", "")).strip()
1355
+ agent_id = str(create_result.get("agent_id", "")).strip()
1356
+ api_key = str(create_result.get("api_key", "")).strip()
1357
+ if not api_endpoint or not agent_id or not api_key:
1358
+ raise ClawpoolAuthError("create-api-agent did not return full Clawpool channel credentials")
1359
+
1360
+ payload = {
1361
+ "ok": True,
1362
+ "action": "bootstrap-openclaw",
1363
+ "used_access_token_source": "provided" if (args.access_token or "").strip() else "login",
1364
+ "login": login_result,
1365
+ "created_agent": create_result,
1366
+ "openclaw_setup": None,
1367
+ "channel_credentials": build_onboard_values(agent_id, api_endpoint, api_key),
1368
+ }
1369
+
1370
+ if not args.skip_openclaw_setup:
1371
+ payload["openclaw_setup"] = build_openclaw_setup_result(args, agent_id, api_endpoint, api_key)
1372
+ payload["bootstrap_state"] = payload["openclaw_setup"].get("setup_state", "")
1373
+ payload.update(
1374
+ build_portal_guidance(
1375
+ bool(payload["openclaw_setup"].get("portal_ready")),
1376
+ str(payload["openclaw_setup"].get("portal_hint", "")).strip(),
1377
+ )
1378
+ )
1379
+ payload.update(
1380
+ build_user_reply_templates(
1381
+ "already_configured"
1382
+ if bool(payload["openclaw_setup"].get("ready_for_group_governance"))
1383
+ and str(payload["bootstrap_state"]).strip() == "already_configured"
1384
+ else "configured_now"
1385
+ if bool(payload["openclaw_setup"].get("ready_for_group_governance"))
1386
+ else "main_ready_admin_pending"
1387
+ if bool(payload["openclaw_setup"].get("ready_for_main_agent"))
1388
+ else "needs_setup"
1389
+ )
1390
+ )
1391
+ else:
1392
+ payload["bootstrap_state"] = "agent_ready_openclaw_setup_skipped"
1393
+ payload.update(build_portal_guidance(False, ""))
1394
+ payload.update(build_user_reply_templates("login_ready"))
1395
+ payload.update(build_clawpool_explainer())
1396
+
1397
+ print_json(payload)
1398
+
1399
+
1400
+ def build_parser():
1401
+ parser = argparse.ArgumentParser(description="ClawPool public auth API helper")
1402
+ parser.add_argument("--base-url", default=DEFAULT_BASE_URL, help="ClawPool web base URL")
1403
+
1404
+ subparsers = parser.add_subparsers(dest="action", required=True)
1405
+
1406
+ fetch_captcha = subparsers.add_parser("fetch-captcha", help="Fetch captcha image")
1407
+ fetch_captcha.set_defaults(handler=handle_fetch_captcha)
1408
+
1409
+ send_email_code = subparsers.add_parser("send-email-code", help="Send email verification code")
1410
+ send_email_code.add_argument("--email", required=True)
1411
+ send_email_code.add_argument("--scene", required=True, choices=["register", "reset", "change_password"])
1412
+ send_email_code.add_argument("--captcha-id", required=True)
1413
+ send_email_code.add_argument("--captcha-value", required=True)
1414
+ send_email_code.set_defaults(handler=handle_send_email_code)
1415
+
1416
+ register = subparsers.add_parser("register", help="Register by email verification code")
1417
+ register.add_argument("--email", required=True)
1418
+ register.add_argument("--password", required=True)
1419
+ register.add_argument("--email-code", required=True)
1420
+ register.add_argument("--device-id", default="")
1421
+ register.add_argument("--platform", default="web")
1422
+ register.set_defaults(handler=handle_register)
1423
+
1424
+ login = subparsers.add_parser("login", help="Login and obtain tokens")
1425
+ login_identity = login.add_mutually_exclusive_group(required=True)
1426
+ login_identity.add_argument("--account")
1427
+ login_identity.add_argument("--email")
1428
+ login.add_argument("--password", required=True)
1429
+ login.add_argument("--device-id", default="")
1430
+ login.add_argument("--platform", default="web")
1431
+ login.set_defaults(handler=handle_login)
1432
+
1433
+ create_api_agent_parser = subparsers.add_parser(
1434
+ "create-api-agent",
1435
+ help="Create a provider_type=3 API agent with a user access token",
1436
+ )
1437
+ create_api_agent_parser.add_argument("--access-token", required=True)
1438
+ create_api_agent_parser.add_argument("--agent-name", required=True)
1439
+ create_api_agent_parser.add_argument("--avatar-url", default="")
1440
+ create_api_agent_parser.add_argument("--no-reuse-existing-agent", action="store_true")
1441
+ create_api_agent_parser.add_argument("--no-rotate-key-on-reuse", action="store_true")
1442
+ create_api_agent_parser.set_defaults(handler=handle_create_api_agent)
1443
+
1444
+ inspect_openclaw = subparsers.add_parser(
1445
+ "inspect-openclaw",
1446
+ help="Inspect local OpenClaw clawpool readiness without mutating local state",
1447
+ )
1448
+ inspect_openclaw.add_argument("--openclaw-bin", default="openclaw")
1449
+ inspect_openclaw.add_argument("--openclaw-profile", default="")
1450
+ inspect_openclaw.add_argument("--config-path", default=DEFAULT_OPENCLAW_CONFIG_PATH)
1451
+ inspect_openclaw.add_argument("--skip-plugin-install", action="store_true")
1452
+ inspect_openclaw.add_argument("--skip-plugin-enable", action="store_true")
1453
+ inspect_openclaw.add_argument("--skip-admin-plugin-install", action="store_true")
1454
+ inspect_openclaw.add_argument("--skip-admin-plugin-enable", action="store_true")
1455
+ inspect_openclaw.set_defaults(handler=handle_inspect_openclaw)
1456
+
1457
+ configure_openclaw = subparsers.add_parser(
1458
+ "configure-openclaw",
1459
+ help="Prepare or apply local OpenClaw clawpool channel setup",
1460
+ )
1461
+ configure_openclaw.add_argument("--agent-id", required=True)
1462
+ configure_openclaw.add_argument("--api-endpoint", required=True)
1463
+ configure_openclaw.add_argument("--api-key", required=True)
1464
+ configure_openclaw.add_argument("--channel-name", default="clawpool-main")
1465
+ configure_openclaw.add_argument("--openclaw-bin", default="openclaw")
1466
+ configure_openclaw.add_argument("--openclaw-profile", default="")
1467
+ configure_openclaw.add_argument("--config-path", default=DEFAULT_OPENCLAW_CONFIG_PATH)
1468
+ configure_openclaw.add_argument("--skip-plugin-install", action="store_true")
1469
+ configure_openclaw.add_argument("--skip-plugin-enable", action="store_true")
1470
+ configure_openclaw.add_argument("--skip-admin-plugin-install", action="store_true")
1471
+ configure_openclaw.add_argument("--skip-admin-plugin-enable", action="store_true")
1472
+ configure_openclaw.add_argument("--skip-gateway-restart", action="store_true")
1473
+ configure_openclaw.add_argument("--apply", action="store_true")
1474
+ configure_openclaw.set_defaults(handler=handle_configure_openclaw)
1475
+
1476
+ bootstrap_openclaw = subparsers.add_parser(
1477
+ "bootstrap-openclaw",
1478
+ help="Login if needed, create provider_type=3 agent, then prepare or apply OpenClaw setup",
1479
+ )
1480
+ bootstrap_openclaw.add_argument("--access-token", default="")
1481
+ bootstrap_identity = bootstrap_openclaw.add_mutually_exclusive_group(required=False)
1482
+ bootstrap_identity.add_argument("--account")
1483
+ bootstrap_identity.add_argument("--email")
1484
+ bootstrap_openclaw.add_argument("--password", default="")
1485
+ bootstrap_openclaw.add_argument("--device-id", default="")
1486
+ bootstrap_openclaw.add_argument("--platform", default="web")
1487
+ bootstrap_openclaw.add_argument("--agent-name", required=True)
1488
+ bootstrap_openclaw.add_argument("--avatar-url", default="")
1489
+ bootstrap_openclaw.add_argument("--channel-name", default="clawpool-main")
1490
+ bootstrap_openclaw.add_argument("--openclaw-bin", default="openclaw")
1491
+ bootstrap_openclaw.add_argument("--openclaw-profile", default="")
1492
+ bootstrap_openclaw.add_argument("--config-path", default=DEFAULT_OPENCLAW_CONFIG_PATH)
1493
+ bootstrap_openclaw.add_argument("--no-reuse-existing-agent", action="store_true")
1494
+ bootstrap_openclaw.add_argument("--no-rotate-key-on-reuse", action="store_true")
1495
+ bootstrap_openclaw.add_argument("--skip-plugin-install", action="store_true")
1496
+ bootstrap_openclaw.add_argument("--skip-plugin-enable", action="store_true")
1497
+ bootstrap_openclaw.add_argument("--skip-admin-plugin-install", action="store_true")
1498
+ bootstrap_openclaw.add_argument("--skip-admin-plugin-enable", action="store_true")
1499
+ bootstrap_openclaw.add_argument("--skip-gateway-restart", action="store_true")
1500
+ bootstrap_openclaw.add_argument("--skip-openclaw-setup", action="store_true")
1501
+ bootstrap_openclaw.add_argument("--apply", action="store_true")
1502
+ bootstrap_openclaw.set_defaults(handler=handle_bootstrap_openclaw)
1503
+
1504
+ return parser
1505
+
1506
+
1507
+ def main():
1508
+ parser = build_parser()
1509
+ args = parser.parse_args()
1510
+ try:
1511
+ args.handler(args)
1512
+ except ClawpoolAuthError as exc:
1513
+ print_json(
1514
+ {
1515
+ "ok": False,
1516
+ "action": args.action,
1517
+ "status": exc.status,
1518
+ "code": exc.code,
1519
+ "error": str(exc),
1520
+ "payload": exc.payload,
1521
+ }
1522
+ )
1523
+ raise SystemExit(1)
1524
+ except Exception as exc:
1525
+ print_json(
1526
+ {
1527
+ "ok": False,
1528
+ "action": args.action,
1529
+ "status": 0,
1530
+ "code": -1,
1531
+ "error": str(exc),
1532
+ }
1533
+ )
1534
+ raise SystemExit(1)
1535
+
1536
+
1537
+ if __name__ == "__main__":
1538
+ main()