mioku-plugin-admin 2.2.2 → 2.3.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/README.md CHANGED
@@ -76,3 +76,8 @@ Mioku 的管理插件,提供事件通知与 Bot 管理指令。
76
76
  | 设管理 | `/设管理 @人` | 设置群管理员(需 Bot 为群主) |
77
77
  | /全体禁言 | `/全体禁言` | 开启全体禁言 |
78
78
  | /全体解禁 | `/全体解禁` | 关闭全体禁言 |
79
+ | /开启验证 | `/开启验证` 或 `#开启验证` | 开启本群入群验证(需 Bot 为群主或管理员) |
80
+ | /关闭验证 | `/关闭验证` 或 `#关闭验证` | 关闭本群入群验证 |
81
+ | /切换验证模式 | `/切换验证模式 回应/数字/手性碳` | 切换验证模式,默认回应 |
82
+ | /绕过验证 | `/绕过验证 @新成员` | 跳过该成员验证直接走欢迎流程 |
83
+ | /重新验证 | `/重新验证 @成员` | 让指定成员重新进行入群验证(不能针对群主/管理/主人) |
@@ -0,0 +1,253 @@
1
+ import type { MiokiContext } from "mioki";
2
+ import { getAtUserId, getMemberRole } from "../config";
3
+ import {
4
+ getGroupVerifyConfig,
5
+ normalizeVerifyMode,
6
+ upsertGroupVerifyConfig,
7
+ type VerifyConfig,
8
+ } from "../verify/config";
9
+ import { replyAdminErrorNotice } from "./notice";
10
+ import type { VerifyController } from "../verify/types";
11
+
12
+ export interface VerifyCommandOptions {
13
+ ctx: MiokiContext;
14
+ getVerifyConfig: () => VerifyConfig;
15
+ setVerifyConfig: (next: VerifyConfig) => Promise<void>;
16
+ verifyController: VerifyController;
17
+ }
18
+
19
+ const VERIFY_MODE_LABELS: Record<string, string> = {
20
+ reaction: "回应",
21
+ number: "数字",
22
+ chiral: "手性碳",
23
+ };
24
+
25
+ export function registerVerifyCommands(options: VerifyCommandOptions) {
26
+ const { ctx, getVerifyConfig, setVerifyConfig, verifyController } = options;
27
+
28
+ ctx.handle("message", async (event: any) => {
29
+ const text = ctx.text(event)?.trim();
30
+ if (!text) return;
31
+ if (event.user_id === event.self_id) return;
32
+
33
+ if (event.message_type !== "group") return;
34
+ const groupId = Number(event.group_id || 0);
35
+ if (!groupId) return;
36
+
37
+ const isVerifyCommand =
38
+ text === "/开启验证" ||
39
+ text === "#开启验证" ||
40
+ text === "/关闭验证" ||
41
+ text === "#关闭验证" ||
42
+ text.startsWith("/切换验证模式") ||
43
+ text.startsWith("#切换验证模式") ||
44
+ text.startsWith("/绕过验证") ||
45
+ text.startsWith("#绕过验证") ||
46
+ text.startsWith("/重新验证") ||
47
+ text.startsWith("#重新验证");
48
+
49
+ try {
50
+ const selfId = event.self_id;
51
+ const bot = ctx.pickBot(selfId);
52
+ if (!bot) return;
53
+
54
+ const isMaster = ctx.isOwner?.(event) ?? false;
55
+ const senderRole = await getMemberRole(bot, groupId, event.user_id);
56
+ const hasAdminPermission =
57
+ isMaster || senderRole === "owner" || senderRole === "admin";
58
+
59
+ const ensureAdminPermission = async (instruction?: string) => {
60
+ if (hasAdminPermission) return true;
61
+ await replyAdminErrorNotice({
62
+ ctx,
63
+ event,
64
+ instruction:
65
+ instruction ||
66
+ "用户想使用入群验证相关指令,但不是群主或管理员,无权限。请告诉用户需要管理权限。",
67
+ fallbackMessage: "你得是群主或管理员才行哦~",
68
+ });
69
+ return false;
70
+ };
71
+
72
+ const groupName =
73
+ String(event?.group?.group_name || "").trim() || String(groupId);
74
+
75
+ // /开启验证 | #开启验证
76
+ if (text === "/开启验证" || text === "#开启验证") {
77
+ if (!(await ensureAdminPermission())) return;
78
+
79
+ const botRole = await getMemberRole(bot, groupId, selfId);
80
+ if (botRole !== "owner" && botRole !== "admin") {
81
+ await replyAdminErrorNotice({
82
+ ctx,
83
+ event,
84
+ instruction:
85
+ "用户想开启入群验证,但Bot在群内不是群主或管理员,无法执行撤回/踢人等验证操作。请告诉用户需要先把Bot设为群主或管理员。",
86
+ fallbackMessage: "我得是群主或管理员才能开验证哦~",
87
+ });
88
+ return;
89
+ }
90
+
91
+ const current = getVerifyConfig();
92
+ const groupCfg = getGroupVerifyConfig(current, groupId);
93
+ if (groupCfg.enabled) {
94
+ await event.reply("本群已经开启验证啦~", true);
95
+ return;
96
+ }
97
+ const next = upsertGroupVerifyConfig(current, groupId, {
98
+ enabled: true,
99
+ });
100
+ await setVerifyConfig(next);
101
+ await event.reply("已开启入群验证~", true);
102
+ return;
103
+ }
104
+
105
+ // /关闭验证 | #关闭验证
106
+ if (text === "/关闭验证" || text === "#关闭验证") {
107
+ if (!(await ensureAdminPermission())) return;
108
+
109
+ const current = getVerifyConfig();
110
+ const groupCfg = getGroupVerifyConfig(current, groupId);
111
+ if (!groupCfg.enabled) {
112
+ await event.reply("本群还没开启验证哦~", true);
113
+ return;
114
+ }
115
+ const next = upsertGroupVerifyConfig(current, groupId, {
116
+ enabled: false,
117
+ });
118
+ await setVerifyConfig(next);
119
+ await event.reply("已关闭入群验证~", true);
120
+ return;
121
+ }
122
+
123
+ // /切换验证模式 回应|数字|手性碳
124
+ if (
125
+ text.startsWith("/切换验证模式") ||
126
+ text.startsWith("#切换验证模式")
127
+ ) {
128
+ if (!(await ensureAdminPermission())) return;
129
+
130
+ const arg = text
131
+ .replace(/^[/#]切换验证模式\s*/, "")
132
+ .trim() as string;
133
+ const mode = normalizeVerifyMode(arg);
134
+ if (!arg) {
135
+ await replyAdminErrorNotice({
136
+ ctx,
137
+ event,
138
+ instruction:
139
+ "用户想切换验证模式但没指定模式。请告诉用户可用模式:回应、数字、手性碳,用法:/切换验证模式 回应。",
140
+ fallbackMessage: "想切换成哪种模式呀~回应/数字/手性碳",
141
+ });
142
+ return;
143
+ }
144
+
145
+ const current = getVerifyConfig();
146
+ const next = upsertGroupVerifyConfig(current, groupId, { mode });
147
+ await setVerifyConfig(next);
148
+ await event.reply(
149
+ `验证模式已切换为:${VERIFY_MODE_LABELS[mode]}~`,
150
+ true,
151
+ );
152
+ return;
153
+ }
154
+
155
+ // /绕过验证 @新成员
156
+ if (text.startsWith("/绕过验证") || text.startsWith("#绕过验证")) {
157
+ if (!(await ensureAdminPermission())) return;
158
+
159
+ const atUser = getAtUserId(event.message);
160
+ if (!atUser) {
161
+ await replyAdminErrorNotice({
162
+ ctx,
163
+ event,
164
+ instruction:
165
+ "用户想绕过某位新成员的验证,但没有@目标成员。请提醒用户在命令后@要绕过验证的新成员。",
166
+ fallbackMessage: "要绕过谁呀~先@一下~",
167
+ });
168
+ return;
169
+ }
170
+
171
+ try {
172
+ await verifyController.bypassVerification({
173
+ selfId,
174
+ groupId,
175
+ userId: atUser,
176
+ groupName,
177
+ });
178
+ await event.reply("done");
179
+ } catch (err) {
180
+ await replyAdminErrorNotice({
181
+ ctx,
182
+ event,
183
+ instruction: `绕过验证执行失败:${String(err)},请简要说明失败并建议稍后重试。`,
184
+ fallbackMessage: `出错了,笨蛋~ ${String(err)}`,
185
+ error: err,
186
+ });
187
+ }
188
+ return;
189
+ }
190
+
191
+ // /重新验证 @某人
192
+ if (text.startsWith("/重新验证") || text.startsWith("#重新验证")) {
193
+ if (!(await ensureAdminPermission())) return;
194
+
195
+ const atUser = getAtUserId(event.message);
196
+ if (!atUser) {
197
+ await replyAdminErrorNotice({
198
+ ctx,
199
+ event,
200
+ instruction:
201
+ "用户想让某人重新进行入群验证,但没有@目标成员。请提醒用户在命令后@要重新验证的成员。",
202
+ fallbackMessage: "要重新验证谁呀~先@一下~",
203
+ });
204
+ return;
205
+ }
206
+
207
+ const isTargetMaster = ctx.isOwner?.(atUser) ?? false;
208
+ const targetRole = await getMemberRole(bot, groupId, atUser);
209
+ if (isTargetMaster || targetRole === "owner" || targetRole === "admin") {
210
+ await replyAdminErrorNotice({
211
+ ctx,
212
+ event,
213
+ instruction:
214
+ "用户想让一位群主/管理员/主人重新验证,但这些成员无需验证。请告诉用户该成员是管理/群主或主人,不能对其重新验证。",
215
+ fallbackMessage: "管理/群主或主人可不用验证哦~",
216
+ });
217
+ return;
218
+ }
219
+
220
+ try {
221
+ const started = await verifyController.restartVerification({
222
+ selfId,
223
+ groupId,
224
+ userId: atUser,
225
+ groupName,
226
+ });
227
+ if (!started) {
228
+ await event.reply("本群还没开启验证哦~", true);
229
+ }
230
+ } catch (err) {
231
+ await replyAdminErrorNotice({
232
+ ctx,
233
+ event,
234
+ instruction: `重新验证执行失败:${String(err)},请简要说明失败并建议稍后重试。`,
235
+ fallbackMessage: `出错了,笨蛋~ ${String(err)}`,
236
+ error: err,
237
+ });
238
+ }
239
+ return;
240
+ }
241
+ } catch (err) {
242
+ ctx.logger.error(`[admin verify] 未捕获异常: ${String(err)}`);
243
+ if (!isVerifyCommand) return;
244
+ await replyAdminErrorNotice({
245
+ ctx,
246
+ event,
247
+ instruction: `入群验证指令执行时发生未捕获异常:${String(err)},请简要说明失败并建议稍后重试。`,
248
+ fallbackMessage: `出错了,笨蛋~ ${String(err)}`,
249
+ error: err,
250
+ });
251
+ }
252
+ });
253
+ }
package/config.md CHANGED
@@ -39,6 +39,113 @@ fields:
39
39
  type: switch
40
40
  description: Bot被踢出群聊时通知主人
41
41
 
42
+ - key: base.welcome.enabled
43
+ label: 启用新人入群欢迎
44
+ type: switch
45
+ description: 新人(非机器人)入群时发送欢迎消息
46
+
47
+ - key: base.welcome.mode
48
+ label: 欢迎模式
49
+ type: select
50
+ options:
51
+ - value: ai
52
+ label: 使用AI生成
53
+ - value: text
54
+ label: 固定文本
55
+ description: AI 模式由 chat-runtime 模型生成;text 模式直接套用固定欢迎模板
56
+
57
+ - key: base.welcome.text
58
+ label: 固定欢迎文本
59
+ type: textarea
60
+ description: 欢迎模板,支持 {user} 和 {group} 占位符
61
+ placeholder: 欢迎新人~
62
+
63
+ - key: base.welcome.aiPrompt
64
+ label: AI欢迎额外提示词
65
+ type: textarea
66
+ description: 作为额外要求传给模型,默认留空即可
67
+ placeholder: 例如:提醒新成员查看群公告
68
+
69
+ - key: base.welcome.batchWindowMs
70
+ label: AI 欢迎聚合窗口 (毫秒)
71
+ type: number
72
+ description: 短时间内多个新成员入群时,攒齐该时长内的成员后只发一次 AI 欢迎,避免频繁调用模型。设为 0 表示关闭聚合、逐个欢迎。默认 20000。
73
+ placeholder: 20000
74
+
75
+ - key: verify.groups
76
+ label: 各群入群验证配置
77
+ type: json
78
+ description: 每个群的验证配置数组,元素形如 {"groupId":123,"enabled":true,"mode":"reaction"}。推荐通过 /开启验证、/关闭验证、/切换验证模式 指令管理,直接编辑需填写合法 JSON 数组。
79
+ placeholder: '[]'
80
+
81
+ - key: verify.reactionEmojiId
82
+ label: 回应模式表态表情ID
83
+ type: text
84
+ description: 回应验证模式下,Bot 给提示消息添加的表态表情 ID,新成员点击该表态即通过验证。默认 424。
85
+ placeholder: "424"
86
+
87
+ - key: verify.reactionDelayMs
88
+ label: 回应模式延迟 (毫秒)
89
+ type: number
90
+ description: 回应模式下新成员入群后,Bot 等待多久再 @新成员发出验证提示并添加表态。默认 3000。重新验证指令不受此延迟影响。
91
+ placeholder: 3000
92
+
93
+ - key: verify.verifyTimeoutMs
94
+ label: 验证超时 (毫秒)
95
+ type: number
96
+ description: 新成员未在此时长内完成验证则视为超时,按下方配置决定是否踢出。默认 120000(2分钟)。
97
+ placeholder: 120000
98
+
99
+ - key: verify.reactionPrompt
100
+ label: 回应模式提示语
101
+ type: textarea
102
+ description: 回应模式下 @新成员 时发送的提示文本。
103
+ placeholder: 新来的小伙伴请在2分钟内点击下方红色按钮完成验证 不听话会被移出群聊喵~
104
+
105
+ - key: verify.numberPrompt
106
+ label: 数字模式提示语
107
+ type: textarea
108
+ description: 数字模式下的提示文本,支持 {question} 占位符替换算术题。
109
+ placeholder: '新来的小伙伴请在2分钟内回答下面的题目完成验证,不听话移出群聊喵~\n请问:{question}'
110
+
111
+ - key: verify.chiralApiUrl
112
+ label: 手性碳验证 API 地址
113
+ type: text
114
+ description: 手性碳模式调用的验证服务地址,默认 https://carbon.crystelf.top。你也可以参照 https://github.com/leafLeaf9/chiral-carbon-captcha 自行部署后填写自己的地址。
115
+ placeholder: https://carbon.crystelf.top
116
+
117
+ - key: verify.chiralDifficulty
118
+ label: 手性碳验证难度
119
+ type: select
120
+ options:
121
+ - value: simple
122
+ label: 简单(显示提示)
123
+ - value: hard
124
+ label: 困难(不显示提示)
125
+ description: 简单模式题图带提示标注,困难模式不带提示。
126
+
127
+ - key: verify.chiralPrompt
128
+ label: 手性碳模式提示语
129
+ type: textarea
130
+ description: 手性碳模式下随题图发送的提示文本,支持 {count} 占位符替换答案数量。
131
+ placeholder: '新来的小伙伴请在2分钟内完成下面的手性碳验证:找出图中{count}个手性碳所在的格子,回复格子编号即可,不听话会被移出群聊喵~'
132
+
133
+ - key: verify.maxInvalidMessages
134
+ label: 最大无关消息数
135
+ type: number
136
+ description: 未验证成员发送与验证无关的消息达到该次数后踢出群聊。默认 5。
137
+ placeholder: 5
138
+
139
+ - key: verify.kickOnFail
140
+ label: 达到上限踢出
141
+ type: switch
142
+ description: 未验证成员无关消息达到上限时是否踢出群聊
143
+
144
+ - key: verify.kickOnTimeout
145
+ label: 超时踢出
146
+ type: switch
147
+ description: 新成员验证超时是否踢出群聊
148
+
42
149
  ---
43
150
 
44
151
  ```mioku-fields
@@ -50,4 +157,21 @@ keys:
50
157
  - base.notifyGroupBan
51
158
  - base.notifyGroupUnban
52
159
  - base.notifyGroupKick
160
+ - base.welcome.enabled
161
+ - base.welcome.mode
162
+ - base.welcome.text
163
+ - base.welcome.aiPrompt
164
+ - base.welcome.batchWindowMs
165
+ - verify.groups
166
+ - verify.reactionEmojiId
167
+ - verify.reactionDelayMs
168
+ - verify.verifyTimeoutMs
169
+ - verify.reactionPrompt
170
+ - verify.numberPrompt
171
+ - verify.chiralApiUrl
172
+ - verify.chiralDifficulty
173
+ - verify.chiralPrompt
174
+ - verify.maxInvalidMessages
175
+ - verify.kickOnFail
176
+ - verify.kickOnTimeout
53
177
  ```
package/config.ts CHANGED
@@ -8,6 +8,13 @@ export interface AdminConfig {
8
8
  notifyGroupBan: boolean;
9
9
  notifyGroupUnban: boolean;
10
10
  notifyGroupKick: boolean;
11
+ welcome: {
12
+ enabled: boolean;
13
+ mode: "ai" | "text";
14
+ text: string;
15
+ aiPrompt: string;
16
+ batchWindowMs: number;
17
+ };
11
18
  }
12
19
 
13
20
  export const DEFAULT_CONFIG: AdminConfig = {
@@ -18,6 +25,13 @@ export const DEFAULT_CONFIG: AdminConfig = {
18
25
  notifyGroupBan: true,
19
26
  notifyGroupUnban: true,
20
27
  notifyGroupKick: true,
28
+ welcome: {
29
+ enabled: true,
30
+ mode: "ai",
31
+ text: "欢迎新人~",
32
+ aiPrompt: "",
33
+ batchWindowMs: 20000,
34
+ },
21
35
  };
22
36
 
23
37
  export function normalizeConfig(raw: any): AdminConfig {
@@ -33,9 +47,30 @@ export function normalizeConfig(raw: any): AdminConfig {
33
47
  notifyGroupBan: raw?.notifyGroupBan ?? DEFAULT_CONFIG.notifyGroupBan,
34
48
  notifyGroupUnban: raw?.notifyGroupUnban ?? DEFAULT_CONFIG.notifyGroupUnban,
35
49
  notifyGroupKick: raw?.notifyGroupKick ?? DEFAULT_CONFIG.notifyGroupKick,
50
+ welcome: {
51
+ enabled: raw?.welcome?.enabled ?? DEFAULT_CONFIG.welcome.enabled,
52
+ mode: raw?.welcome?.mode === "text" ? "text" : "ai",
53
+ text:
54
+ typeof raw?.welcome?.text === "string"
55
+ ? raw.welcome.text
56
+ : DEFAULT_CONFIG.welcome.text,
57
+ aiPrompt:
58
+ typeof raw?.welcome?.aiPrompt === "string"
59
+ ? raw.welcome.aiPrompt
60
+ : DEFAULT_CONFIG.welcome.aiPrompt,
61
+ batchWindowMs: normalizeBatchWindowMs(raw?.welcome?.batchWindowMs),
62
+ },
36
63
  };
37
64
  }
38
65
 
66
+ function normalizeBatchWindowMs(value: unknown): number {
67
+ const num = Number(value);
68
+ if (!Number.isFinite(num) || num < 0) {
69
+ return DEFAULT_CONFIG.welcome.batchWindowMs;
70
+ }
71
+ return Math.floor(num);
72
+ }
73
+
39
74
  // 格式化秒数为可读时长
40
75
  export function formatDuration(seconds: number): string {
41
76
  if (seconds <= 0) return "0秒";
package/index.ts CHANGED
@@ -1,11 +1,18 @@
1
1
  import { definePlugin, type MiokiContext } from "mioki";
2
- import type { ConfigService } from "mioku";
2
+ import type { AIService, ConfigService } from "mioku";
3
3
  import { setPluginRuntimeState, resetPluginRuntimeState } from "mioku";
4
- import { DEFAULT_CONFIG, normalizeConfig } from "./config";
5
- import type { AdminConfig } from "./config";
4
+ import { DEFAULT_CONFIG, normalizeConfig, type AdminConfig } from "./config";
5
+ import {
6
+ DEFAULT_VERIFY_CONFIG,
7
+ normalizeVerifyConfig,
8
+ type VerifyConfig,
9
+ } from "./verify/config";
6
10
  import { registerNotificationHandlers } from "./notify";
7
11
  import { registerPersonalCommands } from "./commands/personal";
8
12
  import { registerGroupAdminCommands } from "./commands/group";
13
+ import { registerVerifyCommands } from "./commands/verify";
14
+ import { registerWelcomeHandler } from "./notify/welcome";
15
+ import { createVerifyController } from "./verify";
9
16
 
10
17
  interface RuntimeState {
11
18
  ctx?: MiokiContext;
@@ -15,12 +22,14 @@ interface RuntimeState {
15
22
  export default definePlugin({
16
23
  name: "admin",
17
24
  version: "1.0.0",
18
- description: "管理插件,提供事件通知与管理指令",
25
+ description: "管理插件,提供事件通知与群管/个人管理指令",
19
26
 
20
27
  async setup(ctx: MiokiContext) {
21
28
  const configService = ctx.services?.config as ConfigService | undefined;
29
+ const aiService = ctx.services?.ai as AIService | undefined;
22
30
 
23
31
  let config: AdminConfig = { ...DEFAULT_CONFIG };
32
+ let verifyConfig: VerifyConfig = { ...DEFAULT_VERIFY_CONFIG };
24
33
 
25
34
  if (configService) {
26
35
  await configService.registerConfig("admin", "base", DEFAULT_CONFIG);
@@ -29,15 +38,59 @@ export default definePlugin({
29
38
  configService.onConfigChange("admin", "base", (next) => {
30
39
  config = normalizeConfig(next);
31
40
  });
41
+
42
+ await configService.registerConfig(
43
+ "admin",
44
+ "verify",
45
+ DEFAULT_VERIFY_CONFIG,
46
+ );
47
+ const verifyRaw = await configService.getConfig("admin", "verify");
48
+ verifyConfig = normalizeVerifyConfig(verifyRaw);
49
+ configService.onConfigChange("admin", "verify", (next) => {
50
+ verifyConfig = normalizeVerifyConfig(next);
51
+ });
32
52
  }
33
53
 
34
54
  setPluginRuntimeState("admin", { ctx });
35
55
 
36
56
  const getConfig = () => config;
57
+ const getVerifyConfig = () => verifyConfig;
58
+ const getWelcomeEnabled = () => config.welcome.enabled;
59
+ const setVerifyConfig = async (next: VerifyConfig) => {
60
+ verifyConfig = next;
61
+ if (configService) {
62
+ await configService.updateConfig("admin", "verify", next);
63
+ }
64
+ };
65
+
66
+ const verifyController = createVerifyController({
67
+ ctx,
68
+ aiService,
69
+ getConfig,
70
+ getVerifyConfig,
71
+ getWelcomeEnabled,
72
+ setVerifyConfig,
73
+ });
37
74
 
38
75
  // 注册事件通知
39
76
  registerNotificationHandlers(ctx, getConfig);
40
77
 
78
+ // 注册新人入群欢迎(开启验证的群由 verify 接管,验证通过后再欢迎)
79
+ const disposeWelcome = registerWelcomeHandler(
80
+ ctx,
81
+ aiService,
82
+ getConfig,
83
+ (info) => verifyController.handleMemberJoin(info),
84
+ );
85
+
86
+ // 注册入群验证指令
87
+ registerVerifyCommands({
88
+ ctx,
89
+ getVerifyConfig,
90
+ setVerifyConfig,
91
+ verifyController,
92
+ });
93
+
41
94
  // 注册指令
42
95
  registerPersonalCommands(ctx);
43
96
  registerGroupAdminCommands(ctx);
@@ -45,6 +98,8 @@ export default definePlugin({
45
98
  ctx.logger.info("管理插件加载成功");
46
99
 
47
100
  return () => {
101
+ disposeWelcome();
102
+ verifyController.dispose();
48
103
  resetPluginRuntimeState("admin");
49
104
  ctx.logger.info("管理插件已卸载");
50
105
  };