@xmanrui/dsh-im 4.28.1 → 4.29.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -628,6 +628,14 @@ export class FeishuHarnessBridge {
628
628
  #logger;
629
629
  #signal;
630
630
  #botId;
631
+ /** Optional voice capability (语音输入/语音回复); null disables all voice paths. */
632
+ #voice = null;
633
+ /**
634
+ * Pending voice turns: session key → { replyTo }, consumed when the full
635
+ * answer lands. Keyed by the same session key as the message queue so
636
+ * concurrent topics of one group chat never share a voice slot.
637
+ */
638
+ #voiceTurns = new Map();
631
639
  #appId;
632
640
  #botOpenId;
633
641
  #groupResponseMode;
@@ -710,6 +718,7 @@ export class FeishuHarnessBridge {
710
718
  interactionCards = true,
711
719
  sessionSyncTargetsFor = null,
712
720
  logger = console,
721
+ voice = null,
713
722
  signal,
714
723
  }) {
715
724
  if (!client || !harness || !state || !status) {
@@ -736,6 +745,13 @@ export class FeishuHarnessBridge {
736
745
  || typeof stepPushClock.delay !== 'function')) {
737
746
  throw new TypeError('Feishu step push clock requires now and delay functions');
738
747
  }
748
+ if (voice !== null && voice !== undefined
749
+ && (typeof voice !== 'object' || Array.isArray(voice)
750
+ || (voice.enabled !== false
751
+ && (typeof voice.transcribeIncoming !== 'function'
752
+ || typeof voice.synthesize !== 'function')))) {
753
+ throw new TypeError('Feishu voice capability requires transcribeIncoming and synthesize functions');
754
+ }
739
755
  this.#client = client;
740
756
  this.#channel = channel;
741
757
  this.#harness = harness;
@@ -752,6 +768,7 @@ export class FeishuHarnessBridge {
752
768
  this.#stepPush = stepPush === true;
753
769
  this.#stepPushMode = normalizeFeishuStepPushMode(stepPushMode);
754
770
  this.#stepPushClock = stepPushClock ?? DEFAULT_STEP_PUSH_CLOCK;
771
+ this.#voice = voice ?? null;
755
772
  this.#repair = repair ?? null;
756
773
  this.#repairPollIntervalMs = repairPollIntervalMs;
757
774
  this.#repairLinkWaitMs = repairLinkWaitMs;
@@ -845,6 +862,10 @@ export class FeishuHarnessBridge {
845
862
  this.#stepPushMode = normalizeFeishuStepPushMode(value);
846
863
  }
847
864
 
865
+ setVoice(voice) {
866
+ this.#voice = voice ?? null;
867
+ }
868
+
848
869
  get stepPushMode() {
849
870
  return this.#stepPushMode;
850
871
  }
@@ -1494,10 +1515,57 @@ export class FeishuHarnessBridge {
1494
1515
  }
1495
1516
 
1496
1517
  const message = extractInboundMessage(event, this.#client);
1497
- const text = message.content;
1518
+ let text = message.content;
1519
+ let voiceTurnReplyTo = null;
1520
+ // 语音输入(渠道能力):音频消息先转写成文字并写回 message.content——
1521
+ // 下方命令识别与 #answerWithStream 都从 content 取文本。转写失败(或返回
1522
+ // 空文本)时保持原语义,走"仅支持文字、图片和文件"的明确降级提示。
1523
+ if (event.message.message_type === 'audio' && this.#voice?.enabled) {
1524
+ try {
1525
+ const transcript = await this.#voice.transcribeIncoming(event, this.#client);
1526
+ if (transcript) {
1527
+ text = transcript;
1528
+ message.content = transcript;
1529
+ voiceTurnReplyTo = event.message.message_id;
1530
+ }
1531
+ } catch (error) {
1532
+ this.#logger.warn?.('[dsh-feishu-voice] 语音转写失败:', error?.message ?? String(error));
1533
+ }
1534
+ }
1498
1535
  const hasImages = hasInboundImages(message);
1499
1536
  const hasFiles = hasInboundFiles(message);
1500
1537
  const hasReply = hasReplyReference(message);
1538
+ // 语音转写后的命令权限复检:accept() 的访问判定发生在转写前,音频消息
1539
+ // 没有命令文本,按普通消息放行;转写得到的文本可能包含命令,须用与
1540
+ // accept() 相同的命令识别与权限规则再校验一次,防止"允许聊天、禁止命令"
1541
+ // 的用户经语音入口绕过 canExecuteCommands——文字与语音入口行为一致。
1542
+ // 仅"命令不允许"给用户提示(与文字路径同文案),其余拒绝原因静默丢弃。
1543
+ if (voiceTurnReplyTo) {
1544
+ const transcribedAccess = evaluateInboundAccess(this.#accessPolicy, {
1545
+ conversationType: event.message.chat_type === 'p2p' ? 'direct'
1546
+ : event.message.chat_type === 'group' ? 'group' : null,
1547
+ senderIds: senderOpenId(event),
1548
+ text,
1549
+ hasImages,
1550
+ hasFiles,
1551
+ isCommand: isSharedLocalCommand(text, { hasImages, hasFiles })
1552
+ || isFeishuLocalCommand(text, { hasImages, hasFiles })
1553
+ || (!hasImages && !hasFiles && NUMBER_REPLY.test(text) && this.#menus.has(key)),
1554
+ });
1555
+ if (!transcribedAccess.allowed) {
1556
+ if (transcribedAccess.reason === 'command-not-allowed') {
1557
+ await this.#send(
1558
+ event.message.chat_id,
1559
+ t(COMMAND_PERMISSION_DENIED_MESSAGE),
1560
+ { replyTo: event.message.message_id },
1561
+ );
1562
+ this.#status.messagesReplied += 1;
1563
+ this.#status.lastReplyAt = new Date().toISOString();
1564
+ }
1565
+ this.#status.lastError = null;
1566
+ return;
1567
+ }
1568
+ }
1501
1569
  // 命令识别对 text 与纯文本 post 一视同仁:post 富文本若仅含单个
1502
1570
  // 文本段落(如复制粘贴的 /new),同样按命令处理;带图片/文件不认。
1503
1571
  // accept() 侧已用 nonEmptyString(content) 判定,两侧保持一致。
@@ -1637,6 +1705,15 @@ export class FeishuHarnessBridge {
1637
1705
  }
1638
1706
 
1639
1707
  this.#logger.info?.(`[dsh-feishu] processing ${event.message.chat_type} message ${messageId}`);
1708
+ // 语音回合状态在进入正常问答路径前才登记:上方命令分支(/help、菜单、
1709
+ // 会话列表等)与空内容分支均提前返回,提前登记会让状态遗留到下一回合,
1710
+ // 导致后续普通文字消息的答案被合成为音频并回复到旧的语音消息。
1711
+ // 本路径的 finally 统一清理回合状态。状态以会话 key 登记——与消息队列
1712
+ // 同粒度:同群不同话题的 key 不同、可并行处理,共用 chat_id 槽位会让
1713
+ // 后登记的回合覆盖先登记的,先完成的回合合成到后一条语音消息上。
1714
+ if (voiceTurnReplyTo) {
1715
+ this.#voiceTurns.set(key, { replyTo: voiceTurnReplyTo });
1716
+ }
1640
1717
  const batchSubmission = event.batchSubmission ?? null;
1641
1718
  let batchAskCompleted = false;
1642
1719
  try {
@@ -1682,6 +1759,7 @@ export class FeishuHarnessBridge {
1682
1759
  } finally {
1683
1760
  await this.#cancelPendingInteraction(key);
1684
1761
  await this.#approvals.closeRoute(key);
1762
+ this.#voiceTurns.delete(key);
1685
1763
  }
1686
1764
  }
1687
1765
 
@@ -2839,7 +2917,7 @@ export class FeishuHarnessBridge {
2839
2917
  }
2840
2918
 
2841
2919
  /**
2842
- * issue #162:已答状态卡的原地替换。patch 失败仅记录警告并明确降级——
2920
+ * 已处理交互卡的原地替换。patch 失败仅记录警告并明确降级——
2843
2921
  * 回执缺失不应影响答案提交,也不得像 #sendCard 那样回退成发送新卡。
2844
2922
  */
2845
2923
  async #patchCardMessage(chatId, messageId, cardJson) {
@@ -2854,7 +2932,7 @@ export class FeishuHarnessBridge {
2854
2932
  }
2855
2933
  return messageId;
2856
2934
  } catch (error) {
2857
- this.#logger.warn?.('[dsh-feishu] answered-state card patch failed:', error?.message ?? error);
2935
+ this.#logger.warn?.('[dsh-feishu] resolved interaction card patch failed:', error?.message ?? error);
2858
2936
  return null;
2859
2937
  }
2860
2938
  }
