koishi-plugin-argus 0.1.0 → 0.4.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/lib/blur.d.ts +14 -4
- package/lib/cache.d.ts +3 -1
- package/lib/compress.d.ts +17 -0
- package/lib/crypto.d.ts +11 -0
- package/lib/index.cjs +137 -25
- package/lib/index.d.ts +2 -1
- package/lib/index.mjs +151 -25
- package/lib/types.d.ts +7 -1
- package/package.json +2 -3
- package/readme.md +10 -1
package/lib/blur.d.ts
CHANGED
|
@@ -1,11 +1,21 @@
|
|
|
1
1
|
export type BlurMode = 'gaussian' | 'fast';
|
|
2
2
|
export interface BlurOptions {
|
|
3
|
-
/** 模糊半径,越大越糊。 */
|
|
3
|
+
/** 模糊半径,越大越糊。0 = 不模糊。 */
|
|
4
4
|
radius: number;
|
|
5
|
-
/**
|
|
5
|
+
/** 兼容旧字段;photon 实现里 'gaussian' 会多过一次 box_blur。 */
|
|
6
6
|
mode?: BlurMode;
|
|
7
7
|
}
|
|
8
8
|
/**
|
|
9
|
-
*
|
|
9
|
+
* 模糊算法(photon WASM 实现):
|
|
10
|
+
*
|
|
11
|
+
* 把图缩到 1/N 尺寸(N 由 radius 决定),不做 upsample 直接编 JPEG。
|
|
12
|
+
* 缩小本身就是强力模糊(细节都被平均掉了),同时 JPEG 编码体积小、耗时短,
|
|
13
|
+
* 整体在 100ms 量级完成。聊天客户端展示时会自动放大,看起来就是糊图。
|
|
14
|
+
*
|
|
15
|
+
* - radius 0 → 直接编 JPEG(不模糊)
|
|
16
|
+
* - radius 1..50 → factor = round(radius / 4) + 2 ≈ 2-15 倍下采样
|
|
17
|
+
* - radius 51..200 → factor = round(radius / 6) + 4 ≈ 12-37 倍下采样
|
|
18
|
+
*
|
|
19
|
+
* `mode='gaussian'` 时多过一次 box_blur 让边缘柔和。
|
|
10
20
|
*/
|
|
11
|
-
export declare function blurImage(input: Buffer, options: BlurOptions):
|
|
21
|
+
export declare function blurImage(input: Buffer, options: BlurOptions): Buffer;
|
package/lib/cache.d.ts
CHANGED
|
@@ -6,8 +6,10 @@ import type { PeekBusyFrame } from './types';
|
|
|
6
6
|
export interface CachedPeek {
|
|
7
7
|
cachedAt: number;
|
|
8
8
|
expiresAt: number;
|
|
9
|
-
/** 已经过模糊处理的最终
|
|
9
|
+
/** 已经过模糊处理的最终 buffer,busy 时为空。 */
|
|
10
10
|
image?: Buffer;
|
|
11
|
+
/** 缓存图片的 mime('image/png' | 'image/jpeg')。 */
|
|
12
|
+
mime?: string;
|
|
11
13
|
/** busy 状态:客户端在玩游戏 / 全屏。 */
|
|
12
14
|
busy?: PeekBusyFrame;
|
|
13
15
|
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
export interface CompressOptions {
|
|
2
|
+
/** 目标体积上限(字节)。 */
|
|
3
|
+
targetBytes: number;
|
|
4
|
+
/** 起始 jpeg 质量。 */
|
|
5
|
+
initialQuality?: number;
|
|
6
|
+
/** 最低 jpeg 质量。 */
|
|
7
|
+
minQuality?: number;
|
|
8
|
+
}
|
|
9
|
+
/**
|
|
10
|
+
* 把图片压到 `targetBytes` 以内的 JPEG。
|
|
11
|
+
*
|
|
12
|
+
* 性能优先(photon WASM 实现):
|
|
13
|
+
* - 输入已经 ≤ target → 直接返回(0 ms)。
|
|
14
|
+
* - 否则估算 quality 编一遍。命中就完事;不行再 resize 一次。
|
|
15
|
+
* - photon 的 resize 偏慢(~180ms / 2560x1440),所以放在最后兜底。
|
|
16
|
+
*/
|
|
17
|
+
export declare function compressToBudget(input: Buffer, options: CompressOptions): Buffer;
|
package/lib/crypto.d.ts
ADDED
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* AES-256-GCM 加密。返回 base64(iv | tag | ciphertext)。
|
|
3
|
+
*/
|
|
4
|
+
export declare function encryptBuffer(plain: Buffer, token: string): string;
|
|
5
|
+
/**
|
|
6
|
+
* 解密 encryptBuffer 的输出。token 不对 / 数据被改 / 格式不匹配都会抛错。
|
|
7
|
+
*/
|
|
8
|
+
export declare function decryptBuffer(payloadBase64: string, token: string): Buffer;
|
|
9
|
+
/** 协议字段:标识 image 字段使用何种加密。当前只用一种。 */
|
|
10
|
+
export declare const ENC_ALGO: "aes-256-gcm";
|
|
11
|
+
export type EncAlgo = typeof ENC_ALGO;
|
package/lib/index.cjs
CHANGED
|
@@ -23,14 +23,14 @@ var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: tru
|
|
|
23
23
|
// src/locales/zh-CN.schema.yml
|
|
24
24
|
var require_zh_CN_schema = __commonJS({
|
|
25
25
|
"src/locales/zh-CN.schema.yml"(exports2, module2) {
|
|
26
|
-
module2.exports = { $desc: "Argus 配置", $inner: { path: "WebSocket 服务挂载路径,必须以 / 开头。", token: "客户端鉴权 token;为空时所有连接都会被拒绝。", commandName: "主命令名(例如 peek、spy、look)。", blur: "默认模糊半径,越大越糊(0 = 不模糊)。", blurMode: { $desc: "模糊算法", $inner: ["高斯模糊(质量好,但慢)", "快速模糊(jimp.blur,速度快)"] }, minBlur: "命令里 -b 临时调小模糊时不可低于此值,防止裸奔。",
|
|
26
|
+
module2.exports = { $desc: "Argus 配置", $inner: { path: "WebSocket 服务挂载路径,必须以 / 开头。", token: "客户端鉴权 token;为空时所有连接都会被拒绝。", commandName: "主命令名(例如 peek、spy、look)。", blur: "默认模糊半径,越大越糊(0 = 不模糊)。", blurMode: { $desc: "模糊算法", $inner: ["高斯模糊(质量好,但慢)", "快速模糊(jimp.blur,速度快)"] }, minBlur: "命令里 -b 临时调小模糊时不可低于此值,防止裸奔。", maxImageKB: "单张截图(客户端→插件,加密后 base64)大小上限(KB)。超过会拒收。默认 8192(8MB)。", finalMaxKB: "发到群里的最终图片体积上限(KB)。插件做完模糊后会再压一遍 JPEG 到这个体积以内。默认 200。设 0 关闭。", timeout: "等待客户端响应的超时(毫秒)。", cacheDuration: "截图缓存时长(毫秒)。在此期间内同一客户端 + 显示器的反复调用会直接返回缓存的图。设为 0 关闭缓存。默认 5 分钟。", registerAlias: "是否给每个连进来的客户端自动注册同名命令作为别名。", authority: "命令所需的最低权限等级。", forceAuthority: "使用 -f / --force 绕过缓存所需的最低权限等级。" } };
|
|
27
27
|
}
|
|
28
28
|
});
|
|
29
29
|
|
|
30
30
|
// src/locales/en-US.schema.yml
|
|
31
31
|
var require_en_US_schema = __commonJS({
|
|
32
32
|
"src/locales/en-US.schema.yml"(exports2, module2) {
|
|
33
|
-
module2.exports = { $desc: "Argus configuration", $inner: { path: "WebSocket mount path; must start with /.", token: "Auth token for clients; if empty all connections are rejected.", commandName: "Top-level command name (e.g. peek, spy, look).", blur: "Default blur radius; larger means blurrier (0 disables blur).", blurMode: { $desc: "Blur algorithm", $inner: ["Gaussian blur (better quality, slower)", "Fast blur (jimp.blur, faster)"] }, minBlur: "Lower bound for the temporary -b override, to prevent unblurred captures.",
|
|
33
|
+
module2.exports = { $desc: "Argus configuration", $inner: { path: "WebSocket mount path; must start with /.", token: "Auth token for clients; if empty all connections are rejected.", commandName: "Top-level command name (e.g. peek, spy, look).", blur: "Default blur radius; larger means blurrier (0 disables blur).", blurMode: { $desc: "Blur algorithm", $inner: ["Gaussian blur (better quality, slower)", "Fast blur (jimp.blur, faster)"] }, minBlur: "Lower bound for the temporary -b override, to prevent unblurred captures.", maxImageKB: "Max screenshot size (client -> plugin, encrypted base64) in KB. Larger payloads are rejected. Default 8192 (8MB).", finalMaxKB: "Final image size budget after blur (KB). The plugin recompresses to JPEG within this budget before sending to chat. Default 200. Set 0 to disable.", timeout: "Timeout (ms) waiting for a client response.", cacheDuration: "Screenshot cache duration in ms. Repeated calls within this window for the same client+display reuse the cached image. Set to 0 to disable. Default 5 minutes.", registerAlias: "Whether to register each connected client's name as a command alias.", authority: "Minimum authority level required to run the command.", forceAuthority: "Minimum authority level required to use -f / --force to bypass the cache." } };
|
|
34
34
|
}
|
|
35
35
|
});
|
|
36
36
|
|
|
@@ -304,25 +304,29 @@ __name(randomId, "randomId");
|
|
|
304
304
|
var import_koishi = require("koishi");
|
|
305
305
|
|
|
306
306
|
// src/blur.ts
|
|
307
|
-
var
|
|
308
|
-
|
|
309
|
-
var Jimp = (0, import_core.createJimp)({
|
|
310
|
-
formats: [...import_jimp.defaultFormats],
|
|
311
|
-
plugins: import_jimp.defaultPlugins
|
|
312
|
-
});
|
|
313
|
-
async function blurImage(input, options) {
|
|
307
|
+
var import_node = require("@cf-wasm/photon/node");
|
|
308
|
+
function blurImage(input, options) {
|
|
314
309
|
const radius = clamp(Math.round(options.radius), 0, 200);
|
|
315
|
-
const
|
|
316
|
-
|
|
317
|
-
if (
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
310
|
+
const img = import_node.PhotonImage.new_from_byteslice(new Uint8Array(input));
|
|
311
|
+
try {
|
|
312
|
+
if (radius === 0) {
|
|
313
|
+
return Buffer.from(img.get_bytes_jpeg(85));
|
|
314
|
+
}
|
|
315
|
+
const factor = radius <= 50 ? Math.round(radius / 4) + 2 : Math.round(radius / 6) + 4;
|
|
316
|
+
const w = img.get_width();
|
|
317
|
+
const h2 = img.get_height();
|
|
318
|
+
const sw = Math.max(2, Math.round(w / factor));
|
|
319
|
+
const sh = Math.max(2, Math.round(h2 / factor));
|
|
320
|
+
const small = (0, import_node.resize)(img, sw, sh, import_node.SamplingFilter.Triangle);
|
|
321
|
+
try {
|
|
322
|
+
if (options.mode === "gaussian") (0, import_node.box_blur)(small);
|
|
323
|
+
return Buffer.from(small.get_bytes_jpeg(85));
|
|
324
|
+
} finally {
|
|
325
|
+
small.free();
|
|
323
326
|
}
|
|
327
|
+
} finally {
|
|
328
|
+
img.free();
|
|
324
329
|
}
|
|
325
|
-
return await image.getBuffer("image/png");
|
|
326
330
|
}
|
|
327
331
|
__name(blurImage, "blurImage");
|
|
328
332
|
function clamp(v, min, max) {
|
|
@@ -330,6 +334,47 @@ function clamp(v, min, max) {
|
|
|
330
334
|
}
|
|
331
335
|
__name(clamp, "clamp");
|
|
332
336
|
|
|
337
|
+
// src/compress.ts
|
|
338
|
+
var import_node2 = require("@cf-wasm/photon/node");
|
|
339
|
+
function compressToBudget(input, options) {
|
|
340
|
+
const target = Math.max(8 * 1024, options.targetBytes);
|
|
341
|
+
if (input.length <= target) return input;
|
|
342
|
+
const initialQuality = options.initialQuality ?? 80;
|
|
343
|
+
const minQuality = options.minQuality ?? 40;
|
|
344
|
+
const img = import_node2.PhotonImage.new_from_byteslice(new Uint8Array(input));
|
|
345
|
+
try {
|
|
346
|
+
const ratioByteWise = target / input.length;
|
|
347
|
+
const estQ = Math.max(
|
|
348
|
+
minQuality,
|
|
349
|
+
Math.min(initialQuality, Math.round(initialQuality * ratioByteWise))
|
|
350
|
+
);
|
|
351
|
+
let buf = Buffer.from(img.get_bytes_jpeg(estQ));
|
|
352
|
+
if (buf.length <= target) return buf;
|
|
353
|
+
const q2 = Math.max(
|
|
354
|
+
minQuality,
|
|
355
|
+
Math.round(estQ * (target / buf.length) * 0.95)
|
|
356
|
+
);
|
|
357
|
+
if (q2 < estQ) {
|
|
358
|
+
buf = Buffer.from(img.get_bytes_jpeg(q2));
|
|
359
|
+
if (buf.length <= target) return buf;
|
|
360
|
+
}
|
|
361
|
+
const w = img.get_width();
|
|
362
|
+
const h2 = img.get_height();
|
|
363
|
+
const scale = Math.sqrt(target / buf.length) * 0.9;
|
|
364
|
+
const newW = Math.max(64, Math.round(w * scale));
|
|
365
|
+
const newH = Math.max(64, Math.round(h2 * scale));
|
|
366
|
+
const small = (0, import_node2.resize)(img, newW, newH, import_node2.SamplingFilter.Triangle);
|
|
367
|
+
try {
|
|
368
|
+
return Buffer.from(small.get_bytes_jpeg(q2));
|
|
369
|
+
} finally {
|
|
370
|
+
small.free();
|
|
371
|
+
}
|
|
372
|
+
} finally {
|
|
373
|
+
img.free();
|
|
374
|
+
}
|
|
375
|
+
}
|
|
376
|
+
__name(compressToBudget, "compressToBudget");
|
|
377
|
+
|
|
333
378
|
// src/cache.ts
|
|
334
379
|
var PeekCache = class {
|
|
335
380
|
constructor(duration) {
|
|
@@ -393,6 +438,37 @@ function formatRemaining(ms) {
|
|
|
393
438
|
}
|
|
394
439
|
__name(formatRemaining, "formatRemaining");
|
|
395
440
|
|
|
441
|
+
// src/crypto.ts
|
|
442
|
+
var import_node_crypto = require("node:crypto");
|
|
443
|
+
var ALGO = "aes-256-gcm";
|
|
444
|
+
var IV_LEN = 12;
|
|
445
|
+
var TAG_LEN = 16;
|
|
446
|
+
var KEY_LEN = 32;
|
|
447
|
+
var SALT = Buffer.from("argus.peek.v1", "utf8");
|
|
448
|
+
var keyCache = /* @__PURE__ */ new Map();
|
|
449
|
+
function deriveKey(token) {
|
|
450
|
+
let key = keyCache.get(token);
|
|
451
|
+
if (key) return key;
|
|
452
|
+
key = (0, import_node_crypto.scryptSync)(token, SALT, KEY_LEN);
|
|
453
|
+
keyCache.set(token, key);
|
|
454
|
+
return key;
|
|
455
|
+
}
|
|
456
|
+
__name(deriveKey, "deriveKey");
|
|
457
|
+
function decryptBuffer(payloadBase64, token) {
|
|
458
|
+
const buf = Buffer.from(payloadBase64, "base64");
|
|
459
|
+
if (buf.length < IV_LEN + TAG_LEN) {
|
|
460
|
+
throw new Error("ciphertext_too_short");
|
|
461
|
+
}
|
|
462
|
+
const iv = buf.subarray(0, IV_LEN);
|
|
463
|
+
const tag = buf.subarray(IV_LEN, IV_LEN + TAG_LEN);
|
|
464
|
+
const ct = buf.subarray(IV_LEN + TAG_LEN);
|
|
465
|
+
const key = deriveKey(token);
|
|
466
|
+
const decipher = (0, import_node_crypto.createDecipheriv)(ALGO, key, iv);
|
|
467
|
+
decipher.setAuthTag(tag);
|
|
468
|
+
return Buffer.concat([decipher.update(ct), decipher.final()]);
|
|
469
|
+
}
|
|
470
|
+
__name(decryptBuffer, "decryptBuffer");
|
|
471
|
+
|
|
396
472
|
// src/commands.ts
|
|
397
473
|
function applyCommands(ctx, server, config, cache) {
|
|
398
474
|
const cmd = ctx.command(
|
|
@@ -436,7 +512,7 @@ function applyCommands(ctx, server, config, cache) {
|
|
|
436
512
|
}
|
|
437
513
|
if (cached.image) {
|
|
438
514
|
return [
|
|
439
|
-
import_koishi.h.image(cached.image, "image/png"),
|
|
515
|
+
import_koishi.h.image(cached.image, cached.mime ?? "image/png"),
|
|
440
516
|
formatCacheNote(session, cached)
|
|
441
517
|
];
|
|
442
518
|
}
|
|
@@ -449,15 +525,40 @@ function applyCommands(ctx, server, config, cache) {
|
|
|
449
525
|
cache.set(cacheKey, { busy: response.frame });
|
|
450
526
|
return formatBusy(session, client.name, response.frame);
|
|
451
527
|
}
|
|
452
|
-
|
|
453
|
-
|
|
528
|
+
let buffer;
|
|
529
|
+
try {
|
|
530
|
+
buffer = decodeImagePayload(response.frame, config.token);
|
|
531
|
+
} catch (err) {
|
|
532
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
533
|
+
ctx.logger.warn(
|
|
534
|
+
"argus decrypt failed for %s: %s",
|
|
535
|
+
client.name,
|
|
536
|
+
message
|
|
537
|
+
);
|
|
538
|
+
return session.text(".failed", ["decrypt_failed"]);
|
|
539
|
+
}
|
|
540
|
+
const blurStart = Date.now();
|
|
541
|
+
const blurred = blurImage(buffer, {
|
|
454
542
|
radius,
|
|
455
543
|
mode: config.blurMode
|
|
456
544
|
});
|
|
545
|
+
const blurMs = Date.now() - blurStart;
|
|
546
|
+
const finalBudget = config.finalMaxKB * 1024;
|
|
547
|
+
const compressStart = Date.now();
|
|
548
|
+
const output = finalBudget > 0 && blurred.length > finalBudget ? compressToBudget(blurred, { targetBytes: finalBudget }) : blurred;
|
|
549
|
+
const compressMs = Date.now() - compressStart;
|
|
550
|
+
const mime = "image/jpeg";
|
|
551
|
+
ctx.logger.debug(
|
|
552
|
+
"peek pipeline: blur=%dms compress=%dms %dKB→%dKB",
|
|
553
|
+
blurMs,
|
|
554
|
+
compressMs,
|
|
555
|
+
Math.round(blurred.length / 1024),
|
|
556
|
+
Math.round(output.length / 1024)
|
|
557
|
+
);
|
|
457
558
|
if (opts.blur === void 0 || opts.blur === config.blur) {
|
|
458
|
-
cache.set(cacheKey, { image: output });
|
|
559
|
+
cache.set(cacheKey, { image: output, mime });
|
|
459
560
|
}
|
|
460
|
-
return import_koishi.h.image(output,
|
|
561
|
+
return import_koishi.h.image(output, mime);
|
|
461
562
|
} catch (err) {
|
|
462
563
|
const message = err instanceof Error ? err.message : String(err);
|
|
463
564
|
ctx.logger.warn(
|
|
@@ -564,6 +665,16 @@ function range(start, end) {
|
|
|
564
665
|
return out;
|
|
565
666
|
}
|
|
566
667
|
__name(range, "range");
|
|
668
|
+
function decodeImagePayload(frame, token) {
|
|
669
|
+
if (!frame.enc || frame.enc === "none") {
|
|
670
|
+
return Buffer.from(frame.image, "base64");
|
|
671
|
+
}
|
|
672
|
+
if (frame.enc === "aes-256-gcm") {
|
|
673
|
+
return decryptBuffer(frame.image, token);
|
|
674
|
+
}
|
|
675
|
+
throw new Error(`unsupported_enc:${frame.enc}`);
|
|
676
|
+
}
|
|
677
|
+
__name(decodeImagePayload, "decodeImagePayload");
|
|
567
678
|
|
|
568
679
|
// src/index.ts
|
|
569
680
|
var name = "argus";
|
|
@@ -575,7 +686,8 @@ var Config = import_koishi2.Schema.object({
|
|
|
575
686
|
blur: import_koishi2.Schema.natural().min(0).max(200).default(40),
|
|
576
687
|
blurMode: import_koishi2.Schema.union(["gaussian", "fast"]).default("fast"),
|
|
577
688
|
minBlur: import_koishi2.Schema.natural().min(0).max(200).default(10),
|
|
578
|
-
|
|
689
|
+
maxImageKB: import_koishi2.Schema.natural().default(8 * 1024),
|
|
690
|
+
finalMaxKB: import_koishi2.Schema.natural().default(200),
|
|
579
691
|
timeout: import_koishi2.Schema.natural().default(15e3),
|
|
580
692
|
cacheDuration: import_koishi2.Schema.natural().default(5 * 60 * 1e3),
|
|
581
693
|
registerAlias: import_koishi2.Schema.boolean().default(true),
|
|
@@ -600,7 +712,7 @@ function apply(ctx, config) {
|
|
|
600
712
|
path: config.path,
|
|
601
713
|
token: config.token,
|
|
602
714
|
timeout: config.timeout,
|
|
603
|
-
maxImageBytes: config.
|
|
715
|
+
maxImageBytes: config.maxImageKB * 1024,
|
|
604
716
|
onClientChange: /* @__PURE__ */ __name((event) => {
|
|
605
717
|
if (event.type === "connect") {
|
|
606
718
|
ctx.emit("argus/client-connect", event.name);
|
package/lib/index.d.ts
CHANGED
package/lib/index.mjs
CHANGED
|
@@ -8,14 +8,14 @@ var __commonJS = (cb, mod) => function __require() {
|
|
|
8
8
|
// src/locales/zh-CN.schema.yml
|
|
9
9
|
var require_zh_CN_schema = __commonJS({
|
|
10
10
|
"src/locales/zh-CN.schema.yml"(exports, module) {
|
|
11
|
-
module.exports = { $desc: "Argus 配置", $inner: { path: "WebSocket 服务挂载路径,必须以 / 开头。", token: "客户端鉴权 token;为空时所有连接都会被拒绝。", commandName: "主命令名(例如 peek、spy、look)。", blur: "默认模糊半径,越大越糊(0 = 不模糊)。", blurMode: { $desc: "模糊算法", $inner: ["高斯模糊(质量好,但慢)", "快速模糊(jimp.blur,速度快)"] }, minBlur: "命令里 -b 临时调小模糊时不可低于此值,防止裸奔。",
|
|
11
|
+
module.exports = { $desc: "Argus 配置", $inner: { path: "WebSocket 服务挂载路径,必须以 / 开头。", token: "客户端鉴权 token;为空时所有连接都会被拒绝。", commandName: "主命令名(例如 peek、spy、look)。", blur: "默认模糊半径,越大越糊(0 = 不模糊)。", blurMode: { $desc: "模糊算法", $inner: ["高斯模糊(质量好,但慢)", "快速模糊(jimp.blur,速度快)"] }, minBlur: "命令里 -b 临时调小模糊时不可低于此值,防止裸奔。", maxImageKB: "单张截图(客户端→插件,加密后 base64)大小上限(KB)。超过会拒收。默认 8192(8MB)。", finalMaxKB: "发到群里的最终图片体积上限(KB)。插件做完模糊后会再压一遍 JPEG 到这个体积以内。默认 200。设 0 关闭。", timeout: "等待客户端响应的超时(毫秒)。", cacheDuration: "截图缓存时长(毫秒)。在此期间内同一客户端 + 显示器的反复调用会直接返回缓存的图。设为 0 关闭缓存。默认 5 分钟。", registerAlias: "是否给每个连进来的客户端自动注册同名命令作为别名。", authority: "命令所需的最低权限等级。", forceAuthority: "使用 -f / --force 绕过缓存所需的最低权限等级。" } };
|
|
12
12
|
}
|
|
13
13
|
});
|
|
14
14
|
|
|
15
15
|
// src/locales/en-US.schema.yml
|
|
16
16
|
var require_en_US_schema = __commonJS({
|
|
17
17
|
"src/locales/en-US.schema.yml"(exports, module) {
|
|
18
|
-
module.exports = { $desc: "Argus configuration", $inner: { path: "WebSocket mount path; must start with /.", token: "Auth token for clients; if empty all connections are rejected.", commandName: "Top-level command name (e.g. peek, spy, look).", blur: "Default blur radius; larger means blurrier (0 disables blur).", blurMode: { $desc: "Blur algorithm", $inner: ["Gaussian blur (better quality, slower)", "Fast blur (jimp.blur, faster)"] }, minBlur: "Lower bound for the temporary -b override, to prevent unblurred captures.",
|
|
18
|
+
module.exports = { $desc: "Argus configuration", $inner: { path: "WebSocket mount path; must start with /.", token: "Auth token for clients; if empty all connections are rejected.", commandName: "Top-level command name (e.g. peek, spy, look).", blur: "Default blur radius; larger means blurrier (0 disables blur).", blurMode: { $desc: "Blur algorithm", $inner: ["Gaussian blur (better quality, slower)", "Fast blur (jimp.blur, faster)"] }, minBlur: "Lower bound for the temporary -b override, to prevent unblurred captures.", maxImageKB: "Max screenshot size (client -> plugin, encrypted base64) in KB. Larger payloads are rejected. Default 8192 (8MB).", finalMaxKB: "Final image size budget after blur (KB). The plugin recompresses to JPEG within this budget before sending to chat. Default 200. Set 0 to disable.", timeout: "Timeout (ms) waiting for a client response.", cacheDuration: "Screenshot cache duration in ms. Repeated calls within this window for the same client+display reuse the cached image. Set to 0 to disable. Default 5 minutes.", registerAlias: "Whether to register each connected client's name as a command alias.", authority: "Minimum authority level required to run the command.", forceAuthority: "Minimum authority level required to use -f / --force to bypass the cache." } };
|
|
19
19
|
}
|
|
20
20
|
});
|
|
21
21
|
|
|
@@ -281,25 +281,34 @@ __name(randomId, "randomId");
|
|
|
281
281
|
import { h } from "koishi";
|
|
282
282
|
|
|
283
283
|
// src/blur.ts
|
|
284
|
-
import {
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
}
|
|
290
|
-
|
|
284
|
+
import {
|
|
285
|
+
PhotonImage,
|
|
286
|
+
box_blur,
|
|
287
|
+
resize,
|
|
288
|
+
SamplingFilter
|
|
289
|
+
} from "@cf-wasm/photon/node";
|
|
290
|
+
function blurImage(input, options) {
|
|
291
291
|
const radius = clamp(Math.round(options.radius), 0, 200);
|
|
292
|
-
const
|
|
293
|
-
|
|
294
|
-
if (
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
292
|
+
const img = PhotonImage.new_from_byteslice(new Uint8Array(input));
|
|
293
|
+
try {
|
|
294
|
+
if (radius === 0) {
|
|
295
|
+
return Buffer.from(img.get_bytes_jpeg(85));
|
|
296
|
+
}
|
|
297
|
+
const factor = radius <= 50 ? Math.round(radius / 4) + 2 : Math.round(radius / 6) + 4;
|
|
298
|
+
const w = img.get_width();
|
|
299
|
+
const h2 = img.get_height();
|
|
300
|
+
const sw = Math.max(2, Math.round(w / factor));
|
|
301
|
+
const sh = Math.max(2, Math.round(h2 / factor));
|
|
302
|
+
const small = resize(img, sw, sh, SamplingFilter.Triangle);
|
|
303
|
+
try {
|
|
304
|
+
if (options.mode === "gaussian") box_blur(small);
|
|
305
|
+
return Buffer.from(small.get_bytes_jpeg(85));
|
|
306
|
+
} finally {
|
|
307
|
+
small.free();
|
|
300
308
|
}
|
|
309
|
+
} finally {
|
|
310
|
+
img.free();
|
|
301
311
|
}
|
|
302
|
-
return await image.getBuffer("image/png");
|
|
303
312
|
}
|
|
304
313
|
__name(blurImage, "blurImage");
|
|
305
314
|
function clamp(v, min, max) {
|
|
@@ -307,6 +316,51 @@ function clamp(v, min, max) {
|
|
|
307
316
|
}
|
|
308
317
|
__name(clamp, "clamp");
|
|
309
318
|
|
|
319
|
+
// src/compress.ts
|
|
320
|
+
import {
|
|
321
|
+
PhotonImage as PhotonImage2,
|
|
322
|
+
resize as resize2,
|
|
323
|
+
SamplingFilter as SamplingFilter2
|
|
324
|
+
} from "@cf-wasm/photon/node";
|
|
325
|
+
function compressToBudget(input, options) {
|
|
326
|
+
const target = Math.max(8 * 1024, options.targetBytes);
|
|
327
|
+
if (input.length <= target) return input;
|
|
328
|
+
const initialQuality = options.initialQuality ?? 80;
|
|
329
|
+
const minQuality = options.minQuality ?? 40;
|
|
330
|
+
const img = PhotonImage2.new_from_byteslice(new Uint8Array(input));
|
|
331
|
+
try {
|
|
332
|
+
const ratioByteWise = target / input.length;
|
|
333
|
+
const estQ = Math.max(
|
|
334
|
+
minQuality,
|
|
335
|
+
Math.min(initialQuality, Math.round(initialQuality * ratioByteWise))
|
|
336
|
+
);
|
|
337
|
+
let buf = Buffer.from(img.get_bytes_jpeg(estQ));
|
|
338
|
+
if (buf.length <= target) return buf;
|
|
339
|
+
const q2 = Math.max(
|
|
340
|
+
minQuality,
|
|
341
|
+
Math.round(estQ * (target / buf.length) * 0.95)
|
|
342
|
+
);
|
|
343
|
+
if (q2 < estQ) {
|
|
344
|
+
buf = Buffer.from(img.get_bytes_jpeg(q2));
|
|
345
|
+
if (buf.length <= target) return buf;
|
|
346
|
+
}
|
|
347
|
+
const w = img.get_width();
|
|
348
|
+
const h2 = img.get_height();
|
|
349
|
+
const scale = Math.sqrt(target / buf.length) * 0.9;
|
|
350
|
+
const newW = Math.max(64, Math.round(w * scale));
|
|
351
|
+
const newH = Math.max(64, Math.round(h2 * scale));
|
|
352
|
+
const small = resize2(img, newW, newH, SamplingFilter2.Triangle);
|
|
353
|
+
try {
|
|
354
|
+
return Buffer.from(small.get_bytes_jpeg(q2));
|
|
355
|
+
} finally {
|
|
356
|
+
small.free();
|
|
357
|
+
}
|
|
358
|
+
} finally {
|
|
359
|
+
img.free();
|
|
360
|
+
}
|
|
361
|
+
}
|
|
362
|
+
__name(compressToBudget, "compressToBudget");
|
|
363
|
+
|
|
310
364
|
// src/cache.ts
|
|
311
365
|
var PeekCache = class {
|
|
312
366
|
constructor(duration) {
|
|
@@ -370,6 +424,42 @@ function formatRemaining(ms) {
|
|
|
370
424
|
}
|
|
371
425
|
__name(formatRemaining, "formatRemaining");
|
|
372
426
|
|
|
427
|
+
// src/crypto.ts
|
|
428
|
+
import {
|
|
429
|
+
createCipheriv,
|
|
430
|
+
createDecipheriv,
|
|
431
|
+
randomBytes,
|
|
432
|
+
scryptSync
|
|
433
|
+
} from "node:crypto";
|
|
434
|
+
var ALGO = "aes-256-gcm";
|
|
435
|
+
var IV_LEN = 12;
|
|
436
|
+
var TAG_LEN = 16;
|
|
437
|
+
var KEY_LEN = 32;
|
|
438
|
+
var SALT = Buffer.from("argus.peek.v1", "utf8");
|
|
439
|
+
var keyCache = /* @__PURE__ */ new Map();
|
|
440
|
+
function deriveKey(token) {
|
|
441
|
+
let key = keyCache.get(token);
|
|
442
|
+
if (key) return key;
|
|
443
|
+
key = scryptSync(token, SALT, KEY_LEN);
|
|
444
|
+
keyCache.set(token, key);
|
|
445
|
+
return key;
|
|
446
|
+
}
|
|
447
|
+
__name(deriveKey, "deriveKey");
|
|
448
|
+
function decryptBuffer(payloadBase64, token) {
|
|
449
|
+
const buf = Buffer.from(payloadBase64, "base64");
|
|
450
|
+
if (buf.length < IV_LEN + TAG_LEN) {
|
|
451
|
+
throw new Error("ciphertext_too_short");
|
|
452
|
+
}
|
|
453
|
+
const iv = buf.subarray(0, IV_LEN);
|
|
454
|
+
const tag = buf.subarray(IV_LEN, IV_LEN + TAG_LEN);
|
|
455
|
+
const ct = buf.subarray(IV_LEN + TAG_LEN);
|
|
456
|
+
const key = deriveKey(token);
|
|
457
|
+
const decipher = createDecipheriv(ALGO, key, iv);
|
|
458
|
+
decipher.setAuthTag(tag);
|
|
459
|
+
return Buffer.concat([decipher.update(ct), decipher.final()]);
|
|
460
|
+
}
|
|
461
|
+
__name(decryptBuffer, "decryptBuffer");
|
|
462
|
+
|
|
373
463
|
// src/commands.ts
|
|
374
464
|
function applyCommands(ctx, server, config, cache) {
|
|
375
465
|
const cmd = ctx.command(
|
|
@@ -413,7 +503,7 @@ function applyCommands(ctx, server, config, cache) {
|
|
|
413
503
|
}
|
|
414
504
|
if (cached.image) {
|
|
415
505
|
return [
|
|
416
|
-
h.image(cached.image, "image/png"),
|
|
506
|
+
h.image(cached.image, cached.mime ?? "image/png"),
|
|
417
507
|
formatCacheNote(session, cached)
|
|
418
508
|
];
|
|
419
509
|
}
|
|
@@ -426,15 +516,40 @@ function applyCommands(ctx, server, config, cache) {
|
|
|
426
516
|
cache.set(cacheKey, { busy: response.frame });
|
|
427
517
|
return formatBusy(session, client.name, response.frame);
|
|
428
518
|
}
|
|
429
|
-
|
|
430
|
-
|
|
519
|
+
let buffer;
|
|
520
|
+
try {
|
|
521
|
+
buffer = decodeImagePayload(response.frame, config.token);
|
|
522
|
+
} catch (err) {
|
|
523
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
524
|
+
ctx.logger.warn(
|
|
525
|
+
"argus decrypt failed for %s: %s",
|
|
526
|
+
client.name,
|
|
527
|
+
message
|
|
528
|
+
);
|
|
529
|
+
return session.text(".failed", ["decrypt_failed"]);
|
|
530
|
+
}
|
|
531
|
+
const blurStart = Date.now();
|
|
532
|
+
const blurred = blurImage(buffer, {
|
|
431
533
|
radius,
|
|
432
534
|
mode: config.blurMode
|
|
433
535
|
});
|
|
536
|
+
const blurMs = Date.now() - blurStart;
|
|
537
|
+
const finalBudget = config.finalMaxKB * 1024;
|
|
538
|
+
const compressStart = Date.now();
|
|
539
|
+
const output = finalBudget > 0 && blurred.length > finalBudget ? compressToBudget(blurred, { targetBytes: finalBudget }) : blurred;
|
|
540
|
+
const compressMs = Date.now() - compressStart;
|
|
541
|
+
const mime = "image/jpeg";
|
|
542
|
+
ctx.logger.debug(
|
|
543
|
+
"peek pipeline: blur=%dms compress=%dms %dKB→%dKB",
|
|
544
|
+
blurMs,
|
|
545
|
+
compressMs,
|
|
546
|
+
Math.round(blurred.length / 1024),
|
|
547
|
+
Math.round(output.length / 1024)
|
|
548
|
+
);
|
|
434
549
|
if (opts.blur === void 0 || opts.blur === config.blur) {
|
|
435
|
-
cache.set(cacheKey, { image: output });
|
|
550
|
+
cache.set(cacheKey, { image: output, mime });
|
|
436
551
|
}
|
|
437
|
-
return h.image(output,
|
|
552
|
+
return h.image(output, mime);
|
|
438
553
|
} catch (err) {
|
|
439
554
|
const message = err instanceof Error ? err.message : String(err);
|
|
440
555
|
ctx.logger.warn(
|
|
@@ -541,6 +656,16 @@ function range(start, end) {
|
|
|
541
656
|
return out;
|
|
542
657
|
}
|
|
543
658
|
__name(range, "range");
|
|
659
|
+
function decodeImagePayload(frame, token) {
|
|
660
|
+
if (!frame.enc || frame.enc === "none") {
|
|
661
|
+
return Buffer.from(frame.image, "base64");
|
|
662
|
+
}
|
|
663
|
+
if (frame.enc === "aes-256-gcm") {
|
|
664
|
+
return decryptBuffer(frame.image, token);
|
|
665
|
+
}
|
|
666
|
+
throw new Error(`unsupported_enc:${frame.enc}`);
|
|
667
|
+
}
|
|
668
|
+
__name(decodeImagePayload, "decodeImagePayload");
|
|
544
669
|
|
|
545
670
|
// src/index.ts
|
|
546
671
|
var name = "argus";
|
|
@@ -552,7 +677,8 @@ var Config = Schema.object({
|
|
|
552
677
|
blur: Schema.natural().min(0).max(200).default(40),
|
|
553
678
|
blurMode: Schema.union(["gaussian", "fast"]).default("fast"),
|
|
554
679
|
minBlur: Schema.natural().min(0).max(200).default(10),
|
|
555
|
-
|
|
680
|
+
maxImageKB: Schema.natural().default(8 * 1024),
|
|
681
|
+
finalMaxKB: Schema.natural().default(200),
|
|
556
682
|
timeout: Schema.natural().default(15e3),
|
|
557
683
|
cacheDuration: Schema.natural().default(5 * 60 * 1e3),
|
|
558
684
|
registerAlias: Schema.boolean().default(true),
|
|
@@ -577,7 +703,7 @@ function apply(ctx, config) {
|
|
|
577
703
|
path: config.path,
|
|
578
704
|
token: config.token,
|
|
579
705
|
timeout: config.timeout,
|
|
580
|
-
maxImageBytes: config.
|
|
706
|
+
maxImageBytes: config.maxImageKB * 1024,
|
|
581
707
|
onClientChange: /* @__PURE__ */ __name((event) => {
|
|
582
708
|
if (event.type === "connect") {
|
|
583
709
|
ctx.emit("argus/client-connect", event.name);
|
package/lib/types.d.ts
CHANGED
|
@@ -30,8 +30,14 @@ export interface PeekRequestFrame {
|
|
|
30
30
|
export interface PeekResultFrame {
|
|
31
31
|
type: 'peek_result';
|
|
32
32
|
id: string;
|
|
33
|
-
/**
|
|
33
|
+
/**
|
|
34
|
+
* 图片 buffer,base64 编码。
|
|
35
|
+
* `enc` 为 `aes-256-gcm` 时为 base64(iv|tag|ciphertext),token 派生 key 解密。
|
|
36
|
+
* `enc` 缺省 / 为 `none` 时为图片原始字节的 base64(向后兼容旧 CLI)。
|
|
37
|
+
*/
|
|
34
38
|
image: string;
|
|
39
|
+
/** 加密算法。新版客户端始终发 `aes-256-gcm`。 */
|
|
40
|
+
enc?: 'aes-256-gcm' | 'none';
|
|
35
41
|
mime?: string;
|
|
36
42
|
width?: number;
|
|
37
43
|
height?: number;
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "koishi-plugin-argus",
|
|
3
3
|
"description": "百眼巨人 Argus:让群友通过 /peek 命令偷窥你电脑屏幕的 Koishi 插件。",
|
|
4
|
-
"version": "0.
|
|
4
|
+
"version": "0.4.0",
|
|
5
5
|
"main": "lib/index.cjs",
|
|
6
6
|
"module": "lib/index.mjs",
|
|
7
7
|
"typings": "lib/index.d.ts",
|
|
@@ -34,8 +34,7 @@
|
|
|
34
34
|
"koishi": "^4.18.9"
|
|
35
35
|
},
|
|
36
36
|
"dependencies": {
|
|
37
|
-
"@
|
|
38
|
-
"jimp": "^1.6.0"
|
|
37
|
+
"@cf-wasm/photon": "^0.3.5"
|
|
39
38
|
},
|
|
40
39
|
"devDependencies": {
|
|
41
40
|
"@types/ws": "^8.5.10",
|
package/readme.md
CHANGED
|
@@ -5,6 +5,14 @@
|
|
|
5
5
|
插件本体作为 WebSocket 服务,配套 CLI 客户端 [`argus-eye`](https://www.npmjs.com/package/argus-eye)
|
|
6
6
|
连接上来后,群里就能通过命令拉取实时模糊截图。
|
|
7
7
|
|
|
8
|
+
## 安全说明
|
|
9
|
+
|
|
10
|
+
- 客户端连接时必须带 `token`,错的直接踢。
|
|
11
|
+
- 截图 buffer 在 CLI 端用 `AES-256-GCM` 加密后才上 WebSocket,
|
|
12
|
+
key 由 token 通过 scrypt 派生,IV 每帧随机。换句话说**抓包只能拿到密文**,
|
|
13
|
+
插件这边解出来再做模糊处理。token 写错或者被改包都会被 GCM 校验拒绝。
|
|
14
|
+
- 默认 `authority: 1`,可调高到只允许特定群友 peek。
|
|
15
|
+
|
|
8
16
|
## 安装
|
|
9
17
|
|
|
10
18
|
```bash
|
|
@@ -62,7 +70,8 @@ npx argus-eye -s ws://your-koishi-host:5140/argus -t a-strong-secret -n dingyi
|
|
|
62
70
|
| `blur` | `number` | `40` | 默认模糊半径(pixel)|
|
|
63
71
|
| `blurMode` | `'gaussian' \| 'fast'` | `'fast'` | 模糊算法 |
|
|
64
72
|
| `minBlur` | `number` | `10` | 命令里调小模糊时不可低于此值 |
|
|
65
|
-
| `
|
|
73
|
+
| `maxImageKB` | `number` | `8192` | 单张截图大小上限(KB) |
|
|
74
|
+
| `finalMaxKB` | `number` | `200` | 发到群里的最终图片体积上限(KB),插件会做二次压缩;0 = 关闭 |
|
|
66
75
|
| `timeout` | `number` | `15000` | 等待客户端响应超时(ms)|
|
|
67
76
|
| `cacheDuration` | `number` | `300000` | 截图缓存时长(ms),默认 5 分钟,0 = 关闭缓存 |
|
|
68
77
|
| `registerAlias` | `boolean` | `true` | 是否给每个客户端注册同名别名 |
|