@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
|
@@ -0,0 +1,338 @@
|
|
|
1
|
+
// Structure-aware Markdown chunks reused by outbound channel renderers.
|
|
2
|
+
const DEFAULT_CHUNK_LIMIT = 4_500;
|
|
3
|
+
const CODE_FENCE_OPEN = /^( {0,3})(`{3,}|~{3,})(.*)$/;
|
|
4
|
+
|
|
5
|
+
function safeSliceIndex(value, limit) {
|
|
6
|
+
let index = Math.min(limit, value.length);
|
|
7
|
+
const before = value.charCodeAt(index - 1);
|
|
8
|
+
const after = value.charCodeAt(index);
|
|
9
|
+
if (before >= 0xD800 && before <= 0xDBFF && after >= 0xDC00 && after <= 0xDFFF) {
|
|
10
|
+
index -= 1;
|
|
11
|
+
}
|
|
12
|
+
return Math.max(1, index);
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
function openingFence(line) {
|
|
16
|
+
const normalized = line.endsWith('\r') ? line.slice(0, -1) : line;
|
|
17
|
+
const match = CODE_FENCE_OPEN.exec(normalized);
|
|
18
|
+
if (!match) return null;
|
|
19
|
+
// CommonMark forbids backticks in the info string of a backtick fence.
|
|
20
|
+
if (match[2][0] === '`' && match[3].includes('`')) return null;
|
|
21
|
+
return { delimiter: match[2], info: match[3], indent: match[1].length };
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function closesFence(line, opening) {
|
|
25
|
+
const match = /^ {0,3}(`{3,}|~{3,})[ \t]*$/.exec(line);
|
|
26
|
+
return Boolean(match
|
|
27
|
+
&& match[1][0] === opening.delimiter[0]
|
|
28
|
+
&& match[1].length >= opening.delimiter.length);
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function longestLeadingRun(text, character) {
|
|
32
|
+
let longest = 0;
|
|
33
|
+
for (const line of text.split('\n')) {
|
|
34
|
+
let offset = 0;
|
|
35
|
+
while (offset < 3 && line[offset] === ' ') offset += 1;
|
|
36
|
+
let length = 0;
|
|
37
|
+
while (line[offset + length] === character) length += 1;
|
|
38
|
+
longest = Math.max(longest, length);
|
|
39
|
+
}
|
|
40
|
+
return longest;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function safeCodeFence(text, info) {
|
|
44
|
+
const backticks = '`'.repeat(Math.max(3, longestLeadingRun(text, '`') + 1));
|
|
45
|
+
const tildes = '~'.repeat(Math.max(3, longestLeadingRun(text, '~') + 1));
|
|
46
|
+
if (info.includes('`')) return tildes;
|
|
47
|
+
return backticks.length <= tildes.length ? backticks : tildes;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function removeFenceIndent(line, indent) {
|
|
51
|
+
let index = 0;
|
|
52
|
+
let column = 0;
|
|
53
|
+
let remaining = indent;
|
|
54
|
+
while (remaining > 0 && index < line.length) {
|
|
55
|
+
if (line[index] === ' ') {
|
|
56
|
+
index += 1;
|
|
57
|
+
column += 1;
|
|
58
|
+
remaining -= 1;
|
|
59
|
+
continue;
|
|
60
|
+
}
|
|
61
|
+
if (line[index] === '\t') {
|
|
62
|
+
const width = 4 - (column % 4);
|
|
63
|
+
if (width <= remaining) {
|
|
64
|
+
index += 1;
|
|
65
|
+
column += width;
|
|
66
|
+
remaining -= width;
|
|
67
|
+
continue;
|
|
68
|
+
}
|
|
69
|
+
return `${' '.repeat(width - remaining)}${line.slice(index + 1)}`;
|
|
70
|
+
}
|
|
71
|
+
break;
|
|
72
|
+
}
|
|
73
|
+
return line.slice(index);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function normalizeFencedContent(lines, indent) {
|
|
77
|
+
return indent === 0 ? lines : lines.map((line) => removeFenceIndent(line, indent));
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
function splitRawText(text, limit) {
|
|
81
|
+
if (text.length <= limit) return [text];
|
|
82
|
+
const parts = [];
|
|
83
|
+
let remaining = text;
|
|
84
|
+
while (remaining.length > limit) {
|
|
85
|
+
let index = safeSliceIndex(remaining, limit);
|
|
86
|
+
const newline = remaining.lastIndexOf('\n', index - 1);
|
|
87
|
+
if (newline >= 0) index = newline + 1;
|
|
88
|
+
parts.push(remaining.slice(0, index));
|
|
89
|
+
remaining = remaining.slice(index);
|
|
90
|
+
}
|
|
91
|
+
if (remaining || parts.length === 0) parts.push(remaining);
|
|
92
|
+
return parts;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
function splitAsFencedCode(text, info, limit) {
|
|
96
|
+
if (limit < 8) return splitRawText(text, limit).map((part) => ({
|
|
97
|
+
markdown: part,
|
|
98
|
+
plain: part,
|
|
99
|
+
}));
|
|
100
|
+
const delimiter = safeCodeFence(text, info);
|
|
101
|
+
let opening = `${delimiter}${info}`;
|
|
102
|
+
if (opening.length + delimiter.length + 2 >= limit) opening = delimiter;
|
|
103
|
+
const payloadLimit = limit - opening.length - delimiter.length - 2;
|
|
104
|
+
if (payloadLimit < 1) return splitRawText(text, limit).map((part) => ({
|
|
105
|
+
markdown: part,
|
|
106
|
+
plain: part,
|
|
107
|
+
}));
|
|
108
|
+
return splitRawText(text, payloadLimit).map((part) => (
|
|
109
|
+
{
|
|
110
|
+
markdown: `${opening}\n${part}${part.endsWith('\n') ? '' : '\n'}${delimiter}`,
|
|
111
|
+
plain: part,
|
|
112
|
+
}
|
|
113
|
+
));
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
function hasClosingBacktickRun(text, start, length) {
|
|
117
|
+
for (let index = start; index < text.length;) {
|
|
118
|
+
if (text[index] !== '`') {
|
|
119
|
+
index += 1;
|
|
120
|
+
continue;
|
|
121
|
+
}
|
|
122
|
+
let end = index + 1;
|
|
123
|
+
while (text[end] === '`') end += 1;
|
|
124
|
+
if (end - index === length) return true;
|
|
125
|
+
index = end;
|
|
126
|
+
}
|
|
127
|
+
return false;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
function gfmTableCells(line) {
|
|
131
|
+
const normalized = line.endsWith('\r') ? line.slice(0, -1) : line;
|
|
132
|
+
if (normalized.startsWith(' ') || normalized.startsWith('\t')) return null;
|
|
133
|
+
const text = normalized.trim();
|
|
134
|
+
if (!text) return null;
|
|
135
|
+
|
|
136
|
+
const cells = [];
|
|
137
|
+
let cell = '';
|
|
138
|
+
let separators = 0;
|
|
139
|
+
let codeRun = 0;
|
|
140
|
+
for (let index = 0; index < text.length;) {
|
|
141
|
+
const character = text[index];
|
|
142
|
+
if (character === '\\' && index + 1 < text.length) {
|
|
143
|
+
cell += text.slice(index, index + 2);
|
|
144
|
+
index += 2;
|
|
145
|
+
continue;
|
|
146
|
+
}
|
|
147
|
+
if (character === '`') {
|
|
148
|
+
let end = index + 1;
|
|
149
|
+
while (text[end] === '`') end += 1;
|
|
150
|
+
const length = end - index;
|
|
151
|
+
if (codeRun === length) {
|
|
152
|
+
codeRun = 0;
|
|
153
|
+
} else if (codeRun === 0 && hasClosingBacktickRun(text, end, length)) {
|
|
154
|
+
codeRun = length;
|
|
155
|
+
}
|
|
156
|
+
cell += text.slice(index, end);
|
|
157
|
+
index = end;
|
|
158
|
+
continue;
|
|
159
|
+
}
|
|
160
|
+
if (character === '|' && codeRun === 0) {
|
|
161
|
+
cells.push(cell);
|
|
162
|
+
cell = '';
|
|
163
|
+
separators += 1;
|
|
164
|
+
index += 1;
|
|
165
|
+
continue;
|
|
166
|
+
}
|
|
167
|
+
cell += character;
|
|
168
|
+
index += 1;
|
|
169
|
+
}
|
|
170
|
+
if (separators === 0) return null;
|
|
171
|
+
cells.push(cell);
|
|
172
|
+
if (cells[0].trim() === '') cells.shift();
|
|
173
|
+
if (cells.at(-1)?.trim() === '') cells.pop();
|
|
174
|
+
return cells.length > 0 ? cells : null;
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
function gfmTableStart(lines, index) {
|
|
178
|
+
const headerCells = gfmTableCells(lines[index]);
|
|
179
|
+
const separatorCells = gfmTableCells(lines[index + 1] ?? '');
|
|
180
|
+
if (!headerCells || !separatorCells || headerCells.length !== separatorCells.length) return null;
|
|
181
|
+
if (!separatorCells.every((cell) => /^\s*:?-+:?\s*$/.test(cell))) return null;
|
|
182
|
+
return { headerCells };
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
function supportedTableBodyCells(line) {
|
|
186
|
+
const normalized = line.endsWith('\r') ? line.slice(0, -1) : line;
|
|
187
|
+
const content = normalized.replace(/^ {0,3}/, '');
|
|
188
|
+
if (openingFence(normalized)
|
|
189
|
+
|| /^(?:#{1,6}(?:[ \t]+|$)|>|(?:[-+*]|\d{1,9}[.)])[ \t]+)/.test(content)) {
|
|
190
|
+
return null;
|
|
191
|
+
}
|
|
192
|
+
return gfmTableCells(normalized);
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
function splitOversizedTable(lines, limit) {
|
|
196
|
+
const source = lines.join('\n');
|
|
197
|
+
if (lines.length < 2 || !gfmTableStart(lines, 0)) {
|
|
198
|
+
return splitAsFencedCode(source, 'text', limit);
|
|
199
|
+
}
|
|
200
|
+
const prefix = `${lines[0]}\n${lines[1]}`;
|
|
201
|
+
const rows = lines.slice(2);
|
|
202
|
+
if (prefix.length > limit
|
|
203
|
+
|| rows.some((row) => `${prefix}\n${row}`.length > limit)) {
|
|
204
|
+
return splitAsFencedCode(source, 'text', limit);
|
|
205
|
+
}
|
|
206
|
+
const chunks = [];
|
|
207
|
+
let current = prefix;
|
|
208
|
+
let currentRows = [];
|
|
209
|
+
for (const row of rows) {
|
|
210
|
+
const candidate = `${current}\n${row}`;
|
|
211
|
+
if (candidate.length > limit) {
|
|
212
|
+
chunks.push({
|
|
213
|
+
markdown: current,
|
|
214
|
+
plain: chunks.length === 0 ? current : currentRows.join('\n'),
|
|
215
|
+
});
|
|
216
|
+
current = `${prefix}\n${row}`;
|
|
217
|
+
currentRows = [row];
|
|
218
|
+
} else {
|
|
219
|
+
current = candidate;
|
|
220
|
+
currentRows.push(row);
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
chunks.push({
|
|
224
|
+
markdown: current,
|
|
225
|
+
plain: chunks.length === 0 ? current : currentRows.join('\n'),
|
|
226
|
+
});
|
|
227
|
+
return chunks;
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
/**
|
|
231
|
+
* 按换行边界切分 Markdown 文本:
|
|
232
|
+
* - 可容纳的代码块和可识别的 GFM pipe table 保持完整;
|
|
233
|
+
* - 超长代码块会补齐围栏,超长表格会为每段重复表头;
|
|
234
|
+
* - 超长行在 limit 处硬切,避免单行超限无法投递。
|
|
235
|
+
*/
|
|
236
|
+
export function chunkMarkdownParts(text, limit = DEFAULT_CHUNK_LIMIT) {
|
|
237
|
+
const value = typeof text === 'string' ? text : '';
|
|
238
|
+
const bound = Number.isInteger(limit) && limit > 0 ? limit : DEFAULT_CHUNK_LIMIT;
|
|
239
|
+
if (value.length <= bound) return value ? [{ markdown: value, plain: value }] : [];
|
|
240
|
+
|
|
241
|
+
// Once splitting is required, normalize line endings so a chunk never ends
|
|
242
|
+
// with a dangling CR whose paired LF moved to the next QQ message.
|
|
243
|
+
const lines = value.replace(/\r\n?/g, '\n').split('\n');
|
|
244
|
+
const chunks = [];
|
|
245
|
+
let current = null;
|
|
246
|
+
|
|
247
|
+
const flushCurrent = () => {
|
|
248
|
+
if (current === null) return;
|
|
249
|
+
if (current.length > 0) chunks.push({ markdown: current, plain: current });
|
|
250
|
+
current = null;
|
|
251
|
+
};
|
|
252
|
+
|
|
253
|
+
const appendBlock = (block) => {
|
|
254
|
+
if (block.length <= bound) {
|
|
255
|
+
if (current === null) {
|
|
256
|
+
current = block;
|
|
257
|
+
return;
|
|
258
|
+
}
|
|
259
|
+
const candidate = `${current}\n${block}`;
|
|
260
|
+
if (candidate.length > bound) {
|
|
261
|
+
if (current.length > 0) chunks.push({ markdown: current, plain: current });
|
|
262
|
+
current = block;
|
|
263
|
+
} else {
|
|
264
|
+
current = candidate;
|
|
265
|
+
}
|
|
266
|
+
return;
|
|
267
|
+
}
|
|
268
|
+
// 超大块:收束当前块后按 bound 硬切,保证每块可投递。
|
|
269
|
+
flushCurrent();
|
|
270
|
+
let remaining = block;
|
|
271
|
+
while (remaining.length > bound) {
|
|
272
|
+
const index = safeSliceIndex(remaining, bound);
|
|
273
|
+
const part = remaining.slice(0, index);
|
|
274
|
+
chunks.push({ markdown: part, plain: part });
|
|
275
|
+
remaining = remaining.slice(index);
|
|
276
|
+
}
|
|
277
|
+
current = remaining;
|
|
278
|
+
};
|
|
279
|
+
|
|
280
|
+
const appendLine = (line) => {
|
|
281
|
+
let remaining = line;
|
|
282
|
+
// 超长行先硬切,保证每块不超过 bound。
|
|
283
|
+
while (remaining.length > bound) {
|
|
284
|
+
flushCurrent();
|
|
285
|
+
const index = safeSliceIndex(remaining, bound);
|
|
286
|
+
const part = remaining.slice(0, index);
|
|
287
|
+
chunks.push({ markdown: part, plain: part });
|
|
288
|
+
remaining = remaining.slice(index);
|
|
289
|
+
}
|
|
290
|
+
appendBlock(remaining);
|
|
291
|
+
};
|
|
292
|
+
|
|
293
|
+
for (let index = 0; index < lines.length;) {
|
|
294
|
+
const fence = openingFence(lines[index]);
|
|
295
|
+
if (fence) {
|
|
296
|
+
flushCurrent();
|
|
297
|
+
let end = index + 1;
|
|
298
|
+
while (end < lines.length && !closesFence(lines[end], fence)) end += 1;
|
|
299
|
+
const hasClosingFence = end < lines.length;
|
|
300
|
+
const blockLines = lines.slice(index, hasClosingFence ? end + 1 : lines.length);
|
|
301
|
+
const block = blockLines.join('\n');
|
|
302
|
+
if (block.length <= bound) {
|
|
303
|
+
appendBlock(block);
|
|
304
|
+
} else {
|
|
305
|
+
const contentLines = blockLines.slice(1, hasClosingFence ? -1 : undefined);
|
|
306
|
+
const content = normalizeFencedContent(contentLines, fence.indent).join('\n');
|
|
307
|
+
chunks.push(...splitAsFencedCode(content, fence.info, bound));
|
|
308
|
+
}
|
|
309
|
+
index = hasClosingFence ? end + 1 : lines.length;
|
|
310
|
+
continue;
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
if (gfmTableStart(lines, index)) {
|
|
314
|
+
let end = index + 2;
|
|
315
|
+
while (end < lines.length && supportedTableBodyCells(lines[end])) end += 1;
|
|
316
|
+
const tableLines = lines.slice(index, end);
|
|
317
|
+
const table = tableLines.join('\n');
|
|
318
|
+
if (table.length <= bound) {
|
|
319
|
+
appendBlock(table);
|
|
320
|
+
} else {
|
|
321
|
+
flushCurrent();
|
|
322
|
+
chunks.push(...splitOversizedTable(tableLines, bound));
|
|
323
|
+
}
|
|
324
|
+
index = end;
|
|
325
|
+
continue;
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
appendLine(lines[index]);
|
|
329
|
+
index += 1;
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
flushCurrent();
|
|
333
|
+
return chunks.filter(({ markdown, plain }) => markdown.length > 0 || plain.length > 0);
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
export function chunkMarkdownText(text, limit = DEFAULT_CHUNK_LIMIT) {
|
|
337
|
+
return chunkMarkdownParts(text, limit).map(({ markdown }) => markdown);
|
|
338
|
+
}
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { connectionHint, extractConnectionEvidence } from './connection-error.mjs';
|
|
1
|
+
import { connectionHint, extractConnectionEvidence, messageDiagnosticError, runtimeDiagnosticDetails } from './connection-error.mjs';
|
|
2
2
|
import { diagnosticFields } from './diagnostic-details.mjs';
|
|
3
3
|
import { randomUUID } from 'node:crypto';
|
|
4
4
|
|
|
@@ -26,6 +26,8 @@ const PROVIDER_FAILURES = Object.freeze({
|
|
|
26
26
|
});
|
|
27
27
|
|
|
28
28
|
const FAILURE_MESSAGES = Object.freeze({
|
|
29
|
+
HARNESS_INTERACTION_TIMEOUT:
|
|
30
|
+
'与 DSH 的交互通道在 30 秒内未就绪,本条消息尚未提交执行。请稍后重试。',
|
|
29
31
|
HARNESS_CONNECT:
|
|
30
32
|
'无法连接处理服务,消息尚未提交。请确认 DeepSeek Harness 正在运行后重试。',
|
|
31
33
|
HARNESS_TIMEOUT:
|
|
@@ -107,18 +109,23 @@ function providerFailureCode(error) {
|
|
|
107
109
|
return PROVIDER_FAILURES[value.trim().toUpperCase()] ?? null;
|
|
108
110
|
}
|
|
109
111
|
|
|
110
|
-
function failureCode(error) {
|
|
112
|
+
function failureCode(error, details) {
|
|
111
113
|
const code = typeof error?.code === 'string' ? error.code : '';
|
|
112
114
|
const providerCode = providerFailureCode(error);
|
|
113
115
|
if (providerCode) return providerCode;
|
|
116
|
+
if (code === 'harness-interaction-timeout') return 'HARNESS_INTERACTION_TIMEOUT';
|
|
117
|
+
|
|
118
|
+
const uncertain = details.submissionState === undefined
|
|
119
|
+
? ['session.prompt', 'session.history'].includes(error?.method)
|
|
120
|
+
: details.submissionState !== 'not-submitted';
|
|
114
121
|
|
|
115
122
|
if (code === 'harness-connect-failed') {
|
|
116
|
-
return
|
|
123
|
+
return uncertain
|
|
117
124
|
? 'HARNESS_RESULT_UNCERTAIN'
|
|
118
125
|
: 'HARNESS_CONNECT';
|
|
119
126
|
}
|
|
120
127
|
if (code === 'harness-timeout') {
|
|
121
|
-
return
|
|
128
|
+
return uncertain
|
|
122
129
|
? 'HARNESS_RESULT_UNCERTAIN'
|
|
123
130
|
: 'HARNESS_TIMEOUT';
|
|
124
131
|
}
|
|
@@ -128,7 +135,7 @@ function failureCode(error) {
|
|
|
128
135
|
'harness-loopback-forbidden', 'harness-host-untrusted',
|
|
129
136
|
'harness-request-forbidden',
|
|
130
137
|
].includes(code)) return 'HARNESS_ACCESS';
|
|
131
|
-
if (['harness-api-not-found', 'harness-response-invalid'].includes(code)) {
|
|
138
|
+
if (['harness-api-not-found', 'harness-response-invalid', 'gateway/input-invalid'].includes(code)) {
|
|
132
139
|
return 'HARNESS_PROTOCOL';
|
|
133
140
|
}
|
|
134
141
|
if (code === 'harness-turn-failed') return 'INTERNAL_UNKNOWN';
|
|
@@ -205,25 +212,38 @@ export function classifyMessageFailure(error, {
|
|
|
205
212
|
at = Date.now(),
|
|
206
213
|
} = {}) {
|
|
207
214
|
const safeReason = safeFailureReason(reason);
|
|
208
|
-
const
|
|
215
|
+
const details = extractConnectionEvidence(error).details;
|
|
216
|
+
const classifiedCode = failureCode(error, details);
|
|
209
217
|
const businessCode = classifiedCode === 'INTERNAL_UNKNOWN'
|
|
210
218
|
&& safeReason
|
|
211
219
|
&& typeof userMessage === 'string'
|
|
212
220
|
&& userMessage.trim()
|
|
213
221
|
? 'INPUT_INVALID'
|
|
214
222
|
: classifiedCode;
|
|
215
|
-
const details = extractConnectionEvidence(error).details;
|
|
216
223
|
const code = businessCode === 'INTERNAL_UNKNOWN'
|
|
217
224
|
? transportFailureCode(error, details)
|
|
218
225
|
: businessCode;
|
|
219
226
|
if (code === 'REQUEST_TIMEOUT' || code === 'NETWORK_ERROR') details.hint = connectionHint(details);
|
|
227
|
+
if (details.stage || details.submissionState) Object.assign(details, runtimeDiagnosticDetails());
|
|
228
|
+
let message = FAILURE_MESSAGES[code];
|
|
229
|
+
if (['INTERNAL_UNKNOWN', 'REQUEST_TIMEOUT', 'NETWORK_ERROR'].includes(code)) {
|
|
230
|
+
if (details.submissionState === 'not-submitted') {
|
|
231
|
+
message = code === 'REQUEST_TIMEOUT'
|
|
232
|
+
? '消息提交前的操作超时,本条消息尚未提交执行。请稍后重试。'
|
|
233
|
+
: code === 'NETWORK_ERROR'
|
|
234
|
+
? '消息提交前的网络请求失败,本条消息尚未提交执行。请检查连接后重试。'
|
|
235
|
+
: '消息在提交前处理失败,尚未提交执行。请重试;若持续发生,请将参考号提供给管理员。';
|
|
236
|
+
} else if (details.submissionState) {
|
|
237
|
+
message = '消息已尝试提交,但任务结果尚未确认。请先到 DSH 查看状态,不要立即重复提交;若持续发生,请将参考号提供给管理员。';
|
|
238
|
+
}
|
|
239
|
+
}
|
|
220
240
|
return Object.freeze({
|
|
221
|
-
...(code === 'INTERNAL_UNKNOWN' || details.reason !== 'unknown' || details.httpStatus || details.providerCode || details.method ? { details } : {}),
|
|
241
|
+
...(code === 'INTERNAL_UNKNOWN' || details.reason !== 'unknown' || details.httpStatus || details.providerCode || details.method || details.stage || details.submissionState ? { details } : {}),
|
|
222
242
|
code,
|
|
223
243
|
reason: safeReason ?? safeFailureReason(error?.code) ?? code,
|
|
224
244
|
message: typeof userMessage === 'string' && userMessage.trim()
|
|
225
245
|
? userMessage.trim()
|
|
226
|
-
: t(
|
|
246
|
+
: t(message) + (code === 'SESSION_ARCHIVED' && error?.promptAccepted === true
|
|
227
247
|
? `\n${t('本条消息可能仍在会话队列中,请先到 DSH 查看状态,避免重复提交。')}` : ''),
|
|
228
248
|
referenceId: safeReferenceId(referenceId),
|
|
229
249
|
at: Number.isFinite(at) ? at : Date.now(),
|
|
@@ -237,7 +257,7 @@ export function messageFailureText(failure) {
|
|
|
237
257
|
export function messageFailureDiagnostic(error, failure) {
|
|
238
258
|
const evidence = extractConnectionEvidence(error);
|
|
239
259
|
return { code: failure.code, reason: failure.reason, referenceId: failure.referenceId,
|
|
240
|
-
details: evidence.details, errors: evidence.errors };
|
|
260
|
+
details: failure.details ?? evidence.details, errors: evidence.errors, ...evidence.correlation };
|
|
241
261
|
}
|
|
242
262
|
|
|
243
263
|
export function setLastMessageFailure(status, error, options) {
|
|
@@ -262,7 +282,7 @@ export function channelDeliveryFailure(error, { uncertain = true } = {}) {
|
|
|
262
282
|
? 'channel-delivery-uncertain'
|
|
263
283
|
: 'channel-delivery-failed';
|
|
264
284
|
if (Number.isInteger(status)) wrapped.status = status;
|
|
265
|
-
return wrapped;
|
|
285
|
+
return messageDiagnosticError(wrapped, { stage: 'message.send' });
|
|
266
286
|
}
|
|
267
287
|
|
|
268
288
|
export function publicMessageFailure(value) {
|
|
@@ -649,8 +649,11 @@ export class TextHarnessBridge {
|
|
|
649
649
|
});
|
|
650
650
|
}
|
|
651
651
|
|
|
652
|
-
sendProactiveText(target, text, { signal } = {}) {
|
|
652
|
+
sendProactiveText(target, text, { signal, format = 'plain' } = {}) {
|
|
653
653
|
signal?.throwIfAborted();
|
|
654
|
+
if (format === 'markdown' && typeof this.#bot.sendDelivery === 'function') {
|
|
655
|
+
return this.#bot.sendDelivery(target, createTextDeliveryBlock(text, format));
|
|
656
|
+
}
|
|
654
657
|
return this.#bot.sendText(target, text);
|
|
655
658
|
}
|
|
656
659
|
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { withSessionBindingLock } from './session-binding-lock.mjs';
|
|
2
2
|
import { initialSessionTitle } from './session-title.mjs';
|
|
3
|
+
import { messageDiagnosticError } from './connection-error.mjs';
|
|
3
4
|
|
|
4
5
|
export const WORKSPACE_SESSION_STALE = 'workspace-session-stale';
|
|
5
6
|
|
|
@@ -114,16 +115,19 @@ export async function askInWorkspaceSession({
|
|
|
114
115
|
?? (typeof askOptions === 'object' ? askOptions?.signal : undefined);
|
|
115
116
|
const renameOptions = renameSignal ? { signal: renameSignal } : undefined;
|
|
116
117
|
while (true) {
|
|
118
|
+
const diagnostic = { stage: 'session.prepare', submissionState: 'not-submitted' };
|
|
117
119
|
try {
|
|
118
120
|
const binding = await withSessionBindingLock(state, key, async () => {
|
|
119
121
|
await awaitPendingConversationSwitch(harness, key);
|
|
120
122
|
let sessionId = state.sessionFor(key);
|
|
123
|
+
diagnostic.sessionId = sessionId;
|
|
121
124
|
let session = sessionId ? workspaceSession(harness, sessionId, key) : null;
|
|
122
125
|
if (!session || !(await sessionExists(session, existsOptions))) {
|
|
123
126
|
sessionId = await createSession(harness, {
|
|
124
127
|
conversationKey: key,
|
|
125
128
|
...(createOptions ?? {}),
|
|
126
129
|
});
|
|
130
|
+
diagnostic.sessionId = sessionId;
|
|
127
131
|
if (await state.setSession(key, sessionId) === false) return null;
|
|
128
132
|
// Binding committed: capture the conversation's effective-workspace
|
|
129
133
|
// generation together with the session, so the prompt below is fenced
|
|
@@ -150,6 +154,8 @@ export async function askInWorkspaceSession({
|
|
|
150
154
|
return { sessionId, session };
|
|
151
155
|
});
|
|
152
156
|
if (!binding) continue;
|
|
157
|
+
diagnostic.sessionId = binding.sessionId;
|
|
158
|
+
diagnostic.stage = 'prompt.prepare';
|
|
153
159
|
const artifacts = [];
|
|
154
160
|
const originalOnArtifact = typeof askOptions === 'object'
|
|
155
161
|
&& typeof askOptions?.onArtifact === 'function'
|
|
@@ -172,6 +178,9 @@ export async function askInWorkspaceSession({
|
|
|
172
178
|
? await prepareContent({ sessionId: binding.sessionId, content, text })
|
|
173
179
|
: content ?? text;
|
|
174
180
|
try {
|
|
181
|
+
// An alternate Harness adapter may not supply finer ask() diagnostics.
|
|
182
|
+
diagnostic.stage = 'operation';
|
|
183
|
+
diagnostic.submissionState = 'unknown';
|
|
175
184
|
answer = await binding.session.ask(prompt, artifactOptions);
|
|
176
185
|
} catch (error) {
|
|
177
186
|
if (error?.code === 'harness-reply-timeout' && deferredDelivery) {
|
|
@@ -190,7 +199,7 @@ export async function askInWorkspaceSession({
|
|
|
190
199
|
...(artifacts.length > 0 ? { artifacts } : {}),
|
|
191
200
|
};
|
|
192
201
|
} catch (error) {
|
|
193
|
-
if (error?.code !== WORKSPACE_SESSION_STALE) throw error;
|
|
202
|
+
if (error?.code !== WORKSPACE_SESSION_STALE) throw messageDiagnosticError(error, diagnostic);
|
|
194
203
|
}
|
|
195
204
|
}
|
|
196
205
|
}
|
|
@@ -271,13 +271,14 @@ export class SlackApi {
|
|
|
271
271
|
: null;
|
|
272
272
|
}
|
|
273
273
|
|
|
274
|
-
postMessage({ channelId, text, threadTs, signal }) {
|
|
274
|
+
postMessage({ channelId, text, threadTs, signal, format = 'plain' }) {
|
|
275
275
|
return this.#request('chat.postMessage', {
|
|
276
276
|
tokenKind: 'bot',
|
|
277
277
|
signal,
|
|
278
278
|
body: {
|
|
279
279
|
channel: slackId(channelId, 'channel id'),
|
|
280
280
|
text: safeOutgoingText(text),
|
|
281
|
+
...(format === 'markdown' ? { blocks: [{ type: 'markdown', text: safeOutgoingText(text) }] } : {}),
|
|
281
282
|
...(threadTs ? { thread_ts: cleanString(threadTs) } : {}),
|
|
282
283
|
mrkdwn: true,
|
|
283
284
|
link_names: false,
|
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
import { extractConnectionEvidence, createConnectionDiagnostics, atConnectionStage } from '../shared/connection-error.mjs';
|
|
2
2
|
import { splitMessageText } from '../shared/editable-message-stream.mjs';
|
|
3
|
+
import { chunkMarkdownParts } from '../shared/markdown-chunks.mjs';
|
|
4
|
+
import { createTextDeliveryBlock } from '../shared/semantic/delivery.mjs';
|
|
3
5
|
import { t } from '../shared/i18n.mjs';
|
|
4
6
|
import { SlackApi } from './slack-api.mjs';
|
|
5
7
|
import { createSlackBridgeStatus, SlackHarnessBridge } from './slack-bridge.mjs';
|
|
@@ -350,6 +352,34 @@ export class SlackBotClient {
|
|
|
350
352
|
return { providerMessageIds };
|
|
351
353
|
}
|
|
352
354
|
|
|
355
|
+
async sendDelivery(target, value) {
|
|
356
|
+
const block = createTextDeliveryBlock(value);
|
|
357
|
+
if (block.format === 'plain') return this.sendText(target, block.text);
|
|
358
|
+
const providerMessageIds = [];
|
|
359
|
+
// Standard Markdown blocks have a 12,000-character payload limit.
|
|
360
|
+
for (const chunk of chunkMarkdownParts(block.text, SLACK_STREAM_CHUNK_LIMIT)) {
|
|
361
|
+
this.#signal?.throwIfAborted();
|
|
362
|
+
try {
|
|
363
|
+
const result = await this.#api.postMessage({
|
|
364
|
+
channelId: target.channelId, threadTs: target.threadTs,
|
|
365
|
+
text: chunk.markdown, format: 'markdown', signal: this.#signal,
|
|
366
|
+
});
|
|
367
|
+
if (typeof result?.ts === 'string' && result.ts) providerMessageIds.push(result.ts);
|
|
368
|
+
} catch (error) {
|
|
369
|
+
// Retry only a definite formatting rejection, never an ambiguous send.
|
|
370
|
+
if (this.#signal?.aborted || Number(error?.status) >= 500
|
|
371
|
+
|| !['invalid_blocks', 'invalid_blocks_format', 'invalid_markdown']
|
|
372
|
+
.includes(error?.providerCode)) throw error;
|
|
373
|
+
this.#logger?.warn?.('[dsh-im:slack] Markdown rejected; sending this chunk as text');
|
|
374
|
+
if (chunk.plain) {
|
|
375
|
+
const result = await this.sendText(target, chunk.plain);
|
|
376
|
+
providerMessageIds.push(...result.providerMessageIds);
|
|
377
|
+
}
|
|
378
|
+
}
|
|
379
|
+
}
|
|
380
|
+
return { providerMessageIds };
|
|
381
|
+
}
|
|
382
|
+
|
|
353
383
|
async addReaction(target, emoji, { signal } = {}) {
|
|
354
384
|
const reactionKey = String(emoji ?? '').trim();
|
|
355
385
|
await this.#api.addReaction({
|
|
@@ -761,6 +761,7 @@ export class TelegramBotClient {
|
|
|
761
761
|
try {
|
|
762
762
|
chunks = splitTelegramRichMarkdown(block.text);
|
|
763
763
|
} catch {
|
|
764
|
+
this.#logger.warn?.('[dsh-im:telegram] Rich Markdown could not be prepared; sending plain text');
|
|
764
765
|
return this.#sendPlain(target, block.text);
|
|
765
766
|
}
|
|
766
767
|
const providerMessageIds = [];
|