@@ -4142,6 +4220,53 @@ export class FeishuHarnessBridge {
4142
4220
  });
4143
4221
  }
4144
4222
 
4223
+ // 语音回复(渠道能力):同回合转写过语音消息的会话,在完整答案投递后追加
4224
+ // 音频回复。回合按会话 key 登记并消费——同群不同话题各自只消费自己的
4225
+ // 回合;登记随消息处理结束(finally)清除,命令类回复(菜单、会话列表等)
4226
+ // 与延迟交付不触发语音;此处失败只告警,不影响文字投递。
4227
+ async #maybeSendVoiceReply(key, chatId, answer, replyTo = null) {
4228
+ const voiceTurn = this.#voiceTurns.get(key);
4229
+ if (!voiceTurn) return;
4230
+ this.#voiceTurns.delete(key);
4231
+ try {
4232
+ await this.#sendVoiceAnswer(chatId, answer, voiceTurn.replyTo ?? replyTo);
4233
+ } catch (error) {
4234
+ this.#logger.warn?.('[dsh-feishu-voice] 语音回复失败:', error?.message ?? String(error));
4235
+ }
4236
+ }
4237
+
4238
+ // 文本 → qwen TTS → opus 上传 → 音频消息;优先回复原语音消息,
4239
+ // reply 不可用时明确降级为普通音频消息。
4240
+ async #sendVoiceAnswer(chatId, text, replyTo = null) {
4241
+ const opus = await this.#voice.synthesize(text);
4242
+ if (!opus) return null;
4243
+ const upload = await this.#client.im.v1.file.create({
4244
+ data: { file_type: 'opus', file_name: 'dsh-im-voice-reply.opus', file: opus },
4245
+ });
4246
+ const fileKey = upload?.file_key ?? upload?.data?.file_key;
4247
+ if (!fileKey) throw new Error('音频上传无 file_key');
4248
+ const content = JSON.stringify({ file_key: fileKey });
4249
+ if (replyTo) {
4250
+ try {
4251
+ const response = await this.#client.im.v1.message.reply({
4252
+ path: { message_id: replyTo },
4253
+ data: { msg_type: 'audio', content },
4254
+ });
4255
+ if (!response?.code || response.code === 0) return response?.data?.message_id ?? null;
4256
+ } catch (error) {
4257
+ this.#logger.warn?.('[dsh-feishu-voice] 音频回复失败,改发普通消息:', error?.message ?? String(error));
4258
+ }
4259
+ }
4260
+ const response = await this.#client.im.v1.message.create({
4261
+ params: { receive_id_type: 'chat_id' },
4262
+ data: { receive_id: chatId, msg_type: 'audio', content },
4263
+ });
4264
+ if (response?.code && response.code !== 0) {
4265
+ throw new Error(`Feishu audio send failed: ${response.msg || response.code}`);
4266
+ }
4267
+ return nonEmptyString(response?.data?.message_id);
4268
+ }
4269
+
4145
4270
  async #deliverArtifacts(chatId, replyTo, artifacts = [], baseReceipt) {
