@xmanrui/dsh-im 4.38.0 → 4.38.2
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/PROACTIVE_DELIVERY.en.md +18 -2
- package/PROACTIVE_DELIVERY.md +18 -2
- package/README.en.md +2 -0
- package/README.md +2 -0
- package/lib/client.js +49 -5
- package/lib/index.js +320 -315
- package/locale/en.json +1 -1
- package/locale/zh.json +1 -1
- package/package.json +1 -1
- package/plugin-src/client/connection-error.js +12 -2
- 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/matrix/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 +1 -1
- package/plugin-src/host/channels/whatsapp/production.mjs +1 -1
- package/plugin-src/host/delivery-adapter.mjs +9 -1
- package/plugin-src/host/delivery-service.mjs +4 -3
- package/plugin-src/host/modern-harness-api.mjs +2 -1
- package/plugin-src/host/session-sync-coordinator.mjs +9 -5
- package/src/channels/dingtalk/dingtalk-api.mjs +41 -24
- package/src/channels/dingtalk/dingtalk-runtime.mjs +3 -2
- package/src/channels/discord/discord-runtime.mjs +13 -2
- package/src/channels/feishu/bridge.mjs +95 -24
- package/src/channels/matrix/matrix-runtime.mjs +16 -6
- package/src/channels/qq/markdown-reply.mjs +7 -340
- package/src/channels/qq/qq-runtime.mjs +11 -3
- package/src/channels/shared/bot-workspace-store.mjs +3 -3
- package/src/channels/shared/connection-error.mjs +29 -4
- package/src/channels/shared/diagnostic-details.mjs +6 -2
- package/src/channels/shared/harness-client.mjs +138 -19
- package/src/channels/shared/harness-mux.mjs +6 -4
- package/src/channels/shared/i18n-en/diagnostics.mjs +24 -0
- package/src/channels/shared/i18n-en/wecom.mjs +3 -0
- package/src/channels/shared/markdown-chunks.mjs +338 -0
- package/src/channels/shared/message-failure.mjs +31 -11
- package/src/channels/shared/text-harness-bridge.mjs +4 -1
- package/src/channels/shared/workspace-session.mjs +10 -1
- package/src/channels/slack/slack-api.mjs +2 -1
- package/src/channels/slack/slack-runtime.mjs +30 -0
- package/src/channels/telegram/telegram-runtime.mjs +1 -0
- package/src/channels/wecom/wecom-bridge.mjs +45 -10
- package/src/channels/wecom/wecom-runtime.mjs +11 -5
- package/src/channels/weixin/weixin-runtime.mjs +17 -10
- package/src/channels/whatsapp/whatsapp-markdown.mjs +59 -0
- package/src/channels/whatsapp/whatsapp-runtime.mjs +26 -1
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { BusyMessageDispatcher, canAutomaticallySteer } from '../shared/busy-message-dispatcher.mjs';
|
|
2
2
|
import { isPermissionCommand, runPermissionCommand } from '../shared/permission-command.mjs';
|
|
3
3
|
import { randomUUID } from 'node:crypto';
|
|
4
|
+
import { chunkMarkdownText } from '../shared/markdown-chunks.mjs';
|
|
4
5
|
import QRCode from 'qrcode';
|
|
5
6
|
import { waitForFeishuOperation } from './feishu-channel.mjs';
|
|
6
7
|
import {
|
|
@@ -209,6 +210,19 @@ const STEP_PUSH_POST_CHUNK_MAX_BYTES = 24_000;
|
|
|
209
210
|
const STEP_STREAM_PATCH_MIN_INTERVAL_MS = 1_000;
|
|
210
211
|
/** Confirm missed boundaries from history; elapsed time is never completion. */
|
|
211
212
|
const MIRROR_CHECK_MS = 30_000;
|
|
213
|
+
/** Retire orphan snapshots outside Feishu's message update window. */
|
|
214
|
+
const MIRROR_STALE_MS = 14 * 24 * 60 * 60_000;
|
|
215
|
+
const MIRROR_RECOVERY_MAX_FAILURES = 20;
|
|
216
|
+
|
|
217
|
+
function mirrorErrorText(error) {
|
|
218
|
+
return [error?.response?.data?.msg, error?.msg, error?.message]
|
|
219
|
+
.filter((value) => typeof value === 'string' && value.trim()).join('; ') || String(error);
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
function isExpiredMirrorError(error) {
|
|
223
|
+
return /Message has expired when updating message|Message can only be updated within fourteen days/i
|
|
224
|
+
.test(mirrorErrorText(error));
|
|
225
|
+
}
|
|
212
226
|
/** One answer chunk inside the streaming card: small enough that the block
|
|
213
227
|
* splitter can always distribute blocks across sealed/live cards. */
|
|
214
228
|
const STEP_STREAM_ANSWER_CHUNK_MAX_BYTES = 18_000;
|
|
@@ -848,27 +862,48 @@ export class FeishuHarnessBridge {
|
|
|
848
862
|
}
|
|
849
863
|
|
|
850
864
|
/** Recover only a known finished turn, using its last successful card JSON. */
|
|
851
|
-
async #sealOrphanMirrors() {
|
|
852
|
-
for (const [key,
|
|
853
|
-
if (
|
|
854
|
-
|
|
855
|
-
await this.#queueEventTask(
|
|
865
|
+
async #sealOrphanMirrors(onlyKey = null) {
|
|
866
|
+
for (const [key, queuedEntry] of this.#state.mirrorEntries?.() ?? []) {
|
|
867
|
+
if (onlyKey !== null && key !== onlyKey) continue;
|
|
868
|
+
if (!queuedEntry?.sessionId) continue;
|
|
869
|
+
await this.#queueEventTask(queuedEntry.sessionId, async () => {
|
|
856
870
|
if (this.#signal?.aborted) return;
|
|
857
871
|
// An adopted turn now owns this record and will finish through its queue.
|
|
858
872
|
if (this.#sessionSyncTurns.has(key)) return;
|
|
873
|
+
// A queued timer must not resurrect a deleted record or overwrite a
|
|
874
|
+
// newer snapshot/failure count with the entry captured before queuing.
|
|
875
|
+
let entry = this.#state.mirrorEntries?.().find(([entryKey]) => entryKey === key)?.[1];
|
|
876
|
+
if (!entry?.chatId || !entry.sessionId || !Number.isSafeInteger(entry.turn)
|
|
877
|
+
|| !Array.isArray(entry.cardIds) || !entry.cardIds.length) return;
|
|
878
|
+
const now = Date.now();
|
|
879
|
+
if (!Number.isFinite(entry.claimedAt) || entry.claimedAt <= 0 || entry.claimedAt > now) {
|
|
880
|
+
entry = { ...entry, claimedAt: now };
|
|
881
|
+
await this.#state.setMirror?.(key, entry);
|
|
882
|
+
}
|
|
883
|
+
if (now - entry.claimedAt >= MIRROR_STALE_MS) {
|
|
884
|
+
await this.#clearMirrorRecovery(key, 'snapshot expired');
|
|
885
|
+
return;
|
|
886
|
+
}
|
|
887
|
+
const failures = Number.isSafeInteger(entry.recoveryFailures) && entry.recoveryFailures > 0
|
|
888
|
+
? entry.recoveryFailures : 0;
|
|
889
|
+
if (failures >= MIRROR_RECOVERY_MAX_FAILURES) {
|
|
890
|
+
await this.#clearMirrorRecovery(key, 'recovery failure limit reached');
|
|
891
|
+
return;
|
|
892
|
+
}
|
|
859
893
|
try {
|
|
860
894
|
const events = await this.#mirrorHistory(entry.sessionId, entry.turn);
|
|
895
|
+
if (this.#signal?.aborted) return;
|
|
861
896
|
const outcome = extractCompletedTurnAnswer(events, { turn: entry.turn });
|
|
862
897
|
if (outcome.endSeq < 0) {
|
|
863
|
-
this.#scheduleMirrorCheck(`recovery\0${key}`, () => this.#sealOrphanMirrors());
|
|
898
|
+
this.#scheduleMirrorCheck(`recovery\0${key}`, () => this.#sealOrphanMirrors(key));
|
|
864
899
|
return;
|
|
865
900
|
}
|
|
866
901
|
if (this.#restoreMirrorCard(key, entry)) {
|
|
867
902
|
const result = await this.#finishStepCard(key, {
|
|
868
903
|
answerText: outcome.text, stopped: outcome.reason !== 'completed',
|
|
869
904
|
});
|
|
870
|
-
if (!result?.ok) throw new Error('Recovered mirror could not deliver its final answer');
|
|
871
|
-
await this.#
|
|
905
|
+
if (!result?.ok) throw result?.error ?? new Error('Recovered mirror could not deliver its final answer');
|
|
906
|
+
await this.#clearMirrorRecovery(key);
|
|
872
907
|
return;
|
|
873
908
|
}
|
|
874
909
|
const content = JSON.parse(entry.lastContent);
|
|
@@ -880,15 +915,40 @@ export class FeishuHarnessBridge {
|
|
|
880
915
|
else elements.push({ tag: 'markdown', content: status });
|
|
881
916
|
// Earlier chunks are sealed history; update only the live card.
|
|
882
917
|
await this.#patchStepCard(entry.cardIds.at(-1), JSON.stringify(content));
|
|
883
|
-
await this.#
|
|
918
|
+
await this.#clearMirrorRecovery(key);
|
|
884
919
|
} catch (error) {
|
|
885
|
-
this.#
|
|
886
|
-
this.#
|
|
920
|
+
if (this.#signal?.aborted) return;
|
|
921
|
+
const current = this.#state.mirrorEntries?.().find(([entryKey]) => entryKey === key)?.[1];
|
|
922
|
+
if (!current) return;
|
|
923
|
+
if (isExpiredMirrorError(error)) {
|
|
924
|
+
await this.#clearMirrorRecovery(key, 'message expired');
|
|
925
|
+
return;
|
|
926
|
+
}
|
|
927
|
+
await this.#state.setMirror?.(key, { ...current, recoveryFailures: failures + 1 });
|
|
928
|
+
if (failures + 1 >= MIRROR_RECOVERY_MAX_FAILURES) {
|
|
929
|
+
await this.#clearMirrorRecovery(key, 'recovery failure limit reached');
|
|
930
|
+
return;
|
|
931
|
+
}
|
|
932
|
+
this.#logger.warn?.('[dsh-feishu] mirror recovery failed:', mirrorErrorText(error),
|
|
933
|
+
{ sessionId: entry.sessionId, turn: entry.turn, recoveryFailures: failures + 1 });
|
|
934
|
+
this.#scheduleMirrorCheck(`recovery\0${key}`, () => this.#sealOrphanMirrors(key));
|
|
887
935
|
}
|
|
888
936
|
});
|
|
889
937
|
}
|
|
890
938
|
}
|
|
891
939
|
|
|
940
|
+
async #clearMirrorRecovery(key, reason = null) {
|
|
941
|
+
const timerKey = `recovery\0${key}`;
|
|
942
|
+
clearTimeout(this.#sessionSyncIdleTimers.get(timerKey));
|
|
943
|
+
this.#sessionSyncIdleTimers.delete(timerKey);
|
|
944
|
+
const entry = this.#state.mirrorEntries?.().find(([entryKey]) => entryKey === key)?.[1];
|
|
945
|
+
await this.#state.clearMirror?.(key);
|
|
946
|
+
if (reason && entry) {
|
|
947
|
+
this.#logger.warn?.('[dsh-feishu] mirror recovery stopped:', reason,
|
|
948
|
+
{ sessionId: entry.sessionId, turn: entry.turn, recoveryFailures: entry.recoveryFailures ?? 0 });
|
|
949
|
+
}
|
|
950
|
+
}
|
|
951
|
+
|
|
892
952
|
setGroupResponseMode(value) {
|
|
893
953
|
this.#groupResponseMode = normalizeFeishuGroupResponseMode(value);
|
|
894
954
|
}
|
|
@@ -4230,10 +4290,12 @@ export class FeishuHarnessBridge {
|
|
|
4230
4290
|
mirror.result = { ok: result?.ok === true, text };
|
|
4231
4291
|
mirror.finishedAt = Date.now();
|
|
4232
4292
|
// Keep the last successful snapshot on failure so recovery can preserve it.
|
|
4233
|
-
if (result?.ok) await this.#
|
|
4234
|
-
else if (
|
|
4293
|
+
if (result?.ok) await this.#clearMirrorRecovery(mirror.key);
|
|
4294
|
+
else if (isExpiredMirrorError(result?.error)) {
|
|
4295
|
+
await this.#clearMirrorRecovery(mirror.key, 'message expired');
|
|
4296
|
+
} else if (recovering) {
|
|
4235
4297
|
this.#sessionSyncTurns.delete(mirror.key);
|
|
4236
|
-
this.#scheduleMirrorCheck(`recovery\0${mirror.key}`, () => this.#sealOrphanMirrors());
|
|
4298
|
+
this.#scheduleMirrorCheck(`recovery\0${mirror.key}`, () => this.#sealOrphanMirrors(mirror.key));
|
|
4237
4299
|
}
|
|
4238
4300
|
return result?.ok === true;
|
|
4239
4301
|
}
|
|
@@ -4790,7 +4852,13 @@ export class FeishuHarnessBridge {
|
|
|
4790
4852
|
|
|
4791
4853
|
/** Replace the draft interval with `text` chunked into splitter-safe blocks. */
|
|
4792
4854
|
#writeStepCardAnswer(card, text) {
|
|
4793
|
-
|
|
4855
|
+
// JSON can expand one UTF-16 unit to six bytes (for example a control
|
|
4856
|
+
// character). Leave room for the element wrapper, and let the existing
|
|
4857
|
+
// Markdown splitter reopen fences in each independently rendered block.
|
|
4858
|
+
const chunks = Buffer.byteLength(JSON.stringify(text), 'utf8') + 128 <= STEP_STREAM_ANSWER_CHUNK_MAX_BYTES
|
|
4859
|
+
? [text]
|
|
4860
|
+
: chunkMarkdownText(text, Math.floor((STEP_STREAM_ANSWER_CHUNK_MAX_BYTES - 128) / 6));
|
|
4861
|
+
const blocks = chunks
|
|
4794
4862
|
.map((chunk) => ({ kind: 'message', text: chunk }));
|
|
4795
4863
|
if (card.answerStart !== null) {
|
|
4796
4864
|
card.blocks.splice(card.answerStart, card.answerEnd - card.answerStart + 1, ...blocks);
|
|
@@ -4917,13 +4985,14 @@ export class FeishuHarnessBridge {
|
|
|
4917
4985
|
card.lastRenderAt = this.#stepPushClock.now();
|
|
4918
4986
|
} catch (error) {
|
|
4919
4987
|
card.broken = true;
|
|
4988
|
+
card.lastError = error;
|
|
4920
4989
|
this.#logger.warn?.(
|
|
4921
4990
|
'[dsh-feishu] step streaming card render failed; the turn continues without it:',
|
|
4922
|
-
|
|
4991
|
+
mirrorErrorText(error),
|
|
4923
4992
|
);
|
|
4924
4993
|
if (card.deliveryViaOpenId) {
|
|
4925
4994
|
this.#logger.warn?.('[dsh-feishu] session-sync mirror card render failed:',
|
|
4926
|
-
|
|
4995
|
+
mirrorErrorText(error),
|
|
4927
4996
|
{ sessionId: card.sessionSyncSessionId, turn: card.sessionSyncTurn });
|
|
4928
4997
|
}
|
|
4929
4998
|
}
|
|
@@ -4933,9 +5002,8 @@ export class FeishuHarnessBridge {
|
|
|
4933
5002
|
* Seal the turn's card with a terminal status and the final answer. The
|
|
4934
5003
|
* streamed answer draft is rewritten in place with the delivery text; when
|
|
4935
5004
|
* no draft exists the answer is appended. Returns `{ ok, cardIds }` when
|
|
4936
|
-
* the answer is visible
|
|
4937
|
-
*
|
|
4938
|
-
* a successful render). Safe to call twice.
|
|
5005
|
+
* the answer is visible, `{ ok: false, error }` on failure, or null when
|
|
5006
|
+
* no card exists. Callers fall back unless ok is true. Safe to call twice.
|
|
4939
5007
|
*/
|
|
4940
5008
|
async #finishStepCard(key, { stopped = false, answerText = null } = {}) {
|
|
4941
5009
|
const card = this.#stepCards.get(key);
|
|
@@ -4944,7 +5012,7 @@ export class FeishuHarnessBridge {
|
|
|
4944
5012
|
try {
|
|
4945
5013
|
await card.renderChain?.catch?.(() => {});
|
|
4946
5014
|
} catch { /* the seal below decides what the user sees */ }
|
|
4947
|
-
if (card.broken) return
|
|
5015
|
+
if (card.broken) return { ok: false, error: card.lastError };
|
|
4948
5016
|
const status = stopped ? 'stopped' : 'completed';
|
|
4949
5017
|
const body = typeof answerText === 'string' ? answerText : '';
|
|
4950
5018
|
// Whether the answer was already visible from a streamed draft render —
|
|
@@ -4975,10 +5043,10 @@ export class FeishuHarnessBridge {
|
|
|
4975
5043
|
}
|
|
4976
5044
|
this.#logger.warn?.(
|
|
4977
5045
|
'[dsh-feishu] step streaming card finish failed:',
|
|
4978
|
-
|
|
5046
|
+
mirrorErrorText(error),
|
|
4979
5047
|
{ sessionId: card.sessionSyncSessionId, turn: card.sessionSyncTurn },
|
|
4980
5048
|
);
|
|
4981
|
-
return
|
|
5049
|
+
return { ok: false, error };
|
|
4982
5050
|
}
|
|
4983
5051
|
}
|
|
4984
5052
|
|
|
@@ -5033,7 +5101,10 @@ export class FeishuHarnessBridge {
|
|
|
5033
5101
|
data: { content: cardJson },
|
|
5034
5102
|
}));
|
|
5035
5103
|
if (response?.code && response.code !== 0) {
|
|
5036
|
-
|
|
5104
|
+
const error = new Error(`Feishu step card patch failed: ${response.msg || response.code}`);
|
|
5105
|
+
error.code = response.code;
|
|
5106
|
+
error.feishuCode = response.code;
|
|
5107
|
+
throw error;
|
|
5037
5108
|
}
|
|
5038
5109
|
}
|
|
5039
5110
|
|
|
@@ -36,6 +36,7 @@ import {
|
|
|
36
36
|
extractOutboundMentions,
|
|
37
37
|
hasRoomMention,
|
|
38
38
|
} from './matrix-rich-text.mjs';
|
|
39
|
+
import { chunkMarkdownParts } from '../shared/markdown-chunks.mjs';
|
|
39
40
|
|
|
40
41
|
const RECONNECT_DELAYS_MS = Object.freeze([1_000, 3_000, 5_000, 10_000, 30_000]);
|
|
41
42
|
const SYNC_LONG_POLL_MS = 30_000;
|
|
@@ -917,14 +918,23 @@ export class MatrixRuntime {
|
|
|
917
918
|
? target.threadId : null;
|
|
918
919
|
const replyToEventId = typeof target?.replyToEventId === 'string' && isMatrixEventId(target.replyToEventId)
|
|
919
920
|
? target.replyToEventId : null;
|
|
920
|
-
const chunks =
|
|
921
|
+
const chunks = options.format === 'markdown'
|
|
922
|
+
? chunkMarkdownParts(text, this.#config.maxMessageLength)
|
|
923
|
+
: splitMessageText(text, this.#config.maxMessageLength).map(plain => ({ markdown: plain, plain }));
|
|
921
924
|
const providerMessageIds = [];
|
|
922
925
|
for (const chunk of chunks) {
|
|
923
|
-
|
|
924
|
-
|
|
925
|
-
|
|
926
|
-
|
|
927
|
-
|
|
926
|
+
let content;
|
|
927
|
+
try {
|
|
928
|
+
content = buildMatrixTextContent({
|
|
929
|
+
text: chunk.markdown,
|
|
930
|
+
mentionUserIds: extractOutboundMentions(chunk.markdown),
|
|
931
|
+
roomMention: this.#config.allowRoomMentions && hasRoomMention(chunk.markdown),
|
|
932
|
+
});
|
|
933
|
+
} catch {
|
|
934
|
+
this.#logger.warn?.('[dsh-im:matrix] Rich text could not be prepared; sending plain text');
|
|
935
|
+
if (!chunk.plain) continue;
|
|
936
|
+
content = { msgtype: 'm.text', body: chunk.plain };
|
|
937
|
+
}
|
|
928
938
|
applyMatrixRelations(content, { threadId, replyToEventId });
|
|
929
939
|
const result = await this.#sendRoomEvent(roomId, 'm.room.message', content, {
|
|
930
940
|
...(options.signal ? { signal: options.signal } : {}),
|
|
@@ -4,347 +4,13 @@
|
|
|
4
4
|
import { t } from '../shared/i18n.mjs';
|
|
5
5
|
import { ApiError } from '@tencent-connect/qqbot-nodejs';
|
|
6
6
|
|
|
7
|
-
|
|
8
|
-
|
|
7
|
+
import { chunkMarkdownParts } from '../shared/markdown-chunks.mjs';
|
|
8
|
+
export { chunkMarkdownText } from '../shared/markdown-chunks.mjs';
|
|
9
|
+
|
|
9
10
|
const MARKDOWN_REJECTION_CODES = new Set([40_034_090]);
|
|
10
11
|
const PASSIVE_REPLY_LIMIT = Object.freeze({ c2c: 4, group: 5 });
|
|
11
12
|
const PARTIAL_REPLY_NOTICE = () => t('回答较长,后续内容未能通过 QQ 完整发送,请回复“继续”。');
|
|
12
13
|
|
|
13
|
-
function safeSliceIndex(value, limit) {
|
|
14
|
-
let index = Math.min(limit, value.length);
|
|
15
|
-
const before = value.charCodeAt(index - 1);
|
|
16
|
-
const after = value.charCodeAt(index);
|
|
17
|
-
if (before >= 0xD800 && before <= 0xDBFF && after >= 0xDC00 && after <= 0xDFFF) {
|
|
18
|
-
index -= 1;
|
|
19
|
-
}
|
|
20
|
-
return Math.max(1, index);
|
|
21
|
-
}
|
|
22
|
-
|
|
23
|
-
function openingFence(line) {
|
|
24
|
-
const normalized = line.endsWith('\r') ? line.slice(0, -1) : line;
|
|
25
|
-
const match = CODE_FENCE_OPEN.exec(normalized);
|
|
26
|
-
if (!match) return null;
|
|
27
|
-
// CommonMark forbids backticks in the info string of a backtick fence.
|
|
28
|
-
if (match[2][0] === '`' && match[3].includes('`')) return null;
|
|
29
|
-
return { delimiter: match[2], info: match[3], indent: match[1].length };
|
|
30
|
-
}
|
|
31
|
-
|
|
32
|
-
function closesFence(line, opening) {
|
|
33
|
-
const match = /^ {0,3}(`{3,}|~{3,})[ \t]*$/.exec(line);
|
|
34
|
-
return Boolean(match
|
|
35
|
-
&& match[1][0] === opening.delimiter[0]
|
|
36
|
-
&& match[1].length >= opening.delimiter.length);
|
|
37
|
-
}
|
|
38
|
-
|
|
39
|
-
function longestLeadingRun(text, character) {
|
|
40
|
-
let longest = 0;
|
|
41
|
-
for (const line of text.split('\n')) {
|
|
42
|
-
let offset = 0;
|
|
43
|
-
while (offset < 3 && line[offset] === ' ') offset += 1;
|
|
44
|
-
let length = 0;
|
|
45
|
-
while (line[offset + length] === character) length += 1;
|
|
46
|
-
longest = Math.max(longest, length);
|
|
47
|
-
}
|
|
48
|
-
return longest;
|
|
49
|
-
}
|
|
50
|
-
|
|
51
|
-
function safeCodeFence(text, info) {
|
|
52
|
-
const backticks = '`'.repeat(Math.max(3, longestLeadingRun(text, '`') + 1));
|
|
53
|
-
const tildes = '~'.repeat(Math.max(3, longestLeadingRun(text, '~') + 1));
|
|
54
|
-
if (info.includes('`')) return tildes;
|
|
55
|
-
return backticks.length <= tildes.length ? backticks : tildes;
|
|
56
|
-
}
|
|
57
|
-
|
|
58
|
-
function removeFenceIndent(line, indent) {
|
|
59
|
-
let index = 0;
|
|
60
|
-
let column = 0;
|
|
61
|
-
let remaining = indent;
|
|
62
|
-
while (remaining > 0 && index < line.length) {
|
|
63
|
-
if (line[index] === ' ') {
|
|
64
|
-
index += 1;
|
|
65
|
-
column += 1;
|
|
66
|
-
remaining -= 1;
|
|
67
|
-
continue;
|
|
68
|
-
}
|
|
69
|
-
if (line[index] === '\t') {
|
|
70
|
-
const width = 4 - (column % 4);
|
|
71
|
-
if (width <= remaining) {
|
|
72
|
-
index += 1;
|
|
73
|
-
column += width;
|
|
74
|
-
remaining -= width;
|
|
75
|
-
continue;
|
|
76
|
-
}
|
|
77
|
-
return `${' '.repeat(width - remaining)}${line.slice(index + 1)}`;
|
|
78
|
-
}
|
|
79
|
-
break;
|
|
80
|
-
}
|
|
81
|
-
return line.slice(index);
|
|
82
|
-
}
|
|
83
|
-
|
|
84
|
-
function normalizeFencedContent(lines, indent) {
|
|
85
|
-
return indent === 0 ? lines : lines.map((line) => removeFenceIndent(line, indent));
|
|
86
|
-
}
|
|
87
|
-
|
|
88
|
-
function splitRawText(text, limit) {
|
|
89
|
-
if (text.length <= limit) return [text];
|
|
90
|
-
const parts = [];
|
|
91
|
-
let remaining = text;
|
|
92
|
-
while (remaining.length > limit) {
|
|
93
|
-
let index = safeSliceIndex(remaining, limit);
|
|
94
|
-
const newline = remaining.lastIndexOf('\n', index - 1);
|
|
95
|
-
if (newline >= 0) index = newline + 1;
|
|
96
|
-
parts.push(remaining.slice(0, index));
|
|
97
|
-
remaining = remaining.slice(index);
|
|
98
|
-
}
|
|
99
|
-
if (remaining || parts.length === 0) parts.push(remaining);
|
|
100
|
-
return parts;
|
|
101
|
-
}
|
|
102
|
-
|
|
103
|
-
function splitAsFencedCode(text, info, limit) {
|
|
104
|
-
if (limit < 8) return splitRawText(text, limit).map((part) => ({
|
|
105
|
-
markdown: part,
|
|
106
|
-
plain: part,
|
|
107
|
-
}));
|
|
108
|
-
const delimiter = safeCodeFence(text, info);
|
|
109
|
-
let opening = `${delimiter}${info}`;
|
|
110
|
-
if (opening.length + delimiter.length + 2 >= limit) opening = delimiter;
|
|
111
|
-
const payloadLimit = limit - opening.length - delimiter.length - 2;
|
|
112
|
-
if (payloadLimit < 1) return splitRawText(text, limit).map((part) => ({
|
|
113
|
-
markdown: part,
|
|
114
|
-
plain: part,
|
|
115
|
-
}));
|
|
116
|
-
return splitRawText(text, payloadLimit).map((part) => (
|
|
117
|
-
{
|
|
118
|
-
markdown: `${opening}\n${part}${part.endsWith('\n') ? '' : '\n'}${delimiter}`,
|
|
119
|
-
plain: part,
|
|
120
|
-
}
|
|
121
|
-
));
|
|
122
|
-
}
|
|
123
|
-
|
|
124
|
-
function hasClosingBacktickRun(text, start, length) {
|
|
125
|
-
for (let index = start; index < text.length;) {
|
|
126
|
-
if (text[index] !== '`') {
|
|
127
|
-
index += 1;
|
|
128
|
-
continue;
|
|
129
|
-
}
|
|
130
|
-
let end = index + 1;
|
|
131
|
-
while (text[end] === '`') end += 1;
|
|
132
|
-
if (end - index === length) return true;
|
|
133
|
-
index = end;
|
|
134
|
-
}
|
|
135
|
-
return false;
|
|
136
|
-
}
|
|
137
|
-
|
|
138
|
-
function gfmTableCells(line) {
|
|
139
|
-
const normalized = line.endsWith('\r') ? line.slice(0, -1) : line;
|
|
140
|
-
if (normalized.startsWith(' ') || normalized.startsWith('\t')) return null;
|
|
141
|
-
const text = normalized.trim();
|
|
142
|
-
if (!text) return null;
|
|
143
|
-
|
|
144
|
-
const cells = [];
|
|
145
|
-
let cell = '';
|
|
146
|
-
let separators = 0;
|
|
147
|
-
let codeRun = 0;
|
|
148
|
-
for (let index = 0; index < text.length;) {
|
|
149
|
-
const character = text[index];
|
|
150
|
-
if (character === '\\' && index + 1 < text.length) {
|
|
151
|
-
cell += text.slice(index, index + 2);
|
|
152
|
-
index += 2;
|
|
153
|
-
continue;
|
|
154
|
-
}
|
|
155
|
-
if (character === '`') {
|
|
156
|
-
let end = index + 1;
|
|
157
|
-
while (text[end] === '`') end += 1;
|
|
158
|
-
const length = end - index;
|
|
159
|
-
if (codeRun === length) {
|
|
160
|
-
codeRun = 0;
|
|
161
|
-
} else if (codeRun === 0 && hasClosingBacktickRun(text, end, length)) {
|
|
162
|
-
codeRun = length;
|
|
163
|
-
}
|
|
164
|
-
cell += text.slice(index, end);
|
|
165
|
-
index = end;
|
|
166
|
-
continue;
|
|
167
|
-
}
|
|
168
|
-
if (character === '|' && codeRun === 0) {
|
|
169
|
-
cells.push(cell);
|
|
170
|
-
cell = '';
|
|
171
|
-
separators += 1;
|
|
172
|
-
index += 1;
|
|
173
|
-
continue;
|
|
174
|
-
}
|
|
175
|
-
cell += character;
|
|
176
|
-
index += 1;
|
|
177
|
-
}
|
|
178
|
-
if (separators === 0) return null;
|
|
179
|
-
cells.push(cell);
|
|
180
|
-
if (cells[0].trim() === '') cells.shift();
|
|
181
|
-
if (cells.at(-1)?.trim() === '') cells.pop();
|
|
182
|
-
return cells.length > 0 ? cells : null;
|
|
183
|
-
}
|
|
184
|
-
|
|
185
|
-
function gfmTableStart(lines, index) {
|
|
186
|
-
const headerCells = gfmTableCells(lines[index]);
|
|
187
|
-
const separatorCells = gfmTableCells(lines[index + 1] ?? '');
|
|
188
|
-
if (!headerCells || !separatorCells || headerCells.length !== separatorCells.length) return null;
|
|
189
|
-
if (!separatorCells.every((cell) => /^\s*:?-+:?\s*$/.test(cell))) return null;
|
|
190
|
-
return { headerCells };
|
|
191
|
-
}
|
|
192
|
-
|
|
193
|
-
function supportedTableBodyCells(line) {
|
|
194
|
-
const normalized = line.endsWith('\r') ? line.slice(0, -1) : line;
|
|
195
|
-
const content = normalized.replace(/^ {0,3}/, '');
|
|
196
|
-
if (openingFence(normalized)
|
|
197
|
-
|| /^(?:#{1,6}(?:[ \t]+|$)|>|(?:[-+*]|\d{1,9}[.)])[ \t]+)/.test(content)) {
|
|
198
|
-
return null;
|
|
199
|
-
}
|
|
200
|
-
return gfmTableCells(normalized);
|
|
201
|
-
}
|
|
202
|
-
|
|
203
|
-
function splitOversizedTable(lines, limit) {
|
|
204
|
-
const source = lines.join('\n');
|
|
205
|
-
if (lines.length < 2 || !gfmTableStart(lines, 0)) {
|
|
206
|
-
return splitAsFencedCode(source, 'text', limit);
|
|
207
|
-
}
|
|
208
|
-
const prefix = `${lines[0]}\n${lines[1]}`;
|
|
209
|
-
const rows = lines.slice(2);
|
|
210
|
-
if (prefix.length > limit
|
|
211
|
-
|| rows.some((row) => `${prefix}\n${row}`.length > limit)) {
|
|
212
|
-
return splitAsFencedCode(source, 'text', limit);
|
|
213
|
-
}
|
|
214
|
-
const chunks = [];
|
|
215
|
-
let current = prefix;
|
|
216
|
-
let currentRows = [];
|
|
217
|
-
for (const row of rows) {
|
|
218
|
-
const candidate = `${current}\n${row}`;
|
|
219
|
-
if (candidate.length > limit) {
|
|
220
|
-
chunks.push({
|
|
221
|
-
markdown: current,
|
|
222
|
-
plain: chunks.length === 0 ? current : currentRows.join('\n'),
|
|
223
|
-
});
|
|
224
|
-
current = `${prefix}\n${row}`;
|
|
225
|
-
currentRows = [row];
|
|
226
|
-
} else {
|
|
227
|
-
current = candidate;
|
|
228
|
-
currentRows.push(row);
|
|
229
|
-
}
|
|
230
|
-
}
|
|
231
|
-
chunks.push({
|
|
232
|
-
markdown: current,
|
|
233
|
-
plain: chunks.length === 0 ? current : currentRows.join('\n'),
|
|
234
|
-
});
|
|
235
|
-
return chunks;
|
|
236
|
-
}
|
|
237
|
-
|
|
238
|
-
/**
|
|
239
|
-
* 按换行边界切分 Markdown 文本:
|
|
240
|
-
* - 可容纳的代码块和可识别的 GFM pipe table 保持完整;
|
|
241
|
-
* - 超长代码块会补齐围栏,超长表格会为每段重复表头;
|
|
242
|
-
* - 超长行在 limit 处硬切,避免单行超限无法投递。
|
|
243
|
-
*/
|
|
244
|
-
function chunkMarkdownParts(text, limit = DEFAULT_CHUNK_LIMIT) {
|
|
245
|
-
const value = typeof text === 'string' ? text : '';
|
|
246
|
-
const bound = Number.isInteger(limit) && limit > 0 ? limit : DEFAULT_CHUNK_LIMIT;
|
|
247
|
-
if (value.length <= bound) return value ? [{ markdown: value, plain: value }] : [];
|
|
248
|
-
|
|
249
|
-
// Once splitting is required, normalize line endings so a chunk never ends
|
|
250
|
-
// with a dangling CR whose paired LF moved to the next QQ message.
|
|
251
|
-
const lines = value.replace(/\r\n?/g, '\n').split('\n');
|
|
252
|
-
const chunks = [];
|
|
253
|
-
let current = null;
|
|
254
|
-
|
|
255
|
-
const flushCurrent = () => {
|
|
256
|
-
if (current === null) return;
|
|
257
|
-
if (current.length > 0) chunks.push({ markdown: current, plain: current });
|
|
258
|
-
current = null;
|
|
259
|
-
};
|
|
260
|
-
|
|
261
|
-
const appendBlock = (block) => {
|
|
262
|
-
if (block.length <= bound) {
|
|
263
|
-
if (current === null) {
|
|
264
|
-
current = block;
|
|
265
|
-
return;
|
|
266
|
-
}
|
|
267
|
-
const candidate = `${current}\n${block}`;
|
|
268
|
-
if (candidate.length > bound) {
|
|
269
|
-
if (current.length > 0) chunks.push({ markdown: current, plain: current });
|
|
270
|
-
current = block;
|
|
271
|
-
} else {
|
|
272
|
-
current = candidate;
|
|
273
|
-
}
|
|
274
|
-
return;
|
|
275
|
-
}
|
|
276
|
-
// 超大块:收束当前块后按 bound 硬切,保证每块可投递。
|
|
277
|
-
flushCurrent();
|
|
278
|
-
let remaining = block;
|
|
279
|
-
while (remaining.length > bound) {
|
|
280
|
-
const index = safeSliceIndex(remaining, bound);
|
|
281
|
-
const part = remaining.slice(0, index);
|
|
282
|
-
chunks.push({ markdown: part, plain: part });
|
|
283
|
-
remaining = remaining.slice(index);
|
|
284
|
-
}
|
|
285
|
-
current = remaining;
|
|
286
|
-
};
|
|
287
|
-
|
|
288
|
-
const appendLine = (line) => {
|
|
289
|
-
let remaining = line;
|
|
290
|
-
// 超长行先硬切,保证每块不超过 bound。
|
|
291
|
-
while (remaining.length > bound) {
|
|
292
|
-
flushCurrent();
|
|
293
|
-
const index = safeSliceIndex(remaining, bound);
|
|
294
|
-
const part = remaining.slice(0, index);
|
|
295
|
-
chunks.push({ markdown: part, plain: part });
|
|
296
|
-
remaining = remaining.slice(index);
|
|
297
|
-
}
|
|
298
|
-
appendBlock(remaining);
|
|
299
|
-
};
|
|
300
|
-
|
|
301
|
-
for (let index = 0; index < lines.length;) {
|
|
302
|
-
const fence = openingFence(lines[index]);
|
|
303
|
-
if (fence) {
|
|
304
|
-
flushCurrent();
|
|
305
|
-
let end = index + 1;
|
|
306
|
-
while (end < lines.length && !closesFence(lines[end], fence)) end += 1;
|
|
307
|
-
const hasClosingFence = end < lines.length;
|
|
308
|
-
const blockLines = lines.slice(index, hasClosingFence ? end + 1 : lines.length);
|
|
309
|
-
const block = blockLines.join('\n');
|
|
310
|
-
if (block.length <= bound) {
|
|
311
|
-
appendBlock(block);
|
|
312
|
-
} else {
|
|
313
|
-
const contentLines = blockLines.slice(1, hasClosingFence ? -1 : undefined);
|
|
314
|
-
const content = normalizeFencedContent(contentLines, fence.indent).join('\n');
|
|
315
|
-
chunks.push(...splitAsFencedCode(content, fence.info, bound));
|
|
316
|
-
}
|
|
317
|
-
index = hasClosingFence ? end + 1 : lines.length;
|
|
318
|
-
continue;
|
|
319
|
-
}
|
|
320
|
-
|
|
321
|
-
if (gfmTableStart(lines, index)) {
|
|
322
|
-
let end = index + 2;
|
|
323
|
-
while (end < lines.length && supportedTableBodyCells(lines[end])) end += 1;
|
|
324
|
-
const tableLines = lines.slice(index, end);
|
|
325
|
-
const table = tableLines.join('\n');
|
|
326
|
-
if (table.length <= bound) {
|
|
327
|
-
appendBlock(table);
|
|
328
|
-
} else {
|
|
329
|
-
flushCurrent();
|
|
330
|
-
chunks.push(...splitOversizedTable(tableLines, bound));
|
|
331
|
-
}
|
|
332
|
-
index = end;
|
|
333
|
-
continue;
|
|
334
|
-
}
|
|
335
|
-
|
|
336
|
-
appendLine(lines[index]);
|
|
337
|
-
index += 1;
|
|
338
|
-
}
|
|
339
|
-
|
|
340
|
-
flushCurrent();
|
|
341
|
-
return chunks.filter(({ markdown, plain }) => markdown.length > 0 || plain.length > 0);
|
|
342
|
-
}
|
|
343
|
-
|
|
344
|
-
export function chunkMarkdownText(text, limit = DEFAULT_CHUNK_LIMIT) {
|
|
345
|
-
return chunkMarkdownParts(text, limit).map(({ markdown }) => markdown);
|
|
346
|
-
}
|
|
347
|
-
|
|
348
14
|
function nextMsgSeq() {
|
|
349
15
|
// 与 SDK getNextMsgSeq 相同的 16-bit 随机 seed;同批分片在 seed 上递增,
|
|
350
16
|
// 保证同一个被动回复 msg_id 内不发生随机碰撞。
|
|
@@ -376,7 +42,7 @@ function sendPlainText(bot, target, content, msgSeq) {
|
|
|
376
42
|
* 以 markdown(msg_type=2)发送回复;单条被平台拒绝时回退纯文本(msg_type=0)。
|
|
377
43
|
* 返回每条消息的平台响应,供调用方提取 provider message ids。
|
|
378
44
|
*/
|
|
379
|
-
export async function sendMarkdownReply(bot, target, text, { logger } = {}) {
|
|
45
|
+
export async function sendMarkdownReply(bot, target, text, { logger, signal, requireComplete = false } = {}) {
|
|
380
46
|
const chunks = chunkMarkdownParts(text);
|
|
381
47
|
const results = [];
|
|
382
48
|
const firstMsgSeq = nextMsgSeq();
|
|
@@ -404,6 +70,7 @@ export async function sendMarkdownReply(bot, target, text, { logger } = {}) {
|
|
|
404
70
|
};
|
|
405
71
|
|
|
406
72
|
for (const [index, chunk] of chunks.entries()) {
|
|
73
|
+
signal?.throwIfAborted();
|
|
407
74
|
const msgSeq = (firstMsgSeq + index) & 0xFFFF;
|
|
408
75
|
const deliveryTarget = overflow && index >= passiveContentCount
|
|
409
76
|
? proactiveTarget
|
|
@@ -423,7 +90,7 @@ export async function sendMarkdownReply(bot, target, text, { logger } = {}) {
|
|
|
423
90
|
'[dsh-im:qq] markdown delivery outcome is uncertain; refusing a duplicate-prone retry:',
|
|
424
91
|
error,
|
|
425
92
|
);
|
|
426
|
-
if (results.length === 0) throw error;
|
|
93
|
+
if (requireComplete || results.length === 0) throw error;
|
|
427
94
|
await sendPartialNotice();
|
|
428
95
|
break;
|
|
429
96
|
}
|
|
@@ -437,7 +104,7 @@ export async function sendMarkdownReply(bot, target, text, { logger } = {}) {
|
|
|
437
104
|
try {
|
|
438
105
|
results.push(await sendPlainText(bot, deliveryTarget, chunk.plain, msgSeq));
|
|
439
106
|
} catch (error) {
|
|
440
|
-
if (results.length === 0) throw error;
|
|
107
|
+
if (requireComplete || results.length === 0) throw error;
|
|
441
108
|
await sendPartialNotice();
|
|
442
109
|
break;
|
|
443
110
|
}
|
|
@@ -9,6 +9,7 @@ import { t } from '../shared/i18n.mjs';
|
|
|
9
9
|
import { evaluateInboundAccess } from '../shared/inbound-access.mjs';
|
|
10
10
|
import { createQqBridgeStatus, QqHarnessBridge } from './qq-bridge.mjs';
|
|
11
11
|
import { isQqMessageAddressed, normalizeQqMentions } from './qq-mention.mjs';
|
|
12
|
+
import { sendMarkdownReply } from './markdown-reply.mjs';
|
|
12
13
|
|
|
13
14
|
function timeoutError() {
|
|
14
15
|
const error = new Error('QQ WebSocket did not become ready in time');
|
|
@@ -114,7 +115,7 @@ export class QqRuntime {
|
|
|
114
115
|
}, options);
|
|
115
116
|
}
|
|
116
117
|
|
|
117
|
-
async sendProactiveText(target, text, { signal } = {}) {
|
|
118
|
+
async sendProactiveText(target, text, { signal, format = 'plain' } = {}) {
|
|
118
119
|
const nativeId = target?.kind === 'user'
|
|
119
120
|
? (typeof target?.route?.userOpenId === 'string' ? target.route.userOpenId.trim() : '')
|
|
120
121
|
: target?.kind === 'group'
|
|
@@ -131,10 +132,17 @@ export class QqRuntime {
|
|
|
131
132
|
throw error;
|
|
132
133
|
}
|
|
133
134
|
signal?.throwIfAborted();
|
|
134
|
-
|
|
135
|
+
const replyTarget = {
|
|
135
136
|
scope: target.kind === 'user' ? 'c2c' : 'group',
|
|
136
137
|
targetId: nativeId,
|
|
137
|
-
}
|
|
138
|
+
};
|
|
139
|
+
if (format === 'markdown') {
|
|
140
|
+
await sendMarkdownReply(this.#bot, replyTarget, text, {
|
|
141
|
+
logger: this.#logger, signal, requireComplete: true,
|
|
142
|
+
});
|
|
143
|
+
return { sent: true };
|
|
144
|
+
}
|
|
145
|
+
return this.#bot.sendText(replyTarget, text);
|
|
138
146
|
}
|
|
139
147
|
|
|
140
148
|
async start() {
|