import * as React from 'react' import { Box, Text, useInput } from '../../ink.js' import { Dialog } from '../../components/design-system/Dialog.js' import { ListItem } from '../../components/design-system/ListItem.js' import type { LocalJSXCommandContext } from '../../commands.js' import type { LocalJSXCommandOnDone } from '../../types/command.js' import { feishuService } from '../../services/feishu/FeishuService.js' import { clearFeishuConfig, getFeishuConfig, maskFeishuAppId, maskFeishuAppSecret, saveFeishuConfig, } from '../../services/feishu/feishuConfig.js' import QRCode from 'qrcode' type Step = | { type: 'menu' } | { type: 'edit-app-id' } | { type: 'edit-app-secret' } | { type: 'edit-encrypt-key' } | { type: 'edit-verification-token' } | { type: 'edit-allowed-users' } | { type: 'edit-admins' } | { type: 'edit-allowed-chats' } | { type: 'edit-mention-policy' } | { type: 'edit-tts-settings' } | { type: 'edit-tts-provider' } | { type: 'edit-tts-ref-audio' } | { type: 'confirm-clear' } | { type: 'scanning' } type Notice = { text: string tone: 'success' | 'error' | 'info' } function TextInput({ title, hint, initialValue, masked = false, onSubmit, onCancel, }: { title: string hint: string initialValue?: string masked?: boolean onSubmit: (value: string) => void onCancel: () => void }) { const [value, setValue] = React.useState(initialValue ?? '') const [cursorOffset, setCursorOffset] = React.useState( (initialValue ?? '').length, ) useInput((input, key) => { if (key.escape) { onCancel() return } if (key.return) { onSubmit(value.trim()) return } if (key.leftArrow) { setCursorOffset(current => Math.max(0, current - 1)) return } if (key.rightArrow) { setCursorOffset(current => Math.min(value.length, current + 1)) return } if (key.backspace || key.delete) { if (cursorOffset === 0) return const nextValue = value.slice(0, cursorOffset - 1) + value.slice(cursorOffset) setValue(nextValue) setCursorOffset(current => Math.max(0, current - 1)) return } if (input) { const nextValue = value.slice(0, cursorOffset) + input + value.slice(cursorOffset) setValue(nextValue) setCursorOffset(current => current + input.length) } }) const displayValue = masked ? '*'.repeat(value.length) : value const cursorChar = cursorOffset < displayValue.length ? displayValue[cursorOffset] : ' ' return ( {title} {hint} Value: {displayValue.slice(0, cursorOffset)} {cursorChar} {displayValue.slice(cursorOffset + 1)} [Enter] 保存 · [Esc] 返回 ) } function ConfirmClear({ onConfirm, onCancel, }: { onConfirm: () => void onCancel: () => void }) { useInput((_, key) => { if (key.escape) { onCancel() return } if (key.return) { onConfirm() } }) return ( 这会清空飞书 App ID / App Secret 以及所有白名单配置。 如果当前 bot 正在运行,会一并停止。按 Enter 确认,Esc 返回。 ) } function FeishuDialog({ onDone, }: { onDone: LocalJSXCommandOnDone }) { const serviceState = React.useSyncExternalStore( feishuService.subscribe, feishuService.getStateSnapshot, ) const [config, setConfig] = React.useState(() => getFeishuConfig()) const [step, setStep] = React.useState({ type: 'menu' }) const [notice, setNotice] = React.useState(null) const [focusIndex, setFocusIndex] = React.useState(0) const [busy, setBusy] = React.useState(false) const refreshConfig = React.useCallback(() => { setConfig(getFeishuConfig()) }, []) const menuItems = React.useMemo( () => [ { label: '设置 App ID', action: () => setStep({ type: 'edit-app-id' }), }, { label: '设置 App Secret', action: () => setStep({ type: 'edit-app-secret' }), }, { label: '设置加密密钥(可选)', action: () => setStep({ type: 'edit-encrypt-key' }), }, { label: '设置验证 Token(可选)', action: () => setStep({ type: 'edit-verification-token' }), }, { label: '设置允许访问的用户', action: () => setStep({ type: 'edit-allowed-users' }), }, { label: '设置管理员', action: () => setStep({ type: 'edit-admins' }), }, { label: '设置允许响应的群', action: () => setStep({ type: 'edit-allowed-chats' }), }, { label: `@提及要求: ${config?.requireMentionInGroup !== false ? '需 @bot' : '所有消息'}`, action: () => setStep({ type: 'edit-mention-policy' }), }, { label: `TTS 语音回复: ${config?.ttsEnabled ? '开' : '关'}`, action: () => { void toggleTts() }, }, { label: `TTS 引擎: ${config?.ttsProvider === 'voxcpm' ? 'VoxCPM' : 'Edge TTS'}`, action: () => { void toggleTtsProvider() }, }, { label: `TTS 语音: ${config?.ttsVoice || 'zh-CN-XiaoxiaoNeural (中文)'}`, action: () => setStep({ type: 'edit-tts-settings' }), }, { label: `TTS 参考音频: ${config?.ttsReferenceAudio ? '已配置' : '未配置'}`, action: () => setStep({ type: 'edit-tts-ref-audio' }), }, { label: serviceState.status === 'running' ? '停止当前飞书 Bot' : '启动当前飞书 Bot', action: async () => { setBusy(true) try { if (serviceState.status === 'running') { await feishuService.stop() setNotice({ text: '飞书 Bot 已停止。', tone: 'success' }) } else { await feishuService.startFromSavedConfig() setNotice({ text: '飞书 Bot 已启动,请在飞书中私聊机器人验证连接。', tone: 'success', }) } } catch (error) { setNotice({ text: error instanceof Error ? error.message : '启动飞书 Bot 失败', tone: 'error', }) } finally { setBusy(false) } }, }, { label: '清空飞书配置', action: () => setStep({ type: 'confirm-clear' }), }, { label: '扫码登录(创建应用)', action: () => setStep({ type: 'scanning' }), }, { label: '关闭', action: () => onDone(undefined, { display: 'skip' }), }, ], [onDone, serviceState.status], ) React.useEffect(() => { setFocusIndex(current => Math.min(current, menuItems.length - 1)) }, [menuItems.length]) const saveAppId = React.useCallback( async (value: string) => { if (!value) { setNotice({ text: 'App ID 不能为空。', tone: 'error' }) setStep({ type: 'menu' }) return } setBusy(true) try { const current = getFeishuConfig() saveFeishuConfig({ ...current, appId: value }) refreshConfig() if (serviceState.status === 'running') { await feishuService.stop() await feishuService.startFromSavedConfig() } setNotice({ text: 'App ID 已保存。', tone: 'success' }) } catch (error) { setNotice({ text: error instanceof Error ? error.message : '保存 App ID 失败', tone: 'error', }) } finally { setBusy(false) setStep({ type: 'menu' }) } }, [refreshConfig, serviceState.status], ) const saveAppSecret = React.useCallback( async (value: string) => { if (!value) { setNotice({ text: 'App Secret 不能为空。', tone: 'error' }) setStep({ type: 'menu' }) return } setBusy(true) try { const current = getFeishuConfig() saveFeishuConfig({ ...current, appSecret: value }) refreshConfig() if (serviceState.status === 'running') { await feishuService.stop() await feishuService.startFromSavedConfig() } setNotice({ text: 'App Secret 已保存。', tone: 'success' }) } catch (error) { setNotice({ text: error instanceof Error ? error.message : '保存 App Secret 失败', tone: 'error', }) } finally { setBusy(false) setStep({ type: 'menu' }) } }, [refreshConfig, serviceState.status], ) const saveEncryptKey = React.useCallback( async (value: string) => { setBusy(true) try { const current = getFeishuConfig() saveFeishuConfig({ ...current, encryptKey: value || undefined }) refreshConfig() if (serviceState.status === 'running') { await feishuService.stop() await feishuService.startFromSavedConfig() } setNotice({ text: '加密密钥已保存。', tone: 'success' }) } catch (error) { setNotice({ text: error instanceof Error ? error.message : '保存失败', tone: 'error', }) } finally { setBusy(false) setStep({ type: 'menu' }) } }, [refreshConfig, serviceState.status], ) const saveVerificationToken = React.useCallback( async (value: string) => { setBusy(true) try { const current = getFeishuConfig() saveFeishuConfig({ ...current, verificationToken: value || undefined }) refreshConfig() if (serviceState.status === 'running') { await feishuService.stop() await feishuService.startFromSavedConfig() } setNotice({ text: '验证 Token 已保存。', tone: 'success' }) } catch (error) { setNotice({ text: error instanceof Error ? error.message : '保存失败', tone: 'error', }) } finally { setBusy(false) setStep({ type: 'menu' }) } }, [refreshConfig, serviceState.status], ) const saveAllowedUsers = React.useCallback( async (value: string) => { const users = value.split(/[,\s]+/).map(s => s.trim()).filter(Boolean) setBusy(true) try { const current = getFeishuConfig() saveFeishuConfig({ ...current, allowedUsers: users.length > 0 ? users : undefined }) refreshConfig() setNotice({ text: '允许用户列表已保存。', tone: 'success' }) } catch (error) { setNotice({ text: error instanceof Error ? error.message : '保存失败', tone: 'error', }) } finally { setBusy(false) setStep({ type: 'menu' }) } }, [refreshConfig], ) const saveAdmins = React.useCallback( async (value: string) => { const users = value.split(/[,\s]+/).map(s => s.trim()).filter(Boolean) setBusy(true) try { const current = getFeishuConfig() saveFeishuConfig({ ...current, admins: users.length > 0 ? users : undefined }) refreshConfig() setNotice({ text: '管理员列表已保存。', tone: 'success' }) } catch (error) { setNotice({ text: error instanceof Error ? error.message : '保存失败', tone: 'error', }) } finally { setBusy(false) setStep({ type: 'menu' }) } }, [refreshConfig], ) const saveAllowedChats = React.useCallback( async (value: string) => { const chats = value.split(/[,\s]+/).map(s => s.trim()).filter(Boolean) setBusy(true) try { const current = getFeishuConfig() saveFeishuConfig({ ...current, allowedChats: chats.length > 0 ? chats : undefined }) refreshConfig() setNotice({ text: '允许响应的群列表已保存。', tone: 'success' }) } catch (error) { setNotice({ text: error instanceof Error ? error.message : '保存失败', tone: 'error', }) } finally { setBusy(false) setStep({ type: 'menu' }) } }, [refreshConfig], ) const toggleMentionPolicy = React.useCallback( async () => { setBusy(true) try { const current = getFeishuConfig() const next = current.requireMentionInGroup !== false ? false : true saveFeishuConfig({ ...current, requireMentionInGroup: next }) refreshConfig() setNotice({ text: next ? '群聊已设为需 @bot 才响应。' : '群聊已设为响应所有消息。', tone: 'success', }) } catch (error) { setNotice({ text: error instanceof Error ? error.message : '保存失败', tone: 'error', }) } finally { setBusy(false) setStep({ type: 'menu' }) } }, [refreshConfig], ) const toggleTts = React.useCallback( async () => { setBusy(true) try { const current = getFeishuConfig() const next = !current.ttsEnabled saveFeishuConfig({ ...current, ttsEnabled: next }) refreshConfig() setNotice({ text: next ? 'TTS 语音回复已开启。' : 'TTS 语音回复已关闭。', tone: 'success', }) } catch (error) { setNotice({ text: error instanceof Error ? error.message : '保存失败', tone: 'error', }) } finally { setBusy(false) setStep({ type: 'menu' }) } }, [refreshConfig], ) const saveTtsVoice = React.useCallback( async (value: string) => { setBusy(true) try { const current = getFeishuConfig() saveFeishuConfig({ ...current, ttsVoice: value || undefined }) refreshConfig() setNotice({ text: 'TTS 语音已保存。', tone: 'success' }) } catch (error) { setNotice({ text: error instanceof Error ? error.message : '保存失败', tone: 'error', }) } finally { setBusy(false) setStep({ type: 'menu' }) } }, [refreshConfig], ) const toggleTtsProvider = React.useCallback( async () => { setBusy(true) try { const current = getFeishuConfig() const next = current.ttsProvider === 'edge' ? 'voxcpm' : 'edge' saveFeishuConfig({ ...current, ttsProvider: next }) refreshConfig() setNotice({ text: `TTS 引擎已切换为 ${next === 'edge' ? 'Edge TTS' : 'VoxCPM'}。`, tone: 'success', }) } catch (error) { setNotice({ text: error instanceof Error ? error.message : '保存失败', tone: 'error', }) } finally { setBusy(false) setStep({ type: 'menu' }) } }, [refreshConfig], ) const saveTtsRefAudio = React.useCallback( async (value: string) => { setBusy(true) try { const current = getFeishuConfig() saveFeishuConfig({ ...current, ttsReferenceAudio: value || undefined }) refreshConfig() setNotice({ text: '参考音频路径已保存。', tone: 'success' }) } catch (error) { setNotice({ text: error instanceof Error ? error.message : '保存失败', tone: 'error', }) } finally { setBusy(false) setStep({ type: 'menu' }) } }, [refreshConfig], ) const clearConfigAndStop = React.useCallback(async () => { setBusy(true) try { await feishuService.stop() clearFeishuConfig() refreshConfig() setNotice({ text: '飞书配置已清空。', tone: 'success' }) } catch (error) { setNotice({ text: error instanceof Error ? error.message : '清空配置失败', tone: 'error', }) } finally { setBusy(false) setStep({ type: 'menu' }) } }, [refreshConfig]) useInput((_, key) => { if (step.type === 'edit-mention-policy') { if (key.escape) { setStep({ type: 'menu' }) return } if (key.return) { void toggleMentionPolicy() return } return } if (step.type !== 'menu' || busy) return if (key.escape) { onDone(undefined, { display: 'skip' }) return } if (key.upArrow) { setFocusIndex(current => (current - 1 + menuItems.length) % menuItems.length) return } if (key.downArrow) { setFocusIndex(current => (current + 1) % menuItems.length) return } if (key.return) { void menuItems[focusIndex]?.action() } }) const noticeColor = notice?.tone === 'error' ? 'red' : notice?.tone === 'success' ? 'green' : 'cyan' // Step rendering if (step.type === 'edit-app-id') { return ( setStep({ type: 'menu' })}> { void saveAppId(value) }} onCancel={() => setStep({ type: 'menu' })} /> ) } if (step.type === 'edit-app-secret') { return ( setStep({ type: 'menu' })}> { void saveAppSecret(value) }} onCancel={() => setStep({ type: 'menu' })} /> ) } if (step.type === 'edit-encrypt-key') { return ( setStep({ type: 'menu' })}> { void saveEncryptKey(value) }} onCancel={() => setStep({ type: 'menu' })} /> ) } if (step.type === 'edit-verification-token') { return ( setStep({ type: 'menu' })}> { void saveVerificationToken(value) }} onCancel={() => setStep({ type: 'menu' })} /> ) } if (step.type === 'edit-allowed-users') { return ( setStep({ type: 'menu' })}> { void saveAllowedUsers(value) }} onCancel={() => setStep({ type: 'menu' })} /> ) } if (step.type === 'edit-admins') { return ( setStep({ type: 'menu' })}> { void saveAdmins(value) }} onCancel={() => setStep({ type: 'menu' })} /> ) } if (step.type === 'edit-allowed-chats') { return ( setStep({ type: 'menu' })}> { void saveAllowedChats(value) }} onCancel={() => setStep({ type: 'menu' })} /> ) } if (step.type === 'edit-mention-policy') { return ( setStep({ type: 'menu' })}> 群聊 @bot 要求 当前: {config?.requireMentionInGroup !== false ? '需 @bot 才响应' : '响应所有群消息'} 切换后: {'\n'}- 需 @bot: 仅在群聊中被 @ 时才处理消息 {'\n'}- 响应所有: 群聊中所有消息都会处理 按 Enter 切换,按 Esc 取消 ) } if (step.type === 'edit-tts-settings') { return ( setStep({ type: 'menu' })}> { void saveTtsVoice(value) }} onCancel={() => setStep({ type: 'menu' })} /> ) } if (step.type === 'edit-tts-ref-audio') { return ( setStep({ type: 'menu' })}> { void saveTtsRefAudio(value) }} onCancel={() => setStep({ type: 'menu' })} /> ) } if (step.type === 'confirm-clear') { return ( setStep({ type: 'menu' })}> { void clearConfigAndStop() }} onCancel={() => setStep({ type: 'menu' })} /> ) } if (step.type === 'scanning') { return setStep({ type: 'menu' })} onSuccess={() => { refreshConfig() setStep({ type: 'menu' }) }} /> } return ( onDone(undefined, { display: 'skip' })}> 启动飞书长连接机器人,并把私聊消息接入当前会话。 状态: {' '} {serviceState.status === 'running' ? '运行中' : serviceState.status === 'starting' ? '启动中' : '未启动'} App ID: {maskFeishuAppId(config?.appId)} App Secret: {maskFeishuAppSecret(config?.appSecret)} 加密密钥: {config?.encryptKey ? '已配置' : '未配置'} 验证 Token: {config?.verificationToken ? '已配置' : '未配置'} 允许用户: {' '} {config?.allowedUsers?.length ? config.allowedUsers.join(', ') : '未配置(使用配对码授权)'} 管理员: {' '} {config?.admins?.length ? config.admins.join(', ') : '未配置'} 允许群聊: {' '} {config?.allowedChats?.length ? `${config.allowedChats.length} 个` : '未配置'} 群聊 @提及: {config?.requireMentionInGroup !== false ? '需 @bot' : '所有消息'} {config?.pairedUsers?.length ? ( 已配对用户: {config.pairedUsers.map(u => u.displayName || u.userId).join(', ')} ) : null} {serviceState.lastError ? ( 最近错误: {serviceState.lastError} ) : null} {notice ? {notice.text} : null} {busy ? 处理中,请稍候... : null} {menuItems.map((item, index) => ( {item.label} ))} 上下箭头选择,Enter 执行,Esc 关闭 ) } function ScanningDialog({ onCancel, onSuccess, }: { onCancel: () => void onSuccess: () => void }) { const [qrUrl, setQrUrl] = React.useState(null) const [expireIn, setExpireIn] = React.useState(0) const [status, setStatus] = React.useState('正在连接飞书...') const [error, setError] = React.useState(null) const [success, setSuccess] = React.useState(false) const [completed, setCompleted] = React.useState(false) // Refs to avoid re-triggering the effect when parent passes inline callbacks const onSuccessRef = React.useRef(onSuccess) onSuccessRef.current = onSuccess const onCancelRef = React.useRef(onCancel) onCancelRef.current = onCancel React.useEffect(() => { let cancelled = false const run = async () => { try { const { registerApp } = await import('@larksuite/channel') const result = await registerApp({ source: 'codev', onQRCodeReady: (info) => { if (cancelled) return setQrUrl(info.url) setExpireIn(info.expireIn) setStatus('请用飞书 App 扫描二维码,并在打开的页面中点击"授权"完成创建') }, onStatusChange: (info) => { if (cancelled) return if (info.status === 'domain_switched') { setStatus('已切换到国际版 (larksuite.com),请继续...') } else if (info.status === 'slow_down') { setStatus('等待授权确认中...') } else if (info.status === 'polling') { setStatus('等待授权确认中,请确保已在飞书中点击了"授权"...') } }, }) if (cancelled) return // Save credentials saveFeishuConfig({ appId: result.client_id, appSecret: result.client_secret, tenant: (result as { user_info?: { tenant_brand?: string } }).user_info?.tenant_brand, }) setSuccess(true) setStatus('应用创建成功!正在启动 Bot...') setCompleted(true) // Start the bot await feishuService.stop() await feishuService.startFromSavedConfig() if (!cancelled) { setTimeout(() => onSuccessRef.current(), 1500) } } catch (err) { if (!cancelled) { setError(err instanceof Error ? err.message : '扫码登录失败') } } } run() return () => { cancelled = true } }, []) // Run once on mount only — refs avoid dep instability useInput((_, key) => { if (key.escape && !completed) { onCancelRef.current() } }) const [qrAscii, setQrAscii] = React.useState('') const [qrError, setQrError] = React.useState(null) React.useEffect(() => { if (!qrUrl) return QRCode.toString(qrUrl, { type: 'terminal', small: true }) .then(ascii => { setQrAscii(ascii) setQrError(null) }) .catch(err => { console.error('[Feishu] QR generation error:', err) setQrError('无法生成二维码,请使用下方链接') }) }, [qrUrl]) return ( {error ? ( <> 错误: {error} 按 Esc 返回 ) : success ? ( <> ✓ {status} ) : qrError ? ( <> 二维码生成失败 请使用下方链接完成授权: {qrUrl} {status} 按 Esc 取消 ) : qrAscii ? ( <> 请用飞书 App 扫描下方二维码 扫描后,请在打开的页面中点击【授权】完成应用创建 {qrAscii} 二维码有效期:约 {Math.max(1, Math.round(expireIn / 60))} 分钟 {qrUrl && ( 或打开链接:{qrUrl.slice(0, 70)}... )} {status} 按 Esc 取消 ) : ( <> {status} 请稍候... )} ) } export async function call( onDone: LocalJSXCommandOnDone, _context: LocalJSXCommandContext, ): Promise { return } export default call