@xmanrui/dsh-im 4.27.0 → 4.28.1
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 +4 -4
- package/README.md +4 -4
- package/lib/client.js +1 -1
- package/lib/index.js +292 -288
- package/package.json +1 -1
- package/plugin-src/host/channels/dingtalk/production.mjs +1 -1
- package/plugin-src/host/channels/feishu/production.mjs +1 -1
- 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 +96 -98
- package/src/channels/feishu/feishu-cards.mjs +178 -7
- package/src/channels/feishu/message-utils.mjs +10 -1
- package/src/channels/shared/bot-workspace-store.mjs +30 -10
- package/src/channels/shared/default-workspace.mjs +6 -3
- package/src/channels/shared/harness-session-binding.mjs +4 -0
- package/src/channels/shared/i18n-en/feishu.mjs +1 -0
- package/src/channels/weixin/weixin-api.mjs +3 -2
package/package.json
CHANGED
|
@@ -63,7 +63,7 @@ export async function createProductionController(ctx, config = {}, internals = {
|
|
|
63
63
|
const { defaultWorkspace, ungroupedWorkspace } = await prepareBotWorkspace(config);
|
|
64
64
|
const WorkspaceStore = internals.WorkspaceStore ?? BotWorkspaceStore;
|
|
65
65
|
const workspaces = internals.workspaces
|
|
66
|
-
?? await new WorkspaceStore(paths.workspaces, { defaultWorkspace }).load();
|
|
66
|
+
?? await new WorkspaceStore(paths.workspaces, { defaultWorkspace, ungroupedWorkspace }).load();
|
|
67
67
|
const canListConfiguredBots = typeof configStore.list === 'function';
|
|
68
68
|
const configuredBots = canListConfiguredBots ? configStore.list() : [];
|
|
69
69
|
if (canListConfiguredBots) {
|
|
@@ -122,7 +122,7 @@ export async function createProductionController(ctx, config = {}, internals = {
|
|
|
122
122
|
const { defaultWorkspace, ungroupedWorkspace } = await prepareBotWorkspace(config);
|
|
123
123
|
const WorkspaceStore = internals.WorkspaceStore ?? BotWorkspaceStore;
|
|
124
124
|
const workspaces = internals.workspaces
|
|
125
|
-
?? await new WorkspaceStore(paths.workspaces, { defaultWorkspace }).load();
|
|
125
|
+
?? await new WorkspaceStore(paths.workspaces, { defaultWorkspace, ungroupedWorkspace }).load();
|
|
126
126
|
const canListConfiguredBots = typeof configStore.list === 'function';
|
|
127
127
|
const listConfiguredBots = () => canListConfiguredBots ? configStore.list() : [];
|
|
128
128
|
const configuredBots = listConfiguredBots();
|
|
@@ -60,7 +60,7 @@ export async function createProductionController(ctx, config = {}, internals = {
|
|
|
60
60
|
const { defaultWorkspace, ungroupedWorkspace } = await prepareBotWorkspace(config);
|
|
61
61
|
const WorkspaceStore = internals.WorkspaceStore ?? BotWorkspaceStore;
|
|
62
62
|
const workspaces = internals.workspaces
|
|
63
|
-
?? await new WorkspaceStore(paths.workspaces, { defaultWorkspace }).load();
|
|
63
|
+
?? await new WorkspaceStore(paths.workspaces, { defaultWorkspace, ungroupedWorkspace }).load();
|
|
64
64
|
const configuredBots = configStore.list();
|
|
65
65
|
await workspaces.reconcile(configuredBots.map((bot) => bot.botId));
|
|
66
66
|
await Promise.all(configuredBots.map((bot) => workspaces.ensure(bot.botId, {
|
|
@@ -70,7 +70,7 @@ export async function createTokenProductionController(ctx, config, internals, de
|
|
|
70
70
|
const { defaultWorkspace, ungroupedWorkspace } = await prepareBotWorkspace(config);
|
|
71
71
|
const WorkspaceStore = internals.WorkspaceStore ?? BotWorkspaceStore;
|
|
72
72
|
const workspaces = internals.workspaces
|
|
73
|
-
?? await new WorkspaceStore(paths.workspaces, { defaultWorkspace }).load();
|
|
73
|
+
?? await new WorkspaceStore(paths.workspaces, { defaultWorkspace, ungroupedWorkspace }).load();
|
|
74
74
|
const configuredBots = configStore.list();
|
|
75
75
|
await workspaces.reconcile(configuredBots.map((bot) => bot.botId));
|
|
76
76
|
await Promise.all(configuredBots.map((bot) => workspaces.ensure(bot.botId, {
|
|
@@ -49,7 +49,7 @@ export async function createProductionController(ctx, config = {}, internals = {
|
|
|
49
49
|
const { defaultWorkspace, ungroupedWorkspace } = await prepareBotWorkspace(config);
|
|
50
50
|
const WorkspaceStore = internals.WorkspaceStore ?? BotWorkspaceStore;
|
|
51
51
|
const workspaces = internals.workspaces
|
|
52
|
-
?? await new WorkspaceStore(paths.workspaces, { defaultWorkspace }).load();
|
|
52
|
+
?? await new WorkspaceStore(paths.workspaces, { defaultWorkspace, ungroupedWorkspace }).load();
|
|
53
53
|
const configuredBots = configStore.list();
|
|
54
54
|
await workspaces.reconcile(configuredBots.map((bot) => bot.botId));
|
|
55
55
|
await Promise.all(configuredBots.map((bot) => workspaces.ensure(bot.botId, {
|
|
@@ -60,7 +60,7 @@ export async function createProductionController(ctx, config = {}, internals = {
|
|
|
60
60
|
const { defaultWorkspace, ungroupedWorkspace } = await prepareBotWorkspace(config);
|
|
61
61
|
const WorkspaceStore = internals.WorkspaceStore ?? BotWorkspaceStore;
|
|
62
62
|
const workspaces = internals.workspaces
|
|
63
|
-
?? await new WorkspaceStore(paths.workspaces, { defaultWorkspace }).load();
|
|
63
|
+
?? await new WorkspaceStore(paths.workspaces, { defaultWorkspace, ungroupedWorkspace }).load();
|
|
64
64
|
const configuredBots = configStore.list();
|
|
65
65
|
await workspaces.reconcile(configuredBots.map((bot) => bot.botId));
|
|
66
66
|
await Promise.all(configuredBots.map((bot) => workspaces.ensure(bot.botId, {
|
|
@@ -74,7 +74,7 @@ export async function createProductionController(ctx, config = {}, internals = {
|
|
|
74
74
|
const { defaultWorkspace, ungroupedWorkspace } = await prepareBotWorkspace(config);
|
|
75
75
|
const WorkspaceStore = internals.WorkspaceStore ?? BotWorkspaceStore;
|
|
76
76
|
const workspaces = internals.workspaces
|
|
77
|
-
?? await new WorkspaceStore(paths.workspaces, { defaultWorkspace }).load();
|
|
77
|
+
?? await new WorkspaceStore(paths.workspaces, { defaultWorkspace, ungroupedWorkspace }).load();
|
|
78
78
|
const configuredBots = configStore.list();
|
|
79
79
|
await workspaces.reconcile(configuredBots.map((bot) => bot.botId));
|
|
80
80
|
await Promise.all(configuredBots.map((bot) => workspaces.ensure(bot.botId, {
|
|
@@ -2,6 +2,7 @@ import { getImageInputSettingsStore } from '../../../../src/channels/shared/imag
|
|
|
2
2
|
import { unlink } from 'node:fs/promises';
|
|
3
3
|
import { homedir } from 'node:os';
|
|
4
4
|
import { join, resolve } from 'node:path';
|
|
5
|
+
import { EnvHttpProxyAgent, fetch as undiciFetch } from 'undici';
|
|
5
6
|
|
|
6
7
|
import { WeixinConfigStore } from '../../../../src/channels/weixin/config-store.mjs';
|
|
7
8
|
import { HarnessClient } from '../../../../src/channels/weixin/harness-client.mjs';
|
|
@@ -83,7 +84,14 @@ export async function createProductionController(ctx, config = {}, internals = {
|
|
|
83
84
|
const Harness = internals.HarnessClient ?? HarnessClient;
|
|
84
85
|
const Controller = internals.Controller ?? WeixinController;
|
|
85
86
|
const Runtime = internals.Runtime ?? WeixinRuntime;
|
|
86
|
-
|
|
87
|
+
let uploadDispatcher;
|
|
88
|
+
const api = internals.api ?? createWeixinApi({
|
|
89
|
+
uploadFetchImpl: (url, options) => {
|
|
90
|
+
// Keep CDN response headers independent of the host's global dispatcher.
|
|
91
|
+
uploadDispatcher ??= new EnvHttpProxyAgent();
|
|
92
|
+
return undiciFetch(url, { ...options, dispatcher: uploadDispatcher });
|
|
93
|
+
},
|
|
94
|
+
});
|
|
87
95
|
const createSupervisor = internals.createConnectionSupervisor ?? createConnectionSupervisor;
|
|
88
96
|
const logger = typeof ctx.logger === 'function'
|
|
89
97
|
? ctx.logger('dsh-weixin')
|
|
@@ -95,7 +103,7 @@ export async function createProductionController(ctx, config = {}, internals = {
|
|
|
95
103
|
const { defaultWorkspace, ungroupedWorkspace } = await prepareBotWorkspace(config);
|
|
96
104
|
const WorkspaceStore = internals.WorkspaceStore ?? BotWorkspaceStore;
|
|
97
105
|
const workspaces = diagnosticWorkspaces(internals.workspaces
|
|
98
|
-
?? await new WorkspaceStore(paths.workspaces, { defaultWorkspace }).load());
|
|
106
|
+
?? await new WorkspaceStore(paths.workspaces, { defaultWorkspace, ungroupedWorkspace }).load());
|
|
99
107
|
const configuredBots = configStore.list();
|
|
100
108
|
await workspaces.reconcile(configuredBots.map((bot) => bot.botId));
|
|
101
109
|
await Promise.all(configuredBots.map((bot) => workspaces.ensure(bot.botId, {
|
|
@@ -232,9 +240,18 @@ export async function createProductionController(ctx, config = {}, internals = {
|
|
|
232
240
|
}),
|
|
233
241
|
ready: supervisor.ready,
|
|
234
242
|
async close() {
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
243
|
+
try {
|
|
244
|
+
await supervisor.close();
|
|
245
|
+
await controller.close();
|
|
246
|
+
} finally {
|
|
247
|
+
const dispatcher = uploadDispatcher;
|
|
248
|
+
uploadDispatcher = undefined;
|
|
249
|
+
try {
|
|
250
|
+
await dispatcher?.destroy();
|
|
251
|
+
} finally {
|
|
252
|
+
harness.stopManagedProcess();
|
|
253
|
+
}
|
|
254
|
+
}
|
|
238
255
|
},
|
|
239
256
|
};
|
|
240
257
|
}
|
|
@@ -70,7 +70,7 @@ export async function createProductionController(ctx, config = {}, internals = {
|
|
|
70
70
|
const { defaultWorkspace, ungroupedWorkspace } = await prepareBotWorkspace(config);
|
|
71
71
|
const WorkspaceStore = internals.WorkspaceStore ?? BotWorkspaceStore;
|
|
72
72
|
const workspaces = internals.workspaces
|
|
73
|
-
?? await new WorkspaceStore(paths.workspaces, { defaultWorkspace }).load();
|
|
73
|
+
?? await new WorkspaceStore(paths.workspaces, { defaultWorkspace, ungroupedWorkspace }).load();
|
|
74
74
|
const configuredBots = configStore.list();
|
|
75
75
|
await workspaces.reconcile(configuredBots.map((bot) => bot.botId));
|
|
76
76
|
await Promise.all(configuredBots.map((bot) => workspaces.ensure(bot.botId, {
|
|
@@ -305,6 +305,32 @@ function isFeishuLocalCommand(text, { hasImages = false, hasFiles = false } = {}
|
|
|
305
305
|
|| ARCHIVED_COMMAND.test(command);
|
|
306
306
|
}
|
|
307
307
|
|
|
308
|
+
/**
|
|
309
|
+
* A p2p text message that is nothing but a mention of the bot.
|
|
310
|
+
*
|
|
311
|
+
* The mention is stripped before we see the text, so `@bot` arrives as an empty
|
|
312
|
+
* body — indistinguishable from an empty message unless the message type is
|
|
313
|
+
* checked. Deliberately narrow:
|
|
314
|
+
*
|
|
315
|
+
* - only `p2p`, where the reader is talking to the bot directly;
|
|
316
|
+
* - only `text`, so a voice note, video, sticker or forwarded card keeps its
|
|
317
|
+
* own "not supported" notice instead of silently opening a menu;
|
|
318
|
+
* - only when nothing else came with it, so `@bot 你好` stays a prompt.
|
|
319
|
+
*
|
|
320
|
+
* The caller also treats this as a command for access control, because opening
|
|
321
|
+
* the menu is `/m` by another route and carries the same information.
|
|
322
|
+
*/
|
|
323
|
+
function isBareMentionMenuRequest(event, text, {
|
|
324
|
+
hasImages = false,
|
|
325
|
+
hasFiles = false,
|
|
326
|
+
hasReply = false,
|
|
327
|
+
} = {}) {
|
|
328
|
+
if (event?.message?.chat_type !== 'p2p') return false;
|
|
329
|
+
if (event?.message?.message_type !== 'text') return false;
|
|
330
|
+
if (hasImages || hasFiles || hasReply) return false;
|
|
331
|
+
return !String(text ?? '').trim();
|
|
332
|
+
}
|
|
333
|
+
|
|
308
334
|
/** Canonical workspace/session help advertised by every bridge family. */
|
|
309
335
|
const WORKSPACE_HELP_LINES = [
|
|
310
336
|
'/workspace 工作区序号或绝对路径 切换工作区',
|
|
@@ -989,7 +1015,16 @@ export class FeishuHarnessBridge {
|
|
|
989
1015
|
hasImages,
|
|
990
1016
|
hasFiles,
|
|
991
1017
|
}) || isFeishuLocalCommand(commandText, { hasImages, hasFiles })
|
|
992
|
-
|| (!hasImages && !hasFiles && NUMBER_REPLY.test(commandText) && this.#menus.has(key))
|
|
1018
|
+
|| (!hasImages && !hasFiles && NUMBER_REPLY.test(commandText) && this.#menus.has(key))
|
|
1019
|
+
// Opening the menu on a bare mention is the `/m` command by another
|
|
1020
|
+
// route, so it must clear the same command gate. Treating it as plain
|
|
1021
|
+
// chat let a sender without `canExecuteCommands` receive a menu that
|
|
1022
|
+
// carries workspace paths and other session titles.
|
|
1023
|
+
|| isBareMentionMenuRequest(event, commandText, {
|
|
1024
|
+
hasImages,
|
|
1025
|
+
hasFiles,
|
|
1026
|
+
hasReply: hasReplyReference(commandMessage),
|
|
1027
|
+
}),
|
|
993
1028
|
});
|
|
994
1029
|
if (!access.allowed) {
|
|
995
1030
|
this.#acceptedMessageIds.set(messageId, null);
|
|
@@ -1468,6 +1503,14 @@ export class FeishuHarnessBridge {
|
|
|
1468
1503
|
// accept() 侧已用 nonEmptyString(content) 判定,两侧保持一致。
|
|
1469
1504
|
const commandText = !hasImages && !hasFiles && text ? text.trim() : null;
|
|
1470
1505
|
if (!text && !hasImages && !hasFiles && !hasReply) {
|
|
1506
|
+
// An "@bot" with nothing else carries no instruction to parse, and the
|
|
1507
|
+
// menu card is what the reader is reaching for — answer it the way `/m`
|
|
1508
|
+
// does rather than with a "text only" notice. Kept to plain p2p text so a
|
|
1509
|
+
// voice note, video or forwarded card still gets its own notice.
|
|
1510
|
+
if (isBareMentionMenuRequest(event, text, { hasImages, hasFiles, hasReply })) {
|
|
1511
|
+
await this.#sendMenuCard(key, event.message.chat_id, { replyTo: event.message.message_id });
|
|
1512
|
+
return;
|
|
1513
|
+
}
|
|
1471
1514
|
await this.#send(event.message.chat_id, t('目前支持文字、图片和文件消息。'), { replyTo: event.message.message_id });
|
|
1472
1515
|
return;
|
|
1473
1516
|
}
|
|
@@ -3755,6 +3798,7 @@ export class FeishuHarnessBridge {
|
|
|
3755
3798
|
Object.assign(card, {
|
|
3756
3799
|
blocks: structuredClone(entry.blocks), cardIds: [...entry.cardIds],
|
|
3757
3800
|
messageId: entry.cardIds.at(-1), chunkCount: entry.cardIds.length,
|
|
3801
|
+
activeCardStart: 0, renderedCardContents: new Map(),
|
|
3758
3802
|
answerStart: entry.answerStart ?? null, answerEnd: entry.answerEnd ?? null,
|
|
3759
3803
|
deliveryViaOpenId: true,
|
|
3760
3804
|
});
|
|
@@ -4266,6 +4310,8 @@ export class FeishuHarnessBridge {
|
|
|
4266
4310
|
renderChain: null,
|
|
4267
4311
|
chunkCount: 1,
|
|
4268
4312
|
cardIds: [],
|
|
4313
|
+
activeCardStart: 0,
|
|
4314
|
+
renderedCardContents: new Map(),
|
|
4269
4315
|
answerStart: null,
|
|
4270
4316
|
answerEnd: null,
|
|
4271
4317
|
// Answer-integrity tracking: the version bumps every time the draft
|
|
@@ -4416,62 +4462,51 @@ export class FeishuHarnessBridge {
|
|
|
4416
4462
|
});
|
|
4417
4463
|
}
|
|
4418
4464
|
|
|
4465
|
+
/** Sync only this interaction's cards; earlier interactions remain sealed. */
|
|
4466
|
+
async #syncStepCardChunks(card, chunks, status) {
|
|
4467
|
+
const answerVersion = card.answerVersion ?? 0;
|
|
4468
|
+
const activeIds = card.cardIds.slice(card.activeCardStart);
|
|
4469
|
+
const groups = chunks.length ? [...chunks] : [[]];
|
|
4470
|
+
// Retain the last message as the live card when a rewritten answer shrinks.
|
|
4471
|
+
// Clear stale prefixes without introducing a separate deletion/retry path.
|
|
4472
|
+
while (groups.length < activeIds.length) {
|
|
4473
|
+
groups.unshift([{ kind: 'message', text: t('内容已合并至后续消息。') }]);
|
|
4474
|
+
}
|
|
4475
|
+
// Serialize before the first await: a concurrent draft must not change the
|
|
4476
|
+
// payload halfway through this render or be marked as already delivered.
|
|
4477
|
+
const contents = groups.map((blocks, index) => stepStreamCard(blocks, {
|
|
4478
|
+
status: index === groups.length - 1 ? status : 'sealed',
|
|
4479
|
+
}));
|
|
4480
|
+
for (let index = 0; index < contents.length; index += 1) {
|
|
4481
|
+
let id = activeIds[index];
|
|
4482
|
+
const content = contents[index];
|
|
4483
|
+
if (id) {
|
|
4484
|
+
if (card.renderedCardContents.get(id) !== content) {
|
|
4485
|
+
await this.#patchStepCard(id, content);
|
|
4486
|
+
}
|
|
4487
|
+
} else {
|
|
4488
|
+
id = await this.#sendCard(card.chatId, content, card.deliveryViaOpenId
|
|
4489
|
+
? { receiveIdType: 'open_id' }
|
|
4490
|
+
: { replyTo: card.replyToMessageId });
|
|
4491
|
+
card.cardIds.push(id);
|
|
4492
|
+
activeIds.push(id);
|
|
4493
|
+
}
|
|
4494
|
+
card.renderedCardContents.set(id, content);
|
|
4495
|
+
}
|
|
4496
|
+
card.messageId = activeIds.at(-1);
|
|
4497
|
+
card.chunkCount = activeIds.length;
|
|
4498
|
+
card.renderedAnswerVersion = answerVersion;
|
|
4499
|
+
}
|
|
4500
|
+
|
|
4419
4501
|
async #renderStepCardNow(chatId, card) {
|
|
4420
4502
|
if (card.broken) return;
|
|
4421
4503
|
const chunks = splitStepStreamCardBlocks(card.blocks);
|
|
4504
|
+
if (!chunks.length && card.messageId === null) return;
|
|
4422
4505
|
const live = chunks[chunks.length - 1] ?? [];
|
|
4423
4506
|
try {
|
|
4424
|
-
|
|
4425
|
-
// First render: deliver every chunk. When the turn opens with a long
|
|
4426
|
-
// answer already in memory, chunks.length can exceed one — sending
|
|
4427
|
-
// only the live chunk here would silently drop its prefix, and the
|
|
4428
|
-
// chunkCount bookkeeping below would then rewrite the delivered
|
|
4429
|
-
// message with chunk 0 on the next render.
|
|
4430
|
-
for (let index = 0; index < chunks.length; index += 1) {
|
|
4431
|
-
const isLive = index === chunks.length - 1;
|
|
4432
|
-
const id = await this.#sendCard(
|
|
4433
|
-
chatId,
|
|
4434
|
-
stepStreamCard(chunks[index], { status: isLive ? 'running' : 'sealed' }),
|
|
4435
|
-
card.deliveryViaOpenId
|
|
4436
|
-
? { receiveIdType: 'open_id' }
|
|
4437
|
-
: { replyTo: card.replyToMessageId },
|
|
4438
|
-
);
|
|
4439
|
-
card.cardIds.push(id);
|
|
4440
|
-
if (isLive) card.messageId = id;
|
|
4441
|
-
}
|
|
4442
|
-
card.chunkCount = chunks.length;
|
|
4443
|
-
await this.#persistMirrorState(card, live, 'running');
|
|
4444
|
-
card.lastRenderAt = this.#stepPushClock.now();
|
|
4445
|
-
card.renderedAnswerVersion = card.answerVersion ?? 0;
|
|
4446
|
-
return;
|
|
4447
|
-
}
|
|
4448
|
-
// Overflow: the accumulated blocks outgrew one card. Seal the current
|
|
4449
|
-
// message (no status line), spill extra chunks, and keep the last one
|
|
4450
|
-
// live. Chunk boundaries are stable because blocks only append.
|
|
4451
|
-
if (chunks.length > card.chunkCount) {
|
|
4452
|
-
await this.#patchStepCard(
|
|
4453
|
-
card.messageId,
|
|
4454
|
-
stepStreamCard(chunks[card.chunkCount - 1], { status: 'sealed' }),
|
|
4455
|
-
);
|
|
4456
|
-
for (let index = card.chunkCount; index < chunks.length; index += 1) {
|
|
4457
|
-
const isLive = index === chunks.length - 1;
|
|
4458
|
-
const id = await this.#sendCard(
|
|
4459
|
-
chatId,
|
|
4460
|
-
stepStreamCard(chunks[index], { status: isLive ? 'running' : 'sealed' }),
|
|
4461
|
-
card.deliveryViaOpenId
|
|
4462
|
-
? { receiveIdType: 'open_id' }
|
|
4463
|
-
: { replyTo: card.replyToMessageId },
|
|
4464
|
-
);
|
|
4465
|
-
card.cardIds.push(id);
|
|
4466
|
-
if (isLive) card.messageId = id;
|
|
4467
|
-
}
|
|
4468
|
-
card.chunkCount = chunks.length;
|
|
4469
|
-
} else {
|
|
4470
|
-
await this.#patchStepCard(card.messageId, stepStreamCard(live, { status: 'running' }));
|
|
4471
|
-
}
|
|
4507
|
+
await this.#syncStepCardChunks(card, chunks, 'running');
|
|
4472
4508
|
await this.#persistMirrorState(card, live, 'running');
|
|
4473
4509
|
card.lastRenderAt = this.#stepPushClock.now();
|
|
4474
|
-
card.renderedAnswerVersion = card.answerVersion ?? 0;
|
|
4475
4510
|
} catch (error) {
|
|
4476
4511
|
card.broken = true;
|
|
4477
4512
|
this.#logger.warn?.(
|
|
@@ -4510,52 +4545,8 @@ export class FeishuHarnessBridge {
|
|
|
4510
4545
|
this.#writeStepCardAnswer(card, body);
|
|
4511
4546
|
}
|
|
4512
4547
|
const chunks = splitStepStreamCardBlocks(card.blocks);
|
|
4513
|
-
const live = chunks[chunks.length - 1] ?? [];
|
|
4514
4548
|
try {
|
|
4515
|
-
|
|
4516
|
-
// A turn without any queued render (plain Q&A): open the cards
|
|
4517
|
-
// directly with their terminal status; every overflow chunk before
|
|
4518
|
-
// the last one is sealed. An empty turn still opens the status card.
|
|
4519
|
-
const groups = chunks.length > 0 ? chunks : [[]];
|
|
4520
|
-
for (let index = 0; index < groups.length; index += 1) {
|
|
4521
|
-
const isLive = index === groups.length - 1;
|
|
4522
|
-
const id = await this.#sendCard(
|
|
4523
|
-
card.chatId,
|
|
4524
|
-
stepStreamCard(groups[index], { status: isLive ? status : 'sealed' }),
|
|
4525
|
-
card.deliveryViaOpenId
|
|
4526
|
-
? { receiveIdType: 'open_id' }
|
|
4527
|
-
: { replyTo: card.replyToMessageId },
|
|
4528
|
-
);
|
|
4529
|
-
card.cardIds.push(id);
|
|
4530
|
-
}
|
|
4531
|
-
return { ok: true, cardIds: card.cardIds };
|
|
4532
|
-
}
|
|
4533
|
-
// The live message only ever shows the last chunk; spill every chunk
|
|
4534
|
-
// that has not been delivered yet as sealed cards before patching the
|
|
4535
|
-
// live one, otherwise a long answer finishing on an existing card
|
|
4536
|
-
// would silently drop its prefix.
|
|
4537
|
-
if (chunks.length > card.chunkCount) {
|
|
4538
|
-
await this.#patchStepCard(
|
|
4539
|
-
card.messageId,
|
|
4540
|
-
stepStreamCard(chunks[card.chunkCount - 1], { status: 'sealed' }),
|
|
4541
|
-
);
|
|
4542
|
-
for (let index = card.chunkCount; index < chunks.length; index += 1) {
|
|
4543
|
-
const isLast = index === chunks.length - 1;
|
|
4544
|
-
const id = await this.#sendCard(
|
|
4545
|
-
card.chatId,
|
|
4546
|
-
stepStreamCard(chunks[index], { status: isLast ? status : 'sealed' }),
|
|
4547
|
-
card.deliveryViaOpenId
|
|
4548
|
-
? { receiveIdType: 'open_id' }
|
|
4549
|
-
: { replyTo: card.replyToMessageId },
|
|
4550
|
-
);
|
|
4551
|
-
card.cardIds.push(id);
|
|
4552
|
-
if (isLast) card.messageId = id;
|
|
4553
|
-
}
|
|
4554
|
-
card.chunkCount = chunks.length;
|
|
4555
|
-
} else {
|
|
4556
|
-
await this.#patchStepCard(card.messageId, stepStreamCard(live, { status }));
|
|
4557
|
-
}
|
|
4558
|
-
card.renderedAnswerVersion = card.answerVersion ?? 0;
|
|
4549
|
+
await this.#syncStepCardChunks(card, chunks, status);
|
|
4559
4550
|
return { ok: true, cardIds: card.cardIds };
|
|
4560
4551
|
} catch (error) {
|
|
4561
4552
|
// Only trust the streamed draft when the LAST SUCCESSFUL render actually
|
|
@@ -4606,8 +4597,7 @@ export class FeishuHarnessBridge {
|
|
|
4606
4597
|
if (card.messageId === null) return;
|
|
4607
4598
|
try {
|
|
4608
4599
|
const chunks = splitStepStreamCardBlocks(card.blocks);
|
|
4609
|
-
|
|
4610
|
-
await this.#patchStepCard(card.messageId, stepStreamCard(live, { status: 'sealed' }));
|
|
4600
|
+
await this.#syncStepCardChunks(card, chunks, 'sealed');
|
|
4611
4601
|
} catch (error) {
|
|
4612
4602
|
this.#logger.warn?.(
|
|
4613
4603
|
'[dsh-feishu] step streaming card rotate seal failed:',
|
|
@@ -4615,6 +4605,8 @@ export class FeishuHarnessBridge {
|
|
|
4615
4605
|
);
|
|
4616
4606
|
}
|
|
4617
4607
|
// Restart the stream on a fresh card below the interaction message.
|
|
4608
|
+
card.activeCardStart = card.cardIds.length;
|
|
4609
|
+
card.renderedCardContents.clear();
|
|
4618
4610
|
card.blocks = [];
|
|
4619
4611
|
card.messageId = null;
|
|
4620
4612
|
card.chunkCount = 1;
|
|
@@ -4988,8 +4980,14 @@ export class FeishuHarnessBridge {
|
|
|
4988
4980
|
});
|
|
4989
4981
|
// 流式卡片模式:每轮一张过程卡(原地 patch),过程与最终答案都进卡;
|
|
4990
4982
|
// post 模式维持逐条直推。先预建卡片状态,纯问答回合也能在收尾时开卡。
|
|
4991
|
-
|
|
4983
|
+
// 飞书原生思考过程按 chat_id 创建,不会落进话题(#244);话题内的回合改用
|
|
4984
|
+
// 实时过程卡(以 reply_in_thread 回复留在话题内),避免过程出现在话题外。
|
|
4985
|
+
const liveCotInTopic = this.#stepPushMode === FEISHU_STEP_PUSH_MODES.LIVE_COT
|
|
4986
|
+
&& this.#replyInThreadFor(messageId);
|
|
4987
|
+
const streamingCard = this.#stepPushMode === FEISHU_STEP_PUSH_MODES.STREAMING_CARD
|
|
4988
|
+
|| liveCotInTopic;
|
|
4992
4989
|
const liveCot = this.#stepPushMode === FEISHU_STEP_PUSH_MODES.LIVE_COT
|
|
4990
|
+
&& !liveCotInTopic
|
|
4993
4991
|
&& typeof this.#channel?.createCot === 'function'
|
|
4994
4992
|
&& typeof this.#channel?.writeCotEvents === 'function';
|
|
4995
4993
|
const cot = liveCot
|
|
@@ -1009,20 +1009,191 @@ export function stepStatusText(status) {
|
|
|
1009
1009
|
return t('运行中');
|
|
1010
1010
|
}
|
|
1011
1011
|
|
|
1012
|
+
/**
|
|
1013
|
+
* A single card accepts only this many `table` elements. Feishu rejects the
|
|
1014
|
+
* whole write with `230099 / ErrCode 11310 card table number over limit`, which
|
|
1015
|
+
* leaves the card stuck on its previous content, so the reply never appears.
|
|
1016
|
+
*
|
|
1017
|
+
* The boundary was established by the maintainer against a live bot: five
|
|
1018
|
+
* tables in one card succeeded, six failed, and a folded process panel's tables
|
|
1019
|
+
* counted toward the total (3 folded + 2 in the body succeeded, 3 + 3 failed).
|
|
1020
|
+
*/
|
|
1021
|
+
export const STEP_STREAM_CARD_MAX_TABLES = 5;
|
|
1022
|
+
|
|
1023
|
+
function hasClosingBacktickRun(text, start, length) {
|
|
1024
|
+
for (let index = start; index < text.length;) {
|
|
1025
|
+
if (text[index] !== '`') {
|
|
1026
|
+
index += 1;
|
|
1027
|
+
continue;
|
|
1028
|
+
}
|
|
1029
|
+
let end = index + 1;
|
|
1030
|
+
while (text[end] === '`') end += 1;
|
|
1031
|
+
if (end - index === length) return true;
|
|
1032
|
+
index = end;
|
|
1033
|
+
}
|
|
1034
|
+
return false;
|
|
1035
|
+
}
|
|
1036
|
+
|
|
1037
|
+
function gfmTableCells(line) {
|
|
1038
|
+
const normalized = line.endsWith('\r') ? line.slice(0, -1) : line;
|
|
1039
|
+
if (normalized.startsWith(' ') || normalized.startsWith('\t')) return null;
|
|
1040
|
+
const text = normalized.trim();
|
|
1041
|
+
if (!text) return null;
|
|
1042
|
+
|
|
1043
|
+
const cells = [];
|
|
1044
|
+
let cell = '';
|
|
1045
|
+
let separators = 0;
|
|
1046
|
+
let codeRun = 0;
|
|
1047
|
+
for (let index = 0; index < text.length;) {
|
|
1048
|
+
const character = text[index];
|
|
1049
|
+
if (character === '\\' && index + 1 < text.length) {
|
|
1050
|
+
cell += text.slice(index, index + 2);
|
|
1051
|
+
index += 2;
|
|
1052
|
+
continue;
|
|
1053
|
+
}
|
|
1054
|
+
if (character === '`') {
|
|
1055
|
+
let end = index + 1;
|
|
1056
|
+
while (text[end] === '`') end += 1;
|
|
1057
|
+
const length = end - index;
|
|
1058
|
+
if (codeRun === length) {
|
|
1059
|
+
codeRun = 0;
|
|
1060
|
+
} else if (codeRun === 0 && hasClosingBacktickRun(text, end, length)) {
|
|
1061
|
+
codeRun = length;
|
|
1062
|
+
}
|
|
1063
|
+
cell += text.slice(index, end);
|
|
1064
|
+
index = end;
|
|
1065
|
+
continue;
|
|
1066
|
+
}
|
|
1067
|
+
if (character === '|' && codeRun === 0) {
|
|
1068
|
+
cells.push(cell);
|
|
1069
|
+
cell = '';
|
|
1070
|
+
separators += 1;
|
|
1071
|
+
index += 1;
|
|
1072
|
+
continue;
|
|
1073
|
+
}
|
|
1074
|
+
cell += character;
|
|
1075
|
+
index += 1;
|
|
1076
|
+
}
|
|
1077
|
+
if (separators === 0) return null;
|
|
1078
|
+
cells.push(cell);
|
|
1079
|
+
if (cells[0].trim() === '') cells.shift();
|
|
1080
|
+
if (cells.at(-1)?.trim() === '') cells.pop();
|
|
1081
|
+
return cells.length > 0 ? cells : null;
|
|
1082
|
+
}
|
|
1083
|
+
|
|
1084
|
+
/** Locate table headers once for both counting and splitting. */
|
|
1085
|
+
function markdownTableStarts(text) {
|
|
1086
|
+
if (typeof text !== 'string' || !text) return [];
|
|
1087
|
+
const lines = text.split('\n');
|
|
1088
|
+
const offsets = [];
|
|
1089
|
+
let offset = 0;
|
|
1090
|
+
for (const line of lines) {
|
|
1091
|
+
offsets.push(offset);
|
|
1092
|
+
offset += line.length + 1;
|
|
1093
|
+
}
|
|
1094
|
+
const starts = [];
|
|
1095
|
+
let fence = null;
|
|
1096
|
+
for (let index = 0; index < lines.length; index += 1) {
|
|
1097
|
+
const line = lines[index].replace(/\r$/, '');
|
|
1098
|
+
if (fence) {
|
|
1099
|
+
const close = /^ {0,3}(`{3,}|~{3,})[ \t]*$/.exec(line);
|
|
1100
|
+
if (close && close[1][0] === fence[0] && close[1].length >= fence.length) fence = null;
|
|
1101
|
+
continue;
|
|
1102
|
+
}
|
|
1103
|
+
const opening = /^ {0,3}(`{3,}|~{3,})(.*)$/.exec(line);
|
|
1104
|
+
if (opening && (opening[1][0] !== '`' || !opening[2].includes('`'))) {
|
|
1105
|
+
fence = opening[1];
|
|
1106
|
+
continue;
|
|
1107
|
+
}
|
|
1108
|
+
const header = gfmTableCells(line);
|
|
1109
|
+
const delimiter = gfmTableCells(lines[index + 1] ?? '');
|
|
1110
|
+
if (!header || !delimiter || header.length !== delimiter.length
|
|
1111
|
+
|| !delimiter.every((cell) => /^\s*:?-+:?\s*$/.test(cell))) continue;
|
|
1112
|
+
starts.push(offsets[index]);
|
|
1113
|
+
index += 1;
|
|
1114
|
+
// A delimiter-looking data row is part of this table, not another header.
|
|
1115
|
+
while (index + 1 < lines.length && gfmTableCells(lines[index + 1])) index += 1;
|
|
1116
|
+
}
|
|
1117
|
+
return starts;
|
|
1118
|
+
}
|
|
1119
|
+
|
|
1120
|
+
export function countMarkdownTables(text) {
|
|
1121
|
+
return markdownTableStarts(text).length;
|
|
1122
|
+
}
|
|
1123
|
+
|
|
1124
|
+
function splitMarkdownByTableLimit(text, maxTables) {
|
|
1125
|
+
const starts = markdownTableStarts(text);
|
|
1126
|
+
const parts = [];
|
|
1127
|
+
let from = 0;
|
|
1128
|
+
for (let index = maxTables; index < starts.length; index += maxTables) {
|
|
1129
|
+
parts.push(text.slice(from, starts[index]));
|
|
1130
|
+
from = starts[index];
|
|
1131
|
+
}
|
|
1132
|
+
parts.push(text.slice(from));
|
|
1133
|
+
return parts;
|
|
1134
|
+
}
|
|
1135
|
+
|
|
1136
|
+
/** Count the Markdown that stepStreamCard actually renders. */
|
|
1137
|
+
function stepStreamCardTableCount(blocks) {
|
|
1138
|
+
return blocks.reduce((sum, block) => {
|
|
1139
|
+
const text = block?.kind === 'notes' || block?.kind === 'tools'
|
|
1140
|
+
? (block.lines ?? []).filter((line) => typeof line === 'string' && line.trim()).join('\n')
|
|
1141
|
+
: block?.text;
|
|
1142
|
+
return sum + countMarkdownTables(text);
|
|
1143
|
+
}, 0);
|
|
1144
|
+
}
|
|
1145
|
+
|
|
1146
|
+
/** Split display copies; the bridge's logical blocks and answer indices stay intact. */
|
|
1147
|
+
export function splitBlockByTableLimit(block, maxTables = STEP_STREAM_CARD_MAX_TABLES) {
|
|
1148
|
+
if (!block || typeof block !== 'object' || !Number.isInteger(maxTables) || maxTables < 1) return [block];
|
|
1149
|
+
if (block.kind === 'notes' || block.kind === 'tools') {
|
|
1150
|
+
const lines = (block.lines ?? []).filter((line) => typeof line === 'string' && line.trim());
|
|
1151
|
+
if (countMarkdownTables(lines.join('\n')) <= maxTables) return [block];
|
|
1152
|
+
const groups = [];
|
|
1153
|
+
let current = [];
|
|
1154
|
+
for (const line of lines) {
|
|
1155
|
+
for (const part of splitMarkdownByTableLimit(line, maxTables)) {
|
|
1156
|
+
if (current.length && countMarkdownTables([...current, part].join('\n')) > maxTables) {
|
|
1157
|
+
groups.push(current);
|
|
1158
|
+
current = [];
|
|
1159
|
+
}
|
|
1160
|
+
current.push(part);
|
|
1161
|
+
}
|
|
1162
|
+
}
|
|
1163
|
+
if (current.length) groups.push(current);
|
|
1164
|
+
return groups.map((lines, index) => ({ ...block, lines, omitted: index === 0 ? block.omitted : 0 }));
|
|
1165
|
+
}
|
|
1166
|
+
if (typeof block.text !== 'string' || countMarkdownTables(block.text) <= maxTables) return [block];
|
|
1167
|
+
return splitMarkdownByTableLimit(block.text, maxTables).map((text) => ({ ...block, text }));
|
|
1168
|
+
}
|
|
1169
|
+
|
|
1012
1170
|
/**
|
|
1013
1171
|
* Split accumulated blocks into card-sized chunks at block boundaries,
|
|
1014
|
-
* budgeted by the encoded running-status card (the largest render)
|
|
1015
|
-
*
|
|
1016
|
-
*
|
|
1172
|
+
* budgeted by the encoded running-status card (the largest render) **and** by
|
|
1173
|
+
* the table count, because a card accepts only a handful of tables no matter
|
|
1174
|
+
* how small it is. Every chunk keeps at least one block so progress is never
|
|
1175
|
+
* dropped. The caller renders all but the last chunk as `sealed` and the last
|
|
1176
|
+
* one live.
|
|
1017
1177
|
*/
|
|
1018
|
-
export function splitStepStreamCardBlocks(
|
|
1019
|
-
|
|
1178
|
+
export function splitStepStreamCardBlocks(
|
|
1179
|
+
blocks,
|
|
1180
|
+
limit = STEP_STREAM_CARD_MAX_BYTES,
|
|
1181
|
+
maxTables = STEP_STREAM_CARD_MAX_TABLES,
|
|
1182
|
+
) {
|
|
1183
|
+
// A single block can carry more tables than a card accepts — one short answer
|
|
1184
|
+
// with six small tables — and splitting only between blocks cannot help. Each
|
|
1185
|
+
// block is broken at table boundaries first, so the walk below sees pieces it
|
|
1186
|
+
// can actually distribute.
|
|
1187
|
+
const list = (Array.isArray(blocks) ? blocks : [])
|
|
1188
|
+
.filter(Boolean)
|
|
1189
|
+
.flatMap((block) => splitBlockByTableLimit(block, maxTables));
|
|
1020
1190
|
if (list.length === 0) return [];
|
|
1021
1191
|
const chunks = [];
|
|
1022
1192
|
let current = [];
|
|
1023
1193
|
for (const block of list) {
|
|
1024
|
-
|
|
1025
|
-
|
|
1194
|
+
const tooLarge = Buffer.byteLength(stepStreamCard([...current, block]), 'utf8') > limit;
|
|
1195
|
+
const tooManyTables = stepStreamCardTableCount([...current, block]) > maxTables;
|
|
1196
|
+
if (current.length > 0 && (tooLarge || tooManyTables)) {
|
|
1026
1197
|
chunks.push(current);
|
|
1027
1198
|
current = [block];
|
|
1028
1199
|
} else {
|
|
@@ -534,8 +534,17 @@ export function extractInboundMessage(event, client) {
|
|
|
534
534
|
const imageKeys = standaloneImageKey ? [standaloneImageKey] : post?.imageKeys ?? [];
|
|
535
535
|
const file = messageType === 'file' ? feishuFileSource(event, client, parsed) : null;
|
|
536
536
|
const replyTo = feishuReplyReference(event, client);
|
|
537
|
+
// A forwarded card arrives as `interactive`. Without this branch its text was
|
|
538
|
+
// dropped entirely, so the message looked empty and the reader got the
|
|
539
|
+
// "text, image and file only" notice — even though the same extraction is
|
|
540
|
+
// already trusted for quoted cards (`feishuReplyReference`).
|
|
541
|
+
const cardText = messageType === 'interactive' ? interactiveCardText(parsed) : '';
|
|
537
542
|
return {
|
|
538
|
-
content: messageType === 'text'
|
|
543
|
+
content: messageType === 'text'
|
|
544
|
+
? extractText(event) ?? ''
|
|
545
|
+
: messageType === 'interactive'
|
|
546
|
+
? cardText
|
|
547
|
+
: post?.text ?? '',
|
|
539
548
|
images: imageKeys.map((key) => feishuImageSource(event, client, key)),
|
|
540
549
|
files: file ? [file] : [],
|
|
541
550
|
...(replyTo ? { replyTo } : {}),
|