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