feat(im): add one-click Feishu bot creation via QR code device flow

This commit is contained in:
xumengxiang
2026-03-20 14:28:24 +08:00
parent 88c082c208
commit 6eecc40b07
11 changed files with 356 additions and 3 deletions
+3
View File
@@ -101,7 +101,10 @@
"@electron/remote": "^2.0.12",
"@headlessui/react": "^1.7.18",
"@heroicons/react": "^2.1.1",
"@larksuite/openclaw-lark": "^2026.3.17",
"@larksuite/openclaw-lark-tools": "~1.0.26",
"@larksuiteoapi/node-sdk": "^1.58.0",
"qrcode.react": "^4.2.0",
"@modelcontextprotocol/sdk": "^1.27.1",
"@reduxjs/toolkit": "^2.2.1",
"@types/uuid": "^10.0.0",
+1
View File
@@ -62,6 +62,7 @@ const EXCLUDED_DIRS = new Set([
'examples',
'coverage',
'.venv',
'.bin', // node_modules/.bin contains symlinks that break tar on cross-platform builds
]);
const EXCLUDED_ENVFILE = /^\.env(\..+)?$/i;
+8
View File
@@ -26,6 +26,10 @@ const translations: Record<LanguageType, Record<string, string>> = {
// Timeout hint
taskTimedOut: '[任务超时] 任务因超过最大允许时长而被自动停止。你可以继续对话以从中断处继续。',
// Feishu bot install
feishuVerifyCredentialsFailed: '凭证验证失败,请检查 App ID 和 App Secret 是否正确',
feishuVerifyFailed: '验证失败',
},
en: {
// Tray menu
@@ -39,6 +43,10 @@ const translations: Record<LanguageType, Record<string, string>> = {
// Timeout hint
taskTimedOut: '[Task timed out] The task was automatically stopped because it exceeded the maximum allowed duration. You can continue the conversation to pick up where it left off.',
// Feishu bot install
feishuVerifyCredentialsFailed: 'Credential validation failed. Please check your App ID and App Secret.',
feishuVerifyFailed: 'Verification failed',
},
};
+85
View File
@@ -6,6 +6,7 @@
import { EventEmitter } from 'events';
import * as path from 'path';
import { t } from '../i18n';
import { NimGateway } from './nimGateway';
import { XiaomifengGateway } from './xiaomifengGateway';
import { IMChatHandler } from './imChatHandler';
@@ -2113,6 +2114,90 @@ export class IMGatewayManager extends EventEmitter {
return status.discord.lastError;
}
// ==================== Feishu Bot Install Helpers ====================
/** Lazy-load and cache the feishu-auth module (avoid repeated dynamic import overhead). */
private _feishuAuthModule: any = null;
private async getFeishuAuthModule() {
if (!this._feishuAuthModule) {
this._feishuAuthModule = await import('@larksuite/openclaw-lark-tools/dist/utils/feishu-auth.js');
}
return this._feishuAuthModule;
}
/**
* Start the Feishu Device Flow onboarding: init + begin.
* Returns data needed to render a QR code in the UI.
* Also caches isLark so that pollFeishuInstall uses the correct domain.
*/
private _feishuInstallIsLark = false;
async startFeishuInstallQrcode(isLark: boolean): Promise<{
url: string;
deviceCode: string;
interval: number;
expireIn: number;
}> {
const { FeishuAuth } = await this.getFeishuAuthModule();
this._feishuInstallIsLark = isLark;
const auth = new FeishuAuth();
auth.setDomain(isLark);
await auth.init();
const resp = await auth.begin();
return {
url: resp.verification_uri_complete,
deviceCode: resp.device_code,
interval: resp.interval ?? 5,
expireIn: resp.expire_in ?? 300,
};
}
/**
* Poll Feishu Device Flow for the result of a QR code scan.
* Uses the domain set during startFeishuInstallQrcode to ensure consistency.
*/
async pollFeishuInstall(deviceCode: string): Promise<{
done: boolean;
appId?: string;
appSecret?: string;
domain?: string;
error?: string;
}> {
const { FeishuAuth } = await this.getFeishuAuthModule();
const auth = new FeishuAuth();
auth.setDomain(this._feishuInstallIsLark);
const resp = await auth.poll(deviceCode);
if (resp.error) {
if (resp.error === 'authorization_pending' || resp.error === 'slow_down') {
return { done: false };
}
return { done: false, error: resp.error_description || resp.error };
}
if (resp.client_id && resp.client_secret) {
const domain = resp.user_info?.tenant_brand === 'lark' ? 'lark' : 'feishu';
return { done: true, appId: resp.client_id, appSecret: resp.client_secret, domain };
}
return { done: false };
}
/**
* Validate existing Feishu app credentials (App ID + App Secret).
*/
async verifyFeishuCredentials(appId: string, appSecret: string): Promise<{
success: boolean;
error?: string;
}> {
const { validateAppCredentials } = await this.getFeishuAuthModule();
try {
const valid = await validateAppCredentials(appId, appSecret);
if (valid) {
return { success: true };
}
return { success: false, error: t('feishuVerifyCredentialsFailed') };
} catch (err: any) {
return { success: false, error: err?.message || t('feishuVerifyFailed') };
}
}
private calculateVerdict(checks: IMConnectivityCheck[]): IMConnectivityVerdict {
if (checks.some((check) => check.level === 'fail')) {
return 'fail';
+25
View File
@@ -2993,6 +2993,31 @@ if (!gotTheLock) {
}
});
// Feishu bot install helpers
ipcMain.handle('feishu:install:qrcode', async (_event, { isLark }: { isLark: boolean }) => {
try {
return await getIMGatewayManager().startFeishuInstallQrcode(isLark);
} catch (error) {
throw new Error(error instanceof Error ? error.message : '获取二维码失败');
}
});
ipcMain.handle('feishu:install:poll', async (_event, { deviceCode }: { deviceCode: string }) => {
try {
return await getIMGatewayManager().pollFeishuInstall(deviceCode);
} catch (error) {
return { done: false, error: error instanceof Error ? error.message : '轮询失败' };
}
});
ipcMain.handle('feishu:install:verify', async (_event, { appId, appSecret }: { appId: string; appSecret: string }) => {
try {
return await getIMGatewayManager().verifyFeishuCredentials(appId, appSecret);
} catch (error) {
return { success: false, error: error instanceof Error ? error.message : '验证失败' };
}
});
ipcMain.handle('generate-session-title', async (_event, userInput: string | null) => {
return generateSessionTitle(userInput);
});
+24
View File
@@ -360,4 +360,28 @@ contextBridge.exposeInMainWorld('electron', {
networkStatus: {
send: (status: 'online' | 'offline') => ipcRenderer.send('network:status-change', status),
},
feishu: {
install: {
qrcode: (isLark: boolean) =>
ipcRenderer.invoke('feishu:install:qrcode', { isLark }) as Promise<{
url: string;
deviceCode: string;
interval: number;
expireIn: number;
}>,
poll: (deviceCode: string) =>
ipcRenderer.invoke('feishu:install:poll', { deviceCode }) as Promise<{
done: boolean;
appId?: string;
appSecret?: string;
domain?: string;
error?: string;
}>,
verify: (appId: string, appSecret: string) =>
ipcRenderer.invoke('feishu:install:verify', { appId, appSecret }) as Promise<{
success: boolean;
error?: string;
}>,
},
},
});
+1 -1
View File
@@ -2940,7 +2940,7 @@ const Settings: React.FC<SettingsProps> = ({ onClose, initialTab, notice, onUpda
return (
<div
className="fixed inset-0 z-50 modal-backdrop flex items-center justify-center"
onClick={onClose}
onClick={(e) => { if (e.target === e.currentTarget) onClose(); }}
>
<div
className="relative flex w-[900px] h-[80vh] rounded-2xl dark:border-claude-darkBorder border-claude-border border shadow-modal overflow-hidden modal-content"
+147
View File
@@ -14,6 +14,8 @@ import { i18nService } from '../../services/i18n';
import type { IMPlatform, IMConnectivityCheck, IMConnectivityTestResult, IMGatewayConfig, TelegramOpenClawConfig, DiscordOpenClawConfig, FeishuOpenClawConfig, DingTalkOpenClawConfig, QQOpenClawConfig, WecomOpenClawConfig, PopoOpenClawConfig } from '../../types/im';
import { getVisibleIMPlatforms } from '../../utils/regionFilter';
import WecomAIBotSDK from '@wecom/wecom-aibot-sdk';
import { QRCodeSVG } from 'qrcode.react';
import { ArrowPathIcon } from '@heroicons/react/24/outline';
import { SchemaForm } from './SchemaForm';
import type { UiHint } from './SchemaForm';
@@ -158,6 +160,82 @@ const IMSettings: React.FC = () => {
}).catch(() => {});
}, []);
// Cleanup feishu QR timers on unmount
useEffect(() => {
return () => {
if (feishuQrPollTimerRef.current) clearInterval(feishuQrPollTimerRef.current);
if (feishuQrCountdownTimerRef.current) clearInterval(feishuQrCountdownTimerRef.current);
};
}, []);
// Reset feishu QR state when switching away from feishu
useEffect(() => {
if (activePlatform !== 'feishu') {
if (feishuQrPollTimerRef.current) { clearInterval(feishuQrPollTimerRef.current); feishuQrPollTimerRef.current = null; }
if (feishuQrCountdownTimerRef.current) { clearInterval(feishuQrCountdownTimerRef.current); feishuQrCountdownTimerRef.current = null; }
setFeishuQrStatus('idle');
setFeishuQrUrl('');
setFeishuQrError('');
}
}, [activePlatform]);
const handleFeishuStartQr = async () => {
if (feishuQrPollTimerRef.current) clearInterval(feishuQrPollTimerRef.current);
if (feishuQrCountdownTimerRef.current) clearInterval(feishuQrCountdownTimerRef.current);
setFeishuQrStatus('loading');
setFeishuQrError('');
try {
const result = await window.electron.feishu.install.qrcode(false);
if (!isMountedRef.current) return;
setFeishuQrUrl(result.url);
feishuQrDeviceCodeRef.current = result.deviceCode;
const expireIn = result.expireIn ?? 300;
setFeishuQrTimeLeft(expireIn);
setFeishuQrStatus('showing');
// Countdown
feishuQrCountdownTimerRef.current = setInterval(() => {
setFeishuQrTimeLeft((prev) => {
if (prev <= 1) {
clearInterval(feishuQrCountdownTimerRef.current!);
feishuQrCountdownTimerRef.current = null;
if (feishuQrPollTimerRef.current) { clearInterval(feishuQrPollTimerRef.current); feishuQrPollTimerRef.current = null; }
setFeishuQrStatus('error');
setFeishuQrError(i18nService.t('feishuBotCreateWizardQrcodeExpired'));
return 0;
}
return prev - 1;
});
}, 1000);
// Poll
const intervalMs = Math.max(result.interval ?? 5, 3) * 1000;
feishuQrPollTimerRef.current = setInterval(async () => {
try {
const pollResult = await window.electron.feishu.install.poll(feishuQrDeviceCodeRef.current);
if (!isMountedRef.current) return;
if (pollResult.done && pollResult.appId && pollResult.appSecret) {
clearInterval(feishuQrPollTimerRef.current!); feishuQrPollTimerRef.current = null;
clearInterval(feishuQrCountdownTimerRef.current!); feishuQrCountdownTimerRef.current = null;
dispatch(setFeishuConfig({ appId: pollResult.appId, appSecret: pollResult.appSecret, enabled: true }));
await imService.updateConfig({ feishu: { ...config.feishu, appId: pollResult.appId, appSecret: pollResult.appSecret, enabled: true } });
if (!isMountedRef.current) return; // re-check after async updateConfig
setFeishuQrStatus('success');
} else if (pollResult.error && pollResult.error !== 'authorization_pending' && pollResult.error !== 'slow_down') {
clearInterval(feishuQrPollTimerRef.current!); feishuQrPollTimerRef.current = null;
clearInterval(feishuQrCountdownTimerRef.current!); feishuQrCountdownTimerRef.current = null;
setFeishuQrStatus('error');
setFeishuQrError(pollResult.error);
}
} catch { /* keep retrying */ }
}, intervalMs);
} catch (err: any) {
if (!isMountedRef.current) return;
setFeishuQrStatus('error');
setFeishuQrError(err?.message || '获取二维码失败');
}
};
// Reset wecom quick setup state when switching away from wecom
useEffect(() => {
if (activePlatform !== 'wecom') {
@@ -245,6 +323,15 @@ const IMSettings: React.FC = () => {
// State for Feishu allow-from inputs
const [feishuAllowedUserIdInput, setFeishuAllowedUserIdInput] = useState('');
const [feishuGroupAllowIdInput, setFeishuGroupAllowIdInput] = useState('');
// Inline QR code state for feishu bot creation (mirroring WeCom quick-setup pattern)
const [feishuQrStatus, setFeishuQrStatus] = useState<'idle' | 'loading' | 'showing' | 'success' | 'error'>('idle');
const [feishuQrUrl, setFeishuQrUrl] = useState<string>('');
const [feishuQrTimeLeft, setFeishuQrTimeLeft] = useState<number>(0);
const [feishuQrError, setFeishuQrError] = useState<string>('');
// These don't need to be state — they don't affect rendering directly
const feishuQrDeviceCodeRef = useRef<string>('');
const feishuQrPollTimerRef = useRef<ReturnType<typeof setInterval> | null>(null);
const feishuQrCountdownTimerRef = useRef<ReturnType<typeof setInterval> | null>(null);
// Pairing state for OpenClaw platforms
const [pairingCodeInput, setPairingCodeInput] = useState<Record<string, string>>({});
@@ -1288,6 +1375,65 @@ const IMSettings: React.FC = () => {
{/* Feishu Settings */}
{activePlatform === 'feishu' && (
<div className="space-y-3">
{/* Scan QR code section */}
<div className="rounded-lg border border-dashed dark:border-claude-darkBorder/60 border-claude-border/60 p-4 text-center space-y-3">
{(feishuQrStatus === 'idle' || feishuQrStatus === 'error') && (
<>
<button
type="button"
onClick={() => void handleFeishuStartQr()}
className="px-4 py-2.5 rounded-lg text-sm font-medium bg-claude-accent text-white hover:bg-claude-accent/90 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
>
{i18nService.t('feishuBotCreateWizardScanBtn')}
</button>
<p className="text-xs text-claude-textSecondary dark:text-claude-darkTextSecondary">
{i18nService.t('feishuBotCreateWizardScanHint')}
</p>
{feishuQrStatus === 'error' && feishuQrError && (
<div className="flex items-center justify-center gap-1.5 text-xs text-red-500 bg-red-500/10 px-3 py-2 rounded-lg">
<XCircleIcon className="h-4 w-4 flex-shrink-0" />
{feishuQrError}
</div>
)}
</>
)}
{feishuQrStatus === 'loading' && (
<div className="flex flex-col items-center gap-2 py-2">
<ArrowPathIcon className="h-7 w-7 text-claude-accent animate-spin" />
<span className="text-xs text-claude-textSecondary dark:text-claude-darkTextSecondary">正在生成二维码…</span>
</div>
)}
{feishuQrStatus === 'showing' && feishuQrUrl && (
<div className="flex flex-col items-center gap-2">
<div className="p-2 bg-white rounded-lg inline-block">
<QRCodeSVG value={feishuQrUrl} size={160} />
</div>
<p className="text-xs text-claude-textSecondary dark:text-claude-darkTextSecondary max-w-[240px]">
{i18nService.t('feishuBotCreateWizardQrcodeDesc')}
</p>
<p className="text-xs text-claude-textSecondary dark:text-claude-darkTextSecondary">
{feishuQrTimeLeft}s
</p>
</div>
)}
{feishuQrStatus === 'success' && (
<div className="flex items-center justify-center gap-1.5 text-xs text-green-600 dark:text-green-400 bg-green-500/10 px-3 py-2 rounded-lg">
<CheckCircleIcon className="h-4 w-4 flex-shrink-0" />
{i18nService.t('feishuBotCreateWizardSuccessTitle')}
</div>
)}
</div>
{/* Divider */}
<div className="relative flex items-center">
<div className="flex-1 border-t dark:border-claude-darkBorder/40 border-claude-border/40" />
<span className="px-3 text-xs text-claude-textSecondary dark:text-claude-darkTextSecondary whitespace-nowrap">
{i18nService.t('feishuBotCreateWizardOrManual')}
</span>
<div className="flex-1 border-t dark:border-claude-darkBorder/40 border-claude-border/40" />
</div>
{/* Manual guide */}
<PlatformGuide
steps={[
i18nService.t('imFeishuGuideStep1'),
@@ -1626,6 +1772,7 @@ const IMSettings: React.FC = () => {
{status.feishu.error}
</div>
)}
</div>
)}
+34
View File
@@ -737,6 +737,23 @@ const translations: Record<LanguageType, Record<string, string>> = {
imDingtalkGuideStep4: '保存后机器人将自动通过 Stream 模式建立长连接',
imFeishuGuideStep1: '在下方填写飞书机器人的 App ID 和 App Secret 即可完成配置',
imFeishuGuideStep2: '可在飞书开放平台查看应用凭证,详情参考配置手册。',
feishuBotCreateWizardTitle: '创建机器人',
feishuBotCreateWizardScanBtn: '扫码创建机器人',
feishuBotCreateWizardScanHint: '使用飞书客户端扫描二维码,一键创建并配置机器人',
feishuBotCreateWizardOrManual: '或 手动填写、修改已有机器人信息',
feishuBotCreateWizardOptionNew: '新建机器人',
feishuBotCreateWizardOptionExisting: '关联已有机器人',
feishuBotCreateWizardQrcodeDesc: '使用飞书客户端扫描二维码,选择「一键创建飞书机器人」完成配置。',
feishuBotCreateWizardQrcodeExpired: '二维码已过期,请点击刷新',
feishuBotCreateWizardQrcodeRefresh: '刷新二维码',
feishuBotCreateWizardVerify: '验证',
feishuBotCreateWizardVerifying: '验证中…',
feishuBotCreateWizardSuccessTitle: '机器人配置成功',
feishuBotCreateWizardSuccessDesc: '飞书机器人已成功配置,可以开始使用了。',
feishuBotCreateWizardErrorRetry: '重试',
feishuBotCreateWizardDone: '完成',
feishuBotCreateWizardAppIdPlaceholder: '输入 App ID',
feishuBotCreateWizardAppSecretPlaceholder: '输入 App Secret',
imWecomGuideStep1: '在企业微信管理后台创建一个新的机器人应用',
imWecomGuideStep2: '获取对应的 BotID 与 Secret 凭证',
imWecomGuideStep3: '将 BotID 和 Secret 填入下方表单',
@@ -1680,6 +1697,23 @@ const translations: Record<LanguageType, Record<string, string>> = {
imDingtalkGuideStep4: 'Once saved, the bot will auto-connect via Stream mode',
imFeishuGuideStep1: 'Enter the Feishu bot App ID and App Secret below to complete setup',
imFeishuGuideStep2: 'App credentials are available on the Feishu Open Platform. See the setup manual for details.',
feishuBotCreateWizardTitle: 'Create Bot',
feishuBotCreateWizardScanBtn: 'Scan QR Code to Create Bot',
feishuBotCreateWizardScanHint: 'Scan the QR code with your Feishu app to create and configure a bot in one step',
feishuBotCreateWizardOrManual: 'or manually enter / edit bot credentials',
feishuBotCreateWizardOptionNew: 'Create a new bot',
feishuBotCreateWizardOptionExisting: 'Use an existing bot',
feishuBotCreateWizardQrcodeDesc: 'Scan the QR code with your Feishu app and select "One-click create Feishu bot" to complete setup.',
feishuBotCreateWizardQrcodeExpired: 'QR code expired, please refresh',
feishuBotCreateWizardQrcodeRefresh: 'Refresh QR Code',
feishuBotCreateWizardVerify: 'Verify',
feishuBotCreateWizardVerifying: 'Verifying...',
feishuBotCreateWizardSuccessTitle: 'Bot configured successfully',
feishuBotCreateWizardSuccessDesc: 'Your Feishu bot has been configured and is ready to use.',
feishuBotCreateWizardErrorRetry: 'Retry',
feishuBotCreateWizardDone: 'Done',
feishuBotCreateWizardAppIdPlaceholder: 'Enter App ID',
feishuBotCreateWizardAppSecretPlaceholder: 'Enter App Secret',
imWecomGuideStep1: 'Create a new bot application in the WeCom Admin Console',
imWecomGuideStep2: 'Copy the BotID and Secret credentials',
imWecomGuideStep3: 'Enter BotID and Secret in the form below',
+21
View File
@@ -433,6 +433,27 @@ interface IElectronAPI {
networkStatus: {
send: (status: 'online' | 'offline') => void;
};
feishu: {
install: {
qrcode: (isLark: boolean) => Promise<{
url: string;
deviceCode: string;
interval: number;
expireIn: number;
}>;
poll: (deviceCode: string) => Promise<{
done: boolean;
appId?: string;
appSecret?: string;
domain?: string;
error?: string;
}>;
verify: (appId: string, appSecret: string) => Promise<{
success: boolean;
error?: string;
}>;
};
};
}
// IM Gateway types
+7 -2
View File
@@ -26,7 +26,12 @@ export default defineConfig({
outDir: 'dist-electron',
minify: false,
rollupOptions: {
external: ['sql.js', 'discord.js', 'zlib-sync', '@discordjs/opus', 'bufferutil', 'utf-8-validate', 'node-nim', 'nim-web-sdk-ng'],
external: (id) => {
const staticExternals = ['sql.js', 'discord.js', 'zlib-sync', '@discordjs/opus', 'bufferutil', 'utf-8-validate', 'node-nim', 'nim-web-sdk-ng'];
if (staticExternals.includes(id)) return true;
if (id.startsWith('@larksuite/openclaw-lark-tools') || id.startsWith('@larksuite/openclaw-lark')) return true;
return false;
},
output: {
// Keep CJS format (default), but load via ESM loader.mjs
inlineDynamicImports: true,
@@ -78,7 +83,7 @@ export default defineConfig({
},
},
optimizeDeps: {
exclude: ['electron'],
exclude: ['electron', '@larksuite/openclaw-lark-tools', '@larksuite/openclaw-lark'],
esbuildOptions: {
define: {
__VERSION__: JSON.stringify(katexVersion),