mirror of
https://github.com/AstrBotDevs/AstrBot
synced 2026-07-02 02:30:16 +08:00
Compare commits
2 Commits
codex/rest
...
feat/lark-
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b991e81977 | ||
|
|
8dde2292fb |
@@ -318,7 +318,7 @@ CONFIG_METADATA_2 = {
|
||||
"QQ 官方机器人(WebSocket)": {
|
||||
"id": "default",
|
||||
"type": "qq_official",
|
||||
"enable": False,
|
||||
"enable": True,
|
||||
"appid": "",
|
||||
"secret": "",
|
||||
"enable_group_c2c": True,
|
||||
@@ -327,7 +327,7 @@ CONFIG_METADATA_2 = {
|
||||
"QQ 官方机器人(Webhook)": {
|
||||
"id": "default",
|
||||
"type": "qq_official_webhook",
|
||||
"enable": False,
|
||||
"enable": True,
|
||||
"appid": "",
|
||||
"secret": "",
|
||||
"is_sandbox": False,
|
||||
@@ -339,7 +339,7 @@ CONFIG_METADATA_2 = {
|
||||
"OneBot v11": {
|
||||
"id": "default",
|
||||
"type": "aiocqhttp",
|
||||
"enable": False,
|
||||
"enable": True,
|
||||
"ws_reverse_host": "0.0.0.0",
|
||||
"ws_reverse_port": 6199,
|
||||
"ws_reverse_token": "",
|
||||
@@ -347,7 +347,7 @@ CONFIG_METADATA_2 = {
|
||||
"微信公众平台": {
|
||||
"id": "weixin_official_account",
|
||||
"type": "weixin_official_account",
|
||||
"enable": False,
|
||||
"enable": True,
|
||||
"appid": "",
|
||||
"secret": "",
|
||||
"token": "",
|
||||
@@ -362,7 +362,7 @@ CONFIG_METADATA_2 = {
|
||||
"企业微信(含微信客服)": {
|
||||
"id": "wecom",
|
||||
"type": "wecom",
|
||||
"enable": False,
|
||||
"enable": True,
|
||||
"corpid": "",
|
||||
"secret": "",
|
||||
"token": "",
|
||||
@@ -399,7 +399,7 @@ CONFIG_METADATA_2 = {
|
||||
"个人微信": {
|
||||
"id": "weixin_personal",
|
||||
"type": "weixin_oc",
|
||||
"enable": False,
|
||||
"enable": True,
|
||||
"weixin_oc_base_url": "https://ilinkai.weixin.qq.com",
|
||||
"weixin_oc_bot_type": "3",
|
||||
"weixin_oc_qr_poll_interval": 1,
|
||||
@@ -409,8 +409,7 @@ CONFIG_METADATA_2 = {
|
||||
"飞书(Lark)": {
|
||||
"id": "lark",
|
||||
"type": "lark",
|
||||
"enable": False,
|
||||
"lark_bot_name": "",
|
||||
"enable": True,
|
||||
"app_id": "",
|
||||
"app_secret": "",
|
||||
"domain": "https://open.feishu.cn",
|
||||
@@ -422,7 +421,7 @@ CONFIG_METADATA_2 = {
|
||||
"钉钉(DingTalk)": {
|
||||
"id": "dingtalk",
|
||||
"type": "dingtalk",
|
||||
"enable": False,
|
||||
"enable": True,
|
||||
"client_id": "",
|
||||
"client_secret": "",
|
||||
"card_template_id": "",
|
||||
@@ -430,7 +429,7 @@ CONFIG_METADATA_2 = {
|
||||
"Telegram": {
|
||||
"id": "telegram",
|
||||
"type": "telegram",
|
||||
"enable": False,
|
||||
"enable": True,
|
||||
"telegram_token": "your_bot_token",
|
||||
"start_message": "Hello, I'm AstrBot!",
|
||||
"telegram_api_base_url": "https://api.telegram.org/bot",
|
||||
@@ -443,7 +442,7 @@ CONFIG_METADATA_2 = {
|
||||
"Discord": {
|
||||
"id": "discord",
|
||||
"type": "discord",
|
||||
"enable": False,
|
||||
"enable": True,
|
||||
"discord_token": "",
|
||||
"discord_proxy": "",
|
||||
"discord_command_register": True,
|
||||
@@ -453,7 +452,7 @@ CONFIG_METADATA_2 = {
|
||||
"Misskey": {
|
||||
"id": "misskey",
|
||||
"type": "misskey",
|
||||
"enable": False,
|
||||
"enable": True,
|
||||
"misskey_instance_url": "https://misskey.example",
|
||||
"misskey_token": "",
|
||||
"misskey_default_visibility": "public",
|
||||
@@ -471,7 +470,7 @@ CONFIG_METADATA_2 = {
|
||||
"Slack": {
|
||||
"id": "slack",
|
||||
"type": "slack",
|
||||
"enable": False,
|
||||
"enable": True,
|
||||
"bot_token": "",
|
||||
"app_token": "",
|
||||
"signing_secret": "",
|
||||
@@ -485,7 +484,7 @@ CONFIG_METADATA_2 = {
|
||||
"Line": {
|
||||
"id": "line",
|
||||
"type": "line",
|
||||
"enable": False,
|
||||
"enable": True,
|
||||
"channel_access_token": "",
|
||||
"channel_secret": "",
|
||||
"unified_webhook_mode": True,
|
||||
@@ -494,7 +493,7 @@ CONFIG_METADATA_2 = {
|
||||
"Satori": {
|
||||
"id": "satori",
|
||||
"type": "satori",
|
||||
"enable": False,
|
||||
"enable": True,
|
||||
"satori_api_base_url": "http://localhost:5140/satori/v1",
|
||||
"satori_endpoint": "ws://localhost:5140/satori/v1/events",
|
||||
"satori_token": "",
|
||||
@@ -505,7 +504,7 @@ CONFIG_METADATA_2 = {
|
||||
"KOOK": {
|
||||
"id": "kook",
|
||||
"type": "kook",
|
||||
"enable": False,
|
||||
"enable": True,
|
||||
"kook_bot_token": "",
|
||||
"kook_reconnect_delay": 1,
|
||||
"kook_max_reconnect_delay": 60,
|
||||
@@ -518,7 +517,7 @@ CONFIG_METADATA_2 = {
|
||||
"Mattermost": {
|
||||
"id": "mattermost",
|
||||
"type": "mattermost",
|
||||
"enable": False,
|
||||
"enable": True,
|
||||
"mattermost_url": "https://chat.example.com",
|
||||
"mattermost_bot_token": "",
|
||||
"mattermost_reconnect_delay": 5.0,
|
||||
@@ -889,11 +888,6 @@ CONFIG_METADATA_2 = {
|
||||
"wecom_ai_bot_connection_mode": "long_connection",
|
||||
},
|
||||
},
|
||||
"lark_bot_name": {
|
||||
"description": "飞书机器人的名字",
|
||||
"type": "string",
|
||||
"hint": "请务必填写正确,否则 @ 机器人将无法唤醒,只能通过前缀唤醒。",
|
||||
},
|
||||
"discord_token": {
|
||||
"description": "Discord Bot Token",
|
||||
"type": "string",
|
||||
|
||||
205
astrbot/core/platform/sources/lark/app_registration.py
Normal file
205
astrbot/core/platform/sources/lark/app_registration.py
Normal file
@@ -0,0 +1,205 @@
|
||||
from dataclasses import dataclass
|
||||
from typing import Any
|
||||
from urllib.parse import urlencode
|
||||
|
||||
import aiohttp
|
||||
|
||||
DEFAULT_FEISHU_OPEN_DOMAIN = "https://open.feishu.cn"
|
||||
DEFAULT_LARK_OPEN_DOMAIN = "https://open.larksuite.com"
|
||||
APP_REGISTRATION_PATH = "/oauth/v1/app/registration"
|
||||
|
||||
|
||||
@dataclass
|
||||
class LarkAppRegistrationEndpoints:
|
||||
accounts_base: str
|
||||
open_base: str
|
||||
registration: str
|
||||
|
||||
|
||||
@dataclass
|
||||
class LarkAppRegistration:
|
||||
device_code: str
|
||||
user_code: str
|
||||
verification_uri: str
|
||||
verification_uri_complete: str
|
||||
expires_in: int
|
||||
interval: int
|
||||
|
||||
|
||||
def resolve_app_registration_endpoints(
|
||||
domain: str,
|
||||
) -> LarkAppRegistrationEndpoints:
|
||||
normalized = (domain or DEFAULT_FEISHU_OPEN_DOMAIN).strip().rstrip("/")
|
||||
if normalized in {"feishu", DEFAULT_FEISHU_OPEN_DOMAIN}:
|
||||
accounts_base = "https://accounts.feishu.cn"
|
||||
open_base = DEFAULT_FEISHU_OPEN_DOMAIN
|
||||
elif normalized in {"lark", DEFAULT_LARK_OPEN_DOMAIN}:
|
||||
accounts_base = "https://accounts.larksuite.com"
|
||||
open_base = DEFAULT_LARK_OPEN_DOMAIN
|
||||
else:
|
||||
open_base = normalized
|
||||
accounts_base = normalized.replace("://open.", "://accounts.", 1)
|
||||
|
||||
return LarkAppRegistrationEndpoints(
|
||||
accounts_base=accounts_base,
|
||||
open_base=open_base,
|
||||
registration=f"{accounts_base}{APP_REGISTRATION_PATH}",
|
||||
)
|
||||
|
||||
|
||||
def _registration_data(raw: dict[str, Any]) -> dict[str, Any]:
|
||||
data = raw.get("data")
|
||||
if isinstance(data, dict):
|
||||
return data
|
||||
return raw
|
||||
|
||||
|
||||
def _string_field(data: dict[str, Any], key: str) -> str:
|
||||
value = data.get(key)
|
||||
if isinstance(value, str):
|
||||
return value
|
||||
return ""
|
||||
|
||||
|
||||
def _int_field(data: dict[str, Any], key: str, default: int) -> int:
|
||||
value = data.get(key)
|
||||
if isinstance(value, int):
|
||||
return value
|
||||
if isinstance(value, float):
|
||||
return int(value)
|
||||
return default
|
||||
|
||||
|
||||
async def _post_registration(
|
||||
endpoint: str,
|
||||
form: dict[str, str],
|
||||
) -> tuple[int, dict[str, Any]]:
|
||||
timeout = aiohttp.ClientTimeout(total=15)
|
||||
async with aiohttp.ClientSession(timeout=timeout, trust_env=True) as session:
|
||||
async with session.post(
|
||||
endpoint,
|
||||
data=form,
|
||||
headers={"Content-Type": "application/x-www-form-urlencoded"},
|
||||
) as response:
|
||||
status = response.status
|
||||
data = await response.json(content_type=None)
|
||||
if not isinstance(data, dict):
|
||||
raise RuntimeError("飞书应用创建响应格式异常")
|
||||
return status, data
|
||||
|
||||
|
||||
def _raise_registration_error(status: int, raw: dict[str, Any], fallback: str) -> None:
|
||||
data = _registration_data(raw)
|
||||
if status < 400 and not raw.get("error") and not data.get("error"):
|
||||
return
|
||||
message = (
|
||||
_string_field(raw, "error_description")
|
||||
or _string_field(data, "error_description")
|
||||
or _string_field(raw, "error")
|
||||
or _string_field(data, "error")
|
||||
or fallback
|
||||
)
|
||||
raise RuntimeError(message)
|
||||
|
||||
|
||||
async def request_app_registration(domain: str) -> LarkAppRegistration:
|
||||
endpoints = resolve_app_registration_endpoints(domain)
|
||||
status, raw = await _post_registration(
|
||||
endpoints.registration,
|
||||
{
|
||||
"action": "begin",
|
||||
"archetype": "PersonalAgent",
|
||||
"auth_method": "client_secret",
|
||||
"request_user_info": "open_id tenant_brand",
|
||||
},
|
||||
)
|
||||
_raise_registration_error(status, raw, "发起扫码创建失败")
|
||||
data = _registration_data(raw)
|
||||
user_code = _string_field(data, "user_code")
|
||||
verification_uri = _string_field(data, "verification_uri")
|
||||
verification_uri_complete = _string_field(data, "verification_uri_complete")
|
||||
if not verification_uri_complete and user_code:
|
||||
verification_uri_complete = (
|
||||
f"{endpoints.open_base}/page/cli?{urlencode({'user_code': user_code})}"
|
||||
)
|
||||
|
||||
return LarkAppRegistration(
|
||||
device_code=_string_field(data, "device_code"),
|
||||
user_code=user_code,
|
||||
verification_uri=verification_uri,
|
||||
verification_uri_complete=verification_uri_complete,
|
||||
expires_in=_int_field(data, "expires_in", 300),
|
||||
interval=_int_field(data, "interval", 5),
|
||||
)
|
||||
|
||||
|
||||
def _tenant_brand(data: dict[str, Any]) -> str:
|
||||
user_info = data.get("user_info")
|
||||
if isinstance(user_info, dict):
|
||||
return _string_field(user_info, "tenant_brand")
|
||||
return _string_field(data, "tenant_brand")
|
||||
|
||||
|
||||
async def poll_app_registration_once(
|
||||
*,
|
||||
domain: str,
|
||||
device_code: str,
|
||||
) -> dict[str, Any]:
|
||||
endpoints = resolve_app_registration_endpoints(domain)
|
||||
status, raw = await _post_registration(
|
||||
endpoints.registration,
|
||||
{
|
||||
"action": "poll",
|
||||
"device_code": device_code,
|
||||
},
|
||||
)
|
||||
data = _registration_data(raw)
|
||||
error = _string_field(raw, "error") or _string_field(data, "error")
|
||||
client_id = _string_field(data, "client_id")
|
||||
client_secret = _string_field(data, "client_secret")
|
||||
tenant_brand = _tenant_brand(data)
|
||||
|
||||
if status < 400 and not error and client_id:
|
||||
if not client_secret and tenant_brand == "lark":
|
||||
client_secret = await _poll_lark_secret(device_code)
|
||||
if not client_secret:
|
||||
return {"status": "error", "message": "应用创建成功但未获取到凭证"}
|
||||
return {
|
||||
"status": "created",
|
||||
"app_id": client_id,
|
||||
"app_secret": client_secret,
|
||||
"tenant_brand": tenant_brand,
|
||||
"domain": DEFAULT_LARK_OPEN_DOMAIN
|
||||
if tenant_brand == "lark"
|
||||
else DEFAULT_FEISHU_OPEN_DOMAIN,
|
||||
}
|
||||
if error == "authorization_pending":
|
||||
return {"status": "pending"}
|
||||
if error == "slow_down":
|
||||
return {"status": "slow_down"}
|
||||
if error == "access_denied":
|
||||
return {"status": "denied", "message": "用户取消了扫码创建"}
|
||||
if error in {"expired_token", "invalid_grant"}:
|
||||
return {"status": "expired", "message": "扫码已过期,请再次创建"}
|
||||
|
||||
message = (
|
||||
_string_field(raw, "error_description")
|
||||
or _string_field(data, "error_description")
|
||||
or error
|
||||
or "获取扫码创建状态失败"
|
||||
)
|
||||
return {"status": "error", "message": message}
|
||||
|
||||
|
||||
async def _poll_lark_secret(device_code: str) -> str:
|
||||
endpoints = resolve_app_registration_endpoints(DEFAULT_LARK_OPEN_DOMAIN)
|
||||
status, raw = await _post_registration(
|
||||
endpoints.registration,
|
||||
{
|
||||
"action": "poll",
|
||||
"device_code": device_code,
|
||||
},
|
||||
)
|
||||
if status >= 400 or raw.get("error"):
|
||||
return ""
|
||||
return _string_field(_registration_data(raw), "client_secret")
|
||||
94
astrbot/core/platform/sources/lark/bot_info.py
Normal file
94
astrbot/core/platform/sources/lark/bot_info.py
Normal file
@@ -0,0 +1,94 @@
|
||||
from dataclasses import dataclass
|
||||
from typing import Any
|
||||
|
||||
import aiohttp
|
||||
|
||||
from .app_registration import DEFAULT_FEISHU_OPEN_DOMAIN, DEFAULT_LARK_OPEN_DOMAIN
|
||||
|
||||
TENANT_ACCESS_TOKEN_INTERNAL_PATH = "/open-apis/auth/v3/tenant_access_token/internal"
|
||||
BOT_INFO_PATH = "/open-apis/bot/v3/info"
|
||||
|
||||
|
||||
@dataclass
|
||||
class LarkBotInfo:
|
||||
app_name: str
|
||||
open_id: str
|
||||
|
||||
|
||||
def _open_base(domain: str) -> str:
|
||||
normalized = (domain or DEFAULT_FEISHU_OPEN_DOMAIN).strip().rstrip("/")
|
||||
if normalized in {"feishu", DEFAULT_FEISHU_OPEN_DOMAIN}:
|
||||
return DEFAULT_FEISHU_OPEN_DOMAIN
|
||||
if normalized in {"lark", DEFAULT_LARK_OPEN_DOMAIN}:
|
||||
return DEFAULT_LARK_OPEN_DOMAIN
|
||||
return normalized
|
||||
|
||||
|
||||
def _string_field(data: dict[str, Any], key: str) -> str:
|
||||
value = data.get(key)
|
||||
return value if isinstance(value, str) else ""
|
||||
|
||||
|
||||
async def _post_json(
|
||||
endpoint: str,
|
||||
payload: dict[str, str],
|
||||
) -> dict[str, Any]:
|
||||
timeout = aiohttp.ClientTimeout(total=15)
|
||||
async with aiohttp.ClientSession(timeout=timeout, trust_env=True) as session:
|
||||
async with session.post(endpoint, json=payload) as response:
|
||||
data = await response.json(content_type=None)
|
||||
if not isinstance(data, dict):
|
||||
raise RuntimeError("飞书接口响应格式异常")
|
||||
return data
|
||||
|
||||
|
||||
async def _get_json(
|
||||
endpoint: str,
|
||||
*,
|
||||
headers: dict[str, str],
|
||||
) -> dict[str, Any]:
|
||||
timeout = aiohttp.ClientTimeout(total=15)
|
||||
async with aiohttp.ClientSession(timeout=timeout, trust_env=True) as session:
|
||||
async with session.get(endpoint, headers=headers) as response:
|
||||
data = await response.json(content_type=None)
|
||||
if not isinstance(data, dict):
|
||||
raise RuntimeError("飞书接口响应格式异常")
|
||||
return data
|
||||
|
||||
|
||||
async def request_lark_bot_info(
|
||||
*,
|
||||
domain: str,
|
||||
app_id: str,
|
||||
app_secret: str,
|
||||
) -> LarkBotInfo:
|
||||
open_base = _open_base(domain)
|
||||
token_data = await _post_json(
|
||||
f"{open_base}{TENANT_ACCESS_TOKEN_INTERNAL_PATH}",
|
||||
{
|
||||
"app_id": app_id,
|
||||
"app_secret": app_secret,
|
||||
},
|
||||
)
|
||||
if token_data.get("code") != 0:
|
||||
raise RuntimeError(_string_field(token_data, "msg") or "获取飞书访问令牌失败")
|
||||
|
||||
tenant_access_token = _string_field(token_data, "tenant_access_token")
|
||||
if not tenant_access_token:
|
||||
raise RuntimeError("飞书访问令牌响应缺少 tenant_access_token")
|
||||
|
||||
bot_data = await _get_json(
|
||||
f"{open_base}{BOT_INFO_PATH}",
|
||||
headers={"Authorization": f"Bearer {tenant_access_token}"},
|
||||
)
|
||||
if bot_data.get("code") != 0:
|
||||
raise RuntimeError(_string_field(bot_data, "msg") or "获取飞书机器人信息失败")
|
||||
|
||||
bot = bot_data.get("bot")
|
||||
if not isinstance(bot, dict):
|
||||
raise RuntimeError("飞书机器人信息响应缺少 bot 字段")
|
||||
|
||||
return LarkBotInfo(
|
||||
app_name=_string_field(bot, "app_name"),
|
||||
open_id=_string_field(bot, "open_id"),
|
||||
)
|
||||
@@ -29,6 +29,7 @@ from astrbot.core.utils.astrbot_path import get_astrbot_temp_path
|
||||
from astrbot.core.utils.webhook_utils import log_webhook_info
|
||||
|
||||
from ...register import register_platform_adapter
|
||||
from .bot_info import request_lark_bot_info
|
||||
from .lark_event import LarkMessageEvent
|
||||
from .server import LarkWebhookServer
|
||||
|
||||
@@ -48,14 +49,12 @@ class LarkPlatformAdapter(Platform):
|
||||
self.appid = platform_config["app_id"]
|
||||
self.appsecret = platform_config["app_secret"]
|
||||
self.domain = platform_config.get("domain", lark.FEISHU_DOMAIN)
|
||||
self.bot_name = platform_config.get("lark_bot_name", "astrbot")
|
||||
self.bot_name = "astrbot"
|
||||
self.bot_open_id = ""
|
||||
|
||||
# socket or webhook
|
||||
self.connection_mode = platform_config.get("lark_connection_mode", "socket")
|
||||
|
||||
if not self.bot_name:
|
||||
logger.warning("未设置飞书机器人名称,@ 机器人可能得不到回复。")
|
||||
|
||||
# 初始化 WebSocket 长连接相关配置
|
||||
async def on_msg_event_recv(event: lark.im.v1.P2ImMessageReceiveV1) -> None:
|
||||
await self.convert_msg(event)
|
||||
@@ -517,7 +516,7 @@ class LarkPlatformAdapter(Platform):
|
||||
)
|
||||
if message.chat_type == "group":
|
||||
abm.group_id = message.chat_id
|
||||
abm.self_id = self.bot_name
|
||||
abm.self_id = self.bot_open_id or self.bot_name
|
||||
abm.message_str = ""
|
||||
|
||||
at_list = {}
|
||||
@@ -534,9 +533,10 @@ class LarkPlatformAdapter(Platform):
|
||||
open_id = m.id.open_id if m.id.open_id else ""
|
||||
at_list[m.key] = Comp.At(qq=open_id, name=m.name)
|
||||
|
||||
if m.name == self.bot_name:
|
||||
if m.id.open_id is not None:
|
||||
abm.self_id = m.id.open_id
|
||||
if (self.bot_open_id and open_id == self.bot_open_id) or (
|
||||
m.name == self.bot_name
|
||||
):
|
||||
abm.self_id = open_id or self.bot_open_id or self.bot_name
|
||||
|
||||
if message.content is None:
|
||||
logger.warning("[Lark] 消息内容为空")
|
||||
@@ -621,6 +621,11 @@ class LarkPlatformAdapter(Platform):
|
||||
logger.error(f"[Lark Webhook] 处理事件失败: {e}", exc_info=True)
|
||||
|
||||
async def run(self) -> None:
|
||||
try:
|
||||
await self._refresh_bot_info()
|
||||
except Exception as e:
|
||||
logger.error(f"[Lark] 启动时获取机器人信息失败: {e}", exc_info=True)
|
||||
|
||||
if self.connection_mode == "webhook":
|
||||
# Webhook 模式
|
||||
if self.webhook_server is None:
|
||||
@@ -643,6 +648,17 @@ class LarkPlatformAdapter(Platform):
|
||||
|
||||
return await self.webhook_server.handle_callback(request)
|
||||
|
||||
async def _refresh_bot_info(self) -> None:
|
||||
bot_info = await request_lark_bot_info(
|
||||
domain=self.domain,
|
||||
app_id=self.appid,
|
||||
app_secret=self.appsecret,
|
||||
)
|
||||
if bot_info.app_name:
|
||||
self.bot_name = bot_info.app_name
|
||||
if bot_info.open_id:
|
||||
self.bot_open_id = bot_info.open_id
|
||||
|
||||
async def terminate(self) -> None:
|
||||
if self.connection_mode == "socket":
|
||||
await self.client._disconnect()
|
||||
|
||||
167
astrbot/core/platform/sources/weixin_oc/login_registration.py
Normal file
167
astrbot/core/platform/sources/weixin_oc/login_registration.py
Normal file
@@ -0,0 +1,167 @@
|
||||
from dataclasses import dataclass
|
||||
from typing import Any
|
||||
|
||||
from .weixin_oc_client import WeixinOCClient
|
||||
|
||||
DEFAULT_WEIXIN_OC_BASE_URL = "https://ilinkai.weixin.qq.com"
|
||||
DEFAULT_WEIXIN_OC_CDN_BASE_URL = "https://novac2c.cdn.weixin.qq.com/c2c"
|
||||
DEFAULT_WEIXIN_OC_BOT_TYPE = "3"
|
||||
DEFAULT_WEIXIN_OC_QR_POLL_INTERVAL = 1
|
||||
DEFAULT_WEIXIN_OC_LONG_POLL_TIMEOUT_MS = 35_000
|
||||
DEFAULT_WEIXIN_OC_API_TIMEOUT_MS = 15_000
|
||||
|
||||
|
||||
@dataclass
|
||||
class WeixinOCLoginRegistration:
|
||||
qrcode: str
|
||||
qrcode_img_content: str
|
||||
interval: int
|
||||
|
||||
|
||||
def normalize_weixin_oc_base_url(base_url: str | None) -> str:
|
||||
return (base_url or DEFAULT_WEIXIN_OC_BASE_URL).strip().rstrip("/")
|
||||
|
||||
|
||||
def _string_field(data: dict[str, Any], key: str) -> str:
|
||||
value = data.get(key)
|
||||
if isinstance(value, str):
|
||||
return value.strip()
|
||||
return ""
|
||||
|
||||
|
||||
def _int_config(value: Any, default: int, minimum: int) -> int:
|
||||
try:
|
||||
parsed = int(value)
|
||||
except (TypeError, ValueError):
|
||||
parsed = default
|
||||
return max(parsed, minimum)
|
||||
|
||||
|
||||
def weixin_oc_login_result(
|
||||
data: dict[str, Any],
|
||||
*,
|
||||
default_base_url: str,
|
||||
) -> dict[str, Any]:
|
||||
raw_status = _string_field(data, "status") or "wait"
|
||||
if raw_status == "confirmed":
|
||||
bot_token = _string_field(data, "bot_token")
|
||||
if not bot_token:
|
||||
return {"status": "error", "message": "登录成功但未返回 token"}
|
||||
base_url = _string_field(data, "baseurl") or default_base_url
|
||||
return {
|
||||
"status": "created",
|
||||
"qr_status": raw_status,
|
||||
"weixin_oc_token": bot_token,
|
||||
"weixin_oc_account_id": _string_field(data, "ilink_bot_id"),
|
||||
"weixin_oc_base_url": normalize_weixin_oc_base_url(base_url),
|
||||
"weixin_oc_user_id": _string_field(data, "ilink_user_id"),
|
||||
}
|
||||
if raw_status == "expired":
|
||||
return {"status": "expired", "qr_status": raw_status, "message": "二维码已过期"}
|
||||
if raw_status in {"cancel", "canceled", "denied"}:
|
||||
return {"status": "denied", "qr_status": raw_status, "message": "用户取消登录"}
|
||||
return {"status": "pending", "qr_status": raw_status}
|
||||
|
||||
|
||||
def _client(
|
||||
*,
|
||||
adapter_id: str,
|
||||
base_url: str,
|
||||
api_timeout_ms: int,
|
||||
) -> WeixinOCClient:
|
||||
return WeixinOCClient(
|
||||
adapter_id=adapter_id,
|
||||
base_url=base_url,
|
||||
cdn_base_url=DEFAULT_WEIXIN_OC_CDN_BASE_URL,
|
||||
api_timeout_ms=api_timeout_ms,
|
||||
)
|
||||
|
||||
|
||||
async def request_weixin_oc_login_qr(
|
||||
platform_config: dict[str, Any],
|
||||
) -> WeixinOCLoginRegistration:
|
||||
base_url = normalize_weixin_oc_base_url(
|
||||
_string_field(platform_config, "weixin_oc_base_url")
|
||||
)
|
||||
bot_type = _string_field(platform_config, "weixin_oc_bot_type")
|
||||
if not bot_type:
|
||||
bot_type = DEFAULT_WEIXIN_OC_BOT_TYPE
|
||||
api_timeout_ms = _int_config(
|
||||
platform_config.get("weixin_oc_api_timeout_ms"),
|
||||
DEFAULT_WEIXIN_OC_API_TIMEOUT_MS,
|
||||
1_000,
|
||||
)
|
||||
interval = _int_config(
|
||||
platform_config.get("weixin_oc_qr_poll_interval"),
|
||||
DEFAULT_WEIXIN_OC_QR_POLL_INTERVAL,
|
||||
1,
|
||||
)
|
||||
|
||||
client = _client(
|
||||
adapter_id=str(platform_config.get("id") or "weixin_oc"),
|
||||
base_url=base_url,
|
||||
api_timeout_ms=api_timeout_ms,
|
||||
)
|
||||
try:
|
||||
data = await client.request_json(
|
||||
"GET",
|
||||
"ilink/bot/get_bot_qrcode",
|
||||
params={"bot_type": bot_type},
|
||||
token_required=False,
|
||||
timeout_ms=15_000,
|
||||
)
|
||||
finally:
|
||||
await client.close()
|
||||
|
||||
qrcode = _string_field(data, "qrcode")
|
||||
qrcode_img_content = _string_field(data, "qrcode_img_content")
|
||||
if not qrcode or not qrcode_img_content:
|
||||
raise RuntimeError("个人微信二维码响应格式异常")
|
||||
|
||||
return WeixinOCLoginRegistration(
|
||||
qrcode=qrcode,
|
||||
qrcode_img_content=qrcode_img_content,
|
||||
interval=interval,
|
||||
)
|
||||
|
||||
|
||||
async def poll_weixin_oc_login_once(
|
||||
*,
|
||||
platform_config: dict[str, Any],
|
||||
qrcode: str,
|
||||
) -> dict[str, Any]:
|
||||
if not qrcode:
|
||||
raise ValueError("Missing qrcode")
|
||||
|
||||
base_url = normalize_weixin_oc_base_url(
|
||||
_string_field(platform_config, "weixin_oc_base_url")
|
||||
)
|
||||
api_timeout_ms = _int_config(
|
||||
platform_config.get("weixin_oc_api_timeout_ms"),
|
||||
DEFAULT_WEIXIN_OC_API_TIMEOUT_MS,
|
||||
1_000,
|
||||
)
|
||||
long_poll_timeout_ms = _int_config(
|
||||
platform_config.get("weixin_oc_long_poll_timeout_ms"),
|
||||
DEFAULT_WEIXIN_OC_LONG_POLL_TIMEOUT_MS,
|
||||
1_000,
|
||||
)
|
||||
|
||||
client = _client(
|
||||
adapter_id=str(platform_config.get("id") or "weixin_oc"),
|
||||
base_url=base_url,
|
||||
api_timeout_ms=api_timeout_ms,
|
||||
)
|
||||
try:
|
||||
data = await client.request_json(
|
||||
"GET",
|
||||
"ilink/bot/get_qrcode_status",
|
||||
params={"qrcode": qrcode},
|
||||
token_required=False,
|
||||
timeout_ms=long_poll_timeout_ms,
|
||||
headers={"iLink-App-ClientVersion": "1"},
|
||||
)
|
||||
finally:
|
||||
await client.close()
|
||||
|
||||
return weixin_oc_login_result(data, default_base_url=base_url)
|
||||
@@ -8,6 +8,15 @@ from quart import request
|
||||
from astrbot.core import logger
|
||||
from astrbot.core.core_lifecycle import AstrBotCoreLifecycle
|
||||
from astrbot.core.platform import Platform
|
||||
from astrbot.core.platform.sources.lark.app_registration import (
|
||||
poll_app_registration_once,
|
||||
request_app_registration,
|
||||
)
|
||||
from astrbot.core.platform.sources.lark.bot_info import request_lark_bot_info
|
||||
from astrbot.core.platform.sources.weixin_oc.login_registration import (
|
||||
poll_weixin_oc_login_once,
|
||||
request_weixin_oc_login_qr,
|
||||
)
|
||||
|
||||
from .route import Response, Route, RouteContext
|
||||
|
||||
@@ -42,6 +51,12 @@ class PlatformRoute(Route):
|
||||
methods=["GET"],
|
||||
)
|
||||
|
||||
self.app.add_url_rule(
|
||||
"/api/platform/registration/<platform_type>",
|
||||
view_func=self.handle_platform_registration,
|
||||
methods=["POST"],
|
||||
)
|
||||
|
||||
async def unified_webhook_callback(self, webhook_uuid: str):
|
||||
"""统一 webhook 回调入口
|
||||
|
||||
@@ -98,3 +113,125 @@ class PlatformRoute(Route):
|
||||
except Exception as e:
|
||||
logger.error(f"获取平台统计信息失败: {e}", exc_info=True)
|
||||
return Response().error(f"获取统计信息失败: {e}").__dict__, 500
|
||||
|
||||
async def handle_platform_registration(self, platform_type: str):
|
||||
"""Handle dashboard one-click platform registration actions."""
|
||||
try:
|
||||
payload = await request.get_json(silent=True) or {}
|
||||
action = str(payload.get("action", "")).strip().lower()
|
||||
if not action:
|
||||
return Response().error("Missing action").__dict__, 400
|
||||
|
||||
platform_config = payload.get("platform_config")
|
||||
if not isinstance(platform_config, dict):
|
||||
platform_config = {}
|
||||
|
||||
if platform_type == "lark":
|
||||
return await self._handle_lark_registration(
|
||||
action,
|
||||
payload,
|
||||
platform_config,
|
||||
)
|
||||
if platform_type == "weixin_oc":
|
||||
return await self._handle_weixin_oc_registration(
|
||||
action,
|
||||
payload,
|
||||
platform_config,
|
||||
)
|
||||
|
||||
return Response().error(
|
||||
f"Unsupported platform registration: {platform_type}"
|
||||
).__dict__, 404
|
||||
except Exception as e:
|
||||
logger.error(f"处理平台一键创建请求失败: {e}", exc_info=True)
|
||||
return Response().error(str(e)).__dict__, 500
|
||||
|
||||
async def _handle_lark_registration(
|
||||
self,
|
||||
action: str,
|
||||
payload: dict,
|
||||
platform_config: dict,
|
||||
):
|
||||
domain = str(platform_config.get("domain") or "").strip()
|
||||
|
||||
if action == "start":
|
||||
registration = await request_app_registration(domain)
|
||||
return (
|
||||
Response()
|
||||
.ok(
|
||||
{
|
||||
"status": "pending",
|
||||
"device_code": registration.device_code,
|
||||
"registration_code": registration.device_code,
|
||||
"user_code": registration.user_code,
|
||||
"verification_uri": registration.verification_uri,
|
||||
"verification_uri_complete": registration.verification_uri_complete,
|
||||
"expires_in": registration.expires_in,
|
||||
"interval": registration.interval,
|
||||
}
|
||||
)
|
||||
.__dict__
|
||||
)
|
||||
|
||||
if action == "poll":
|
||||
device_code = str(
|
||||
payload.get("device_code") or payload.get("registration_code") or ""
|
||||
).strip()
|
||||
if not device_code:
|
||||
return Response().error("Missing device_code").__dict__, 400
|
||||
result = await poll_app_registration_once(
|
||||
domain=domain,
|
||||
device_code=device_code,
|
||||
)
|
||||
if result.get("status") == "created":
|
||||
try:
|
||||
bot_info = await request_lark_bot_info(
|
||||
domain=str(result.get("domain") or domain),
|
||||
app_id=str(result.get("app_id") or ""),
|
||||
app_secret=str(result.get("app_secret") or ""),
|
||||
)
|
||||
if bot_info.app_name:
|
||||
result["bot_name"] = bot_info.app_name
|
||||
if bot_info.open_id:
|
||||
result["bot_open_id"] = bot_info.open_id
|
||||
except Exception as e:
|
||||
logger.error(f"获取飞书机器人信息失败: {e}", exc_info=True)
|
||||
return Response().ok(result).__dict__
|
||||
|
||||
return Response().error(f"Unsupported action: {action}").__dict__, 400
|
||||
|
||||
async def _handle_weixin_oc_registration(
|
||||
self,
|
||||
action: str,
|
||||
payload: dict,
|
||||
platform_config: dict,
|
||||
):
|
||||
if action == "start":
|
||||
registration = await request_weixin_oc_login_qr(platform_config)
|
||||
return (
|
||||
Response()
|
||||
.ok(
|
||||
{
|
||||
"status": "pending",
|
||||
"registration_code": registration.qrcode,
|
||||
"qrcode": registration.qrcode,
|
||||
"qrcode_img_content": registration.qrcode_img_content,
|
||||
"interval": registration.interval,
|
||||
}
|
||||
)
|
||||
.__dict__
|
||||
)
|
||||
|
||||
if action == "poll":
|
||||
qrcode = str(
|
||||
payload.get("qrcode") or payload.get("registration_code") or ""
|
||||
).strip()
|
||||
if not qrcode:
|
||||
return Response().error("Missing qrcode").__dict__, 400
|
||||
result = await poll_weixin_oc_login_once(
|
||||
platform_config=platform_config,
|
||||
qrcode=qrcode,
|
||||
)
|
||||
return Response().ok(result).__dict__
|
||||
|
||||
return Response().error(f"Unsupported action: {action}").__dict__, 400
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
/* Auto-generated MDI subset – 263 icons */
|
||||
/* Auto-generated MDI subset – 266 icons */
|
||||
/* Do not edit manually. Run: pnpm run subset-icons */
|
||||
|
||||
@font-face {
|
||||
@@ -216,6 +216,10 @@
|
||||
content: "\F0765";
|
||||
}
|
||||
|
||||
.mdi-circle-outline::before {
|
||||
content: "\F0766";
|
||||
}
|
||||
|
||||
.mdi-circle-small::before {
|
||||
content: "\F09DF";
|
||||
}
|
||||
@@ -824,6 +828,10 @@
|
||||
content: "\F0995";
|
||||
}
|
||||
|
||||
.mdi-progress-download::before {
|
||||
content: "\F0997";
|
||||
}
|
||||
|
||||
.mdi-puzzle::before {
|
||||
content: "\F0431";
|
||||
}
|
||||
@@ -1000,6 +1008,10 @@
|
||||
content: "\F051B";
|
||||
}
|
||||
|
||||
.mdi-timer-sand::before {
|
||||
content: "\F051F";
|
||||
}
|
||||
|
||||
.mdi-tools::before {
|
||||
content: "\F1064";
|
||||
}
|
||||
|
||||
Binary file not shown.
Binary file not shown.
@@ -30,11 +30,58 @@
|
||||
|
||||
</v-select>
|
||||
<div class="mt-3" v-if="selectedPlatformConfig">
|
||||
<v-btn color="info" variant="tonal" @click="openTutorial" class="mt-2">
|
||||
<v-icon start>mdi-book-open-variant</v-icon>
|
||||
{{ tm('dialog.viewTutorial') }}
|
||||
</v-btn>
|
||||
<div class="mt-2">
|
||||
<div v-if="isLarkPlatform">
|
||||
<div class="lark-creation-title mt-4 mb-1">
|
||||
{{ tm('registrationAction.mode.title') }}
|
||||
</div>
|
||||
<v-radio-group
|
||||
v-model="larkCreationMode"
|
||||
class="lark-creation-mode"
|
||||
hide-details
|
||||
>
|
||||
<v-radio value="scan" :label="tm('registrationAction.mode.scan')"></v-radio>
|
||||
<v-radio value="manual" :label="tm('registrationAction.mode.manual')"></v-radio>
|
||||
</v-radio-group>
|
||||
|
||||
<div v-if="larkCreationMode === 'scan'" class="lark-registration-inline mt-3">
|
||||
<PlatformRegistrationAction
|
||||
:platform-config="selectedPlatformConfig"
|
||||
:active="larkCreationMode === 'scan'"
|
||||
@created="handlePlatformRegistrationCreated"
|
||||
@success="showSuccess"
|
||||
@error="showError"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div v-else-if="larkCreationMode === 'manual'" class="mt-2">
|
||||
<div class="platform-action-row">
|
||||
<v-btn color="info" variant="tonal" @click="openTutorial" class="mt-2">
|
||||
<v-icon start>mdi-book-open-variant</v-icon>
|
||||
{{ tm('dialog.viewTutorial') }}
|
||||
</v-btn>
|
||||
</div>
|
||||
<AstrBotConfig :iterable="selectedPlatformConfig" :metadata="metadata['platform_group']?.metadata"
|
||||
metadataKey="platform" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-else-if="isWeixinOcPlatform" class="weixin-oc-registration-inline mt-4">
|
||||
<PlatformRegistrationAction
|
||||
:platform-config="selectedPlatformConfig"
|
||||
:active="isWeixinOcPlatform"
|
||||
@created="handlePlatformRegistrationCreated"
|
||||
@success="showSuccess"
|
||||
@error="showError"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div v-else class="mt-2">
|
||||
<div class="platform-action-row">
|
||||
<v-btn color="info" variant="tonal" @click="openTutorial" class="mt-2">
|
||||
<v-icon start>mdi-book-open-variant</v-icon>
|
||||
{{ tm('dialog.viewTutorial') }}
|
||||
</v-btn>
|
||||
</div>
|
||||
<AstrBotConfig :iterable="selectedPlatformConfig" :metadata="metadata['platform_group']?.metadata"
|
||||
metadataKey="platform" />
|
||||
</div>
|
||||
@@ -310,10 +357,11 @@ import { getPlatformIcon, getPlatformDescription, getTutorialLink } from '@/util
|
||||
import AstrBotConfig from '@/components/shared/AstrBotConfig.vue';
|
||||
import AstrBotCoreConfigWrapper from '@/components/config/AstrBotCoreConfigWrapper.vue';
|
||||
import ConfigPage from '@/views/ConfigPage.vue';
|
||||
import PlatformRegistrationAction from '@/components/platform/PlatformRegistrationAction.vue';
|
||||
|
||||
export default {
|
||||
name: 'AddNewPlatform',
|
||||
components: { AstrBotConfig, AstrBotCoreConfigWrapper, ConfigPage },
|
||||
components: { AstrBotConfig, AstrBotCoreConfigWrapper, ConfigPage, PlatformRegistrationAction },
|
||||
emits: ['update:show', 'show-toast', 'refresh-config'],
|
||||
props: {
|
||||
show: {
|
||||
@@ -341,6 +389,7 @@ export default {
|
||||
return {
|
||||
selectedPlatformType: null,
|
||||
selectedPlatformConfig: null,
|
||||
larkCreationMode: '',
|
||||
|
||||
aBConfigRadioVal: '0',
|
||||
selectedAbConfId: 'default',
|
||||
@@ -410,6 +459,20 @@ export default {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (this.isLarkPlatform && !this.larkCreationMode) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (this.isLarkPlatform && this.larkCreationMode === 'scan') {
|
||||
if (!this.selectedPlatformConfig?.app_id || !this.selectedPlatformConfig?.app_secret) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
if (this.isWeixinOcPlatform && !this.selectedPlatformConfig?.weixin_oc_token) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// 如果是使用现有配置文件模式
|
||||
if (this.aBConfigRadioVal === '0') {
|
||||
return !!this.selectedAbConfId;
|
||||
@@ -442,14 +505,22 @@ export default {
|
||||
{ label: this.tm('createDialog.messageTypeOptions.group'), value: 'GroupMessage' },
|
||||
{ label: this.tm('createDialog.messageTypeOptions.friend'), value: 'FriendMessage' },
|
||||
];
|
||||
},
|
||||
isLarkPlatform() {
|
||||
return this.selectedPlatformConfig?.type === 'lark';
|
||||
},
|
||||
isWeixinOcPlatform() {
|
||||
return this.selectedPlatformConfig?.type === 'weixin_oc';
|
||||
}
|
||||
},
|
||||
watch: {
|
||||
selectedPlatformType(newType) {
|
||||
if (newType && this.platformTemplates[newType]) {
|
||||
this.selectedPlatformConfig = JSON.parse(JSON.stringify(this.platformTemplates[newType]));
|
||||
this.larkCreationMode = '';
|
||||
} else {
|
||||
this.selectedPlatformConfig = null;
|
||||
this.larkCreationMode = '';
|
||||
}
|
||||
},
|
||||
selectedAbConfId(newConfigId) {
|
||||
@@ -534,6 +605,7 @@ export default {
|
||||
resetForm() {
|
||||
this.selectedPlatformType = null;
|
||||
this.selectedPlatformConfig = null;
|
||||
this.larkCreationMode = '';
|
||||
|
||||
this.aBConfigRadioVal = '0';
|
||||
this.selectedAbConfId = 'default';
|
||||
@@ -838,6 +910,21 @@ export default {
|
||||
this.$emit('show-toast', { message: message, type: 'error' });
|
||||
},
|
||||
|
||||
handlePlatformRegistrationCreated(data) {
|
||||
if (!this.selectedPlatformConfig || !data?.bot_name) {
|
||||
return;
|
||||
}
|
||||
const currentId = String(this.selectedPlatformConfig.id || '').trim();
|
||||
const safeBotName = String(data.bot_name || '').trim().replace(/[!:]/g, '_');
|
||||
if (!currentId || !safeBotName) {
|
||||
return;
|
||||
}
|
||||
const suffix = `-${safeBotName}`;
|
||||
this.selectedPlatformConfig.id = currentId.endsWith(suffix)
|
||||
? currentId
|
||||
: `${currentId}${suffix}`;
|
||||
},
|
||||
|
||||
isPlatformIdValid(id) {
|
||||
if (!id) {
|
||||
return false;
|
||||
@@ -1086,4 +1173,28 @@ export default {
|
||||
overflow-y: auto;
|
||||
padding: 16px 16px 24px 16px;
|
||||
}
|
||||
|
||||
.platform-action-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.lark-creation-mode .v-label {
|
||||
opacity: 0.9;
|
||||
}
|
||||
|
||||
.lark-creation-title {
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: rgba(0, 0, 0, 0.78);
|
||||
}
|
||||
|
||||
.lark-registration-inline {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: flex-start;
|
||||
width: 320px;
|
||||
}
|
||||
</style>
|
||||
|
||||
384
dashboard/src/components/platform/PlatformRegistrationAction.vue
Normal file
384
dashboard/src/components/platform/PlatformRegistrationAction.vue
Normal file
@@ -0,0 +1,384 @@
|
||||
<template>
|
||||
<div v-if="action" class="platform-registration-panel">
|
||||
<div class="registration-scan-title">
|
||||
{{ tm(action.scanTitleKey) }}
|
||||
</div>
|
||||
|
||||
<div class="registration-scan-content">
|
||||
<div class="registration-qr-stage">
|
||||
<div
|
||||
class="registration-qr-shell"
|
||||
:class="{ 'registration-qr-shell-created': flow.status === 'created' }"
|
||||
>
|
||||
<QrCodeViewer
|
||||
v-if="qrValue"
|
||||
:value="qrValue"
|
||||
:alt="tm(action.titleKey)"
|
||||
:size="150"
|
||||
:margin="1"
|
||||
/>
|
||||
<div v-else class="registration-qr-loading">
|
||||
<v-progress-circular indeterminate color="primary"></v-progress-circular>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="flow.status === 'created'" class="registration-created-overlay">
|
||||
<div class="registration-created-mark">
|
||||
<v-icon size="58" color="white">mdi-check</v-icon>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="registration-action-status mt-2">
|
||||
<v-icon size="small" class="me-1" :color="getStatusColor(flow.status)">
|
||||
{{ getStatusIcon(flow.status) }}
|
||||
</v-icon>
|
||||
{{ getStatusText(flow.status) }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="flow.message" class="registration-action-message mt-2">
|
||||
{{ flow.message }}
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import axios from 'axios';
|
||||
import { useModuleI18n } from '@/i18n/composables';
|
||||
import QrCodeViewer from '@/components/shared/QrCodeViewer.vue';
|
||||
|
||||
const FEISHU_DOMAIN = 'https://open.feishu.cn';
|
||||
|
||||
const REGISTRATION_ACTIONS = {
|
||||
lark: {
|
||||
endpoint: '/api/platform/registration/lark',
|
||||
icon: 'mdi-qrcode',
|
||||
titleKey: 'registrationAction.lark.title',
|
||||
scanTitleKey: 'registrationAction.lark.scanTitle',
|
||||
successKey: 'registrationAction.created',
|
||||
},
|
||||
weixin_oc: {
|
||||
endpoint: '/api/platform/registration/weixin_oc',
|
||||
icon: 'mdi-qrcode',
|
||||
titleKey: 'registrationAction.weixinOc.title',
|
||||
scanTitleKey: 'registrationAction.weixinOc.scanTitle',
|
||||
successKey: 'registrationAction.weixinOc.created',
|
||||
statusKeyPrefix: 'registrationAction.weixinOc.status',
|
||||
},
|
||||
};
|
||||
|
||||
export default {
|
||||
name: 'PlatformRegistrationAction',
|
||||
components: { QrCodeViewer },
|
||||
emits: ['success', 'error', 'created'],
|
||||
props: {
|
||||
platformConfig: {
|
||||
type: Object,
|
||||
default: null,
|
||||
},
|
||||
active: {
|
||||
type: Boolean,
|
||||
default: true,
|
||||
},
|
||||
},
|
||||
setup() {
|
||||
const { tm } = useModuleI18n('features/platform');
|
||||
return { tm };
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
flow: {
|
||||
status: 'idle',
|
||||
},
|
||||
loading: false,
|
||||
pollTimer: null,
|
||||
};
|
||||
},
|
||||
computed: {
|
||||
action() {
|
||||
return REGISTRATION_ACTIONS[this.platformConfig?.type] || null;
|
||||
},
|
||||
selectedDomain() {
|
||||
return this.platformConfig?.domain || FEISHU_DOMAIN;
|
||||
},
|
||||
qrValue() {
|
||||
return this.flow.verification_uri_complete
|
||||
|| this.flow.qrcode_img_content
|
||||
|| this.flow.qrcode
|
||||
|| '';
|
||||
},
|
||||
},
|
||||
watch: {
|
||||
active: {
|
||||
immediate: true,
|
||||
handler(active) {
|
||||
if (active) {
|
||||
this.ensureStarted();
|
||||
} else {
|
||||
this.stopPolling();
|
||||
}
|
||||
},
|
||||
},
|
||||
'platformConfig.type'() {
|
||||
this.resetFlow();
|
||||
this.ensureStarted();
|
||||
},
|
||||
},
|
||||
beforeUnmount() {
|
||||
this.stopPolling();
|
||||
},
|
||||
methods: {
|
||||
resetFlow() {
|
||||
this.stopPolling();
|
||||
this.flow = { status: 'idle' };
|
||||
},
|
||||
ensureStarted() {
|
||||
if (!this.active || !this.action || this.flow.status !== 'idle') {
|
||||
return;
|
||||
}
|
||||
this.startAction();
|
||||
},
|
||||
buildPayload(action, extra = {}) {
|
||||
return {
|
||||
action,
|
||||
platform_config: {
|
||||
...this.platformConfig,
|
||||
domain: this.selectedDomain,
|
||||
},
|
||||
...extra,
|
||||
};
|
||||
},
|
||||
async startAction() {
|
||||
if (!this.action || this.loading) {
|
||||
return;
|
||||
}
|
||||
this.stopPolling();
|
||||
this.loading = true;
|
||||
this.flow = { status: 'starting' };
|
||||
try {
|
||||
const res = await axios.post(this.action.endpoint, this.buildPayload('start'));
|
||||
if (res.data.status !== 'ok') {
|
||||
throw new Error(res.data.message || this.tm('registrationAction.startFailed'));
|
||||
}
|
||||
this.flow = {
|
||||
...res.data.data,
|
||||
status: res.data.data?.status || 'pending',
|
||||
};
|
||||
if (this.flow.registration_code && this.flow.status === 'pending') {
|
||||
this.schedulePoll(this.flow.interval || 5);
|
||||
}
|
||||
} catch (err) {
|
||||
this.flow = {
|
||||
status: 'error',
|
||||
message: err.response?.data?.message || err.message || this.tm('registrationAction.startFailed'),
|
||||
};
|
||||
this.$emit('error', this.flow.message);
|
||||
} finally {
|
||||
this.loading = false;
|
||||
}
|
||||
},
|
||||
schedulePoll(intervalSeconds) {
|
||||
this.stopPolling();
|
||||
const seconds = Math.max(Number(intervalSeconds || 3), 1);
|
||||
this.pollTimer = setTimeout(() => {
|
||||
this.pollAction();
|
||||
}, seconds * 1000);
|
||||
},
|
||||
stopPolling() {
|
||||
if (this.pollTimer) {
|
||||
clearTimeout(this.pollTimer);
|
||||
this.pollTimer = null;
|
||||
}
|
||||
},
|
||||
async pollAction() {
|
||||
if (!this.action || !this.flow.registration_code) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const res = await axios.post(this.action.endpoint, this.buildPayload('poll', {
|
||||
registration_code: this.flow.registration_code,
|
||||
}));
|
||||
if (res.data.status !== 'ok') {
|
||||
throw new Error(res.data.message || this.tm('registrationAction.pollFailed'));
|
||||
}
|
||||
const data = res.data.data || {};
|
||||
this.flow = {
|
||||
...this.flow,
|
||||
...data,
|
||||
status: data.status || 'error',
|
||||
};
|
||||
if (this.flow.status === 'created') {
|
||||
this.applyRegistrationResult(data);
|
||||
this.stopPolling();
|
||||
this.$emit('created', data);
|
||||
this.$emit('success', this.tm(this.action.successKey || 'registrationAction.created'));
|
||||
return;
|
||||
}
|
||||
if (this.flow.status === 'pending' || this.flow.status === 'slow_down') {
|
||||
const nextInterval = this.flow.status === 'slow_down'
|
||||
? Number(this.flow.interval || 5) + 5
|
||||
: Number(this.flow.interval || 5);
|
||||
this.flow.interval = nextInterval;
|
||||
this.schedulePoll(nextInterval);
|
||||
return;
|
||||
}
|
||||
this.stopPolling();
|
||||
} catch (err) {
|
||||
this.flow = {
|
||||
...this.flow,
|
||||
status: 'error',
|
||||
message: err.response?.data?.message || err.message || this.tm('registrationAction.pollFailed'),
|
||||
};
|
||||
this.$emit('error', this.flow.message);
|
||||
this.stopPolling();
|
||||
}
|
||||
},
|
||||
applyRegistrationResult(data) {
|
||||
if (!this.platformConfig || !data) {
|
||||
return;
|
||||
}
|
||||
if (data.app_id) {
|
||||
this.platformConfig.app_id = data.app_id;
|
||||
}
|
||||
if (data.app_secret) {
|
||||
this.platformConfig.app_secret = data.app_secret;
|
||||
}
|
||||
if (data.domain) {
|
||||
this.platformConfig.domain = data.domain;
|
||||
}
|
||||
if (data.weixin_oc_token) {
|
||||
this.platformConfig.weixin_oc_token = data.weixin_oc_token;
|
||||
}
|
||||
if (data.weixin_oc_account_id) {
|
||||
this.platformConfig.weixin_oc_account_id = data.weixin_oc_account_id;
|
||||
}
|
||||
if (data.weixin_oc_base_url) {
|
||||
this.platformConfig.weixin_oc_base_url = data.weixin_oc_base_url;
|
||||
}
|
||||
},
|
||||
getStatusText(status) {
|
||||
const normalizedStatus = status || 'idle';
|
||||
if (this.action?.statusKeyPrefix) {
|
||||
const platformStatusKey = `${this.action.statusKeyPrefix}.${normalizedStatus}`;
|
||||
const platformStatusText = this.tm(platformStatusKey);
|
||||
if (platformStatusText && platformStatusText !== platformStatusKey) {
|
||||
return platformStatusText;
|
||||
}
|
||||
}
|
||||
return this.tm(`registrationAction.status.${normalizedStatus}`);
|
||||
},
|
||||
getStatusColor(status) {
|
||||
switch (status) {
|
||||
case 'created': return 'success';
|
||||
case 'error':
|
||||
case 'denied':
|
||||
case 'expired': return 'error';
|
||||
case 'starting':
|
||||
case 'pending':
|
||||
case 'slow_down': return 'warning';
|
||||
default: return 'grey';
|
||||
}
|
||||
},
|
||||
getStatusIcon(status) {
|
||||
switch (status) {
|
||||
case 'created': return 'mdi-check-circle';
|
||||
case 'error':
|
||||
case 'denied':
|
||||
case 'expired': return 'mdi-alert-circle';
|
||||
case 'starting': return 'mdi-loading';
|
||||
case 'pending':
|
||||
case 'slow_down': return 'mdi-timer-sand';
|
||||
default: return 'mdi-circle-outline';
|
||||
}
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.platform-registration-panel {
|
||||
width: 320px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
.registration-scan-title {
|
||||
width: 190px;
|
||||
margin-bottom: 4px;
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
text-align: left;
|
||||
color: rgba(0, 0, 0, 0.78);
|
||||
}
|
||||
|
||||
.registration-scan-content {
|
||||
margin-left: 8px;
|
||||
}
|
||||
|
||||
.registration-qr-stage {
|
||||
position: relative;
|
||||
width: 190px;
|
||||
min-height: 190px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.registration-qr-shell {
|
||||
width: 190px;
|
||||
min-height: 190px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
transition: filter 160ms ease, opacity 160ms ease;
|
||||
}
|
||||
|
||||
.registration-qr-shell :deep(.qr-code-image) {
|
||||
width: 190px;
|
||||
}
|
||||
|
||||
.registration-qr-shell-created {
|
||||
filter: blur(2px);
|
||||
opacity: 0.32;
|
||||
}
|
||||
|
||||
.registration-qr-loading {
|
||||
width: 160px;
|
||||
height: 160px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border: 1px solid rgba(0, 0, 0, 0.12);
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.registration-created-overlay {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.registration-created-mark {
|
||||
width: 86px;
|
||||
height: 86px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: 50%;
|
||||
background: rgb(var(--v-theme-success));
|
||||
}
|
||||
|
||||
.registration-action-status,
|
||||
.registration-action-message {
|
||||
width: 190px;
|
||||
text-align: center;
|
||||
font-size: 13px;
|
||||
color: rgba(0, 0, 0, 0.72);
|
||||
word-break: break-word;
|
||||
}
|
||||
</style>
|
||||
@@ -30,6 +30,10 @@ export default {
|
||||
type: Number,
|
||||
default: 260,
|
||||
},
|
||||
margin: {
|
||||
type: Number,
|
||||
default: 2,
|
||||
},
|
||||
emptyHint: {
|
||||
type: String,
|
||||
default: "暂无可用二维码",
|
||||
@@ -56,7 +60,7 @@ export default {
|
||||
|
||||
try {
|
||||
this.imageSrc = await QRCode.toDataURL(value, {
|
||||
margin: 2,
|
||||
margin: this.margin,
|
||||
width: this.size,
|
||||
errorCorrectionLevel: "M",
|
||||
});
|
||||
|
||||
@@ -465,10 +465,6 @@
|
||||
"description": "WeChat Customer Service Account Name",
|
||||
"hint": "Optional. Customer service account name (not ID). Get it at https://kf.weixin.qq.com/kf/frame#/accounts"
|
||||
},
|
||||
"lark_bot_name": {
|
||||
"description": "Lark Bot Name",
|
||||
"hint": "Must be correct; otherwise @ mentions will not wake the bot and only prefix wake will work."
|
||||
},
|
||||
"lark_connection_mode": {
|
||||
"description": "Subscription Mode",
|
||||
"labels": [
|
||||
|
||||
@@ -130,6 +130,49 @@
|
||||
"waiting": "Waiting for QR",
|
||||
"close": "Close"
|
||||
},
|
||||
"registrationAction": {
|
||||
"lark": {
|
||||
"title": "One-click QR Setup",
|
||||
"show": "One-click QR Setup",
|
||||
"scanTitle": "Scan with Feishu on your phone",
|
||||
"feishu": "Feishu China",
|
||||
"lark": "Lark Global"
|
||||
},
|
||||
"weixinOc": {
|
||||
"title": "Personal WeChat QR Login",
|
||||
"scanTitle": "Scan with WeChat on your phone",
|
||||
"created": "Login Complete",
|
||||
"status": {
|
||||
"idle": "Not Started",
|
||||
"starting": "Getting QR Code",
|
||||
"pending": "Waiting for Scan",
|
||||
"created": "Login Complete - remember to click the save button!",
|
||||
"denied": "Canceled",
|
||||
"expired": "Expired",
|
||||
"error": "Login Failed"
|
||||
}
|
||||
},
|
||||
"start": "Start Setup",
|
||||
"created": "Setup Complete",
|
||||
"startFailed": "Failed to start QR setup",
|
||||
"pollFailed": "Failed to poll QR setup status",
|
||||
"mode": {
|
||||
"title": "Choose setup method",
|
||||
"scan": "One-click QR setup",
|
||||
"manual": "Manual setup (supports self-hosted Feishu)"
|
||||
},
|
||||
"scanTitle": "Scan with Feishu on your phone",
|
||||
"status": {
|
||||
"idle": "Not Started",
|
||||
"starting": "Starting",
|
||||
"pending": "Waiting for Scan",
|
||||
"slow_down": "Polling Slowed",
|
||||
"created": "Setup Complete - remember to click the save button!",
|
||||
"denied": "Canceled",
|
||||
"expired": "Expired",
|
||||
"error": "Setup Failed"
|
||||
}
|
||||
},
|
||||
"errorDialog": {
|
||||
"title": "Error Details",
|
||||
"platformId": "Platform ID",
|
||||
|
||||
@@ -465,10 +465,6 @@
|
||||
"description": "Имя аккаунта службы поддержки WeChat",
|
||||
"hint": "Необязательно. См. https://kf.weixin.qq.com/kf/frame#/accounts"
|
||||
},
|
||||
"lark_bot_name": {
|
||||
"description": "Имя бота Lark",
|
||||
"hint": "Должно быть точным для работы через @ упоминания."
|
||||
},
|
||||
"lark_connection_mode": {
|
||||
"description": "Режим подписки",
|
||||
"labels": [
|
||||
|
||||
@@ -130,6 +130,49 @@
|
||||
"waiting": "Ожидание QR",
|
||||
"close": "Закрыть"
|
||||
},
|
||||
"registrationAction": {
|
||||
"lark": {
|
||||
"title": "Настройка по QR",
|
||||
"show": "Настройка по QR",
|
||||
"scanTitle": "Отсканируйте в мобильном Feishu",
|
||||
"feishu": "Feishu China",
|
||||
"lark": "Lark Global"
|
||||
},
|
||||
"weixinOc": {
|
||||
"title": "QR вход в WeChat",
|
||||
"scanTitle": "Отсканируйте в мобильном WeChat",
|
||||
"created": "Вход выполнен",
|
||||
"status": {
|
||||
"idle": "Не начато",
|
||||
"starting": "Получение QR",
|
||||
"pending": "Ожидание сканирования",
|
||||
"created": "Вход выполнен - не забудьте нажать кнопку сохранения!",
|
||||
"denied": "Отменено",
|
||||
"expired": "Истекло",
|
||||
"error": "Ошибка входа"
|
||||
}
|
||||
},
|
||||
"start": "Начать настройку",
|
||||
"created": "Настройка завершена",
|
||||
"startFailed": "Не удалось начать QR настройку",
|
||||
"pollFailed": "Не удалось проверить статус QR настройки",
|
||||
"mode": {
|
||||
"title": "Выберите способ создания",
|
||||
"scan": "Создать через QR",
|
||||
"manual": "Создать вручную (поддерживает self-hosted Feishu)"
|
||||
},
|
||||
"scanTitle": "Отсканируйте в мобильном Feishu",
|
||||
"status": {
|
||||
"idle": "Не начато",
|
||||
"starting": "Запуск",
|
||||
"pending": "Ожидание сканирования",
|
||||
"slow_down": "Опрос замедлен",
|
||||
"created": "Настройка завершена - не забудьте нажать кнопку сохранения!",
|
||||
"denied": "Отменено",
|
||||
"expired": "Истекло",
|
||||
"error": "Ошибка настройки"
|
||||
}
|
||||
},
|
||||
"errorDialog": {
|
||||
"title": "Детали ошибки",
|
||||
"platformId": "ID платформы",
|
||||
|
||||
@@ -467,10 +467,6 @@
|
||||
"description": "微信客服账号名",
|
||||
"hint": "如果填写此项,即代表你将使用企业微信客服,而不是企业微信应用。可在 https://kf.weixin.qq.com/kf/frame#/accounts 获取。"
|
||||
},
|
||||
"lark_bot_name": {
|
||||
"description": "飞书机器人的名字",
|
||||
"hint": "请务必填写正确,否则 @ 机器人将无法唤醒,只能通过前缀唤醒。"
|
||||
},
|
||||
"lark_connection_mode": {
|
||||
"description": "订阅方式",
|
||||
"labels": [
|
||||
|
||||
@@ -130,6 +130,49 @@
|
||||
"waiting": "等待二维码",
|
||||
"close": "关闭"
|
||||
},
|
||||
"registrationAction": {
|
||||
"lark": {
|
||||
"title": "一键扫码创建",
|
||||
"show": "一键扫码创建",
|
||||
"scanTitle": "请使用手机飞书扫码",
|
||||
"feishu": "飞书国内版",
|
||||
"lark": "Lark 海外版"
|
||||
},
|
||||
"weixinOc": {
|
||||
"title": "个人微信扫码登录",
|
||||
"scanTitle": "请使用手机微信扫码",
|
||||
"created": "登录成功",
|
||||
"status": {
|
||||
"idle": "未开始",
|
||||
"starting": "正在获取二维码",
|
||||
"pending": "等待扫码确认",
|
||||
"created": "登录成功,记得点击下方保存按钮!",
|
||||
"denied": "用户取消",
|
||||
"expired": "已过期",
|
||||
"error": "登录失败"
|
||||
}
|
||||
},
|
||||
"start": "开始创建",
|
||||
"created": "创建成功",
|
||||
"startFailed": "发起扫码创建失败",
|
||||
"pollFailed": "获取扫码创建状态失败",
|
||||
"mode": {
|
||||
"title": "选择创建方式",
|
||||
"scan": "扫码一键创建",
|
||||
"manual": "手动创建(支持企业自部署飞书平台)"
|
||||
},
|
||||
"scanTitle": "请使用手机飞书扫码",
|
||||
"status": {
|
||||
"idle": "未开始",
|
||||
"starting": "正在发起",
|
||||
"pending": "等待扫码确认",
|
||||
"slow_down": "轮询降速",
|
||||
"created": "创建成功,记得点击下方保存按钮!",
|
||||
"denied": "用户取消",
|
||||
"expired": "已过期",
|
||||
"error": "创建失败"
|
||||
}
|
||||
},
|
||||
"errorDialog": {
|
||||
"title": "错误详情",
|
||||
"platformId": "平台 ID",
|
||||
|
||||
@@ -20,6 +20,31 @@ The Lark client version must be >= 7.20. Lower versions only display the title a
|
||||
|
||||
## Creating a Bot
|
||||
|
||||
Lark supports two setup methods: one-click QR creation in AstrBot, or manually creating a custom enterprise app in the Lark Developer Console.
|
||||
|
||||
### Option 1: One-click QR Creation
|
||||
|
||||
AstrBot version requirement: >= 4.25.0.
|
||||
|
||||
Open the AstrBot management panel, click `Bots` in the left sidebar, click `+ Create Bot`, and select `lark`.
|
||||
|
||||
Under `Creation Method`, select `One-click QR Creation`, choose the China or international edition as needed, then scan the QR code with the Lark mobile app and confirm. After creation succeeds, AstrBot automatically fills in the app's `app_id`, `app_secret`, and domain configuration.
|
||||
|
||||
> [!IMPORTANT]
|
||||
> After an app is created through QR scanning, group chats receive only messages that @ mention the bot or messages triggered by a wake prefix such as `/` by default. If you need the bot to receive all group messages, enable the additional permissions in the Lark Developer Console.
|
||||
>
|
||||
> Replace `<APP_ID>` in the URL below with your Lark app ID, then open it to jump to the permission enablement page:
|
||||
>
|
||||
> To find the App ID, go back to AstrBot's `Bots` page, find the Lark bot you just created, click `Edit`, and check the dialog that opens.
|
||||
>
|
||||
> ```text
|
||||
> https://open.feishu.cn/app/<APP_ID>/auth?q=contact:contact.base:readonly,im:message.p2p_msg:readonly,im:message.group_at_msg:readonly,im:message:send,im:message,im:message:send_as_bot,im:resource:upload,im:resource,cardkit:card:write,im:message.group_at_msg:readonly,im:message.group_msg&op_from=openapi&token_type=tenant
|
||||
> ```
|
||||
|
||||
After QR creation succeeds, continue checking the event subscription, permissions, version release, and group installation steps below.
|
||||
|
||||
### Option 2: Manual Creation
|
||||
|
||||
Navigate to the [Developer Console](https://open.feishu.cn/app) and create a custom enterprise application.
|
||||
|
||||

|
||||
@@ -38,6 +63,7 @@ Click on "Credentials & Basic Info" to obtain your app_id and app_secret.
|
||||
2. Click on `Bots` in the left sidebar
|
||||
3. In the right panel, click `+ Create Bot`
|
||||
4. Select `lark`
|
||||
5. If you want AstrBot to create the app for you, select `One-click QR Creation` and complete the scan. If you already created the app yourself, select `Manual Creation`
|
||||
|
||||
Fill in the configuration fields as follows:
|
||||
|
||||
@@ -45,7 +71,6 @@ Fill in the configuration fields as follows:
|
||||
- Enable: Check this option
|
||||
- app_id: The app_id you obtained earlier
|
||||
- app_secret: The app_secret you obtained earlier
|
||||
- Bot name: Your Lark bot's name
|
||||
|
||||
For the domain field, if you're using Lark China, keep the default value. If you're using Lark International, set it to `https://open.larksuite.com`. If you're using a self-hosted enterprise Lark instance, enter your Lark instance's domain.
|
||||
|
||||
@@ -92,6 +117,11 @@ Next, click on "Permission Management," click "Enable Permissions," and enter `i
|
||||
|
||||
Enter `im:resource:upload,im:resource` again to enable image upload permissions.
|
||||
|
||||
If you want to use the bot in group chats, additionally enable `im:message.group_at_msg:readonly` and `im:message.group_msg`.
|
||||
|
||||
> [!TIP]
|
||||
> Apps created through one-click QR creation are suitable for @ mentions and wake-prefix triggers by default. To receive every group message, make sure `im:message.group_msg` is enabled. You can also use the permission URL above to quickly open the corresponding page.
|
||||
|
||||
If you want to use streaming output, additionally enable `Create and update cards (cardkit:card:write)`.
|
||||
|
||||
The final set of permissions should look like this:
|
||||
|
||||
@@ -25,6 +25,8 @@ AstrBot supports connecting a personal WeChat account through the `Personal WeCh
|
||||
2. Click `Bots` in the left sidebar.
|
||||
3. Click `+ Create Bot` in the upper-right corner.
|
||||
4. Select `Personal WeChat`.
|
||||
5. The login QR code is shown directly. Scan it with WeChat on your phone and confirm the login inside WeChat.
|
||||
6. After login succeeds, click `Save`.
|
||||
|
||||
## Configuration Notes
|
||||
|
||||
@@ -44,15 +46,12 @@ Leave the remaining options at their default values unless you explicitly know y
|
||||
|
||||
## QR Login
|
||||
|
||||
1. Fill in the configuration and click `Save`.
|
||||
2. Return to the bot list. AstrBot will automatically request a login QR code from WeChat.
|
||||
3. On the bot card, click `View QR Code` to open the QR dialog.
|
||||
4. Scan it with WeChat on your phone, then confirm the login inside WeChat.
|
||||
After you select `Personal WeChat`, AstrBot automatically requests a login QR code from WeChat and shows it directly in the create-bot dialog. Scan it with WeChat on your phone and confirm the login. When the QR area shows the login-success state, click `Save` to finish creating the bot.
|
||||
|
||||
After login succeeds, AstrBot will automatically persist the login state. On later restarts, if the session is still valid, you usually do not need to scan again.
|
||||
After login succeeds and the bot is saved, AstrBot will automatically persist the login state. On later restarts, if the session is still valid, you usually do not need to scan again.
|
||||
|
||||
> [!NOTE]
|
||||
> If the QR code expires, AstrBot will automatically request a new one. Please scan the refreshed QR code instead of the old one.
|
||||
> If the QR code expires, close and reopen the create-bot dialog, or select `Personal WeChat` again to request a new QR code.
|
||||
|
||||
## Verification
|
||||
|
||||
|
||||
@@ -20,6 +20,31 @@
|
||||
|
||||
## 创建机器人
|
||||
|
||||
飞书(Lark)支持两种创建方式:在 AstrBot 中扫码一键创建,或在飞书开发者后台手动创建企业自建应用。
|
||||
|
||||
### 方式一:扫码一键创建
|
||||
|
||||
需要版本 >4.25.0。
|
||||
|
||||
进入 AstrBot 管理面板,点击左边栏 `机器人`,然后点击 `+ 创建机器人`,选择 `lark(飞书)`。
|
||||
|
||||
在 `选择创建方式` 中选择 `扫码一键创建`,按需选择国内版或海外版,然后使用手机飞书扫描页面中的二维码并确认。创建成功后,AstrBot 会自动写入该应用的 `app_id`、`app_secret` 和域名配置。
|
||||
|
||||
> [!IMPORTANT]
|
||||
> 通过扫码方式创建后,群聊下默认仅会接收 @ 机器人和通过唤醒前缀(例如 `/`)触发的消息。如果你希望机器人接收群聊中的所有消息,需要前往飞书开发者后台为应用开通额外权限。
|
||||
>
|
||||
> 可以将下面链接中的 `<APP_ID>` 替换为你的飞书应用 App ID 后打开,一键进入权限开通页:
|
||||
>
|
||||
> App ID 获取方式:回到 AstrBot 的 `机器人` 页,找到刚刚创建的飞书机器人,点击 `编辑`,弹出的对话框中可以看到 App ID。
|
||||
>
|
||||
> ```text
|
||||
> https://open.feishu.cn/app/<APP_ID>/auth?q=contact:contact.base:readonly,im:message.p2p_msg:readonly,im:message.group_at_msg:readonly,im:message:send,im:message,im:message:send_as_bot,im:resource:upload,im:resource,cardkit:card:write,im:message.group_at_msg:readonly,im:message.group_msg&op_from=openapi&token_type=tenant
|
||||
> ```
|
||||
|
||||
扫码创建完成后,建议继续检查后文的事件订阅、权限、版本发布和拉入群组步骤。
|
||||
|
||||
### 方式二:手动创建
|
||||
|
||||
前往 [开发者后台](https://open.feishu.cn/app) ,创建企业自建应用。
|
||||
|
||||

|
||||
@@ -38,6 +63,7 @@
|
||||
2. 点击左边栏 `机器人`
|
||||
3. 然后在右边的界面中,点击 `+ 创建机器人`
|
||||
4. 选择 `lark(飞书)`
|
||||
5. 如果使用扫码一键创建,选择 `扫码一键创建` 并完成扫码;如果使用自己创建的企业自建应用,选择 `手动创建`
|
||||
|
||||
弹出的配置项填写:
|
||||
|
||||
@@ -45,7 +71,6 @@
|
||||
- 启用(enable): 勾选。
|
||||
- app_id: 获取的 app_id
|
||||
- app_secret: 获取的 app_secret
|
||||
- 飞书机器人的名字
|
||||
|
||||
对于 domain,如果您使用国内版飞书,保持默认即可;如果您正在用国际版飞书,请设置为 `https://open.larksuite.com`;如果您使用企业自部署飞书,请填写您的飞书实例的域名。
|
||||
|
||||
@@ -94,6 +119,9 @@
|
||||
|
||||
如果需要在群聊里使用,请额外开通 `im:message.group_at_msg:readonly` 和 `im:message.group_msg` 权限。
|
||||
|
||||
> [!TIP]
|
||||
> 扫码一键创建的应用默认适合 @ 机器人和唤醒前缀触发。如果要接收群聊所有消息,请确认已经开通 `im:message.group_msg`。你也可以使用上文提供的权限开通链接快速进入对应页面。
|
||||
|
||||
如果需要使用流式输出,请额外开通 `创建与更新卡片(cardkit:card:write)` 权限。
|
||||
|
||||
最终开通的权限如下图:
|
||||
|
||||
@@ -23,6 +23,8 @@ AstrBot 支持通过 `个人微信` 适配器接入微信个人号。该适配
|
||||
2. 点击左侧栏 `机器人`。
|
||||
3. 点击右上角 `+ 创建机器人`。
|
||||
4. 选择 `个人微信`。
|
||||
5. 页面会直接显示登录二维码,使用手机微信扫码,并在微信内确认登录。
|
||||
6. 登录成功后点击 `保存`。
|
||||
|
||||
## 配置项说明
|
||||
|
||||
@@ -42,18 +44,12 @@ AstrBot 支持通过 `个人微信` 适配器接入微信个人号。该适配
|
||||
|
||||
## 扫码登录
|
||||
|
||||
1. 填好配置后点击 `保存`。
|
||||
2. 返回机器人列表,AstrBot 会自动向微信接口申请登录二维码。
|
||||
3. 在**机器人卡片**中点击 “查看二维码” 按钮,会弹出二维码对话框。(点击保存之后可能需要等 5 到 10 秒左右才会出现这个按钮)
|
||||
4. 使用手机微信扫码,并在微信内确认登录。
|
||||
选择 `个人微信` 后,AstrBot 会自动向微信接口申请登录二维码,并直接显示在创建机器人弹窗中。使用手机微信扫码确认后,二维码会显示登录成功状态,此时点击 `保存` 即可完成创建。
|
||||
|
||||

|
||||
|
||||
登录成功后,AstrBot 会自动保存登录态。后续重启时,如果登录态仍有效,通常不需要再次扫码。
|
||||
登录成功并保存后,AstrBot 会自动保存登录态。后续重启时,如果登录态仍有效,通常不需要再次扫码。
|
||||
|
||||
> [!NOTE]
|
||||
> 1. 如果二维码过期,AstrBot 会自动重新申请新的二维码。刷新后请使用新的二维码重新扫码。
|
||||
> 2. 如果 WebUI 没看到 “查看二维码” 按钮,可以前往终端或者 WebUI 控制台,找到 `请使用手机微信扫码登录,二维码有效期 5 分钟,过期后会自动刷新。` 对应的日志,附近会显示二维码扫码链接和终端直接输出的二维码,直选择一种方式扫码即可。
|
||||
> 如果二维码过期,请关闭并重新打开创建机器人弹窗,或重新选择 `个人微信` 以获取新的二维码。
|
||||
|
||||
## 验证
|
||||
|
||||
@@ -77,4 +73,3 @@ AstrBot 支持通过 `个人微信` 适配器接入微信个人号。该适配
|
||||
|
||||
- 该适配器通过扫码登录个人微信,接入方式与微信公众号、企业微信不同。
|
||||
- 不需要配置公网回调地址,也不需要开启统一 Webhook 模式。
|
||||
|
||||
|
||||
32
tests/test_lark_app_registration.py
Normal file
32
tests/test_lark_app_registration.py
Normal file
@@ -0,0 +1,32 @@
|
||||
from astrbot.core.platform.sources.lark.app_registration import (
|
||||
DEFAULT_FEISHU_OPEN_DOMAIN,
|
||||
DEFAULT_LARK_OPEN_DOMAIN,
|
||||
_registration_data,
|
||||
resolve_app_registration_endpoints,
|
||||
)
|
||||
|
||||
|
||||
def test_resolve_app_registration_endpoints_uses_feishu_accounts_domain():
|
||||
endpoints = resolve_app_registration_endpoints(DEFAULT_FEISHU_OPEN_DOMAIN)
|
||||
|
||||
assert endpoints.open_base == DEFAULT_FEISHU_OPEN_DOMAIN
|
||||
assert endpoints.registration == (
|
||||
"https://accounts.feishu.cn/oauth/v1/app/registration"
|
||||
)
|
||||
|
||||
|
||||
def test_resolve_app_registration_endpoints_uses_lark_accounts_domain():
|
||||
endpoints = resolve_app_registration_endpoints(DEFAULT_LARK_OPEN_DOMAIN)
|
||||
|
||||
assert endpoints.open_base == DEFAULT_LARK_OPEN_DOMAIN
|
||||
assert endpoints.registration == (
|
||||
"https://accounts.larksuite.com/oauth/v1/app/registration"
|
||||
)
|
||||
|
||||
|
||||
def test_registration_data_accepts_wrapped_and_plain_payloads():
|
||||
wrapped = {"data": {"device_code": "device"}}
|
||||
plain = {"device_code": "device"}
|
||||
|
||||
assert _registration_data(wrapped) == {"device_code": "device"}
|
||||
assert _registration_data(plain) == {"device_code": "device"}
|
||||
47
tests/test_weixin_oc_login_registration.py
Normal file
47
tests/test_weixin_oc_login_registration.py
Normal file
@@ -0,0 +1,47 @@
|
||||
from astrbot.core.platform.sources.weixin_oc.login_registration import (
|
||||
DEFAULT_WEIXIN_OC_BASE_URL,
|
||||
normalize_weixin_oc_base_url,
|
||||
weixin_oc_login_result,
|
||||
)
|
||||
|
||||
|
||||
def test_normalize_weixin_oc_base_url_uses_default_and_strips_slash():
|
||||
assert normalize_weixin_oc_base_url("") == DEFAULT_WEIXIN_OC_BASE_URL
|
||||
assert (
|
||||
normalize_weixin_oc_base_url("https://ilinkai.weixin.qq.com/")
|
||||
== DEFAULT_WEIXIN_OC_BASE_URL
|
||||
)
|
||||
|
||||
|
||||
def test_weixin_oc_login_result_maps_confirmed_payload():
|
||||
result = weixin_oc_login_result(
|
||||
{
|
||||
"status": "confirmed",
|
||||
"bot_token": "token",
|
||||
"ilink_bot_id": "bot-id",
|
||||
"baseurl": "https://example.com/",
|
||||
"ilink_user_id": "user-id",
|
||||
},
|
||||
default_base_url=DEFAULT_WEIXIN_OC_BASE_URL,
|
||||
)
|
||||
|
||||
assert result == {
|
||||
"status": "created",
|
||||
"qr_status": "confirmed",
|
||||
"weixin_oc_token": "token",
|
||||
"weixin_oc_account_id": "bot-id",
|
||||
"weixin_oc_base_url": "https://example.com",
|
||||
"weixin_oc_user_id": "user-id",
|
||||
}
|
||||
|
||||
|
||||
def test_weixin_oc_login_result_maps_wait_and_expired_payloads():
|
||||
assert weixin_oc_login_result(
|
||||
{"status": "wait"},
|
||||
default_base_url=DEFAULT_WEIXIN_OC_BASE_URL,
|
||||
) == {"status": "pending", "qr_status": "wait"}
|
||||
|
||||
assert weixin_oc_login_result(
|
||||
{"status": "expired"},
|
||||
default_base_url=DEFAULT_WEIXIN_OC_BASE_URL,
|
||||
) == {"status": "expired", "qr_status": "expired", "message": "二维码已过期"}
|
||||
Reference in New Issue
Block a user