feat: feishu voice tts
Browse files- README.md +3 -1
- src/commands/feishu/feishu.tsx +69 -0
- src/hooks/useFeishuBridge.ts +14 -1
- src/services/feishu/FeishuService.ts +83 -0
- src/services/feishu/feishuConfig.ts +4 -0
- src/utils/feishuMarkdown.ts +8 -0
README.md
CHANGED
|
@@ -151,10 +151,12 @@ mitmproxy -p 8888
|
|
| 151 |
```bash
|
| 152 |
# /voice
|
| 153 |
uv venv
|
| 154 |
-
uv
|
| 155 |
|
| 156 |
hf download Systran/faster-whisper-base \
|
| 157 |
--local-dir ~/.cache/huggingface/hub/models--Systran--faster-whisper-base
|
|
|
|
|
|
|
| 158 |
```
|
| 159 |
|
| 160 |
## 🌐 Desktop
|
|
|
|
| 151 |
```bash
|
| 152 |
# /voice
|
| 153 |
uv venv
|
| 154 |
+
uv pip install faster-whisper edge-tts voxcpm
|
| 155 |
|
| 156 |
hf download Systran/faster-whisper-base \
|
| 157 |
--local-dir ~/.cache/huggingface/hub/models--Systran--faster-whisper-base
|
| 158 |
+
|
| 159 |
+
|
| 160 |
```
|
| 161 |
|
| 162 |
## 🌐 Desktop
|
src/commands/feishu/feishu.tsx
CHANGED
|
@@ -24,6 +24,7 @@ type Step =
|
|
| 24 |
| { type: 'edit-admins' }
|
| 25 |
| { type: 'edit-allowed-chats' }
|
| 26 |
| { type: 'edit-mention-policy' }
|
|
|
|
| 27 |
| { type: 'confirm-clear' }
|
| 28 |
| { type: 'scanning' }
|
| 29 |
|
|
@@ -194,6 +195,14 @@ function FeishuDialog({
|
|
| 194 |
label: `@提及要求: ${config?.requireMentionInGroup !== false ? '需 @bot' : '所有消息'}`,
|
| 195 |
action: () => setStep({ type: 'edit-mention-policy' }),
|
| 196 |
},
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 197 |
{
|
| 198 |
label:
|
| 199 |
serviceState.status === 'running' ? '停止当前飞书 Bot' : '启动当前飞书 Bot',
|
|
@@ -443,6 +452,52 @@ function FeishuDialog({
|
|
| 443 |
[refreshConfig],
|
| 444 |
)
|
| 445 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 446 |
const clearConfigAndStop = React.useCallback(async () => {
|
| 447 |
setBusy(true)
|
| 448 |
try {
|
|
@@ -626,6 +681,20 @@ function FeishuDialog({
|
|
| 626 |
)
|
| 627 |
}
|
| 628 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 629 |
if (step.type === 'confirm-clear') {
|
| 630 |
return (
|
| 631 |
<Dialog title="清空飞书配置" onCancel={() => setStep({ type: 'menu' })}>
|
|
|
|
| 24 |
| { type: 'edit-admins' }
|
| 25 |
| { type: 'edit-allowed-chats' }
|
| 26 |
| { type: 'edit-mention-policy' }
|
| 27 |
+
| { type: 'edit-tts-settings' }
|
| 28 |
| { type: 'confirm-clear' }
|
| 29 |
| { type: 'scanning' }
|
| 30 |
|
|
|
|
| 195 |
label: `@提及要求: ${config?.requireMentionInGroup !== false ? '需 @bot' : '所有消息'}`,
|
| 196 |
action: () => setStep({ type: 'edit-mention-policy' }),
|
| 197 |
},
|
| 198 |
+
{
|
| 199 |
+
label: `TTS 语音回复: ${config?.ttsEnabled ? '开' : '关'}`,
|
| 200 |
+
action: () => { void toggleTts() },
|
| 201 |
+
},
|
| 202 |
+
{
|
| 203 |
+
label: `TTS 语音: ${config?.ttsVoice || 'zh-CN-XiaoxiaoNeural (中文)'}`,
|
| 204 |
+
action: () => setStep({ type: 'edit-tts-settings' }),
|
| 205 |
+
},
|
| 206 |
{
|
| 207 |
label:
|
| 208 |
serviceState.status === 'running' ? '停止当前飞书 Bot' : '启动当前飞书 Bot',
|
|
|
|
| 452 |
[refreshConfig],
|
| 453 |
)
|
| 454 |
|
| 455 |
+
const toggleTts = React.useCallback(
|
| 456 |
+
async () => {
|
| 457 |
+
setBusy(true)
|
| 458 |
+
try {
|
| 459 |
+
const current = getFeishuConfig()
|
| 460 |
+
const next = !current.ttsEnabled
|
| 461 |
+
saveFeishuConfig({ ...current, ttsEnabled: next })
|
| 462 |
+
refreshConfig()
|
| 463 |
+
setNotice({
|
| 464 |
+
text: next ? 'TTS 语音回复已开启。' : 'TTS 语音回复已关闭。',
|
| 465 |
+
tone: 'success',
|
| 466 |
+
})
|
| 467 |
+
} catch (error) {
|
| 468 |
+
setNotice({
|
| 469 |
+
text: error instanceof Error ? error.message : '保存失败',
|
| 470 |
+
tone: 'error',
|
| 471 |
+
})
|
| 472 |
+
} finally {
|
| 473 |
+
setBusy(false)
|
| 474 |
+
setStep({ type: 'menu' })
|
| 475 |
+
}
|
| 476 |
+
},
|
| 477 |
+
[refreshConfig],
|
| 478 |
+
)
|
| 479 |
+
|
| 480 |
+
const saveTtsVoice = React.useCallback(
|
| 481 |
+
async (value: string) => {
|
| 482 |
+
setBusy(true)
|
| 483 |
+
try {
|
| 484 |
+
const current = getFeishuConfig()
|
| 485 |
+
saveFeishuConfig({ ...current, ttsVoice: value || undefined })
|
| 486 |
+
refreshConfig()
|
| 487 |
+
setNotice({ text: 'TTS 语音已保存。', tone: 'success' })
|
| 488 |
+
} catch (error) {
|
| 489 |
+
setNotice({
|
| 490 |
+
text: error instanceof Error ? error.message : '保存失败',
|
| 491 |
+
tone: 'error',
|
| 492 |
+
})
|
| 493 |
+
} finally {
|
| 494 |
+
setBusy(false)
|
| 495 |
+
setStep({ type: 'menu' })
|
| 496 |
+
}
|
| 497 |
+
},
|
| 498 |
+
[refreshConfig],
|
| 499 |
+
)
|
| 500 |
+
|
| 501 |
const clearConfigAndStop = React.useCallback(async () => {
|
| 502 |
setBusy(true)
|
| 503 |
try {
|
|
|
|
| 681 |
)
|
| 682 |
}
|
| 683 |
|
| 684 |
+
if (step.type === 'edit-tts-settings') {
|
| 685 |
+
return (
|
| 686 |
+
<Dialog title="TTS 语音设置" onCancel={() => setStep({ type: 'menu' })}>
|
| 687 |
+
<TextInput
|
| 688 |
+
title="TTS 语音名称"
|
| 689 |
+
hint="Edge TTS 语音标识符,例如:zh-CN-XiaoxiaoNeural(中文女声)、zh-CN-YunxiNeural(中文男声)、en-US-JennyNeural(英文女声)。留空恢复默认。"
|
| 690 |
+
initialValue={config?.ttsVoice}
|
| 691 |
+
onSubmit={value => { void saveTtsVoice(value) }}
|
| 692 |
+
onCancel={() => setStep({ type: 'menu' })}
|
| 693 |
+
/>
|
| 694 |
+
</Dialog>
|
| 695 |
+
)
|
| 696 |
+
}
|
| 697 |
+
|
| 698 |
if (step.type === 'confirm-clear') {
|
| 699 |
return (
|
| 700 |
<Dialog title="清空飞书配置" onCancel={() => setStep({ type: 'menu' })}>
|
src/hooks/useFeishuBridge.ts
CHANGED
|
@@ -112,7 +112,20 @@ export function useFeishuBridge({ messages, isLoading }: Props): void {
|
|
| 112 |
const reply =
|
| 113 |
completedTurn.responseParts.join('\n\n').trim() ||
|
| 114 |
'这一轮没有可回传的文本结果,请查看本地终端会话。'
|
| 115 |
-
await feishuService.
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 116 |
} catch (error) {
|
| 117 |
console.warn(
|
| 118 |
'[feishu] failed to send outbound reply:',
|
|
|
|
| 112 |
const reply =
|
| 113 |
completedTurn.responseParts.join('\n\n').trim() ||
|
| 114 |
'这一轮没有可回传的文本结果,请查看本地终端会话。'
|
| 115 |
+
await feishuService.sendMarkdown(completedTurn.chatId, reply)
|
| 116 |
+
|
| 117 |
+
// Also send TTS voice if enabled
|
| 118 |
+
const config = getFeishuConfig()
|
| 119 |
+
if (config.ttsEnabled) {
|
| 120 |
+
const plainText = completedTurn.responseParts
|
| 121 |
+
.join('\n')
|
| 122 |
+
.replace(/[#*`_~\[\]()>|\\]/g, '')
|
| 123 |
+
.replace(/{[^}]*}/g, '')
|
| 124 |
+
.trim()
|
| 125 |
+
if (plainText) {
|
| 126 |
+
await feishuService.sendVoice(completedTurn.chatId, plainText)
|
| 127 |
+
}
|
| 128 |
+
}
|
| 129 |
} catch (error) {
|
| 130 |
console.warn(
|
| 131 |
'[feishu] failed to send outbound reply:',
|
src/services/feishu/FeishuService.ts
CHANGED
|
@@ -3,6 +3,8 @@
|
|
| 3 |
* Refactored to use vendor modules from lark-coding-agent-bridge.
|
| 4 |
*/
|
| 5 |
|
|
|
|
|
|
|
| 6 |
import {
|
| 7 |
createLarkChannel,
|
| 8 |
type LarkChannel,
|
|
@@ -498,6 +500,87 @@ class FeishuService {
|
|
| 498 |
}
|
| 499 |
}
|
| 500 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 501 |
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
| 502 |
onMessagesChange(_messages: unknown[], _isLoading: boolean): void {
|
| 503 |
// No-op
|
|
|
|
| 3 |
* Refactored to use vendor modules from lark-coding-agent-bridge.
|
| 4 |
*/
|
| 5 |
|
| 6 |
+
import * as path from 'node:path'
|
| 7 |
+
import * as os from 'node:os'
|
| 8 |
import {
|
| 9 |
createLarkChannel,
|
| 10 |
type LarkChannel,
|
|
|
|
| 500 |
}
|
| 501 |
}
|
| 502 |
|
| 503 |
+
async sendMarkdown(chatId: string, markdown: string): Promise<void> {
|
| 504 |
+
if (!this.channel) return
|
| 505 |
+
try {
|
| 506 |
+
const { optimizeMarkdownForFeishu } = await import(
|
| 507 |
+
'../../utils/feishuMarkdown.js'
|
| 508 |
+
)
|
| 509 |
+
const optimized = optimizeMarkdownForFeishu(markdown)
|
| 510 |
+
await this.channel.send(chatId, { markdown: optimized })
|
| 511 |
+
} catch (err) {
|
| 512 |
+
log.fail('send', err, { chatId, markdownLen: markdown.length })
|
| 513 |
+
}
|
| 514 |
+
}
|
| 515 |
+
|
| 516 |
+
async sendVoice(chatId: string, text: string): Promise<void> {
|
| 517 |
+
if (!this.channel) return
|
| 518 |
+
try {
|
| 519 |
+
const config = getFeishuConfig()
|
| 520 |
+
console.log('[feishu][tts] config.ttsEnabled:', config.ttsEnabled, 'voice:', config.ttsVoice)
|
| 521 |
+
if (!config.ttsEnabled) return
|
| 522 |
+
|
| 523 |
+
const { readFile, unlink } = await import('node:fs/promises')
|
| 524 |
+
const { spawn } = await import('node:child_process')
|
| 525 |
+
|
| 526 |
+
const voice = config.ttsVoice || 'zh-CN-XiaoxiaoNeural'
|
| 527 |
+
const mp3Path = path.join(os.tmpdir(), `feishu_tts_${Date.now()}.mp3`)
|
| 528 |
+
const oggPath = mp3Path.replace(/\.mp3$/, '.ogg')
|
| 529 |
+
|
| 530 |
+
console.log('[feishu][tts] generating MP3 with voice:', voice, 'text len:', text.length)
|
| 531 |
+
|
| 532 |
+
// Generate MP3 via edge-tts CLI
|
| 533 |
+
await new Promise<void>((resolve, reject) => {
|
| 534 |
+
const child = spawn('edge-tts', [
|
| 535 |
+
'--voice', voice,
|
| 536 |
+
'--text', text,
|
| 537 |
+
'--write-media', mp3Path,
|
| 538 |
+
])
|
| 539 |
+
let stderr = ''
|
| 540 |
+
child.stderr?.on('data', c => { stderr += String(c) })
|
| 541 |
+
child.on('close', code => {
|
| 542 |
+
console.log('[feishu][tts] edge-tts exit code:', code, 'stderr:', stderr.slice(0, 100))
|
| 543 |
+
if (code === 0) resolve()
|
| 544 |
+
else reject(new Error(`edge-tts exit ${code}: ${stderr.slice(0, 200)}`))
|
| 545 |
+
})
|
| 546 |
+
child.on('error', err => {
|
| 547 |
+
console.log('[feishu][tts] edge-tts spawn error:', err.message)
|
| 548 |
+
reject(err)
|
| 549 |
+
})
|
| 550 |
+
})
|
| 551 |
+
|
| 552 |
+
// Convert MP3 → OGG/Opus (Feishu voice requires opus format)
|
| 553 |
+
console.log('[feishu][tts] converting MP3 → OGG')
|
| 554 |
+
await new Promise<void>((resolve, reject) => {
|
| 555 |
+
const child = spawn('ffmpeg', [
|
| 556 |
+
'-i', mp3Path,
|
| 557 |
+
'-c:a', 'libopus',
|
| 558 |
+
'-b:a', '128k',
|
| 559 |
+
'-y',
|
| 560 |
+
oggPath,
|
| 561 |
+
])
|
| 562 |
+
child.on('close', code => {
|
| 563 |
+
console.log('[feishu][tts] ffmpeg exit code:', code)
|
| 564 |
+
if (code === 0) resolve()
|
| 565 |
+
else reject(new Error(`ffmpeg exit ${code}`))
|
| 566 |
+
})
|
| 567 |
+
child.on('error', reject)
|
| 568 |
+
})
|
| 569 |
+
|
| 570 |
+
const buffer = await readFile(oggPath)
|
| 571 |
+
console.log('[feishu][tts] sending audio buffer, size:', buffer.length)
|
| 572 |
+
await this.channel.send(chatId, { audio: { source: buffer } })
|
| 573 |
+
console.log('[feishu][tts] sent successfully')
|
| 574 |
+
|
| 575 |
+
// Clean up temp files
|
| 576 |
+
await unlink(mp3Path).catch(() => {})
|
| 577 |
+
await unlink(oggPath).catch(() => {})
|
| 578 |
+
} catch (err) {
|
| 579 |
+
// Don't let TTS failure break the main message flow
|
| 580 |
+
console.log('[feishu][tts] send-voice-failed:', err instanceof Error ? err.message : String(err))
|
| 581 |
+
}
|
| 582 |
+
}
|
| 583 |
+
|
| 584 |
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
| 585 |
onMessagesChange(_messages: unknown[], _isLoading: boolean): void {
|
| 586 |
// No-op
|
src/services/feishu/feishuConfig.ts
CHANGED
|
@@ -39,6 +39,10 @@ export type FeishuRuntimeConfig = {
|
|
| 39 |
authorizedUsers?: AuthorizedUser[]
|
| 40 |
/** 是否使用卡片回复模式 */
|
| 41 |
streamingCard?: boolean
|
|
|
|
|
|
|
|
|
|
|
|
|
| 42 |
}
|
| 43 |
|
| 44 |
function getConfigPath(): string {
|
|
|
|
| 39 |
authorizedUsers?: AuthorizedUser[]
|
| 40 |
/** 是否使用卡片回复模式 */
|
| 41 |
streamingCard?: boolean
|
| 42 |
+
/** TTS 语音回复开关 — 发送消息时同时发送语音 */
|
| 43 |
+
ttsEnabled?: boolean
|
| 44 |
+
/** TTS 语音名称(edge-tts 格式,默认 zh-CN-XiaoxiaoNeural) */
|
| 45 |
+
ttsVoice?: string
|
| 46 |
}
|
| 47 |
|
| 48 |
function getConfigPath(): string {
|
src/utils/feishuMarkdown.ts
ADDED
|
@@ -0,0 +1,8 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
/**
|
| 2 |
+
* Re-export feishu markdown optimization for use within src/.
|
| 3 |
+
*/
|
| 4 |
+
export {
|
| 5 |
+
optimizeMarkdownForFeishu,
|
| 6 |
+
sanitizeTextForCard,
|
| 7 |
+
FEISHU_CARD_TABLE_LIMIT,
|
| 8 |
+
} from '../../feishu/markdown-style.js'
|