Skip to content

Commit aace90d

Browse files
authored
feat: supports scan QR code to configure feishu / lark (#8191)
* feat(lark): implement app registration and bot info retrieval - Add app registration functionality for Lark and Feishu platforms, including endpoints and request handling. - Introduce polling mechanism for app registration status. - Create bot info retrieval functionality to fetch bot details after successful registration. - Enhance dashboard with new UI components for one-click QR setup and manual setup options. - Update internationalization files to support new features and actions. - Add unit tests for app registration endpoint resolution and data handling. * feat(weixin_oc): add WeChat login registration and QR code handling
1 parent 094c2de commit aace90d

24 files changed

Lines changed: 1440 additions & 68 deletions

astrbot/core/config/default.py

Lines changed: 16 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -318,7 +318,7 @@
318318
"QQ 官方机器人(WebSocket)": {
319319
"id": "default",
320320
"type": "qq_official",
321-
"enable": False,
321+
"enable": True,
322322
"appid": "",
323323
"secret": "",
324324
"enable_group_c2c": True,
@@ -327,7 +327,7 @@
327327
"QQ 官方机器人(Webhook)": {
328328
"id": "default",
329329
"type": "qq_official_webhook",
330-
"enable": False,
330+
"enable": True,
331331
"appid": "",
332332
"secret": "",
333333
"is_sandbox": False,
@@ -339,15 +339,15 @@
339339
"OneBot v11": {
340340
"id": "default",
341341
"type": "aiocqhttp",
342-
"enable": False,
342+
"enable": True,
343343
"ws_reverse_host": "0.0.0.0",
344344
"ws_reverse_port": 6199,
345345
"ws_reverse_token": "",
346346
},
347347
"微信公众平台": {
348348
"id": "weixin_official_account",
349349
"type": "weixin_official_account",
350-
"enable": False,
350+
"enable": True,
351351
"appid": "",
352352
"secret": "",
353353
"token": "",
@@ -362,7 +362,7 @@
362362
"企业微信(含微信客服)": {
363363
"id": "wecom",
364364
"type": "wecom",
365-
"enable": False,
365+
"enable": True,
366366
"corpid": "",
367367
"secret": "",
368368
"token": "",
@@ -399,7 +399,7 @@
399399
"个人微信": {
400400
"id": "weixin_personal",
401401
"type": "weixin_oc",
402-
"enable": False,
402+
"enable": True,
403403
"weixin_oc_base_url": "https://ilinkai.weixin.qq.com",
404404
"weixin_oc_bot_type": "3",
405405
"weixin_oc_qr_poll_interval": 1,
@@ -409,8 +409,7 @@
409409
"飞书(Lark)": {
410410
"id": "lark",
411411
"type": "lark",
412-
"enable": False,
413-
"lark_bot_name": "",
412+
"enable": True,
414413
"app_id": "",
415414
"app_secret": "",
416415
"domain": "https://open.feishu.cn",
@@ -422,15 +421,15 @@
422421
"钉钉(DingTalk)": {
423422
"id": "dingtalk",
424423
"type": "dingtalk",
425-
"enable": False,
424+
"enable": True,
426425
"client_id": "",
427426
"client_secret": "",
428427
"card_template_id": "",
429428
},
430429
"Telegram": {
431430
"id": "telegram",
432431
"type": "telegram",
433-
"enable": False,
432+
"enable": True,
434433
"telegram_token": "your_bot_token",
435434
"start_message": "Hello, I'm AstrBot!",
436435
"telegram_api_base_url": "https://api.telegram.org/bot",
@@ -443,7 +442,7 @@
443442
"Discord": {
444443
"id": "discord",
445444
"type": "discord",
446-
"enable": False,
445+
"enable": True,
447446
"discord_token": "",
448447
"discord_proxy": "",
449448
"discord_command_register": True,
@@ -453,7 +452,7 @@
453452
"Misskey": {
454453
"id": "misskey",
455454
"type": "misskey",
456-
"enable": False,
455+
"enable": True,
457456
"misskey_instance_url": "https://misskey.example",
458457
"misskey_token": "",
459458
"misskey_default_visibility": "public",
@@ -471,7 +470,7 @@
471470
"Slack": {
472471
"id": "slack",
473472
"type": "slack",
474-
"enable": False,
473+
"enable": True,
475474
"bot_token": "",
476475
"app_token": "",
477476
"signing_secret": "",
@@ -485,7 +484,7 @@
485484
"Line": {
486485
"id": "line",
487486
"type": "line",
488-
"enable": False,
487+
"enable": True,
489488
"channel_access_token": "",
490489
"channel_secret": "",
491490
"unified_webhook_mode": True,
@@ -494,7 +493,7 @@
494493
"Satori": {
495494
"id": "satori",
496495
"type": "satori",
497-
"enable": False,
496+
"enable": True,
498497
"satori_api_base_url": "http://localhost:5140/satori/v1",
499498
"satori_endpoint": "ws://localhost:5140/satori/v1/events",
500499
"satori_token": "",
@@ -505,7 +504,7 @@
505504
"KOOK": {
506505
"id": "kook",
507506
"type": "kook",
508-
"enable": False,
507+
"enable": True,
509508
"kook_bot_token": "",
510509
"kook_reconnect_delay": 1,
511510
"kook_max_reconnect_delay": 60,
@@ -518,7 +517,7 @@
518517
"Mattermost": {
519518
"id": "mattermost",
520519
"type": "mattermost",
521-
"enable": False,
520+
"enable": True,
522521
"mattermost_url": "https://chat.example.com",
523522
"mattermost_bot_token": "",
524523
"mattermost_reconnect_delay": 5.0,
@@ -889,11 +888,6 @@
889888
"wecom_ai_bot_connection_mode": "long_connection",
890889
},
891890
},
892-
"lark_bot_name": {
893-
"description": "飞书机器人的名字",
894-
"type": "string",
895-
"hint": "请务必填写正确,否则 @ 机器人将无法唤醒,只能通过前缀唤醒。",
896-
},
897891
"discord_token": {
898892
"description": "Discord Bot Token",
899893
"type": "string",
Lines changed: 205 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,205 @@
1+
from dataclasses import dataclass
2+
from typing import Any
3+
from urllib.parse import urlencode
4+
5+
import aiohttp
6+
7+
DEFAULT_FEISHU_OPEN_DOMAIN = "https://open.feishu.cn"
8+
DEFAULT_LARK_OPEN_DOMAIN = "https://open.larksuite.com"
9+
APP_REGISTRATION_PATH = "/oauth/v1/app/registration"
10+
11+
12+
@dataclass
13+
class LarkAppRegistrationEndpoints:
14+
accounts_base: str
15+
open_base: str
16+
registration: str
17+
18+
19+
@dataclass
20+
class LarkAppRegistration:
21+
device_code: str
22+
user_code: str
23+
verification_uri: str
24+
verification_uri_complete: str
25+
expires_in: int
26+
interval: int
27+
28+
29+
def resolve_app_registration_endpoints(
30+
domain: str,
31+
) -> LarkAppRegistrationEndpoints:
32+
normalized = (domain or DEFAULT_FEISHU_OPEN_DOMAIN).strip().rstrip("/")
33+
if normalized in {"feishu", DEFAULT_FEISHU_OPEN_DOMAIN}:
34+
accounts_base = "https://accounts.feishu.cn"
35+
open_base = DEFAULT_FEISHU_OPEN_DOMAIN
36+
elif normalized in {"lark", DEFAULT_LARK_OPEN_DOMAIN}:
37+
accounts_base = "https://accounts.larksuite.com"
38+
open_base = DEFAULT_LARK_OPEN_DOMAIN
39+
else:
40+
open_base = normalized
41+
accounts_base = normalized.replace("://open.", "://accounts.", 1)
42+
43+
return LarkAppRegistrationEndpoints(
44+
accounts_base=accounts_base,
45+
open_base=open_base,
46+
registration=f"{accounts_base}{APP_REGISTRATION_PATH}",
47+
)
48+
49+
50+
def _registration_data(raw: dict[str, Any]) -> dict[str, Any]:
51+
data = raw.get("data")
52+
if isinstance(data, dict):
53+
return data
54+
return raw
55+
56+
57+
def _string_field(data: dict[str, Any], key: str) -> str:
58+
value = data.get(key)
59+
if isinstance(value, str):
60+
return value
61+
return ""
62+
63+
64+
def _int_field(data: dict[str, Any], key: str, default: int) -> int:
65+
value = data.get(key)
66+
if isinstance(value, int):
67+
return value
68+
if isinstance(value, float):
69+
return int(value)
70+
return default
71+
72+
73+
async def _post_registration(
74+
endpoint: str,
75+
form: dict[str, str],
76+
) -> tuple[int, dict[str, Any]]:
77+
timeout = aiohttp.ClientTimeout(total=15)
78+
async with aiohttp.ClientSession(timeout=timeout, trust_env=True) as session:
79+
async with session.post(
80+
endpoint,
81+
data=form,
82+
headers={"Content-Type": "application/x-www-form-urlencoded"},
83+
) as response:
84+
status = response.status
85+
data = await response.json(content_type=None)
86+
if not isinstance(data, dict):
87+
raise RuntimeError("飞书应用创建响应格式异常")
88+
return status, data
89+
90+
91+
def _raise_registration_error(status: int, raw: dict[str, Any], fallback: str) -> None:
92+
data = _registration_data(raw)
93+
if status < 400 and not raw.get("error") and not data.get("error"):
94+
return
95+
message = (
96+
_string_field(raw, "error_description")
97+
or _string_field(data, "error_description")
98+
or _string_field(raw, "error")
99+
or _string_field(data, "error")
100+
or fallback
101+
)
102+
raise RuntimeError(message)
103+
104+
105+
async def request_app_registration(domain: str) -> LarkAppRegistration:
106+
endpoints = resolve_app_registration_endpoints(domain)
107+
status, raw = await _post_registration(
108+
endpoints.registration,
109+
{
110+
"action": "begin",
111+
"archetype": "PersonalAgent",
112+
"auth_method": "client_secret",
113+
"request_user_info": "open_id tenant_brand",
114+
},
115+
)
116+
_raise_registration_error(status, raw, "发起扫码创建失败")
117+
data = _registration_data(raw)
118+
user_code = _string_field(data, "user_code")
119+
verification_uri = _string_field(data, "verification_uri")
120+
verification_uri_complete = _string_field(data, "verification_uri_complete")
121+
if not verification_uri_complete and user_code:
122+
verification_uri_complete = (
123+
f"{endpoints.open_base}/page/cli?{urlencode({'user_code': user_code})}"
124+
)
125+
126+
return LarkAppRegistration(
127+
device_code=_string_field(data, "device_code"),
128+
user_code=user_code,
129+
verification_uri=verification_uri,
130+
verification_uri_complete=verification_uri_complete,
131+
expires_in=_int_field(data, "expires_in", 300),
132+
interval=_int_field(data, "interval", 5),
133+
)
134+
135+
136+
def _tenant_brand(data: dict[str, Any]) -> str:
137+
user_info = data.get("user_info")
138+
if isinstance(user_info, dict):
139+
return _string_field(user_info, "tenant_brand")
140+
return _string_field(data, "tenant_brand")
141+
142+
143+
async def poll_app_registration_once(
144+
*,
145+
domain: str,
146+
device_code: str,
147+
) -> dict[str, Any]:
148+
endpoints = resolve_app_registration_endpoints(domain)
149+
status, raw = await _post_registration(
150+
endpoints.registration,
151+
{
152+
"action": "poll",
153+
"device_code": device_code,
154+
},
155+
)
156+
data = _registration_data(raw)
157+
error = _string_field(raw, "error") or _string_field(data, "error")
158+
client_id = _string_field(data, "client_id")
159+
client_secret = _string_field(data, "client_secret")
160+
tenant_brand = _tenant_brand(data)
161+
162+
if status < 400 and not error and client_id:
163+
if not client_secret and tenant_brand == "lark":
164+
client_secret = await _poll_lark_secret(device_code)
165+
if not client_secret:
166+
return {"status": "error", "message": "应用创建成功但未获取到凭证"}
167+
return {
168+
"status": "created",
169+
"app_id": client_id,
170+
"app_secret": client_secret,
171+
"tenant_brand": tenant_brand,
172+
"domain": DEFAULT_LARK_OPEN_DOMAIN
173+
if tenant_brand == "lark"
174+
else DEFAULT_FEISHU_OPEN_DOMAIN,
175+
}
176+
if error == "authorization_pending":
177+
return {"status": "pending"}
178+
if error == "slow_down":
179+
return {"status": "slow_down"}
180+
if error == "access_denied":
181+
return {"status": "denied", "message": "用户取消了扫码创建"}
182+
if error in {"expired_token", "invalid_grant"}:
183+
return {"status": "expired", "message": "扫码已过期,请再次创建"}
184+
185+
message = (
186+
_string_field(raw, "error_description")
187+
or _string_field(data, "error_description")
188+
or error
189+
or "获取扫码创建状态失败"
190+
)
191+
return {"status": "error", "message": message}
192+
193+
194+
async def _poll_lark_secret(device_code: str) -> str:
195+
endpoints = resolve_app_registration_endpoints(DEFAULT_LARK_OPEN_DOMAIN)
196+
status, raw = await _post_registration(
197+
endpoints.registration,
198+
{
199+
"action": "poll",
200+
"device_code": device_code,
201+
},
202+
)
203+
if status >= 400 or raw.get("error"):
204+
return ""
205+
return _string_field(_registration_data(raw), "client_secret")

0 commit comments

Comments
 (0)