chenbhao commited on
Commit
1caee20
·
1 Parent(s): 44e0492

feat: feishu voice tts

Browse files
README.md CHANGED
@@ -151,10 +151,12 @@ mitmproxy -p 8888
151
  ```bash
152
  # /voice
153
  uv venv
154
- uv uv pip install faster-whisper edge-tts
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.sendText(completedTurn.chatId, reply)
 
 
 
 
 
 
 
 
 
 
 
 
 
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'