@xmanrui/dsh-im 4.20.2 → 4.21.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.en.md +21 -9
- package/README.md +21 -9
- package/lib/client.js +116 -21
- package/lib/index.js +288 -287
- package/package.json +17 -1
- package/plugin-src/client/channel-logos.js +18 -5
- package/plugin-src/client/channels/imessage/styles.js +1 -1
- package/plugin-src/client/channels/slack/styles.js +1 -1
- package/plugin-src/client/channels/weixin/connection-error.js +4 -1
- package/plugin-src/client/i18n.js +8 -0
- package/plugin-src/client/model-setting.js +4 -2
- package/plugin-src/client/session-channel-logos.js +1 -2
- package/plugin-src/client/styles.js +3 -2
- package/plugin-src/host/channels/qq/production.mjs +1 -1
- package/plugin-src/host/channels/qq/rpc.mjs +2 -1
- package/plugin-src/host/index.mjs +7 -0
- package/plugin-src/host/injected-context.mjs +104 -0
- package/plugin-src/host/modern-harness-api.mjs +91 -3
- package/scripts/verify-model-setting.mjs +4 -1
- package/scripts/verify-package.mjs +3 -1
- package/src/channels/dingtalk/dingtalk-bridge.mjs +105 -27
- package/src/channels/discord/discord-runtime.mjs +4 -1
- package/src/channels/feishu/bridge.mjs +44 -3
- package/src/channels/feishu/feishu-channel.mjs +1 -1
- package/src/channels/qq/qq-bridge.mjs +36 -3
- package/src/channels/qq/qq-controller.mjs +11 -5
- package/src/channels/qq/state-error.mjs +17 -0
- package/src/channels/qq/state-store.mjs +35 -9
- package/src/channels/shared/batch-input.mjs +22 -2
- package/src/channels/shared/bot-workspace-store.mjs +46 -26
- package/src/channels/shared/config-read-error.mjs +24 -0
- package/src/channels/shared/context-enhancement.mjs +40 -3
- package/src/channels/shared/control-command.mjs +8 -1
- package/src/channels/shared/harness-client.mjs +20 -12
- package/src/channels/shared/harness-question.mjs +10 -2
- package/src/channels/shared/i18n-en/dingtalk.mjs +1 -0
- package/src/channels/shared/i18n-en/qq.mjs +3 -0
- package/src/channels/shared/i18n-en/shared-a.mjs +11 -0
- package/src/channels/shared/i18n-en/shared-c.mjs +2 -0
- package/src/channels/shared/i18n-en/weixin.mjs +2 -0
- package/src/channels/shared/im-source-guidance.mjs +65 -0
- package/src/channels/shared/injected-context.mjs +362 -0
- package/src/channels/shared/semantic/artifact.mjs +4 -4
- package/src/channels/shared/semantic/reply-reference.mjs +2 -1
- package/src/channels/shared/text-harness-bridge.mjs +221 -5
- package/src/channels/shared/token-config-store.mjs +23 -8
- package/src/channels/shared/workspace-session.mjs +16 -1
- package/src/channels/slack/slack-runtime.mjs +3 -1
- package/src/channels/telegram/telegram-api.mjs +62 -2
- package/src/channels/telegram/telegram-bridge.mjs +66 -1
- package/src/channels/telegram/telegram-rich-message.mjs +6 -4
- package/src/channels/telegram/telegram-runtime.mjs +128 -6
- package/src/channels/wecom/wecom-bridge.mjs +15 -1
- package/src/channels/wecom-app/config-store.mjs +3 -1
- package/src/channels/wecom-app/wecom-app-bridge.mjs +12 -1
- package/src/channels/weixin/config-store.mjs +24 -15
- package/src/channels/weixin/connection-error.en.mjs +21 -0
- package/src/channels/weixin/connection-error.mjs +21 -6
- package/src/channels/weixin/diagnostic-details.mjs +24 -1
- package/src/channels/weixin/weixin-api.mjs +52 -13
- package/src/channels/weixin/weixin-bridge.mjs +14 -1
|
@@ -30,7 +30,11 @@ import {
|
|
|
30
30
|
} from '../shared/preset-command.mjs';
|
|
31
31
|
import { runWorkspaceCommand } from '../shared/workspace-command.mjs';
|
|
32
32
|
import { askInWorkspaceSession } from '../shared/workspace-session.mjs';
|
|
33
|
-
import {
|
|
33
|
+
import {
|
|
34
|
+
captureContextEnhancement,
|
|
35
|
+
captureContextEnhancementSource,
|
|
36
|
+
enhanceContextContent,
|
|
37
|
+
} from '../shared/context-enhancement.mjs';
|
|
34
38
|
import {
|
|
35
39
|
BatchInputManager,
|
|
36
40
|
batchInputBusyMessage,
|
|
@@ -43,6 +47,7 @@ import {
|
|
|
43
47
|
imagePromptUserMessage,
|
|
44
48
|
} from '../shared/image-prompt.mjs';
|
|
45
49
|
import {
|
|
50
|
+
InboundFileError,
|
|
46
51
|
hasInboundFiles,
|
|
47
52
|
inboundFileUserMessage,
|
|
48
53
|
prefetchInboundFiles,
|
|
@@ -195,6 +200,13 @@ function downloadCodeFor(value) {
|
|
|
195
200
|
return nonEmptyString(value?.downloadCode) ?? nonEmptyString(value?.pictureDownloadCode);
|
|
196
201
|
}
|
|
197
202
|
|
|
203
|
+
function dingtalkImageEntries(msgtype, content) {
|
|
204
|
+
if (msgtype === 'picture') return [content];
|
|
205
|
+
if (msgtype !== 'richtext') return [];
|
|
206
|
+
return richTextEntries(content)
|
|
207
|
+
.filter((entry) => String(entry?.type ?? '').toLowerCase() === 'picture');
|
|
208
|
+
}
|
|
209
|
+
|
|
198
210
|
function dingtalkTimestampMs(value) {
|
|
199
211
|
const number = typeof value === 'string' && value.trim() ? Number(value) : value;
|
|
200
212
|
if (!Number.isFinite(number) || number < 0) return null;
|
|
@@ -206,24 +218,36 @@ function usefulReplyText(value) {
|
|
|
206
218
|
return text && !/^\[interactive card message\]$/iu.test(text) ? text : null;
|
|
207
219
|
}
|
|
208
220
|
|
|
209
|
-
function
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
if (!replied || typeof replied !== 'object') {
|
|
214
|
-
return { unavailableReason: 'not-delivered' };
|
|
215
|
-
}
|
|
216
|
-
|
|
221
|
+
function dingtalkReplyMessage(message) {
|
|
222
|
+
if (message?.text?.isReplyMsg !== true) return null;
|
|
223
|
+
const replied = message.text.repliedMsg;
|
|
224
|
+
if (!replied || typeof replied !== 'object') return null;
|
|
217
225
|
const msgtype = nonEmptyString(replied.msgType ?? replied.msgtype)?.toLowerCase() ?? '';
|
|
218
226
|
const repliedContent = parsedMessageContent({ content: replied.content }) ?? {};
|
|
219
|
-
|
|
227
|
+
// Quoted richText entries use msgType instead of the direct callback's type.
|
|
228
|
+
const content = msgtype === 'richtext' ? {
|
|
229
|
+
...repliedContent,
|
|
230
|
+
richText: richTextEntries(repliedContent).map((entry) => ({
|
|
231
|
+
...entry, type: entry?.type ?? entry?.msgType ?? entry?.msgtype,
|
|
232
|
+
})),
|
|
233
|
+
} : repliedContent;
|
|
234
|
+
return {
|
|
220
235
|
msgtype,
|
|
236
|
+
robotCode: message.robotCode,
|
|
221
237
|
text: {
|
|
222
238
|
content: nonEmptyString(repliedContent.text)
|
|
223
239
|
?? (typeof replied.content === 'string' ? replied.content : ''),
|
|
224
240
|
},
|
|
225
|
-
content
|
|
241
|
+
content,
|
|
226
242
|
};
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
function dingtalkReplyReference(message, options) {
|
|
246
|
+
if (message?.text?.isReplyMsg !== true) return null;
|
|
247
|
+
const pseudoMessage = dingtalkReplyMessage(message);
|
|
248
|
+
if (!pseudoMessage) return { unavailableReason: 'not-delivered' };
|
|
249
|
+
const replied = message.text.repliedMsg;
|
|
250
|
+
const { msgtype, content: repliedContent } = pseudoMessage;
|
|
227
251
|
const normalized = dingtalkInboundMessage(pseudoMessage, options);
|
|
228
252
|
let attachments = [];
|
|
229
253
|
if (msgtype === 'picture') {
|
|
@@ -232,8 +256,7 @@ function dingtalkReplyReference(message, options) {
|
|
|
232
256
|
const name = nonEmptyString(repliedContent.fileName ?? repliedContent.file_name);
|
|
233
257
|
attachments = [{ kind: 'file', ...(name ? { name } : {}) }];
|
|
234
258
|
} else if (msgtype === 'richtext') {
|
|
235
|
-
attachments =
|
|
236
|
-
.filter((entry) => String(entry?.type ?? '').toLowerCase() === 'picture')
|
|
259
|
+
attachments = dingtalkImageEntries(msgtype, repliedContent)
|
|
237
260
|
.map(() => ({ kind: 'image' }));
|
|
238
261
|
} else if (msgtype === 'voice' || msgtype === 'audio') {
|
|
239
262
|
attachments = [{ kind: 'audio' }];
|
|
@@ -278,6 +301,43 @@ function dingtalkReplyReference(message, options) {
|
|
|
278
301
|
};
|
|
279
302
|
}
|
|
280
303
|
|
|
304
|
+
// Resolve only the immediate quote, after command/interaction routing has finished.
|
|
305
|
+
function prepareDingtalkReplyAttachments(message, promptMessage, options) {
|
|
306
|
+
const quoted = dingtalkReplyMessage(message);
|
|
307
|
+
if (!quoted) return promptMessage;
|
|
308
|
+
if (['audio', 'voice', 'video'].includes(quoted.msgtype)) quoted.msgtype = 'file';
|
|
309
|
+
const imageCodes = dingtalkImageEntries(quoted.msgtype, quoted.content).map(downloadCodeFor);
|
|
310
|
+
const fileCodes = quoted.msgtype === 'file' ? [downloadCodeFor(quoted.content)] : [];
|
|
311
|
+
if ([...imageCodes, ...fileCodes].some((code) => !code)) {
|
|
312
|
+
throw new InboundFileError(
|
|
313
|
+
'dingtalk-quoted-attachment-unavailable',
|
|
314
|
+
'DingTalk did not deliver a download reference for the quoted attachment.',
|
|
315
|
+
t('钉钉未提供引用附件的下载信息,无法读取原附件。请直接重新发送附件后再提问。'),
|
|
316
|
+
);
|
|
317
|
+
}
|
|
318
|
+
if (imageCodes.length === 0 && fileCodes.length === 0) return promptMessage;
|
|
319
|
+
const currentType = String(message?.msgtype ?? '').toLowerCase();
|
|
320
|
+
const currentContent = parsedMessageContent(message);
|
|
321
|
+
const seen = new Set([
|
|
322
|
+
...dingtalkImageEntries(currentType, currentContent).map(downloadCodeFor),
|
|
323
|
+
...(currentType === 'file' ? [downloadCodeFor(currentContent)] : []),
|
|
324
|
+
].filter(Boolean));
|
|
325
|
+
const take = (code) => {
|
|
326
|
+
if (seen.has(code)) return false;
|
|
327
|
+
seen.add(code);
|
|
328
|
+
return true;
|
|
329
|
+
};
|
|
330
|
+
const normalized = dingtalkInboundMessage(quoted, options);
|
|
331
|
+
const images = normalized.images.filter((_, index) => take(imageCodes[index]));
|
|
332
|
+
const files = normalized.files.filter((_, index) => take(fileCodes[index]));
|
|
333
|
+
const prefetched = prefetchInboundFiles({ files }, { signal: options.signal });
|
|
334
|
+
return {
|
|
335
|
+
...promptMessage,
|
|
336
|
+
images: [...promptMessage.images, ...images],
|
|
337
|
+
files: [...promptMessage.files, ...prefetched.files],
|
|
338
|
+
};
|
|
339
|
+
}
|
|
340
|
+
|
|
281
341
|
/** Normalize DingTalk picture and richText callbacks into lazy image references. */
|
|
282
342
|
export function dingtalkInboundMessage(message, {
|
|
283
343
|
api,
|
|
@@ -291,17 +351,7 @@ export function dingtalkInboundMessage(message, {
|
|
|
291
351
|
const text = msgtype === 'text'
|
|
292
352
|
? nonEmptyString(message?.text?.content) ?? ''
|
|
293
353
|
: richEntries.map(richTextEntryText).filter(Boolean).join('\n');
|
|
294
|
-
const imageCodes =
|
|
295
|
-
if (msgtype === 'picture') {
|
|
296
|
-
const code = downloadCodeFor(content);
|
|
297
|
-
if (code) imageCodes.push(code);
|
|
298
|
-
} else if (msgtype === 'richtext') {
|
|
299
|
-
for (const entry of richEntries) {
|
|
300
|
-
if (String(entry?.type ?? '').toLowerCase() !== 'picture') continue;
|
|
301
|
-
const code = downloadCodeFor(entry);
|
|
302
|
-
if (code) imageCodes.push(code);
|
|
303
|
-
}
|
|
304
|
-
}
|
|
354
|
+
const imageCodes = dingtalkImageEntries(msgtype, content).map(downloadCodeFor).filter(Boolean);
|
|
305
355
|
const fileCode = msgtype === 'file' ? downloadCodeFor(content) : null;
|
|
306
356
|
const replyTo = dingtalkReplyReference(message, {
|
|
307
357
|
api,
|
|
@@ -838,6 +888,17 @@ export class DingtalkHarnessBridge {
|
|
|
838
888
|
signal: this.#signal, isDirect: String(message.conversationType) === '1',
|
|
839
889
|
pendingInteraction: this.#pendingInteractions.has(key) || this.#approvals.hasPending(key),
|
|
840
890
|
control: { owner: this, key }, deferredDelivery: this.#deferred,
|
|
891
|
+
enhancement: captureContextEnhancementSource(
|
|
892
|
+
this.#contextEnhancement,
|
|
893
|
+
String(message.conversationType) === '1' ? 'direct' : 'group',
|
|
894
|
+
() => ({
|
|
895
|
+
channel: 'dingtalk',
|
|
896
|
+
senderId: senderStaffId(message),
|
|
897
|
+
senderName: message.senderNick,
|
|
898
|
+
conversationTitle: message.conversationTitle,
|
|
899
|
+
chatId: message.conversationId,
|
|
900
|
+
}),
|
|
901
|
+
),
|
|
841
902
|
};
|
|
842
903
|
const access = evaluateInboundAccess(this.#accessPolicy, {
|
|
843
904
|
conversationType: options.isDirect ? 'direct' : 'group',
|
|
@@ -1102,6 +1163,17 @@ export class DingtalkHarnessBridge {
|
|
|
1102
1163
|
|| this.#approvals.hasPending(key),
|
|
1103
1164
|
control: { owner: this, key },
|
|
1104
1165
|
deferredDelivery: this.#deferred,
|
|
1166
|
+
enhancement: captureContextEnhancementSource(
|
|
1167
|
+
this.#contextEnhancement,
|
|
1168
|
+
String(message.conversationType) === '1' ? 'direct' : 'group',
|
|
1169
|
+
() => ({
|
|
1170
|
+
channel: 'dingtalk',
|
|
1171
|
+
senderId: senderStaffId(message),
|
|
1172
|
+
senderName: message.senderNick,
|
|
1173
|
+
conversationTitle: message.conversationTitle,
|
|
1174
|
+
chatId: message.conversationId,
|
|
1175
|
+
}),
|
|
1176
|
+
),
|
|
1105
1177
|
},
|
|
1106
1178
|
);
|
|
1107
1179
|
if (result?.stopped) {
|
|
@@ -1261,8 +1333,12 @@ export class DingtalkHarnessBridge {
|
|
|
1261
1333
|
return;
|
|
1262
1334
|
}
|
|
1263
1335
|
|
|
1264
|
-
|
|
1265
|
-
|
|
1336
|
+
const modelMessage = prepareDingtalkReplyAttachments(message, promptMessage, {
|
|
1337
|
+
api: this.#api, clientId: this.#clientId, clientSecret: this.#clientSecret,
|
|
1338
|
+
signal: this.#signal,
|
|
1339
|
+
});
|
|
1340
|
+
let content = hasInboundImages(modelMessage) || hasReply
|
|
1341
|
+
? await promptContentForInboundMessage(modelMessage, { signal: this.#signal })
|
|
1266
1342
|
: undefined;
|
|
1267
1343
|
const snapshot = this.#acceptedMessageIds.get(messageId);
|
|
1268
1344
|
let contextEnhanced = false;
|
|
@@ -1299,6 +1375,8 @@ export class DingtalkHarnessBridge {
|
|
|
1299
1375
|
key,
|
|
1300
1376
|
text,
|
|
1301
1377
|
content,
|
|
1378
|
+
titleText: batchSubmission?.title,
|
|
1379
|
+
sourceGuidance: snapshot?.config?.guidance,
|
|
1302
1380
|
contextEnhanced,
|
|
1303
1381
|
createOptions: { signal: this.#signal },
|
|
1304
1382
|
existsOptions: { signal: this.#signal },
|
|
@@ -1316,7 +1394,7 @@ export class DingtalkHarnessBridge {
|
|
|
1316
1394
|
requiresMention: String(message.conversationType) === '2',
|
|
1317
1395
|
}),
|
|
1318
1396
|
onInteractionResolved: (resolution) => this.#handleInteractionResolved(resolution),
|
|
1319
|
-
files:
|
|
1397
|
+
files: modelMessage.files,
|
|
1320
1398
|
},
|
|
1321
1399
|
});
|
|
1322
1400
|
if (batchSubmission) {
|
|
@@ -66,7 +66,10 @@ function gatewayCloseError(code) {
|
|
|
66
66
|
|
|
67
67
|
function stripBotMention(text, botId) {
|
|
68
68
|
if (typeof text !== 'string') return '';
|
|
69
|
-
return text
|
|
69
|
+
return text
|
|
70
|
+
.split(`<@${botId}>`).join('')
|
|
71
|
+
.split(`<@!${botId}>`).join('')
|
|
72
|
+
.trim();
|
|
70
73
|
}
|
|
71
74
|
|
|
72
75
|
function cleanThreadName(message, botId) {
|
|
@@ -60,7 +60,11 @@ import {
|
|
|
60
60
|
} from '../shared/workspace-command.mjs';
|
|
61
61
|
import { askInWorkspaceSession } from '../shared/workspace-session.mjs';
|
|
62
62
|
import { createDeferredDeliveryCoordinator, deferredOutcomeText } from '../shared/deferred-delivery-coordinator.mjs';
|
|
63
|
-
import {
|
|
63
|
+
import {
|
|
64
|
+
captureContextEnhancement,
|
|
65
|
+
captureContextEnhancementSource,
|
|
66
|
+
enhanceContextContent,
|
|
67
|
+
} from '../shared/context-enhancement.mjs';
|
|
64
68
|
import { deliverOutboundArtifacts } from '../shared/semantic/artifact-delivery.mjs';
|
|
65
69
|
import {
|
|
66
70
|
createDeliveryReceipt,
|
|
@@ -1037,12 +1041,16 @@ export class FeishuHarnessBridge {
|
|
|
1037
1041
|
if (result.kind === 'submit') {
|
|
1038
1042
|
const submissionEvent = {
|
|
1039
1043
|
...event,
|
|
1040
|
-
batchSubmission: { token: result.token },
|
|
1044
|
+
batchSubmission: { token: result.token, title: result.title },
|
|
1041
1045
|
message: {
|
|
1042
1046
|
...event.message,
|
|
1043
1047
|
message_type: 'text',
|
|
1044
1048
|
content: JSON.stringify({ text: result.prompt }),
|
|
1045
1049
|
mentions: [],
|
|
1050
|
+
// The submission is exactly the collected text, so quoting the
|
|
1051
|
+
// message that carried /send must not attach it to the batch.
|
|
1052
|
+
parent_id: undefined,
|
|
1053
|
+
root_id: undefined,
|
|
1046
1054
|
},
|
|
1047
1055
|
};
|
|
1048
1056
|
return this.#enqueueMessage(
|
|
@@ -1412,6 +1420,16 @@ export class FeishuHarnessBridge {
|
|
|
1412
1420
|
hasFiles: hasInboundFiles(message),
|
|
1413
1421
|
pendingInteraction: this.#hasPendingInteraction(key),
|
|
1414
1422
|
control: { owner: this, key },
|
|
1423
|
+
enhancement: captureContextEnhancementSource(
|
|
1424
|
+
this.#contextEnhancement,
|
|
1425
|
+
event.message.chat_type === 'p2p' ? 'direct' : 'group',
|
|
1426
|
+
() => ({
|
|
1427
|
+
channel: 'feishu',
|
|
1428
|
+
senderId: senderOpenId(event),
|
|
1429
|
+
chatId: event.message.chat_id,
|
|
1430
|
+
threadId: event.message.thread_id,
|
|
1431
|
+
}),
|
|
1432
|
+
),
|
|
1415
1433
|
},
|
|
1416
1434
|
);
|
|
1417
1435
|
if (result?.stopped) {
|
|
@@ -2471,7 +2489,7 @@ export class FeishuHarnessBridge {
|
|
|
2471
2489
|
await this.#sendCard(chatId, customSteerCard(), { key, updateMessageId: messageId, replyTo: messageId });
|
|
2472
2490
|
return;
|
|
2473
2491
|
}
|
|
2474
|
-
await this.#sendSteer({ key, chatId, messageId }, raw);
|
|
2492
|
+
await this.#sendSteer({ key, chatId, messageId, actor }, raw);
|
|
2475
2493
|
return;
|
|
2476
2494
|
}
|
|
2477
2495
|
if (action === 'presets') {
|
|
@@ -3197,11 +3215,26 @@ export class FeishuHarnessBridge {
|
|
|
3197
3215
|
*/
|
|
3198
3216
|
async #sendSteer(entry, text) {
|
|
3199
3217
|
const { key, chatId } = entry;
|
|
3218
|
+
// Card routes carry the conversation key, not the raw event, so the topic
|
|
3219
|
+
// id is recovered from the key the channel itself minted.
|
|
3220
|
+
const threadId = typeof key === 'string'
|
|
3221
|
+
? /(?:^|:)thread:(.+)$/u.exec(key)?.[1]
|
|
3222
|
+
: undefined;
|
|
3200
3223
|
const result = await runControlCommand(
|
|
3201
3224
|
`/steer ${text}`, this.#harness, this.#state, key, {
|
|
3202
3225
|
signal: this.#signal,
|
|
3203
3226
|
pendingInteraction: this.#hasPendingInteraction(key),
|
|
3204
3227
|
control: { owner: this, key },
|
|
3228
|
+
enhancement: captureContextEnhancementSource(
|
|
3229
|
+
this.#contextEnhancement,
|
|
3230
|
+
typeof key === 'string' && key.startsWith('p2p:') ? 'direct' : 'group',
|
|
3231
|
+
() => ({
|
|
3232
|
+
channel: 'feishu',
|
|
3233
|
+
senderId: entry.actor ?? entry.operatorOpenId,
|
|
3234
|
+
chatId,
|
|
3235
|
+
threadId,
|
|
3236
|
+
}),
|
|
3237
|
+
),
|
|
3205
3238
|
},
|
|
3206
3239
|
);
|
|
3207
3240
|
await this.#send(chatId, result?.message || t('已提交补充指令。'), { replyTo: entry.messageId ?? null });
|
|
@@ -4959,6 +4992,8 @@ export class FeishuHarnessBridge {
|
|
|
4959
4992
|
key,
|
|
4960
4993
|
text,
|
|
4961
4994
|
content,
|
|
4995
|
+
titleText: event.batchSubmission?.title,
|
|
4996
|
+
sourceGuidance: snapshot?.config?.guidance,
|
|
4962
4997
|
contextEnhanced,
|
|
4963
4998
|
createOptions: { signal: this.#signal },
|
|
4964
4999
|
existsOptions: { signal: this.#signal },
|
|
@@ -5201,6 +5236,8 @@ export class FeishuHarnessBridge {
|
|
|
5201
5236
|
key,
|
|
5202
5237
|
text,
|
|
5203
5238
|
content,
|
|
5239
|
+
titleText: event.batchSubmission?.title,
|
|
5240
|
+
sourceGuidance: snapshot?.config?.guidance,
|
|
5204
5241
|
contextEnhanced,
|
|
5205
5242
|
createOptions: { signal: this.#signal },
|
|
5206
5243
|
existsOptions: { signal: this.#signal },
|
|
@@ -5280,6 +5317,8 @@ export class FeishuHarnessBridge {
|
|
|
5280
5317
|
key,
|
|
5281
5318
|
text,
|
|
5282
5319
|
content,
|
|
5320
|
+
titleText: event.batchSubmission?.title,
|
|
5321
|
+
sourceGuidance: snapshot?.config?.guidance,
|
|
5283
5322
|
contextEnhanced,
|
|
5284
5323
|
createOptions: { signal: this.#signal },
|
|
5285
5324
|
existsOptions: { signal: this.#signal },
|
|
@@ -5350,6 +5389,8 @@ export class FeishuHarnessBridge {
|
|
|
5350
5389
|
key,
|
|
5351
5390
|
text,
|
|
5352
5391
|
content,
|
|
5392
|
+
titleText: event.batchSubmission?.title,
|
|
5393
|
+
sourceGuidance: snapshot?.config?.guidance,
|
|
5353
5394
|
contextEnhanced,
|
|
5354
5395
|
createOptions: { signal: this.#signal },
|
|
5355
5396
|
existsOptions: { signal: this.#signal },
|
|
@@ -576,7 +576,7 @@ export class VerifiedFeishuChannel {
|
|
|
576
576
|
assertApiSuccess('Feishu message delete', response);
|
|
577
577
|
return true;
|
|
578
578
|
} catch (error) {
|
|
579
|
-
console.warn(
|
|
579
|
+
console.warn('[bridge] unable to recall message:', label, error.message);
|
|
580
580
|
return false;
|
|
581
581
|
}
|
|
582
582
|
}
|
|
@@ -22,7 +22,11 @@ import {
|
|
|
22
22
|
runPresetCommand,
|
|
23
23
|
} from '../shared/preset-command.mjs';
|
|
24
24
|
import { askInWorkspaceSession } from '../shared/workspace-session.mjs';
|
|
25
|
-
import {
|
|
25
|
+
import {
|
|
26
|
+
captureContextEnhancement,
|
|
27
|
+
captureContextEnhancementSource,
|
|
28
|
+
enhanceContextContent,
|
|
29
|
+
} from '../shared/context-enhancement.mjs';
|
|
26
30
|
import {
|
|
27
31
|
BatchInputManager,
|
|
28
32
|
batchInputBusyMessage,
|
|
@@ -570,7 +574,14 @@ export class QqHarnessBridge {
|
|
|
570
574
|
if (result.handled) {
|
|
571
575
|
if (result.kind === 'submit') {
|
|
572
576
|
return this.#enqueueMessage(
|
|
573
|
-
{
|
|
577
|
+
{
|
|
578
|
+
...message,
|
|
579
|
+
content: result.prompt,
|
|
580
|
+
// The submission is exactly the collected text: the command
|
|
581
|
+
// message's own attachments and quoted message are not part of it.
|
|
582
|
+
attachments: [],
|
|
583
|
+
refMsgIdx: undefined,
|
|
584
|
+
},
|
|
574
585
|
messageId,
|
|
575
586
|
key,
|
|
576
587
|
{ batchSubmission: result },
|
|
@@ -752,7 +763,17 @@ export class QqHarnessBridge {
|
|
|
752
763
|
return { message: t('当前任务仍在运行,请先停止任务或等待任务完成后再执行此操作。') };
|
|
753
764
|
}
|
|
754
765
|
const options = { signal: this.#signal, isDirect: message.kind === 'c2c', pendingInteraction,
|
|
755
|
-
control: { owner: this, key }, deferredDelivery: this.#deferred
|
|
766
|
+
control: { owner: this, key }, deferredDelivery: this.#deferred,
|
|
767
|
+
enhancement: captureContextEnhancementSource(
|
|
768
|
+
this.#contextEnhancement,
|
|
769
|
+
message.kind === 'c2c' ? 'direct' : 'group',
|
|
770
|
+
() => ({
|
|
771
|
+
channel: 'qq',
|
|
772
|
+
senderId: nonEmptyString(message.senderId),
|
|
773
|
+
senderName: message.kind === 'group' ? message.senderName : undefined,
|
|
774
|
+
chatId: message.kind === 'group' ? message.groupOpenid : message.senderId,
|
|
775
|
+
}),
|
|
776
|
+
) };
|
|
756
777
|
// Existing runners own all Host mutations and control authorization.
|
|
757
778
|
const execute = async () => {
|
|
758
779
|
this.#signal?.throwIfAborted();
|
|
@@ -817,6 +838,16 @@ export class QqHarnessBridge {
|
|
|
817
838
|
|| this.#approvals.hasPending(key),
|
|
818
839
|
control: { owner: this, key },
|
|
819
840
|
deferredDelivery: this.#deferred,
|
|
841
|
+
enhancement: captureContextEnhancementSource(
|
|
842
|
+
this.#contextEnhancement,
|
|
843
|
+
message.kind === 'c2c' ? 'direct' : 'group',
|
|
844
|
+
() => ({
|
|
845
|
+
channel: 'qq',
|
|
846
|
+
senderId: nonEmptyString(message.senderId),
|
|
847
|
+
senderName: message.kind === 'group' ? message.senderName : undefined,
|
|
848
|
+
chatId: message.kind === 'group' ? message.groupOpenid : message.senderId,
|
|
849
|
+
}),
|
|
850
|
+
),
|
|
820
851
|
});
|
|
821
852
|
if (result?.stopped) {
|
|
822
853
|
await Promise.allSettled([
|
|
@@ -1038,6 +1069,8 @@ export class QqHarnessBridge {
|
|
|
1038
1069
|
key,
|
|
1039
1070
|
text,
|
|
1040
1071
|
content,
|
|
1072
|
+
titleText: batchSubmission?.title,
|
|
1073
|
+
sourceGuidance: snapshot?.config?.guidance,
|
|
1041
1074
|
contextEnhanced,
|
|
1042
1075
|
createOptions: { signal: this.#signal },
|
|
1043
1076
|
existsOptions: { signal: this.#signal },
|
|
@@ -4,6 +4,7 @@ import { connectionTestMessage } from '../shared/connection-test.mjs';
|
|
|
4
4
|
import { t } from '../shared/i18n.mjs';
|
|
5
5
|
import { publicMessageFailure } from '../shared/message-failure.mjs';
|
|
6
6
|
import { deriveQqBotIdentity, maskQqAppId } from './config-store.mjs';
|
|
7
|
+
import { publicQqStateError } from './state-error.mjs';
|
|
7
8
|
|
|
8
9
|
const ACTIVE_ATTEMPT_STATES = new Set(['starting', 'pending', 'refreshing', 'connecting']);
|
|
9
10
|
const TERMINAL_ATTEMPT_STATES = new Set(['connected', 'failed', 'cancelled']);
|
|
@@ -17,6 +18,10 @@ function safeError(code, message) {
|
|
|
17
18
|
return Object.freeze({ code, message });
|
|
18
19
|
}
|
|
19
20
|
|
|
21
|
+
function connectionError(error, message) {
|
|
22
|
+
return publicQqStateError(error) ?? safeError('connection-failed', message);
|
|
23
|
+
}
|
|
24
|
+
|
|
20
25
|
function publicAttempt(record) {
|
|
21
26
|
if (!record) return null;
|
|
22
27
|
return {
|
|
@@ -86,7 +91,7 @@ export class QqController {
|
|
|
86
91
|
await this.#startRuntime(config, appSecret);
|
|
87
92
|
this.#errors.delete(config.botId);
|
|
88
93
|
} catch (error) {
|
|
89
|
-
this.#errors.set(config.botId,
|
|
94
|
+
this.#errors.set(config.botId, connectionError(error, t('QQ 连接未就绪,插件会自动重试。')));
|
|
90
95
|
this.#logger.warn?.(`[dsh-im:qq] bot ${config.botId} failed to initialize:`, error);
|
|
91
96
|
} finally {
|
|
92
97
|
this.#touch();
|
|
@@ -209,7 +214,7 @@ export class QqController {
|
|
|
209
214
|
await this.#startRuntime(config, normalizedSecret);
|
|
210
215
|
this.#errors.delete(identity.botId);
|
|
211
216
|
} catch (error) {
|
|
212
|
-
this.#errors.set(identity.botId,
|
|
217
|
+
this.#errors.set(identity.botId, connectionError(error, t('QQ 机器人已绑定,消息连接暂未就绪。')));
|
|
213
218
|
this.#logger.warn?.(`[dsh-im:qq] bot ${identity.botId} credential connection failed:`, error);
|
|
214
219
|
}
|
|
215
220
|
this.#touch();
|
|
@@ -242,7 +247,7 @@ export class QqController {
|
|
|
242
247
|
await this.#startRuntime(config, secret);
|
|
243
248
|
this.#errors.delete(botId);
|
|
244
249
|
} catch (error) {
|
|
245
|
-
this.#errors.set(botId,
|
|
250
|
+
this.#errors.set(botId, connectionError(error, t('QQ 连接仍未就绪,请稍后重试。')));
|
|
246
251
|
throw error;
|
|
247
252
|
} finally {
|
|
248
253
|
this.#touch();
|
|
@@ -326,7 +331,8 @@ export class QqController {
|
|
|
326
331
|
health: {
|
|
327
332
|
status: connected ? 'healthy' : state === 'error' ? 'error' : 'offline',
|
|
328
333
|
summary: connected ? t('QQ WebSocket 长连接运行正常')
|
|
329
|
-
:
|
|
334
|
+
: this.#errors.get(config.botId)?.message
|
|
335
|
+
?? (state === 'error' ? t('QQ 连接未就绪,插件会自动重试') : t('QQ 连接当前离线')),
|
|
330
336
|
lastCheckedAt: runtimeStatus?.lastCheckedAt ?? null,
|
|
331
337
|
lastConnectedAt: runtimeStatus?.lastConnectedAt ?? null,
|
|
332
338
|
},
|
|
@@ -434,7 +440,7 @@ export class QqController {
|
|
|
434
440
|
await this.#restoreCredential(identity.secretRef, previousSecret);
|
|
435
441
|
throw error;
|
|
436
442
|
}
|
|
437
|
-
this.#errors.set(identity.botId,
|
|
443
|
+
this.#errors.set(identity.botId, connectionError(error, t('QQ 机器人已绑定,消息连接暂未就绪。')));
|
|
438
444
|
this.#logger.warn?.(`[dsh-im:qq] bot ${identity.botId} activation connection failed:`, error);
|
|
439
445
|
}
|
|
440
446
|
this.#touch();
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import { t } from '../shared/i18n.mjs';
|
|
2
|
+
|
|
3
|
+
const MESSAGES = Object.freeze({
|
|
4
|
+
'state-read-failed': '无法读取 QQ 本地状态,请检查数据目录及访问权限。',
|
|
5
|
+
'state-backup-failed': 'QQ 本地状态已损坏,但无法备份,原文件已保留。',
|
|
6
|
+
'state-write-failed': '无法保存 QQ 本地状态,请检查磁盘空间及目录写入权限。',
|
|
7
|
+
});
|
|
8
|
+
|
|
9
|
+
export function qqStateError(code, cause) {
|
|
10
|
+
return Object.assign(new Error('QQ state operation failed', { cause }), { code });
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export function publicQqStateError(error) {
|
|
14
|
+
return Object.hasOwn(MESSAGES, error?.code ?? '')
|
|
15
|
+
? { code: error.code, message: t(MESSAGES[error.code]) }
|
|
16
|
+
: null;
|
|
17
|
+
}
|
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
import { deferredStateAccess, normalizeDeferredState } from '../shared/deferred-state.mjs';
|
|
2
|
-
import {
|
|
2
|
+
import { randomUUID } from 'node:crypto';
|
|
3
|
+
import fs from 'node:fs/promises';
|
|
3
4
|
import { dirname } from 'node:path';
|
|
5
|
+
import { qqStateError } from './state-error.mjs';
|
|
4
6
|
|
|
5
7
|
const EMPTY_STATE = Object.freeze({ version: 1, sessions: {}, seenMessageIds: [] });
|
|
6
8
|
|
|
@@ -27,22 +29,46 @@ function normalizeState(value) {
|
|
|
27
29
|
|
|
28
30
|
export class QqStateStore {
|
|
29
31
|
#path;
|
|
32
|
+
#logger;
|
|
30
33
|
#state = structuredClone(EMPTY_STATE);
|
|
31
34
|
#writeQueue = Promise.resolve();
|
|
32
35
|
#deferred = deferredStateAccess(() => this.#state, () => this.#persist());
|
|
33
36
|
|
|
34
|
-
constructor(path) {
|
|
37
|
+
constructor(path, { logger = console } = {}) {
|
|
35
38
|
this.#path = path;
|
|
39
|
+
this.#logger = logger;
|
|
36
40
|
}
|
|
37
41
|
|
|
38
42
|
async load() {
|
|
43
|
+
let bytes;
|
|
39
44
|
try {
|
|
40
|
-
|
|
45
|
+
bytes = await fs.readFile(this.#path);
|
|
41
46
|
} catch (error) {
|
|
42
|
-
if (error?.code !== 'ENOENT') throw error;
|
|
47
|
+
if (error?.code !== 'ENOENT') throw qqStateError('state-read-failed', error);
|
|
48
|
+
this.#state = structuredClone(EMPTY_STATE);
|
|
49
|
+
await this.#persist();
|
|
50
|
+
return this;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
let parsed;
|
|
54
|
+
try {
|
|
55
|
+
parsed = JSON.parse(bytes.toString('utf8'));
|
|
56
|
+
} catch (error) {
|
|
57
|
+
if (!(error instanceof SyntaxError)) throw error;
|
|
58
|
+
const backup = `${this.#path}.corrupt-${Date.now()}-${randomUUID()}`;
|
|
59
|
+
try {
|
|
60
|
+
// Copy the exact bytes before replacing anything. A failed rebuild
|
|
61
|
+
// leaves both the original and this exclusive backup available.
|
|
62
|
+
await fs.writeFile(backup, bytes, { flag: 'wx', mode: 0o600 });
|
|
63
|
+
} catch (cause) {
|
|
64
|
+
throw qqStateError('state-backup-failed', cause);
|
|
65
|
+
}
|
|
43
66
|
this.#state = structuredClone(EMPTY_STATE);
|
|
44
67
|
await this.#persist();
|
|
68
|
+
this.#logger.warn?.(`[dsh-im:qq] state-recovered: ${this.#path}; backup: ${backup}; conversation mappings, message deduplication and deferred deliveries have been reset.`);
|
|
69
|
+
return this;
|
|
45
70
|
}
|
|
71
|
+
this.#state = normalizeState(parsed);
|
|
46
72
|
return this;
|
|
47
73
|
}
|
|
48
74
|
|
|
@@ -96,7 +122,7 @@ export class QqStateStore {
|
|
|
96
122
|
|
|
97
123
|
async remove() {
|
|
98
124
|
try {
|
|
99
|
-
await unlink(this.#path);
|
|
125
|
+
await fs.unlink(this.#path);
|
|
100
126
|
} catch (error) {
|
|
101
127
|
if (error?.code !== 'ENOENT') throw error;
|
|
102
128
|
}
|
|
@@ -106,12 +132,12 @@ export class QqStateStore {
|
|
|
106
132
|
async #persist() {
|
|
107
133
|
const snapshot = `${JSON.stringify(this.#state, null, 2)}\n`;
|
|
108
134
|
const operation = this.#writeQueue.then(async () => {
|
|
109
|
-
await mkdir(dirname(this.#path), { recursive: true, mode: 0o700 });
|
|
135
|
+
await fs.mkdir(dirname(this.#path), { recursive: true, mode: 0o700 });
|
|
110
136
|
const temporary = `${this.#path}.tmp`;
|
|
111
|
-
await writeFile(temporary, snapshot, { encoding: 'utf8', mode: 0o600 });
|
|
112
|
-
await rename(temporary, this.#path);
|
|
137
|
+
await fs.writeFile(temporary, snapshot, { encoding: 'utf8', mode: 0o600 });
|
|
138
|
+
await fs.rename(temporary, this.#path);
|
|
113
139
|
});
|
|
114
140
|
this.#writeQueue = operation.then(() => undefined, () => undefined);
|
|
115
|
-
await operation;
|
|
141
|
+
await operation.catch(error => { throw qqStateError('state-write-failed', error); });
|
|
116
142
|
}
|
|
117
143
|
}
|
|
@@ -33,6 +33,18 @@ function submissionPrompt(messages) {
|
|
|
33
33
|
].join('\n\n');
|
|
34
34
|
}
|
|
35
35
|
|
|
36
|
+
/**
|
|
37
|
+
* Session title for one submission.
|
|
38
|
+
*
|
|
39
|
+
* The submitted prompt is dsh-im's own composition -- a framing sentence plus
|
|
40
|
+
* `[消息 N]` labels -- so using it as the conversation title would put plugin
|
|
41
|
+
* text where the user's own words belong. The first collected message is what
|
|
42
|
+
* the user actually said first.
|
|
43
|
+
*/
|
|
44
|
+
function submissionTitle(messages) {
|
|
45
|
+
return messages.find((message) => message.trim()) ?? '';
|
|
46
|
+
}
|
|
47
|
+
|
|
36
48
|
export function isBatchInputCommand(text) {
|
|
37
49
|
return commandName(text) !== null;
|
|
38
50
|
}
|
|
@@ -73,7 +85,10 @@ export class BatchInputManager {
|
|
|
73
85
|
|
|
74
86
|
if (!batch) {
|
|
75
87
|
if (!name) return { handled: false };
|
|
76
|
-
|
|
88
|
+
// Only starting a batch requires the plain-text command itself; /send and
|
|
89
|
+
// /cancel carry no content, so they must answer with their own state
|
|
90
|
+
// message instead of being refused as uncollectable content.
|
|
91
|
+
if (!plainText && name === 'batch') {
|
|
77
92
|
return result('unsupported-content', t('批量输入命令仅支持纯文字,请移除图片、文件或引用消息后重试。'));
|
|
78
93
|
}
|
|
79
94
|
if (name === 'send') {
|
|
@@ -90,7 +105,11 @@ export class BatchInputManager {
|
|
|
90
105
|
});
|
|
91
106
|
}
|
|
92
107
|
|
|
93
|
-
|
|
108
|
+
// A command is never collected content: /send, /cancel and a repeated
|
|
109
|
+
// /batch must keep working even when the message that carries them is a
|
|
110
|
+
// quoted reply, an image caption or a file. Only the collected text itself
|
|
111
|
+
// has to be plain.
|
|
112
|
+
if (!plainText && batch.phase === 'collecting' && !name) {
|
|
94
113
|
return result('unsupported-content', t(`批量输入模式目前仅支持文字,不支持图片、文件或引用消息,这条消息未收录。
|
|
95
114
|
请继续发送文字,或使用 /send、/cancel。`), {
|
|
96
115
|
count: batch.messages.length,
|
|
@@ -141,6 +160,7 @@ export class BatchInputManager {
|
|
|
141
160
|
token,
|
|
142
161
|
messages,
|
|
143
162
|
prompt: submissionPrompt(messages),
|
|
163
|
+
title: submissionTitle(messages),
|
|
144
164
|
count: messages.length,
|
|
145
165
|
});
|
|
146
166
|
}
|