feat(feishu): one-click QR-scan app creation

This commit is contained in:
zhayujie
2026-05-05 18:32:58 +08:00
parent 8730f7fd27
commit a9c8a59f58
8 changed files with 867 additions and 145 deletions

View File

@@ -55,6 +55,176 @@ def _ensure_lark_imported():
return lark
def _print_qr_to_terminal(qr_url: str):
"""Render a QR code as ASCII art and emit it via logger.
走 logger 而非 print 是为了避免 nohup/cow 后台启动场景下 stdout 块缓冲导致
二维码滞后输出看起来像出现了两次。logger 的 StreamHandler 是行缓冲,
既能在前台终端看到,也能进 run.log。
"""
qr_lines = []
try:
import qrcode as qr_lib
import io
qr = qr_lib.QRCode(error_correction=qr_lib.constants.ERROR_CORRECT_L, box_size=1, border=1)
qr.add_data(qr_url)
qr.make(fit=True)
buf = io.StringIO()
qr.print_ascii(out=buf, invert=True)
qr_lines = buf.getvalue().splitlines()
except ImportError:
qr_lines = ["(未安装 qrcode 包,无法渲染 ASCII 二维码pip install qrcode)"]
except Exception as e:
qr_lines = [f"(渲染二维码失败:{e})"]
header = "=" * 60
banner = [
"",
header,
" 飞书一键创建应用:请使用 飞书 App 扫描下方二维码",
" (二维码 10 分钟内有效,仅供一次扫描)",
header,
]
footer = [
f" 或点击链接创建: {qr_url}",
" 等待扫码...",
"",
]
full = banner + qr_lines + footer
logger.info("[FeiShu] One-click 飞书应用创建二维码(请用飞书 App 扫码):\n" + "\n".join(full))
def _persist_feishu_credentials(app_id: str, app_secret: str) -> bool:
"""Write feishu_app_id / feishu_app_secret + ensure feishu in channel_type into config.json.
Returns True on success, False on failure (e.g. config.json missing or unwritable).
"""
try:
config_path = os.path.join(
os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))),
"config.json",
)
if os.path.exists(config_path):
with open(config_path, "r", encoding="utf-8") as f:
file_cfg = json.load(f)
else:
file_cfg = {}
file_cfg["feishu_app_id"] = app_id
file_cfg["feishu_app_secret"] = app_secret
# 保证 channel_type 中包含 feishu用户可能纯通过 CLI 启动单通道)
ch_type = file_cfg.get("channel_type", conf().get("channel_type", "")) or ""
existing = [s.strip() for s in ch_type.split(",") if s.strip()]
if "feishu" not in existing:
existing.append("feishu")
file_cfg["channel_type"] = ",".join(existing)
with open(config_path, "w", encoding="utf-8") as f:
json.dump(file_cfg, f, indent=4, ensure_ascii=False)
# 同步到内存中的 conf(),让本次启动直接生效
conf()["feishu_app_id"] = app_id
conf()["feishu_app_secret"] = app_secret
if "channel_type" in file_cfg:
conf()["channel_type"] = file_cfg["channel_type"]
try:
os.chmod(config_path, 0o600)
except Exception:
pass
return True
except Exception as e:
logger.error(f"[FeiShu] Failed to persist credentials to config.json: {e}")
return False
def _register_via_qr_in_terminal() -> bool:
"""CLI-side one-click app creation via lark_oapi.register_app.
Blocks the calling thread (typically the channel startup thread) until the user
finishes scanning, the QR code expires, or registration is cancelled.
Returns True if credentials were obtained AND persisted; False otherwise.
The caller should fall back to the original "missing credentials" error in that case.
"""
if not LARK_SDK_AVAILABLE:
logger.error(
"[FeiShu] 缺少 feishu_app_id / feishu_app_secret。"
"未安装 lark-oapi SDK无法在终端发起扫码创建。"
"请执行 pip install -U 'lark-oapi>=1.5.5' 后重试,或手动在 config.json 中填入凭据。"
)
return False
try:
lark_mod = _ensure_lark_imported()
except Exception as e:
logger.error(f"[FeiShu] Import lark_oapi failed: {e}")
return False
# register_app 是 lark-oapi 1.5.5 才引入的能力,旧版本调用会得到难以理解的
# AttributeError。提前显式检查给出明确的升级提示。
if not hasattr(lark_mod, "register_app"):
try:
from importlib.metadata import version as _pkg_version
installed = _pkg_version("lark-oapi")
except Exception:
installed = "unknown"
logger.error(
f"[FeiShu] 当前 lark-oapi 版本 ({installed}) 不支持一键创建应用,需要 >= 1.5.5。"
"请执行 pip install -U 'lark-oapi>=1.5.5' 后重试,或手动在 config.json 中填入凭据。"
)
return False
logger.info("[FeiShu] 检测到尚未配置 feishu_app_id / feishu_app_secret"
"正在向飞书申请一键创建应用...")
def _on_qr(info):
url = info.get("url", "")
if url:
_print_qr_to_terminal(url)
def _on_status(info):
# 过滤 polling 心跳(每 5 秒一次),保留 slow_down / domain_switched 等
status = info.get("status")
if status == "polling":
return
logger.info(f"[FeiShu] register_app status: {info}")
try:
result = lark_mod.register_app(
on_qr_code=_on_qr,
on_status_change=_on_status,
source="cowagent",
)
except Exception as e:
err_cls = e.__class__.__name__
if "Expired" in err_cls:
logger.error("[FeiShu] 二维码已过期,请重启程序后重试。")
elif "Denied" in err_cls:
logger.error("[FeiShu] 已取消授权。")
else:
logger.error(f"[FeiShu] 一键创建失败:{e}")
return False
app_id = result.get("client_id", "")
app_secret = result.get("client_secret", "")
if not app_id or not app_secret:
logger.error("[FeiShu] 创建结果缺少 app_id/app_secret无法继续。")
return False
if not _persist_feishu_credentials(app_id, app_secret):
logger.error(
"[FeiShu] 应用创建成功但写入 config.json 失败,请手动复制以下值到配置文件:\n"
f" feishu_app_id = {app_id}\n"
f" feishu_app_secret = {app_secret}"
)
return False
logger.info(f"[FeiShu] 应用创建成功,凭据已写入 config.json (app_id={app_id})。")
return True
@singleton
class FeiShuChanel(ChatChannel):
feishu_app_id = conf().get('feishu_app_id')
@@ -90,6 +260,20 @@ class FeiShuChanel(ChatChannel):
self.feishu_app_secret = conf().get('feishu_app_secret')
self.feishu_token = conf().get('feishu_token')
self.feishu_event_mode = conf().get('feishu_event_mode', 'websocket')
# 命令行启动场景:缺少凭据时尝试通过 lark.register_app 在终端弹二维码
# 引导用户扫码创建应用。Web 控制台启动同样会走到这里,但控制台用户通常
# 已经通过 /api/feishu/register 完成了创建并写回 config.json。
if not self.feishu_app_id or not self.feishu_app_secret:
if _register_via_qr_in_terminal():
self.feishu_app_id = conf().get('feishu_app_id')
self.feishu_app_secret = conf().get('feishu_app_secret')
else:
err = "[FeiShu] feishu_app_id 与 feishu_app_secret 缺失,无法启动通道"
logger.error(err)
self.report_startup_error(err)
return
self._fetch_bot_open_id()
if self.feishu_event_mode == 'websocket':
self._startup_websocket()

