@xmanrui/dsh-im 4.34.2 → 4.35.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.en.md +3 -1
- package/README.md +3 -1
- package/lib/client.js +1 -1
- package/lib/index.js +282 -281
- 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 +8 -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/harness-client.mjs +9 -3
- 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.0",
|
|
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));
|
|
@@ -506,9 +507,9 @@ export class QqHarnessBridge {
|
|
|
506
507
|
|| !['c2c', 'group'].includes(message?.kind)
|
|
507
508
|
|| this.#state.hasSeen(messageId)
|
|
508
509
|
|| this.#acceptedMessageIds.has(messageId)) return Promise.resolve();
|
|
510
|
+
message = normalizeQqMentions(message, new Set([this.#bot.appId]));
|
|
509
511
|
const key = conversationKey(message);
|
|
510
|
-
const addressed = message
|
|
511
|
-
|| message.rawEventType === 'GROUP_AT_MESSAGE_CREATE';
|
|
512
|
+
const addressed = isQqMessageAddressed(message);
|
|
512
513
|
const commandText = safeText(message);
|
|
513
514
|
const menuTextOnly = !hasQqImageAttachments(message) && !hasQqFileAttachments(message)
|
|
514
515
|
&& !qqReplyReference(message);
|
|
@@ -634,7 +635,7 @@ export class QqHarnessBridge {
|
|
|
634
635
|
actor: sender,
|
|
635
636
|
messageId,
|
|
636
637
|
text: hasQqImageAttachments(message) || hasQqFileAttachments(message) ? '' : safeText(message),
|
|
637
|
-
addressed
|
|
638
|
+
addressed,
|
|
638
639
|
hasPendingQuestion: Boolean(pending),
|
|
639
640
|
questionCompletion: pending?.submitting || pending?.claimedReplyMessageId
|
|
640
641
|
? pending.queue
|
|
@@ -689,8 +690,7 @@ export class QqHarnessBridge {
|
|
|
689
690
|
alreadyRecorded = false,
|
|
690
691
|
batchSubmission = null,
|
|
691
692
|
} = {}) {
|
|
692
|
-
const addressed = message
|
|
693
|
-
|| message.rawEventType === 'GROUP_AT_MESSAGE_CREATE';
|
|
693
|
+
const addressed = isQqMessageAddressed(message);
|
|
694
694
|
const preparedMessage = addressed
|
|
695
695
|
? prefetchInboundFiles(
|
|
696
696
|
qqInboundMessage(message, { fetchImpl: this.#fetchImpl }),
|
|
@@ -984,7 +984,7 @@ export class QqHarnessBridge {
|
|
|
984
984
|
await this.#state.markSeen(messageId);
|
|
985
985
|
messageRecorded = true;
|
|
986
986
|
};
|
|
987
|
-
if (message
|
|
987
|
+
if (!isQqMessageAddressed(message)) return;
|
|
988
988
|
|
|
989
989
|
const target = message.replyTarget;
|
|
990
990
|
const promptMessage = preparedMessage
|
|
@@ -1245,7 +1245,7 @@ export class QqHarnessBridge {
|
|
|
1245
1245
|
this.#status.messagesReceived += 1;
|
|
1246
1246
|
this.#status.lastMessageAt = new Date().toISOString();
|
|
1247
1247
|
|
|
1248
|
-
if (message
|
|
1248
|
+
if (!isQqMessageAddressed(message)) return;
|
|
1249
1249
|
const text = nonEmptyString(safeText(message));
|
|
1250
1250
|
if (!text || hasQqImageAttachments(message) || hasQqFileAttachments(message)) {
|
|
1251
1251
|
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') {
|
|
@@ -242,10 +242,12 @@ function turnStoppedError() {
|
|
|
242
242
|
return error;
|
|
243
243
|
}
|
|
244
244
|
|
|
245
|
-
function
|
|
245
|
+
function workspaceEntries(value) {
|
|
246
246
|
if (!Array.isArray(value?.items)) return [];
|
|
247
247
|
return value.items.flatMap((item) => (
|
|
248
|
-
typeof item?.path === 'string' && isAbsolute(item.path)
|
|
248
|
+
typeof item?.path === 'string' && isAbsolute(item.path)
|
|
249
|
+
? [{ path: item.path, title: typeof item.title === 'string' ? item.title : '' }]
|
|
250
|
+
: []
|
|
249
251
|
));
|
|
250
252
|
}
|
|
251
253
|
|
|
@@ -1081,8 +1083,12 @@ export class HarnessClient {
|
|
|
1081
1083
|
}
|
|
1082
1084
|
|
|
1083
1085
|
async listWorkspaces(options = {}) {
|
|
1086
|
+
return (await this.listWorkspaceEntries(options)).map((item) => item.path);
|
|
1087
|
+
}
|
|
1088
|
+
|
|
1089
|
+
async listWorkspaceEntries(options = {}) {
|
|
1084
1090
|
await this.ensureRunning(options);
|
|
1085
|
-
return
|
|
1091
|
+
return workspaceEntries(await this.rpc('workspace.list', {}, 30_000, options));
|
|
1086
1092
|
}
|
|
1087
1093
|
|
|
1088
1094
|
async listWorkspaceSessions(workspacePath, options = {}) {
|
|
@@ -62,17 +62,19 @@ export function parseSessionListArgument(value) {
|
|
|
62
62
|
return { selector: '', limit };
|
|
63
63
|
}
|
|
64
64
|
|
|
65
|
+
async function existingWorkspacePath(value) {
|
|
66
|
+
const workspace = normalizedWorkspacePath(value);
|
|
67
|
+
if (!workspace) return null;
|
|
68
|
+
try {
|
|
69
|
+
if (!(await stat(workspace)).isDirectory()) return null;
|
|
70
|
+
return normalizedWorkspacePath(await realpath(workspace));
|
|
71
|
+
} catch {
|
|
72
|
+
return null;
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
65
76
|
async function existingWorkspacePaths(values) {
|
|
66
|
-
const checked = await Promise.all(values.map(
|
|
67
|
-
const workspace = normalizedWorkspacePath(value);
|
|
68
|
-
if (!workspace) return null;
|
|
69
|
-
try {
|
|
70
|
-
if (!(await stat(workspace)).isDirectory()) return null;
|
|
71
|
-
return normalizedWorkspacePath(await realpath(workspace));
|
|
72
|
-
} catch {
|
|
73
|
-
return null;
|
|
74
|
-
}
|
|
75
|
-
}));
|
|
77
|
+
const checked = await Promise.all(values.map(existingWorkspacePath));
|
|
76
78
|
return [...new Set(checked.filter(Boolean))];
|
|
77
79
|
}
|
|
78
80
|
|
|
@@ -109,16 +111,26 @@ async function selectedWorkspacePath(value) {
|
|
|
109
111
|
}
|
|
110
112
|
}
|
|
111
113
|
|
|
112
|
-
export async function workspacePathSnapshot(harness, options = {}) {
|
|
113
|
-
const
|
|
114
|
+
export async function workspacePathSnapshot(harness, { includeTitles = false, ...options } = {}) {
|
|
115
|
+
const withEntries = includeTitles && typeof harness.listWorkspaceEntries === 'function';
|
|
116
|
+
const listed = await (withEntries ? harness.listWorkspaceEntries(options) : harness.listWorkspaces(options));
|
|
114
117
|
const currentValue = typeof harness?.currentWorkspace === 'function'
|
|
115
118
|
? harness.currentWorkspace()
|
|
116
119
|
: null;
|
|
117
|
-
const
|
|
118
|
-
const
|
|
119
|
-
|
|
120
|
+
const current = await existingWorkspacePath(currentValue);
|
|
121
|
+
const entries = await Promise.all((Array.isArray(listed) ? listed : []).map(async (item) => ({
|
|
122
|
+
path: await existingWorkspacePath(withEntries ? item?.path : item),
|
|
123
|
+
title: withEntries ? safeDisplayText(item?.title) : '',
|
|
124
|
+
})));
|
|
125
|
+
const paths = [...new Set([...(current ? [current] : []), ...entries.map((item) => item.path).filter(Boolean)])];
|
|
126
|
+
const titles = {};
|
|
127
|
+
if (includeTitles) {
|
|
128
|
+
for (const item of entries) {
|
|
129
|
+
if (item.path && item.title && !titles[item.path]) titles[item.path] = item.title;
|
|
130
|
+
}
|
|
131
|
+
}
|
|
120
132
|
harness.assertWorkspaceScope?.();
|
|
121
|
-
return { current: current ?? null, paths };
|
|
133
|
+
return { current: current ?? null, paths, ...(includeTitles ? { titles } : {}) };
|
|
122
134
|
}
|
|
123
135
|
|
|
124
136
|
export function splitWorkspaceCommandMessage(message) {
|