@xmanrui/dsh-im 4.34.2 → 4.35.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 +3 -1
- package/README.md +3 -1
- package/lib/client.js +1 -1
- package/lib/index.js +291 -289
- package/locale/en.json +1 -0
- package/locale/zh.json +1 -0
- package/package.json +1 -1
- package/plugin-src/host/delivery-adapter.mjs +2 -2
- package/plugin-src/host/delivery-service.mjs +13 -4
- package/plugin-src/host/index.mjs +1 -0
- package/src/channels/feishu/bridge.mjs +5 -5
- package/src/channels/feishu/feishu-cards.mjs +58 -6
- package/src/channels/feishu/feishu-runtime.mjs +9 -2
- package/src/channels/feishu/multi-bot-controller.mjs +1 -1
- package/src/channels/qq/qq-bridge.mjs +10 -8
- package/src/channels/qq/qq-mention.mjs +37 -0
- package/src/channels/qq/qq-runtime.mjs +11 -3
- package/src/channels/shared/bot-workspace-store.mjs +1 -0
- package/src/channels/shared/deferred-delivery-coordinator.mjs +2 -1
- package/src/channels/shared/deferred-delivery.mjs +8 -5
- package/src/channels/shared/harness-client.mjs +125 -13
- package/src/channels/shared/i18n-en/shared-a.mjs +6 -2
- package/src/channels/shared/message-failure.mjs +6 -2
- package/src/channels/shared/workspace-command.mjs +28 -16
package/locale/en.json
CHANGED
package/locale/zh.json
CHANGED
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@xmanrui/dsh-im",
|
|
3
|
-
"version": "4.
|
|
3
|
+
"version": "4.35.1",
|
|
4
4
|
"icon": "./assets/logo-plugin-message-link-gradient.webp",
|
|
5
5
|
"description": "把微信、飞书、企微、钉钉、QQ、Telegram、WhatsApp、Slack、Discord、Matrix接入DSH。 Connect Telegram, WhatsApp, Slack, Discord, Matrix, WeChat, Feishu, WeCom, DingTalk, and QQ to DSH.",
|
|
6
6
|
"keywords": [
|
|
@@ -286,8 +286,8 @@ export function createDeliveryAdapter({ channel, workspaces, coreController, sta
|
|
|
286
286
|
if (typeof coreController.sendProactiveText !== 'function') {
|
|
287
287
|
throw new TypeError('delivery controller cannot send proactive text');
|
|
288
288
|
}
|
|
289
|
-
await coreController.sendProactiveText(botId, normalized, text, options);
|
|
290
|
-
return { sent: true };
|
|
289
|
+
const result = await coreController.sendProactiveText(botId, normalized, text, options);
|
|
290
|
+
return options.receipt === true ? result : { sent: true };
|
|
291
291
|
},
|
|
292
292
|
async setSessionSync(botId, targetId, enabled) {
|
|
293
293
|
if (typeof workspaces.setDeliveryTargetSessionSync !== 'function') {
|
|
@@ -30,6 +30,7 @@ const DELIVERY_ERROR_CODES = new Set([
|
|
|
30
30
|
'bot-not-connected',
|
|
31
31
|
'target-rejected',
|
|
32
32
|
'delivery-failed',
|
|
33
|
+
'send-result-unknown',
|
|
33
34
|
'session-sync-unavailable',
|
|
34
35
|
'cancelled',
|
|
35
36
|
]);
|
|
@@ -314,10 +315,10 @@ export class DeliveryService {
|
|
|
314
315
|
} catch (error) { throw publicOperationError(error); }
|
|
315
316
|
}
|
|
316
317
|
|
|
317
|
-
async sendChecked(botId, targetId, text, { expectedFingerprint, expectedTargetDigest, signal, format = 'plain' } = {}) {
|
|
318
|
+
async sendChecked(botId, targetId, text, { expectedFingerprint, expectedTargetDigest, signal, format = 'plain', receipt = false } = {}) {
|
|
318
319
|
const id = botIdOf(botId);
|
|
319
320
|
const key = targetIdOf(targetId);
|
|
320
|
-
if (typeof text !== 'string' || !text.trim() || !['plain', 'markdown'].includes(format)
|
|
321
|
+
if (typeof text !== 'string' || !text.trim() || !['plain', 'markdown'].includes(format) || typeof receipt !== 'boolean'
|
|
321
322
|
|| !/^[a-f0-9]{64}$/.test(expectedFingerprint ?? '') || !/^[a-f0-9]{64}$/.test(expectedTargetDigest ?? '')) {
|
|
322
323
|
throw deliveryError('bad-request');
|
|
323
324
|
}
|
|
@@ -341,14 +342,22 @@ export class DeliveryService {
|
|
|
341
342
|
throw deliveryError('capability-unavailable');
|
|
342
343
|
}
|
|
343
344
|
if (account.account?.fingerprint !== expectedFingerprint) throw deliveryError('account-changed');
|
|
345
|
+
if (receipt && (!account.capabilities?.includes('proactive-receipt-checked') || target.kind !== 'group'))
|
|
346
|
+
throw deliveryError('capability-unavailable');
|
|
344
347
|
const beforeSend = () => {
|
|
345
348
|
cancellation(signal);
|
|
346
349
|
this.#assertRegistered(registration);
|
|
347
350
|
};
|
|
348
351
|
beforeSend();
|
|
349
|
-
await adapter.sendText(id, target, text, { signal, expectedFingerprint, beforeSend,
|
|
352
|
+
const result = await adapter.sendText(id, target, text, { signal, expectedFingerprint, beforeSend,
|
|
353
|
+
...(receipt ? { receipt: true } : {}),
|
|
350
354
|
...(format === 'markdown' ? { format } : {}) });
|
|
351
|
-
return { sent: true };
|
|
355
|
+
if (!receipt) return { sent: true };
|
|
356
|
+
if (result?.sent !== true || result.receipt?.version !== 1
|
|
357
|
+
|| typeof result.receipt.messageId !== 'string' || !result.receipt.messageId || result.receipt.messageId.length > 512
|
|
358
|
+
|| result.receipt.conversationId !== target.route.chatId)
|
|
359
|
+
throw deliveryError('send-result-unknown');
|
|
360
|
+
return { sent: true, receipt: { version: 1, messageId: result.receipt.messageId, conversationId: result.receipt.conversationId } };
|
|
352
361
|
} catch (error) { throw publicOperationError(error); }
|
|
353
362
|
}
|
|
354
363
|
|
|
@@ -92,6 +92,7 @@ export function createImHostPlugin(internals = {}) {
|
|
|
92
92
|
if (typeof ctx?.provide === 'function') {
|
|
93
93
|
ctx.provide('dshIm', Object.freeze({
|
|
94
94
|
contractVersion: 1,
|
|
95
|
+
receiptVersion: 1,
|
|
95
96
|
describeBot: (botId) => deliveryService.describeBot(botId),
|
|
96
97
|
sendChecked: (botId, targetId, text, options) => deliveryService.sendChecked(botId, targetId, text, options),
|
|
97
98
|
send: (botId, targetId, text, options) => (
|
|
@@ -3041,14 +3041,14 @@ export class FeishuHarnessBridge {
|
|
|
3041
3041
|
|
|
3042
3042
|
async #showWorkspaces({ chatId, key, replyTo = null }, { updateMessageId = null } = {}) {
|
|
3043
3043
|
try {
|
|
3044
|
-
const { current, paths } = await workspacePathSnapshot(
|
|
3044
|
+
const { current, paths, titles } = await workspacePathSnapshot(
|
|
3045
3045
|
this.#harness,
|
|
3046
|
-
{ signal: this.#cardDataSignal() },
|
|
3046
|
+
{ signal: this.#cardDataSignal(), includeTitles: true },
|
|
3047
3047
|
);
|
|
3048
3048
|
this.#rememberMenu(key, { kind: 'workspaces', paths });
|
|
3049
3049
|
await this.#sendCard(
|
|
3050
3050
|
chatId,
|
|
3051
|
-
workspaceListCard(paths, current),
|
|
3051
|
+
workspaceListCard(paths, current, titles),
|
|
3052
3052
|
{ key, updateMessageId, replyTo },
|
|
3053
3053
|
);
|
|
3054
3054
|
} catch (error) {
|
|
@@ -3239,7 +3239,7 @@ export class FeishuHarnessBridge {
|
|
|
3239
3239
|
const dataSignal = this.#cardDataSignal();
|
|
3240
3240
|
// Independent sections start together. Each one degrades on its own so a
|
|
3241
3241
|
// slow preset/model RPC cannot force redundant session-list scans.
|
|
3242
|
-
const workspaceTask = workspacePathSnapshot(this.#harness, { signal: dataSignal })
|
|
3242
|
+
const workspaceTask = workspacePathSnapshot(this.#harness, { signal: dataSignal, includeTitles: true })
|
|
3243
3243
|
.catch(() => {
|
|
3244
3244
|
const current = typeof this.#harness.currentWorkspace === 'function'
|
|
3245
3245
|
? this.#harness.currentWorkspace()
|
|
@@ -3316,7 +3316,7 @@ export class FeishuHarnessBridge {
|
|
|
3316
3316
|
const archiveVisible = this.#state?.includesArchivedSessions?.() ?? false;
|
|
3317
3317
|
this.#rememberMenu(key, { kind: 'menu', chatId });
|
|
3318
3318
|
const menuContext = {
|
|
3319
|
-
workspaces, currentWorkspace,
|
|
3319
|
+
workspaces, currentWorkspace, workspaceTitles: snapshot.titles,
|
|
3320
3320
|
currentSession: currentSessionId ? { id: currentSessionId, title: currentSessionTitle } : null,
|
|
3321
3321
|
sessions, archiveVisible, presetCatalog, modelCatalog,
|
|
3322
3322
|
newSessionAction: { id: randomUUID(), status: 'idle' },
|
|
@@ -14,6 +14,7 @@
|
|
|
14
14
|
* without button callbacks.
|
|
15
15
|
*/
|
|
16
16
|
|
|
17
|
+
import { posix, win32 } from 'node:path';
|
|
17
18
|
import { t } from '../shared/i18n.mjs';
|
|
18
19
|
|
|
19
20
|
export const MENU_PAGE_SIZE = 10;
|
|
@@ -87,9 +88,56 @@ function initialIndex(options, currentValue) {
|
|
|
87
88
|
return idx >= 0 ? idx + 1 : 0;
|
|
88
89
|
}
|
|
89
90
|
|
|
91
|
+
function cleanTitle(value) {
|
|
92
|
+
return String(value ?? '').replace(/[\p{Cc}\p{Cf}\p{Zl}\p{Zp}]+/gu, ' ').replace(/\s+/gu, ' ').trim();
|
|
93
|
+
}
|
|
94
|
+
|
|
90
95
|
function safeTitle(value) {
|
|
91
|
-
|
|
92
|
-
|
|
96
|
+
return cleanTitle(value) || t('暂无标题');
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
const WORKSPACE_LABEL_LENGTH = 22;
|
|
100
|
+
|
|
101
|
+
/** Display labels only: paths and their snapshot order remain the callback identity. */
|
|
102
|
+
function workspaceLabels(paths, titles = {}) {
|
|
103
|
+
const entries = paths.map((path) => {
|
|
104
|
+
const api = /^[a-z]:[\\/]|^\\\\/iu.test(path) ? win32 : posix;
|
|
105
|
+
const normalized = api.normalize(path);
|
|
106
|
+
const name = cleanTitle(api.basename(normalized) || api.parse(normalized).root);
|
|
107
|
+
const base = (typeof titles[path] === 'string' && cleanTitle(titles[path])) || name;
|
|
108
|
+
return {
|
|
109
|
+
api, base, label: base, parent: api.dirname(normalized),
|
|
110
|
+
context: base === name ? '' : api.basename(normalized),
|
|
111
|
+
};
|
|
112
|
+
});
|
|
113
|
+
const counts = () => {
|
|
114
|
+
const result = new Map();
|
|
115
|
+
for (const { label } of entries) result.set(label, (result.get(label) ?? 0) + 1);
|
|
116
|
+
return result;
|
|
117
|
+
};
|
|
118
|
+
// Grow only ambiguous path suffixes; stop at the root even if display text collides.
|
|
119
|
+
for (;;) {
|
|
120
|
+
const repeated = counts();
|
|
121
|
+
let changed = false;
|
|
122
|
+
for (const entry of entries) {
|
|
123
|
+
if (repeated.get(entry.label) < 2 || entry.parent === null) continue;
|
|
124
|
+
const { api, parent } = entry;
|
|
125
|
+
entry.context = api.join(api.basename(parent) || parent, entry.context);
|
|
126
|
+
entry.label = `${entry.base} · ${cleanTitle(entry.context)}`;
|
|
127
|
+
const next = api.dirname(parent);
|
|
128
|
+
entry.parent = next === parent ? null : next;
|
|
129
|
+
changed = true;
|
|
130
|
+
}
|
|
131
|
+
if (!changed) break;
|
|
132
|
+
}
|
|
133
|
+
for (const entry of entries) {
|
|
134
|
+
const chars = Array.from(entry.label);
|
|
135
|
+
if (chars.length > WORKSPACE_LABEL_LENGTH) {
|
|
136
|
+
entry.label = `${chars.slice(0, 11).join('')}…${chars.slice(-10).join('')}`;
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
const repeated = counts();
|
|
140
|
+
return entries.map(({ label }) => ({ label, numbered: repeated.get(label) > 1 }));
|
|
93
141
|
}
|
|
94
142
|
|
|
95
143
|
function cardWith(headerText, elements) {
|
|
@@ -117,6 +165,7 @@ function backButton() {
|
|
|
117
165
|
* 4. 系统 → 状态 · 帮助
|
|
118
166
|
* `ctx` bundle:
|
|
119
167
|
* - workspaces: string[] (工作区下拉选项)
|
|
168
|
+
* - workspaceTitles: object (可选,按绝对路径索引的工作区名称)
|
|
120
169
|
* - currentWorkspace: string|null (当前工作区,下拉高亮)
|
|
121
170
|
* - currentSession: {id,title}|null (当前绑定会话,续写目标)
|
|
122
171
|
* - sessions: {id,title}[] (最近会话,供会话下拉切换)
|
|
@@ -130,6 +179,7 @@ function backButton() {
|
|
|
130
179
|
export function menuCard(ctx) {
|
|
131
180
|
const {
|
|
132
181
|
workspaces = [],
|
|
182
|
+
workspaceTitles = {},
|
|
133
183
|
currentWorkspace = null,
|
|
134
184
|
currentSession = null,
|
|
135
185
|
sessions = [],
|
|
@@ -188,8 +238,9 @@ export function menuCard(ctx) {
|
|
|
188
238
|
|
|
189
239
|
let workspaceDropdown = null;
|
|
190
240
|
if (hasWorkspaces) {
|
|
191
|
-
const
|
|
192
|
-
|
|
241
|
+
const labels = workspaceLabels(workspaces, workspaceTitles);
|
|
242
|
+
const wsOptions = workspaces.slice(0, 20).map((path, index) => ({
|
|
243
|
+
text: { tag: 'plain_text', content: `${path === currentWorkspace ? '✓ ' : ''}${labels[index].numbered ? `${index + 1}. ` : ''}${labels[index].label}` },
|
|
193
244
|
value: path,
|
|
194
245
|
}));
|
|
195
246
|
workspaceDropdown = {
|
|
@@ -726,7 +777,8 @@ export function sessionListCard(workspace, sessions, page, total, watchedSession
|
|
|
726
777
|
// ── Workspace list card (preserved, with back button) ─────────────────────
|
|
727
778
|
|
|
728
779
|
/** The workspace list card (switch-workspace buttons + reply fallback). */
|
|
729
|
-
export function workspaceListCard(paths, current) {
|
|
780
|
+
export function workspaceListCard(paths, current, titles = {}) {
|
|
781
|
+
const labels = workspaceLabels(paths, titles);
|
|
730
782
|
const elements = paths.length === 0
|
|
731
783
|
? [
|
|
732
784
|
{ tag: 'div', text: markdown(t('当前 Host 上没有已登记的工作区。')) },
|
|
@@ -735,7 +787,7 @@ export function workspaceListCard(paths, current) {
|
|
|
735
787
|
: [
|
|
736
788
|
{ tag: 'div', text: markdown(t('回复数字切换工作区,或点击按钮:')) },
|
|
737
789
|
...paths.map((path, index) => button(
|
|
738
|
-
`${index + 1}. ${
|
|
790
|
+
`${index + 1}. ${labels[index].label}${path === current ? t('(当前)') : ''}`,
|
|
739
791
|
`workspace:${path}`,
|
|
740
792
|
)),
|
|
741
793
|
{ tag: 'hr' },
|
|
@@ -675,7 +675,7 @@ export class FeishuRuntime {
|
|
|
675
675
|
});
|
|
676
676
|
}
|
|
677
677
|
|
|
678
|
-
async sendProactiveText(target, text, { signal, format = 'plain' } = {}) {
|
|
678
|
+
async sendProactiveText(target, text, { signal, format = 'plain', receipt = false } = {}) {
|
|
679
679
|
if (!this.#status.ready || !this.#client) {
|
|
680
680
|
const error = new Error('飞书机器人尚未连接');
|
|
681
681
|
error.code = 'bot-not-connected';
|
|
@@ -698,6 +698,8 @@ export class FeishuRuntime {
|
|
|
698
698
|
error.code = 'bad-request';
|
|
699
699
|
throw error;
|
|
700
700
|
}
|
|
701
|
+
if (typeof receipt !== 'boolean' || (receipt && target.kind !== 'group'))
|
|
702
|
+
throw Object.assign(new Error('capability-unavailable'), { code: 'capability-unavailable' });
|
|
701
703
|
signal?.throwIfAborted();
|
|
702
704
|
// Use the same native Markdown element as chat, without opening a stream
|
|
703
705
|
// or retrying as plain text after a possibly accepted delivery.
|
|
@@ -724,7 +726,12 @@ export class FeishuRuntime {
|
|
|
724
726
|
error.code = 'target-rejected';
|
|
725
727
|
throw error;
|
|
726
728
|
}
|
|
727
|
-
return { sent: true };
|
|
729
|
+
if (!receipt) return { sent: true };
|
|
730
|
+
const messageId = response?.data?.message_id;
|
|
731
|
+
const conversationId = response?.data?.chat_id;
|
|
732
|
+
if (typeof messageId !== 'string' || !messageId || messageId.length > 512 || conversationId !== receiveId)
|
|
733
|
+
throw Object.assign(new Error('send-result-unknown'), { code: 'send-result-unknown' });
|
|
734
|
+
return { sent: true, receipt: { version: 1, messageId, conversationId } };
|
|
728
735
|
}
|
|
729
736
|
|
|
730
737
|
/**
|
|
@@ -594,7 +594,7 @@ export class MultiBotDshFeishuController {
|
|
|
594
594
|
this.#assertOpen('capability-unavailable');
|
|
595
595
|
return { version: 1, botId, channel: 'feishu', account,
|
|
596
596
|
connected: isConnected(connectionStatus(this.#runtimes.get(botId))),
|
|
597
|
-
capabilities: ['proactive-text-checked'] };
|
|
597
|
+
capabilities: ['proactive-text-checked', 'proactive-receipt-checked'] };
|
|
598
598
|
});
|
|
599
599
|
}
|
|
600
600
|
|
|
@@ -75,6 +75,7 @@ import {
|
|
|
75
75
|
} from './qq-menu.mjs';
|
|
76
76
|
import { withSessionBindingLock } from '../shared/session-binding-lock.mjs';
|
|
77
77
|
import { t } from '../shared/i18n.mjs';
|
|
78
|
+
import { isQqMessageAddressed, normalizeQqMentions } from './qq-mention.mjs';
|
|
78
79
|
|
|
79
80
|
function interactionResolvedText() {
|
|
80
81
|
return t('这个问题已在其他客户端处理,无需再次回答。');
|
|
@@ -408,7 +409,7 @@ async function sendQqFile(
|
|
|
408
409
|
function canClaimInteractionReply(message, pending) {
|
|
409
410
|
return pending.questions[pending.index]
|
|
410
411
|
&& nonEmptyString(message?.senderId) === pending.actor
|
|
411
|
-
&& (message
|
|
412
|
+
&& isQqMessageAddressed(message)
|
|
412
413
|
&& !hasQqImageAttachments(message)
|
|
413
414
|
&& !hasQqFileAttachments(message)
|
|
414
415
|
&& nonEmptyString(safeText(message));
|
|
@@ -487,6 +488,7 @@ export class QqHarnessBridge {
|
|
|
487
488
|
this.#replyTimeoutMs = replyTimeoutMs;
|
|
488
489
|
this.#signal = signal;
|
|
489
490
|
this.#deferred = createDeferredDeliveryCoordinator({ harness, state, signal, logger,
|
|
491
|
+
finalAnswerOnly: true,
|
|
490
492
|
deliver: (entry, outcome) => this.#deliverDeferredOutcome(entry, outcome),
|
|
491
493
|
});
|
|
492
494
|
this.#fetchImpl = fetchImpl;
|
|
@@ -506,9 +508,9 @@ export class QqHarnessBridge {
|
|
|
506
508
|
|| !['c2c', 'group'].includes(message?.kind)
|
|
507
509
|
|| this.#state.hasSeen(messageId)
|
|
508
510
|
|| this.#acceptedMessageIds.has(messageId)) return Promise.resolve();
|
|
511
|
+
message = normalizeQqMentions(message, new Set([this.#bot.appId]));
|
|
509
512
|
const key = conversationKey(message);
|
|
510
|
-
const addressed = message
|
|
511
|
-
|| message.rawEventType === 'GROUP_AT_MESSAGE_CREATE';
|
|
513
|
+
const addressed = isQqMessageAddressed(message);
|
|
512
514
|
const commandText = safeText(message);
|
|
513
515
|
const menuTextOnly = !hasQqImageAttachments(message) && !hasQqFileAttachments(message)
|
|
514
516
|
&& !qqReplyReference(message);
|
|
@@ -634,7 +636,7 @@ export class QqHarnessBridge {
|
|
|
634
636
|
actor: sender,
|
|
635
637
|
messageId,
|
|
636
638
|
text: hasQqImageAttachments(message) || hasQqFileAttachments(message) ? '' : safeText(message),
|
|
637
|
-
addressed
|
|
639
|
+
addressed,
|
|
638
640
|
hasPendingQuestion: Boolean(pending),
|
|
639
641
|
questionCompletion: pending?.submitting || pending?.claimedReplyMessageId
|
|
640
642
|
? pending.queue
|
|
@@ -689,8 +691,7 @@ export class QqHarnessBridge {
|
|
|
689
691
|
alreadyRecorded = false,
|
|
690
692
|
batchSubmission = null,
|
|
691
693
|
} = {}) {
|
|
692
|
-
const addressed = message
|
|
693
|
-
|| message.rawEventType === 'GROUP_AT_MESSAGE_CREATE';
|
|
694
|
+
const addressed = isQqMessageAddressed(message);
|
|
694
695
|
const preparedMessage = addressed
|
|
695
696
|
? prefetchInboundFiles(
|
|
696
697
|
qqInboundMessage(message, { fetchImpl: this.#fetchImpl }),
|
|
@@ -984,7 +985,7 @@ export class QqHarnessBridge {
|
|
|
984
985
|
await this.#state.markSeen(messageId);
|
|
985
986
|
messageRecorded = true;
|
|
986
987
|
};
|
|
987
|
-
if (message
|
|
988
|
+
if (!isQqMessageAddressed(message)) return;
|
|
988
989
|
|
|
989
990
|
const target = message.replyTarget;
|
|
990
991
|
const promptMessage = preparedMessage
|
|
@@ -1085,6 +1086,7 @@ export class QqHarnessBridge {
|
|
|
1085
1086
|
signal: this.#signal,
|
|
1086
1087
|
control: { owner: this, key },
|
|
1087
1088
|
progressMode: 'all',
|
|
1089
|
+
finalAnswerOnly: true,
|
|
1088
1090
|
onUpdate: (update) => {
|
|
1089
1091
|
if (update.error) {
|
|
1090
1092
|
const name = (nonEmptyString(update.toolName) ?? t('工具'))
|
|
@@ -1245,7 +1247,7 @@ export class QqHarnessBridge {
|
|
|
1245
1247
|
this.#status.messagesReceived += 1;
|
|
1246
1248
|
this.#status.lastMessageAt = new Date().toISOString();
|
|
1247
1249
|
|
|
1248
|
-
if (message
|
|
1250
|
+
if (!isQqMessageAddressed(message)) return;
|
|
1249
1251
|
const text = nonEmptyString(safeText(message));
|
|
1250
1252
|
if (!text || hasQqImageAttachments(message) || hasQqFileAttachments(message)) {
|
|
1251
1253
|
await this.#bot.sendText(message.replyTarget, t('请用文字回答当前问题。'));
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
// Keep the decision after the SDK sanitizes the mention out of the content.
|
|
2
|
+
const addressedToBot = Symbol('qq.addressedToBot');
|
|
3
|
+
|
|
4
|
+
function selfMentions(message) {
|
|
5
|
+
return Array.isArray(message?.mentions)
|
|
6
|
+
? message.mentions.filter((mention) => mention?.is_you === true)
|
|
7
|
+
: [];
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export function isQqMessageAddressed(message) {
|
|
11
|
+
return Boolean(message) && (message.kind !== 'group'
|
|
12
|
+
|| message[addressedToBot] === true
|
|
13
|
+
|| message.rawEventType === 'GROUP_AT_MESSAGE_CREATE'
|
|
14
|
+
|| selfMentions(message).length > 0);
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/** botIds belongs to one runtime; only platform-marked self mentions add IDs. */
|
|
18
|
+
export function normalizeQqMentions(message, botIds = new Set()) {
|
|
19
|
+
if (message?.kind !== 'group') return message;
|
|
20
|
+
for (const mention of selfMentions(message)) {
|
|
21
|
+
for (const id of [mention.id, mention.user_openid, mention.member_openid]) {
|
|
22
|
+
if (typeof id === 'string' && id.trim()) botIds.add(id.trim());
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
const marker = /<@!?([^<>\s]+)>/gu;
|
|
26
|
+
const content = typeof message.content === 'string' ? message.content : '';
|
|
27
|
+
// raw.content is still the current message, never the quoted message snapshot.
|
|
28
|
+
const rawContent = typeof message.raw?.content === 'string' ? message.raw.content : '';
|
|
29
|
+
const mentioned = isQqMessageAddressed(message)
|
|
30
|
+
|| [...`${content}\n${rawContent}`.matchAll(marker)].some((match) => botIds.has(match[1]));
|
|
31
|
+
// Match the SDK sanitizer's in-place contract: later sender enrichment must
|
|
32
|
+
// remain visible to the existing queued message and source-context capture.
|
|
33
|
+
message[addressedToBot] = mentioned;
|
|
34
|
+
message.content = content.replace(/<@!?([^<>\s]+)>[ \t]*/gu,
|
|
35
|
+
(match, id) => botIds.has(id) ? '' : match).trim();
|
|
36
|
+
return message;
|
|
37
|
+
}
|
|
@@ -8,6 +8,7 @@ import {
|
|
|
8
8
|
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
|
+
import { isQqMessageAddressed, normalizeQqMentions } from './qq-mention.mjs';
|
|
11
12
|
|
|
12
13
|
function timeoutError() {
|
|
13
14
|
const error = new Error('QQ WebSocket did not become ready in time');
|
|
@@ -178,6 +179,11 @@ export class QqRuntime {
|
|
|
178
179
|
replyTimeoutMs: this.#replyTimeoutMs,
|
|
179
180
|
signal: controller.signal,
|
|
180
181
|
});
|
|
182
|
+
const botMentionIds = new Set([this.#config.appId]);
|
|
183
|
+
bot.use(async (ctx, next) => {
|
|
184
|
+
ctx.message = normalizeQqMentions(ctx.message, botMentionIds);
|
|
185
|
+
await next();
|
|
186
|
+
});
|
|
181
187
|
// QQ delivers emoji/face messages as opaque `<faceType=..,faceId="..",ext="..">`
|
|
182
188
|
// tags. Parse them into readable text so the Harness sees what the sender
|
|
183
189
|
// actually meant instead of an unusable markup fragment.
|
|
@@ -186,8 +192,7 @@ export class QqRuntime {
|
|
|
186
192
|
keepAlive: true,
|
|
187
193
|
predicate: (ctx) => {
|
|
188
194
|
const message = ctx?.message;
|
|
189
|
-
if (!
|
|
190
|
-
&& message.rawEventType !== 'GROUP_AT_MESSAGE_CREATE')) return false;
|
|
195
|
+
if (!isQqMessageAddressed(message)) return false;
|
|
191
196
|
if (!this.#accessPolicy) {
|
|
192
197
|
return message.kind === 'group'
|
|
193
198
|
|| this.#config.ownerUserOpenid === '*'
|
|
@@ -207,7 +212,10 @@ export class QqRuntime {
|
|
|
207
212
|
readyResolve = resolve;
|
|
208
213
|
readyReject = reject;
|
|
209
214
|
});
|
|
210
|
-
const onReady = () => {
|
|
215
|
+
const onReady = (data) => {
|
|
216
|
+
if (typeof data?.user?.id === 'string' && data.user.id.trim()) {
|
|
217
|
+
botMentionIds.add(data.user.id.trim());
|
|
218
|
+
}
|
|
211
219
|
const now = Date.now();
|
|
212
220
|
this.#status.ready = true;
|
|
213
221
|
this.#status.qqConnectionState = 'connected';
|
|
@@ -1709,6 +1709,7 @@ export function createBotWorkspaceScope(
|
|
|
1709
1709
|
};
|
|
1710
1710
|
}
|
|
1711
1711
|
if ((property === 'listWorkspaces'
|
|
1712
|
+
|| property === 'listWorkspaceEntries'
|
|
1712
1713
|
|| property === 'listWorkspaceSessions'
|
|
1713
1714
|
|| property === 'listModels')
|
|
1714
1715
|
&& typeof target[property] === 'function') {
|
|
@@ -24,6 +24,7 @@ function orderedEvents(history) {
|
|
|
24
24
|
export function createDeferredDeliveryCoordinator({
|
|
25
25
|
harness, state, deliver, signal, logger = console,
|
|
26
26
|
retryDelayMs = 1_000, pollDelayMs = 30_000, watch = true,
|
|
27
|
+
finalAnswerOnly = false,
|
|
27
28
|
} = {}) {
|
|
28
29
|
const enabled = ['deferredEntries', 'putDeferred', 'patchDeferred', 'removeDeferred']
|
|
29
30
|
.every((name) => typeof state?.[name] === 'function');
|
|
@@ -100,7 +101,7 @@ export function createDeferredDeliveryCoordinator({
|
|
|
100
101
|
}
|
|
101
102
|
const outcome = targetTurn === null
|
|
102
103
|
? { found: false, endSeq: -1, turn: null }
|
|
103
|
-
: extractCompletedTurnAnswer(ordered, { turn: targetTurn });
|
|
104
|
+
: extractCompletedTurnAnswer(ordered, { turn: targetTurn, finalAnswerOnly });
|
|
104
105
|
const hasStart = ordered.some((event) => event.type === 'turn/start' && event.data?.turn === targetTurn);
|
|
105
106
|
if (!history.hasMore || hasStart) return { ...outcome, turn: targetTurn };
|
|
106
107
|
const oldest = batch[0]?.seq;
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { AssistantTextAccumulator } from './harness-client.mjs';
|
|
1
|
+
import { AssistantTextAccumulator, accumulateFinalAssistantText } from './harness-client.mjs';
|
|
2
2
|
|
|
3
3
|
export const MAX_DEFERRED_PER_KEY = 4;
|
|
4
4
|
|
|
@@ -35,12 +35,13 @@ function textFromContent(content) {
|
|
|
35
35
|
*
|
|
36
36
|
* Reuses HarnessReplyTracker text accumulation: chunk deltas accumulate per
|
|
37
37
|
* (step, part index); a canonical assistant/message replaces its step; steps
|
|
38
|
-
* join with a blank line.
|
|
38
|
+
* join with a blank line. finalAnswerOnly discards text before tool calls.
|
|
39
|
+
* `turn` omitted → prefer the latest completed answer.
|
|
39
40
|
* Returns `{ found, turn, text, reason, endSeq }`; `found: false` when the
|
|
40
41
|
* turn ended without a completed final answer (stopped/failed/empty/
|
|
41
42
|
* interrupted) — `reason`/`endSeq` still identify the confirmed terminal.
|
|
42
43
|
*/
|
|
43
|
-
export function extractCompletedTurnAnswer(entries, { turn } = {}) {
|
|
44
|
+
export function extractCompletedTurnAnswer(entries, { turn, finalAnswerOnly = false } = {}) {
|
|
44
45
|
const events = orderedEvents(entries);
|
|
45
46
|
const wantTurn = Number.isSafeInteger(turn) ? turn : null;
|
|
46
47
|
function turnAnswer(turnEnd) {
|
|
@@ -48,12 +49,14 @@ export function extractCompletedTurnAnswer(entries, { turn } = {}) {
|
|
|
48
49
|
let interrupted = false;
|
|
49
50
|
for (const event of events) {
|
|
50
51
|
if (event.seq > turnEnd.seq || event.data?.turn !== turnEnd.data.turn) continue;
|
|
51
|
-
if (
|
|
52
|
+
if (finalAnswerOnly === true) {
|
|
53
|
+
accumulateFinalAssistantText(accumulator, event);
|
|
54
|
+
} else if (event.type === 'assistant/chunk' && event.data?.chunk?.type === 'text-delta') {
|
|
52
55
|
accumulator.appendDelta(event.data.step, event.data.chunk.index, event.data.chunk.text);
|
|
53
56
|
} else if (event.type === 'assistant/message') {
|
|
54
57
|
accumulator.setCanonical(event.data.step, textFromContent(event.data.message?.content));
|
|
55
|
-
interrupted = event.data.interrupted === true;
|
|
56
58
|
}
|
|
59
|
+
if (event.type === 'assistant/message') interrupted = event.data.interrupted === true;
|
|
57
60
|
}
|
|
58
61
|
return { text: accumulator.text, interrupted };
|
|
59
62
|
}
|