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 (
)
}
if (step.type === 'edit-app-secret') {
return (
)
}
if (step.type === 'edit-encrypt-key') {
return (
)
}
if (step.type === 'edit-verification-token') {
return (
)
}
if (step.type === 'edit-allowed-users') {
return (
)
}
if (step.type === 'edit-admins') {
return (
)
}
if (step.type === 'edit-allowed-chats') {
return (
)
}
if (step.type === 'edit-mention-policy') {
return (
)
}
if (step.type === 'edit-tts-settings') {
return (
)
}
if (step.type === 'edit-tts-ref-audio') {
return (
)
}
if (step.type === 'confirm-clear') {
return (
)
}
if (step.type === 'scanning') {
return setStep({ type: 'menu' })} onSuccess={() => {
refreshConfig()
setStep({ type: 'menu' })
}} />
}
return (
)
}
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 (
)
}
export async function call(
onDone: LocalJSXCommandOnDone,
_context: LocalJSXCommandContext,
): Promise {
return
}
export default call