@xmanrui/dsh-im 4.28.0 → 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.
- package/README.en.md +9 -7
- package/README.md +9 -7
- package/lib/client.js +370 -115
- package/lib/index.js +298 -298
- package/package.json +5 -1
- package/plugin-src/client/channels/feishu/api.js +3 -0
- package/plugin-src/client/channels/feishu/voice-editor.js +115 -0
- package/plugin-src/client/channels/feishu/voice-settings.js +71 -0
- package/plugin-src/client/delivery-settings.js +8 -0
- package/plugin-src/client/i18n.js +16 -0
- package/plugin-src/client/styles.js +11 -0
- package/plugin-src/host/channels/dingtalk/production.mjs +1 -1
- package/plugin-src/host/channels/feishu/production.mjs +12 -1
- package/plugin-src/host/channels/feishu/rpc.mjs +21 -0
- package/plugin-src/host/channels/qq/production.mjs +1 -1
- package/plugin-src/host/channels/shared/production.mjs +1 -1
- package/plugin-src/host/channels/slack/production.mjs +1 -1
- package/plugin-src/host/channels/wecom/production.mjs +1 -1
- package/plugin-src/host/channels/wecom-app/production.mjs +1 -1
- package/plugin-src/host/channels/weixin/production.mjs +22 -5
- package/plugin-src/host/channels/whatsapp/production.mjs +1 -1
- package/src/channels/feishu/bridge.mjs +165 -12
- package/src/channels/feishu/feishu-cards.mjs +4 -2
- package/src/channels/feishu/feishu-runtime.mjs +16 -0
- package/src/channels/feishu/multi-bot-controller.mjs +22 -0
- package/src/channels/feishu/plugin-config-store.mjs +2 -0
- package/src/channels/feishu/voice-config.mjs +34 -0
- package/src/channels/feishu/voice.mjs +134 -0
- package/src/channels/shared/bot-workspace-store.mjs +30 -10
- package/src/channels/shared/default-workspace.mjs +6 -3
- package/src/channels/shared/harness-approval.mjs +24 -17
- package/src/channels/shared/harness-session-binding.mjs +4 -0
- package/src/channels/weixin/weixin-api.mjs +3 -2
|
@@ -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
|
-
|
|
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
|
-
*
|
|
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]
|
|
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,
|
|
@@ -4980,8 +5105,14 @@ export class FeishuHarnessBridge {
|
|
|
4980
5105
|
});
|
|
4981
5106
|
// 流式卡片模式:每轮一张过程卡(原地 patch),过程与最终答案都进卡;
|
|
4982
5107
|
// post 模式维持逐条直推。先预建卡片状态,纯问答回合也能在收尾时开卡。
|
|
4983
|
-
|
|
5108
|
+
// 飞书原生思考过程按 chat_id 创建,不会落进话题(#244);话题内的回合改用
|
|
5109
|
+
// 实时过程卡(以 reply_in_thread 回复留在话题内),避免过程出现在话题外。
|
|
5110
|
+
const liveCotInTopic = this.#stepPushMode === FEISHU_STEP_PUSH_MODES.LIVE_COT
|
|
5111
|
+
&& this.#replyInThreadFor(messageId);
|
|
5112
|
+
const streamingCard = this.#stepPushMode === FEISHU_STEP_PUSH_MODES.STREAMING_CARD
|
|
5113
|
+
|| liveCotInTopic;
|
|
4984
5114
|
const liveCot = this.#stepPushMode === FEISHU_STEP_PUSH_MODES.LIVE_COT
|
|
5115
|
+
&& !liveCotInTopic
|
|
4985
5116
|
&& typeof this.#channel?.createCot === 'function'
|
|
4986
5117
|
&& typeof this.#channel?.writeCotEvents === 'function';
|
|
4987
5118
|
const cot = liveCot
|
|
@@ -5162,6 +5293,9 @@ export class FeishuHarnessBridge {
|
|
|
5162
5293
|
providerMessageIds: seal.cardIds,
|
|
5163
5294
|
});
|
|
5164
5295
|
this.#status.streamResponses = (this.#status.streamResponses ?? 0) + 1;
|
|
5296
|
+
// 流式卡封存成功也是完整答案投递:文本进了卡片,语音回合同样要
|
|
5297
|
+
// 在此消费并合成音频回复——否则语音消息只得到卡片、没有声音。
|
|
5298
|
+
await this.#maybeSendVoiceReply(key, chatId, deliveryText, messageId);
|
|
5165
5299
|
const delivery = await this.#deliverArtifacts(
|
|
5166
5300
|
chatId,
|
|
5167
5301
|
messageId,
|
|
@@ -5211,6 +5345,7 @@ export class FeishuHarnessBridge {
|
|
|
5211
5345
|
providerMessageIds,
|
|
5212
5346
|
});
|
|
5213
5347
|
this.#status.streamResponses = (this.#status.streamResponses ?? 0) + 1;
|
|
5348
|
+
await this.#maybeSendVoiceReply(key, chatId, deliveryText, messageId);
|
|
5214
5349
|
} catch (error) {
|
|
5215
5350
|
this.#status.streamErrors = (this.#status.streamErrors ?? 0) + 1;
|
|
5216
5351
|
this.#logger.warn?.('[dsh-feishu] step push post failed; sending final text:', error.message);
|
|
@@ -5261,8 +5396,10 @@ export class FeishuHarnessBridge {
|
|
|
5261
5396
|
setLastMessageFailure(this.#status, textSendError);
|
|
5262
5397
|
}
|
|
5263
5398
|
this.#status.streamFallbacks = (this.#status.streamFallbacks ?? 0) + 1;
|
|
5399
|
+
await this.#maybeSendVoiceReply(key, chatId, deliveryText, messageId);
|
|
5264
5400
|
return { ...delivery, textDeliveryErrors: textSendError ? 1 : 0 };
|
|
5265
5401
|
}
|
|
5402
|
+
await this.#maybeSendVoiceReply(key, chatId, deliveryText, messageId);
|
|
5266
5403
|
const delivery = await this.#deliverArtifacts(
|
|
5267
5404
|
chatId,
|
|
5268
5405
|
messageId,
|
|
@@ -5330,6 +5467,7 @@ export class FeishuHarnessBridge {
|
|
|
5330
5467
|
error,
|
|
5331
5468
|
);
|
|
5332
5469
|
}
|
|
5470
|
+
await this.#maybeSendVoiceReply(key, chatId, answerTextForDelivery(answer, artifacts), messageId);
|
|
5333
5471
|
const delivery = await this.#deliverArtifacts(chatId, messageId, artifacts, textReceipt);
|
|
5334
5472
|
const artifactDispatched = delivery.receipt.artifacts.some(
|
|
5335
5473
|
({ outcome }) => outcome === 'sent' || outcome === 'unknown',
|
|
@@ -5396,6 +5534,7 @@ export class FeishuHarnessBridge {
|
|
|
5396
5534
|
completedAnswer = completed.answer;
|
|
5397
5535
|
completedArtifacts = completed.artifacts ?? [];
|
|
5398
5536
|
await controller.setContent(answerTextForDelivery(completedAnswer, completedArtifacts));
|
|
5537
|
+
await this.#maybeSendVoiceReply(key, chatId, answerTextForDelivery(completedAnswer, completedArtifacts), messageId);
|
|
5399
5538
|
},
|
|
5400
5539
|
}, {
|
|
5401
5540
|
replyTo: messageId,
|
|
@@ -5429,6 +5568,7 @@ export class FeishuHarnessBridge {
|
|
|
5429
5568
|
fallbackError,
|
|
5430
5569
|
);
|
|
5431
5570
|
}
|
|
5571
|
+
await this.#maybeSendVoiceReply(key, chatId, answerTextForDelivery(completedAnswer, completedArtifacts), messageId);
|
|
5432
5572
|
const delivery = await this.#deliverArtifacts(
|
|
5433
5573
|
chatId,
|
|
5434
5574
|
messageId,
|
|
@@ -5485,6 +5625,7 @@ export class FeishuHarnessBridge {
|
|
|
5485
5625
|
fallbackError,
|
|
5486
5626
|
);
|
|
5487
5627
|
}
|
|
5628
|
+
await this.#maybeSendVoiceReply(key, chatId, answerTextForDelivery(answer, artifacts), messageId);
|
|
5488
5629
|
const delivery = await this.#deliverArtifacts(chatId, messageId, artifacts, textReceipt);
|
|
5489
5630
|
const artifactDispatched = delivery.receipt.artifacts.some(
|
|
5490
5631
|
({ outcome }) => outcome === 'sent' || outcome === 'unknown',
|
|
@@ -5664,6 +5805,8 @@ export class FeishuHarnessBridge {
|
|
|
5664
5805
|
requiresMention,
|
|
5665
5806
|
replyToMessageId,
|
|
5666
5807
|
}) {
|
|
5808
|
+
let approvalCardMessageId = null;
|
|
5809
|
+
let approvalCardData = null;
|
|
5667
5810
|
if (await this.#approvals.handleRequested(interaction, {
|
|
5668
5811
|
key,
|
|
5669
5812
|
actor,
|
|
@@ -5675,15 +5818,16 @@ export class FeishuHarnessBridge {
|
|
|
5675
5818
|
...(this.#interactionCards
|
|
5676
5819
|
? {
|
|
5677
5820
|
render: async (pending) => {
|
|
5678
|
-
//
|
|
5679
|
-
|
|
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(
|
|
5680
5829
|
chatId,
|
|
5681
|
-
approvalCard(
|
|
5682
|
-
toolName: pending.toolCall?.name ?? pending.payload?.toolName,
|
|
5683
|
-
operation: operationArguments(pending.toolCall),
|
|
5684
|
-
reason: pending.payload?.reason,
|
|
5685
|
-
approvalId: pending.approvalId,
|
|
5686
|
-
}),
|
|
5830
|
+
approvalCard(approvalCardData),
|
|
5687
5831
|
{ key, replyTo: replyToMessageId },
|
|
5688
5832
|
).catch(async () => {
|
|
5689
5833
|
// Fall back to the plain-text approval if the card cannot be
|
|
@@ -5691,8 +5835,17 @@ export class FeishuHarnessBridge {
|
|
|
5691
5835
|
// the pending approval is not marked as presented and the
|
|
5692
5836
|
// existing retry/reconnect logic can run.
|
|
5693
5837
|
await this.#send(chatId, pending.text, { replyTo: replyToMessageId });
|
|
5838
|
+
return null;
|
|
5694
5839
|
});
|
|
5695
5840
|
},
|
|
5841
|
+
onResolved: async (resolvedText) => {
|
|
5842
|
+
if (!approvalCardMessageId) return;
|
|
5843
|
+
await this.#patchCardMessage(
|
|
5844
|
+
chatId,
|
|
5845
|
+
approvalCardMessageId,
|
|
5846
|
+
approvalCard({ ...approvalCardData, resolvedText }),
|
|
5847
|
+
);
|
|
5848
|
+
},
|
|
5696
5849
|
}
|
|
5697
5850
|
: {}),
|
|
5698
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
|
-
|
|
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
|
+
}
|