remote-claude 1.0.4 → 1.0.5

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,999 @@
1
+ """
2
+ 飞书机器人配置向导
3
+
4
+ 自动创建飞书应用(通过 OAuth 设备流扫码),并引导用户完成机器人能力配置。
5
+
6
+ 用法:
7
+ python3 -m lark_client.setup_wizard # 交互式向导
8
+ python3 -m lark_client.setup_wizard --check # 仅检查现有配置
9
+ """
10
+
11
+ import json
12
+ import os
13
+ import sys
14
+ import time
15
+ import urllib.request
16
+ import urllib.parse
17
+ import urllib.error
18
+ import webbrowser
19
+ from pathlib import Path
20
+
21
+ # 将项目根目录加入 sys.path
22
+ _PROJECT_ROOT = str(Path(__file__).resolve().parent.parent)
23
+ if _PROJECT_ROOT not in sys.path:
24
+ sys.path.insert(0, _PROJECT_ROOT)
25
+
26
+ from utils.session import USER_DATA_DIR, get_env_file
27
+
28
+ # ── ANSI 颜色 ──────────────────────────────────────────────────────────────
29
+ GREEN = "\033[32m"
30
+ YELLOW = "\033[33m"
31
+ BLUE = "\033[34m"
32
+ CYAN = "\033[36m"
33
+ RED = "\033[31m"
34
+ BOLD = "\033[1m"
35
+ DIM = "\033[2m"
36
+ RESET = "\033[0m"
37
+
38
+ # ── 飞书 API 端点 ──────────────────────────────────────────────────────────
39
+ FEISHU_ACCOUNTS_URL = "https://accounts.feishu.cn"
40
+ LARK_ACCOUNTS_URL = "https://accounts.larksuite.com"
41
+ FEISHU_OPEN_URL = "https://open.feishu.cn"
42
+ LARK_OPEN_URL = "https://open.larksuite.com"
43
+
44
+
45
+ def _post_form(url: str, data: dict, timeout: int = 10) -> dict:
46
+ """发送 application/x-www-form-urlencoded POST 请求,返回 JSON 响应。"""
47
+ encoded = urllib.parse.urlencode(data).encode("utf-8")
48
+ req = urllib.request.Request(
49
+ url,
50
+ data=encoded,
51
+ headers={"Content-Type": "application/x-www-form-urlencoded"},
52
+ method="POST",
53
+ )
54
+ with urllib.request.urlopen(req, timeout=timeout) as resp:
55
+ return json.loads(resp.read().decode("utf-8"))
56
+
57
+
58
+ def _post_json(url: str, data: dict, timeout: int = 10) -> dict:
59
+ """发送 application/json POST 请求,返回 JSON 响应。"""
60
+ encoded = json.dumps(data).encode("utf-8")
61
+ req = urllib.request.Request(
62
+ url,
63
+ data=encoded,
64
+ headers={"Content-Type": "application/json"},
65
+ method="POST",
66
+ )
67
+ with urllib.request.urlopen(req, timeout=timeout) as resp:
68
+ return json.loads(resp.read().decode("utf-8"))
69
+
70
+
71
+ def _read_input(prompt: str, default: str = "") -> str:
72
+ """读取用户输入,支持默认值。"""
73
+ if default:
74
+ full_prompt = f"{prompt} [{DIM}{default}{RESET}]: "
75
+ else:
76
+ full_prompt = f"{prompt}: "
77
+ try:
78
+ val = input(full_prompt).strip()
79
+ except (EOFError, KeyboardInterrupt):
80
+ print()
81
+ raise
82
+ return val if val else default
83
+
84
+
85
+ def _read_secret(prompt: str) -> str:
86
+ """读取密钥输入(不回显)。"""
87
+ import getpass
88
+ try:
89
+ return getpass.getpass(f"{prompt}: ").strip()
90
+ except (EOFError, KeyboardInterrupt):
91
+ print()
92
+ raise
93
+
94
+
95
+ def _print_header():
96
+ print(f"\n{CYAN}{BOLD}━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━{RESET}")
97
+ print(f"{CYAN}{BOLD} 飞书机器人配置向导{RESET}")
98
+ print(f"{CYAN}{BOLD}━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━{RESET}\n")
99
+
100
+
101
+ def _print_step(n: int, title: str):
102
+ print(f"\n{BLUE}{BOLD}[{n}] {title}{RESET}")
103
+
104
+
105
+ def _ok(msg: str):
106
+ print(f" {GREEN}✓{RESET} {msg}")
107
+
108
+
109
+ def _warn(msg: str):
110
+ print(f" {YELLOW}⚠{RESET} {msg}")
111
+
112
+
113
+ def _err(msg: str):
114
+ print(f" {RED}✗{RESET} {msg}")
115
+
116
+
117
+ def _info(msg: str):
118
+ print(f" {DIM}{msg}{RESET}")
119
+
120
+
121
+ # ── lark-cli 配置读取 ───────────────────────────────────────────────────────
122
+
123
+ # ── OAuth 设备流:创建应用 ──────────────────────────────────────────────────
124
+
125
+ def request_app_registration(accounts_base: str = FEISHU_ACCOUNTS_URL) -> dict:
126
+ """
127
+ 调用飞书 OAuth 设备流 API,发起应用注册请求。
128
+
129
+ 返回包含 device_code, user_code, verification_url, expires_in, interval 的字典。
130
+ """
131
+ url = f"{accounts_base}/oauth/v1/app/registration"
132
+ resp = _post_form(url, {
133
+ "action": "begin",
134
+ "archetype": "PersonalAgent",
135
+ "auth_method": "client_secret",
136
+ "request_user_info": "open_id tenant_brand",
137
+ })
138
+
139
+ # 构建完整验证 URL
140
+ verification_url = resp.get("verification_uri_complete") or resp.get("verification_uri", "")
141
+ user_code = resp.get("user_code", "")
142
+
143
+ # 追加 CLI 参数(与 lark-cli 行为一致)
144
+ if verification_url and "?" not in verification_url:
145
+ verification_url = f"{verification_url}?from=remote-claude"
146
+ elif verification_url:
147
+ verification_url = f"{verification_url}&from=remote-claude"
148
+
149
+ return {
150
+ "device_code": resp["device_code"],
151
+ "user_code": user_code,
152
+ "verification_url": verification_url,
153
+ "expires_in": resp.get("expires_in", 300),
154
+ "interval": resp.get("interval", 5),
155
+ }
156
+
157
+
158
+ def poll_app_registration(device_code: str, expires_in: int, interval: int,
159
+ accounts_base: str = FEISHU_ACCOUNTS_URL) -> dict:
160
+ """
161
+ 轮询应用注册结果,直到用户完成扫码授权或超时。
162
+
163
+ 成功时返回包含 client_id, client_secret, user_info 的字典。
164
+ """
165
+ url = f"{accounts_base}/oauth/v1/app/registration"
166
+ deadline = time.time() + expires_in
167
+ attempt = 0
168
+ max_attempts = 200
169
+
170
+ print(f" {DIM}等待扫码...{RESET}", end="", flush=True)
171
+
172
+ while time.time() < deadline and attempt < max_attempts:
173
+ attempt += 1
174
+ time.sleep(3)
175
+ print(".", end="", flush=True)
176
+
177
+ try:
178
+ resp = _post_form(url, {
179
+ "action": "poll",
180
+ "device_code": device_code,
181
+ })
182
+ except Exception:
183
+ continue
184
+
185
+ error = resp.get("error", "")
186
+
187
+ if not error:
188
+ # 成功
189
+ if resp.get("client_id"):
190
+ print(f" {GREEN}✓{RESET}")
191
+ return resp
192
+ # 可能还在处理,继续轮询
193
+ continue
194
+ elif error == "authorization_pending":
195
+ continue
196
+ elif error == "slow_down":
197
+ pass
198
+ elif error == "access_denied":
199
+ print()
200
+ raise RuntimeError("用户拒绝了授权请求")
201
+ elif error in ("expired_token", "invalid_grant"):
202
+ print()
203
+ raise RuntimeError("二维码已过期,请重新运行向导")
204
+ else:
205
+ print()
206
+ raise RuntimeError(f"注册失败:{error} - {resp.get('error_description', '')}")
207
+
208
+ print()
209
+ raise RuntimeError("等待超时,请重新运行向导")
210
+
211
+
212
+ def create_app_via_scan(brand: str = "feishu") -> tuple[str, str]:
213
+ """
214
+ 通过扫码创建飞书应用。
215
+
216
+ 返回 (app_id, app_secret) 元组。
217
+ """
218
+ accounts_base = LARK_ACCOUNTS_URL if brand == "lark" else FEISHU_ACCOUNTS_URL
219
+
220
+ # 发起注册请求
221
+ reg = request_app_registration(accounts_base)
222
+
223
+ print(f"\n {CYAN}请在浏览器中打开以下链接,扫码创建应用:{RESET}")
224
+ print(f"\n {BOLD}{reg['verification_url']}{RESET}\n")
225
+
226
+ # 尝试自动打开浏览器
227
+ try:
228
+ webbrowser.open(reg["verification_url"])
229
+ _info("已尝试自动在浏览器中打开(如未打开请手动复制上方链接)")
230
+ except Exception:
231
+ pass
232
+
233
+ # 尝试显示终端二维码
234
+ _try_print_qrcode(reg["verification_url"])
235
+
236
+ # 轮询结果
237
+ result = poll_app_registration(
238
+ reg["device_code"],
239
+ reg["expires_in"],
240
+ reg["interval"],
241
+ accounts_base,
242
+ )
243
+
244
+ # DEBUG: 打印 user_info 原始内容,用于调试企业检测逻辑(测试完删除)
245
+ import json as _json
246
+ print(f"\n [DEBUG] user_info: {_json.dumps(result.get('user_info', {}), ensure_ascii=False, indent=2)}\n")
247
+
248
+ # 处理 Lark 双端特殊情况(feishu 端点可能不返回 lark 租户的 secret)
249
+ tenant_brand = result.get("user_info", {}).get("tenant_brand", "feishu")
250
+ if tenant_brand == "lark" and not result.get("client_secret"):
251
+ _info("检测到 Lark 租户,从 Lark 端点重新获取凭证...")
252
+ result = poll_app_registration(
253
+ reg["device_code"],
254
+ reg["expires_in"],
255
+ reg["interval"],
256
+ LARK_ACCOUNTS_URL,
257
+ )
258
+
259
+ app_id = result.get("client_id", "")
260
+ app_secret = result.get("client_secret", "")
261
+
262
+ if not app_id or not app_secret:
263
+ raise RuntimeError(f"创建成功但未获取到凭证,响应:{result}")
264
+
265
+ return app_id, app_secret
266
+
267
+
268
+ def _try_print_qrcode(url: str):
269
+ """尝试在终端打印二维码(需要 qrcode 库)。"""
270
+ try:
271
+ import qrcode
272
+ qr = qrcode.QRCode(border=1)
273
+ qr.add_data(url)
274
+ qr.make(fit=True)
275
+ print()
276
+ qr.print_ascii(invert=True)
277
+ print()
278
+ except ImportError:
279
+ pass # 没有 qrcode 库,跳过
280
+ except Exception:
281
+ pass
282
+
283
+
284
+ # ── OAuth 设备流:权限授权 ─────────────────────────────────────────────────
285
+
286
+ # remote_claude 机器人所需的最小权限 scope 集合
287
+ # 格式:飞书 OAuth scope(空格分隔)
288
+ REMOTE_CLAUDE_SCOPES = " ".join([
289
+ # 卡片
290
+ "cardkit:card:write",
291
+ # 通讯录
292
+ "contact:contact.base:readonly",
293
+ "contact:user.base:readonly",
294
+ "contact:user.employee_id:readonly",
295
+ "contact:user.id:readonly",
296
+ # 群聊管理
297
+ "im:chat.managers:write_only",
298
+ "im:chat.members:read",
299
+ "im:chat.members:write_only",
300
+ "im:chat.tabs:read",
301
+ "im:chat.tabs:write_only",
302
+ "im:chat.top_notice:write_only",
303
+ "im:chat:create",
304
+ "im:chat:delete",
305
+ "im:chat:operate_as_owner",
306
+ "im:chat:read",
307
+ "im:chat:update",
308
+ # 消息收发
309
+ "im:message.group_at_msg:readonly",
310
+ "im:message.group_msg",
311
+ "im:message.p2p_msg:readonly",
312
+ "im:message.reactions:read",
313
+ "im:message.reactions:write_only",
314
+ "im:message.urgent",
315
+ "im:message.urgent.status:write",
316
+ "im:message:readonly",
317
+ "im:message:recall",
318
+ "im:message:send_as_bot",
319
+ "im:message:update",
320
+ "im:resource",
321
+ # 离线访问(refresh token)
322
+ "offline_access",
323
+ ])
324
+
325
+
326
+ # 应用身份权限(tenant scope)列表,与 README.md tenant 部分保持一致
327
+ # 仅保留 Remote Claude 实际使用的权限(消息收发、卡片、群聊管理、加急通知)
328
+ TENANT_SCOPES = [
329
+ # 卡片
330
+ "cardkit:card:write",
331
+ # 通讯录
332
+ "contact:contact.base:readonly",
333
+ "contact:user.employee_id:readonly",
334
+ "contact:user.id:readonly",
335
+ # 群聊管理
336
+ "im:chat.members:read",
337
+ "im:chat.members:write_only",
338
+ "im:chat.tabs:read",
339
+ "im:chat.tabs:write_only",
340
+ "im:chat.top_notice:write_only",
341
+ "im:chat:create",
342
+ "im:chat:delete",
343
+ "im:chat:operate_as_owner",
344
+ "im:chat:read",
345
+ "im:chat:update",
346
+ # 消息收发
347
+ "im:message.group_at_msg:readonly",
348
+ "im:message.group_msg",
349
+ "im:message.p2p_msg:readonly",
350
+ "im:message.reactions:read",
351
+ "im:message.reactions:write_only",
352
+ "im:message.urgent",
353
+ "im:message.urgent.status:write",
354
+ "im:message:readonly",
355
+ "im:message:recall",
356
+ "im:message:send_as_bot",
357
+ "im:message:update",
358
+ "im:message:urgent_app",
359
+ "im:resource",
360
+ ]
361
+
362
+
363
+ def authorize_tenant_scopes(app_id: str, brand: str = "feishu") -> None:
364
+ """
365
+ 打开飞书开发者后台权限申请页,引导用户开通应用身份权限(tenant scope)。
366
+
367
+ 飞书支持快捷权限申请 URL 格式,点击后自动列出所有待开通权限,用户全选确认即可。
368
+ """
369
+ base = "https://open.feishu.cn" if brand == "feishu" else "https://open.larksuite.com"
370
+ scopes_str = ",".join(TENANT_SCOPES)
371
+ url = f"{base}/app/{app_id}/auth?q={scopes_str}&token_type=tenant"
372
+
373
+ print(f"\n {CYAN}请打开以下链接,在开发者后台开通应用身份权限:{RESET}")
374
+ print(f" {DIM}(页面加载后,点击「全选」,再点击「确认开通权限」){RESET}")
375
+ print(f"\n {BOLD}{url}{RESET}\n")
376
+
377
+ try:
378
+ webbrowser.open(url)
379
+ _info("已尝试自动在浏览器中打开")
380
+ except Exception:
381
+ pass
382
+
383
+ _try_print_qrcode(url)
384
+
385
+ try:
386
+ input(f" {DIM}完成后按 Enter 继续...{RESET}")
387
+ except (KeyboardInterrupt, EOFError):
388
+ print()
389
+
390
+
391
+ def request_device_authorization(app_id: str, app_secret: str,
392
+ scope: str,
393
+ brand: str = "feishu") -> dict:
394
+ """
395
+ 发起 OAuth device flow 权限授权请求(与 lark-cli auth login 相同机制)。
396
+
397
+ scope 参数传给飞书后,返回的 verification_uri_complete URL 中会预带 scope 参数,
398
+ 用户打开链接后飞书授权页会自动勾选这些权限,用户一键确认即可。
399
+
400
+ 返回包含 device_code, verification_url, expires_in, interval 的字典。
401
+ """
402
+ import base64
403
+ accounts_base = LARK_ACCOUNTS_URL if brand == "lark" else FEISHU_ACCOUNTS_URL
404
+ url = f"{accounts_base}/oauth/v1/device_authorization"
405
+
406
+ # Authorization: Basic base64(appId:appSecret)
407
+ basic = base64.b64encode(f"{app_id}:{app_secret}".encode()).decode()
408
+
409
+ encoded = urllib.parse.urlencode({
410
+ "client_id": app_id,
411
+ "scope": scope,
412
+ }).encode("utf-8")
413
+ req = urllib.request.Request(
414
+ url,
415
+ data=encoded,
416
+ headers={
417
+ "Content-Type": "application/x-www-form-urlencoded",
418
+ "Authorization": f"Basic {basic}",
419
+ },
420
+ method="POST",
421
+ )
422
+ with urllib.request.urlopen(req, timeout=10) as resp:
423
+ data = json.loads(resp.read().decode("utf-8"))
424
+
425
+ verification_url = data.get("verification_uri_complete") or data.get("verification_uri", "")
426
+ return {
427
+ "device_code": data["device_code"],
428
+ "verification_url": verification_url,
429
+ "expires_in": data.get("expires_in", 300),
430
+ "interval": data.get("interval", 5),
431
+ }
432
+
433
+
434
+ def poll_device_token(app_id: str, app_secret: str,
435
+ device_code: str, expires_in: int, interval: int,
436
+ brand: str = "feishu") -> dict:
437
+ """轮询 OAuth device token,直到用户完成授权或超时。"""
438
+ open_base = LARK_OPEN_URL if brand == "lark" else FEISHU_OPEN_URL
439
+ url = f"{open_base}/open-apis/authen/v2/oauth/token"
440
+
441
+ deadline = time.time() + expires_in
442
+ attempt = 0
443
+
444
+ print(f" {DIM}等待授权确认...{RESET}", end="", flush=True)
445
+
446
+ while time.time() < deadline and attempt < 200:
447
+ attempt += 1
448
+ time.sleep(3)
449
+ print(".", end="", flush=True)
450
+
451
+ try:
452
+ resp = _post_form(url, {
453
+ "grant_type": "urn:ietf:params:oauth:grant-type:device_code",
454
+ "device_code": device_code,
455
+ "client_id": app_id,
456
+ "client_secret": app_secret,
457
+ })
458
+ except Exception:
459
+ continue
460
+
461
+ error = resp.get("error", "")
462
+ if not error and resp.get("access_token"):
463
+ print(f" {GREEN}✓{RESET}")
464
+ return resp
465
+ elif error == "authorization_pending":
466
+ continue
467
+ elif error == "slow_down":
468
+ pass
469
+ elif error == "access_denied":
470
+ print()
471
+ raise RuntimeError("用户拒绝了授权")
472
+ elif error in ("expired_token", "invalid_grant"):
473
+ print()
474
+ raise RuntimeError("授权链接已过期")
475
+ else:
476
+ print()
477
+ raise RuntimeError(f"授权失败:{error}")
478
+
479
+ print()
480
+ raise RuntimeError("等待超时")
481
+
482
+
483
+ def authorize_app_scopes(app_id: str, app_secret: str,
484
+ brand: str = "feishu") -> bool:
485
+ """
486
+ 通过 OAuth device flow 引导用户为应用授权所需权限。
487
+
488
+ 与 lark-cli auth login --recommend 相同机制:
489
+ - scope 在请求中传给飞书
490
+ - 飞书返回带 scope 参数的验证 URL
491
+ - 用户打开链接,授权页自动预选这些权限,一键确认即可
492
+
493
+ 返回 True 表示授权成功,False 表示跳过或失败。
494
+ """
495
+ try:
496
+ auth = request_device_authorization(app_id, app_secret, REMOTE_CLAUDE_SCOPES, brand)
497
+ except Exception as e:
498
+ _warn(f"无法生成授权链接:{e}")
499
+ return False
500
+
501
+ print(f"\n {CYAN}请打开以下链接,确认授予应用所需权限:{RESET}")
502
+ print(f" {DIM}(页面中的权限已自动预选,直接点击确认即可){RESET}")
503
+ print(f"\n {BOLD}{auth['verification_url']}{RESET}\n")
504
+
505
+ try:
506
+ webbrowser.open(auth["verification_url"])
507
+ _info("已尝试自动在浏览器中打开")
508
+ except Exception:
509
+ pass
510
+
511
+ _try_print_qrcode(auth["verification_url"])
512
+
513
+ try:
514
+ poll_device_token(app_id, app_secret,
515
+ auth["device_code"], auth["expires_in"], auth["interval"],
516
+ brand)
517
+ return True
518
+ except RuntimeError as e:
519
+ _warn(f"授权未完成:{e}")
520
+ return False
521
+ except Exception as e:
522
+ _warn(f"授权出错:{e}")
523
+ return False
524
+
525
+
526
+ # ── 凭证验证 ───────────────────────────────────────────────────────────────
527
+
528
+ def verify_credentials(app_id: str, app_secret: str) -> tuple[bool, str]:
529
+ """
530
+ 验证飞书应用凭证是否有效。
531
+
532
+ 返回 (成功, 错误信息或 tenant_access_token)。
533
+ """
534
+ try:
535
+ resp = _post_json(
536
+ f"{FEISHU_OPEN_URL}/open-apis/auth/v3/tenant_access_token/internal",
537
+ {"app_id": app_id, "app_secret": app_secret},
538
+ )
539
+ if resp.get("code") == 0:
540
+ return True, resp.get("tenant_access_token", "")
541
+ return False, f"code={resp.get('code')}, msg={resp.get('msg', '未知错误')}"
542
+ except Exception as e:
543
+ return False, str(e)
544
+
545
+
546
+ # ── .env 文件读写 ──────────────────────────────────────────────────────────
547
+
548
+ def _read_env_file(path: Path) -> list[str]:
549
+ """读取 .env 文件的所有行。"""
550
+ if path.exists():
551
+ return path.read_text(encoding="utf-8").splitlines()
552
+ return []
553
+
554
+
555
+ def write_env_file(app_id: str, app_secret: str) -> Path:
556
+ """
557
+ 写入 app_id 和 app_secret 到 ~/.remote-claude/.env。
558
+
559
+ 如果文件已存在,只更新 FEISHU_APP_ID 和 FEISHU_APP_SECRET 两行,保留其他配置。
560
+ 如果文件不存在,从 .env.example 生成。
561
+ """
562
+ env_path = get_env_file()
563
+ USER_DATA_DIR.mkdir(parents=True, exist_ok=True)
564
+
565
+ lines = _read_env_file(env_path)
566
+
567
+ if not lines:
568
+ # 从 .env.example 生成
569
+ example_path = Path(_PROJECT_ROOT) / ".env.example"
570
+ if example_path.exists():
571
+ lines = example_path.read_text(encoding="utf-8").splitlines()
572
+ else:
573
+ lines = [
574
+ "# Remote Claude 飞书客户端配置",
575
+ "FEISHU_APP_ID=",
576
+ "FEISHU_APP_SECRET=",
577
+ ]
578
+
579
+ # 更新或追加 FEISHU_APP_ID / FEISHU_APP_SECRET
580
+ updated_id = False
581
+ updated_secret = False
582
+ new_lines = []
583
+ for line in lines:
584
+ stripped = line.strip()
585
+ if stripped.startswith("FEISHU_APP_ID=") and not stripped.startswith("#"):
586
+ new_lines.append(f"FEISHU_APP_ID={app_id}")
587
+ updated_id = True
588
+ elif stripped.startswith("FEISHU_APP_SECRET=") and not stripped.startswith("#"):
589
+ new_lines.append(f"FEISHU_APP_SECRET={app_secret}")
590
+ updated_secret = True
591
+ else:
592
+ new_lines.append(line)
593
+
594
+ if not updated_id:
595
+ new_lines.append(f"FEISHU_APP_ID={app_id}")
596
+ if not updated_secret:
597
+ new_lines.append(f"FEISHU_APP_SECRET={app_secret}")
598
+
599
+ content = "\n".join(new_lines) + "\n"
600
+
601
+ # 写入(权限 0600)
602
+ fd = os.open(str(env_path), os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600)
603
+ with os.fdopen(fd, "w", encoding="utf-8") as f:
604
+ f.write(content)
605
+
606
+ return env_path
607
+
608
+
609
+ def _read_current_config() -> tuple[str, str]:
610
+ """读取当前 .env 中的 app_id 和 app_secret。"""
611
+ env_path = get_env_file()
612
+ if not env_path.exists():
613
+ return "", ""
614
+ for line in env_path.read_text(encoding="utf-8").splitlines():
615
+ stripped = line.strip()
616
+ if stripped.startswith("FEISHU_APP_ID=") and not stripped.startswith("#"):
617
+ _, _, val = stripped.partition("=")
618
+ app_id = val.strip()
619
+ elif stripped.startswith("FEISHU_APP_SECRET=") and not stripped.startswith("#"):
620
+ _, _, val = stripped.partition("=")
621
+ app_secret = val.strip()
622
+ return locals().get("app_id", ""), locals().get("app_secret", "")
623
+
624
+
625
+ # ── 应用配置 Checklist ─────────────────────────────────────────────────────
626
+
627
+ # remote_claude 需要的最小权限集
628
+ REQUIRED_PERMISSIONS = [
629
+ ("base:app:read", "读取多维表格应用信息"),
630
+ ("base:field:read", "读取多维表格字段"),
631
+ ("base:form:read", "读取多维表格表单"),
632
+ ("base:record:read", "读取多维表格记录"),
633
+ ("base:record:retrieve", "检索多维表格记录"),
634
+ ("base:table:read", "读取多维表格表格"),
635
+ ("board:whiteboard:node:read", "读取白板节点"),
636
+ ("calendar:calendar.event:create", "创建日历事件"),
637
+ ("calendar:calendar.event:delete", "删除日历事件"),
638
+ ("calendar:calendar.event:read", "读取日历事件"),
639
+ ("calendar:calendar.event:reply", "回复日历事件"),
640
+ ("calendar:calendar.event:update", "更新日历事件"),
641
+ ("calendar:calendar.free_busy:read", "读取日历忙闲状态"),
642
+ ("calendar:calendar:read", "读取日历信息"),
643
+ ("cardkit:card:write", "写入卡片内容"),
644
+ ("contact:contact.base:readonly", "读取通讯录基本信息"),
645
+ ("contact:user.base:readonly", "读取用户基本信息"),
646
+ ("contact:user.employee_id:readonly", "读取用户工号"),
647
+ ("contact:user.id:readonly", "读取用户 ID"),
648
+ ("docs:document.comment:read", "读取文档评论"),
649
+ ("docs:document.content:read", "读取文档内容"),
650
+ ("docs:document.media:download", "下载文档媒体"),
651
+ ("docs:document.media:upload", "上传文档媒体"),
652
+ ("docs:document:import", "导入文档"),
653
+ ("docs:permission.member:auth", "校验文档成员权限"),
654
+ ("docs:permission.member:create", "创建文档成员"),
655
+ ("docs:permission.member:transfer", "转让文档所有者"),
656
+ ("docx:document.block:convert", "转换文档块"),
657
+ ("docx:document:create", "创建 Docx 文档"),
658
+ ("docx:document:readonly", "只读 Docx 文档"),
659
+ ("docx:document:write_only", "写入 Docx 文档"),
660
+ ("drive:drive.metadata:readonly", "读取云空间元数据"),
661
+ ("drive:drive.search:readonly", "搜索云空间文件"),
662
+ ("drive:drive:version:readonly", "读取云空间版本"),
663
+ ("drive:file:download", "下载云空间文件"),
664
+ ("drive:file:upload", "上传云空间文件"),
665
+ ("im:chat.managers:write_only", "管理群聊管理员"),
666
+ ("im:chat.members:read", "读取群成员"),
667
+ ("im:chat.members:write_only", "管理群成员"),
668
+ ("im:chat.tabs:read", "读取群标签页"),
669
+ ("im:chat.tabs:write_only", "管理群标签页"),
670
+ ("im:chat.top_notice:write_only", "设置群置顶公告"),
671
+ ("im:chat:create", "创建群聊"),
672
+ ("im:chat:delete", "删除群聊"),
673
+ ("im:chat:operate_as_owner", "以群主身份操作"),
674
+ ("im:chat:read", "读取群聊信息"),
675
+ ("im:chat:update", "更新群聊信息"),
676
+ ("im:message.group_at_msg:readonly", "读取群 @ 消息"),
677
+ ("im:message.group_msg", "发送群消息"),
678
+ ("im:message.p2p_msg:readonly", "读取私聊消息"),
679
+ ("im:message.reactions:read", "读取消息表情回应"),
680
+ ("im:message.reactions:write_only", "管理消息表情回应"),
681
+ ("im:message.urgent", "发送加急消息"),
682
+ ("im:message.urgent.status:write", "更新加急消息状态"),
683
+ ("im:message:readonly", "只读消息"),
684
+ ("im:message:recall", "撤回消息"),
685
+ ("im:message:send_as_bot", "以机器人身份发消息"),
686
+ ("im:message:update", "更新消息"),
687
+ ("im:resource", "上传/下载消息资源"),
688
+ ("search:docs:read", "搜索文档"),
689
+ ("sheets:spreadsheet.meta:read", "读取电子表格元数据"),
690
+ ("sheets:spreadsheet.meta:write_only", "写入电子表格元数据"),
691
+ ("sheets:spreadsheet:create", "创建电子表格"),
692
+ ("sheets:spreadsheet:read", "读取电子表格"),
693
+ ("sheets:spreadsheet:write_only", "写入电子表格"),
694
+ ("space:document:delete", "删除知识空间文档"),
695
+ ("space:document:retrieve", "检索知识空间文档"),
696
+ ("task:task:read", "读取任务"),
697
+ ("task:task:readonly", "只读任务"),
698
+ ("task:task:write", "写入任务"),
699
+ ("task:task:writeonly", "只写任务"),
700
+ ("task:tasklist:read", "读取任务列表"),
701
+ ("wiki:wiki:readonly", "只读知识库"),
702
+ ]
703
+
704
+ REQUIRED_EVENTS = [
705
+ ("im.message.receive_v1", "接收消息事件"),
706
+ ("card.action.trigger", "卡片交互回调"),
707
+ ]
708
+
709
+
710
+ def print_checklist(app_id: str):
711
+ """打印需要手动完成的飞书开放平台配置步骤。"""
712
+ base = f"https://open.feishu.cn/app/{app_id}"
713
+
714
+ print(f"\n{YELLOW}{BOLD}━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━{RESET}")
715
+ print(f"{YELLOW}{BOLD} 还需在飞书开放平台完成以下配置{RESET}")
716
+ print(f"{YELLOW}{BOLD}━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━{RESET}")
717
+
718
+ print(f"""
719
+ {BOLD}1. 启用「机器人」能力{RESET}
720
+ → {CYAN}{base}/bot{RESET}
721
+ 勾选「机器人」,保存
722
+
723
+ {BOLD}2. 配置事件订阅{RESET}(选择「使用长连接接收事件」模式)
724
+ → {CYAN}{base}/event{RESET}
725
+ 添加以下事件:""")
726
+ for event, desc in REQUIRED_EVENTS:
727
+ print(f" - {GREEN}{event}{RESET} {DIM}({desc}){RESET}")
728
+
729
+ print(f"""
730
+ {BOLD}3. 添加 API 权限{RESET}
731
+ → {CYAN}{base}/permission{RESET}
732
+ 申请以下权限:""")
733
+ for perm, desc in REQUIRED_PERMISSIONS:
734
+ print(f" - {GREEN}{perm}{RESET} {DIM}({desc}){RESET}")
735
+
736
+ print(f"""
737
+ {BOLD}4. 创建版本并发布应用{RESET}
738
+ → {CYAN}{base}/version{RESET}
739
+ 点击「创建版本」→ 填写版本信息 → 提交审核(或直接发布)
740
+
741
+ {DIM}提示:如果是个人开发者测试,可以在审批流程中选择「无需审批直接启用」{RESET}
742
+ """)
743
+
744
+ print(f"完成以上步骤后,运行以下命令验证配置:")
745
+ print(f" {CYAN}remote-claude lark init --check{RESET}\n")
746
+
747
+
748
+ # ── Check 模式 ─────────────────────────────────────────────────────────────
749
+
750
+ def run_check():
751
+ """检查当前配置状态,验证凭证有效性。"""
752
+ _print_header()
753
+ print(f"{BOLD}配置检查模式{RESET}\n")
754
+
755
+ env_path = get_env_file()
756
+
757
+ # 检查 .env 文件
758
+ if not env_path.exists():
759
+ _err(f"配置文件不存在:{env_path}")
760
+ print(f"\n运行 {CYAN}remote-claude lark init{RESET} 开始配置")
761
+ return 1
762
+
763
+ _ok(f"配置文件:{env_path}")
764
+
765
+ app_id, app_secret = _read_current_config()
766
+
767
+ if not app_id or app_id in ("cli_xxxxx", ""):
768
+ _err("FEISHU_APP_ID 未配置")
769
+ return 1
770
+ _ok(f"App ID:{app_id}")
771
+
772
+ if not app_secret or app_secret in ("xxxxx", ""):
773
+ _err("FEISHU_APP_SECRET 未配置")
774
+ return 1
775
+ _ok(f"App Secret:{'*' * 8}{app_secret[-4:] if len(app_secret) > 4 else '****'}")
776
+
777
+ # 验证凭证
778
+ print(f"\n 验证凭证...", end="", flush=True)
779
+ ok, result = verify_credentials(app_id, app_secret)
780
+ if ok:
781
+ print(f" {GREEN}✓ 有效{RESET}")
782
+ else:
783
+ print(f" {RED}✗ 无效{RESET}")
784
+ _err(f"凭证验证失败:{result}")
785
+ return 1
786
+
787
+ # 尝试 WebSocket 连接检测
788
+ print(f"\n 尝试 WebSocket 连接...", end="", flush=True)
789
+ ws_ok, ws_msg = _check_websocket(app_id, app_secret)
790
+ if ws_ok:
791
+ print(f" {GREEN}✓ 成功{RESET}")
792
+ print(f"\n{GREEN}{BOLD}✅ 配置完整,飞书机器人已就绪!{RESET}")
793
+ print(f"\n现在可以启动:{CYAN}remote-claude lark start{RESET}\n")
794
+ else:
795
+ print(f" {YELLOW}⚠{RESET}")
796
+ _warn(f"WebSocket 连接失败:{ws_msg}")
797
+ _warn("可能原因:未启用机器人能力、未配置事件订阅或应用未发布")
798
+ print()
799
+ print_checklist(app_id)
800
+ return 1
801
+
802
+ return 0
803
+
804
+
805
+ def _check_websocket(app_id: str, app_secret: str) -> tuple[bool, str]:
806
+ """尝试建立 WebSocket 连接测试机器人能力是否就绪。"""
807
+ try:
808
+ import lark_oapi as lark
809
+ # 仅测试能否获取 WS endpoint,不真正建立持久连接
810
+ client = lark.Client.builder().app_id(app_id).app_secret(app_secret).build()
811
+ # 调用一个轻量级 API 测试 bot 权限:获取机器人信息
812
+ from lark_oapi.api.bot.v3 import GetBotInfoRequest
813
+ req = GetBotInfoRequest.builder().build()
814
+ resp = client.bot.v3.bot.get(req)
815
+ if resp.success():
816
+ return True, ""
817
+ return False, f"code={resp.code}, msg={resp.msg}"
818
+ except ImportError:
819
+ # 没有 lark_oapi,退化为 API 检查
820
+ return _check_bot_api(app_id, app_secret)
821
+ except Exception as e:
822
+ return False, str(e)
823
+
824
+
825
+ def _check_bot_api(app_id: str, app_secret: str) -> tuple[bool, str]:
826
+ """使用飞书 REST API 检查机器人能力。"""
827
+ try:
828
+ # 获取 tenant token
829
+ ok, token = verify_credentials(app_id, app_secret)
830
+ if not ok:
831
+ return False, token
832
+
833
+ # 调用获取机器人信息 API
834
+ req = urllib.request.Request(
835
+ f"{FEISHU_OPEN_URL}/open-apis/bot/v3/info",
836
+ headers={"Authorization": f"Bearer {token}"},
837
+ )
838
+ with urllib.request.urlopen(req, timeout=10) as resp:
839
+ data = json.loads(resp.read().decode("utf-8"))
840
+
841
+ if data.get("code") == 0:
842
+ bot = data.get("bot", {})
843
+ bot_name = bot.get("app_name", "未知")
844
+ _ok(f"机器人名称:{bot_name}")
845
+ return True, ""
846
+ elif data.get("code") == 230013:
847
+ return False, "应用未启用机器人能力(需在开放平台启用)"
848
+ else:
849
+ return False, f"code={data.get('code')}, msg={data.get('msg', '')}"
850
+ except Exception as e:
851
+ return False, str(e)
852
+
853
+
854
+ # ── 主向导流程 ─────────────────────────────────────────────────────────────
855
+
856
+ class SetupWizard:
857
+ """飞书机器人配置向导。"""
858
+
859
+ def __init__(self, check_only: bool = False, new_only: bool = False):
860
+ self.check_only = check_only
861
+ self.new_only = new_only
862
+
863
+ def run(self) -> int:
864
+ if self.check_only:
865
+ return run_check()
866
+
867
+ try:
868
+ return self._run_wizard(new_mode=self.new_only)
869
+ except KeyboardInterrupt:
870
+ print(f"\n\n{YELLOW}已取消配置{RESET}\n")
871
+ return 1
872
+
873
+ def _run_wizard(self, new_mode: bool = False) -> int:
874
+ _print_header()
875
+
876
+ if new_mode:
877
+ print(f"{BOLD}创建新飞书应用{RESET} {DIM}(不会修改现有配置){RESET}\n")
878
+ else:
879
+ # ── 阶段 0:检查已有配置 ─────────────────────────────────────
880
+ _print_step(0, "检查现有配置")
881
+
882
+ env_path = get_env_file()
883
+ existing_id, existing_secret = _read_current_config()
884
+
885
+ if existing_id and existing_id not in ("cli_xxxxx", ""):
886
+ masked_id = existing_id
887
+ masked_secret = ('*' * 8 + existing_secret[-4:]) if len(existing_secret) > 4 else "****"
888
+ _ok(f"已有配置:{masked_id} / {masked_secret}")
889
+ ans = _read_input(f" 是否重新配置?(y/N)", default="n")
890
+ if ans.lower() not in ("y", "yes"):
891
+ print(f"\n保持现有配置。运行 {CYAN}remote-claude lark init --check{RESET} 可验证配置状态。\n")
892
+ return 0
893
+
894
+ # ── 阶段 1:获取凭证 ───────────────────────────────────────────────
895
+ _print_step(1, "扫码创建飞书应用")
896
+
897
+ app_id, app_secret = self._get_credentials_via_scan()
898
+
899
+ if not app_id or not app_secret:
900
+ _err("凭证获取失败")
901
+ return 1
902
+
903
+ # ── 阶段 2:验证凭证 ───────────────────────────────────────────────
904
+ _print_step(2, "验证凭证")
905
+
906
+ print(f" 验证中...", end="", flush=True)
907
+ ok, result = verify_credentials(app_id, app_secret)
908
+ if ok:
909
+ print(f" {GREEN}✓ 有效{RESET}")
910
+ else:
911
+ print(f" {RED}✗{RESET}")
912
+ _err(f"凭证无效:{result}")
913
+ return 1
914
+
915
+ # ── 阶段 3:应用身份权限开通(tenant scope)──────────────────────
916
+ _print_step(3, "开通应用身份权限")
917
+ authorize_tenant_scopes(app_id)
918
+ _ok("应用身份权限配置完成")
919
+
920
+ # ── 阶段 4:用户身份权限授权(预选权限,一键确认)──────────────
921
+ _print_step(4, "授权用户身份权限")
922
+ print(f" {DIM}页面中的权限已自动预选,直接点击确认即可{RESET}")
923
+ scope_ok = authorize_app_scopes(app_id, app_secret)
924
+ if scope_ok:
925
+ _ok("用户身份权限授权完成")
926
+ else:
927
+ _warn("用户身份权限授权跳过,稍后可手动在开放平台配置")
928
+
929
+ # ── 阶段 5:发布应用 ───────────────────────────────────────────────
930
+ _print_step(5, "发布应用(必须完成,否则无法使用)")
931
+
932
+ publish_url = f"https://open.feishu.cn/app/{app_id}/version/create"
933
+ print(f" {DIM}未发布的应用无法被飞书用户搜索和使用。{RESET}")
934
+ print(f" 正在打开发布页面:{CYAN}{publish_url}{RESET}\n")
935
+ try:
936
+ webbrowser.open(publish_url)
937
+ except Exception:
938
+ pass
939
+
940
+ print(f" 请在浏览器中按以下步骤操作:")
941
+ print(f" 1. 填写版本号")
942
+ print(f" 2. 填写更新说明(随便填)")
943
+ print(f" 3. 点击「保存」")
944
+ print(f" 4. 点击「确认发布」\n")
945
+ input(f" {DIM}完成发布后按 Enter 继续...{RESET}")
946
+
947
+ # ── 阶段 6:保存凭证 ───────────────────────────────────────────────
948
+ _print_step(6, "保存凭证")
949
+
950
+ if new_mode:
951
+ print(f"""
952
+ {CYAN}{BOLD}━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━{RESET}
953
+ {BOLD}应用凭证(请妥善保存){RESET}
954
+ {CYAN}{BOLD}━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━{RESET}
955
+
956
+ {BOLD}FEISHU_APP_ID{RESET} = {GREEN}{app_id}{RESET}
957
+ {BOLD}FEISHU_APP_SECRET{RESET} = {GREEN}{app_secret}{RESET}
958
+ """)
959
+ else:
960
+ saved_path = write_env_file(app_id, app_secret)
961
+ _ok(f"已写入:{saved_path}")
962
+
963
+ print()
964
+ _ok("配置全部完成!")
965
+ if not new_mode:
966
+ print(f"\n 运行以下命令验证配置:")
967
+ print(f" {CYAN}remote-claude lark init --check{RESET}\n")
968
+
969
+ return 0
970
+
971
+ def _get_credentials_via_scan(self) -> tuple[str, str]:
972
+ """通过扫码创建应用获取凭证。"""
973
+ print()
974
+ try:
975
+ app_id, app_secret = create_app_via_scan("feishu")
976
+ _ok(f"应用已创建:{app_id}")
977
+ return app_id, app_secret
978
+ except RuntimeError as e:
979
+ _err(str(e))
980
+ return "", ""
981
+ except Exception as e:
982
+ _err(f"创建失败:{e}")
983
+ return "", ""
984
+
985
+ def main():
986
+ """命令行入口。"""
987
+ import argparse
988
+ parser = argparse.ArgumentParser(description="飞书机器人配置向导")
989
+ group = parser.add_mutually_exclusive_group()
990
+ group.add_argument("--check", action="store_true", help="仅检查现有配置状态")
991
+ group.add_argument("--new", action="store_true", help="扫码创建新应用(不修改已有配置)")
992
+ args = parser.parse_args()
993
+
994
+ wizard = SetupWizard(check_only=args.check, new_only=args.new)
995
+ sys.exit(wizard.run())
996
+
997
+
998
+ if __name__ == "__main__":
999
+ main()