4146
4271
  const delivery = await deliverOutboundArtifacts({
4147
4272
  artifacts,
@@ -5168,6 +5293,9 @@ export class FeishuHarnessBridge {
5168
5293
  providerMessageIds: seal.cardIds,
5169
5294
  });
5170
5295
  this.#status.streamResponses = (this.#status.streamResponses ?? 0) + 1;
5296
+ // 流式卡封存成功也是完整答案投递:文本进了卡片,语音回合同样要
5297
+ // 在此消费并合成音频回复——否则语音消息只得到卡片、没有声音。
5298
+ await this.#maybeSendVoiceReply(key, chatId, deliveryText, messageId);
5171
5299
  const delivery = await this.#deliverArtifacts(
5172
5300
  chatId,
5173
5301
  messageId,
@@ -5217,6 +5345,7 @@ export class FeishuHarnessBridge {
5217
5345
  providerMessageIds,
5218
5346
  });
5219
5347
  this.#status.streamResponses = (this.#status.streamResponses ?? 0) + 1;
5348
+ await this.#maybeSendVoiceReply(key, chatId, deliveryText, messageId);
5220
5349
  } catch (error) {
5221
5350
  this.#status.streamErrors = (this.#status.streamErrors ?? 0) + 1;
5222
5351
  this.#logger.warn?.('[dsh-feishu] step push post failed; sending final text:', error.message);
@@ -5267,8 +5396,10 @@ export class FeishuHarnessBridge {
5267
5396
  setLastMessageFailure(this.#status, textSendError);
5268
5397
  }
5269
5398
  this.#status.streamFallbacks = (this.#status.streamFallbacks ?? 0) + 1;
5399
+ await this.#maybeSendVoiceReply(key, chatId, deliveryText, messageId);
5270
5400
  return { ...delivery, textDeliveryErrors: textSendError ? 1 : 0 };
5271
5401
  }
5402
+ await this.#maybeSendVoiceReply(key, chatId, deliveryText, messageId);
5272
5403
  const delivery = await this.#deliverArtifacts(
5273
5404
  chatId,
5274
5405
  messageId,
@@ -5336,6 +5467,7 @@ export class FeishuHarnessBridge {
5336
5467
  error,
5337
5468
  );
5338
5469
  }
