@xmanrui/dsh-im 4.32.0 → 4.34.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/PROACTIVE_DELIVERY.en.md +11 -0
- package/PROACTIVE_DELIVERY.md +11 -0
- package/README.en.md +11 -7
- package/README.md +11 -7
- package/lib/client.js +3 -71
- package/lib/index.js +298 -295
- package/package.json +1 -71
- package/plugin-src/host/delivery-adapter.mjs +8 -0
- package/plugin-src/host/delivery-service.mjs +75 -0
- package/plugin-src/host/host-language.mjs +21 -21
- package/plugin-src/host/index.mjs +3 -0
- package/plugin-src/host/modern-harness-api.mjs +22 -0
- package/plugin-src/host/session-sync-coordinator.mjs +10 -13
- package/src/channels/feishu/bridge.mjs +87 -7
- package/src/channels/feishu/feishu-cards.mjs +29 -1
- package/src/channels/feishu/message-utils.mjs +61 -64
- package/src/channels/feishu/multi-bot-controller.mjs +59 -5
- package/src/channels/feishu/resource-download.mjs +120 -0
- package/src/channels/feishu/slash-command-panel.mjs +2 -0
- package/src/channels/qq/qq-bridge.mjs +6 -1
- package/src/channels/shared/bot-workspace-store.mjs +5 -0
- package/src/channels/shared/command-list-snapshots.mjs +33 -0
- package/src/channels/shared/harness-client.mjs +11 -4
- package/src/channels/shared/i18n-en/feishu.mjs +7 -0
- package/src/channels/shared/i18n-en/permission.mjs +30 -0
- package/src/channels/shared/i18n-en.mjs +2 -0
- package/src/channels/shared/permission-command.mjs +149 -0
- package/src/channels/shared/preset-command.mjs +6 -51
- package/src/channels/shared/semantic/artifact.mjs +88 -18
- package/src/channels/telegram/telegram-runtime.mjs +12 -26
|
@@ -1,8 +1,11 @@
|
|
|
1
|
+
import { addAbortSignal, Readable } from 'node:stream';
|
|
2
|
+
import { downloadFeishuResourceRanges } from './resource-download.mjs';
|
|
1
3
|
import { imageDownloadLimitMessage } from '../shared/image-prompt.mjs';
|
|
2
4
|
import { ImagePromptError } from '../shared/image-prompt.mjs';
|
|
3
5
|
import { t } from '../shared/i18n.mjs';
|
|
4
6
|
|
|
5
7
|
const FEISHU_MISSING_MESSAGE_SCOPE_CODE = 99991672;
|
|
8
|
+
const FEISHU_RESOURCE_SIZE_LIMIT_CODE = 234037;
|
|
6
9
|
const FEISHU_CARD_MESSAGE_CONTENT_TYPE = 'raw_card_content';
|
|
7
10
|
const FEISHU_ERROR_BODY_LIMIT = 64 * 1024;
|
|
8
11
|
const FEISHU_ERROR_BODY_TIMEOUT_MS = 1_000;
|
|
@@ -305,31 +308,6 @@ async function readBoundedStream(stream, { signal, maxBytes }) {
|
|
|
305
308
|
}
|
|
306
309
|
}
|
|
307
310
|
|
|
308
|
-
async function readStream(stream, { signal }) {
|
|
309
|
-
if (!stream || typeof stream[Symbol.asyncIterator] !== 'function') {
|
|
310
|
-
throw new Error('Feishu file download returned no readable stream');
|
|
311
|
-
}
|
|
312
|
-
signal?.throwIfAborted();
|
|
313
|
-
const abort = () => stream.destroy?.(
|
|
314
|
-
signal.reason ?? new DOMException('Feishu file download aborted', 'AbortError'),
|
|
315
|
-
);
|
|
316
|
-
signal?.addEventListener('abort', abort, { once: true });
|
|
317
|
-
const chunks = [];
|
|
318
|
-
let size = 0;
|
|
319
|
-
try {
|
|
320
|
-
for await (const chunk of stream) {
|
|
321
|
-
signal?.throwIfAborted();
|
|
322
|
-
const data = Buffer.from(chunk);
|
|
323
|
-
size += data.length;
|
|
324
|
-
chunks.push(data);
|
|
325
|
-
}
|
|
326
|
-
signal?.throwIfAborted();
|
|
327
|
-
return Buffer.concat(chunks, size);
|
|
328
|
-
} finally {
|
|
329
|
-
signal?.removeEventListener('abort', abort);
|
|
330
|
-
}
|
|
331
|
-
}
|
|
332
|
-
|
|
333
311
|
function providerCode(value) {
|
|
334
312
|
if (!value || typeof value !== 'object') return null;
|
|
335
313
|
const code = value.code ?? value.error?.code;
|
|
@@ -375,7 +353,7 @@ async function feishuProviderCode(error, signal) {
|
|
|
375
353
|
seen.add(value);
|
|
376
354
|
const directCode = providerCode(value);
|
|
377
355
|
const data = value.response?.data ?? value.data;
|
|
378
|
-
if (directCode === FEISHU_MISSING_MESSAGE_SCOPE_CODE) {
|
|
356
|
+
if (directCode === FEISHU_MISSING_MESSAGE_SCOPE_CODE || directCode === FEISHU_RESOURCE_SIZE_LIMIT_CODE) {
|
|
379
357
|
data?.destroy?.();
|
|
380
358
|
return directCode;
|
|
381
359
|
}
|
|
@@ -383,21 +361,22 @@ async function feishuProviderCode(error, signal) {
|
|
|
383
361
|
const body = await readFeishuErrorBody(data, signal);
|
|
384
362
|
try {
|
|
385
363
|
const parsedCode = providerCode(JSON.parse(body));
|
|
386
|
-
if (parsedCode === FEISHU_MISSING_MESSAGE_SCOPE_CODE) return parsedCode;
|
|
364
|
+
if (parsedCode === FEISHU_MISSING_MESSAGE_SCOPE_CODE || parsedCode === FEISHU_RESOURCE_SIZE_LIMIT_CODE) return parsedCode;
|
|
387
365
|
} catch {
|
|
388
366
|
// Non-JSON provider failures keep the generic image download message.
|
|
389
367
|
}
|
|
390
368
|
} else {
|
|
391
369
|
const dataCode = providerCode(data);
|
|
392
|
-
if (dataCode === FEISHU_MISSING_MESSAGE_SCOPE_CODE) return dataCode;
|
|
370
|
+
if (dataCode === FEISHU_MISSING_MESSAGE_SCOPE_CODE || dataCode === FEISHU_RESOURCE_SIZE_LIMIT_CODE) return dataCode;
|
|
393
371
|
}
|
|
394
372
|
pending.push(value.cause);
|
|
395
373
|
}
|
|
396
374
|
return null;
|
|
397
375
|
}
|
|
398
376
|
|
|
399
|
-
async function feishuImageDownloadError(error, signal) {
|
|
400
|
-
if (await feishuProviderCode(error, signal)
|
|
377
|
+
async function feishuImageDownloadError(error, signal, code = undefined) {
|
|
378
|
+
if (code === undefined) code = await feishuProviderCode(error, signal);
|
|
379
|
+
if (code !== FEISHU_MISSING_MESSAGE_SCOPE_CODE) return error;
|
|
401
380
|
return new ImagePromptError(
|
|
402
381
|
'feishu-image-permission-required',
|
|
403
382
|
'Feishu image download requires the im:message:readonly tenant scope',
|
|
@@ -406,33 +385,59 @@ async function feishuImageDownloadError(error, signal) {
|
|
|
406
385
|
);
|
|
407
386
|
}
|
|
408
387
|
|
|
409
|
-
function
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
388
|
+
async function feishuResourceStream(event, client, key, type, { signal, maxBytes } = {}) {
|
|
389
|
+
signal?.throwIfAborted();
|
|
390
|
+
const path = { message_id: event.message.message_id, file_key: key };
|
|
391
|
+
let resource;
|
|
392
|
+
try {
|
|
393
|
+
resource = await client?.im?.v1?.messageResource?.get?.({ path, params: { type } });
|
|
394
|
+
} catch (error) {
|
|
395
|
+
if (signal?.aborted) {
|
|
396
|
+
error?.response?.data?.destroy?.();
|
|
397
|
+
signal.throwIfAborted();
|
|
398
|
+
}
|
|
399
|
+
const code = await feishuProviderCode(error, signal);
|
|
400
|
+
if (code !== FEISHU_RESOURCE_SIZE_LIMIT_CODE) {
|
|
401
|
+
throw type === 'image' ? await feishuImageDownloadError(error, signal, code) : error;
|
|
402
|
+
}
|
|
403
|
+
return Readable.from((async function* ranges() {
|
|
414
404
|
try {
|
|
415
|
-
|
|
416
|
-
path: {
|
|
417
|
-
message_id: event.message.message_id,
|
|
418
|
-
file_key: key,
|
|
419
|
-
},
|
|
420
|
-
params: { type: 'image' },
|
|
421
|
-
});
|
|
405
|
+
yield* downloadFeishuResourceRanges(client, path, type, { signal, maxBytes });
|
|
422
406
|
} catch (error) {
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
resource?.getReadableStream?.().destroy?.();
|
|
429
|
-
throw new ImagePromptError(
|
|
430
|
-
'image-too-large',
|
|
431
|
-
`Feishu image declares ${size} bytes; the limit is ${maxBytes}`,
|
|
432
|
-
imageDownloadLimitMessage(maxBytes),
|
|
433
|
-
);
|
|
407
|
+
try {
|
|
408
|
+
throw type === 'image' ? await feishuImageDownloadError(error, signal) : error;
|
|
409
|
+
} finally {
|
|
410
|
+
error?.response?.data?.destroy?.();
|
|
411
|
+
}
|
|
434
412
|
}
|
|
435
|
-
|
|
413
|
+
}()), { objectMode: false, signal });
|
|
414
|
+
}
|
|
415
|
+
const stream = resource?.getReadableStream?.();
|
|
416
|
+
try {
|
|
417
|
+
signal?.throwIfAborted();
|
|
418
|
+
const size = declaredSize(resource?.headers);
|
|
419
|
+
if (size !== null && size > maxBytes) {
|
|
420
|
+
throw new ImagePromptError(
|
|
421
|
+
'image-too-large',
|
|
422
|
+
`Feishu image declares ${size} bytes; the limit is ${maxBytes}`,
|
|
423
|
+
imageDownloadLimitMessage(maxBytes),
|
|
424
|
+
);
|
|
425
|
+
}
|
|
426
|
+
if (!stream || typeof stream[Symbol.asyncIterator] !== 'function') {
|
|
427
|
+
throw new Error(`Feishu ${type} download returned no readable stream`);
|
|
428
|
+
}
|
|
429
|
+
return signal ? addAbortSignal(signal, stream) : stream;
|
|
430
|
+
} catch (error) {
|
|
431
|
+
stream?.destroy?.();
|
|
432
|
+
throw error;
|
|
433
|
+
}
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
function feishuImageSource(event, client, key) {
|
|
437
|
+
return {
|
|
438
|
+
async load({ signal, maxBytes }) {
|
|
439
|
+
const stream = await feishuResourceStream(event, client, key, 'image', { signal, maxBytes });
|
|
440
|
+
return readBoundedStream(stream, { signal, maxBytes });
|
|
436
441
|
},
|
|
437
442
|
};
|
|
438
443
|
}
|
|
@@ -443,16 +448,8 @@ function feishuFileSource(event, client, file) {
|
|
|
443
448
|
return {
|
|
444
449
|
name: nonEmptyString(file?.file_name) ?? 'file',
|
|
445
450
|
async load({ signal } = {}) {
|
|
446
|
-
signal
|
|
447
|
-
|
|
448
|
-
path: {
|
|
449
|
-
message_id: event.message.message_id,
|
|
450
|
-
file_key: key,
|
|
451
|
-
},
|
|
452
|
-
params: { type: 'file' },
|
|
453
|
-
});
|
|
454
|
-
signal?.throwIfAborted();
|
|
455
|
-
return readStream(resource?.getReadableStream?.(), { signal });
|
|
451
|
+
const stream = await feishuResourceStream(event, client, key, 'file', { signal });
|
|
452
|
+
return { stream };
|
|
456
453
|
},
|
|
457
454
|
};
|
|
458
455
|
}
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { atConnectionStage, createConnectionDiagnostics } from '../shared/connection-error.mjs';
|
|
2
|
-
import { randomUUID } from 'node:crypto';
|
|
2
|
+
import { createHash, randomUUID } from 'node:crypto';
|
|
3
3
|
import { connectionTestMessage } from '../shared/connection-test.mjs';
|
|
4
4
|
import { publicMessageFailure } from '../shared/message-failure.mjs';
|
|
5
5
|
import { RegistrationManager } from './registration-manager.mjs';
|
|
@@ -563,10 +563,60 @@ export class MultiBotDshFeishuController {
|
|
|
563
563
|
});
|
|
564
564
|
}
|
|
565
565
|
|
|
566
|
+
async #deliveryAccount(config) {
|
|
567
|
+
let verified;
|
|
568
|
+
try {
|
|
569
|
+
const resolved = await this.#credentials.resolve(config.secretRef);
|
|
570
|
+
if (!resolved?.value) throw new Error('Account credentials unavailable');
|
|
571
|
+
verified = await this.#verifyApp({ appId: config.appId, appSecret: resolved.value, domain: config.domain });
|
|
572
|
+
} catch (cause) {
|
|
573
|
+
const error = new Error('Account credentials could not be verified', { cause });
|
|
574
|
+
error.code = 'account-unverified';
|
|
575
|
+
throw error;
|
|
576
|
+
}
|
|
577
|
+
if (!verified?.openId || verified.openId !== config.botOpenId) {
|
|
578
|
+
const error = new Error('Authenticated account identity changed');
|
|
579
|
+
error.code = 'account-changed';
|
|
580
|
+
throw error;
|
|
581
|
+
}
|
|
582
|
+
const fingerprint = createHash('sha256').update(JSON.stringify({
|
|
583
|
+
provider: 'feishu', domain: config.domain, appId: config.appId, botOpenId: verified.openId,
|
|
584
|
+
})).digest('hex');
|
|
585
|
+
return { fingerprint, ...(verified.name ? { name: verified.name } : {}) };
|
|
586
|
+
}
|
|
587
|
+
|
|
588
|
+
async describeDeliveryAccount(botId) {
|
|
589
|
+
this.#assertOpen('capability-unavailable');
|
|
590
|
+
return this.#withBotTransition(botId, async () => {
|
|
591
|
+
this.#assertOpen('capability-unavailable');
|
|
592
|
+
const config = this.#requireBot(botId);
|
|
593
|
+
const account = await this.#deliveryAccount(config);
|
|
594
|
+
this.#assertOpen('capability-unavailable');
|
|
595
|
+
return { version: 1, botId, channel: 'feishu', account,
|
|
596
|
+
connected: isConnected(connectionStatus(this.#runtimes.get(botId))),
|
|
597
|
+
capabilities: ['proactive-text-checked'] };
|
|
598
|
+
});
|
|
599
|
+
}
|
|
600
|
+
|
|
566
601
|
async sendProactiveText(botId, target, text, options = {}) {
|
|
567
|
-
|
|
602
|
+
const checked = options.expectedFingerprint !== undefined;
|
|
603
|
+
this.#assertOpen(checked ? 'capability-unavailable' : undefined);
|
|
568
604
|
return this.#withBotTransition(botId, async () => {
|
|
569
|
-
this.#
|
|
605
|
+
this.#assertOpen(checked ? 'capability-unavailable' : undefined);
|
|
606
|
+
const config = this.#requireBot(botId);
|
|
607
|
+
if (checked) {
|
|
608
|
+
const account = await this.#deliveryAccount(config);
|
|
609
|
+
if (account.fingerprint !== options.expectedFingerprint) {
|
|
610
|
+
const error = new Error('Authenticated account identity changed');
|
|
611
|
+
error.code = 'account-changed';
|
|
612
|
+
throw error;
|
|
613
|
+
}
|
|
614
|
+
// Account verification yields; a Registration can be replaced or disposed
|
|
615
|
+
// before the SDK request starts. Recheck its owner at the effect boundary.
|
|
616
|
+
options.beforeSend?.();
|
|
617
|
+
this.#assertOpen('capability-unavailable');
|
|
618
|
+
options.signal?.throwIfAborted();
|
|
619
|
+
}
|
|
570
620
|
const runtime = this.#runtimes.get(botId);
|
|
571
621
|
if (!isConnected(connectionStatus(runtime))
|
|
572
622
|
|| typeof runtime.sendProactiveText !== 'function') {
|
|
@@ -1386,8 +1436,12 @@ export class MultiBotDshFeishuController {
|
|
|
1386
1436
|
return config;
|
|
1387
1437
|
}
|
|
1388
1438
|
|
|
1389
|
-
#assertOpen() {
|
|
1390
|
-
if (this.#closed)
|
|
1439
|
+
#assertOpen(code) {
|
|
1440
|
+
if (this.#closed) {
|
|
1441
|
+
const error = new Error('The Feishu controller is closed');
|
|
1442
|
+
if (code) error.code = code;
|
|
1443
|
+
throw error;
|
|
1444
|
+
}
|
|
1391
1445
|
}
|
|
1392
1446
|
|
|
1393
1447
|
#serializeConfig(operation) {
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
import { ImagePromptError, imageDownloadLimitMessage } from '../shared/image-prompt.mjs';
|
|
2
|
+
|
|
3
|
+
const RESOURCE_CHUNK_BYTES = 8 * 1024 * 1024;
|
|
4
|
+
|
|
5
|
+
function headerValue(headers, name) {
|
|
6
|
+
if (typeof headers?.get === 'function') return headers.get(name) ?? null;
|
|
7
|
+
const key = Object.keys(headers ?? {}).find((key) => key.toLowerCase() === name);
|
|
8
|
+
return key ? headers[key] : null;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
function checkLimit(size, maxBytes) {
|
|
12
|
+
if (size > maxBytes) {
|
|
13
|
+
throw new ImagePromptError(
|
|
14
|
+
'image-too-large',
|
|
15
|
+
`Feishu image exceeds ${maxBytes} bytes`,
|
|
16
|
+
imageDownloadLimitMessage(maxBytes),
|
|
17
|
+
);
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function contentLength(headers) {
|
|
22
|
+
const value = headerValue(headers, 'content-length');
|
|
23
|
+
if (value === null || value === undefined) return null;
|
|
24
|
+
if (!/^\d+$/.test(String(value)) || !Number.isSafeInteger(Number(value))) {
|
|
25
|
+
throw new Error('Invalid Feishu resource Content-Length');
|
|
26
|
+
}
|
|
27
|
+
return Number(value);
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function contentRange(headers, start, end, total) {
|
|
31
|
+
const match = /^bytes (\d+)-(\d+)\/(\d+)$/.exec(headerValue(headers, 'content-range') ?? '');
|
|
32
|
+
if (!match) throw new Error('Invalid Feishu resource Content-Range');
|
|
33
|
+
const [first, last, size] = match.slice(1).map(Number);
|
|
34
|
+
if (![first, last, size].every(Number.isSafeInteger)
|
|
35
|
+
|| first !== start || last < first || last > end || last >= size
|
|
36
|
+
|| (total !== null && size !== total)) {
|
|
37
|
+
throw new Error('Inconsistent Feishu resource Content-Range');
|
|
38
|
+
}
|
|
39
|
+
return { size, length: last - first + 1 };
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** Stream sequential ranges; never buffer an ordinary file or publish a partial success. */
|
|
43
|
+
export async function* downloadFeishuResourceRanges(client, path, type, { signal, maxBytes } = {}) {
|
|
44
|
+
let offset = 0;
|
|
45
|
+
let total = null;
|
|
46
|
+
do {
|
|
47
|
+
signal?.throwIfAborted();
|
|
48
|
+
const end = Math.min(
|
|
49
|
+
offset + RESOURCE_CHUNK_BYTES - 1,
|
|
50
|
+
(total ?? maxBytes ?? Number.MAX_SAFE_INTEGER) - 1,
|
|
51
|
+
);
|
|
52
|
+
let status;
|
|
53
|
+
const response = await client.request({
|
|
54
|
+
method: 'GET',
|
|
55
|
+
url: `open-apis/im/v1/messages/${encodeURIComponent(path.message_id)}/resources/${encodeURIComponent(path.file_key)}`,
|
|
56
|
+
params: { type },
|
|
57
|
+
headers: { Range: `bytes=${offset}-${end}`, 'Accept-Encoding': 'identity' },
|
|
58
|
+
responseType: 'stream',
|
|
59
|
+
$return_headers: true,
|
|
60
|
+
decompress: false,
|
|
61
|
+
signal,
|
|
62
|
+
// The SDK strips status from successful responses, including stream responses.
|
|
63
|
+
validateStatus(value) {
|
|
64
|
+
status = value;
|
|
65
|
+
return value >= 200 && value < 300;
|
|
66
|
+
},
|
|
67
|
+
});
|
|
68
|
+
const stream = response?.data;
|
|
69
|
+
const abort = () => stream?.destroy?.(signal.reason);
|
|
70
|
+
signal?.addEventListener('abort', abort, { once: true });
|
|
71
|
+
try {
|
|
72
|
+
signal?.throwIfAborted();
|
|
73
|
+
if (!stream || typeof stream[Symbol.asyncIterator] !== 'function') {
|
|
74
|
+
throw new Error('Feishu resource download returned no readable stream');
|
|
75
|
+
}
|
|
76
|
+
const encoding = headerValue(response.headers, 'content-encoding');
|
|
77
|
+
if (encoding && encoding !== 'identity') {
|
|
78
|
+
throw new Error('Unexpected Feishu resource Content-Encoding');
|
|
79
|
+
}
|
|
80
|
+
let length = contentLength(response.headers);
|
|
81
|
+
if (status === 206) {
|
|
82
|
+
const range = contentRange(response.headers, offset, end, total);
|
|
83
|
+
total = range.size;
|
|
84
|
+
checkLimit(total, maxBytes);
|
|
85
|
+
if (length !== null && length !== range.length) {
|
|
86
|
+
throw new Error('Feishu resource Content-Length disagrees with Content-Range');
|
|
87
|
+
}
|
|
88
|
+
length = range.length;
|
|
89
|
+
} else if (status === 200 && offset === 0) {
|
|
90
|
+
// A server may ignore the first Range. Read it once as a whole file.
|
|
91
|
+
if (headerValue(response.headers, 'content-range') !== null) {
|
|
92
|
+
throw new Error('Unexpected Feishu resource Content-Range on HTTP 200');
|
|
93
|
+
}
|
|
94
|
+
checkLimit(length, maxBytes);
|
|
95
|
+
} else {
|
|
96
|
+
throw new Error(`Unexpected Feishu resource range status: ${status}`);
|
|
97
|
+
}
|
|
98
|
+
let received = 0;
|
|
99
|
+
for await (const chunk of stream) {
|
|
100
|
+
signal?.throwIfAborted();
|
|
101
|
+
const data = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
|
|
102
|
+
received += data.length;
|
|
103
|
+
checkLimit(offset + received, maxBytes);
|
|
104
|
+
if (length !== null && received > length) {
|
|
105
|
+
throw new Error('Feishu resource body exceeds the declared length');
|
|
106
|
+
}
|
|
107
|
+
yield data;
|
|
108
|
+
}
|
|
109
|
+
signal?.throwIfAborted();
|
|
110
|
+
if (length !== null && received !== length) {
|
|
111
|
+
throw new Error('Feishu resource body does not match the declared length');
|
|
112
|
+
}
|
|
113
|
+
if (status === 200) return;
|
|
114
|
+
offset += received;
|
|
115
|
+
} finally {
|
|
116
|
+
signal?.removeEventListener('abort', abort);
|
|
117
|
+
stream?.destroy?.();
|
|
118
|
+
}
|
|
119
|
+
} while (offset < total);
|
|
120
|
+
}
|
|
@@ -49,6 +49,8 @@ export const SLASH_COMMAND_MANIFEST = Object.freeze([
|
|
|
49
49
|
{ command: 'reasoning', icon: 'ai-deepthink_outlined', default: '查看或切换推理等级', en_us: 'Show or switch the reasoning effort' },
|
|
50
50
|
{ command: 'presetlist', icon: 'skill_outlined', default: '列出可用 Agent 预设', en_us: 'List available Agent Presets' },
|
|
51
51
|
{ command: 'preset', icon: 'skill_outlined', default: '查看或切换 Agent 预设', en_us: 'Show or switch the Agent Preset' },
|
|
52
|
+
{ command: 'permissionlist', icon: 'skill_outlined', default: '按序号列出权限档位', en_us: 'List numbered permission presets' },
|
|
53
|
+
{ command: 'permission', icon: 'skill_outlined', default: '查看或切换当前会话权限', en_us: 'Show or switch current session permissions' },
|
|
52
54
|
{ command: 'stop', icon: 'clear_outlined', default: '停止当前任务', en_us: 'Stop the current task' },
|
|
53
55
|
{ command: 'steer', icon: 'promptword_outlined', default: '给当前任务补充指令', en_us: 'Send additional instructions to the current task' },
|
|
54
56
|
{ command: 'batch', icon: 'chat-ai_outlined', default: '开始批量输入(仅私聊)', en_us: 'Start batch input (private chats only)' },
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { isPermissionCommand, runPermissionCommand } from '../shared/permission-command.mjs';
|
|
1
2
|
import { createDeferredDeliveryCoordinator, deferredOutcomeText } from '../shared/deferred-delivery-coordinator.mjs';
|
|
2
3
|
import { runWorkspaceCommand } from '../shared/workspace-command.mjs';
|
|
3
4
|
import { runCompactCommand } from '../shared/compact-command.mjs';
|
|
@@ -115,6 +116,9 @@ function helpText() {
|
|
|
115
116
|
t('/preset [序号或完整ID] 查看或设置当前机器人 Agent Preset'),
|
|
116
117
|
t('纯数字 ID:/preset id:<ID>'),
|
|
117
118
|
t('/preset --default 跟随 Host 默认'),
|
|
119
|
+
t('/permissionlist 按序号列出权限档位'),
|
|
120
|
+
t('/permission [序号或完整ID] 查看或切换当前会话权限'),
|
|
121
|
+
t('纯数字 ID:/permission id:<ID>'),
|
|
118
122
|
t('/stop 停止当前任务'),
|
|
119
123
|
t('/steer 补充指令 纠偏当前任务'),
|
|
120
124
|
t('/batch 开始批量输入(仅私聊,最多 10 条文字)'),
|
|
@@ -523,7 +527,7 @@ export class QqHarnessBridge {
|
|
|
523
527
|
text: commandText,
|
|
524
528
|
hasImages: hasQqImageAttachments(message),
|
|
525
529
|
hasFiles: hasQqFileAttachments(message),
|
|
526
|
-
...(explicitMenu || numericMenu ? { isCommand: true } : {}),
|
|
530
|
+
...(explicitMenu || numericMenu || isPermissionCommand(commandText) ? { isCommand: true } : {}),
|
|
527
531
|
})
|
|
528
532
|
: senderAllowed(message, this.#ownerUserOpenid)
|
|
529
533
|
? { allowed: true, reason: 'legacy-owner' }
|
|
@@ -594,6 +598,7 @@ export class QqHarnessBridge {
|
|
|
594
598
|
const commandRunner = explicitMenu || numericMenu
|
|
595
599
|
? () => this.#runMenuCommand(message, menuInput, key)
|
|
596
600
|
: isHistoryCommand(commandText) ? runHistoryCommand
|
|
601
|
+
: isPermissionCommand(commandText) ? runPermissionCommand
|
|
597
602
|
: hasQqFileAttachments(message) ? null : isControlCommand(commandText)
|
|
598
603
|
? runControlCommand
|
|
599
604
|
: (isModelCommand(commandText)
|
|
@@ -2002,6 +2002,11 @@ export function createBotWorkspaceScope(
|
|
|
2002
2002
|
models(...args) {
|
|
2003
2003
|
return invokeCurrentSession('getSessionModels', args, 'model listing');
|
|
2004
2004
|
},
|
|
2005
|
+
...(typeof target.getSessionPermissions === 'function' ? {
|
|
2006
|
+
permissions(...args) {
|
|
2007
|
+
return invokeCurrentSession('getSessionPermissions', args, 'permission listing');
|
|
2008
|
+
},
|
|
2009
|
+
} : {}),
|
|
2005
2010
|
readHistory(...args) {
|
|
2006
2011
|
return invokeCurrentSession('readSessionHistory', args, 'history read');
|
|
2007
2012
|
},
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
export const COMMAND_LIST_TTL_MS = 15 * 60_000;
|
|
2
|
+
export const COMMAND_LIST_MAX_ENTRIES = 256;
|
|
3
|
+
|
|
4
|
+
/** Short-lived lists scoped to a bot's state and conversation, never persisted. */
|
|
5
|
+
export function createCommandListSnapshots() {
|
|
6
|
+
const states = new WeakMap();
|
|
7
|
+
return {
|
|
8
|
+
save(state, key, value) {
|
|
9
|
+
if ((!state || typeof state !== 'object') && typeof state !== 'function') return;
|
|
10
|
+
let entries = states.get(state);
|
|
11
|
+
if (!entries) states.set(state, entries = new Map());
|
|
12
|
+
const now = Date.now();
|
|
13
|
+
for (const [entryKey, entry] of entries) {
|
|
14
|
+
if (entry.expiresAt <= now) entries.delete(entryKey);
|
|
15
|
+
}
|
|
16
|
+
entries.delete(key);
|
|
17
|
+
entries.set(key, { value, expiresAt: now + COMMAND_LIST_TTL_MS });
|
|
18
|
+
while (entries.size > COMMAND_LIST_MAX_ENTRIES) entries.delete(entries.keys().next().value);
|
|
19
|
+
},
|
|
20
|
+
load(state, key) {
|
|
21
|
+
const entries = states.get(state);
|
|
22
|
+
const entry = entries?.get(key);
|
|
23
|
+
if (!entry) return null;
|
|
24
|
+
if (entry.expiresAt <= Date.now()) {
|
|
25
|
+
entries.delete(key);
|
|
26
|
+
return null;
|
|
27
|
+
}
|
|
28
|
+
entries.delete(key);
|
|
29
|
+
entries.set(key, entry);
|
|
30
|
+
return entry.value;
|
|
31
|
+
},
|
|
32
|
+
};
|
|
33
|
+
}
|
|
@@ -1139,6 +1139,12 @@ export class HarnessClient {
|
|
|
1139
1139
|
return validateModelCatalog(value, 'session.models', { session: true });
|
|
1140
1140
|
}
|
|
1141
1141
|
|
|
1142
|
+
async getSessionPermissions(sessionId, options = {}) {
|
|
1143
|
+
if (typeof sessionId !== 'string' || !sessionId) throw new TypeError('sessionId is required');
|
|
1144
|
+
await this.ensureRunning(options);
|
|
1145
|
+
return this.rpc('session.permissions', { sessionId }, 30_000, options);
|
|
1146
|
+
}
|
|
1147
|
+
|
|
1142
1148
|
async selectSessionModel(sessionId, selection, options = {}) {
|
|
1143
1149
|
if (typeof sessionId !== 'string' || !sessionId) throw new TypeError('sessionId is required');
|
|
1144
1150
|
if (!validModelSelection(selection)) {
|
|
@@ -1729,9 +1735,10 @@ export class HarnessClient {
|
|
|
1729
1735
|
this.#registerInteractionOwnership(sessionId, ownership);
|
|
1730
1736
|
this.#registerControlOwnership(ownership);
|
|
1731
1737
|
}
|
|
1732
|
-
//
|
|
1733
|
-
|
|
1734
|
-
|
|
1738
|
+
// Advertise delivery only when this request can actually claim its files.
|
|
1739
|
+
const closeArtifactConsumer = typeof onArtifact === 'function'
|
|
1740
|
+
? outboundArtifactRegistry.openConsumer(sessionId, promptRpcId)
|
|
1741
|
+
: null;
|
|
1735
1742
|
|
|
1736
1743
|
try {
|
|
1737
1744
|
const basePrompt = prompt;
|
|
@@ -1929,7 +1936,7 @@ export class HarnessClient {
|
|
|
1929
1936
|
}
|
|
1930
1937
|
}
|
|
1931
1938
|
}
|
|
1932
|
-
closeArtifactConsumer();
|
|
1939
|
+
closeArtifactConsumer?.();
|
|
1933
1940
|
if (ownership) {
|
|
1934
1941
|
this.#unregisterControlOwnership(ownership);
|
|
1935
1942
|
this.#unregisterInteractionOwnership(sessionId, ownership);
|
|
@@ -143,6 +143,8 @@ export default {
|
|
|
143
143
|
'请输入补充指令后再提交。': 'Enter an instruction before submitting.',
|
|
144
144
|
'操作过于频繁,请稍后再试。': 'Too many card actions are pending. Please try again shortly.',
|
|
145
145
|
'卡片操作失败,请稍后重试。': 'The card action failed. Please try again later.',
|
|
146
|
+
'新会话操作的卡片状态未能更新,请发送 /m 重新打开菜单。':
|
|
147
|
+
'Could not update the new-session card status. Send /m to reopen the menu.',
|
|
146
148
|
'请先选择至少一个会话。': 'Select at least one session first.',
|
|
147
149
|
'已批量关注 {count} 个会话。': 'Now watching {count} sessions.',
|
|
148
150
|
'已批量关注 {count} 个会话,另有 {failed} 个未成功。':
|
|
@@ -187,6 +189,11 @@ export default {
|
|
|
187
189
|
'🤖 切换预设': '🤖 Switch preset',
|
|
188
190
|
'🧠 切换模型': '🧠 Switch model',
|
|
189
191
|
'🆕 新会话': '🆕 New session',
|
|
192
|
+
'正在开启…': 'Starting…',
|
|
193
|
+
'已执行:新会话': 'Done: new session',
|
|
194
|
+
'正在开启新会话…': 'Starting a new session…',
|
|
195
|
+
'已执行:新会话。发送消息即可开始。': 'Done: new session. Send a message to start.',
|
|
196
|
+
'新会话操作失败:{message}': 'New session action failed: {message}',
|
|
190
197
|
'📋 会话/关注': '📋 Sessions / watches',
|
|
191
198
|
'🗂 工作区列表': '🗂 Workspace list',
|
|
192
199
|
'**任务控制**': '**Task controls**',
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
export default {
|
|
2
|
+
'用法:/permission 查看当前权限;/permissionlist 列出档位;/permission <序号或完整ID> 切换;纯数字 ID 使用 /permission id:<ID>。': 'Usage: /permission to show current permissions; /permissionlist to list presets; /permission <number or full ID> to switch; use /permission id:<ID> for numeric IDs.',
|
|
3
|
+
'仅影响当前会话;沙箱与审批策略会同时按 Host 的档位定义更新。': 'Applies only to the current session; sandbox and approval policies change together according to the Host preset.',
|
|
4
|
+
'请先执行 /permissionlist,再按列表序号选择权限档位。': 'Run /permissionlist first, then select a permission preset by its number.',
|
|
5
|
+
'完全文件访问,不再请求审批。': 'Full file access without approval prompts.',
|
|
6
|
+
'可写工作区和允许的临时目录;扩大访问范围需要审批。': 'Write inside the workspace and permitted temporary directories; wider retries require approval.',
|
|
7
|
+
'当前聊天绑定的会话已不存在,请先绑定或建立会话。': 'The bound session no longer exists. Bind or start a session first.',
|
|
8
|
+
'当前 Host 暂不支持从机器人查看或切换权限档位。': 'This Host does not support viewing or switching permission presets from the bot.',
|
|
9
|
+
'未能确认权限切换结果,请执行 /permission 核对当前档位。': 'Could not confirm the permission change. Run /permission to check the current preset.',
|
|
10
|
+
'暂时无法获取权限档位,请稍后重试。': 'Could not read permission presets. Please try again later.',
|
|
11
|
+
'权限命令仅支持纯文字,请移除图片或文件后重试。': 'Permission commands require plain text. Remove images or files and try again.',
|
|
12
|
+
'当前聊天尚未绑定会话,请先发送普通消息建立会话,或使用 /session 绑定已有会话。': 'This chat has no bound session. Send a regular message to start one, or use /session to bind an existing session.',
|
|
13
|
+
'当前会话已变化,请重新执行 /permissionlist。': 'The current session changed. Run /permissionlist again.',
|
|
14
|
+
'当前会话权限:{id}': 'Current session permissions: {id}',
|
|
15
|
+
' ✓ 当前': ' ✓ Current',
|
|
16
|
+
'当前没有可用权限档位。': 'No permission presets are currently available.',
|
|
17
|
+
'切换:/permission <序号或完整ID>': 'Switch: /permission <number or full ID>',
|
|
18
|
+
'查看可用档位与序号:/permissionlist': 'Show available presets and numbers: /permissionlist',
|
|
19
|
+
'权限档位序号无效,请重新执行 /permissionlist。': 'Invalid permission preset number. Run /permissionlist again.',
|
|
20
|
+
'当前 Host 未注册 /permission 命令,请确认权限预设组件已启用。': 'This Host has not registered /permission. Check that permission presets are enabled.',
|
|
21
|
+
'该权限档位不存在或已不可用,请重新执行 /permissionlist。': 'That permission preset no longer exists or is unavailable. Run /permissionlist again.',
|
|
22
|
+
'权限切换失败。': 'Permission change failed.',
|
|
23
|
+
'当前会话权限已切换为:{id}': 'Current session permissions changed to: {id}',
|
|
24
|
+
'恢复工作区权限:/permission workspace-write(需 Host 支持该档位)': 'Restore workspace permissions: /permission workspace-write (if supported by the Host)',
|
|
25
|
+
'/permissionlist 按序号列出权限档位': '/permissionlist List numbered permission presets',
|
|
26
|
+
'/permission [序号或完整ID] 查看或切换当前会话权限': '/permission [number or full ID] Show or switch current session permissions',
|
|
27
|
+
'纯数字 ID:/permission id:<ID>': 'Numeric IDs: /permission id:<ID>',
|
|
28
|
+
'`/permissionlist` — 按序号列出权限档位': '`/permissionlist` — List numbered permission presets',
|
|
29
|
+
'`/permission [序号/ID]` — 查看或切换当前会话权限': '`/permission [number/ID]` — Show or switch current session permissions',
|
|
30
|
+
};
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import imageInput from './i18n-en/image-input.mjs';
|
|
2
|
+
import permission from './i18n-en/permission.mjs';
|
|
2
3
|
import diagnostics from './i18n-en/diagnostics.mjs';
|
|
3
4
|
// English translations for host-side user-facing text.
|
|
4
5
|
// Keys are the exact Chinese literals passed to t() in src/channels/**.
|
|
@@ -26,6 +27,7 @@ export const EN = Object.freeze(Object.assign(
|
|
|
26
27
|
{},
|
|
27
28
|
diagnostics,
|
|
28
29
|
imageInput,
|
|
30
|
+
permission,
|
|
29
31
|
sharedA,
|
|
30
32
|
sharedB,
|
|
31
33
|
sharedC,
|