@xmanrui/dsh-im 4.26.0 → 4.28.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/lib/client.js +3 -1
- package/lib/index.js +292 -282
- package/package.json +1 -1
- package/plugin-src/client/i18n.js +1 -0
- package/plugin-src/client/update-panel.js +1 -0
- package/plugin-src/host/channels/matrix/production.mjs +14 -0
- package/src/channels/feishu/bridge.mjs +89 -97
- package/src/channels/feishu/feishu-cards.mjs +178 -7
- package/src/channels/feishu/message-utils.mjs +10 -1
- package/src/channels/feishu/state-store.mjs +3 -2
- package/src/channels/matrix/matrix-config-store.mjs +14 -2
- package/src/channels/matrix/matrix-room-history.mjs +316 -0
- package/src/channels/matrix/matrix-runtime.mjs +134 -20
- package/src/channels/shared/i18n-en/feishu.mjs +1 -0
- package/src/channels/shared/i18n-en/matrix.mjs +10 -0
package/package.json
CHANGED
|
@@ -111,6 +111,7 @@ const EN = Object.freeze({
|
|
|
111
111
|
'DSH 版本兼容说明': 'DSH version compatibility',
|
|
112
112
|
'dsh-im 4.25.0 支持到 DSH v0.1.6-alpha.2。': 'dsh-im 4.25.0 supports DSH up to v0.1.6-alpha.2.',
|
|
113
113
|
'dsh-im 4.26.0 仅支持 DSH v0.1.7-alpha.1。': 'dsh-im 4.26.0 only supports DSH v0.1.7-alpha.1.',
|
|
114
|
+
'dsh-im 4.27.0 仅支持 DSH v0.1.7-alpha.1 及更新版本。': 'dsh-im 4.27.0 only supports DSH v0.1.7-alpha.1 and later.',
|
|
114
115
|
'升级到 dsh-im 4.26.0 前,请先将 DSH 升级到 v0.1.7-alpha.1;使用旧版 DSH 请保留 dsh-im 4.25.0。': 'Before updating to dsh-im 4.26.0, upgrade DSH to v0.1.7-alpha.1. If you use an older DSH version, stay on dsh-im 4.25.0.',
|
|
115
116
|
'重新检查': 'Check again',
|
|
116
117
|
'刷新状态': 'Refresh status',
|
|
@@ -471,6 +471,7 @@ export function UpdatePanel({ rpcCall, clientVersion, onStatus }) {
|
|
|
471
471
|
h('strong', null, 'DSH 版本兼容说明'),
|
|
472
472
|
h('p', { className: 'dim-updateManualHint' }, 'dsh-im 4.25.0 支持到 DSH v0.1.6-alpha.2。'),
|
|
473
473
|
h('p', { className: 'dim-updateManualHint' }, 'dsh-im 4.26.0 仅支持 DSH v0.1.7-alpha.1。'),
|
|
474
|
+
h('p', { className: 'dim-updateManualHint' }, 'dsh-im 4.27.0 仅支持 DSH v0.1.7-alpha.1 及更新版本。'),
|
|
474
475
|
h('p', { className: 'dim-updateManualHint' },
|
|
475
476
|
'升级到 dsh-im 4.26.0 前,请先将 DSH 升级到 v0.1.7-alpha.1;使用旧版 DSH 请保留 dsh-im 4.25.0。')),
|
|
476
477
|
h(ManualUpdateCommand, {
|
|
@@ -5,6 +5,7 @@ import { MatrixConfigStore, MatrixSidecarStore } from '../../../../src/channels/
|
|
|
5
5
|
import { MatrixController } from '../../../../src/channels/matrix/matrix-controller.mjs';
|
|
6
6
|
import { MatrixCryptoStore, matrixCryptoPathFor } from '../../../../src/channels/matrix/matrix-crypto-store.mjs';
|
|
7
7
|
import { MatrixHarnessClient } from '../../../../src/channels/matrix/matrix-harness-client.mjs';
|
|
8
|
+
import { MatrixRoomHistoryStore, matrixRoomHistoryPathFor } from '../../../../src/channels/matrix/matrix-room-history.mjs';
|
|
8
9
|
import { MatrixRuntime } from '../../../../src/channels/matrix/matrix-runtime.mjs';
|
|
9
10
|
import { ConversationStateStore } from '../../../../src/channels/shared/conversation-state-store.mjs';
|
|
10
11
|
import {
|
|
@@ -63,10 +64,13 @@ export async function createProductionController(ctx, config = {}, internals = {
|
|
|
63
64
|
const stateStores = new Map();
|
|
64
65
|
const sidecarStores = new Map();
|
|
65
66
|
const cryptoStores = new Map();
|
|
67
|
+
const historyStores = new Map();
|
|
68
|
+
const ResolvedRoomHistory = internals.RoomHistoryStore ?? MatrixRoomHistoryStore;
|
|
66
69
|
const ResolvedCryptoStore = internals.CryptoStore ?? MatrixCryptoStore;
|
|
67
70
|
const statePath = (botId) => resolve(paths.bots, botId, 'state.json');
|
|
68
71
|
const sidecarPath = (botId) => resolve(paths.bots, botId, 'matrix.json');
|
|
69
72
|
const cryptoPath = (botId) => matrixCryptoPathFor(resolve(paths.bots, botId));
|
|
73
|
+
const historyPath = (botId) => matrixRoomHistoryPathFor(resolve(paths.bots, botId));
|
|
70
74
|
const stateFor = async (botId) => {
|
|
71
75
|
let state = stateStores.get(botId);
|
|
72
76
|
if (!state) {
|
|
@@ -91,6 +95,14 @@ export async function createProductionController(ctx, config = {}, internals = {
|
|
|
91
95
|
}
|
|
92
96
|
return crypto;
|
|
93
97
|
};
|
|
98
|
+
const historyFor = async (botId) => {
|
|
99
|
+
let history = historyStores.get(botId);
|
|
100
|
+
if (!history) {
|
|
101
|
+
history = await new ResolvedRoomHistory(historyPath(botId)).load();
|
|
102
|
+
historyStores.set(botId, history);
|
|
103
|
+
}
|
|
104
|
+
return history;
|
|
105
|
+
};
|
|
94
106
|
const commandExecutor = createHarnessCommandExecutor(ctx, internals.commandExecutor);
|
|
95
107
|
const inboundTtl = internals.inboundTtl ?? getInboundTtlRuntime(ctx, config);
|
|
96
108
|
const inboundTtlService = inboundTtl?.service ?? inboundTtl;
|
|
@@ -137,6 +149,7 @@ export async function createProductionController(ctx, config = {}, internals = {
|
|
|
137
149
|
});
|
|
138
150
|
const e2eeMode = String(botConfig?.e2eeMode ?? 'optional').trim().toLowerCase();
|
|
139
151
|
const cryptoStore = e2eeMode === 'off' ? null : await cryptoFor(botId);
|
|
152
|
+
const roomHistory = await historyFor(botId);
|
|
140
153
|
return new ResolvedRuntime({
|
|
141
154
|
config: botConfig,
|
|
142
155
|
...(accessToken ? { accessToken } : {}),
|
|
@@ -145,6 +158,7 @@ export async function createProductionController(ctx, config = {}, internals = {
|
|
|
145
158
|
harness: workspaceScope.harness,
|
|
146
159
|
state: workspaceScope.state,
|
|
147
160
|
sidecar,
|
|
161
|
+
roomHistory,
|
|
148
162
|
...(cryptoStore ? { cryptoStore } : {}),
|
|
149
163
|
contextEnhancement: { botId, getSettings: () => workspaces.contextEnhancementFor(botId) },
|
|
150
164
|
accessPolicy: accessPolicyProvider(workspaces, 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;
|
|
@@ -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 } : {}),
|
|
@@ -202,12 +202,13 @@ export class StateStore {
|
|
|
202
202
|
|
|
203
203
|
async #persist() {
|
|
204
204
|
const snapshot = JSON.stringify(this.#state, null, 2) + '\n';
|
|
205
|
-
|
|
205
|
+
const operation = this.#writeQueue.then(async () => {
|
|
206
206
|
await mkdir(dirname(this.#path), { recursive: true, mode: 0o700 });
|
|
207
207
|
const temporary = `${this.#path}.tmp`;
|
|
208
208
|
await writeFile(temporary, snapshot, { encoding: 'utf8', mode: 0o600 });
|
|
209
209
|
await rename(temporary, this.#path);
|
|
210
210
|
});
|
|
211
|
-
|
|
211
|
+
this.#writeQueue = operation.then(() => undefined, () => undefined);
|
|
212
|
+
await operation;
|
|
212
213
|
}
|
|
213
214
|
}
|
|
@@ -13,6 +13,9 @@ const E2EE_MODES = new Set(['off', 'optional', 'required']);
|
|
|
13
13
|
const AUTO_JOIN_MODES = new Set(['authorized', 'all']);
|
|
14
14
|
const MAX_MESSAGE_LENGTH_BOUNDS = Object.freeze({ min: 500, max: 65_535 });
|
|
15
15
|
const MAX_MEDIA_BYTES_CEILING = 104_857_600;
|
|
16
|
+
const ROOM_CONTEXT_LIMIT_BOUNDS = Object.freeze({ min: 1, max: 500 });
|
|
17
|
+
const ROOM_CONTEXT_CHARS_BOUNDS = Object.freeze({ min: 200, max: 200_000 });
|
|
18
|
+
const ROOM_CONTEXT_TZ_BOUNDS = Object.freeze({ min: -840, max: 840 });
|
|
16
19
|
|
|
17
20
|
function cleanString(value) {
|
|
18
21
|
return typeof value === 'string' && value.trim() ? value.trim() : null;
|
|
@@ -179,10 +182,15 @@ export class MatrixConfigStore {
|
|
|
179
182
|
const allowedRooms = roomIdList(value.allowedRooms);
|
|
180
183
|
const freeResponseRooms = roomIdList(value.freeResponseRooms);
|
|
181
184
|
const ignoreUserPatterns = patternList(value.ignoreUserPatterns);
|
|
185
|
+
const roomContextLimit = boundedInteger(value.roomContextLimit, ROOM_CONTEXT_LIMIT_BOUNDS);
|
|
186
|
+
const roomContextMaxChars = boundedInteger(value.roomContextMaxChars, ROOM_CONTEXT_CHARS_BOUNDS);
|
|
187
|
+
const roomContextTzOffsetMinutes = boundedInteger(value.roomContextTzOffsetMinutes, ROOM_CONTEXT_TZ_BOUNDS);
|
|
182
188
|
if (!E2EE_MODES.has(e2eeMode) || !AUTO_JOIN_MODES.has(autoJoinInvites)
|
|
183
189
|
|| maxMessageLength === null || maxMediaBytes === null
|
|
184
|
-
|| allowedRooms === null || freeResponseRooms === null || ignoreUserPatterns === null
|
|
185
|
-
|
|
190
|
+
|| allowedRooms === null || freeResponseRooms === null || ignoreUserPatterns === null
|
|
191
|
+
|| roomContextLimit === null || roomContextMaxChars === null
|
|
192
|
+
|| roomContextTzOffsetMinutes === null) return null;
|
|
193
|
+
for (const key of ['requireMention', 'processNotices', 'allowRoomMentions', 'reactions', 'roomContextEnabled']) {
|
|
186
194
|
if (value?.[key] !== undefined && typeof value[key] !== 'boolean') return null;
|
|
187
195
|
}
|
|
188
196
|
return Object.freeze({
|
|
@@ -208,6 +216,10 @@ export class MatrixConfigStore {
|
|
|
208
216
|
...(allowedRooms !== undefined ? { allowedRooms: Object.freeze(allowedRooms) } : {}),
|
|
209
217
|
...(freeResponseRooms !== undefined ? { freeResponseRooms: Object.freeze(freeResponseRooms) } : {}),
|
|
210
218
|
...(ignoreUserPatterns !== undefined ? { ignoreUserPatterns: Object.freeze(ignoreUserPatterns) } : {}),
|
|
219
|
+
...(typeof value.roomContextEnabled === 'boolean' ? { roomContextEnabled: value.roomContextEnabled } : {}),
|
|
220
|
+
...(roomContextLimit !== undefined ? { roomContextLimit } : {}),
|
|
221
|
+
...(roomContextMaxChars !== undefined ? { roomContextMaxChars } : {}),
|
|
222
|
+
...(roomContextTzOffsetMinutes !== undefined ? { roomContextTzOffsetMinutes } : {}),
|
|
211
223
|
});
|
|
212
224
|
}
|
|
213
225
|
|