5470
+ await this.#maybeSendVoiceReply(key, chatId, answerTextForDelivery(answer, artifacts), messageId);
5339
5471
  const delivery = await this.#deliverArtifacts(chatId, messageId, artifacts, textReceipt);
5340
5472
  const artifactDispatched = delivery.receipt.artifacts.some(
5341
5473
  ({ outcome }) => outcome === 'sent' || outcome === 'unknown',
@@ -5402,6 +5534,7 @@ export class FeishuHarnessBridge {
5402
5534
  completedAnswer = completed.answer;
5403
5535
  completedArtifacts = completed.artifacts ?? [];
5404
5536
  await controller.setContent(answerTextForDelivery(completedAnswer, completedArtifacts));
5537
+ await this.#maybeSendVoiceReply(key, chatId, answerTextForDelivery(completedAnswer, completedArtifacts), messageId);
5405
5538
  },
5406
5539
  }, {
5407
5540
  replyTo: messageId,
@@ -5435,6 +5568,7 @@ export class FeishuHarnessBridge {
5435
5568
  fallbackError,
5436
5569
  );
5437
5570
  }
5571
+ await this.#maybeSendVoiceReply(key, chatId, answerTextForDelivery(completedAnswer, completedArtifacts), messageId);
5438
5572
  const delivery = await this.#deliverArtifacts(
5439
5573
  chatId,
5440
5574
  messageId,
@@ -5491,6 +5625,7 @@ export class FeishuHarnessBridge {
5491
5625
  fallbackError,
5492
5626
  );
5493
5627
  }
5628
+ await this.#maybeSendVoiceReply(key, chatId, answerTextForDelivery(answer, artifacts), messageId);
5494
5629
  const delivery = await this.#deliverArtifacts(chatId, messageId, artifacts, textReceipt);
5495
5630
  const artifactDispatched = delivery.receipt.artifacts.some(
5496
5631
  ({ outcome }) => outcome === 'sent' || outcome === 'unknown',
@@ -5670,6 +5805,8 @@ export class FeishuHarnessBridge {
5670
5805
  requiresMention,
5671
5806
  replyToMessageId,
5672
5807
  }) {
5808
+ let approvalCardMessageId = null;
5809
+ let approvalCardData = null;
5673
5810
  if (await this.#approvals.handleRequested(interaction, {
5674
5811
  key,
5675
5812
  actor,
@@ -5681,15 +5818,16 @@ export class FeishuHarnessBridge {
5681
5818
  ...(this.#interactionCards
5682
5819
  ? {
5683
5820
  render: async (pending) => {
5684
- // Show the approval as an interactive card with approve/reject buttons.
5685
- await this.#sendCard(
5821
+ // Keep the original card details and id with this approval's callbacks.
5822
+ approvalCardData = {
5823
+ toolName: pending.toolCall?.name ?? pending.interaction.payload.toolName,
5824
+ operation: operationArguments(pending.toolCall),
5825
+ reason: pending.interaction.payload.reason,
5826
+ approvalId: pending.approvalId,
5827
+ };
5828
+ approvalCardMessageId = await this.#sendCard(
5686
5829
  chatId,
5687
- approvalCard({
5688
- toolName: pending.toolCall?.name ?? pending.payload?.toolName,
5689
- operation: operationArguments(pending.toolCall),
5690
- reason: pending.payload?.reason,
5691
- approvalId: pending.approvalId,
5692
- }),
5830
+ approvalCard(approvalCardData),
5693
5831
  { key, replyTo: replyToMessageId },
5694
5832
  ).catch(async () => {
5695
5833
  // Fall back to the plain-text approval if the card cannot be
@@ -5697,8 +5835,17 @@ export class FeishuHarnessBridge {
5697
5835
  // the pending approval is not marked as presented and the
5698
5836
  // existing retry/reconnect logic can run.
5699
5837
  await this.#send(chatId, pending.text, { replyTo: replyToMessageId });
5838
+ return null;
5700
5839
  });
5701
5840
  },
5841
+ onResolved: async (resolvedText) => {
5842
+ if (!approvalCardMessageId) return;
5843
+ await this.#patchCardMessage(
5844
+ chatId,
5845
+ approvalCardMessageId,
5846
+ approvalCard({ ...approvalCardData, resolvedText }),
5847
+ );
5848
+ },
5702
5849
  }
5703
5850
  : {}),
5704
5851
  })) return;