View File

@@ -78,6 +78,19 @@ const I18N = {
wecom_scan_success: '创建成功,正在启动通道...',
wecom_scan_fail: '创建失败',
wecom_mode_scan: '扫码接入', wecom_mode_manual: '手动填写',
feishu_scan_btn: '一键创建飞书应用',
feishu_scan_desc: '使用飞书 App 扫码,自动创建应用并预置全部权限与事件订阅',
feishu_scan_replace_desc: '使用飞书 App 扫码创建新机器人,将覆盖当前的 App ID / Secret',
feishu_scan_loading: '正在向飞书申请二维码...',
feishu_scan_waiting: '等待扫码...',
feishu_scan_tip: '二维码 10 分钟内有效,仅供一次扫描',
feishu_scan_open_link: '或点击此处在浏览器中打开',
feishu_scan_success: '应用创建成功,正在启动通道...',
feishu_scan_expired: '二维码已过期,请重试',
feishu_scan_denied: '已取消授权',
feishu_scan_fail: '创建失败',
feishu_scan_retry: '重试',
feishu_mode_scan: '扫码创建', feishu_mode_manual: '手动填写',
tasks_title: '定时任务', tasks_desc: '查看和管理定时任务',
tasks_coming: '即将推出', tasks_coming_desc: '定时任务管理功能即将在此提供',
logs_title: '日志', logs_desc: '实时日志输出 (run.log)',
@@ -164,6 +177,19 @@ const I18N = {
wecom_scan_success: 'Bot created, starting channel...',
wecom_scan_fail: 'Bot creation failed',
wecom_mode_scan: 'Scan QR', wecom_mode_manual: 'Manual',
feishu_scan_btn: 'One-click Create Feishu App',
feishu_scan_desc: 'Scan with Feishu App to create an app with all required permissions pre-configured',
feishu_scan_replace_desc: 'Scan with Feishu App to create a new bot — will overwrite the current App ID / Secret',
feishu_scan_loading: 'Requesting QR code from Feishu...',
feishu_scan_waiting: 'Waiting for scan...',
feishu_scan_tip: 'QR code expires in 10 minutes, single use only',
feishu_scan_open_link: 'Or click here to open in browser',
feishu_scan_success: 'App created, starting channel...',
feishu_scan_expired: 'QR code expired, please retry',
feishu_scan_denied: 'Authorization cancelled',
feishu_scan_fail: 'App creation failed',
feishu_scan_retry: 'Retry',
feishu_mode_scan: 'Scan QR', feishu_mode_manual: 'Manual',
tasks_title: 'Scheduled Tasks', tasks_desc: 'View and manage scheduled tasks',
tasks_coming: 'Coming Soon', tasks_coming_desc: 'Scheduled task management will be available here',
logs_title: 'Logs', logs_desc: 'Real-time log output (run.log)',
@@ -2999,6 +3025,8 @@ function renderActiveChannels() {
const weixinWaiting = ch.name === 'weixin' && ch.login_status && ch.login_status !== 'logged_in';
const wecomNeedsCreds = ch.name === 'wecom_bot' && !_wecomBotHasCreds(ch);
// 飞书 active 卡片渲染带 Tab 的 panel手动填写 + 扫码重建(覆盖现有配置)
const isFeishu = ch.name === 'feishu';
let statusDot, statusText;
if (weixinWaiting) {
statusDot = 'bg-amber-400 animate-pulse';
@@ -3014,7 +3042,7 @@ function renderActiveChannels() {
}
card.innerHTML = `
<div class="flex items-center gap-4${hasFields || weixinWaiting || wecomNeedsCreds ? ' mb-5' : ''}">
<div class="flex items-center gap-4${hasFields || weixinWaiting || wecomNeedsCreds || isFeishu ? ' mb-5' : ''}">
<div class="w-10 h-10 rounded-xl bg-${ch.color}-50 dark:bg-${ch.color}-900/20 flex items-center justify-center flex-shrink-0">
<i class="fas ${ch.icon} text-${ch.color}-500 text-base"></i>
</div>
@@ -3050,7 +3078,7 @@ function renderActiveChannels() {
</button>
<div id="wecom-card-scan-status" class="mt-3"></div>
</div>` : ''}
${hasFields ? `<div class="space-y-4">
${isFeishu ? buildFeishuPanel(ch, true) : (hasFields ? `<div class="space-y-4">
${fieldsHtml}
<div class="flex items-center justify-end gap-3 pt-1">
<span id="ch-status-${ch.name}" class="text-xs text-primary-500 opacity-0 transition-opacity duration-300"></span>
@@ -3059,7 +3087,7 @@ function renderActiveChannels() {
cursor-pointer transition-colors duration-150 disabled:opacity-50 disabled:cursor-not-allowed"
id="ch-save-${ch.name}">${t('channels_save')}</button>
</div>
</div>` : ''}`;
</div>` : '')}`;
container.appendChild(card);
bindSecretFieldEvents(card);
@@ -3256,6 +3284,7 @@ function openAddChannelPanel() {
function closeAddChannelPanel() {
stopWeixinQrPoll();
stopFeishuRegisterPoll();
const panel = document.getElementById('channels-add-panel');
if (panel) {
panel.classList.add('hidden');
@@ -3267,6 +3296,7 @@ function closeAddChannelPanel() {
function onAddChannelSelect(chName) {
stopWeixinQrPoll();
stopFeishuRegisterPoll();
const fieldsContainer = document.getElementById('add-channel-fields');
const actions = document.getElementById('add-channel-actions');
@@ -3293,6 +3323,13 @@ function onAddChannelSelect(chName) {
return;
}
if (chName === 'feishu') {
actions.classList.add('hidden');
const ch = channelsData.find(c => c.name === chName);
fieldsContainer.innerHTML = buildFeishuPanel(ch);
return;
}
const ch = channelsData.find(c => c.name === chName);
if (!ch) return;
@@ -3690,15 +3727,246 @@ function startWecomBotAuthInCard() {
// Initialize wecom bot panel with correct default mode when inserted into DOM
document.addEventListener('DOMContentLoaded', function() {
const observer = new MutationObserver(function() {
const panel = document.getElementById('wecom-bot-panel');
if (panel && !panel.dataset.initialized) {
panel.dataset.initialized = '1';
switchWecomBotMode(panel.dataset.defaultMode || 'scan');
const wecomPanel = document.getElementById('wecom-bot-panel');
if (wecomPanel && !wecomPanel.dataset.initialized) {
wecomPanel.dataset.initialized = '1';
switchWecomBotMode(wecomPanel.dataset.defaultMode || 'scan');
}
const feishuPanel = document.getElementById('feishu-panel');
if (feishuPanel && !feishuPanel.dataset.initialized) {
feishuPanel.dataset.initialized = '1';
switchFeishuMode(feishuPanel.dataset.defaultMode || 'scan');
}
});
observer.observe(document.body, { childList: true, subtree: true });
});
// =====================================================================
// Feishu One-click App Registration (lark-oapi register_app)
// =====================================================================
let _feishuRegisterPollTimer = null;
function _feishuHasCreds(ch) {
if (!ch || !ch.fields) return false;
const idField = ch.fields.find(f => f.key === 'feishu_app_id');
const secretField = ch.fields.find(f => f.key === 'feishu_app_secret');
return !!(idField && idField.value && secretField && secretField.value);
}
function buildFeishuPanel(ch, isActive) {
const scanLabel = t('feishu_mode_scan');
const manualLabel = t('feishu_mode_manual');
// 已有凭据时默认进入手动 Tab方便修改否则推荐扫码
const defaultMode = _feishuHasCreds(ch) ? 'manual' : 'scan';
const activeAttr = isActive ? 'data-active="1"' : '';
return `
<div id="feishu-panel" data-default-mode="${defaultMode}" ${activeAttr}>
<div class="flex items-center justify-center gap-1 mb-5 bg-slate-100 dark:bg-white/5 rounded-lg p-1">
<button id="feishu-tab-scan" onclick="switchFeishuMode('scan')"
class="flex-1 px-3 py-1.5 rounded-md text-xs font-medium transition-colors
bg-white dark:bg-slate-700 text-slate-800 dark:text-slate-100 shadow-sm">
${scanLabel}
</button>
<button id="feishu-tab-manual" onclick="switchFeishuMode('manual')"
class="flex-1 px-3 py-1.5 rounded-md text-xs font-medium transition-colors
text-slate-500 dark:text-slate-400 hover:text-slate-700 dark:hover:text-slate-200">
${manualLabel}
</button>
</div>
<div id="feishu-mode-content"></div>
</div>`;
}
function switchFeishuMode(mode) {
const panel = document.getElementById('feishu-panel');
const scanTab = document.getElementById('feishu-tab-scan');
const manualTab = document.getElementById('feishu-tab-manual');
const content = document.getElementById('feishu-mode-content');
if (!scanTab || !manualTab || !content) return;
// 已激活通道卡片中嵌入此 panel 时,没有 add-channel-actions保存按钮就近渲染
const isActive = panel && panel.dataset.active === '1';
const actions = isActive ? null : document.getElementById('add-channel-actions');
const activeClasses = 'bg-white dark:bg-slate-700 text-slate-800 dark:text-slate-100 shadow-sm';
const inactiveClasses = 'text-slate-500 dark:text-slate-400 hover:text-slate-700 dark:hover:text-slate-200';
stopFeishuRegisterPoll();
if (mode === 'scan') {
scanTab.className = `flex-1 px-3 py-1.5 rounded-md text-xs font-medium transition-colors ${activeClasses}`;
manualTab.className = `flex-1 px-3 py-1.5 rounded-md text-xs font-medium transition-colors ${inactiveClasses}`;
if (actions) actions.classList.add('hidden');
// active 卡片下扫码替换的提示文案,强调"创建新机器人会覆盖现有配置"
const desc = isActive
? t('feishu_scan_replace_desc')
: t('feishu_scan_desc');
content.innerHTML = `
<div id="feishu-scan-panel" class="flex flex-col items-center py-4">
<p class="text-sm text-slate-600 dark:text-slate-300 mb-3 text-center">${desc}</p>
<button onclick="startFeishuRegister()"
class="mt-2 px-6 py-2.5 rounded-lg bg-emerald-500 hover:bg-emerald-600 text-white text-sm font-medium
cursor-pointer transition-colors duration-150">
<i class="fas fa-qrcode mr-2"></i>${t('feishu_scan_btn')}
</button>
<div id="feishu-scan-status" class="mt-4 w-full"></div>
</div>`;
} else {
manualTab.className = `flex-1 px-3 py-1.5 rounded-md text-xs font-medium transition-colors ${activeClasses}`;
scanTab.className = `flex-1 px-3 py-1.5 rounded-md text-xs font-medium transition-colors ${inactiveClasses}`;
const ch = channelsData.find(c => c.name === 'feishu');
const fieldsHtml = buildChannelFieldsHtml('feishu', ch ? ch.fields || [] : []);
if (isActive) {
// 已接入卡片:内置保存按钮,复用 saveChannelConfig 走 update 流程
content.innerHTML = `
<div class="space-y-4">
${fieldsHtml}
<div class="flex items-center justify-end gap-3 pt-1">
<span id="ch-status-feishu" class="text-xs text-primary-500 opacity-0 transition-opacity duration-300"></span>
<button onclick="saveChannelConfig('feishu')"
class="px-4 py-2 rounded-lg bg-primary-500 hover:bg-primary-600 text-white text-sm font-medium
cursor-pointer transition-colors duration-150 disabled:opacity-50 disabled:cursor-not-allowed"
id="ch-save-feishu">${t('channels_save')}</button>
</div>
</div>`;
} else {
content.innerHTML = `<div class="space-y-4">${fieldsHtml}</div>`;
if (actions) actions.classList.remove('hidden');
}
bindSecretFieldEvents(content);
}
}
function stopFeishuRegisterPoll() {
if (_feishuRegisterPollTimer) {
clearTimeout(_feishuRegisterPollTimer);
_feishuRegisterPollTimer = null;
}
}
function startFeishuRegister(targetStatusId) {
const statusId = targetStatusId || 'feishu-scan-status';
const statusEl = document.getElementById(statusId);
if (statusEl) {
statusEl.innerHTML = `<p class="text-sm text-slate-500 dark:text-slate-400 text-center">${t('feishu_scan_loading')}</p>`;
}
stopFeishuRegisterPoll();
fetch('/api/feishu/register')
.then(r => r.json())
.then(data => {
if (data.status !== 'success') {
renderFeishuRegisterError(statusId, data.message || t('feishu_scan_fail'));
return;
}
renderFeishuQr(statusId, data.qr_image, data.qrcode_url);
pollFeishuRegisterStatus(statusId);
})
.catch(err => {
renderFeishuRegisterError(statusId, err.message || t('feishu_scan_fail'));
});
}
function renderFeishuQr(statusId, qrImage, qrUrl) {
const statusEl = document.getElementById(statusId);
if (!statusEl) return;
const imgHtml = qrImage
? `<img src="${qrImage}" alt="QR" class="w-44 h-44 rounded-lg border border-slate-200 dark:border-white/10 bg-white p-2"/>`
: `<div class="w-44 h-44 rounded-lg border border-dashed border-slate-300 flex items-center justify-center text-xs text-slate-400">QR</div>`;
statusEl.innerHTML = `
<div class="flex flex-col items-center gap-3">
${imgHtml}
<p class="text-xs text-amber-500">${t('feishu_scan_waiting')}</p>
<p class="text-xs text-slate-400 dark:text-slate-500">${t('feishu_scan_tip')}</p>
${qrUrl ? `<a href="${qrUrl}" target="_blank" rel="noopener"
class="text-xs text-blue-500 hover:text-blue-600 underline">${t('feishu_scan_open_link')}</a>` : ''}
</div>`;
}
function renderFeishuRegisterError(statusId, message) {
const statusEl = document.getElementById(statusId);
if (!statusEl) return;
statusEl.innerHTML = `
<div class="flex flex-col items-center gap-2 py-2">
<p class="text-sm text-red-500 text-center">${message}</p>
<button onclick="startFeishuRegister('${statusId}')"
class="mt-1 px-4 py-1.5 rounded-md text-xs font-medium
bg-slate-100 dark:bg-white/10 text-slate-700 dark:text-slate-200
hover:bg-slate-200 dark:hover:bg-white/20 cursor-pointer">
<i class="fas fa-rotate-right mr-1"></i>${t('feishu_scan_retry')}
</button>
</div>`;
}
function pollFeishuRegisterStatus(statusId) {
stopFeishuRegisterPoll();
_feishuRegisterPollTimer = setTimeout(() => {
fetch('/api/feishu/register', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ action: 'poll' })
})
.then(r => r.json())
.then(data => {
if (data.status !== 'success') {
renderFeishuRegisterError(statusId, data.message || t('feishu_scan_fail'));
return;
}
const rs = data.register_status;
if (rs === 'done') {
const statusEl = document.getElementById(statusId);
if (statusEl) {
statusEl.innerHTML = `
<div class="flex flex-col items-center py-2">
<div class="w-10 h-10 rounded-full bg-emerald-50 dark:bg-emerald-900/30 flex items-center justify-center mb-2">
<i class="fas fa-check text-emerald-500 text-lg"></i>
</div>
<p class="text-sm font-medium text-emerald-600 dark:text-emerald-400">${t('feishu_scan_success')}</p>
</div>`;
}
connectFeishuAfterRegister(data.app_id, data.app_secret);
} else if (rs === 'expired') {
renderFeishuRegisterError(statusId, t('feishu_scan_expired'));
} else if (rs === 'denied') {
renderFeishuRegisterError(statusId, t('feishu_scan_denied'));
} else if (rs === 'error') {
renderFeishuRegisterError(statusId, data.message || t('feishu_scan_fail'));
} else {
pollFeishuRegisterStatus(statusId);
}
})
.catch(() => {
pollFeishuRegisterStatus(statusId);
});
}, 2000);
}
function connectFeishuAfterRegister(appId, appSecret) {
fetch('/api/channels', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
action: 'connect',
channel: 'feishu',
config: { feishu_app_id: appId, feishu_app_secret: appSecret }
})
})
.then(r => r.json())
.then(data => {
if (data.status === 'success') {
const ch = channelsData.find(c => c.name === 'feishu');
if (ch) {
ch.active = true;
(ch.fields || []).forEach(f => {
if (f.key === 'feishu_app_id') f.value = appId;
if (f.key === 'feishu_app_secret') f.value = ChannelsHandler_maskSecret(appSecret);
});
}
setTimeout(() => renderActiveChannels(), 1500);
}
})
.catch(() => {});
}
// =====================================================================
// Scheduler View
// =====================================================================

View File

@@ -575,6 +575,7 @@ class WebChannel(ChatChannel):
'/config', 'ConfigHandler',
'/api/channels', 'ChannelsHandler',
'/api/weixin/qrlogin', 'WeixinQrHandler',
'/api/feishu/register', 'FeishuRegisterHandler',
'/api/tools', 'ToolsHandler',
'/api/skills', 'SkillsHandler',
'/api/memory', 'MemoryHandler',
@@ -1034,8 +1035,6 @@ class ChannelsHandler:
"fields": [
{"key": "feishu_app_id", "label": "App ID", "type": "text"},
{"key": "feishu_app_secret", "label": "App Secret", "type": "secret"},
{"key": "feishu_token", "label": "Verification Token", "type": "secret"},
{"key": "feishu_bot_name", "label": "Bot Name", "type": "text"},
],
}),
("dingtalk", {
@@ -1530,6 +1529,174 @@ class WeixinQrHandler:
return json.dumps({"status": "success", "qr_status": qr_status})
class FeishuRegisterHandler:
"""飞书智能体应用一键创建OAuth 设备授权流,基于 lark.register_app SDK
GET /api/feishu/register → 启动注册:调用 SDK 生成二维码 URL立即返回
后台线程继续轮询飞书侧直到用户扫码授权。
POST /api/feishu/register → 轮询当前会话状态pending / done / error / expired
注册成功后不直接写 config由前端再调
/api/channels {action:'connect'} 走标准启用流程。
"""
# 进程内单例状态({url, expire_in, status, app_id, app_secret, error, thread})。
# 简单的本地自部署场景下不需要 session 隔离。
_state = {}
_lock = threading.Lock()
@staticmethod
def _qr_to_data_uri(data: str) -> str:
"""复用 WeixinQrHandler 的二维码渲染。"""
return WeixinQrHandler._qr_to_data_uri(data)
@classmethod
def _reset_state(cls):
with cls._lock:
cls._state = {}
@classmethod
def _start_register_thread(cls):
"""启动一次新的注册会话。如已有进行中的会话,先取消(通过 cancel_event"""
# 先取消可能存在的上一次会话,避免两个 SDK 线程并发 poll 同一个端点
with cls._lock:
old_cancel = cls._state.get("cancel_event") if cls._state else None
if old_cancel is not None:
old_cancel.set()
cancel_event = threading.Event()
cls._state = {"status": "starting", "cancel_event": cancel_event}
def _worker():
try:
import lark_oapi as lark
except ImportError:
with cls._lock:
cls._state["status"] = "error"
cls._state["error"] = "lark-oapi SDK 未安装,请执行 pip install -U lark-oapi"
return
def _on_qr(info):
# SDK 拿到二维码 URL 后立即回调;写入 state 让前端 GET 立刻能拿到
with cls._lock:
cls._state["url"] = info.get("url", "")
cls._state["expire_in"] = info.get("expire_in", 600)
cls._state["qr_image"] = cls._qr_to_data_uri(info.get("url", ""))
cls._state["status"] = "pending"
logger.info(f"[FeishuRegister] QR ready, expire_in={info.get('expire_in')}s")
def _on_status(info):
# 过滤掉 polling 心跳(每 5 秒一次,纯噪音);
# 保留 slow_down / domain_switched 等真正的状态切换事件
status = info.get("status")
if status == "polling":
return
logger.info(f"[FeishuRegister] SDK status: {info}")
try:
result = lark.register_app(
on_qr_code=_on_qr,
on_status_change=_on_status,
source="cowagent",
cancel_event=cancel_event,
)
with cls._lock:
cls._state["status"] = "done"
cls._state["app_id"] = result.get("client_id", "")
cls._state["app_secret"] = result.get("client_secret", "")
logger.info(f"[FeishuRegister] App created: app_id={result.get('client_id')}")
except Exception as e:
err_msg = str(e)
err_cls = e.__class__.__name__
# 飞书 SDK 抛出的 AppExpiredError / AppAccessDeniedError / RegisterAppError
if "Expired" in err_cls:
status = "expired"
elif "Denied" in err_cls:
status = "denied"
elif "abort" in err_msg.lower() or "cancel" in err_msg.lower():
# 被新一轮注册抢占,保持安静
return
else:
status = "error"
with cls._lock:
# 仅当当前 state 仍属于本次 worker 时才写入,避免覆盖更新的会话
if cls._state.get("cancel_event") is cancel_event:
cls._state["status"] = status
cls._state["error"] = err_msg
logger.warning(f"[FeishuRegister] Register failed ({err_cls}): {err_msg}")
threading.Thread(target=_worker, daemon=True, name="feishu-register").start()
def GET(self):
"""启动一次新的注册会话。如果已有 pending/done 会话则覆盖。"""
_require_auth()
web.header('Content-Type', 'application/json; charset=utf-8')
try:
self._start_register_thread()
# 等待 SDK 拿到二维码 URL最多 10s。SDK 内部会马上回调 _on_qr。
import time as _t
for _ in range(100):
with self._lock:
if self._state.get("url") or self._state.get("status") in ("error", "expired", "denied"):
break
_t.sleep(0.1)
with self._lock:
if self._state.get("status") in ("error", "expired", "denied"):
return json.dumps({
"status": "error",
"message": self._state.get("error", "register failed"),
})
if not self._state.get("url"):
return json.dumps({
"status": "error",
"message": "等待飞书二维码超时,请重试",
})
return json.dumps({
"status": "success",
"qrcode_url": self._state["url"],
"qr_image": self._state.get("qr_image", ""),
"expire_in": self._state.get("expire_in", 600),
})
except Exception as e:
logger.error(f"[WebChannel] FeishuRegister GET error: {e}")
return json.dumps({"status": "error", "message": str(e)})
def POST(self):
"""轮询注册结果。"""
_require_auth()
web.header('Content-Type', 'application/json; charset=utf-8')
try:
body = json.loads(web.data() or b"{}")
action = body.get("action", "poll")
if action != "poll":
return json.dumps({"status": "error", "message": f"unknown action: {action}"})
with self._lock:
status = self._state.get("status", "idle")
if status == "done":
payload = {
"status": "success",
"register_status": "done",
"app_id": self._state.get("app_id", ""),
"app_secret": self._state.get("app_secret", ""),
}
# 一次性返回凭据后清掉,避免敏感信息长期驻留内存
self._state = {}
return json.dumps(payload)
if status in ("error", "expired", "denied"):
return json.dumps({
"status": "success",
"register_status": status,
"message": self._state.get("error", ""),
})
# pending / starting还在等用户扫码
return json.dumps({
"status": "success",
"register_status": "pending",
})
except Exception as e:
logger.error(f"[WebChannel] FeishuRegister POST error: {e}")
return json.dumps({"status": "error", "message": str(e)})
def _get_workspace_root():
"""Resolve the agent workspace directory."""
from common.utils import expand_path