tnyma-bridge 0.1.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,2063 @@
1
+ #!/usr/bin/env python3
2
+ # Copyright (C) 2025 Tencent. All rights reserved.
3
+ #
4
+ # Adapted for tnyma-bridge: this is the Tencent Lighthouse Feishu bot creator
5
+ # spawned by bridge-node's /v1/integrations/feishu/bind/stream RPC. Paths and
6
+ # ports are env-overridable so multiple environments (local docker, prod VM)
7
+ # can coexist without code changes.
8
+ """
9
+ 飞书/Lark 开放平台 - 自动创建企业自建机器人 (非交互式)
10
+
11
+ This script is invoked by bridge-node when the user clicks "扫码自动创建" in
12
+ the tnyma-web UI. stdout emits one JSON line per event; bridge-node parses
13
+ those lines and forwards them as SSE events to the browser.
14
+
15
+ Environment knobs (all optional, defaults shown):
16
+ FEISHU_BOT_CDP_PORT=9222
17
+ FEISHU_BOT_STATE_DIR=/tmp
18
+ FEISHU_BOT_OPENCLAW_CONFIG=/root/.openclaw/openclaw.json
19
+ FEISHU_BOT_OPENCLAW_ALLOW_FROM=/root/.openclaw/credentials/feishu-default-allowFrom.json
20
+ FEISHU_BOT_ACCOUNT_ID=default # channels.feishu.accounts.<id> key
21
+
22
+ Usage:
23
+ python3 feishu_bot_creator.py init # check/install playwright + chromium
24
+ python3 feishu_bot_creator.py create # full flow (default platform: feishu)
25
+ python3 feishu_bot_creator.py create --platform lark
26
+ python3 feishu_bot_creator.py cleanup # kill leftover browser
27
+ """
28
+
29
+ from __future__ import annotations
30
+
31
+ # ============================================================
32
+ # 依赖自举
33
+ # ============================================================
34
+ import importlib
35
+ import importlib.util
36
+ import os
37
+ import subprocess
38
+ import sys
39
+
40
+ _REQUIRED_PACKAGES = [("playwright", "playwright")]
41
+
42
+
43
+ def _ensure_pip():
44
+ try:
45
+ importlib.import_module("pip")
46
+ return
47
+ except ImportError:
48
+ pass
49
+ try:
50
+ subprocess.check_call(
51
+ [sys.executable, "-m", "ensurepip", "--upgrade"],
52
+ stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL,
53
+ )
54
+ return
55
+ except (subprocess.CalledProcessError, FileNotFoundError):
56
+ pass
57
+ import tempfile, urllib.request
58
+ with tempfile.NamedTemporaryFile(suffix=".py", delete=False) as f:
59
+ urllib.request.urlretrieve("https://bootstrap.pypa.io/get-pip.py", f.name)
60
+ subprocess.check_call(
61
+ [sys.executable, f.name, "--quiet", "--break-system-packages"])
62
+
63
+
64
+ def _ensure_dependencies():
65
+ missing = [pip for mod, pip in _REQUIRED_PACKAGES
66
+ if not importlib.util.find_spec(mod)]
67
+ if missing:
68
+ _ensure_pip()
69
+ subprocess.check_call(
70
+ [sys.executable, "-m", "pip", "install", "--quiet",
71
+ "--break-system-packages"] + missing)
72
+ importlib.invalidate_caches()
73
+ import site; site.main()
74
+
75
+ from playwright.sync_api import sync_playwright
76
+ try:
77
+ pw = sync_playwright().start()
78
+ pw.chromium.launch(headless=True).close()
79
+ pw.stop()
80
+ except Exception:
81
+ _install_system_deps()
82
+ env = os.environ.copy()
83
+ env["PLAYWRIGHT_SKIP_BROWSER_DOWNLOAD"] = "0"
84
+ env["DEBIAN_FRONTEND"] = "noninteractive"
85
+ try:
86
+ subprocess.check_call(
87
+ [sys.executable, "-m", "playwright", "install", "chromium"],
88
+ env=env)
89
+ except subprocess.CalledProcessError:
90
+ try:
91
+ pw2 = sync_playwright().start()
92
+ path = pw2.chromium.executable_path
93
+ pw2.stop()
94
+ if not os.path.isfile(path):
95
+ raise FileNotFoundError(path)
96
+ except Exception:
97
+ sys.exit(1)
98
+
99
+
100
+ def _install_system_deps():
101
+ _LIBS_YUM = [
102
+ "nss", "nspr", "atk", "at-spi2-atk", "at-spi2-core",
103
+ "libdrm", "libXcomposite", "libXdamage", "libXrandr",
104
+ "mesa-libgbm", "pango", "cups-libs", "libxkbcommon",
105
+ "alsa-lib", "libXfixes", "libxshmfence",
106
+ ]
107
+ _LIBS_APT = [
108
+ "libnss3", "libnspr4", "libatk1.0-0", "libatk-bridge2.0-0",
109
+ "libdrm2", "libxcomposite1", "libxdamage1", "libxrandr2",
110
+ "libgbm1", "libpango-1.0-0", "libcups2", "libxkbcommon0",
111
+ "libasound2", "libxfixes3", "libxshmfence1",
112
+ ]
113
+ for pkg_mgr, libs in [
114
+ (["yum", "install", "-y"], _LIBS_YUM),
115
+ (["dnf", "install", "-y"], _LIBS_YUM),
116
+ (["apt-get", "install", "-y"], _LIBS_APT),
117
+ ]:
118
+ try:
119
+ subprocess.check_call(
120
+ [pkg_mgr[0], "--version"],
121
+ stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL,
122
+ )
123
+ except (FileNotFoundError, subprocess.CalledProcessError):
124
+ continue
125
+ try:
126
+ subprocess.call(
127
+ pkg_mgr + libs,
128
+ stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL,
129
+ )
130
+ except Exception:
131
+ pass
132
+ break
133
+
134
+
135
+ # ============================================================
136
+ # Stdout: line-buffered / write-through so bridge-node sees lines immediately
137
+ # ============================================================
138
+ if hasattr(sys.stdout, "reconfigure"):
139
+ sys.stdout.reconfigure(write_through=True)
140
+ elif hasattr(sys.stdout, "buffer"):
141
+ import io as _io
142
+ sys.stdout = _io.TextIOWrapper(
143
+ sys.stdout.buffer, write_through=True,
144
+ encoding=sys.stdout.encoding, errors=sys.stdout.errors,
145
+ )
146
+
147
+ import json
148
+ import random
149
+ import signal
150
+ import ssl
151
+ import time
152
+ import uuid
153
+ import urllib.request
154
+ import urllib.error
155
+ import urllib.parse
156
+ from typing import Optional
157
+
158
+ # ============================================================
159
+ # Env-overridable config
160
+ # ============================================================
161
+ CDP_PORT = int(os.environ.get("FEISHU_BOT_CDP_PORT", "9222"))
162
+ STATE_DIR = os.environ.get("FEISHU_BOT_STATE_DIR", "/tmp")
163
+ OPENCLAW_CONFIG = os.environ.get(
164
+ "FEISHU_BOT_OPENCLAW_CONFIG", "/root/.openclaw/openclaw.json")
165
+ OPENCLAW_ALLOW_FROM = os.environ.get(
166
+ "FEISHU_BOT_OPENCLAW_ALLOW_FROM",
167
+ "/root/.openclaw/credentials/feishu-default-allowFrom.json")
168
+ OPENCLAW_ACCOUNT_ID = os.environ.get("FEISHU_BOT_ACCOUNT_ID", "default")
169
+
170
+ LOGIN_TIMEOUT = 90
171
+ POLL_INTERVAL = 2
172
+ QR_MAX_RETRIES = 3
173
+
174
+ # Bundled TnymaAI brand mark (the 512×512 Q-version mascot from
175
+ # tnyma-ai-installer). Sits next to this .py file in the tarball (tsup
176
+ # copies tools/feishu-bot-creator/assets/ → dist/feishu-bot-creator/assets/).
177
+ # Using the local file removes the dependency on ai.tnyma.com being
178
+ # reachable from the user's machine at create-time.
179
+ #
180
+ # Network URL kept as fallback only — if the bundled asset is somehow
181
+ # missing (developer running from source without copying assets, broken
182
+ # install), fall back to fetching over the wire.
183
+ BUNDLED_AVATAR_PATH = os.path.join(
184
+ os.path.dirname(os.path.abspath(__file__)), "assets", "tnyma-ai-avatar.png")
185
+ DEFAULT_AVATAR_URL = "https://ai.tnyma.com/assets/8-tight.png"
186
+
187
+ WEBSOCKET_POLL_INTERVAL = 3
188
+ WEBSOCKET_POLL_TIMEOUT = 60
189
+
190
+ PERMISSION_UPDATE_TIMEOUT_MS = int(
191
+ os.environ.get("FEISHU_BOT_PERMISSION_UPDATE_TIMEOUT_MS", "60000") or "60000")
192
+ PERMISSION_UPDATE_RETRY_DELAYS_SECONDS = [0, 5, 15, 30]
193
+
194
+ # ============================================================
195
+ # Platform configs
196
+ # ============================================================
197
+ PLATFORM = "feishu"
198
+
199
+ _PLATFORM_CONFIGS = {
200
+ "feishu": {
201
+ "base_url": "https://open.feishu.cn",
202
+ "login_url": (
203
+ "https://accounts.feishu.cn/accounts/page/login"
204
+ "?app_id=7&no_trap=1"
205
+ "&redirect_uri=https%3A%2F%2Fopen.feishu.cn%2Fapp"
206
+ ),
207
+ "accounts_host": "accounts.feishu.cn",
208
+ "open_host": "open.feishu.cn",
209
+ "admin_audit_url": "https://feishu.cn/admin/appCenter/audit",
210
+ "config_domain": "feishu",
211
+ "primary_lang": "zh_cn",
212
+ "default_greeting": "Hi,我是你刚刚使用 OpenClaw 创建的机器人,你现在可以跟我聊天了!",
213
+ "state_file_prefix": "feishu-bot",
214
+ "profile_dir_name": "feishu-bot-chrome-profile",
215
+ "qr_default": True,
216
+ },
217
+ "lark": {
218
+ "base_url": "https://open.larksuite.com",
219
+ "login_url": (
220
+ "https://accounts.larksuite.com/accounts/page/login"
221
+ "?app_id=7&no_trap=1"
222
+ "&redirect_uri=https%3A%2F%2Fopen.larksuite.com%2F%3Flang%3Dzh-CN"
223
+ ),
224
+ "accounts_host": "accounts.larksuite.com",
225
+ "open_host": "open.larksuite.com",
226
+ "admin_audit_url": "https://larksuite.com/admin/appCenter/audit",
227
+ "config_domain": "lark",
228
+ "primary_lang": "en_us",
229
+ "default_greeting": "Hi, I'm the bot you just created with OpenClaw. You can chat with me now!",
230
+ "state_file_prefix": "lark-bot",
231
+ "profile_dir_name": "lark-bot-chrome-profile",
232
+ "qr_default": False,
233
+ },
234
+ }
235
+
236
+
237
+ def _pcfg(key: str):
238
+ return _PLATFORM_CONFIGS[PLATFORM][key]
239
+
240
+
241
+ # Permissions
242
+ BOT_PERMISSIONS = [
243
+ "im:message", "im:message.p2p_msg:readonly",
244
+ "im:message.group_at_msg:readonly", "im:message:send_as_bot",
245
+ "im:resource", "im:message.group_msg", "im:message:readonly",
246
+ "im:message:update", "im:message:recall", "im:message.reactions:read",
247
+ "im:chat", "im:chat:readonly", "im:chat:read", "im:chat:update",
248
+ "contact:user.base:readonly", "contact:contact.base:readonly",
249
+ "docx:document:readonly", "docx:document", "docx:document.block:convert",
250
+ "wiki:wiki:readonly", "wiki:wiki",
251
+ "bitable:app:readonly", "bitable:app",
252
+ "task:task:read", "task:task:write",
253
+ ]
254
+
255
+ BOT_PERMISSIONS_NEED_AUDIT = [
256
+ "drive:drive:readonly",
257
+ "drive:drive",
258
+ ]
259
+
260
+
261
+ def _gen_bot_name() -> str:
262
+ # Feishu rejects duplicate app names within the same workspace, so
263
+ # we still suffix a 4-digit counter — but the brand string is fixed
264
+ # at TnymaAI (vs. the legacy OpenClaw default).
265
+ if PLATFORM == "lark":
266
+ return f"TnymaAI-{random.randint(1000, 9999)}"
267
+ return f"TnymaAI-{random.randint(1000, 9999)}"
268
+
269
+
270
+ def _state_file() -> str:
271
+ return os.path.join(STATE_DIR, f"{_pcfg('state_file_prefix')}-creator-state.json")
272
+
273
+
274
+ def _save_state(data: dict) -> None:
275
+ with open(_state_file(), "w") as f:
276
+ json.dump(data, f, ensure_ascii=False)
277
+
278
+
279
+ # ============================================================
280
+ # Structured JSON output (stdout)
281
+ # ============================================================
282
+ def _emit(action: str, level: str, step: str, message: str, **extra) -> None:
283
+ record = {"action": action, "level": level, "step": step,
284
+ "message": message, "ts": int(time.time())}
285
+ record.update(extra)
286
+ print(json.dumps(record, ensure_ascii=False))
287
+
288
+
289
+ def _log_info(step: str, message: str, **extra) -> None:
290
+ _emit("log", "info", step, message, **extra)
291
+
292
+
293
+ def _log_success(step: str, message: str, **extra) -> None:
294
+ _emit("log", "success", step, message, **extra)
295
+
296
+
297
+ def _log_warn(step: str, message: str, **extra) -> None:
298
+ _emit("log", "warn", step, message, **extra)
299
+
300
+
301
+ def _log_error(step: str, message: str, **extra) -> None:
302
+ _emit("log", "error", step, message, **extra)
303
+
304
+
305
+ def _emit_progress(step: str, message: str, current: int, total: int) -> None:
306
+ _emit("progress", "info", step, message, current=current, total=total)
307
+
308
+
309
+ def _emit_finish(message: str, data: dict) -> None:
310
+ _emit("finish", "success", "finish", message, data=data)
311
+
312
+
313
+ def _emit_error(step: str, message: str) -> None:
314
+ _emit("finish", "error", step, message)
315
+
316
+
317
+ def _download_avatar(avatar_url: str = "") -> str:
318
+ # When the caller passes an explicit --avatar-url, honour it (network
319
+ # fetch only). Otherwise prefer the bundled brand asset shipped next
320
+ # to this script — that's the canonical TnymaAI mascot and removes
321
+ # the dependency on ai.tnyma.com being reachable.
322
+ if not avatar_url and os.path.isfile(BUNDLED_AVATAR_PATH) \
323
+ and os.path.getsize(BUNDLED_AVATAR_PATH) > 0:
324
+ _log_info("create_app", f"使用内置品牌头像: {BUNDLED_AVATAR_PATH}")
325
+ return BUNDLED_AVATAR_PATH
326
+
327
+ url = avatar_url or DEFAULT_AVATAR_URL
328
+ avatar_path = os.path.join(STATE_DIR, f"{_pcfg('state_file_prefix')}-avatar.png")
329
+ if not avatar_url and os.path.isfile(avatar_path) and os.path.getsize(avatar_path) > 0:
330
+ _log_info("create_app", f"使用已缓存头像: {avatar_path}")
331
+ return avatar_path
332
+
333
+ _log_info("create_app", f"正在下载头像: {url}")
334
+ ctx = ssl.create_default_context()
335
+ ctx.check_hostname = False
336
+ ctx.verify_mode = ssl.CERT_NONE
337
+ req = urllib.request.Request(url, headers={"User-Agent": "Mozilla/5.0"})
338
+ try:
339
+ with urllib.request.urlopen(req, context=ctx) as resp:
340
+ data = resp.read()
341
+ with open(avatar_path, "wb") as f:
342
+ f.write(data)
343
+ _log_info("create_app", f"头像下载完成: {len(data)} bytes")
344
+ return avatar_path
345
+ except Exception as e:
346
+ _log_warn("create_app", f"头像下载失败: {e}")
347
+ return ""
348
+
349
+
350
+ def _write_openclaw_config(app_id: str, app_secret: str,
351
+ account_id: str | None = None) -> bool:
352
+ """Write channel config in the nested schema expected by bridge-node's
353
+ buildChannelPatch. Merge-only: each call adds/updates one account
354
+ under channels.feishu.accounts.<account_id>; other accounts are
355
+ preserved. Required so a team flow can create N+1 Feishu apps and
356
+ each agent gets its own appId/appSecret.
357
+
358
+ channels.feishu.{
359
+ enabled: true,
360
+ defaultAccount: <first-account-id-seen>,
361
+ accounts.{<account_id>}.{
362
+ enabled: true,
363
+ name: <account_id>,
364
+ appId, appSecret,
365
+ domain, groupPolicy
366
+ }
367
+ }
368
+ """
369
+ aid = (account_id or OPENCLAW_ACCOUNT_ID or "default").strip() or "default"
370
+ config_dir = os.path.dirname(OPENCLAW_CONFIG)
371
+ os.makedirs(config_dir, exist_ok=True)
372
+
373
+ config: dict = {}
374
+ if os.path.isfile(OPENCLAW_CONFIG):
375
+ try:
376
+ with open(OPENCLAW_CONFIG) as f:
377
+ config = json.load(f)
378
+ except (json.JSONDecodeError, IOError):
379
+ pass
380
+
381
+ channels = config.setdefault("channels", {})
382
+ feishu = channels.setdefault("feishu", {})
383
+ feishu["enabled"] = True
384
+ # Only set defaultAccount when it's missing — don't clobber the
385
+ # controller's claim when later members write their entries.
386
+ feishu.setdefault("defaultAccount", aid)
387
+ accounts = feishu.setdefault("accounts", {})
388
+ accounts[aid] = {
389
+ "enabled": True,
390
+ "name": aid,
391
+ "appId": app_id,
392
+ "appSecret": app_secret,
393
+ "domain": _pcfg("config_domain"),
394
+ "groupPolicy": "open",
395
+ }
396
+
397
+ # Atomic write: write to .tmp first then os.replace. The gateway
398
+ # uses a file watcher to detect config changes; if we wrote directly
399
+ # to OPENCLAW_CONFIG the watcher could fire mid-write and pick up
400
+ # a truncated JSON. os.replace is atomic on POSIX so readers see
401
+ # either the old file or the new one, never a half-written one.
402
+ tmp = OPENCLAW_CONFIG + ".tmp"
403
+ try:
404
+ with open(tmp, "w") as f:
405
+ json.dump(config, f, ensure_ascii=False, indent=2)
406
+ os.replace(tmp, OPENCLAW_CONFIG)
407
+ _log_success(
408
+ "config",
409
+ f"已写入配置 (account={aid}, domain={_pcfg('config_domain')})")
410
+ return True
411
+ except IOError as e:
412
+ try:
413
+ os.remove(tmp)
414
+ except OSError:
415
+ pass
416
+ _log_error("config", f"写入配置文件失败: {e}")
417
+ return False
418
+
419
+
420
+ def _allow_from_path_for_account(account_id: str | None = None) -> str:
421
+ aid = (account_id or OPENCLAW_ACCOUNT_ID or "default").strip() or "default"
422
+ return OPENCLAW_ALLOW_FROM.replace(
423
+ f"feishu-{OPENCLAW_ACCOUNT_ID}-allowFrom.json",
424
+ f"feishu-{aid}-allowFrom.json",
425
+ )
426
+
427
+
428
+ def _write_allow_from(open_id: str, account_id: str | None = None) -> bool:
429
+ """Write the per-account allowFrom file. account_id is interpolated
430
+ into the path replacing the global default, so each Feishu bot in a
431
+ team gets its own ~/.openclaw/credentials/feishu-<id>-allowFrom.json.
432
+ """
433
+ aid = (account_id or OPENCLAW_ACCOUNT_ID or "default").strip() or "default"
434
+ target_path = _allow_from_path_for_account(aid)
435
+ _emit("write_config", "info", "config", "写入 allowFrom",
436
+ path=target_path)
437
+
438
+ config_dir = os.path.dirname(target_path)
439
+ os.makedirs(config_dir, exist_ok=True)
440
+
441
+ data = {"version": 1, "allowFrom": [open_id]}
442
+
443
+ # Same atomic write strategy as _write_openclaw_config.
444
+ tmp = target_path + ".tmp"
445
+ try:
446
+ with open(tmp, "w") as f:
447
+ json.dump(data, f, ensure_ascii=False, indent=2)
448
+ os.replace(tmp, target_path)
449
+ _log_success("config", f"已写入 allowFrom (account={aid}, open_id={open_id})")
450
+ return True
451
+ except IOError as e:
452
+ try:
453
+ os.remove(tmp)
454
+ except OSError:
455
+ pass
456
+ _log_error("config", f"写入 allowFrom 失败: {e}")
457
+ return False
458
+
459
+
460
+ def _cleanup_openclaw_feishu_accounts(account_ids: list[str]) -> None:
461
+ ids = [aid for aid in _dedupe_keep_order(account_ids) if aid != "main"]
462
+ if not ids:
463
+ return
464
+
465
+ removed_accounts: list[str] = []
466
+ config: dict = {}
467
+ if os.path.isfile(OPENCLAW_CONFIG):
468
+ try:
469
+ with open(OPENCLAW_CONFIG) as f:
470
+ config = json.load(f)
471
+ except (json.JSONDecodeError, IOError) as e:
472
+ _log_warn("config", f"回滚配置失败,无法读取 openclaw.json: {e}")
473
+ config = {}
474
+
475
+ channels = config.get("channels")
476
+ feishu = channels.get("feishu") if isinstance(channels, dict) else None
477
+ accounts = feishu.get("accounts") if isinstance(feishu, dict) else None
478
+ if isinstance(accounts, dict):
479
+ for aid in ids:
480
+ if accounts.pop(aid, None) is not None:
481
+ removed_accounts.append(aid)
482
+ if feishu.get("defaultAccount") in ids:
483
+ feishu["defaultAccount"] = next(iter(accounts.keys()), None)
484
+ if feishu["defaultAccount"] is None:
485
+ feishu.pop("defaultAccount", None)
486
+
487
+ if removed_accounts:
488
+ tmp = OPENCLAW_CONFIG + ".tmp"
489
+ try:
490
+ with open(tmp, "w") as f:
491
+ json.dump(config, f, ensure_ascii=False, indent=2)
492
+ os.replace(tmp, OPENCLAW_CONFIG)
493
+ _log_warn("config", f"已回滚本次失败流程写入的飞书账号: {removed_accounts}")
494
+ except IOError as e:
495
+ try:
496
+ os.remove(tmp)
497
+ except OSError:
498
+ pass
499
+ _log_warn("config", f"回滚 openclaw.json 失败: {e}")
500
+
501
+ for aid in ids:
502
+ path = _allow_from_path_for_account(aid)
503
+ try:
504
+ if os.path.isfile(path):
505
+ os.remove(path)
506
+ _log_warn("config", f"已删除失败流程残留 allowFrom: {path}")
507
+ except OSError as e:
508
+ _log_warn("config", f"删除 allowFrom 残留失败: {path}: {e}")
509
+
510
+
511
+ def _send_greeting(app_id: str, app_secret: str, open_id: str, greeting: str = "") -> None:
512
+ _log_info("greeting", "发送初始问候消息")
513
+ greeting = greeting or _pcfg("default_greeting")
514
+ base_url = _pcfg("base_url")
515
+
516
+ ctx = ssl.create_default_context()
517
+ ctx.check_hostname = False
518
+ ctx.verify_mode = ssl.CERT_NONE
519
+
520
+ token_payload = json.dumps({
521
+ "app_id": app_id, "app_secret": app_secret,
522
+ }).encode()
523
+ token_req = urllib.request.Request(
524
+ f"{base_url}/open-apis/auth/v3/tenant_access_token/internal",
525
+ data=token_payload,
526
+ headers={"Content-Type": "application/json"},
527
+ )
528
+ try:
529
+ with urllib.request.urlopen(token_req, context=ctx) as resp:
530
+ token_data = json.loads(resp.read())
531
+ token = token_data.get("tenant_access_token")
532
+ if not token:
533
+ return
534
+ except Exception:
535
+ return
536
+
537
+ send_payload = json.dumps({
538
+ "receive_id": open_id,
539
+ "msg_type": "text",
540
+ "content": json.dumps({"text": greeting}),
541
+ "uuid": str(uuid.uuid4()),
542
+ }).encode()
543
+ send_req = urllib.request.Request(
544
+ f"{base_url}/open-apis/im/v1/messages?receive_id_type=open_id",
545
+ data=send_payload,
546
+ headers={
547
+ "Content-Type": "application/json",
548
+ "Authorization": f"Bearer {token}",
549
+ },
550
+ )
551
+ try:
552
+ with urllib.request.urlopen(send_req, context=ctx) as resp:
553
+ resp.read()
554
+ except Exception:
555
+ pass
556
+
557
+
558
+ def _fetch_tenant_access_token(app_id: str, app_secret: str) -> str:
559
+ base_url = _pcfg("base_url")
560
+ ctx = ssl.create_default_context()
561
+ ctx.check_hostname = False
562
+ ctx.verify_mode = ssl.CERT_NONE
563
+
564
+ payload = json.dumps({
565
+ "app_id": app_id, "app_secret": app_secret,
566
+ }).encode()
567
+ req = urllib.request.Request(
568
+ f"{base_url}/open-apis/auth/v3/tenant_access_token/internal",
569
+ data=payload,
570
+ headers={"Content-Type": "application/json; charset=utf-8"},
571
+ method="POST",
572
+ )
573
+ try:
574
+ with urllib.request.urlopen(req, context=ctx) as resp:
575
+ body = json.loads(resp.read())
576
+ except Exception as e:
577
+ raise RuntimeError(f"获取 tenant_access_token 失败: {e}") from e
578
+
579
+ token = body.get("tenant_access_token")
580
+ if not token:
581
+ raise RuntimeError(
582
+ f"获取 tenant_access_token 失败: {body.get('msg') or body}")
583
+ return token
584
+
585
+
586
+ def _request_feishu_json(method: str, path: str, token: str,
587
+ payload: Optional[dict] = None,
588
+ query: Optional[dict] = None) -> dict:
589
+ base_url = _pcfg("base_url")
590
+ query_string = urllib.parse.urlencode(query or {})
591
+ url = f"{base_url}/open-apis{path}"
592
+ if query_string:
593
+ url = f"{url}?{query_string}"
594
+
595
+ data = None
596
+ if payload is not None:
597
+ data = json.dumps(payload, ensure_ascii=False).encode()
598
+
599
+ req = urllib.request.Request(
600
+ url,
601
+ data=data,
602
+ headers={
603
+ "Content-Type": "application/json; charset=utf-8",
604
+ "Authorization": f"Bearer {token}",
605
+ },
606
+ method=method,
607
+ )
608
+ ctx = ssl.create_default_context()
609
+ ctx.check_hostname = False
610
+ ctx.verify_mode = ssl.CERT_NONE
611
+
612
+ try:
613
+ with urllib.request.urlopen(req, context=ctx) as resp:
614
+ raw = resp.read().decode("utf-8")
615
+ return json.loads(raw or "{}")
616
+ except urllib.error.HTTPError as e:
617
+ try:
618
+ detail = e.read().decode("utf-8")
619
+ except Exception:
620
+ detail = str(e)
621
+ raise RuntimeError(f"{method} {path} HTTP {e.code}: {detail}") from e
622
+ except Exception as e:
623
+ raise RuntimeError(f"{method} {path} 失败: {e}") from e
624
+
625
+
626
+ def _require_feishu_ok(action: str, body: dict) -> dict:
627
+ if body.get("code") == 0:
628
+ return body.get("data", {}) or {}
629
+ raise RuntimeError(f"{action} 失败: code={body.get('code')}, msg={body.get('msg')}")
630
+
631
+
632
+ def _dedupe_keep_order(values: list[str]) -> list[str]:
633
+ seen = set()
634
+ result: list[str] = []
635
+ for value in values:
636
+ item = (value or "").strip()
637
+ if not item or item in seen:
638
+ continue
639
+ seen.add(item)
640
+ result.append(item)
641
+ return result
642
+
643
+
644
+ TEAM_GROUP_BOT_PROPAGATION_WAIT_SECONDS = int(
645
+ os.environ.get("TNYMA_FEISHU_TEAM_GROUP_BOT_WAIT_SECONDS", "0") or "0")
646
+ TEAM_GROUP_MEMBER_RETRY_DELAYS_SECONDS = [0, 5, 10, 20]
647
+ TEAM_GROUP_CREATE_RETRY_DELAYS_SECONDS = [0, 3, 8, 15]
648
+
649
+
650
+ def _is_retryable_feishu_team_group_error(error: Exception) -> bool:
651
+ message = str(error)
652
+ return (
653
+ "HTTP 500" in message
654
+ or "Internal Error" in message
655
+ or "code=2200" in message
656
+ or "Too Many Requests" in message
657
+ or "HTTP 429" in message
658
+ )
659
+
660
+
661
+ def _join_feishu_chat(token: str, chat_id: str) -> None:
662
+ body = _request_feishu_json(
663
+ "PATCH",
664
+ f"/im/v1/chats/{chat_id}/members/me_join",
665
+ token,
666
+ )
667
+ _require_feishu_ok("协作机器人加入团队群", body)
668
+
669
+
670
+ def _join_feishu_team_group_members_with_retry(
671
+ chat_id: str, member_app_ids: list[str],
672
+ app_secret_by_id: dict[str, str],
673
+ label_by_id: dict[str, str]) -> list[str]:
674
+ members = _dedupe_keep_order(member_app_ids)
675
+ if not members:
676
+ return []
677
+ pending = members[:]
678
+ last_errors: dict[str, Exception] = {}
679
+ member_errors: list[str] = []
680
+ joined_count = 0
681
+ for attempt, delay_seconds in enumerate(TEAM_GROUP_MEMBER_RETRY_DELAYS_SECONDS, start=1):
682
+ if delay_seconds > 0:
683
+ _emit("progress", "warn", "team_group/member_retry",
684
+ f"{len(pending)} 个协作机器人暂未入群,{delay_seconds}s 后重试 "
685
+ f"({attempt}/{len(TEAM_GROUP_MEMBER_RETRY_DELAYS_SECONDS)})")
686
+ time.sleep(delay_seconds)
687
+ next_pending: list[str] = []
688
+ for app_id in pending:
689
+ label = label_by_id.get(app_id) or app_id
690
+ app_secret = (app_secret_by_id.get(app_id) or "").strip()
691
+ if not app_secret:
692
+ member_errors.append(f"{label}: 缺少 app_secret,无法加入群")
693
+ continue
694
+ try:
695
+ member_token = _fetch_tenant_access_token(app_id, app_secret)
696
+ _join_feishu_chat(member_token, chat_id)
697
+ joined_count += 1
698
+ _emit("progress", "success", "team_group/member",
699
+ f"已拉入协作机器人:{label}")
700
+ except Exception as e:
701
+ last_errors[app_id] = e
702
+ if _is_retryable_feishu_team_group_error(e):
703
+ next_pending.append(app_id)
704
+ else:
705
+ member_errors.append(f"{label}: 加入群失败: {e}")
706
+ pending = next_pending
707
+ if not pending:
708
+ break
709
+
710
+ for app_id in pending:
711
+ label = label_by_id.get(app_id) or app_id
712
+ member_errors.append(
713
+ f"{label}: 加入群失败: {last_errors.get(app_id)}")
714
+ if joined_count:
715
+ _emit("progress", "success", "team_group/members",
716
+ f"已拉入 {joined_count}/{len(members)} 个协作机器人")
717
+ return member_errors
718
+
719
+
720
+ def _create_feishu_team_chat_with_retry(token: str, payload: dict,
721
+ query: dict) -> dict:
722
+ last_error: Exception | None = None
723
+ for attempt, delay_seconds in enumerate(TEAM_GROUP_CREATE_RETRY_DELAYS_SECONDS, start=1):
724
+ if delay_seconds > 0:
725
+ _emit("progress", "warn", "team_group/create_retry",
726
+ f"飞书团队群创建暂未成功,{delay_seconds}s 后重试 "
727
+ f"({attempt}/{len(TEAM_GROUP_CREATE_RETRY_DELAYS_SECONDS)})")
728
+ time.sleep(delay_seconds)
729
+ try:
730
+ body = _request_feishu_json(
731
+ "POST",
732
+ "/im/v1/chats",
733
+ token,
734
+ payload,
735
+ query,
736
+ )
737
+ return _require_feishu_ok("创建团队群", body)
738
+ except Exception as e:
739
+ last_error = e
740
+ if not _is_retryable_feishu_team_group_error(e):
741
+ break
742
+ raise RuntimeError(f"创建团队群失败: {last_error}") from last_error
743
+
744
+
745
+ def _send_feishu_chat_message(token: str, chat_id: str, text: str) -> None:
746
+ body = _request_feishu_json(
747
+ "POST",
748
+ "/im/v1/messages",
749
+ token,
750
+ {
751
+ "receive_id": chat_id,
752
+ "msg_type": "text",
753
+ "content": json.dumps({"text": text}, ensure_ascii=False),
754
+ "uuid": str(uuid.uuid4()),
755
+ },
756
+ {"receive_id_type": "chat_id"},
757
+ )
758
+ _require_feishu_ok("发送群消息", body)
759
+
760
+
761
+ def _create_team_group(all_results: list[dict]) -> Optional[dict]:
762
+ if len(all_results) <= 1:
763
+ return None
764
+
765
+ operator = all_results[0]
766
+ operator_app_id = (operator.get("app_id") or "").strip()
767
+ operator_app_secret = (operator.get("app_secret") or "").strip()
768
+ owner_open_id = next(
769
+ ((r.get("open_id") or "").strip() for r in all_results
770
+ if (r.get("open_id") or "").strip()),
771
+ "",
772
+ )
773
+ owner_name = next(
774
+ ((r.get("owner_name") or "").strip() for r in all_results
775
+ if (r.get("owner_name") or "").strip()),
776
+ "用户",
777
+ )
778
+ bot_names = _dedupe_keep_order([
779
+ (r.get("bot_name") or r.get("account_id") or "").strip()
780
+ for r in all_results
781
+ ])
782
+ bot_app_ids = _dedupe_keep_order([
783
+ (r.get("app_id") or "").strip() for r in all_results
784
+ ])
785
+ bot_name_by_app_id = {
786
+ (r.get("app_id") or "").strip():
787
+ (r.get("bot_name") or r.get("account_id") or "").strip()
788
+ for r in all_results
789
+ if (r.get("app_id") or "").strip()
790
+ }
791
+ bot_secret_by_app_id = {
792
+ (r.get("app_id") or "").strip(): (r.get("app_secret") or "").strip()
793
+ for r in all_results
794
+ if (r.get("app_id") or "").strip()
795
+ }
796
+
797
+ if not operator_app_id or not operator_app_secret:
798
+ raise RuntimeError("缺少团队主控机器人的 app_id/app_secret")
799
+ if not owner_open_id:
800
+ raise RuntimeError("缺少用户 open_id,无法把用户拉入团队群")
801
+
802
+ title = " + ".join([*bot_names, owner_name]).strip() or "Tnyma AI Team"
803
+ _emit("progress", "info", "team_group/start",
804
+ f"创建飞书团队群:{title}")
805
+
806
+ token = _fetch_tenant_access_token(operator_app_id, operator_app_secret)
807
+ invited_bot_app_ids = [
808
+ app_id for app_id in bot_app_ids if app_id != operator_app_id
809
+ ]
810
+ create_payload = {
811
+ "name": title,
812
+ "description": f"Tnyma AI 智能体团队:{title}",
813
+ "owner_id": owner_open_id,
814
+ "chat_mode": "group",
815
+ "chat_type": "public",
816
+ "external": False,
817
+ "join_message_visibility": "all_members",
818
+ "leave_message_visibility": "all_members",
819
+ "membership_approval": "no_approval_required",
820
+ }
821
+
822
+ data = _create_feishu_team_chat_with_retry(
823
+ token,
824
+ create_payload,
825
+ {
826
+ "user_id_type": "open_id",
827
+ "set_bot_manager": "true",
828
+ "uuid": str(uuid.uuid4()),
829
+ },
830
+ )
831
+ chat_id = (data.get("chat_id") or "").strip()
832
+ if not chat_id:
833
+ raise RuntimeError(f"创建团队群未返回 chat_id: {data}")
834
+
835
+ member_errors: list[str] = []
836
+ if invited_bot_app_ids:
837
+ if TEAM_GROUP_BOT_PROPAGATION_WAIT_SECONDS > 0:
838
+ _emit("progress", "info", "team_group/member_wait",
839
+ "等待飞书发布结果同步后拉协作机器人入群",
840
+ seconds=TEAM_GROUP_BOT_PROPAGATION_WAIT_SECONDS)
841
+ time.sleep(TEAM_GROUP_BOT_PROPAGATION_WAIT_SECONDS)
842
+ member_errors.extend(_join_feishu_team_group_members_with_retry(
843
+ chat_id,
844
+ invited_bot_app_ids,
845
+ bot_secret_by_app_id,
846
+ bot_name_by_app_id,
847
+ ))
848
+ for error in member_errors:
849
+ _log_warn("team_group", error)
850
+
851
+ if member_errors:
852
+ _emit("progress", "warn", "team_group/members",
853
+ f"飞书群已创建,但 {len(member_errors)} 个协作机器人未拉入群",
854
+ chat_id=chat_id, errors=member_errors)
855
+ else:
856
+ _emit("progress", "success", "team_group/members",
857
+ f"已配置 {len(invited_bot_app_ids)} 个协作机器人入群",
858
+ chat_id=chat_id)
859
+ message_error = ""
860
+ try:
861
+ _send_feishu_chat_message(token, chat_id, f"团队群已创建:{title}")
862
+ except Exception as e:
863
+ message_error = str(e)
864
+ _log_warn("team_group", f"团队群消息发送失败: {message_error}")
865
+ _emit("progress", "warn", "team_group/message",
866
+ f"团队群已创建,但群消息发送失败: {message_error}",
867
+ chat_id=chat_id)
868
+
869
+ level = "warn" if member_errors else "success"
870
+ _emit("progress", level, "team_group/done",
871
+ f"飞书团队群已创建:{title}", chat_id=chat_id,
872
+ errors=member_errors)
873
+ return {
874
+ "chat_id": chat_id,
875
+ "name": title,
876
+ "owner_open_id": owner_open_id,
877
+ "owner_name": owner_name,
878
+ "bot_app_ids": bot_app_ids,
879
+ "invited_bot_app_ids": invited_bot_app_ids,
880
+ "create_bot_app_ids": [],
881
+ "fallback_bot_app_ids": invited_bot_app_ids,
882
+ "member_errors": member_errors,
883
+ "message_error": message_error,
884
+ }
885
+
886
+
887
+ def _kill_cdp_browser():
888
+ sf = _state_file()
889
+ if os.path.isfile(sf):
890
+ try:
891
+ with open(sf) as f:
892
+ data = json.load(f)
893
+ pid = data.get("chrome_pid")
894
+ if pid:
895
+ os.kill(int(pid), signal.SIGKILL)
896
+ except (json.JSONDecodeError, OSError, ProcessLookupError):
897
+ pass
898
+
899
+ try:
900
+ out = subprocess.check_output(
901
+ ["lsof", "-ti", f":{CDP_PORT}"], stderr=subprocess.DEVNULL
902
+ ).decode().strip()
903
+ if out:
904
+ for pid in out.split("\n"):
905
+ pid = pid.strip()
906
+ if pid:
907
+ try:
908
+ os.kill(int(pid), signal.SIGKILL)
909
+ except (OSError, ProcessLookupError):
910
+ pass
911
+ except (subprocess.CalledProcessError, FileNotFoundError):
912
+ pass
913
+
914
+ profile_dir = os.path.join(STATE_DIR, _pcfg("profile_dir_name"))
915
+ for lock_file in ("SingletonLock", "SingletonSocket", "SingletonCookie"):
916
+ p = os.path.join(profile_dir, lock_file)
917
+ if os.path.exists(p):
918
+ try:
919
+ os.remove(p)
920
+ except OSError:
921
+ pass
922
+
923
+
924
+ # ============================================================
925
+ # BotCreator
926
+ # ============================================================
927
+ class FeishuBotCreator:
928
+
929
+ def __init__(self, page):
930
+ self.page = page
931
+ self.csrf_token: Optional[str] = None
932
+ self.app_id: Optional[str] = None
933
+ self.app_secret: Optional[str] = None
934
+ self.version_id: Optional[str] = None
935
+ self.owner_name = ""
936
+ self._base_url = _pcfg("base_url")
937
+ self._api_base = f"{self._base_url}/developers/v1"
938
+ self._app_page = f"{self._base_url}/app"
939
+ self._open_host = _pcfg("open_host")
940
+
941
+ def install_network_capture(self) -> None:
942
+ open_host = self._open_host
943
+
944
+ def _on_request(req):
945
+ if open_host not in req.url:
946
+ return
947
+ token = req.headers.get("x-csrf-token") or req.headers.get("X-CSRF-Token")
948
+ if token:
949
+ self.csrf_token = token
950
+
951
+ self.page.on("request", _on_request)
952
+
953
+ def _csrf(self) -> Optional[str]:
954
+ if self.csrf_token:
955
+ return self.csrf_token
956
+ try:
957
+ token = self.page.evaluate("window.csrfToken || ''")
958
+ if token:
959
+ self.csrf_token = token
960
+ return token
961
+ except Exception:
962
+ pass
963
+ try:
964
+ cookies = {c["name"]: c["value"]
965
+ for c in self.page.context.cookies([self._base_url])}
966
+ token = (cookies.get("lark_oapi_csrf_token")
967
+ or cookies.get("lgw_csrf_token")
968
+ or cookies.get("swp_csrf_token"))
969
+ if token:
970
+ self.csrf_token = token
971
+ return token
972
+ except Exception:
973
+ return None
974
+
975
+ def _headers(self, *, with_body: bool = False) -> dict:
976
+ h = {"accept": "*/*", "x-timezone-offset": "-480"}
977
+ if with_body:
978
+ h.update({"content-type": "application/json",
979
+ "origin": self._base_url, "referer": self._app_page})
980
+ csrf = self._csrf()
981
+ if csrf:
982
+ h["x-csrf-token"] = csrf
983
+ return h
984
+
985
+ def _post(self, url: str, payload: dict, *,
986
+ timeout_ms: int | None = None,
987
+ log_step: str = "") -> Optional[dict]:
988
+ try:
989
+ kwargs = {
990
+ "data": payload,
991
+ "headers": self._headers(with_body=True),
992
+ }
993
+ if timeout_ms is not None:
994
+ kwargs["timeout"] = timeout_ms
995
+ resp = self.page.request.post(url, **kwargs)
996
+ return resp.json()
997
+ except Exception as e:
998
+ if log_step:
999
+ message = str(e).splitlines()[0][:240]
1000
+ _log_warn(log_step, f"请求失败: {type(e).__name__}: {message}")
1001
+ return None
1002
+
1003
+ def _get(self, url: str) -> Optional[dict]:
1004
+ try:
1005
+ return self.page.request.get(url, headers=self._headers()).json()
1006
+ except Exception:
1007
+ return None
1008
+
1009
+ def _ok(self, body: Optional[dict], step: str, log_step: str = "") -> Optional[dict]:
1010
+ if body is None:
1011
+ return None
1012
+ if body.get("code") != 0:
1013
+ if log_step:
1014
+ _log_error(log_step, f"{step}失败: code={body.get('code')}, msg={body.get('msg')}")
1015
+ return None
1016
+ return body
1017
+
1018
+ @staticmethod
1019
+ def _build_multipart(fields: dict, files: dict):
1020
+ boundary = f"----WebKitFormBoundary{uuid.uuid4().hex[:16]}"
1021
+ parts = []
1022
+ for key, value in fields.items():
1023
+ parts.append(f"--{boundary}\r\n".encode())
1024
+ parts.append(f'Content-Disposition: form-data; name="{key}"\r\n\r\n'.encode())
1025
+ parts.append(f"{value}\r\n".encode())
1026
+ for key, (filename, data, content_type) in files.items():
1027
+ parts.append(f"--{boundary}\r\n".encode())
1028
+ parts.append(f'Content-Disposition: form-data; name="{key}"; filename="{filename}"\r\n'.encode())
1029
+ parts.append(f"Content-Type: {content_type}\r\n\r\n".encode())
1030
+ parts.append(data)
1031
+ parts.append(b"\r\n")
1032
+ parts.append(f"--{boundary}--\r\n".encode())
1033
+ return b"".join(parts), f"multipart/form-data; boundary={boundary}"
1034
+
1035
+ def _upload_avatar(self, avatar_path: str) -> Optional[str]:
1036
+ with open(avatar_path, "rb") as f:
1037
+ img_data = f.read()
1038
+
1039
+ csrf = self._csrf()
1040
+ if not csrf:
1041
+ return None
1042
+
1043
+ browser_cookies = self.page.context.cookies([self._base_url])
1044
+ cookie_str = "; ".join(f"{c['name']}={c['value']}" for c in browser_cookies)
1045
+
1046
+ body, content_type = self._build_multipart(
1047
+ fields={
1048
+ "uploadType": "4",
1049
+ "isIsv": "false",
1050
+ "scale": '{"width":240,"height":240}',
1051
+ },
1052
+ files={
1053
+ "file": (str(uuid.uuid4()), img_data, "image/png"),
1054
+ },
1055
+ )
1056
+
1057
+ headers = {
1058
+ "Accept": "*/*",
1059
+ "Content-Type": content_type,
1060
+ "Cookie": cookie_str,
1061
+ "Origin": self._base_url,
1062
+ "Referer": self._app_page,
1063
+ "User-Agent": ("Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) "
1064
+ "AppleWebKit/537.36 (KHTML, like Gecko) "
1065
+ "Chrome/145.0.0.0 Safari/537.36"),
1066
+ "x-csrf-token": csrf,
1067
+ "x-timezone-offset": "-480",
1068
+ }
1069
+
1070
+ ssl_ctx = ssl.create_default_context()
1071
+ ssl_ctx.check_hostname = False
1072
+ ssl_ctx.verify_mode = ssl.CERT_NONE
1073
+
1074
+ req = urllib.request.Request(
1075
+ f"{self._api_base}/app/upload/image",
1076
+ data=body, headers=headers, method="POST",
1077
+ )
1078
+ try:
1079
+ with urllib.request.urlopen(req, context=ssl_ctx) as resp:
1080
+ result = json.loads(resp.read())
1081
+ except urllib.error.HTTPError as e:
1082
+ e.read().decode()
1083
+ return None
1084
+ except Exception:
1085
+ return None
1086
+
1087
+ if result.get("code") != 0:
1088
+ return None
1089
+
1090
+ url = result["data"].get("url", "")
1091
+ return url
1092
+
1093
+ def step1_create_app(self, name: str, desc: str, avatar_path: str) -> bool:
1094
+ _log_info("create_app", f"创建企业自建应用: {name}")
1095
+ if avatar_path and os.path.isfile(avatar_path):
1096
+ _log_info("create_app", f"上传图标: {os.path.basename(avatar_path)}")
1097
+ avatar_url = self._upload_avatar(avatar_path) or ""
1098
+ else:
1099
+ avatar_url = ""
1100
+
1101
+ lang = _pcfg("primary_lang")
1102
+ body = self._post(f"{self._api_base}/app/create", {
1103
+ "appSceneType": 0, "name": name, "desc": desc,
1104
+ "avatar": avatar_url,
1105
+ "i18n": {lang: {"name": name, "description": desc}},
1106
+ "primaryLang": lang,
1107
+ })
1108
+ if not self._ok(body, "创建应用", "create_app"):
1109
+ return False
1110
+ self.app_id = body["data"]["ClientID"]
1111
+ _log_success("create_app", "创建应用成功", app_id=self.app_id)
1112
+ return True
1113
+
1114
+ def step2_get_credentials(self) -> bool:
1115
+ _log_info("credential", "获取应用凭证")
1116
+ body = self._get(f"{self._api_base}/secret/{self.app_id}")
1117
+ if not self._ok(body, "获取 App Secret", "credential"):
1118
+ return False
1119
+ d = body.get("data", {})
1120
+ self.app_secret = (d.get("appSecret") or d.get("app_secret")
1121
+ or d.get("secret") or d.get("AppSecret"))
1122
+ if not self.app_secret:
1123
+ _log_error("credential", f"未找到 App Secret, keys={list(d.keys())}")
1124
+ return False
1125
+ # NOTE: app_secret is logged here for the script's standalone debugging
1126
+ # mode only. When invoked by bridge-node, the wrapper strips this from
1127
+ # the SSE stream before it reaches the client.
1128
+ _log_success("credential", "获取凭证成功",
1129
+ app_id=self.app_id, app_secret=self.app_secret)
1130
+ return True
1131
+
1132
+ def step3_add_bot(self) -> bool:
1133
+ _log_info("bot_ability", "添加机器人能力")
1134
+ result = self._ok(
1135
+ self._post(f"{self._api_base}/robot/switch/{self.app_id}", {"enable": True}),
1136
+ "开启机器人能力", "bot_ability")
1137
+ if result is not None:
1138
+ _log_success("bot_ability", "开启机器人能力成功")
1139
+ return True
1140
+ return False
1141
+
1142
+ def step4_event_mode(self) -> bool:
1143
+ _log_info("event_mode", "切换事件模式为长连接 (WebSocket)")
1144
+ deadline = time.time() + WEBSOCKET_POLL_TIMEOUT
1145
+ attempt = 0
1146
+ total = WEBSOCKET_POLL_TIMEOUT // WEBSOCKET_POLL_INTERVAL
1147
+ while time.time() < deadline:
1148
+ attempt += 1
1149
+ body = self._post(f"{self._api_base}/event/switch/{self.app_id}", {"eventMode": 4})
1150
+ if body and body.get("code") == 10068:
1151
+ _emit_progress("event_mode", "等待 WebSocket 连接...",
1152
+ current=attempt, total=total)
1153
+ time.sleep(WEBSOCKET_POLL_INTERVAL)
1154
+ continue
1155
+ if self._ok(body, "切换事件模式 → WebSocket(4)", "event_mode") is not None:
1156
+ _log_success("event_mode", "切换事件模式成功")
1157
+ return True
1158
+ return False
1159
+ _log_error("event_mode", f"等待 WebSocket 连接超时 ({WEBSOCKET_POLL_TIMEOUT}s)")
1160
+ return False
1161
+
1162
+ def step5_add_event(self) -> bool:
1163
+ _log_info("event", "添加「接收消息」事件")
1164
+ ev = self._get(f"{self._api_base}/event/{self.app_id}")
1165
+ mode = ev.get("data", {}).get("eventMode", 1) if ev and ev.get("code") == 0 else 1
1166
+ body = self._post(f"{self._api_base}/event/update/{self.app_id}", {
1167
+ "operation": "add",
1168
+ "events": ["im.message.receive_v1"],
1169
+ "eventMode": mode,
1170
+ })
1171
+ if not self._ok(body, "添加 im.message.receive_v1", "event"):
1172
+ return False
1173
+ verify = self._get(f"{self._api_base}/event/{self.app_id}")
1174
+ if verify and verify.get("code") == 0:
1175
+ events = verify["data"].get("events", [])
1176
+ if "im.message.receive_v1" in events:
1177
+ _log_success("event", "添加 im.message.receive_v1 成功")
1178
+ else:
1179
+ _log_warn("event", f"事件列表中未找到 im.message.receive_v1: {events}")
1180
+ return True
1181
+
1182
+ def step6_callback_mode(self) -> bool:
1183
+ _log_info("callback", "配置长连接接收回调")
1184
+ deadline = time.time() + WEBSOCKET_POLL_TIMEOUT
1185
+ attempt = 0
1186
+ total = WEBSOCKET_POLL_TIMEOUT // WEBSOCKET_POLL_INTERVAL
1187
+ while time.time() < deadline:
1188
+ attempt += 1
1189
+ body = self._post(f"{self._api_base}/callback/switch/{self.app_id}", {"callbackMode": 4})
1190
+ if body and body.get("code") == 10068:
1191
+ _emit_progress("callback", "等待 WebSocket 连接...",
1192
+ current=attempt, total=total)
1193
+ time.sleep(WEBSOCKET_POLL_INTERVAL)
1194
+ continue
1195
+ if self._ok(body, "切换回调模式 → 长连接(4)", "callback") is not None:
1196
+ _log_success("callback", "切换回调模式成功")
1197
+ return True
1198
+ return False
1199
+ _log_error("callback", f"等待 WebSocket 连接超时 ({WEBSOCKET_POLL_TIMEOUT}s)")
1200
+ return False
1201
+
1202
+ def step7_permissions(self) -> bool:
1203
+ _log_info("basic_perm", "批量导入权限")
1204
+ body = self._get(f"{self._api_base}/scope/all/{self.app_id}")
1205
+ if not self._ok(body, "获取权限列表", "basic_perm"):
1206
+ return False
1207
+ name_to_id = {}
1208
+ for s in body.get("data", {}).get("scopes", []):
1209
+ name = s.get("name") or s.get("scopeName", "")
1210
+ sid = s.get("id", "")
1211
+ if name and sid:
1212
+ name_to_id[name] = str(sid)
1213
+ ids = [name_to_id[n] for n in BOT_PERMISSIONS if n in name_to_id]
1214
+ missing = [n for n in BOT_PERMISSIONS if n not in name_to_id]
1215
+ _log_info("basic_perm", f"匹配 {len(ids)}/{len(BOT_PERMISSIONS)} 个权限")
1216
+ if missing:
1217
+ _log_warn("basic_perm",
1218
+ f"{len(missing)} 个权限未匹配: {json.dumps(missing, ensure_ascii=False)}")
1219
+ if not ids:
1220
+ _log_error("basic_perm", "无可用权限 ID")
1221
+ return False
1222
+ payload = {
1223
+ "clientId": self.app_id,
1224
+ "appScopeIDs": ids, "userScopeIDs": [], "scopeIds": [],
1225
+ "operation": "add",
1226
+ }
1227
+ total_attempts = len(PERMISSION_UPDATE_RETRY_DELAYS_SECONDS)
1228
+ for attempt, delay_seconds in enumerate(
1229
+ PERMISSION_UPDATE_RETRY_DELAYS_SECONDS,
1230
+ start=1,
1231
+ ):
1232
+ if delay_seconds > 0:
1233
+ _emit(
1234
+ "progress",
1235
+ "warn",
1236
+ "basic_perm/retry",
1237
+ f"批量添加权限暂未成功,{delay_seconds}s 后重试 "
1238
+ f"({attempt}/{total_attempts})",
1239
+ )
1240
+ time.sleep(delay_seconds)
1241
+ body = self._post(
1242
+ f"{self._api_base}/scope/update/{self.app_id}",
1243
+ payload,
1244
+ timeout_ms=PERMISSION_UPDATE_TIMEOUT_MS,
1245
+ log_step="basic_perm",
1246
+ )
1247
+ result = self._ok(
1248
+ body,
1249
+ f"批量添加权限 (第 {attempt}/{total_attempts} 次)",
1250
+ "basic_perm",
1251
+ )
1252
+ if result is not None:
1253
+ _log_success("basic_perm", "批量添加权限成功")
1254
+ return True
1255
+ if body is None:
1256
+ _log_warn(
1257
+ "basic_perm",
1258
+ f"第 {attempt}/{total_attempts} 次批量添加权限无响应或超时",
1259
+ )
1260
+ _log_error("basic_perm", f"批量添加权限连续 {total_attempts} 次失败")
1261
+ return False
1262
+
1263
+ def _get_scope_name_to_id(self) -> dict:
1264
+ body = self._get(f"{self._api_base}/scope/all/{self.app_id}")
1265
+ if not self._ok(body, "获取权限列表", "permission"):
1266
+ return {}
1267
+ name_to_id = {}
1268
+ for s in body.get("data", {}).get("scopes", []):
1269
+ name = s.get("name") or s.get("scopeName", "")
1270
+ sid = s.get("id", "")
1271
+ if name and sid:
1272
+ name_to_id[name] = str(sid)
1273
+ return name_to_id
1274
+
1275
+ def step_add_audit_permissions(self) -> dict:
1276
+ result = {"added": False, "need_audit": False, "audit_url": None}
1277
+
1278
+ name_to_id = self._get_scope_name_to_id()
1279
+ if not name_to_id:
1280
+ _log_error("advanced_perm", "获取权限映射表失败")
1281
+ return result
1282
+ ids = [name_to_id[n] for n in BOT_PERMISSIONS_NEED_AUDIT if n in name_to_id]
1283
+ missing = [n for n in BOT_PERMISSIONS_NEED_AUDIT if n not in name_to_id]
1284
+ if missing:
1285
+ _log_warn("advanced_perm", f"高级权限未匹配: {missing}")
1286
+ if not ids:
1287
+ result["added"] = True
1288
+ return result
1289
+
1290
+ body = self._post(f"{self._api_base}/scope/update/{self.app_id}", {
1291
+ "clientId": self.app_id,
1292
+ "appScopeIDs": ids, "userScopeIDs": [], "scopeIds": [],
1293
+ "operation": "add",
1294
+ })
1295
+ if not self._ok(body, "添加高级权限", "permission"):
1296
+ return result
1297
+ result["added"] = True
1298
+
1299
+ audit_url = _pcfg("admin_audit_url")
1300
+
1301
+ _log_info("advanced_perm", "正在发布高级权限...")
1302
+ publish_ok = self.step8_publish(version="1.0.1", silent=True)
1303
+
1304
+ if not publish_ok:
1305
+ _log_error("advanced_perm", "高级权限发布失败")
1306
+ result["need_audit"] = True
1307
+ result["audit_url"] = self.audit_url or audit_url
1308
+ return result
1309
+
1310
+ time.sleep(2)
1311
+
1312
+ info = self._get(f"{self._api_base}/app/{self.app_id}")
1313
+ if info and info.get("code") == 0:
1314
+ data = info.get("data", {})
1315
+ audit_version_id = data.get("auditVersionId")
1316
+ audit_status = data.get("auditStatus")
1317
+
1318
+ is_new_version = (audit_version_id
1319
+ and str(audit_version_id) == str(self.version_id))
1320
+
1321
+ if is_new_version and audit_status == 100:
1322
+ _log_success("advanced_perm", "高级权限发布成功")
1323
+ result["need_audit"] = False
1324
+ elif is_new_version and audit_status != 100:
1325
+ _log_error("advanced_perm", "免审批发布高级权限失败")
1326
+ result["need_audit"] = True
1327
+ result["audit_url"] = audit_url
1328
+ else:
1329
+ _log_error("advanced_perm", "免审批发布高级权限失败")
1330
+ result["need_audit"] = True
1331
+ result["audit_url"] = audit_url
1332
+ else:
1333
+ _log_error("advanced_perm", "免审批发布高级权限失败")
1334
+ result["need_audit"] = True
1335
+ result["audit_url"] = audit_url
1336
+
1337
+ return result
1338
+
1339
+ def step8_publish(self, version: str = "1.0.0", silent: bool = False) -> bool:
1340
+ self.publish_fail_reason = ""
1341
+ self.audit_url = None
1342
+ admin_audit_url = _pcfg("admin_audit_url")
1343
+ changelog = "Initial version" if PLATFORM == "lark" else "初始版本"
1344
+
1345
+ if not silent:
1346
+ _log_info("publish", "正在发布...")
1347
+ body = self._post(f"{self._api_base}/app_version/create/{self.app_id}", {
1348
+ "clientId": self.app_id, "appVersion": version,
1349
+ "changeLog": changelog, "autoPublish": False,
1350
+ "pcDefaultAbility": "bot", "mobileDefaultAbility": "bot",
1351
+ })
1352
+ if not self._ok(body, "创建版本", "publish"):
1353
+ self.publish_fail_reason = f"创建版本失败: code={(body or {}).get('code')}, msg={(body or {}).get('msg')}"
1354
+ return False
1355
+ self.version_id = body.get("data", {}).get("versionId") or body["data"].get("version_id")
1356
+ if not self.version_id:
1357
+ _log_error("publish", "发布失败")
1358
+ self.publish_fail_reason = "未获取到版本 ID"
1359
+ return False
1360
+
1361
+ time.sleep(1)
1362
+ body = self._post(f"{self._api_base}/publish/commit/{self.app_id}/{self.version_id}", {})
1363
+ if not self._ok(body, "提交审核", "publish"):
1364
+ self.publish_fail_reason = f"提交审核失败: code={(body or {}).get('code')}, msg={(body or {}).get('msg')}"
1365
+ return False
1366
+
1367
+ time.sleep(1)
1368
+ body = self._post(
1369
+ f"{self._api_base}/publish/release/{self.app_id}/{self.version_id}",
1370
+ {"clientId": self.app_id, "versionId": self.version_id})
1371
+ release_code = (body or {}).get("code")
1372
+
1373
+ if release_code == 0:
1374
+ if not silent:
1375
+ _log_success("publish", "基础权限发布成功", version_id=self.version_id)
1376
+ return True
1377
+
1378
+ if release_code == 10002:
1379
+ time.sleep(1)
1380
+ info = self._get(f"{self._api_base}/app/{self.app_id}")
1381
+ if info and info.get("code") == 0:
1382
+ app_status = info.get("data", {}).get("appStatus")
1383
+ if app_status == 1:
1384
+ if not silent:
1385
+ _log_success("publish", "基础权限发布成功", version_id=self.version_id)
1386
+ return True
1387
+ self.audit_url = admin_audit_url
1388
+ _log_error("publish", "免审批发布基础权限失败")
1389
+ self.publish_fail_reason = (
1390
+ f"需要管理员审批: appStatus={app_status}, "
1391
+ f"release code={release_code}, msg={body.get('msg')}")
1392
+ return False
1393
+ if not silent:
1394
+ _log_success("publish", "基础权限发布成功", version_id=self.version_id)
1395
+ return True
1396
+
1397
+ if release_code is None:
1398
+ time.sleep(1)
1399
+ info = self._get(f"{self._api_base}/app/{self.app_id}")
1400
+ if info and info.get("code") == 0:
1401
+ app_status = info.get("data", {}).get("appStatus")
1402
+ if app_status == 1:
1403
+ if not silent:
1404
+ _log_success("publish", "基础权限发布成功", version_id=self.version_id)
1405
+ return True
1406
+
1407
+ fail_msg = (body or {}).get("msg", "未知错误")
1408
+ _log_error("publish", f"发布失败: {fail_msg}")
1409
+ self.publish_fail_reason = f"发布失败: code={release_code}, msg={fail_msg}"
1410
+ return False
1411
+
1412
+ def step9_get_owner_open_id(self) -> Optional[str]:
1413
+ _log_info("owner", "获取应用 Owner 的 open_id")
1414
+ if not self.app_id or not self.app_secret:
1415
+ _log_warn("owner", "缺少 app_id 或 app_secret,跳过")
1416
+ return None
1417
+
1418
+ ctx = ssl.create_default_context()
1419
+ ctx.check_hostname = False
1420
+ ctx.verify_mode = ssl.CERT_NONE
1421
+
1422
+ payload = json.dumps({
1423
+ "app_id": self.app_id, "app_secret": self.app_secret,
1424
+ }).encode()
1425
+ req = urllib.request.Request(
1426
+ f"{self._base_url}/open-apis/auth/v3/tenant_access_token/internal",
1427
+ data=payload,
1428
+ headers={"Content-Type": "application/json; charset=utf-8"},
1429
+ method="POST",
1430
+ )
1431
+ try:
1432
+ with urllib.request.urlopen(req, context=ctx) as resp:
1433
+ token_data = json.loads(resp.read())
1434
+ except Exception:
1435
+ _log_error("owner", "未获取到 tenant_access_token")
1436
+ return None
1437
+
1438
+ token = token_data.get("tenant_access_token")
1439
+ if not token:
1440
+ _log_error("owner", "未获取到 tenant_access_token")
1441
+ return None
1442
+
1443
+ req2 = urllib.request.Request(
1444
+ f"{self._base_url}/open-apis/contact/v3/users?page_size=50&user_id_type=open_id",
1445
+ headers={
1446
+ "Content-Type": "application/json; charset=utf-8",
1447
+ "Authorization": f"Bearer {token}",
1448
+ },
1449
+ method="GET",
1450
+ )
1451
+ try:
1452
+ with urllib.request.urlopen(req2, context=ctx) as resp:
1453
+ user_data = json.loads(resp.read())
1454
+ except urllib.error.HTTPError:
1455
+ _log_error("owner", "查询用户列表失败")
1456
+ return None
1457
+ except Exception:
1458
+ _log_error("owner", "查询用户列表失败")
1459
+ return None
1460
+
1461
+ items = user_data.get("data", {}).get("items", [])
1462
+ if not items:
1463
+ _log_error("owner", "用户列表为空")
1464
+ return None
1465
+
1466
+ owner = items[0]
1467
+ open_id = owner.get("open_id", "")
1468
+ name = owner.get("name", "未知")
1469
+ self.owner_name = name
1470
+ _log_success("owner", f"用户: {name}, open_id: {open_id}")
1471
+ return open_id
1472
+
1473
+
1474
+ # ============================================================
1475
+ # Chromium launcher (CDP)
1476
+ # ============================================================
1477
+ def _get_chromium_path() -> str:
1478
+ from playwright.sync_api import sync_playwright
1479
+ pw = sync_playwright().start()
1480
+ path = pw.chromium.executable_path
1481
+ pw.stop()
1482
+ return path
1483
+
1484
+
1485
+ def _launch_detached_chromium() -> int:
1486
+ chrome_path = _get_chromium_path()
1487
+ if not os.path.isfile(chrome_path):
1488
+ raise FileNotFoundError(f"Chromium 不存在: {chrome_path}")
1489
+
1490
+ user_data_dir = os.path.join(STATE_DIR, _pcfg("profile_dir_name"))
1491
+ os.makedirs(user_data_dir, exist_ok=True)
1492
+
1493
+ args = [
1494
+ chrome_path,
1495
+ "--headless=new",
1496
+ f"--remote-debugging-port={CDP_PORT}",
1497
+ f"--user-data-dir={user_data_dir}",
1498
+ "--no-first-run",
1499
+ "--no-default-browser-check",
1500
+ "--disable-gpu",
1501
+ "--disable-extensions",
1502
+ "--disable-background-networking",
1503
+ "--no-sandbox",
1504
+ "about:blank",
1505
+ ]
1506
+
1507
+ proc = subprocess.Popen(
1508
+ args,
1509
+ stdout=subprocess.DEVNULL,
1510
+ stderr=subprocess.DEVNULL,
1511
+ start_new_session=True,
1512
+ )
1513
+ return proc.pid
1514
+
1515
+
1516
+ def _wait_for_cdp_ready(timeout: int = 15) -> bool:
1517
+ import socket
1518
+ deadline = time.time() + timeout
1519
+ while time.time() < deadline:
1520
+ try:
1521
+ s = socket.create_connection(("127.0.0.1", CDP_PORT), timeout=1)
1522
+ s.close()
1523
+ return True
1524
+ except (ConnectionRefusedError, OSError):
1525
+ time.sleep(0.5)
1526
+ return False
1527
+
1528
+
1529
+ def cmd_init():
1530
+ _log_info("init", "检查并安装依赖...")
1531
+ _ensure_dependencies()
1532
+ _log_success("init", "依赖就绪")
1533
+ sys.exit(0)
1534
+
1535
+
1536
+ # ============================================================
1537
+ # cmd_create
1538
+ # ============================================================
1539
+ def cmd_create(avatar_url: str = "", greeting: str = "", bot_name_override: str = "",
1540
+ bot_specs: list[dict] | None = None):
1541
+ base_url = _pcfg("base_url")
1542
+ login_url = _pcfg("login_url")
1543
+ open_host = _pcfg("open_host")
1544
+ accounts_host = _pcfg("accounts_host")
1545
+ app_page = f"{base_url}/app"
1546
+ admin_audit_url = _pcfg("admin_audit_url")
1547
+ platform_label = "Lark" if PLATFORM == "lark" else "飞书"
1548
+
1549
+ _log_info("init", "检查并安装依赖...")
1550
+ _ensure_dependencies()
1551
+ _log_success("init", "依赖就绪")
1552
+
1553
+ _log_info("login", "启动浏览器,获取二维码...")
1554
+
1555
+ _kill_cdp_browser()
1556
+ time.sleep(1)
1557
+
1558
+ import shutil
1559
+ profile_dir = os.path.join(STATE_DIR, _pcfg("profile_dir_name"))
1560
+ if os.path.isdir(profile_dir):
1561
+ for _ in range(3):
1562
+ try:
1563
+ shutil.rmtree(profile_dir)
1564
+ break
1565
+ except OSError:
1566
+ time.sleep(0.5)
1567
+
1568
+ chrome_pid = _launch_detached_chromium()
1569
+
1570
+ if not _wait_for_cdp_ready(timeout=20):
1571
+ try:
1572
+ os.kill(chrome_pid, signal.SIGKILL)
1573
+ except OSError:
1574
+ pass
1575
+ _emit_error("login", "Chromium 启动超时")
1576
+ sys.exit(1)
1577
+
1578
+ from playwright.sync_api import sync_playwright
1579
+
1580
+ pw = sync_playwright().start()
1581
+ try:
1582
+ browser = pw.chromium.connect_over_cdp(f"http://127.0.0.1:{CDP_PORT}")
1583
+ except Exception as e:
1584
+ pw.stop()
1585
+ try:
1586
+ os.kill(chrome_pid, signal.SIGKILL)
1587
+ except OSError:
1588
+ pass
1589
+ _emit_error("login", f"连接 Chromium 失败: {e}")
1590
+ sys.exit(1)
1591
+
1592
+ if PLATFORM == "lark":
1593
+ ctx = browser.new_context(
1594
+ user_agent=(
1595
+ "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) "
1596
+ "AppleWebKit/537.36 (KHTML, like Gecko) "
1597
+ "Chrome/120.0.0.0 Safari/537.36"
1598
+ ),
1599
+ viewport={"width": 1280, "height": 800},
1600
+ )
1601
+ page = ctx.new_page()
1602
+ else:
1603
+ contexts = browser.contexts
1604
+ if not contexts or not contexts[0].pages:
1605
+ page = browser.new_context().new_page()
1606
+ else:
1607
+ page = contexts[0].pages[0]
1608
+
1609
+ state = {"qr_token": None}
1610
+
1611
+ def _on_response(resp):
1612
+ try:
1613
+ if "qrlogin/init" in resp.url:
1614
+ body = resp.json()
1615
+ if body.get("code") == 0:
1616
+ state["qr_token"] = body["data"]["step_info"]["token"]
1617
+ except Exception:
1618
+ pass
1619
+
1620
+ page.on("response", _on_response)
1621
+
1622
+ def _switch_to_qr_mode():
1623
+ try:
1624
+ page.wait_for_timeout(2000)
1625
+ page.evaluate("""() => {
1626
+ const el = document.querySelector('.switch-login-mode-box');
1627
+ if (el) el.dispatchEvent(new MouseEvent('click', {bubbles: true, cancelable: true}));
1628
+ }""")
1629
+ page.wait_for_timeout(1500)
1630
+ except Exception:
1631
+ pass
1632
+
1633
+ def _fetch_qr_token() -> bool:
1634
+ state["qr_token"] = None
1635
+ try:
1636
+ page.goto(login_url, wait_until="domcontentloaded", timeout=30000)
1637
+ except Exception:
1638
+ try:
1639
+ page.goto(login_url, wait_until="commit", timeout=30000)
1640
+ except Exception:
1641
+ return False
1642
+ if not _pcfg("qr_default"):
1643
+ _switch_to_qr_mode()
1644
+ for _ in range(25):
1645
+ if state["qr_token"]:
1646
+ return True
1647
+ page.wait_for_timeout(100)
1648
+ try:
1649
+ page.reload(wait_until="domcontentloaded", timeout=30000)
1650
+ except Exception:
1651
+ try:
1652
+ page.goto(login_url, wait_until="domcontentloaded", timeout=30000)
1653
+ except Exception:
1654
+ return False
1655
+ if not _pcfg("qr_default"):
1656
+ _switch_to_qr_mode()
1657
+ for _ in range(25):
1658
+ if state["qr_token"]:
1659
+ return True
1660
+ page.wait_for_timeout(200)
1661
+ return False
1662
+
1663
+ def _poll_qr_login() -> bool:
1664
+ login_state = {"login_ok": False, "scanned": False}
1665
+
1666
+ def _on_poll_response(resp):
1667
+ try:
1668
+ if "qrlogin/polling" in resp.url:
1669
+ body = resp.json()
1670
+ if body.get("code") != 0:
1671
+ return
1672
+ data = body.get("data", {})
1673
+ info = data.get("step_info", {})
1674
+ status = info.get("status")
1675
+ redirect_url = data.get("redirect_url", "")
1676
+ if status == 2 and not login_state["scanned"]:
1677
+ login_state["scanned"] = True
1678
+ _log_info("login", "已扫码,请在手机上点击确认")
1679
+ if redirect_url:
1680
+ login_state["login_ok"] = True
1681
+ except Exception:
1682
+ pass
1683
+
1684
+ page.on("response", _on_poll_response)
1685
+
1686
+ poll_deadline = time.time() + LOGIN_TIMEOUT
1687
+ while time.time() < poll_deadline:
1688
+ if login_state["login_ok"]:
1689
+ break
1690
+ if login_state["scanned"]:
1691
+ try:
1692
+ current_url = page.url
1693
+ if open_host in current_url and accounts_host not in current_url:
1694
+ login_state["login_ok"] = True
1695
+ break
1696
+ except Exception:
1697
+ pass
1698
+ try:
1699
+ page.wait_for_timeout(500)
1700
+ except Exception:
1701
+ pass
1702
+
1703
+ page.remove_listener("response", _on_poll_response)
1704
+ return login_state["login_ok"]
1705
+
1706
+ for attempt in range(1, QR_MAX_RETRIES + 1):
1707
+ if not _fetch_qr_token():
1708
+ if attempt < QR_MAX_RETRIES:
1709
+ _log_warn("login", f"未能获取二维码 token,正在重试 ({attempt}/{QR_MAX_RETRIES})")
1710
+ time.sleep(2)
1711
+ continue
1712
+ pw.stop()
1713
+ try:
1714
+ os.kill(chrome_pid, signal.SIGKILL)
1715
+ except OSError:
1716
+ pass
1717
+ _emit_error("login", "未能获取二维码 token")
1718
+ sys.exit(1)
1719
+
1720
+ qr_content = {"qrlogin": {"token": state["qr_token"]}}
1721
+
1722
+ _save_state({
1723
+ "phase": "create",
1724
+ "qr_token": state["qr_token"],
1725
+ "qr_content": json.dumps(qr_content),
1726
+ "deadline": int(time.time()) + LOGIN_TIMEOUT,
1727
+ "cdp_url": f"http://127.0.0.1:{CDP_PORT}",
1728
+ "chrome_pid": chrome_pid,
1729
+ })
1730
+
1731
+ _emit("show_qrcode", "info", "login", f"请扫码登录{platform_label}",
1732
+ content=json.dumps(qr_content, ensure_ascii=False))
1733
+
1734
+ _emit_progress("login", "等待扫码登录...",
1735
+ current=attempt, total=QR_MAX_RETRIES)
1736
+
1737
+ if _poll_qr_login():
1738
+ break
1739
+
1740
+ if attempt < QR_MAX_RETRIES:
1741
+ _log_warn("login", f"二维码已过期,正在刷新 ({attempt}/{QR_MAX_RETRIES})")
1742
+ try:
1743
+ page.wait_for_load_state("domcontentloaded", timeout=5000)
1744
+ except Exception:
1745
+ pass
1746
+ time.sleep(1)
1747
+ else:
1748
+ _kill_cdp_browser()
1749
+ pw.stop()
1750
+ _emit_error("login", f"{QR_MAX_RETRIES} 次超时未扫码,退出")
1751
+ sys.exit(1)
1752
+
1753
+ _log_info("login", "页面已跳转到开放平台,登录成功")
1754
+ jump_deadline = time.time() + 15
1755
+ while time.time() < jump_deadline:
1756
+ current_url = page.url
1757
+ if open_host in current_url and accounts_host not in current_url:
1758
+ break
1759
+ page.wait_for_timeout(500)
1760
+ else:
1761
+ page.goto(app_page, wait_until="domcontentloaded", timeout=30000)
1762
+
1763
+ _log_success("login", "扫码登录成功!")
1764
+ page.wait_for_timeout(2000)
1765
+
1766
+ # Normalize bot_specs. If the caller passed a single bot_name_override
1767
+ # (legacy path) wrap it in a one-element spec list. Otherwise expect a
1768
+ # list of {name?, account_id?} dicts and create them in order with the
1769
+ # same login session.
1770
+ if bot_specs:
1771
+ specs = list(bot_specs)
1772
+ else:
1773
+ specs = [{"name": bot_name_override, "account_id": OPENCLAW_ACCOUNT_ID}]
1774
+ if not specs:
1775
+ _emit_error("config", "no bot specs provided")
1776
+ sys.exit(1)
1777
+
1778
+ avatar_path = _download_avatar(avatar_url)
1779
+ all_results: list[dict] = []
1780
+ created_account_ids: list[str] = []
1781
+ any_audit = False
1782
+
1783
+ for idx, spec in enumerate(specs, start=1):
1784
+ spec_name = (spec.get("name") or "").strip() or _gen_bot_name()
1785
+ spec_account_id = (spec.get("account_id") or "").strip() or f"agent-{idx}"
1786
+ total = len(specs)
1787
+
1788
+ _emit_progress("bot", f"开始创建第 {idx}/{total} 个机器人:{spec_name}",
1789
+ current=idx, total=total)
1790
+
1791
+ result = _create_one_bot(
1792
+ page=page,
1793
+ base_url=base_url,
1794
+ app_page=app_page,
1795
+ admin_audit_url=admin_audit_url,
1796
+ platform_label=platform_label,
1797
+ avatar_path=avatar_path,
1798
+ greeting=greeting,
1799
+ bot_name=spec_name,
1800
+ account_id=spec_account_id,
1801
+ )
1802
+ if result.get("status") != "ok":
1803
+ _cleanup_openclaw_feishu_accounts(
1804
+ [*created_account_ids, spec_account_id])
1805
+ _kill_cdp_browser(); pw.stop()
1806
+ err_step, err_msg = result.get("error", ("create_app", "创建失败"))
1807
+ _emit_error(err_step, err_msg)
1808
+ sys.exit(1)
1809
+
1810
+ all_results.append(result["bot"])
1811
+ created_account_ids.append(result["bot"].get("account_id") or spec_account_id)
1812
+ if result["bot"].get("audit_url"):
1813
+ any_audit = True
1814
+
1815
+ _kill_cdp_browser()
1816
+ pw.stop()
1817
+
1818
+ if len(all_results) == 1:
1819
+ # Single-bot path keeps the old data shape so existing
1820
+ # subscribers that read data.app_id / data.bot_name still work.
1821
+ result = all_results[0]
1822
+ finish_msg_lines = [
1823
+ f"✅ 机器人「{result['bot_name']}」已创建并发布"
1824
+ + (",可正常使用。" if result.get("audit_url") else ",所有权限已生效。"),
1825
+ f"管理地址:{result.get('manage_url', '')}",
1826
+ ]
1827
+ if any_audit:
1828
+ finish_msg_lines += [
1829
+ "",
1830
+ "⚠️ 以下高级权限无法免审批发布,已自动为您提交申请:",
1831
+ " · 查看、评论和下载云空间中所有文件",
1832
+ " · 查看、评论、编辑和管理云空间中所有文件",
1833
+ f"如需启用,请联系管理员前往审批:{result.get('audit_url', '')}",
1834
+ ]
1835
+ _save_state({"phase": "done", **result})
1836
+ _emit_finish("\n".join(finish_msg_lines), result)
1837
+ return
1838
+
1839
+ team_group = None
1840
+ try:
1841
+ team_group = _create_team_group(all_results)
1842
+ except Exception as e:
1843
+ _log_warn("team_group", f"飞书团队群创建失败: {e}")
1844
+ _emit("progress", "warn", "team_group/error",
1845
+ f"飞书团队群创建失败: {e}")
1846
+
1847
+ # Multi-bot (team) path: aggregate.
1848
+ team_data = {
1849
+ "bots": all_results,
1850
+ "team_group": team_group,
1851
+ # Convenience fields: pin the first bot (controller) at the top
1852
+ # level so older readers still see *something* useful — they'll
1853
+ # treat the team as a single bot keyed on the controller.
1854
+ "app_id": all_results[0].get("app_id"),
1855
+ "app_secret": all_results[0].get("app_secret"),
1856
+ "bot_name": all_results[0].get("bot_name"),
1857
+ "open_id": all_results[0].get("open_id"),
1858
+ "account_id": all_results[0].get("account_id"),
1859
+ "manage_url": all_results[0].get("manage_url"),
1860
+ }
1861
+ summary_lines = [
1862
+ f"✅ 团队 {len(all_results)} 个机器人已创建并发布:",
1863
+ ]
1864
+ for r in all_results:
1865
+ summary_lines.append(
1866
+ f" · 「{r.get('bot_name')}」(account={r.get('account_id')}, app_id={r.get('app_id')})"
1867
+ )
1868
+ if team_group:
1869
+ summary_lines.append(
1870
+ f" · 飞书群「{team_group.get('name')}」(chat_id={team_group.get('chat_id')})"
1871
+ )
1872
+ member_errors = team_group.get("member_errors") or []
1873
+ if member_errors:
1874
+ summary_lines += [
1875
+ "",
1876
+ f"⚠️ 飞书群已创建,但 {len(member_errors)} 个协作机器人未拉入群:",
1877
+ *[f" · {item}" for item in member_errors],
1878
+ ]
1879
+ if any_audit:
1880
+ summary_lines += [
1881
+ "",
1882
+ "⚠️ 部分高级权限需审批,详见各机器人审批地址。",
1883
+ ]
1884
+ if not team_group:
1885
+ summary_lines += [
1886
+ "",
1887
+ "⚠️ 飞书团队群未创建成功,请查看上方 team_group 日志。",
1888
+ ]
1889
+ _save_state({"phase": "done", **team_data})
1890
+ _emit_finish("\n".join(summary_lines), team_data)
1891
+
1892
+
1893
+ def _create_one_bot(*, page, base_url: str, app_page: str, admin_audit_url: str,
1894
+ platform_label: str, avatar_path: str, greeting: str,
1895
+ bot_name: str, account_id: str) -> dict:
1896
+ """Run the per-bot pipeline (step1..step9) using a logged-in page.
1897
+ Returns either {"status": "ok", "bot": {...}} or
1898
+ {"status": "error", "error": (step, message)}.
1899
+ """
1900
+ bot_desc = bot_name
1901
+ creator = FeishuBotCreator(page)
1902
+ creator.install_network_capture()
1903
+
1904
+ page.goto(app_page, wait_until="domcontentloaded", timeout=30000)
1905
+ page.wait_for_timeout(2000)
1906
+ csrf = creator._csrf()
1907
+ _log_info("create_app", f"CSRF token: {'获取成功' if csrf else '未获取,继续尝试'}")
1908
+
1909
+ if not creator.step1_create_app(bot_name, bot_desc, avatar_path):
1910
+ return {"status": "error", "error": ("create_app", "创建应用失败")}
1911
+
1912
+ if not creator.step2_get_credentials():
1913
+ return {"status": "error", "error": ("credential", "获取凭证失败")}
1914
+
1915
+ _emit("write_config", "info", "config", "写入 openclaw.json,等待服务建立长连接",
1916
+ path=OPENCLAW_CONFIG, account_id=account_id)
1917
+ if not _write_openclaw_config(creator.app_id, creator.app_secret, account_id):
1918
+ return {"status": "error", "error": ("config", "写入 openclaw 配置失败")}
1919
+
1920
+ _log_info("config", "等待服务读取配置...")
1921
+ time.sleep(2)
1922
+
1923
+ _step_error_messages = {
1924
+ "step3_add_bot": ("bot_ability", "开启机器人能力失败"),
1925
+ "step4_event_mode": ("event_mode", "建立长链接失败,请检查 gateway 是否启动"),
1926
+ "step5_add_event": ("event", "添加「接收消息」事件失败"),
1927
+ "step6_callback_mode": ("callback", "配置长连接回调失败,请检查 gateway 是否启动"),
1928
+ "step7_permissions": ("basic_perm", "批量导入权限失败"),
1929
+ }
1930
+ for fn in [
1931
+ creator.step3_add_bot,
1932
+ creator.step4_event_mode,
1933
+ creator.step5_add_event,
1934
+ creator.step6_callback_mode,
1935
+ creator.step7_permissions,
1936
+ ]:
1937
+ if not fn():
1938
+ step_name = fn.__name__
1939
+ err_step, err_msg = _step_error_messages.get(
1940
+ step_name, (step_name, f"{step_name} 失败")
1941
+ )
1942
+ return {"status": "error", "error": (err_step, err_msg)}
1943
+
1944
+ manage_url = f"{base_url}/app/{creator.app_id}"
1945
+ publish_ok = creator.step8_publish()
1946
+ if not publish_ok:
1947
+ fail_reason = getattr(creator, 'publish_fail_reason', '') or '发布失败'
1948
+ audit_url = getattr(creator, 'audit_url', None) or admin_audit_url
1949
+ return {
1950
+ "status": "error",
1951
+ "error": (
1952
+ "publish",
1953
+ (
1954
+ f"当前用户权限无法免审批发布{platform_label}机器人「{bot_name}」,"
1955
+ f"请联系管理员审批后再用。审批地址:{audit_url}"
1956
+ ),
1957
+ ),
1958
+ }
1959
+
1960
+ _log_info("advanced_perm", "正在添加高级权限...")
1961
+ audit_result = creator.step_add_audit_permissions()
1962
+
1963
+ open_id = creator.step9_get_owner_open_id()
1964
+ if open_id:
1965
+ _write_allow_from(open_id, account_id)
1966
+ _send_greeting(creator.app_id, creator.app_secret, open_id, greeting)
1967
+ else:
1968
+ _log_warn("owner", "未获取到 open_id,跳过 allowFrom 写入")
1969
+
1970
+ bot_result = {
1971
+ "app_id": creator.app_id,
1972
+ "app_secret": creator.app_secret,
1973
+ "bot_name": bot_name,
1974
+ "version_id": creator.version_id,
1975
+ "open_id": open_id,
1976
+ "owner_name": creator.owner_name,
1977
+ "account_id": account_id,
1978
+ "manage_url": manage_url,
1979
+ }
1980
+ if audit_result.get("need_audit"):
1981
+ bot_result["audit_url"] = audit_result.get("audit_url") or admin_audit_url
1982
+ bot_result["audit_permissions"] = BOT_PERMISSIONS_NEED_AUDIT
1983
+
1984
+ return {"status": "ok", "bot": bot_result}
1985
+
1986
+
1987
+ def cmd_cleanup():
1988
+ _kill_cdp_browser()
1989
+ sf = _state_file()
1990
+ if os.path.isfile(sf):
1991
+ os.remove(sf)
1992
+ _log_success("cleanup", "已清理")
1993
+
1994
+
1995
+ def main():
1996
+ global PLATFORM
1997
+
1998
+ if len(sys.argv) < 2 or sys.argv[1] in ("-h", "--help", "help"):
1999
+ sys.exit(0)
2000
+
2001
+ cmd = sys.argv[1]
2002
+
2003
+ if cmd == "init":
2004
+ cmd_init()
2005
+ elif cmd == "create":
2006
+ avatar_url = ""
2007
+ greeting = ""
2008
+ bot_name_override = ""
2009
+ bot_specs: list[dict] | None = None
2010
+ args = sys.argv[2:]
2011
+ i = 0
2012
+ while i < len(args):
2013
+ if args[i] == "--avatar-url" and i + 1 < len(args):
2014
+ avatar_url = args[i + 1]
2015
+ i += 2
2016
+ elif args[i] == "--greeting" and i + 1 < len(args):
2017
+ greeting = args[i + 1]
2018
+ i += 2
2019
+ elif args[i] == "--bot-name" and i + 1 < len(args):
2020
+ bot_name_override = args[i + 1]
2021
+ i += 2
2022
+ elif args[i] == "--bots-json" and i + 1 < len(args):
2023
+ try:
2024
+ parsed = json.loads(args[i + 1])
2025
+ except json.JSONDecodeError as e:
2026
+ _emit_error("main", f"--bots-json 解析失败: {e}")
2027
+ sys.exit(1)
2028
+ if not isinstance(parsed, list) or not parsed:
2029
+ _emit_error("main", "--bots-json 必须是非空数组")
2030
+ sys.exit(1)
2031
+ bot_specs = parsed
2032
+ i += 2
2033
+ elif args[i] == "--platform" and i + 1 < len(args):
2034
+ p = args[i + 1].lower()
2035
+ if p not in ("feishu", "lark"):
2036
+ _emit_error("main", f"不支持的平台: {p},请使用 feishu 或 lark")
2037
+ sys.exit(1)
2038
+ PLATFORM = p
2039
+ i += 2
2040
+ else:
2041
+ i += 1
2042
+ cmd_create(avatar_url=avatar_url, greeting=greeting,
2043
+ bot_name_override=bot_name_override,
2044
+ bot_specs=bot_specs)
2045
+ elif cmd == "cleanup":
2046
+ args = sys.argv[2:]
2047
+ i = 0
2048
+ while i < len(args):
2049
+ if args[i] == "--platform" and i + 1 < len(args):
2050
+ p = args[i + 1].lower()
2051
+ if p in ("feishu", "lark"):
2052
+ PLATFORM = p
2053
+ i += 2
2054
+ else:
2055
+ i += 1
2056
+ cmd_cleanup()
2057
+ else:
2058
+ _emit_error("main", f"未知命令: {cmd}")
2059
+ sys.exit(1)
2060
+
2061
+
2062
+ if __name__ == "__main__":
2063
+ main()