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 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
- /** 模糊算法。`gaussian` 质量好但慢;`fast` jimp.blur,速度快。 */
5
+ /** 兼容旧字段;photon 实现里 'gaussian' 会多过一次 box_blur。 */
6
6
  mode?: BlurMode;
7
7
  }
8
8
  /**
9
- * 加载任意常见格式(png/jpg/webp 等)的图片,应用模糊后输出 PNG buffer。
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): Promise<Buffer>;
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
- /** 已经过模糊处理的最终 PNG buffer,busy 时为空。 */
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;
@@ -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 临时调小模糊时不可低于此值,防止裸奔。", maxImageBytes: "单张截图大小上限(字节)。", timeout: "等待客户端响应的超时(毫秒)。", cacheDuration: "截图缓存时长(毫秒)。在此期间内同一客户端 + 显示器的反复调用会直接返回缓存的图。设为 0 关闭缓存。默认 5 分钟。", registerAlias: "是否给每个连进来的客户端自动注册同名命令作为别名。", authority: "命令所需的最低权限等级。", forceAuthority: "使用 -f / --force 绕过缓存所需的最低权限等级。" } };
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.", maxImageBytes: "Max screenshot size in bytes.", 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." } };
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 import_core = require("@jimp/core");
308
- var import_jimp = require("jimp");
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 image = await Jimp.read(input);
316
- if (radius > 0) {
317
- if (options.mode === "gaussian") {
318
- const r = Math.max(1, Math.min(10, Math.round(radius / 20)));
319
- image.gaussian(r);
320
- } else {
321
- const r = Math.max(1, Math.min(100, radius));
322
- image.blur(r);
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
- const buffer = Buffer.from(response.frame.image, "base64");
453
- const output = await blurImage(buffer, {
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, "image/png");
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
- maxImageBytes: import_koishi2.Schema.natural().default(8 * 1024 * 1024),
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.maxImageBytes,
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
@@ -9,7 +9,8 @@ export interface Config {
9
9
  blur: number;
10
10
  blurMode: BlurMode;
11
11
  minBlur: number;
12
- maxImageBytes: number;
12
+ maxImageKB: number;
13
+ finalMaxKB: number;
13
14
  timeout: number;
14
15
  cacheDuration: number;
15
16
  registerAlias: boolean;
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 临时调小模糊时不可低于此值,防止裸奔。", maxImageBytes: "单张截图大小上限(字节)。", timeout: "等待客户端响应的超时(毫秒)。", cacheDuration: "截图缓存时长(毫秒)。在此期间内同一客户端 + 显示器的反复调用会直接返回缓存的图。设为 0 关闭缓存。默认 5 分钟。", registerAlias: "是否给每个连进来的客户端自动注册同名命令作为别名。", authority: "命令所需的最低权限等级。", forceAuthority: "使用 -f / --force 绕过缓存所需的最低权限等级。" } };
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.", maxImageBytes: "Max screenshot size in bytes.", 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." } };
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 { createJimp } from "@jimp/core";
285
- import { defaultFormats, defaultPlugins } from "jimp";
286
- var Jimp = createJimp({
287
- formats: [...defaultFormats],
288
- plugins: defaultPlugins
289
- });
290
- async function blurImage(input, options) {
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 image = await Jimp.read(input);
293
- if (radius > 0) {
294
- if (options.mode === "gaussian") {
295
- const r = Math.max(1, Math.min(10, Math.round(radius / 20)));
296
- image.gaussian(r);
297
- } else {
298
- const r = Math.max(1, Math.min(100, radius));
299
- image.blur(r);
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
- const buffer = Buffer.from(response.frame.image, "base64");
430
- const output = await blurImage(buffer, {
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, "image/png");
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
- maxImageBytes: Schema.natural().default(8 * 1024 * 1024),
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.maxImageBytes,
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
- /** PNG/JPEG base64(不带 data: 前缀)。*/
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.1.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
- "@jimp/core": "^1.6.0",
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
- | `maxImageBytes` | `number` | `8 * 1024 * 1024` | 单张截图大小上限 |
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` | 是否给每个客户端注册同名别名 |