@zhin.js/plugin-repeater 3.0.1 → 3.0.3
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/commands/repeater-status.ts +13 -0
- package/lib/engine.d.ts +40 -0
- package/lib/engine.d.ts.map +1 -0
- package/lib/engine.js +130 -0
- package/lib/engine.js.map +1 -0
- package/lib/index.d.ts +2 -1
- package/lib/index.d.ts.map +1 -1
- package/lib/index.js +1 -114
- package/lib/index.js.map +1 -1
- package/middlewares/repeater.ts +34 -0
- package/package.json +38 -12
- package/plugin.ts +13 -0
- package/schema.json +28 -0
- package/src/engine.ts +161 -0
- package/src/index.ts +9 -136
- /package/{skills/repeater/SKILL.md → agent/skills/repeater.md} +0 -0
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import { defineCommand } from '@zhin.js/command';
|
|
2
|
+
import {
|
|
3
|
+
getRepeaterEngine,
|
|
4
|
+
resolveRepeaterConfig,
|
|
5
|
+
type RepeaterConfig,
|
|
6
|
+
} from '../src/engine.js';
|
|
7
|
+
|
|
8
|
+
export default defineCommand<RepeaterConfig>({
|
|
9
|
+
description: '查看复读机的运行状态',
|
|
10
|
+
execute({ config }) {
|
|
11
|
+
return getRepeaterEngine().statusLines(resolveRepeaterConfig(config));
|
|
12
|
+
},
|
|
13
|
+
});
|
package/lib/engine.d.ts
ADDED
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
export interface RepeaterConfig {
|
|
2
|
+
readonly threshold: number;
|
|
3
|
+
readonly cooldown: number;
|
|
4
|
+
readonly maxLength: number;
|
|
5
|
+
}
|
|
6
|
+
export declare const DEFAULT_REPEATER_CONFIG: RepeaterConfig;
|
|
7
|
+
export declare function resolveRepeaterConfig(raw: unknown): RepeaterConfig;
|
|
8
|
+
export interface RepeaterInboundFields {
|
|
9
|
+
readonly target: string;
|
|
10
|
+
readonly content: string;
|
|
11
|
+
readonly sender?: string;
|
|
12
|
+
readonly metadata?: Readonly<Record<string, unknown>>;
|
|
13
|
+
}
|
|
14
|
+
/**
|
|
15
|
+
* Best-effort group detection for Plugin Runtime `Message`.
|
|
16
|
+
* Gap: channel type is not a first-class Message field; we read
|
|
17
|
+
* `metadata.type` / `metadata.channelType` when present. Without them,
|
|
18
|
+
* a non-empty `target` is treated as a group key (may false-positive DMs).
|
|
19
|
+
*/
|
|
20
|
+
export declare function resolveGroupId(message: RepeaterInboundFields): string | null;
|
|
21
|
+
export type RepeaterTickResult = {
|
|
22
|
+
readonly action: 'next';
|
|
23
|
+
} | {
|
|
24
|
+
readonly action: 'repeat';
|
|
25
|
+
readonly content: string;
|
|
26
|
+
};
|
|
27
|
+
export declare class RepeaterEngine {
|
|
28
|
+
#private;
|
|
29
|
+
constructor();
|
|
30
|
+
get totalRepeats(): number;
|
|
31
|
+
get activeGroups(): number;
|
|
32
|
+
tick(message: RepeaterInboundFields, config: RepeaterConfig): RepeaterTickResult;
|
|
33
|
+
statusLines(config: RepeaterConfig): string;
|
|
34
|
+
pruneStale(now?: number, expiry?: number, cooldown?: number): void;
|
|
35
|
+
dispose(): void;
|
|
36
|
+
}
|
|
37
|
+
export declare function getRepeaterEngine(): RepeaterEngine;
|
|
38
|
+
/** Test helper — reset the process-wide singleton. */
|
|
39
|
+
export declare function resetRepeaterEngine(): void;
|
|
40
|
+
//# sourceMappingURL=engine.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"engine.d.ts","sourceRoot":"","sources":["../src/engine.ts"],"names":[],"mappings":"AAAA,MAAM,WAAW,cAAc;IAC7B,QAAQ,CAAC,SAAS,EAAE,MAAM,CAAC;IAC3B,QAAQ,CAAC,QAAQ,EAAE,MAAM,CAAC;IAC1B,QAAQ,CAAC,SAAS,EAAE,MAAM,CAAC;CAC5B;AAED,eAAO,MAAM,uBAAuB,EAAE,cAIpC,CAAC;AAEH,wBAAgB,qBAAqB,CAAC,GAAG,EAAE,OAAO,GAAG,cAAc,CAMlE;AAOD,MAAM,WAAW,qBAAqB;IACpC,QAAQ,CAAC,MAAM,EAAE,MAAM,CAAC;IACxB,QAAQ,CAAC,OAAO,EAAE,MAAM,CAAC;IACzB,QAAQ,CAAC,MAAM,CAAC,EAAE,MAAM,CAAC;IACzB,QAAQ,CAAC,QAAQ,CAAC,EAAE,QAAQ,CAAC,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC,CAAC;CACvD;AAED;;;;;GAKG;AACH,wBAAgB,cAAc,CAAC,OAAO,EAAE,qBAAqB,GAAG,MAAM,GAAG,IAAI,CAY5E;AASD,MAAM,MAAM,kBAAkB,GAC1B;IAAE,QAAQ,CAAC,MAAM,EAAE,MAAM,CAAA;CAAE,GAC3B;IAAE,QAAQ,CAAC,MAAM,EAAE,QAAQ,CAAC;IAAC,QAAQ,CAAC,OAAO,EAAE,MAAM,CAAA;CAAE,CAAC;AAE5D,qBAAa,cAAc;;;IAazB,IAAI,YAAY,IAAI,MAAM,CAEzB;IAED,IAAI,YAAY,IAAI,MAAM,CAEzB;IAED,IAAI,CAAC,OAAO,EAAE,qBAAqB,EAAE,MAAM,EAAE,cAAc,GAAG,kBAAkB;IAqChF,WAAW,CAAC,MAAM,EAAE,cAAc,GAAG,MAAM;IAW3C,UAAU,CAAC,GAAG,SAAa,EAAE,MAAM,SAAa,EAAE,QAAQ,SAAmC,GAAG,IAAI;IASpG,OAAO,IAAI,IAAI;CAMhB;AAID,wBAAgB,iBAAiB,IAAI,cAAc,CAGlD;AAED,sDAAsD;AACtD,wBAAgB,mBAAmB,IAAI,IAAI,CAG1C"}
|
package/lib/engine.js
ADDED
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
export const DEFAULT_REPEATER_CONFIG = Object.freeze({
|
|
2
|
+
threshold: 3,
|
|
3
|
+
cooldown: 30_000,
|
|
4
|
+
maxLength: 200,
|
|
5
|
+
});
|
|
6
|
+
export function resolveRepeaterConfig(raw) {
|
|
7
|
+
const input = raw && typeof raw === 'object' ? raw : {};
|
|
8
|
+
const threshold = clampNumber(input.threshold, DEFAULT_REPEATER_CONFIG.threshold, 2, 10);
|
|
9
|
+
const cooldown = clampNumber(input.cooldown, DEFAULT_REPEATER_CONFIG.cooldown, 5_000, 300_000);
|
|
10
|
+
const maxLength = clampNumber(input.maxLength, DEFAULT_REPEATER_CONFIG.maxLength, 10, 1_000);
|
|
11
|
+
return Object.freeze({ threshold, cooldown, maxLength });
|
|
12
|
+
}
|
|
13
|
+
function clampNumber(value, fallback, min, max) {
|
|
14
|
+
const n = typeof value === 'number' && Number.isFinite(value) ? value : fallback;
|
|
15
|
+
return Math.min(max, Math.max(min, Math.trunc(n)));
|
|
16
|
+
}
|
|
17
|
+
/**
|
|
18
|
+
* Best-effort group detection for Plugin Runtime `Message`.
|
|
19
|
+
* Gap: channel type is not a first-class Message field; we read
|
|
20
|
+
* `metadata.type` / `metadata.channelType` when present. Without them,
|
|
21
|
+
* a non-empty `target` is treated as a group key (may false-positive DMs).
|
|
22
|
+
*/
|
|
23
|
+
export function resolveGroupId(message) {
|
|
24
|
+
const meta = message.metadata ?? {};
|
|
25
|
+
const type = meta.type ?? meta.channelType
|
|
26
|
+
?? (meta.channel && typeof meta.channel === 'object'
|
|
27
|
+
? meta.channel.type
|
|
28
|
+
: undefined);
|
|
29
|
+
if (type === 'private')
|
|
30
|
+
return null;
|
|
31
|
+
if (type === 'group' || type === 'channel') {
|
|
32
|
+
return String(message.target || '');
|
|
33
|
+
}
|
|
34
|
+
if (type != null)
|
|
35
|
+
return null;
|
|
36
|
+
return message.target ? String(message.target) : null;
|
|
37
|
+
}
|
|
38
|
+
export class RepeaterEngine {
|
|
39
|
+
#groupStates = new Map();
|
|
40
|
+
#cooldownSet = new Map();
|
|
41
|
+
#totalRepeats = 0;
|
|
42
|
+
#cleanupTimer;
|
|
43
|
+
constructor() {
|
|
44
|
+
this.#cleanupTimer = setInterval(() => this.pruneStale(), 120_000);
|
|
45
|
+
if (typeof this.#cleanupTimer === 'object' && 'unref' in this.#cleanupTimer) {
|
|
46
|
+
this.#cleanupTimer.unref();
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
get totalRepeats() {
|
|
50
|
+
return this.#totalRepeats;
|
|
51
|
+
}
|
|
52
|
+
get activeGroups() {
|
|
53
|
+
return this.#groupStates.size;
|
|
54
|
+
}
|
|
55
|
+
tick(message, config) {
|
|
56
|
+
const groupId = resolveGroupId(message);
|
|
57
|
+
if (!groupId)
|
|
58
|
+
return { action: 'next' };
|
|
59
|
+
const content = message.content.trim();
|
|
60
|
+
if (!content || content.length > config.maxLength)
|
|
61
|
+
return { action: 'next' };
|
|
62
|
+
const senderId = String(message.sender ?? '');
|
|
63
|
+
if (!senderId)
|
|
64
|
+
return { action: 'next' };
|
|
65
|
+
const state = this.#groupStates.get(groupId);
|
|
66
|
+
if (state && state.content === content) {
|
|
67
|
+
if (state.users.has(senderId))
|
|
68
|
+
return { action: 'next' };
|
|
69
|
+
state.users.add(senderId);
|
|
70
|
+
state.lastTime = Date.now();
|
|
71
|
+
if (state.users.size >= config.threshold && !state.repeated) {
|
|
72
|
+
const lastCd = this.#cooldownSet.get(groupId);
|
|
73
|
+
if (lastCd && Date.now() - lastCd < config.cooldown) {
|
|
74
|
+
return { action: 'next' };
|
|
75
|
+
}
|
|
76
|
+
state.repeated = true;
|
|
77
|
+
this.#cooldownSet.set(groupId, Date.now());
|
|
78
|
+
this.#totalRepeats += 1;
|
|
79
|
+
return { action: 'repeat', content };
|
|
80
|
+
}
|
|
81
|
+
return { action: 'next' };
|
|
82
|
+
}
|
|
83
|
+
this.#groupStates.set(groupId, {
|
|
84
|
+
content,
|
|
85
|
+
users: new Set([senderId]),
|
|
86
|
+
repeated: false,
|
|
87
|
+
lastTime: Date.now(),
|
|
88
|
+
});
|
|
89
|
+
return { action: 'next' };
|
|
90
|
+
}
|
|
91
|
+
statusLines(config) {
|
|
92
|
+
return [
|
|
93
|
+
'复读机状态',
|
|
94
|
+
`监控群数: ${this.activeGroups}`,
|
|
95
|
+
`触发阈值: ${config.threshold} 人`,
|
|
96
|
+
`冷却时间: ${config.cooldown / 1000}s`,
|
|
97
|
+
`消息长度上限: ${config.maxLength}`,
|
|
98
|
+
`累计复读: ${this.#totalRepeats} 次`,
|
|
99
|
+
].join('\n');
|
|
100
|
+
}
|
|
101
|
+
pruneStale(now = Date.now(), expiry = 5 * 60_000, cooldown = DEFAULT_REPEATER_CONFIG.cooldown) {
|
|
102
|
+
for (const [key, state] of this.#groupStates) {
|
|
103
|
+
if (now - state.lastTime > expiry)
|
|
104
|
+
this.#groupStates.delete(key);
|
|
105
|
+
}
|
|
106
|
+
for (const [key, time] of this.#cooldownSet) {
|
|
107
|
+
if (now - time > cooldown * 2)
|
|
108
|
+
this.#cooldownSet.delete(key);
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
dispose() {
|
|
112
|
+
if (this.#cleanupTimer)
|
|
113
|
+
clearInterval(this.#cleanupTimer);
|
|
114
|
+
this.#cleanupTimer = undefined;
|
|
115
|
+
this.#groupStates.clear();
|
|
116
|
+
this.#cooldownSet.clear();
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
let shared;
|
|
120
|
+
export function getRepeaterEngine() {
|
|
121
|
+
if (!shared)
|
|
122
|
+
shared = new RepeaterEngine();
|
|
123
|
+
return shared;
|
|
124
|
+
}
|
|
125
|
+
/** Test helper — reset the process-wide singleton. */
|
|
126
|
+
export function resetRepeaterEngine() {
|
|
127
|
+
shared?.dispose();
|
|
128
|
+
shared = undefined;
|
|
129
|
+
}
|
|
130
|
+
//# sourceMappingURL=engine.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"engine.js","sourceRoot":"","sources":["../src/engine.ts"],"names":[],"mappings":"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"}
|
package/lib/index.d.ts
CHANGED
|
@@ -1,2 +1,3 @@
|
|
|
1
|
-
export {};
|
|
1
|
+
export { DEFAULT_REPEATER_CONFIG, RepeaterEngine, getRepeaterEngine, resetRepeaterEngine, resolveGroupId, resolveRepeaterConfig, } from './engine.js';
|
|
2
|
+
export type { RepeaterConfig, RepeaterInboundFields, RepeaterTickResult } from './engine.js';
|
|
2
3
|
//# sourceMappingURL=index.d.ts.map
|
package/lib/index.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":""}
|
|
1
|
+
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,OAAO,EACL,uBAAuB,EACvB,cAAc,EACd,iBAAiB,EACjB,mBAAmB,EACnB,cAAc,EACd,qBAAqB,GACtB,MAAM,aAAa,CAAC;AACrB,YAAY,EAAE,cAAc,EAAE,qBAAqB,EAAE,kBAAkB,EAAE,MAAM,aAAa,CAAC"}
|
package/lib/index.js
CHANGED
|
@@ -1,115 +1,2 @@
|
|
|
1
|
-
|
|
2
|
-
* @zhin.js/plugin-repeater
|
|
3
|
-
*
|
|
4
|
-
* 复读机插件 —— 当群内连续多人发送相同消息时自动跟读
|
|
5
|
-
*
|
|
6
|
-
* 规则:
|
|
7
|
-
* - 同一群聊内,连续 N 人(默认 3)发送完全相同的消息后触发复读
|
|
8
|
-
* - 同一用户的连续消息不计入(防止单人刷屏触发)
|
|
9
|
-
* - 每条消息在同一群只复读一次
|
|
10
|
-
* - 可配置中断策略(不同消息会重置计数)
|
|
11
|
-
* - 支持冷却时间防止频繁复读
|
|
12
|
-
*
|
|
13
|
-
* 命令:
|
|
14
|
-
* repeater-status 查看当前复读机状态
|
|
15
|
-
*
|
|
16
|
-
* 配置(zhin.config.yml):
|
|
17
|
-
* ```yaml
|
|
18
|
-
* plugins:
|
|
19
|
-
* - "@zhin.js/plugin-repeater"
|
|
20
|
-
* repeater:
|
|
21
|
-
* threshold: 3
|
|
22
|
-
* cooldown: 30000
|
|
23
|
-
* maxLength: 200
|
|
24
|
-
* ```
|
|
25
|
-
*/
|
|
26
|
-
import { formatCompact, MessageCommand, Schema, usePlugin } from 'zhin.js';
|
|
27
|
-
const plugin = usePlugin();
|
|
28
|
-
const { logger, addCommand, onDispose, declareConfig, root } = plugin;
|
|
29
|
-
const config = declareConfig("repeater", Schema.object({
|
|
30
|
-
threshold: Schema.number().default(3).min(2).max(10).description("触发复读的最少人数"),
|
|
31
|
-
cooldown: Schema.number().default(30_000).min(5000).max(300_000).description("同一群冷却时间 (ms)"),
|
|
32
|
-
maxLength: Schema.number().default(200).min(10).max(1000).description("消息长度上限"),
|
|
33
|
-
}));
|
|
34
|
-
const groupStates = new Map();
|
|
35
|
-
const cooldownSet = new Map();
|
|
36
|
-
function getGroupId(message) {
|
|
37
|
-
if (message.type !== "group")
|
|
38
|
-
return null;
|
|
39
|
-
return String(message.$group?.id || message.$target?.id || "");
|
|
40
|
-
}
|
|
41
|
-
function getSenderId(message) {
|
|
42
|
-
return String(message.$sender?.id || "");
|
|
43
|
-
}
|
|
44
|
-
let totalRepeats = 0;
|
|
45
|
-
root.addMiddleware(async function repeaterMiddleware(message, next) {
|
|
46
|
-
const groupId = getGroupId(message);
|
|
47
|
-
if (!groupId)
|
|
48
|
-
return next();
|
|
49
|
-
const content = (message.$raw || "").trim();
|
|
50
|
-
if (!content || content.length > config.maxLength)
|
|
51
|
-
return next();
|
|
52
|
-
const senderId = getSenderId(message);
|
|
53
|
-
if (!senderId)
|
|
54
|
-
return next();
|
|
55
|
-
const state = groupStates.get(groupId);
|
|
56
|
-
if (state && state.content === content) {
|
|
57
|
-
if (state.users.has(senderId))
|
|
58
|
-
return next();
|
|
59
|
-
state.users.add(senderId);
|
|
60
|
-
state.lastTime = Date.now();
|
|
61
|
-
if (state.users.size >= config.threshold && !state.repeated) {
|
|
62
|
-
const lastCd = cooldownSet.get(groupId);
|
|
63
|
-
if (lastCd && Date.now() - lastCd < config.cooldown) {
|
|
64
|
-
return next();
|
|
65
|
-
}
|
|
66
|
-
state.repeated = true;
|
|
67
|
-
cooldownSet.set(groupId, Date.now());
|
|
68
|
-
totalRepeats++;
|
|
69
|
-
await message.$reply(content);
|
|
70
|
-
return;
|
|
71
|
-
}
|
|
72
|
-
}
|
|
73
|
-
else {
|
|
74
|
-
groupStates.set(groupId, {
|
|
75
|
-
content,
|
|
76
|
-
users: new Set([senderId]),
|
|
77
|
-
repeated: false,
|
|
78
|
-
lastTime: Date.now(),
|
|
79
|
-
});
|
|
80
|
-
}
|
|
81
|
-
return next();
|
|
82
|
-
});
|
|
83
|
-
const staleCleanup = setInterval(() => {
|
|
84
|
-
const now = Date.now();
|
|
85
|
-
const expiry = 5 * 60_000;
|
|
86
|
-
for (const [key, state] of groupStates) {
|
|
87
|
-
if (now - state.lastTime > expiry)
|
|
88
|
-
groupStates.delete(key);
|
|
89
|
-
}
|
|
90
|
-
for (const [key, time] of cooldownSet) {
|
|
91
|
-
if (now - time > config.cooldown * 2)
|
|
92
|
-
cooldownSet.delete(key);
|
|
93
|
-
}
|
|
94
|
-
}, 120_000);
|
|
95
|
-
onDispose(() => {
|
|
96
|
-
clearInterval(staleCleanup);
|
|
97
|
-
groupStates.clear();
|
|
98
|
-
cooldownSet.clear();
|
|
99
|
-
});
|
|
100
|
-
addCommand(new MessageCommand("repeater-status")
|
|
101
|
-
.desc("复读机状态", "查看复读机的运行状态")
|
|
102
|
-
.action(async () => {
|
|
103
|
-
const activeGroups = groupStates.size;
|
|
104
|
-
const lines = [
|
|
105
|
-
"复读机状态",
|
|
106
|
-
`监控群数: ${activeGroups}`,
|
|
107
|
-
`触发阈值: ${config.threshold} 人`,
|
|
108
|
-
`冷却时间: ${config.cooldown / 1000}s`,
|
|
109
|
-
`消息长度上限: ${config.maxLength}`,
|
|
110
|
-
`累计复读: ${totalRepeats} 次`,
|
|
111
|
-
];
|
|
112
|
-
return lines.join("\n");
|
|
113
|
-
}));
|
|
114
|
-
logger.info(formatCompact({ op: "load", threshold: config.threshold, cooldown_s: config.cooldown / 1000 }));
|
|
1
|
+
export { DEFAULT_REPEATER_CONFIG, RepeaterEngine, getRepeaterEngine, resetRepeaterEngine, resolveGroupId, resolveRepeaterConfig, } from './engine.js';
|
|
115
2
|
//# sourceMappingURL=index.js.map
|
package/lib/index.js.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"index.js","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA
|
|
1
|
+
{"version":3,"file":"index.js","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,OAAO,EACL,uBAAuB,EACvB,cAAc,EACd,iBAAiB,EACjB,mBAAmB,EACnB,cAAc,EACd,qBAAqB,GACtB,MAAM,aAAa,CAAC"}
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import { defineMiddleware } from '@zhin.js/middleware';
|
|
2
|
+
import type { Message } from '@zhin.js/core/runtime';
|
|
3
|
+
import {
|
|
4
|
+
getRepeaterEngine,
|
|
5
|
+
resolveRepeaterConfig,
|
|
6
|
+
type RepeaterConfig,
|
|
7
|
+
} from '../src/engine.js';
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Gaps vs legacy Message:
|
|
11
|
+
* - Uses Runtime `Message.content` / `Message.sender` / `Message.target` / `Message.metadata`
|
|
12
|
+
* (no `$raw` / `$channel` / `$sender`).
|
|
13
|
+
* - Group vs private relies on `metadata.type|channelType`; see `resolveGroupId`.
|
|
14
|
+
* - Reply uses `Message.$reply` (available during inbound middleware scope).
|
|
15
|
+
*/
|
|
16
|
+
export default defineMiddleware<Message, RepeaterConfig>({
|
|
17
|
+
target: 'inbound',
|
|
18
|
+
async handle(context, next) {
|
|
19
|
+
const config = resolveRepeaterConfig(context.config);
|
|
20
|
+
const engine = getRepeaterEngine();
|
|
21
|
+
const result = engine.tick({
|
|
22
|
+
target: context.input.target,
|
|
23
|
+
content: context.input.content,
|
|
24
|
+
sender: context.input.sender,
|
|
25
|
+
metadata: context.input.metadata,
|
|
26
|
+
}, config);
|
|
27
|
+
|
|
28
|
+
if (result.action === 'repeat') {
|
|
29
|
+
await context.input.$reply(result.content);
|
|
30
|
+
return;
|
|
31
|
+
}
|
|
32
|
+
await next();
|
|
33
|
+
},
|
|
34
|
+
});
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@zhin.js/plugin-repeater",
|
|
3
|
-
"version": "3.0.
|
|
4
|
-
"description": "Repeater plugin for Zhin.js — auto-repeat
|
|
3
|
+
"version": "3.0.3",
|
|
4
|
+
"description": "Repeater plugin for Zhin.js — auto-repeat when group members echo the same content (Plugin Runtime)",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./lib/index.js",
|
|
7
7
|
"types": "./lib/index.d.ts",
|
|
@@ -14,11 +14,13 @@
|
|
|
14
14
|
"./package.json": "./package.json"
|
|
15
15
|
},
|
|
16
16
|
"files": [
|
|
17
|
+
"plugin.ts",
|
|
18
|
+
"schema.json",
|
|
19
|
+
"commands",
|
|
20
|
+
"middlewares",
|
|
17
21
|
"src",
|
|
18
22
|
"lib",
|
|
19
|
-
"
|
|
20
|
-
"dist",
|
|
21
|
-
"skills",
|
|
23
|
+
"agent",
|
|
22
24
|
"README.md"
|
|
23
25
|
],
|
|
24
26
|
"keywords": [
|
|
@@ -38,13 +40,15 @@
|
|
|
38
40
|
"url": "https://github.com/lc-cn"
|
|
39
41
|
},
|
|
40
42
|
"license": "MIT",
|
|
41
|
-
"dependencies": {
|
|
43
|
+
"dependencies": {
|
|
44
|
+
"@zhin.js/command": "1.0.1",
|
|
45
|
+
"@zhin.js/core": "1.3.5",
|
|
46
|
+
"@zhin.js/middleware": "1.0.1",
|
|
47
|
+
"@zhin.js/plugin-runtime": "1.0.1"
|
|
48
|
+
},
|
|
42
49
|
"devDependencies": {
|
|
43
50
|
"typescript": "^6.0.3",
|
|
44
|
-
"
|
|
45
|
-
},
|
|
46
|
-
"peerDependencies": {
|
|
47
|
-
"zhin.js": "4.1.1"
|
|
51
|
+
"vitest": "^4.1.10"
|
|
48
52
|
},
|
|
49
53
|
"repository": {
|
|
50
54
|
"type": "git",
|
|
@@ -55,8 +59,30 @@
|
|
|
55
59
|
"access": "public",
|
|
56
60
|
"registry": "https://registry.npmjs.org"
|
|
57
61
|
},
|
|
62
|
+
"engines": {
|
|
63
|
+
"node": "^20.19.0 || >=22.12.0"
|
|
64
|
+
},
|
|
65
|
+
"zhin": {
|
|
66
|
+
"protocol": 1,
|
|
67
|
+
"type": "plugin",
|
|
68
|
+
"entry": "./plugin.ts",
|
|
69
|
+
"engine": "^1.0.0",
|
|
70
|
+
"runtime": "trusted",
|
|
71
|
+
"features": [
|
|
72
|
+
{
|
|
73
|
+
"package": "@zhin.js/command",
|
|
74
|
+
"api": "^1.0.0"
|
|
75
|
+
},
|
|
76
|
+
{
|
|
77
|
+
"package": "@zhin.js/middleware",
|
|
78
|
+
"api": "^1.0.0"
|
|
79
|
+
}
|
|
80
|
+
],
|
|
81
|
+
"plugins": []
|
|
82
|
+
},
|
|
58
83
|
"scripts": {
|
|
59
|
-
"build": "tsc
|
|
60
|
-
"clean": "rimraf lib"
|
|
84
|
+
"build": "tsc",
|
|
85
|
+
"clean": "rimraf lib",
|
|
86
|
+
"test": "NODE_OPTIONS=--experimental-strip-types vitest run --root ../../.. plugins/utils/repeater/tests"
|
|
61
87
|
}
|
|
62
88
|
}
|
package/plugin.ts
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import { definePlugin } from '@zhin.js/plugin-runtime';
|
|
2
|
+
import { getRepeaterEngine } from './src/engine.js';
|
|
3
|
+
|
|
4
|
+
export default definePlugin({
|
|
5
|
+
name: 'repeater',
|
|
6
|
+
metadata: {
|
|
7
|
+
displayName: 'Repeater',
|
|
8
|
+
},
|
|
9
|
+
setup(context) {
|
|
10
|
+
const engine = getRepeaterEngine();
|
|
11
|
+
context.lifecycle.add(() => engine.dispose());
|
|
12
|
+
},
|
|
13
|
+
});
|
package/schema.json
ADDED
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
{
|
|
2
|
+
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
|
3
|
+
"type": "object",
|
|
4
|
+
"additionalProperties": false,
|
|
5
|
+
"properties": {
|
|
6
|
+
"threshold": {
|
|
7
|
+
"type": "number",
|
|
8
|
+
"default": 3,
|
|
9
|
+
"minimum": 2,
|
|
10
|
+
"maximum": 10,
|
|
11
|
+
"description": "触发复读的最少人数"
|
|
12
|
+
},
|
|
13
|
+
"cooldown": {
|
|
14
|
+
"type": "number",
|
|
15
|
+
"default": 30000,
|
|
16
|
+
"minimum": 5000,
|
|
17
|
+
"maximum": 300000,
|
|
18
|
+
"description": "同一群冷却时间 (ms)"
|
|
19
|
+
},
|
|
20
|
+
"maxLength": {
|
|
21
|
+
"type": "number",
|
|
22
|
+
"default": 200,
|
|
23
|
+
"minimum": 10,
|
|
24
|
+
"maximum": 1000,
|
|
25
|
+
"description": "消息长度上限"
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
}
|
package/src/engine.ts
ADDED
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
export interface RepeaterConfig {
|
|
2
|
+
readonly threshold: number;
|
|
3
|
+
readonly cooldown: number;
|
|
4
|
+
readonly maxLength: number;
|
|
5
|
+
}
|
|
6
|
+
|
|
7
|
+
export const DEFAULT_REPEATER_CONFIG: RepeaterConfig = Object.freeze({
|
|
8
|
+
threshold: 3,
|
|
9
|
+
cooldown: 30_000,
|
|
10
|
+
maxLength: 200,
|
|
11
|
+
});
|
|
12
|
+
|
|
13
|
+
export function resolveRepeaterConfig(raw: unknown): RepeaterConfig {
|
|
14
|
+
const input = raw && typeof raw === 'object' ? raw as Record<string, unknown> : {};
|
|
15
|
+
const threshold = clampNumber(input.threshold, DEFAULT_REPEATER_CONFIG.threshold, 2, 10);
|
|
16
|
+
const cooldown = clampNumber(input.cooldown, DEFAULT_REPEATER_CONFIG.cooldown, 5_000, 300_000);
|
|
17
|
+
const maxLength = clampNumber(input.maxLength, DEFAULT_REPEATER_CONFIG.maxLength, 10, 1_000);
|
|
18
|
+
return Object.freeze({ threshold, cooldown, maxLength });
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function clampNumber(value: unknown, fallback: number, min: number, max: number): number {
|
|
22
|
+
const n = typeof value === 'number' && Number.isFinite(value) ? value : fallback;
|
|
23
|
+
return Math.min(max, Math.max(min, Math.trunc(n)));
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export interface RepeaterInboundFields {
|
|
27
|
+
readonly target: string;
|
|
28
|
+
readonly content: string;
|
|
29
|
+
readonly sender?: string;
|
|
30
|
+
readonly metadata?: Readonly<Record<string, unknown>>;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Best-effort group detection for Plugin Runtime `Message`.
|
|
35
|
+
* Gap: channel type is not a first-class Message field; we read
|
|
36
|
+
* `metadata.type` / `metadata.channelType` when present. Without them,
|
|
37
|
+
* a non-empty `target` is treated as a group key (may false-positive DMs).
|
|
38
|
+
*/
|
|
39
|
+
export function resolveGroupId(message: RepeaterInboundFields): string | null {
|
|
40
|
+
const meta = message.metadata ?? {};
|
|
41
|
+
const type = meta.type ?? meta.channelType
|
|
42
|
+
?? (meta.channel && typeof meta.channel === 'object'
|
|
43
|
+
? (meta.channel as { type?: unknown }).type
|
|
44
|
+
: undefined);
|
|
45
|
+
if (type === 'private') return null;
|
|
46
|
+
if (type === 'group' || type === 'channel') {
|
|
47
|
+
return String(message.target || '');
|
|
48
|
+
}
|
|
49
|
+
if (type != null) return null;
|
|
50
|
+
return message.target ? String(message.target) : null;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
interface RepeatState {
|
|
54
|
+
content: string;
|
|
55
|
+
users: Set<string>;
|
|
56
|
+
repeated: boolean;
|
|
57
|
+
lastTime: number;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export type RepeaterTickResult =
|
|
61
|
+
| { readonly action: 'next' }
|
|
62
|
+
| { readonly action: 'repeat'; readonly content: string };
|
|
63
|
+
|
|
64
|
+
export class RepeaterEngine {
|
|
65
|
+
readonly #groupStates = new Map<string, RepeatState>();
|
|
66
|
+
readonly #cooldownSet = new Map<string, number>();
|
|
67
|
+
#totalRepeats = 0;
|
|
68
|
+
#cleanupTimer?: ReturnType<typeof setInterval>;
|
|
69
|
+
|
|
70
|
+
constructor() {
|
|
71
|
+
this.#cleanupTimer = setInterval(() => this.pruneStale(), 120_000);
|
|
72
|
+
if (typeof this.#cleanupTimer === 'object' && 'unref' in this.#cleanupTimer) {
|
|
73
|
+
this.#cleanupTimer.unref();
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
get totalRepeats(): number {
|
|
78
|
+
return this.#totalRepeats;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
get activeGroups(): number {
|
|
82
|
+
return this.#groupStates.size;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
tick(message: RepeaterInboundFields, config: RepeaterConfig): RepeaterTickResult {
|
|
86
|
+
const groupId = resolveGroupId(message);
|
|
87
|
+
if (!groupId) return { action: 'next' };
|
|
88
|
+
|
|
89
|
+
const content = message.content.trim();
|
|
90
|
+
if (!content || content.length > config.maxLength) return { action: 'next' };
|
|
91
|
+
|
|
92
|
+
const senderId = String(message.sender ?? '');
|
|
93
|
+
if (!senderId) return { action: 'next' };
|
|
94
|
+
|
|
95
|
+
const state = this.#groupStates.get(groupId);
|
|
96
|
+
if (state && state.content === content) {
|
|
97
|
+
if (state.users.has(senderId)) return { action: 'next' };
|
|
98
|
+
state.users.add(senderId);
|
|
99
|
+
state.lastTime = Date.now();
|
|
100
|
+
if (state.users.size >= config.threshold && !state.repeated) {
|
|
101
|
+
const lastCd = this.#cooldownSet.get(groupId);
|
|
102
|
+
if (lastCd && Date.now() - lastCd < config.cooldown) {
|
|
103
|
+
return { action: 'next' };
|
|
104
|
+
}
|
|
105
|
+
state.repeated = true;
|
|
106
|
+
this.#cooldownSet.set(groupId, Date.now());
|
|
107
|
+
this.#totalRepeats += 1;
|
|
108
|
+
return { action: 'repeat', content };
|
|
109
|
+
}
|
|
110
|
+
return { action: 'next' };
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
this.#groupStates.set(groupId, {
|
|
114
|
+
content,
|
|
115
|
+
users: new Set([senderId]),
|
|
116
|
+
repeated: false,
|
|
117
|
+
lastTime: Date.now(),
|
|
118
|
+
});
|
|
119
|
+
return { action: 'next' };
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
statusLines(config: RepeaterConfig): string {
|
|
123
|
+
return [
|
|
124
|
+
'复读机状态',
|
|
125
|
+
`监控群数: ${this.activeGroups}`,
|
|
126
|
+
`触发阈值: ${config.threshold} 人`,
|
|
127
|
+
`冷却时间: ${config.cooldown / 1000}s`,
|
|
128
|
+
`消息长度上限: ${config.maxLength}`,
|
|
129
|
+
`累计复读: ${this.#totalRepeats} 次`,
|
|
130
|
+
].join('\n');
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
pruneStale(now = Date.now(), expiry = 5 * 60_000, cooldown = DEFAULT_REPEATER_CONFIG.cooldown): void {
|
|
134
|
+
for (const [key, state] of this.#groupStates) {
|
|
135
|
+
if (now - state.lastTime > expiry) this.#groupStates.delete(key);
|
|
136
|
+
}
|
|
137
|
+
for (const [key, time] of this.#cooldownSet) {
|
|
138
|
+
if (now - time > cooldown * 2) this.#cooldownSet.delete(key);
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
dispose(): void {
|
|
143
|
+
if (this.#cleanupTimer) clearInterval(this.#cleanupTimer);
|
|
144
|
+
this.#cleanupTimer = undefined;
|
|
145
|
+
this.#groupStates.clear();
|
|
146
|
+
this.#cooldownSet.clear();
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
let shared: RepeaterEngine | undefined;
|
|
151
|
+
|
|
152
|
+
export function getRepeaterEngine(): RepeaterEngine {
|
|
153
|
+
if (!shared) shared = new RepeaterEngine();
|
|
154
|
+
return shared;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/** Test helper — reset the process-wide singleton. */
|
|
158
|
+
export function resetRepeaterEngine(): void {
|
|
159
|
+
shared?.dispose();
|
|
160
|
+
shared = undefined;
|
|
161
|
+
}
|
package/src/index.ts
CHANGED
|
@@ -1,136 +1,9 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
* - 可配置中断策略(不同消息会重置计数)
|
|
11
|
-
* - 支持冷却时间防止频繁复读
|
|
12
|
-
*
|
|
13
|
-
* 命令:
|
|
14
|
-
* repeater-status 查看当前复读机状态
|
|
15
|
-
*
|
|
16
|
-
* 配置(zhin.config.yml):
|
|
17
|
-
* ```yaml
|
|
18
|
-
* plugins:
|
|
19
|
-
* - "@zhin.js/plugin-repeater"
|
|
20
|
-
* repeater:
|
|
21
|
-
* threshold: 3
|
|
22
|
-
* cooldown: 30000
|
|
23
|
-
* maxLength: 200
|
|
24
|
-
* ```
|
|
25
|
-
*/
|
|
26
|
-
import { formatCompact, MessageCommand, Schema, usePlugin } from 'zhin.js';
|
|
27
|
-
|
|
28
|
-
const plugin = usePlugin();
|
|
29
|
-
const { logger, addCommand, onDispose, declareConfig, root } = plugin;
|
|
30
|
-
|
|
31
|
-
const config = declareConfig("repeater", Schema.object({
|
|
32
|
-
threshold: Schema.number().default(3).min(2).max(10).description("触发复读的最少人数"),
|
|
33
|
-
cooldown: Schema.number().default(30_000).min(5000).max(300_000).description("同一群冷却时间 (ms)"),
|
|
34
|
-
maxLength: Schema.number().default(200).min(10).max(1000).description("消息长度上限"),
|
|
35
|
-
}));
|
|
36
|
-
|
|
37
|
-
interface RepeatState {
|
|
38
|
-
content: string;
|
|
39
|
-
users: Set<string>;
|
|
40
|
-
repeated: boolean;
|
|
41
|
-
lastTime: number;
|
|
42
|
-
}
|
|
43
|
-
|
|
44
|
-
const groupStates = new Map<string, RepeatState>();
|
|
45
|
-
|
|
46
|
-
const cooldownSet = new Map<string, number>();
|
|
47
|
-
|
|
48
|
-
function getGroupId(message: any): string | null {
|
|
49
|
-
if (message.type !== "group") return null;
|
|
50
|
-
return String(message.$group?.id || message.$target?.id || "");
|
|
51
|
-
}
|
|
52
|
-
|
|
53
|
-
function getSenderId(message: any): string {
|
|
54
|
-
return String(message.$sender?.id || "");
|
|
55
|
-
}
|
|
56
|
-
|
|
57
|
-
let totalRepeats = 0;
|
|
58
|
-
|
|
59
|
-
root.addMiddleware(async function repeaterMiddleware(message: any, next: () => Promise<void>) {
|
|
60
|
-
const groupId = getGroupId(message);
|
|
61
|
-
if (!groupId) return next();
|
|
62
|
-
|
|
63
|
-
const content = (message.$raw || "").trim();
|
|
64
|
-
if (!content || content.length > config.maxLength) return next();
|
|
65
|
-
|
|
66
|
-
const senderId = getSenderId(message);
|
|
67
|
-
if (!senderId) return next();
|
|
68
|
-
|
|
69
|
-
const state = groupStates.get(groupId);
|
|
70
|
-
|
|
71
|
-
if (state && state.content === content) {
|
|
72
|
-
if (state.users.has(senderId)) return next();
|
|
73
|
-
|
|
74
|
-
state.users.add(senderId);
|
|
75
|
-
state.lastTime = Date.now();
|
|
76
|
-
|
|
77
|
-
if (state.users.size >= config.threshold && !state.repeated) {
|
|
78
|
-
const lastCd = cooldownSet.get(groupId);
|
|
79
|
-
if (lastCd && Date.now() - lastCd < config.cooldown) {
|
|
80
|
-
return next();
|
|
81
|
-
}
|
|
82
|
-
|
|
83
|
-
state.repeated = true;
|
|
84
|
-
cooldownSet.set(groupId, Date.now());
|
|
85
|
-
totalRepeats++;
|
|
86
|
-
|
|
87
|
-
await message.$reply(content);
|
|
88
|
-
return;
|
|
89
|
-
}
|
|
90
|
-
} else {
|
|
91
|
-
groupStates.set(groupId, {
|
|
92
|
-
content,
|
|
93
|
-
users: new Set([senderId]),
|
|
94
|
-
repeated: false,
|
|
95
|
-
lastTime: Date.now(),
|
|
96
|
-
});
|
|
97
|
-
}
|
|
98
|
-
|
|
99
|
-
return next();
|
|
100
|
-
});
|
|
101
|
-
|
|
102
|
-
const staleCleanup = setInterval(() => {
|
|
103
|
-
const now = Date.now();
|
|
104
|
-
const expiry = 5 * 60_000;
|
|
105
|
-
for (const [key, state] of groupStates) {
|
|
106
|
-
if (now - state.lastTime > expiry) groupStates.delete(key);
|
|
107
|
-
}
|
|
108
|
-
for (const [key, time] of cooldownSet) {
|
|
109
|
-
if (now - time > config.cooldown * 2) cooldownSet.delete(key);
|
|
110
|
-
}
|
|
111
|
-
}, 120_000);
|
|
112
|
-
|
|
113
|
-
onDispose(() => {
|
|
114
|
-
clearInterval(staleCleanup);
|
|
115
|
-
groupStates.clear();
|
|
116
|
-
cooldownSet.clear();
|
|
117
|
-
});
|
|
118
|
-
|
|
119
|
-
addCommand(
|
|
120
|
-
new MessageCommand("repeater-status")
|
|
121
|
-
.desc("复读机状态", "查看复读机的运行状态")
|
|
122
|
-
.action(async () => {
|
|
123
|
-
const activeGroups = groupStates.size;
|
|
124
|
-
const lines = [
|
|
125
|
-
"复读机状态",
|
|
126
|
-
`监控群数: ${activeGroups}`,
|
|
127
|
-
`触发阈值: ${config.threshold} 人`,
|
|
128
|
-
`冷却时间: ${config.cooldown / 1000}s`,
|
|
129
|
-
`消息长度上限: ${config.maxLength}`,
|
|
130
|
-
`累计复读: ${totalRepeats} 次`,
|
|
131
|
-
];
|
|
132
|
-
return lines.join("\n");
|
|
133
|
-
}),
|
|
134
|
-
);
|
|
135
|
-
|
|
136
|
-
logger.info(formatCompact( { op: "load", threshold: config.threshold, cooldown_s: config.cooldown / 1000 }));
|
|
1
|
+
export {
|
|
2
|
+
DEFAULT_REPEATER_CONFIG,
|
|
3
|
+
RepeaterEngine,
|
|
4
|
+
getRepeaterEngine,
|
|
5
|
+
resetRepeaterEngine,
|
|
6
|
+
resolveGroupId,
|
|
7
|
+
resolveRepeaterConfig,
|
|
8
|
+
} from './engine.js';
|
|
9
|
+
export type { RepeaterConfig, RepeaterInboundFields, RepeaterTickResult } from './engine.js';
|
|
File without changes
|