@@ -877,7 +877,7 @@ export function customSteerCard() {
877
877
  * `requiresMention` is advisory; a button click is itself the operator's
878
878
  * explicit intent, so it does not need an @ mention in groups.
879
879
  */
880
- export function approvalCard({ toolName, operation, reason, approvalId }) {
880
+ export function approvalCard({ toolName, operation, reason, approvalId, resolvedText }) {
881
881
  const elements = [];
882
882
  if (toolName) {
883
883
  elements.push({ tag: 'div', text: markdown(t('工具:{tool}', { tool: String(toolName) })) });
@@ -898,7 +898,9 @@ export function approvalCard({ toolName, operation, reason, approvalId }) {
898
898
  }
899
899
  elements.push(
900
900
  { tag: 'hr' },
901
- buttonPair(t('✅ 批准'), `approve:${approvalId}`, t('❌ 拒绝'), `reject:${approvalId}`),
901
+ resolvedText
902
+ ? { tag: 'div', text: markdown(resolvedText) }
903
+ : buttonPair(t('✅ 批准'), `approve:${approvalId}`, t('❌ 拒绝'), `reject:${approvalId}`),
902
904
  );
903
905
  return cardWith(t('🔐 工具审批'), elements);
904
906
  }
@@ -5,6 +5,7 @@ import { cardActionProbeCard } from './feishu-cards.mjs';
5
5
  import { VerifiedFeishuChannel } from './feishu-channel.mjs';
6
6
  import { normalizeFeishuGroupResponseMode } from './group-response-mode.mjs';
7
7
  import { normalizeFeishuStepPushMode } from './step-push-mode.mjs';
8
+ import { createVoice } from './voice.mjs';
8
9
  import {
9
10
  registerSlashCommands,
10
11
  SLASH_COMMAND_MANIFEST,
@@ -115,6 +116,7 @@ export class FeishuRuntime {
115
116
  #groupTopicReply;
116
117
  #stepPush;
117
118
  #stepPushMode;
119
+ #voice = null;
118
120
  #sessionSyncTargetsFor;
119
121
  #ownerOpenIds;
120
122
  #harness;
@@ -149,6 +151,7 @@ export class FeishuRuntime {
149
151
  groupTopicReply = false,
150
152
  stepPush = false,
151
153
  stepPushMode = 'post',
154
+ voice = null,
152
155
  sessionSyncTargetsFor = null,
153
156
  ownerOpenId,
154
157
  ownerOpenIds,
@@ -188,6 +191,11 @@ export class FeishuRuntime {
188
191
  this.#groupTopicReply = groupTopicReply === true;
189
192
  this.#stepPush = stepPush === true;
190
193
  this.#stepPushMode = normalizeFeishuStepPushMode(stepPushMode);
194
+ // voice 为 { config, secret } 来源;凭据缺失时 createVoice 返回禁用对象,
195
+ // 桥接层完全跳过语音路径,不影响连接。
196
+ this.#voice = voice == null
197
+ ? null
198
+ : createVoice({ settings: voice.config, secret: voice.secret, logger });
191
199
  this.#sessionSyncTargetsFor = typeof sessionSyncTargetsFor === 'function'
192
200
  ? sessionSyncTargetsFor
193
201
  : null;
@@ -230,6 +238,13 @@ export class FeishuRuntime {
230
238
  this.#bridge?.setStepPushMode(this.#stepPushMode);
231
239
  }
232
240
 
241
+ setVoice(source) {
242
+ this.#voice = source == null
243
+ ? null
244
+ : createVoice({ settings: source.config, secret: source.secret, logger: this.#logger });
245
+ this.#bridge?.setVoice(this.#voice);
246
+ }
247
+
233
248
  async start() {
234
249
  while (true) {
235
250
  while (this.#stopping) await this.#stopping;
@@ -321,6 +336,7 @@ export class FeishuRuntime {
321
336
  groupTopicReply: this.#groupTopicReply,
322
337
  stepPush: this.#stepPush,
323
338
  stepPushMode: this.#stepPushMode,
339
+ voice: this.#voice,
324
340
  sessionSyncTargetsFor: this.#sessionSyncTargetsFor,
325
341
  repair: this.#repair,
326
342
  replyTimeoutMs: this.#replyTimeoutMs,
@@ -21,6 +21,7 @@ import {
21
21
  isFeishuStepPushMode,
22
22
  normalizeFeishuStepPushMode,
23
23
  } from './step-push-mode.mjs';
24
+ import { normalizeFeishuVoiceConfig } from './voice-config.mjs';
24
25
 
25
26
  const ACTIVE_REGISTRATION_STATES = new Set([
26
27
  'starting', 'qr_ready', 'polling', 'slow_down', 'domain_switched',
@@ -99,6 +100,7 @@ function configuredBotFingerprint(config) {
99
100
  groupTopicReply: config.groupTopicReply === true,
100
101
  stepPush: config.stepPush === true,
101
102
  stepPushMode: normalizeFeishuStepPushMode(config.stepPushMode),
103
+ voice: config.voice,
102
104
  groupMessagePermissionGranted: config.groupMessagePermissionGranted === true,
103
105
  deletionPending: config.deletionPending === true,
104
106
  connectedAt: config.connectedAt ?? null,
@@ -645,6 +647,25 @@ export class MultiBotDshFeishuController {
645
647
  }));
646
648
  }
647
649
 
650
+ async updateVoice(botId, voice) {
651
+ this.#assertOpen();
652
+ if (voice !== null && (typeof voice !== 'object' || Array.isArray(voice))) {
653
+ throw new TypeError('Invalid Feishu voice configuration');
654
+ }
655
+ const normalized = normalizeFeishuVoiceConfig(voice);
656
+ return this.#serializeConfig(() => this.#withBotTransition(botId, async () => {
657
+ const config = this.#requireBot(botId);
658
+ const saved = await this.#configStore.saveBot({ ...config, voice: normalized });
659
+ // 语音是可选的渠道能力:凭据缺失时静默降级为禁用,不打断配置保存。
660
+ const secret = normalized
661
+ ? await atConnectionStage('credential.read', () => this.#credentials.resolve(normalized.secretRef), 'credential-store').catch(() => null)
662
+ : null;
663
+ this.#runtimes.get(botId)?.setVoice?.({ config: normalized, secret: secret?.value ?? null });
664
+ this.#touch();
665
+ return this.status(botId);
666
+ }));
667
+ }
668
+
648
669
  async deleteBot(botId) {
649
670
  this.#assertOpen();
650
671
  return this.#serializeConfig(() => this.#withBotTransition(botId, async () => {
@@ -710,6 +731,7 @@ export class MultiBotDshFeishuController {
710
731
  groupTopicReply: config.groupTopicReply === true,
711
732
  stepPush: config.stepPush === true,
712
733
  stepPushMode: normalizeFeishuStepPushMode(config.stepPushMode),
734
+ voice: normalizeFeishuVoiceConfig(config.voice),
713
735
  groupMessagePermissionGranted: config.groupMessagePermissionGranted === true,
714
736
  bot: publicBot(config),
715
737
  connection,
@@ -3,6 +3,7 @@ import { mkdir, readFile, rename, unlink, writeFile } from 'node:fs/promises';
3
3
  import { dirname } from 'node:path';
4
4
  import { normalizeFeishuGroupResponseMode } from './group-response-mode.mjs';
5
5
  import { normalizeFeishuStepPushMode } from './step-push-mode.mjs';
6
+ import { normalizeFeishuVoiceConfig } from './voice-config.mjs';
6
7
 
7
8
  export const LEGACY_FEISHU_SECRET_REF = 'DSH_FEISHU_APP_SECRET';
8
9
 
@@ -49,6 +50,7 @@ function normalizeBot(value, { legacy = false } = {}) {
49
50
  groupTopicReply: value.groupTopicReply === true,
50
51
  stepPush: value.stepPush === true,
51
52
  stepPushMode: normalizeFeishuStepPushMode(value.stepPushMode),
53
+ voice: normalizeFeishuVoiceConfig(value.voice),
52
54
  groupMessagePermissionGranted: value.groupMessagePermissionGranted === true,
53
55
  deletionPending: value.deletionPending === true,
54
56
  connectedAt: cleanString(value.connectedAt),
@@ -0,0 +1,34 @@
1
+ // voice-config.mjs — 飞书语音配置的纯数据归一化(浏览器安全,无 Node 内置模块)。
2
+ // 运行时转写/合成能力见 voice.mjs;此处只负责把每机器人配置校验为固定形状,
3
+ // 供 host 侧配置存储、RPC 校验与 client 侧设置页共用。
4
+ export const DEFAULT_VOICE_SECRET_REF = 'DASHSCOPE_API_KEY';
5
+ export const DEFAULT_ASR_BASE_URL = 'https://dashscope.aliyuncs.com/compatible-mode/v1';
6
+ export const DEFAULT_ASR_MODEL = 'qwen3-asr-flash';
7
+ export const DEFAULT_TTS_MODEL = 'qwen3-tts-flash';
8
+ export const DEFAULT_TTS_VOICE = 'Momo';
9
+ export const TTS_ENDPOINT = 'https://dashscope.aliyuncs.com/api/v1/services/aigc/multimodal-generation/generation';
10
+ export const MAX_TTS_CHARS = 600;
11
+
12
+ function cleanString(value) {
13
+ return typeof value === 'string' && value.trim() ? value.trim() : null;
14
+ }
15
+
16
+ /**
17
+ * 归一化每机器人语音配置。语音是可选的渠道能力:关闭或配置不完整时返回 null,
18
+ * 与未配置行为完全一致。secretRef 走插件凭据库(与飞书 app secret 相同的环境变量
19
+ * 命名规范),密钥本身绝不进配置。
20
+ */
21
+ export function normalizeFeishuVoiceConfig(value) {
22
+ if (value == null || typeof value !== 'object' || value.enabled !== true) return null;
23
+ const secretRef = cleanString(value.secretRef);
24
+ if (!secretRef || !/^[A-Za-z_][A-Za-z0-9_]*$/.test(secretRef)) return null;
25
+ return Object.freeze({
26
+ enabled: true,
27
+ secretRef,
28
+ asrModel: cleanString(value.asrModel) ?? DEFAULT_ASR_MODEL,
29
+ ttsModel: cleanString(value.ttsModel) ?? DEFAULT_TTS_MODEL,
30
+ ttsVoice: cleanString(value.ttsVoice) ?? DEFAULT_TTS_VOICE,
31
+ asrBaseUrl: cleanString(value.asrBaseUrl) ?? DEFAULT_ASR_BASE_URL,
32
+ ffmpeg: cleanString(value.ffmpeg),
33
+ });
34
+ }
@@ -0,0 +1,134 @@
1
+ // voice.mjs — 飞书渠道语音能力运行时(渠道能力:语音输入与语音回复)
2
+ // 入站:音频消息 opus → ffmpeg 转 wav 16k → DashScope qwen3-asr-flash 转文字,
3
+ // 回流入正常文本流水线;转写失败走明确降级(保持原有"仅支持文字/图片/文件"提示)。
4
+ // 出站:同回合文字回复 → qwen3-tts-flash → ffmpeg 转 opus → 飞书音频消息,
5
+ // 优先回复原语音消息,reply 不可用时降级为普通音频消息。
6
+ // 凭据经插件凭据库(secretRef)在运行时注入;本模块不落盘、不把密钥写进日志。
7
+ // 配置归一化(浏览器安全)在 voice-config.mjs,供 client 侧设置页与 host 侧共用。
8
+ import { spawn } from 'node:child_process';
9
+ import { randomUUID } from 'node:crypto';
10
+ import { mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
11
+ import { tmpdir } from 'node:os';
12
+ import { join } from 'node:path';
13
+ import { MAX_TTS_CHARS, TTS_ENDPOINT, normalizeFeishuVoiceConfig } from './voice-config.mjs';
14
+
15
+ function runFfmpeg(bin, args) {
16
+ return new Promise((resolve, reject) => {
17
+ const p = spawn(bin, ['-hide_banner', '-loglevel', 'error', '-y', ...args]);
18
+ let err = '';
19
+ p.stderr.on('data', (d) => { err += d; if (err.length > 2000) err = err.slice(-2000); });
20
+ // 启动失败(未安装 ffmpeg、配置路径不存在或不可执行)时 spawn 触发
21
+ // 'error' 事件而非 'close';不监听会让未处理的 ENOENT 异常击穿 Node 进程。
22
+ // 这里改为正常 reject,由外层的转写/合成失败降级统一兜底。
23
+ p.on('error', reject);
24
+ p.on('close', (code) => (code === 0 ? resolve() : reject(new Error(`ffmpeg exit ${code}: ${err.slice(-300)}`))));
25
+ });
26
+ }
27
+
28
+ async function readResourceToBuffer(resource) {
29
+ if (typeof resource?.writeFile === 'function') {
30
+ const path = join(tmpdir(), `dsh-voice-in-${randomUUID()}.opus`);
31
+ await resource.writeFile(path);
32
+ const buf = readFileSync(path);
33
+ rmSync(path, { force: true });
34
+ return buf;
35
+ }
36
+ if (resource?.getReadableStream) {
37
+ const chunks = [];
38
+ for await (const chunk of resource.getReadableStream()) {
39
+ chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk));
40
+ }
41
+ return Buffer.concat(chunks);
42
+ }
43
+ throw new Error('消息资源无可用读取接口');
44
+ }
45
+
46
+ /**
47
+ * 创建语音能力对象。settings 为 normalizeFeishuVoiceConfig 的产物,secret 为
48
+ * 凭据库解析出的 DashScope api_key。两者任一缺失即返回 { enabled: false },
49
+ * 桥接层据此完全跳过语音路径。
50
+ */
51
+ export function createVoice({ settings, secret, logger } = {}) {
52
+ const config = normalizeFeishuVoiceConfig(settings);
53
+ const apiKey = typeof secret === 'string' && secret.trim() ? secret.trim() : null;
54
+ if (!config || !apiKey) {
55
+ if (config) logger?.warn?.('[dsh-feishu-voice] 语音凭据缺失,语音已禁用');
56
+ return Object.freeze({ enabled: false });
57
+ }
58
+ const frozen = Object.freeze({
59
+ apiKey,
60
+ asrBaseUrl: config.asrBaseUrl,
61
+ asrModel: config.asrModel,
62
+ ttsModel: config.ttsModel,
63
+ ttsVoice: config.ttsVoice,
64
+ ffmpeg: config.ffmpeg ?? 'ffmpeg',
65
+ });
66
+
67
+ async function transcribeIncoming(event, client) {
68
+ const fileKey = JSON.parse(event.message.content).file_key;
69
+ if (!fileKey) return null;
70
+ const resource = await client.im.v1.messageResource.get({
71
+ params: { type: 'file' },
72
+ path: { message_id: event.message.message_id, file_key: fileKey },
73
+ });
74
+ const opusBuf = await readResourceToBuffer(resource);
75
+ const work = join(tmpdir(), `dsh-voice-${randomUUID()}`);
76
+ mkdirSync(work, { recursive: true });
77
+ try {
78
+ const opusIn = join(work, 'in.opus');
79
+ const wavIn = join(work, 'in.wav');
80
+ writeFileSync(opusIn, opusBuf);
81
+ await runFfmpeg(frozen.ffmpeg, ['-i', opusIn, '-ar', '16000', '-ac', '1', wavIn]);
82
+ const b64 = readFileSync(wavIn).toString('base64');
83
+ // qwen3-asr-flash 走 compatible-mode chat/completions + input_audio data URI
84
+ const res = await fetch(`${frozen.asrBaseUrl}/chat/completions`, {
85
+ method: 'POST',
86
+ headers: { Authorization: `Bearer ${frozen.apiKey}`, 'Content-Type': 'application/json' },
87
+ body: JSON.stringify({
88
+ model: frozen.asrModel,
89
+ messages: [{
90
+ role: 'user',
91
+ content: [{ type: 'input_audio', input_audio: { data: `data:audio/wav;base64,${b64}` } }],
92
+ }],
93
+ }),
94
+ });
95
+ const text = await res.text();
96
+ if (!res.ok) throw new Error(`dashscope ASR ${res.status}: ${text.slice(0, 200)}`);
97
+ const j = JSON.parse(text);
98
+ const out = j?.choices?.[0]?.message?.content;
99
+ if (typeof out !== 'string' || !out.trim()) throw new Error('ASR 无文本输出');
100
+ return out.trim();
101
+ } finally {
102
+ rmSync(work, { recursive: true, force: true });
103
+ }
104
+ }
105
+
106
+ async function synthesize(text) {
107
+ const clean = String(text ?? '').replace(/\s+/g, ' ').trim().slice(0, MAX_TTS_CHARS);
108
+ if (!clean) return null;
109
+ // qwen3-tts-flash 走 DashScope 原生 multimodal-generation 接口(compatible-mode 不支持 TTS)
110
+ const res = await fetch(TTS_ENDPOINT, {
111
+ method: 'POST',
112
+ headers: { Authorization: `Bearer ${frozen.apiKey}`, 'Content-Type': 'application/json' },
113
+ body: JSON.stringify({ model: frozen.ttsModel, input: { text: clean, voice: frozen.ttsVoice } }),
114
+ });
115
+ const j = await res.json().catch(() => null);
116
+ const url = j?.output?.audio?.url;
117
+ if (!url) throw new Error(`TTS 无音频:${res.status} ${JSON.stringify(j ?? {}).slice(0, 160)}`);
118
+ const aud = await fetch(url);
119
+ if (!aud.ok) throw new Error(`TTS 音频下载 ${aud.status}`);
120
+ const work = join(tmpdir(), `dsh-voice-${randomUUID()}`);
121
+ mkdirSync(work, { recursive: true });
122
+ try {
123
+ const wavOut = join(work, 'out.wav');
124
+ const opusOut = join(work, 'out.opus');
125
+ writeFileSync(wavOut, Buffer.from(await aud.arrayBuffer()));
126
+ await runFfmpeg(frozen.ffmpeg, ['-i', wavOut, '-c:a', 'libopus', '-b:a', '48k', opusOut]);
127
+ return readFileSync(opusOut);
128
+ } finally {
129
+ rmSync(work, { recursive: true, force: true });
130
+ }
131
+ }
132
+
133
+ return Object.freeze({ enabled: true, transcribeIncoming, synthesize });
134
+ }