@zhin.js/plugin-repeater 1.0.0 → 1.1.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/{skills/repeater/SKILL.md → agent/skills/repeater.md} +2 -2
- package/commands/repeater-status.js +9 -0
- package/commands/repeater-status.ts +13 -0
- package/lib/engine.d.ts +45 -0
- package/lib/engine.d.ts.map +1 -0
- package/lib/engine.js +135 -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.js +30 -0
- package/middlewares/repeater.ts +36 -0
- package/package.json +49 -12
- package/plugin.js +13 -0
- package/schema.json +28 -0
- package/src/engine.ts +169 -0
- package/src/index.ts +9 -136
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
name: repeater
|
|
3
3
|
description: >-
|
|
4
|
-
复读机:群内连续多人发送相同消息时
|
|
4
|
+
复读机:群内连续多人发送相同消息时 Endpoint 自动跟读。纯中间件行为,
|
|
5
5
|
由阈值和冷却配置控制,无 AI 工具。
|
|
6
6
|
keywords:
|
|
7
7
|
- repeater
|
|
@@ -18,7 +18,7 @@ tools: []
|
|
|
18
18
|
|
|
19
19
|
# 复读机
|
|
20
20
|
|
|
21
|
-
中间件自动检测群内连续相同消息,达到阈值后
|
|
21
|
+
中间件自动检测群内连续相同消息,达到阈值后 Endpoint 跟读一次。无 AI 工具可调用。
|
|
22
22
|
|
|
23
23
|
## 行为
|
|
24
24
|
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
// Generated by build-plugin-runtime-entries.mjs. Do not edit.
|
|
2
|
+
import { defineCommand } from 'zhin.js/command';
|
|
3
|
+
import { getRepeaterEngine, resolveRepeaterConfig, } from "../lib/engine.js";
|
|
4
|
+
export default defineCommand({
|
|
5
|
+
description: '查看复读机的运行状态',
|
|
6
|
+
execute({ config }) {
|
|
7
|
+
return getRepeaterEngine().statusLines(resolveRepeaterConfig(config));
|
|
8
|
+
},
|
|
9
|
+
});
|
|
@@ -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,45 @@
|
|
|
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 conversation: {
|
|
10
|
+
readonly kind: 'private' | 'group' | 'channel';
|
|
11
|
+
readonly id: string;
|
|
12
|
+
};
|
|
13
|
+
readonly content: string;
|
|
14
|
+
readonly sender?: {
|
|
15
|
+
readonly id: string;
|
|
16
|
+
readonly name?: string;
|
|
17
|
+
} | string;
|
|
18
|
+
readonly metadata?: Readonly<Record<string, unknown>>;
|
|
19
|
+
}
|
|
20
|
+
/**
|
|
21
|
+
* Best-effort group detection for Plugin Runtime `Message`.
|
|
22
|
+
* Channel type comes from `conversation.kind`(metadata.type/channelType 优先,
|
|
23
|
+
* 兼容早期适配器);无类型信息时按会话 `kind:id` 作为 group key。
|
|
24
|
+
*/
|
|
25
|
+
export declare function resolveGroupId(message: RepeaterInboundFields): string | null;
|
|
26
|
+
export type RepeaterTickResult = {
|
|
27
|
+
readonly action: 'next';
|
|
28
|
+
} | {
|
|
29
|
+
readonly action: 'repeat';
|
|
30
|
+
readonly content: string;
|
|
31
|
+
};
|
|
32
|
+
export declare class RepeaterEngine {
|
|
33
|
+
#private;
|
|
34
|
+
constructor();
|
|
35
|
+
get totalRepeats(): number;
|
|
36
|
+
get activeGroups(): number;
|
|
37
|
+
tick(message: RepeaterInboundFields, config: RepeaterConfig): RepeaterTickResult;
|
|
38
|
+
statusLines(config: RepeaterConfig): string;
|
|
39
|
+
pruneStale(now?: number, expiry?: number, cooldown?: number): void;
|
|
40
|
+
dispose(): void;
|
|
41
|
+
}
|
|
42
|
+
export declare function getRepeaterEngine(): RepeaterEngine;
|
|
43
|
+
/** Test helper — reset the process-wide singleton. */
|
|
44
|
+
export declare function resetRepeaterEngine(): void;
|
|
45
|
+
//# 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,YAAY,EAAE;QACrB,QAAQ,CAAC,IAAI,EAAE,SAAS,GAAG,OAAO,GAAG,SAAS,CAAC;QAC/C,QAAQ,CAAC,EAAE,EAAE,MAAM,CAAC;KACrB,CAAC;IACF,QAAQ,CAAC,OAAO,EAAE,MAAM,CAAC;IACzB,QAAQ,CAAC,MAAM,CAAC,EAAE;QAAE,QAAQ,CAAC,EAAE,EAAE,MAAM,CAAC;QAAC,QAAQ,CAAC,IAAI,CAAC,EAAE,MAAM,CAAA;KAAE,GAAG,MAAM,CAAC;IAC3E,QAAQ,CAAC,QAAQ,CAAC,EAAE,QAAQ,CAAC,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC,CAAC;CACvD;AAED;;;;GAIG;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;;;IAezB,IAAI,YAAY,IAAI,MAAM,CAEzB;IAED,IAAI,YAAY,IAAI,MAAM,CAEzB;IAED,IAAI,CAAC,OAAO,EAAE,qBAAqB,EAAE,MAAM,EAAE,cAAc,GAAG,kBAAkB;IAuChF,WAAW,CAAC,MAAM,EAAE,cAAc,GAAG,MAAM;IAW3C,UAAU,CAAC,GAAG,SAAa,EAAE,MAAM,SAAa,EAAE,QAAQ,SAAqB,GAAG,IAAI;IAStF,OAAO,IAAI,IAAI;CAQhB;AAID,wBAAgB,iBAAiB,IAAI,cAAc,CAGlD;AAED,sDAAsD;AACtD,wBAAgB,mBAAmB,IAAI,IAAI,CAG1C"}
|
package/lib/engine.js
ADDED
|
@@ -0,0 +1,135 @@
|
|
|
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
|
+
* Channel type comes from `conversation.kind`(metadata.type/channelType 优先,
|
|
20
|
+
* 兼容早期适配器);无类型信息时按会话 `kind:id` 作为 group key。
|
|
21
|
+
*/
|
|
22
|
+
export function resolveGroupId(message) {
|
|
23
|
+
const meta = message.metadata ?? {};
|
|
24
|
+
const type = meta.type ?? meta.channelType
|
|
25
|
+
?? (meta.channel && typeof meta.channel === 'object'
|
|
26
|
+
? meta.channel.type
|
|
27
|
+
: undefined)
|
|
28
|
+
?? message.conversation.kind;
|
|
29
|
+
if (type === 'private')
|
|
30
|
+
return null;
|
|
31
|
+
if (type === 'group' || type === 'channel') {
|
|
32
|
+
return message.conversation.id;
|
|
33
|
+
}
|
|
34
|
+
return null;
|
|
35
|
+
}
|
|
36
|
+
export class RepeaterEngine {
|
|
37
|
+
#groupStates = new Map();
|
|
38
|
+
#cooldownSet = new Map();
|
|
39
|
+
#totalRepeats = 0;
|
|
40
|
+
#cleanupTimer;
|
|
41
|
+
/** 最近一次 tick 的配置冷却,供 pruneStale 用实际配置而非默认值清理。 */
|
|
42
|
+
#lastCooldown = DEFAULT_REPEATER_CONFIG.cooldown;
|
|
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
|
+
this.#lastCooldown = config.cooldown;
|
|
57
|
+
const groupId = resolveGroupId(message);
|
|
58
|
+
if (!groupId)
|
|
59
|
+
return { action: 'next' };
|
|
60
|
+
const content = message.content.trim();
|
|
61
|
+
if (!content || content.length > config.maxLength)
|
|
62
|
+
return { action: 'next' };
|
|
63
|
+
const rawSender = message.sender;
|
|
64
|
+
const senderId = typeof rawSender === 'object' ? rawSender?.id ?? '' : String(rawSender ?? '');
|
|
65
|
+
if (!senderId)
|
|
66
|
+
return { action: 'next' };
|
|
67
|
+
const state = this.#groupStates.get(groupId);
|
|
68
|
+
if (state && state.content === content) {
|
|
69
|
+
if (state.users.has(senderId))
|
|
70
|
+
return { action: 'next' };
|
|
71
|
+
state.users.add(senderId);
|
|
72
|
+
state.lastTime = Date.now();
|
|
73
|
+
if (state.users.size >= config.threshold && !state.repeated) {
|
|
74
|
+
const lastCd = this.#cooldownSet.get(groupId);
|
|
75
|
+
if (lastCd && Date.now() - lastCd < config.cooldown) {
|
|
76
|
+
return { action: 'next' };
|
|
77
|
+
}
|
|
78
|
+
state.repeated = true;
|
|
79
|
+
this.#cooldownSet.set(groupId, Date.now());
|
|
80
|
+
this.#totalRepeats += 1;
|
|
81
|
+
return { action: 'repeat', content };
|
|
82
|
+
}
|
|
83
|
+
return { action: 'next' };
|
|
84
|
+
}
|
|
85
|
+
this.#groupStates.set(groupId, {
|
|
86
|
+
content,
|
|
87
|
+
users: new Set([senderId]),
|
|
88
|
+
repeated: false,
|
|
89
|
+
lastTime: Date.now(),
|
|
90
|
+
});
|
|
91
|
+
return { action: 'next' };
|
|
92
|
+
}
|
|
93
|
+
statusLines(config) {
|
|
94
|
+
return [
|
|
95
|
+
'复读机状态',
|
|
96
|
+
`监控群数: ${this.activeGroups}`,
|
|
97
|
+
`触发阈值: ${config.threshold} 人`,
|
|
98
|
+
`冷却时间: ${config.cooldown / 1000}s`,
|
|
99
|
+
`消息长度上限: ${config.maxLength}`,
|
|
100
|
+
`累计复读: ${this.#totalRepeats} 次`,
|
|
101
|
+
].join('\n');
|
|
102
|
+
}
|
|
103
|
+
pruneStale(now = Date.now(), expiry = 5 * 60_000, cooldown = this.#lastCooldown) {
|
|
104
|
+
for (const [key, state] of this.#groupStates) {
|
|
105
|
+
if (now - state.lastTime > expiry)
|
|
106
|
+
this.#groupStates.delete(key);
|
|
107
|
+
}
|
|
108
|
+
for (const [key, time] of this.#cooldownSet) {
|
|
109
|
+
if (now - time > cooldown * 2)
|
|
110
|
+
this.#cooldownSet.delete(key);
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
dispose() {
|
|
114
|
+
if (this.#cleanupTimer)
|
|
115
|
+
clearInterval(this.#cleanupTimer);
|
|
116
|
+
this.#cleanupTimer = undefined;
|
|
117
|
+
this.#groupStates.clear();
|
|
118
|
+
this.#cooldownSet.clear();
|
|
119
|
+
// 单例 dispose 后不得继续服役:下个 getRepeaterEngine() 必须拿到新实例。
|
|
120
|
+
if (shared === this)
|
|
121
|
+
shared = undefined;
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
let shared;
|
|
125
|
+
export function getRepeaterEngine() {
|
|
126
|
+
if (!shared)
|
|
127
|
+
shared = new RepeaterEngine();
|
|
128
|
+
return shared;
|
|
129
|
+
}
|
|
130
|
+
/** Test helper — reset the process-wide singleton. */
|
|
131
|
+
export function resetRepeaterEngine() {
|
|
132
|
+
shared?.dispose();
|
|
133
|
+
shared = undefined;
|
|
134
|
+
}
|
|
135
|
+
//# 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, addMiddleware, onDispose, declareConfig } = 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
|
-
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,30 @@
|
|
|
1
|
+
// Generated by build-plugin-runtime-entries.mjs. Do not edit.
|
|
2
|
+
import { defineMiddleware } from 'zhin.js/middleware';
|
|
3
|
+
import { getRepeaterEngine, resolveRepeaterConfig, } from "../lib/engine.js";
|
|
4
|
+
/**
|
|
5
|
+
* Runtime `Message` 是 conversation 原生:`conversation.kind/id` 直接喂给引擎
|
|
6
|
+
* (引擎契约 `RepeaterInboundFields.conversation`,私聊由引擎判 null 跳过)。
|
|
7
|
+
* Sender 为 MessageSenderRef(`{ id, name?, roles? }`),引擎只读 `id`。
|
|
8
|
+
* Reply uses `Message.$reply` (available during inbound middleware scope).
|
|
9
|
+
*/
|
|
10
|
+
export default defineMiddleware({
|
|
11
|
+
target: 'inbound',
|
|
12
|
+
async handle(context, next) {
|
|
13
|
+
const config = resolveRepeaterConfig(context.config);
|
|
14
|
+
const engine = getRepeaterEngine();
|
|
15
|
+
const result = engine.tick({
|
|
16
|
+
conversation: {
|
|
17
|
+
kind: context.input.conversation.kind,
|
|
18
|
+
id: context.input.conversation.id,
|
|
19
|
+
},
|
|
20
|
+
content: context.input.content,
|
|
21
|
+
sender: context.input.sender,
|
|
22
|
+
metadata: context.input.metadata,
|
|
23
|
+
}, config);
|
|
24
|
+
if (result.action === 'repeat') {
|
|
25
|
+
await context.input.$reply(result.content);
|
|
26
|
+
return;
|
|
27
|
+
}
|
|
28
|
+
await next();
|
|
29
|
+
},
|
|
30
|
+
});
|
|
@@ -0,0 +1,36 @@
|
|
|
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
|
+
* Runtime `Message` 是 conversation 原生:`conversation.kind/id` 直接喂给引擎
|
|
11
|
+
* (引擎契约 `RepeaterInboundFields.conversation`,私聊由引擎判 null 跳过)。
|
|
12
|
+
* Sender 为 MessageSenderRef(`{ id, name?, roles? }`),引擎只读 `id`。
|
|
13
|
+
* Reply uses `Message.$reply` (available during inbound middleware scope).
|
|
14
|
+
*/
|
|
15
|
+
export default defineMiddleware<Message, RepeaterConfig>({
|
|
16
|
+
target: 'inbound',
|
|
17
|
+
async handle(context, next) {
|
|
18
|
+
const config = resolveRepeaterConfig(context.config);
|
|
19
|
+
const engine = getRepeaterEngine();
|
|
20
|
+
const result = engine.tick({
|
|
21
|
+
conversation: {
|
|
22
|
+
kind: context.input.conversation.kind,
|
|
23
|
+
id: context.input.conversation.id,
|
|
24
|
+
},
|
|
25
|
+
content: context.input.content,
|
|
26
|
+
sender: context.input.sender,
|
|
27
|
+
metadata: context.input.metadata,
|
|
28
|
+
}, config);
|
|
29
|
+
|
|
30
|
+
if (result.action === 'repeat') {
|
|
31
|
+
await context.input.$reply(result.content);
|
|
32
|
+
return;
|
|
33
|
+
}
|
|
34
|
+
await next();
|
|
35
|
+
},
|
|
36
|
+
});
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@zhin.js/plugin-repeater",
|
|
3
|
-
"version": "1.
|
|
4
|
-
"description": "Repeater plugin for Zhin.js — auto-repeat
|
|
3
|
+
"version": "1.1.0",
|
|
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.js",
|
|
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,13 @@
|
|
|
38
40
|
"url": "https://github.com/lc-cn"
|
|
39
41
|
},
|
|
40
42
|
"license": "MIT",
|
|
41
|
-
"dependencies": {
|
|
43
|
+
"dependencies": {
|
|
44
|
+
"@zhin.js/core": "1.1.35"
|
|
45
|
+
},
|
|
42
46
|
"devDependencies": {
|
|
43
47
|
"typescript": "^6.0.3",
|
|
44
|
-
"
|
|
45
|
-
|
|
46
|
-
"peerDependencies": {
|
|
47
|
-
"zhin.js": "2.0.0"
|
|
48
|
+
"vitest": "^4.1.10",
|
|
49
|
+
"zhin.js": "1.1.0"
|
|
48
50
|
},
|
|
49
51
|
"repository": {
|
|
50
52
|
"type": "git",
|
|
@@ -55,8 +57,43 @@
|
|
|
55
57
|
"access": "public",
|
|
56
58
|
"registry": "https://registry.npmjs.org"
|
|
57
59
|
},
|
|
60
|
+
"engines": {
|
|
61
|
+
"node": "^20.19.0 || >=22.12.0"
|
|
62
|
+
},
|
|
63
|
+
"zhin": {
|
|
64
|
+
"protocol": 1,
|
|
65
|
+
"type": "plugin",
|
|
66
|
+
"entry": "./plugin.js",
|
|
67
|
+
"engine": "^1.0.0",
|
|
68
|
+
"runtime": "trusted",
|
|
69
|
+
"features": [
|
|
70
|
+
{
|
|
71
|
+
"package": "@zhin.js/command",
|
|
72
|
+
"api": "^1.0.0"
|
|
73
|
+
},
|
|
74
|
+
{
|
|
75
|
+
"package": "@zhin.js/middleware",
|
|
76
|
+
"api": "^1.0.0"
|
|
77
|
+
}
|
|
78
|
+
],
|
|
79
|
+
"plugins": []
|
|
80
|
+
},
|
|
81
|
+
"peerDependencies": {
|
|
82
|
+
"@zhin.js/command": "^1.1.0",
|
|
83
|
+
"@zhin.js/middleware": "^1.1.0",
|
|
84
|
+
"zhin.js": "^1.1.0"
|
|
85
|
+
},
|
|
86
|
+
"peerDependenciesMeta": {
|
|
87
|
+
"@zhin.js/command": {
|
|
88
|
+
"optional": true
|
|
89
|
+
},
|
|
90
|
+
"@zhin.js/middleware": {
|
|
91
|
+
"optional": true
|
|
92
|
+
}
|
|
93
|
+
},
|
|
58
94
|
"scripts": {
|
|
59
|
-
"build": "tsc
|
|
60
|
-
"clean": "rimraf lib"
|
|
95
|
+
"build": "tsc",
|
|
96
|
+
"clean": "rimraf lib",
|
|
97
|
+
"test": "NODE_OPTIONS=--experimental-strip-types vitest run --root ../../.. plugins/utils/repeater/tests"
|
|
61
98
|
}
|
|
62
99
|
}
|
package/plugin.js
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
// Generated by build-plugin-runtime-entries.mjs. Do not edit.
|
|
2
|
+
import { definePlugin } from 'zhin.js';
|
|
3
|
+
import { getRepeaterEngine } from "./lib/engine.js";
|
|
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,169 @@
|
|
|
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 conversation: {
|
|
28
|
+
readonly kind: 'private' | 'group' | 'channel';
|
|
29
|
+
readonly id: string;
|
|
30
|
+
};
|
|
31
|
+
readonly content: string;
|
|
32
|
+
readonly sender?: { readonly id: string; readonly name?: string } | string;
|
|
33
|
+
readonly metadata?: Readonly<Record<string, unknown>>;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Best-effort group detection for Plugin Runtime `Message`.
|
|
38
|
+
* Channel type comes from `conversation.kind`(metadata.type/channelType 优先,
|
|
39
|
+
* 兼容早期适配器);无类型信息时按会话 `kind:id` 作为 group key。
|
|
40
|
+
*/
|
|
41
|
+
export function resolveGroupId(message: RepeaterInboundFields): string | null {
|
|
42
|
+
const meta = message.metadata ?? {};
|
|
43
|
+
const type = meta.type ?? meta.channelType
|
|
44
|
+
?? (meta.channel && typeof meta.channel === 'object'
|
|
45
|
+
? (meta.channel as { type?: unknown }).type
|
|
46
|
+
: undefined)
|
|
47
|
+
?? message.conversation.kind;
|
|
48
|
+
if (type === 'private') return null;
|
|
49
|
+
if (type === 'group' || type === 'channel') {
|
|
50
|
+
return message.conversation.id;
|
|
51
|
+
}
|
|
52
|
+
return null;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
interface RepeatState {
|
|
56
|
+
content: string;
|
|
57
|
+
users: Set<string>;
|
|
58
|
+
repeated: boolean;
|
|
59
|
+
lastTime: number;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export type RepeaterTickResult =
|
|
63
|
+
| { readonly action: 'next' }
|
|
64
|
+
| { readonly action: 'repeat'; readonly content: string };
|
|
65
|
+
|
|
66
|
+
export class RepeaterEngine {
|
|
67
|
+
readonly #groupStates = new Map<string, RepeatState>();
|
|
68
|
+
readonly #cooldownSet = new Map<string, number>();
|
|
69
|
+
#totalRepeats = 0;
|
|
70
|
+
#cleanupTimer?: ReturnType<typeof setInterval>;
|
|
71
|
+
/** 最近一次 tick 的配置冷却,供 pruneStale 用实际配置而非默认值清理。 */
|
|
72
|
+
#lastCooldown = DEFAULT_REPEATER_CONFIG.cooldown;
|
|
73
|
+
|
|
74
|
+
constructor() {
|
|
75
|
+
this.#cleanupTimer = setInterval(() => this.pruneStale(), 120_000);
|
|
76
|
+
if (typeof this.#cleanupTimer === 'object' && 'unref' in this.#cleanupTimer) {
|
|
77
|
+
this.#cleanupTimer.unref();
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
get totalRepeats(): number {
|
|
82
|
+
return this.#totalRepeats;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
get activeGroups(): number {
|
|
86
|
+
return this.#groupStates.size;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
tick(message: RepeaterInboundFields, config: RepeaterConfig): RepeaterTickResult {
|
|
90
|
+
this.#lastCooldown = config.cooldown;
|
|
91
|
+
const groupId = resolveGroupId(message);
|
|
92
|
+
if (!groupId) return { action: 'next' };
|
|
93
|
+
|
|
94
|
+
const content = message.content.trim();
|
|
95
|
+
if (!content || content.length > config.maxLength) return { action: 'next' };
|
|
96
|
+
|
|
97
|
+
const rawSender = message.sender;
|
|
98
|
+
const senderId = typeof rawSender === 'object' ? rawSender?.id ?? '' : String(rawSender ?? '');
|
|
99
|
+
if (!senderId) return { action: 'next' };
|
|
100
|
+
|
|
101
|
+
const state = this.#groupStates.get(groupId);
|
|
102
|
+
if (state && state.content === content) {
|
|
103
|
+
if (state.users.has(senderId)) return { action: 'next' };
|
|
104
|
+
state.users.add(senderId);
|
|
105
|
+
state.lastTime = Date.now();
|
|
106
|
+
if (state.users.size >= config.threshold && !state.repeated) {
|
|
107
|
+
const lastCd = this.#cooldownSet.get(groupId);
|
|
108
|
+
if (lastCd && Date.now() - lastCd < config.cooldown) {
|
|
109
|
+
return { action: 'next' };
|
|
110
|
+
}
|
|
111
|
+
state.repeated = true;
|
|
112
|
+
this.#cooldownSet.set(groupId, Date.now());
|
|
113
|
+
this.#totalRepeats += 1;
|
|
114
|
+
return { action: 'repeat', content };
|
|
115
|
+
}
|
|
116
|
+
return { action: 'next' };
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
this.#groupStates.set(groupId, {
|
|
120
|
+
content,
|
|
121
|
+
users: new Set([senderId]),
|
|
122
|
+
repeated: false,
|
|
123
|
+
lastTime: Date.now(),
|
|
124
|
+
});
|
|
125
|
+
return { action: 'next' };
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
statusLines(config: RepeaterConfig): string {
|
|
129
|
+
return [
|
|
130
|
+
'复读机状态',
|
|
131
|
+
`监控群数: ${this.activeGroups}`,
|
|
132
|
+
`触发阈值: ${config.threshold} 人`,
|
|
133
|
+
`冷却时间: ${config.cooldown / 1000}s`,
|
|
134
|
+
`消息长度上限: ${config.maxLength}`,
|
|
135
|
+
`累计复读: ${this.#totalRepeats} 次`,
|
|
136
|
+
].join('\n');
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
pruneStale(now = Date.now(), expiry = 5 * 60_000, cooldown = this.#lastCooldown): void {
|
|
140
|
+
for (const [key, state] of this.#groupStates) {
|
|
141
|
+
if (now - state.lastTime > expiry) this.#groupStates.delete(key);
|
|
142
|
+
}
|
|
143
|
+
for (const [key, time] of this.#cooldownSet) {
|
|
144
|
+
if (now - time > cooldown * 2) this.#cooldownSet.delete(key);
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
dispose(): void {
|
|
149
|
+
if (this.#cleanupTimer) clearInterval(this.#cleanupTimer);
|
|
150
|
+
this.#cleanupTimer = undefined;
|
|
151
|
+
this.#groupStates.clear();
|
|
152
|
+
this.#cooldownSet.clear();
|
|
153
|
+
// 单例 dispose 后不得继续服役:下个 getRepeaterEngine() 必须拿到新实例。
|
|
154
|
+
if (shared === this) shared = undefined;
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
let shared: RepeaterEngine | undefined;
|
|
159
|
+
|
|
160
|
+
export function getRepeaterEngine(): RepeaterEngine {
|
|
161
|
+
if (!shared) shared = new RepeaterEngine();
|
|
162
|
+
return shared;
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
/** Test helper — reset the process-wide singleton. */
|
|
166
|
+
export function resetRepeaterEngine(): void {
|
|
167
|
+
shared?.dispose();
|
|
168
|
+
shared = undefined;
|
|
169
|
+
}
|
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, addMiddleware, onDispose, declareConfig } = 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
|
-
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';
|