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.
- package/README.md +35 -0
- package/bundle/feishu-bot-creator/README.md +66 -0
- package/bundle/feishu-bot-creator/assets/tnyma-ai-avatar.png +0 -0
- package/bundle/feishu-bot-creator/feishu_bot_creator.py +2063 -0
- package/bundle/index.js +214 -0
- package/bundle/postinstall.js +5 -0
- package/package.json +50 -0
|
@@ -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()
|