| 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 ( |
| <Box flexDirection="column" gap={1}> |
| <Text bold>{title}</Text> |
| <Text dimColor>{hint}</Text> |
| <Box flexDirection="column" marginTop={1}> |
| <Text dimColor>Value:</Text> |
| <Box> |
| <Text>{displayValue.slice(0, cursorOffset)}</Text> |
| <Text backgroundColor="white" color="black"> |
| {cursorChar} |
| </Text> |
| <Text>{displayValue.slice(cursorOffset + 1)}</Text> |
| </Box> |
| </Box> |
| <Text dimColor>[Enter] 保存 · [Esc] 返回</Text> |
| </Box> |
| ) |
| } |
|
|
| function ConfirmClear({ |
| onConfirm, |
| onCancel, |
| }: { |
| onConfirm: () => void |
| onCancel: () => void |
| }) { |
| useInput((_, key) => { |
| if (key.escape) { |
| onCancel() |
| return |
| } |
|
|
| if (key.return) { |
| onConfirm() |
| } |
| }) |
|
|
| return ( |
| <Box flexDirection="column" gap={1}> |
| <Text color="yellow">这会清空飞书 App ID / App Secret 以及所有白名单配置。</Text> |
| <Text dimColor> |
| 如果当前 bot 正在运行,会一并停止。按 Enter 确认,Esc 返回。 |
| </Text> |
| </Box> |
| ) |
| } |
|
|
| function FeishuDialog({ |
| onDone, |
| }: { |
| onDone: LocalJSXCommandOnDone |
| }) { |
| const serviceState = React.useSyncExternalStore( |
| feishuService.subscribe, |
| feishuService.getStateSnapshot, |
| ) |
| const [config, setConfig] = React.useState(() => getFeishuConfig()) |
| const [step, setStep] = React.useState<Step>({ type: 'menu' }) |
| const [notice, setNotice] = React.useState<Notice | null>(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' |
|
|
| |
| if (step.type === 'edit-app-id') { |
| return ( |
| <Dialog title="飞书 App ID" onCancel={() => setStep({ type: 'menu' })}> |
| <TextInput |
| title="App ID" |
| hint="从飞书开放平台 > 凭证与基础信息 获取。" |
| initialValue={config?.appId} |
| onSubmit={value => { void saveAppId(value) }} |
| onCancel={() => setStep({ type: 'menu' })} |
| /> |
| </Dialog> |
| ) |
| } |
|
|
| if (step.type === 'edit-app-secret') { |
| return ( |
| <Dialog title="飞书 App Secret" onCancel={() => setStep({ type: 'menu' })}> |
| <TextInput |
| title="App Secret" |
| hint="从飞书开放平台 > 凭证与基础信息 获取。" |
| initialValue={config?.appSecret} |
| masked={true} |
| onSubmit={value => { void saveAppSecret(value) }} |
| onCancel={() => setStep({ type: 'menu' })} |
| /> |
| </Dialog> |
| ) |
| } |
|
|
| if (step.type === 'edit-encrypt-key') { |
| return ( |
| <Dialog title="飞书加密密钥" onCancel={() => setStep({ type: 'menu' })}> |
| <TextInput |
| title="加密密钥(可选)" |
| hint="如果启用了「订阅事件请求 URL 加密模式」才需要填写。" |
| initialValue={config?.encryptKey} |
| masked={true} |
| onSubmit={value => { void saveEncryptKey(value) }} |
| onCancel={() => setStep({ type: 'menu' })} |
| /> |
| </Dialog> |
| ) |
| } |
|
|
| if (step.type === 'edit-verification-token') { |
| return ( |
| <Dialog title="飞书验证 Token" onCancel={() => setStep({ type: 'menu' })}> |
| <TextInput |
| title="验证 Token(可选)" |
| hint="如果启用了「订阅事件请求 URL 验证」才需要填写。" |
| initialValue={config?.verificationToken} |
| masked={true} |
| onSubmit={value => { void saveVerificationToken(value) }} |
| onCancel={() => setStep({ type: 'menu' })} |
| /> |
| </Dialog> |
| ) |
| } |
|
|
| if (step.type === 'edit-allowed-users') { |
| return ( |
| <Dialog title="允许访问的用户" onCancel={() => setStep({ type: 'menu' })}> |
| <TextInput |
| title="允许访问的飞书用户 open_id" |
| hint="可输入一个或多个 open_id,支持空格或逗号分隔。配对成功后可发送配对码完成授权。" |
| initialValue={config?.allowedUsers?.join(', ')} |
| onSubmit={value => { void saveAllowedUsers(value) }} |
| onCancel={() => setStep({ type: 'menu' })} |
| /> |
| </Dialog> |
| ) |
| } |
|
|
| if (step.type === 'edit-admins') { |
| return ( |
| <Dialog title="管理员" onCancel={() => setStep({ type: 'menu' })}> |
| <TextInput |
| title="管理员 open_id" |
| hint="管理员拥有全部权限,可执行 /stop、/config 等管理命令。输入 open_id,多个以空格或逗号分隔。" |
| initialValue={config?.admins?.join(', ')} |
| onSubmit={value => { void saveAdmins(value) }} |
| onCancel={() => setStep({ type: 'menu' })} |
| /> |
| </Dialog> |
| ) |
| } |
|
|
| if (step.type === 'edit-allowed-chats') { |
| return ( |
| <Dialog title="允许响应的群聊" onCancel={() => setStep({ type: 'menu' })}> |
| <TextInput |
| title="群聊 chat_id" |
| hint="输入群聊 chat_id,多个以空格或逗号分隔。仅在白名单中的群聊 bot 才会响应。" |
| initialValue={config?.allowedChats?.join(', ')} |
| onSubmit={value => { void saveAllowedChats(value) }} |
| onCancel={() => setStep({ type: 'menu' })} |
| /> |
| </Dialog> |
| ) |
| } |
|
|
| if (step.type === 'edit-mention-policy') { |
| return ( |
| <Dialog title="@提及要求" onCancel={() => setStep({ type: 'menu' })}> |
| <Box flexDirection="column" gap={1}> |
| <Text bold>群聊 @bot 要求</Text> |
| <Text dimColor> |
| 当前: {config?.requireMentionInGroup !== false ? '需 @bot 才响应' : '响应所有群消息'} |
| </Text> |
| <Text dimColor marginTop={1}> |
| 切换后: |
| {'\n'}- 需 @bot: 仅在群聊中被 @ 时才处理消息 |
| {'\n'}- 响应所有: 群聊中所有消息都会处理 |
| </Text> |
| <Box marginTop={1}> |
| <Text>按 Enter 切换,按 Esc 取消</Text> |
| </Box> |
| </Box> |
| </Dialog> |
| ) |
| } |
|
|
| if (step.type === 'edit-tts-settings') { |
| return ( |
| <Dialog title="TTS 语音设置" onCancel={() => setStep({ type: 'menu' })}> |
| <TextInput |
| title="TTS 语音名称" |
| hint="Edge TTS 语音标识符,例如:zh-CN-XiaoxiaoNeural(中文女声)、zh-CN-YunxiNeural(中文男声)、en-US-JennyNeural(英文女声)。留空恢复默认。" |
| initialValue={config?.ttsVoice} |
| onSubmit={value => { void saveTtsVoice(value) }} |
| onCancel={() => setStep({ type: 'menu' })} |
| /> |
| </Dialog> |
| ) |
| } |
|
|
| if (step.type === 'edit-tts-ref-audio') { |
| return ( |
| <Dialog title="VoxCPM 参考音频" onCancel={() => setStep({ type: 'menu' })}> |
| <TextInput |
| title="参考音频路径" |
| hint="VoxCPM 语音克隆的参考音频文件路径(支持 WAV/MP3)。留空清空配置。" |
| initialValue={config?.ttsReferenceAudio} |
| onSubmit={value => { void saveTtsRefAudio(value) }} |
| onCancel={() => setStep({ type: 'menu' })} |
| /> |
| </Dialog> |
| ) |
| } |
|
|
| if (step.type === 'confirm-clear') { |
| return ( |
| <Dialog title="清空飞书配置" onCancel={() => setStep({ type: 'menu' })}> |
| <ConfirmClear |
| onConfirm={() => { void clearConfigAndStop() }} |
| onCancel={() => setStep({ type: 'menu' })} |
| /> |
| </Dialog> |
| ) |
| } |
|
|
| if (step.type === 'scanning') { |
| return <ScanningDialog onCancel={() => setStep({ type: 'menu' })} onSuccess={() => { |
| refreshConfig() |
| setStep({ type: 'menu' }) |
| }} /> |
| } |
|
|
| return ( |
| <Dialog title="飞书 (Feishu)" onCancel={() => onDone(undefined, { display: 'skip' })}> |
| <Box flexDirection="column" gap={1}> |
| <Text dimColor> |
| 启动飞书长连接机器人,并把私聊消息接入当前会话。 |
| </Text> |
| <Text> |
| 状态: |
| {' '} |
| <Text color={serviceState.status === 'running' ? 'green' : 'yellow'}> |
| {serviceState.status === 'running' |
| ? '运行中' |
| : serviceState.status === 'starting' |
| ? '启动中' |
| : '未启动'} |
| </Text> |
| </Text> |
| <Text>App ID: {maskFeishuAppId(config?.appId)}</Text> |
| <Text>App Secret: {maskFeishuAppSecret(config?.appSecret)}</Text> |
| <Text> |
| 加密密钥: {config?.encryptKey ? '已配置' : '未配置'} |
| </Text> |
| <Text> |
| 验证 Token: {config?.verificationToken ? '已配置' : '未配置'} |
| </Text> |
| <Text> |
| 允许用户: |
| {' '} |
| {config?.allowedUsers?.length |
| ? config.allowedUsers.join(', ') |
| : '未配置(使用配对码授权)'} |
| </Text> |
| <Text> |
| 管理员: |
| {' '} |
| {config?.admins?.length |
| ? config.admins.join(', ') |
| : '未配置'} |
| </Text> |
| <Text> |
| 允许群聊: |
| {' '} |
| {config?.allowedChats?.length |
| ? `${config.allowedChats.length} 个` |
| : '未配置'} |
| </Text> |
| <Text> |
| 群聊 @提及: {config?.requireMentionInGroup !== false ? '需 @bot' : '所有消息'} |
| </Text> |
| {config?.pairedUsers?.length ? ( |
| <Text> |
| 已配对用户: {config.pairedUsers.map(u => u.displayName || u.userId).join(', ')} |
| </Text> |
| ) : null} |
| {serviceState.lastError ? ( |
| <Text color="red">最近错误: {serviceState.lastError}</Text> |
| ) : null} |
| {notice ? <Text color={noticeColor}>{notice.text}</Text> : null} |
| {busy ? <Text dimColor>处理中,请稍候...</Text> : null} |
| <Box flexDirection="column" marginTop={1}> |
| {menuItems.map((item, index) => ( |
| <ListItem key={item.label} isFocused={focusIndex === index}> |
| <Text>{item.label}</Text> |
| </ListItem> |
| ))} |
| </Box> |
| <Text dimColor>上下箭头选择,Enter 执行,Esc 关闭</Text> |
| </Box> |
| </Dialog> |
| ) |
| } |
|
|
| function ScanningDialog({ |
| onCancel, |
| onSuccess, |
| }: { |
| onCancel: () => void |
| onSuccess: () => void |
| }) { |
| const [qrUrl, setQrUrl] = React.useState<string | null>(null) |
| const [expireIn, setExpireIn] = React.useState<number>(0) |
| const [status, setStatus] = React.useState<string>('正在连接飞书...') |
| const [error, setError] = React.useState<string | null>(null) |
| const [success, setSuccess] = React.useState(false) |
| const [completed, setCompleted] = React.useState(false) |
|
|
| |
| 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 |
|
|
| |
| 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) |
|
|
| |
| 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 |
| } |
| }, []) |
|
|
| useInput((_, key) => { |
| if (key.escape && !completed) { |
| onCancelRef.current() |
| } |
| }) |
|
|
| const [qrAscii, setQrAscii] = React.useState<string>('') |
| const [qrError, setQrError] = React.useState<string | null>(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 ( |
| <Dialog title="扫码登录飞书" onCancel={completed ? undefined : onCancel}> |
| <Box flexDirection="column" gap={1}> |
| {error ? ( |
| <> |
| <Text color="red">错误: {error}</Text> |
| <Text dimColor>按 Esc 返回</Text> |
| </> |
| ) : success ? ( |
| <> |
| <Text color="green">✓ {status}</Text> |
| </> |
| ) : qrError ? ( |
| <> |
| <Text bold color="yellow">二维码生成失败</Text> |
| <Text dimColor marginTop={1}>请使用下方链接完成授权:</Text> |
| <Box marginTop={1}> |
| <Text selectText>{qrUrl}</Text> |
| </Box> |
| <Text dimColor marginTop={1}>{status}</Text> |
| <Text dimColor marginTop={1}>按 Esc 取消</Text> |
| </> |
| ) : qrAscii ? ( |
| <> |
| <Text bold>请用飞书 App 扫描下方二维码</Text> |
| <Text dimColor>扫描后,请在打开的页面中点击【授权】完成应用创建</Text> |
| <Box marginTop={1}> |
| <Text>{qrAscii}</Text> |
| </Box> |
| <Text dimColor marginTop={1}> |
| 二维码有效期:约 {Math.max(1, Math.round(expireIn / 60))} 分钟 |
| </Text> |
| {qrUrl && ( |
| <Text dimColor marginTop={1}> |
| 或打开链接:{qrUrl.slice(0, 70)}... |
| </Text> |
| )} |
| <Text color="cyan" marginTop={1}>{status}</Text> |
| <Text dimColor marginTop={1}>按 Esc 取消</Text> |
| </> |
| ) : ( |
| <> |
| <Text>{status}</Text> |
| <Text dimColor>请稍候...</Text> |
| </> |
| )} |
| </Box> |
| </Dialog> |
| ) |
| } |
|
|
| export async function call( |
| onDone: LocalJSXCommandOnDone, |
| _context: LocalJSXCommandContext, |
| ): Promise<React.ReactNode> { |
| return <FeishuDialog onDone={onDone} /> |
| } |
|
|
| export default call |