koishi-plugin-temporaryban 1.0.6 → 1.0.7-beta1

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.
@@ -11,7 +11,7 @@ const info_1 = require("./info");
11
11
  function registerCommands(ctx, config, detector, mailer, userRecords, history, whitelistService) {
12
12
  (0, admin_1.registerAdminCommands)(ctx, config, mailer);
13
13
  (0, dictionary_1.registerDictionaryCommands)(ctx, config, detector);
14
- (0, whitelist_1.registerWhitelistCommands)(ctx, config, whitelistService);
14
+ (0, whitelist_1.registerWhitelistCommands)(ctx, config, whitelistService, detector);
15
15
  (0, stats_1.registerStatsCommands)(ctx, config, userRecords);
16
16
  (0, check_1.registerCheckCommands)(ctx, config, detector);
17
17
  (0, history_1.registerHistoryCommands)(ctx, config, history);
@@ -16,7 +16,7 @@ function registerInfoCommands(ctx, config, whitelistService) {
16
16
  const groupConfig = config.groups.find(g => g.groupId === session.guildId);
17
17
  if (!groupConfig)
18
18
  return session.text('commands.temporaryban.messages.group_not_configured');
19
- const whitelistCount = whitelistService.getList(session.guildId).length;
19
+ const whitelistCount = whitelistService.getWhitelist(session.guildId).length;
20
20
  const methods = groupConfig.detectionMethods.join(', ') || 'None';
21
21
  const smartVer = groupConfig.smartVerification ? 'ON' : 'OFF';
22
22
  return session.text('commands.temporaryban.messages.group_info', [
@@ -1,4 +1,5 @@
1
1
  import { Context } from 'koishi';
2
2
  import { Config } from '../config';
3
3
  import { WhitelistService } from '../services/whitelist';
4
- export declare function registerWhitelistCommands(ctx: Context, config: Config, whitelistService: WhitelistService): void;
4
+ import { DetectorService } from '../services/detector';
5
+ export declare function registerWhitelistCommands(ctx: Context, config: Config, whitelistService: WhitelistService, detector: DetectorService): void;
@@ -2,9 +2,26 @@
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.registerWhitelistCommands = registerWhitelistCommands;
4
4
  const permission_1 = require("../utils/permission");
5
- function registerWhitelistCommands(ctx, config, whitelistService) {
5
+ function registerWhitelistCommands(ctx, config, whitelistService, detector) {
6
6
  const cmd = ctx.command('temporaryban');
7
- // 5. Whitelist Add
7
+ // 10. Whitelist User List
8
+ cmd.subcommand('.whitelist.list')
9
+ .action(async ({ session }) => {
10
+ if (!session)
11
+ return;
12
+ if (!(0, permission_1.checkPermission)(session, config))
13
+ return session.text('commands.temporaryban.messages.permission_denied');
14
+ if (!session.guildId)
15
+ return session.text('commands.temporaryban.messages.group_only');
16
+ const groupConfig = config.groups.find(g => g.groupId === session.guildId);
17
+ if (!groupConfig)
18
+ return session.text('commands.temporaryban.messages.group_not_configured');
19
+ const items = whitelistService.getWhitelist(session.guildId);
20
+ if (items.length === 0)
21
+ return session.text('commands.temporaryban.messages.no_whitelist_users');
22
+ return session.text('commands.temporaryban.messages.whitelist_users_list', [items.length, items.join(', ')]);
23
+ });
24
+ // 5. Whitelist Add User
8
25
  cmd.subcommand('.whitelist.add <user:string>')
9
26
  .action(async ({ session }, user) => {
10
27
  if (!session)
@@ -23,7 +40,7 @@ function registerWhitelistCommands(ctx, config, whitelistService) {
23
40
  return session.text('commands.temporaryban.messages.already_whitelisted');
24
41
  return session.text('commands.temporaryban.messages.user_added_whitelist', [user]);
25
42
  });
26
- // 6. Whitelist Remove
43
+ // 6. Whitelist Remove User
27
44
  cmd.subcommand('.whitelist.remove <user:string>')
28
45
  .action(async ({ session }, user) => {
29
46
  if (!session)
@@ -40,4 +57,59 @@ function registerWhitelistCommands(ctx, config, whitelistService) {
40
57
  return session.text('commands.temporaryban.messages.not_in_whitelist');
41
58
  return session.text('commands.temporaryban.messages.user_removed_whitelist', [user]);
42
59
  });
60
+ // 7. Whitelist Word Add
61
+ cmd.subcommand('.whitelist.word.add <word:string>')
62
+ .action(async ({ session }, word) => {
63
+ if (!session)
64
+ return;
65
+ if (!(0, permission_1.checkPermission)(session, config))
66
+ return session.text('commands.temporaryban.messages.permission_denied');
67
+ if (!session.guildId)
68
+ return session.text('commands.temporaryban.messages.group_only');
69
+ if (!word)
70
+ return session.text('commands.temporaryban.messages.specify_word');
71
+ const groupConfig = config.groups.find(g => g.groupId === session.guildId);
72
+ if (!groupConfig)
73
+ return session.text('commands.temporaryban.messages.group_not_configured');
74
+ const success = await detector.addIgnoredWord(session.guildId, word);
75
+ if (!success)
76
+ return session.text('commands.temporaryban.messages.word_exists');
77
+ return session.text('commands.temporaryban.messages.ignored_word_added', [word]);
78
+ });
79
+ // 8. Whitelist Word Remove
80
+ cmd.subcommand('.whitelist.word.remove <word:string>')
81
+ .action(async ({ session }, word) => {
82
+ if (!session)
83
+ return;
84
+ if (!(0, permission_1.checkPermission)(session, config))
85
+ return session.text('commands.temporaryban.messages.permission_denied');
86
+ if (!session.guildId)
87
+ return session.text('commands.temporaryban.messages.group_only');
88
+ if (!word)
89
+ return session.text('commands.temporaryban.messages.specify_word');
90
+ const groupConfig = config.groups.find(g => g.groupId === session.guildId);
91
+ if (!groupConfig)
92
+ return session.text('commands.temporaryban.messages.group_not_configured');
93
+ const success = await detector.removeIgnoredWord(session.guildId, word);
94
+ if (!success)
95
+ return session.text('commands.temporaryban.messages.word_not_found');
96
+ return session.text('commands.temporaryban.messages.ignored_word_removed', [word]);
97
+ });
98
+ // 9. Whitelist Word List
99
+ cmd.subcommand('.whitelist.word.list')
100
+ .action(async ({ session }) => {
101
+ if (!session)
102
+ return;
103
+ if (!(0, permission_1.checkPermission)(session, config))
104
+ return session.text('commands.temporaryban.messages.permission_denied');
105
+ if (!session.guildId)
106
+ return session.text('commands.temporaryban.messages.group_only');
107
+ const groupConfig = config.groups.find(g => g.groupId === session.guildId);
108
+ if (!groupConfig)
109
+ return session.text('commands.temporaryban.messages.group_not_configured');
110
+ const items = detector.getIgnoredWords(session.guildId);
111
+ if (items.length === 0)
112
+ return session.text('commands.temporaryban.messages.no_ignored_words');
113
+ return session.text('commands.temporaryban.messages.ignored_words_list', [items.length, items.join(', ')]);
114
+ });
43
115
  }
package/lib/config.d.ts CHANGED
@@ -36,6 +36,7 @@ export interface GroupConfig {
36
36
  smartVerification: boolean;
37
37
  contextMsgCount: number;
38
38
  aiThreshold?: number;
39
+ showCensoredWord?: boolean;
39
40
  localBadWordDict: string;
40
41
  whitelist: WhitelistItem[];
41
42
  triggerThreshold?: number;
@@ -66,17 +67,24 @@ export interface OpenAIConfig {
66
67
  export interface Config {
67
68
  debug: boolean;
68
69
  adminList: string[];
69
- smtp: SmtpConfig;
70
- api: ApiConfig;
71
- baidu: BaiduConfig;
72
- aliyun: AliyunConfig;
73
- tencent: TencentConfig;
74
- openai: OpenAIConfig;
70
+ useApi: boolean;
71
+ useBaidu: boolean;
72
+ useAliyun: boolean;
73
+ useTencent: boolean;
74
+ useOpenAI: boolean;
75
+ useEmail: boolean;
76
+ smtp?: SmtpConfig;
77
+ api?: ApiConfig;
78
+ baidu?: BaiduConfig;
79
+ aliyun?: AliyunConfig;
80
+ tencent?: TencentConfig;
81
+ openai?: OpenAIConfig;
75
82
  defaultMuteMinutes: number;
76
83
  defaultTriggerThreshold: number;
77
84
  defaultAiThreshold: number;
78
85
  defaultCheckProbability: number;
79
86
  checkAdmin: boolean;
87
+ defaultShowCensoredWord: boolean;
80
88
  groups: GroupConfig[];
81
89
  }
82
90
  export declare const Config: Schema<Config>;
package/lib/config.js CHANGED
@@ -2,74 +2,144 @@
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.Config = void 0;
4
4
  const koishi_1 = require("koishi");
5
- exports.Config = koishi_1.Schema.object({
6
- debug: koishi_1.Schema.boolean().description('开启全局调试日志。开启后,控制台将输出详细的消息处理流程和错误堆栈,建议仅在排查问题时启用。').default(false),
7
- adminList: koishi_1.Schema.array(String).description('全局管理员列表 (OneBot 用户ID)。在此列表中的用户可以使用 `temporaryban.report` 等高级管理指令,拥有最高权限。').role('table'),
8
- checkAdmin: koishi_1.Schema.boolean().description('是否检查机器人在群内的管理权限。开启后,如果机器人不是群主或管理员,将不执行违禁词检查。').default(true),
9
- defaultMuteMinutes: koishi_1.Schema.number().description('全局默认禁言时长 (分钟)。当群组未配置时使用。').default(10).min(0.1),
10
- defaultTriggerThreshold: koishi_1.Schema.number().description('全局默认触发阈值 (次数)。当群组未配置时使用。').default(3).min(1),
11
- defaultAiThreshold: koishi_1.Schema.number().description('全局默认 AI 判定阈值 (0.0 - 1.0)。当群组未配置时使用。').default(0.6).min(0).max(1).step(0.1),
12
- defaultCheckProbability: koishi_1.Schema.number().description('全局默认检查概率 (0.0 - 1.0)。1.0 表示检查所有消息。当群组未配置时使用。').default(1.0).min(0).max(1).step(0.1),
13
- smtp: koishi_1.Schema.object({
14
- host: koishi_1.Schema.string().description('SMTP 服务器地址 (例如 smtp.qq.com, smtp.163.com)').default('smtp.example.com'),
15
- port: koishi_1.Schema.number().description('SMTP 端口 (SSL通常为465, 非SSL通常为25)').default(465),
16
- secure: koishi_1.Schema.boolean().description('启用 SSL/TLS 加密链接。如果端口是465,通常需要开启此项。').default(true),
17
- user: koishi_1.Schema.string().description('SMTP 用户名 (通常是您的邮箱地址)').default('user@example.com'),
18
- pass: koishi_1.Schema.string().role('secret').description('SMTP 密码或授权码。注意:QQ邮箱/163邮箱等通常需要使用生成的授权码,而非登录密码。').default('password'),
19
- senderName: koishi_1.Schema.string().description('邮件发件人显示名称').default('Koishi Bot'),
20
- senderEmail: koishi_1.Schema.string().description('邮件发件人地址 (必须与SMTP用户名匹配)').default('bot@example.com'),
21
- receivers: koishi_1.Schema.array(String).description('接收违规通知的管理员邮箱列表').role('table'),
22
- summaryIntervalDays: koishi_1.Schema.number().description('邮件汇总周期 (天)。设置为 0 时,每条违规都会立即发送邮件;设置为 >0 时 (如 1),将每隔 N 天发送一次违规汇总报告。').default(1).min(0),
23
- }).description('邮件通知设置 (SMTP)'),
24
- api: koishi_1.Schema.object({
25
- apiUrl: koishi_1.Schema.string().description('在线检测 API 地址 (默认使用 ApiHz 接口)').default('https://cn.apihz.cn/api/zici/mgc.php'),
26
- apiId: koishi_1.Schema.string().description('ApiHz 开发者 ID (必填,否则无法使用 API 检测)').default(''),
27
- apiKey: koishi_1.Schema.string().role('secret').description('ApiHz 开发者 Key (必填)').default(''),
28
- }).description('在线检测设置 (ApiHz)'),
29
- openai: koishi_1.Schema.object({
30
- apiKey: koishi_1.Schema.string().role('secret').description('OpenAI/SiliconFlow API Key').default(''),
31
- baseUrl: koishi_1.Schema.string().description('API Base URL (默认为 SiliconFlow)').default('https://api.siliconflow.cn/v1'),
32
- model: koishi_1.Schema.string().description('模型 ID').default('deepseek-ai/DeepSeek-V2.5'),
33
- }).description('AI 检测设置 (OpenAI/SiliconFlow)'),
34
- baidu: koishi_1.Schema.object({
35
- apiKey: koishi_1.Schema.string().description('百度智能云 API Key').default(''),
36
- secretKey: koishi_1.Schema.string().role('secret').description('百度智能云 Secret Key').default(''),
37
- }).description('百度智能云设置'),
38
- aliyun: koishi_1.Schema.object({
39
- accessKeyId: koishi_1.Schema.string().description('阿里云 AccessKey ID').default(''),
40
- accessKeySecret: koishi_1.Schema.string().role('secret').description('阿里云 AccessKey Secret').default(''),
41
- endpoint: koishi_1.Schema.string().description('阿里云内容安全 Endpoint').default('green-cip.cn-shanghai.aliyuncs.com'),
42
- }).description('阿里云内容安全设置'),
43
- tencent: koishi_1.Schema.object({
44
- secretId: koishi_1.Schema.string().description('腾讯云 SecretId').default(''),
45
- secretKey: koishi_1.Schema.string().role('secret').description('腾讯云 SecretKey').default(''),
46
- region: koishi_1.Schema.string().description('腾讯云地域 (例如 ap-shanghai)').default('ap-shanghai'),
47
- }).description('腾讯云内容安全设置'),
48
- groups: koishi_1.Schema.array(koishi_1.Schema.object({
49
- id: koishi_1.Schema.string().hidden().default(''),
50
- groupId: koishi_1.Schema.string().description('群组 ID (群号)。机器人将监控此群内的消息。').required(),
51
- enable: koishi_1.Schema.boolean().description('是否启用对该群组的监控。关闭后插件将忽略该群的所有消息。').default(true),
52
- detectionMethods: koishi_1.Schema.array(koishi_1.Schema.union([
53
- koishi_1.Schema.const('local').description('本地词库 (数据库)'),
54
- koishi_1.Schema.const('ai').description('AI 模型检测 (OpenAI/SiliconFlow)'),
55
- koishi_1.Schema.const('api').description('在线 API (ApiHz)'),
56
- koishi_1.Schema.const('baidu').description('百度智能云'),
57
- koishi_1.Schema.const('aliyun').description('阿里云 (内容安全增强版)'),
58
- koishi_1.Schema.const('tencent').description('腾讯云 (TMS)'),
59
- ])).role('checkbox').description('启用的检测方式 (多选)。若开启多个,只要有任意一个检测到违规即视为违规 (除非开启了智能验证)。').default(['local']),
60
- smartVerification: koishi_1.Schema.boolean().description('开启智能验证 (Smart Verification)。开启后,当【本地词库】或【API】检测到违规时,不会立即惩罚,而是将该用户的最近几条聊天记录发送给 AI 进行二次确认。只有 AI 也判定违规时才执行惩罚。需确保【AI 模型检测】已配置 API Key。').default(false),
61
- contextMsgCount: koishi_1.Schema.number().description('智能验证时的上下文消息数量。仅在开启智能验证时生效。').default(3).min(1).max(10),
62
- aiThreshold: koishi_1.Schema.number().description('AI 违规判定阈值 (0.0 - 1.0)。留空则使用全局默认值。').min(0).max(1).step(0.1),
63
- checkProbability: koishi_1.Schema.number().description('消息检查概率 (0.0 - 1.0)。留空则使用全局默认值。').min(0).max(1).step(0.1),
64
- localBadWordDict: koishi_1.Schema.string()
65
- .description('【初始导入/Legacy】本地违禁词库配置。插件现已使用数据库存储词库。首次启动时,若数据库为空,将自动导入此处的词汇。之后的增删操作请使用指令 `temporaryban.add/remove`,此配置项将不再生效。')
66
- .default(''),
67
- whitelist: koishi_1.Schema.array(koishi_1.Schema.object({
68
- userId: koishi_1.Schema.string().description('用户 ID (QQ号)')
69
- })).description('白名单用户列表。列表中的用户触发违禁词时不会受到惩罚。注:群管理员和群主会自动获得白名单豁免,无需在此手动添加。').role('table'),
70
- triggerThreshold: koishi_1.Schema.number().description('触发禁言的累计违规次数。留空则使用全局默认值。').min(1),
71
- triggerWindowMinutes: koishi_1.Schema.number().description('违规计数的时间窗口 (分钟)。在此时间内累计的违规次数达到阈值即触发禁言。超过此时间窗口后,计数将重置。').default(5).min(0.1),
72
- muteMinutes: koishi_1.Schema.number().description('禁言时长 (分钟)。留空则使用全局默认值。').min(0.1),
73
- detailedLog: koishi_1.Schema.boolean().description('开启此群组的详细日志。用于调试特定群组的检测逻辑。').default(false),
74
- }).description('群组配置')).description('监控群组列表').role('list').default([])
75
- }).description('违禁词检测插件配置');
5
+ exports.Config = koishi_1.Schema.intersect([
6
+ koishi_1.Schema.object({
7
+ debug: koishi_1.Schema.boolean().description('开启全局调试日志。').default(false),
8
+ adminList: koishi_1.Schema.array(String).description('全局管理员列表 (OneBot 用户ID)。').role('table'),
9
+ checkAdmin: koishi_1.Schema.boolean().description('是否检查机器人在群内的管理权限。').default(true),
10
+ defaultMuteMinutes: koishi_1.Schema.number().description('全局默认禁言时长 (分钟)').default(10).min(0.1),
11
+ defaultTriggerThreshold: koishi_1.Schema.number().description('全局默认触发阈值 (次数)').default(3).min(1),
12
+ defaultAiThreshold: koishi_1.Schema.number().description('全局默认 AI 判定阈值 (0.0 - 1.0)。').default(0.6).min(0).max(1).step(0.1),
13
+ defaultCheckProbability: koishi_1.Schema.number().description('全局默认检查概率 (0.0 - 1.0)。').default(1.0).min(0).max(1).step(0.1),
14
+ defaultShowCensoredWord: koishi_1.Schema.boolean().description('全局默认是否在警告中显示触发的违禁词。').default(true),
15
+ }).description('基础设置'),
16
+ koishi_1.Schema.intersect([
17
+ koishi_1.Schema.object({
18
+ useEmail: koishi_1.Schema.boolean().description('启用邮件通知系统').default(false),
19
+ }).description('邮件通知开关'),
20
+ koishi_1.Schema.union([
21
+ koishi_1.Schema.object({
22
+ useEmail: koishi_1.Schema.const(true).required(),
23
+ smtp: koishi_1.Schema.object({
24
+ host: koishi_1.Schema.string().description('SMTP 服务器地址').default('smtp.example.com'),
25
+ port: koishi_1.Schema.number().description('SMTP 端口').default(465),
26
+ secure: koishi_1.Schema.boolean().description('启用 SSL/TLS 加密链接').default(true),
27
+ user: koishi_1.Schema.string().description('SMTP 用户名').default('user@example.com'),
28
+ pass: koishi_1.Schema.string().role('secret').description('SMTP 密码或授权码').default('password'),
29
+ senderName: koishi_1.Schema.string().description('邮件发件人显示名称').default('Koishi Bot'),
30
+ senderEmail: koishi_1.Schema.string().description('邮件发件人地址').default('bot@example.com'),
31
+ receivers: koishi_1.Schema.array(String).description('接收违规通知的管理员邮箱列表').role('table'),
32
+ summaryIntervalDays: koishi_1.Schema.number().description('邮件汇总周期 (天)').default(1).min(0),
33
+ }).description('邮件通知设置 (SMTP)'),
34
+ }),
35
+ koishi_1.Schema.object({}),
36
+ ]),
37
+ ]),
38
+ koishi_1.Schema.intersect([
39
+ koishi_1.Schema.object({
40
+ useApi: koishi_1.Schema.boolean().description('启用在线 API 检测 (ApiHz)').default(false),
41
+ }).description('在线 API 开关'),
42
+ koishi_1.Schema.union([
43
+ koishi_1.Schema.object({
44
+ useApi: koishi_1.Schema.const(true).required(),
45
+ api: koishi_1.Schema.object({
46
+ apiUrl: koishi_1.Schema.string().description('在线检测 API 地址').default('https://cn.apihz.cn/api/zici/mgc.php'),
47
+ apiId: koishi_1.Schema.string().description('ApiHz 开发者 ID').default(''),
48
+ apiKey: koishi_1.Schema.string().role('secret').description('ApiHz 开发者 Key').default(''),
49
+ }).description('在线检测设置 (ApiHz)'),
50
+ }),
51
+ koishi_1.Schema.object({}),
52
+ ]),
53
+ ]),
54
+ koishi_1.Schema.intersect([
55
+ koishi_1.Schema.object({
56
+ useOpenAI: koishi_1.Schema.boolean().description('启用 AI 模型检测 (OpenAI/SiliconFlow)').default(false),
57
+ }).description('AI 检测开关'),
58
+ koishi_1.Schema.union([
59
+ koishi_1.Schema.object({
60
+ useOpenAI: koishi_1.Schema.const(true).required(),
61
+ openai: koishi_1.Schema.object({
62
+ apiKey: koishi_1.Schema.string().role('secret').description('API Key').default(''),
63
+ baseUrl: koishi_1.Schema.string().description('API Base URL').default('https://api.siliconflow.cn/v1'),
64
+ model: koishi_1.Schema.string().description('模型 ID').default('deepseek-ai/DeepSeek-V2.5'),
65
+ }).description('AI 检测设置'),
66
+ }),
67
+ koishi_1.Schema.object({}),
68
+ ]),
69
+ ]),
70
+ koishi_1.Schema.intersect([
71
+ koishi_1.Schema.object({
72
+ useBaidu: koishi_1.Schema.boolean().description('启用百度智能云检测').default(false),
73
+ }).description('百度智能云开关'),
74
+ koishi_1.Schema.union([
75
+ koishi_1.Schema.object({
76
+ useBaidu: koishi_1.Schema.const(true).required(),
77
+ baidu: koishi_1.Schema.object({
78
+ apiKey: koishi_1.Schema.string().description('API Key').default(''),
79
+ secretKey: koishi_1.Schema.string().role('secret').description('Secret Key').default(''),
80
+ }).description('百度智能云设置'),
81
+ }),
82
+ koishi_1.Schema.object({}),
83
+ ]),
84
+ ]),
85
+ koishi_1.Schema.intersect([
86
+ koishi_1.Schema.object({
87
+ useAliyun: koishi_1.Schema.boolean().description('启用阿里云内容安全检测').default(false),
88
+ }).description('阿里云开关'),
89
+ koishi_1.Schema.union([
90
+ koishi_1.Schema.object({
91
+ useAliyun: koishi_1.Schema.const(true).required(),
92
+ aliyun: koishi_1.Schema.object({
93
+ accessKeyId: koishi_1.Schema.string().description('AccessKey ID').default(''),
94
+ accessKeySecret: koishi_1.Schema.string().role('secret').description('AccessKey Secret').default(''),
95
+ endpoint: koishi_1.Schema.string().description('Endpoint').default('green-cip.cn-shanghai.aliyuncs.com'),
96
+ }).description('阿里云设置'),
97
+ }),
98
+ koishi_1.Schema.object({}),
99
+ ]),
100
+ ]),
101
+ koishi_1.Schema.intersect([
102
+ koishi_1.Schema.object({
103
+ useTencent: koishi_1.Schema.boolean().description('启用腾讯云检测').default(false),
104
+ }).description('腾讯云开关'),
105
+ koishi_1.Schema.union([
106
+ koishi_1.Schema.object({
107
+ useTencent: koishi_1.Schema.const(true).required(),
108
+ tencent: koishi_1.Schema.object({
109
+ secretId: koishi_1.Schema.string().description('SecretId').default(''),
110
+ secretKey: koishi_1.Schema.string().role('secret').description('SecretKey').default(''),
111
+ region: koishi_1.Schema.string().description('Region').default('ap-shanghai'),
112
+ }).description('腾讯云设置'),
113
+ }),
114
+ koishi_1.Schema.object({}),
115
+ ]),
116
+ ]),
117
+ koishi_1.Schema.object({
118
+ groups: koishi_1.Schema.array(koishi_1.Schema.object({
119
+ id: koishi_1.Schema.string().hidden().default(''),
120
+ groupId: koishi_1.Schema.string().description('群组 ID (群号)').required(),
121
+ enable: koishi_1.Schema.boolean().description('是否启用监控').default(true),
122
+ detectionMethods: koishi_1.Schema.array(koishi_1.Schema.union([
123
+ koishi_1.Schema.const('local').description('本地词库 (数据库)'),
124
+ koishi_1.Schema.const('ai').description('AI 模型检测'),
125
+ koishi_1.Schema.const('api').description('在线 API'),
126
+ koishi_1.Schema.const('baidu').description('百度智能云'),
127
+ koishi_1.Schema.const('aliyun').description('阿里云'),
128
+ koishi_1.Schema.const('tencent').description('腾讯云'),
129
+ ])).role('checkbox').description('启用的检测方式').default(['local']),
130
+ smartVerification: koishi_1.Schema.boolean().description('开启智能验证').default(false),
131
+ contextMsgCount: koishi_1.Schema.number().description('智能验证上下文数量').default(3).min(1).max(10),
132
+ aiThreshold: koishi_1.Schema.number().description('AI 判定阈值 (留空用默认)').min(0).max(1).step(0.1),
133
+ checkProbability: koishi_1.Schema.number().description('检查概率 (留空用默认)').min(0).max(1).step(0.1),
134
+ showCensoredWord: koishi_1.Schema.boolean().description('是否显示触发的违禁词 (留空用默认)'),
135
+ localBadWordDict: koishi_1.Schema.string().description('【Legacy】本地违禁词库 (初始导入)').default(''),
136
+ whitelist: koishi_1.Schema.array(koishi_1.Schema.object({
137
+ userId: koishi_1.Schema.string().description('用户 ID')
138
+ })).description('白名单用户列表 (管理员自动豁免)').role('table'),
139
+ triggerThreshold: koishi_1.Schema.number().description('触发禁言阈值 (留空用默认)').min(1),
140
+ triggerWindowMinutes: koishi_1.Schema.number().description('违规计数窗口 (分钟)').default(5).min(0.1),
141
+ muteMinutes: koishi_1.Schema.number().description('禁言时长 (分钟, 留空用默认)').min(0.1),
142
+ detailedLog: koishi_1.Schema.boolean().description('开启详细日志').default(false),
143
+ }).description('群组配置')).description('监控群组列表').role('list').default([])
144
+ }).description('群组监控设置')
145
+ ]);
package/lib/index.d.ts CHANGED
@@ -4,11 +4,27 @@ import { WhitelistTable } from './services/whitelist';
4
4
  export * from './config';
5
5
  export declare const name = "koishi-plugin-temporaryban";
6
6
  export declare const inject: string[];
7
+ export interface IgnoredWordTable {
8
+ id: number;
9
+ groupId: string;
10
+ word: string;
11
+ createdAt: Date;
12
+ }
13
+ export interface ViolationTable {
14
+ id: number;
15
+ userId: string;
16
+ groupId: string;
17
+ words: string[];
18
+ content: string;
19
+ timestamp: Date;
20
+ }
7
21
  declare module 'koishi' {
8
22
  interface Tables {
9
23
  temporaryban_badwords: BadWordTable;
10
24
  temporaryban_message_history: MessageHistoryTable;
11
25
  temporaryban_whitelist: WhitelistTable;
26
+ temporaryban_ignored_words: IgnoredWordTable;
27
+ temporaryban_violations: ViolationTable;
12
28
  }
13
29
  }
14
30
  export interface MessageHistoryTable {
package/lib/index.js CHANGED
@@ -29,7 +29,7 @@ const en_US_1 = __importDefault(require("./locales/en-US"));
29
29
  const commands_1 = require("./commands");
30
30
  __exportStar(require("./config"), exports);
31
31
  exports.name = 'koishi-plugin-temporaryban';
32
- exports.inject = ['database'];
32
+ exports.inject = ['database', 'http'];
33
33
  const logger = new koishi_1.Logger('temporaryban');
34
34
  function apply(ctx, config) {
35
35
  // --- Services ---
@@ -68,6 +68,24 @@ function apply(ctx, config) {
68
68
  }, {
69
69
  autoInc: true,
70
70
  });
71
+ ctx.model.extend('temporaryban_ignored_words', {
72
+ id: 'unsigned',
73
+ groupId: 'string',
74
+ word: 'string',
75
+ createdAt: 'timestamp',
76
+ }, {
77
+ autoInc: true,
78
+ });
79
+ ctx.model.extend('temporaryban_violations', {
80
+ id: 'unsigned',
81
+ userId: 'string',
82
+ groupId: 'string',
83
+ words: 'list',
84
+ content: 'text',
85
+ timestamp: 'timestamp',
86
+ }, {
87
+ autoInc: true,
88
+ });
71
89
  // --- Lifecycle ---
72
90
  ctx.on('ready', async () => {
73
91
  logger.info('Plugin initialized.');
@@ -255,24 +273,11 @@ function apply(ctx, config) {
255
273
  logger.error(`[Recall Failed] Group: ${groupId}, Msg: ${messageId}, Error: ${err}`);
256
274
  }
257
275
  }
258
- // 2. Send Censored Text (Modified format)
259
- if (result.censoredText) {
260
- try {
261
- // Format: "您触发了违禁词检测:违禁词:(censored_text)"
262
- const warningMsg = session.text('commands.temporaryban.messages.violation_detected', [result.censoredText]);
263
- await session.send(koishi_1.h.at(userId) + ' ' + warningMsg);
264
- }
265
- catch (err) {
266
- logger.error(`[Send Failed] Group: ${groupId}, Error: ${err}`);
267
- }
268
- }
269
- // 3. Record Violation (Email Notification Logic Changed)
270
- await mailer.recordViolation(userId, groupId, words, session.content);
271
- // 4. Track & Punish
276
+ // 2. Track & Punish Calculation
272
277
  const recordKey = `${groupId}-${userId}`;
273
- let record = userRecords.get(recordKey);
274
- // Use effectiveConfig
278
+ // 'now' variable is already declared above at line 262
275
279
  const triggerWindow = (groupConfig.triggerWindowMinutes ?? 5) * koishi_1.Time.minute;
280
+ let record = userRecords.get(recordKey);
276
281
  if (!record) {
277
282
  record = { count: 1, firstTime: now };
278
283
  userRecords.set(recordKey, record);
@@ -288,9 +293,35 @@ function apply(ctx, config) {
288
293
  record.count++;
289
294
  }
290
295
  }
291
- logger.info(`[COUNT] [Group: ${groupId}] [User: ${userId}] ${record.count}/${effectiveConfig.triggerThreshold}`);
292
- if (record.count >= effectiveConfig.triggerThreshold) {
293
- const muteSeconds = Math.floor(effectiveConfig.muteMinutes * 60);
296
+ const currentCount = record.count;
297
+ const maxCount = effectiveConfig.triggerThreshold;
298
+ const remaining = Math.max(0, maxCount - currentCount);
299
+ const muteMinutes = effectiveConfig.muteMinutes;
300
+ logger.info(`[COUNT] [Group: ${groupId}] [User: ${userId}] ${currentCount}/${maxCount}`);
301
+ // 3. Send Warning
302
+ if (result.censoredText) {
303
+ try {
304
+ const showWord = groupConfig.showCensoredWord ?? config.defaultShowCensoredWord ?? true;
305
+ const wordDisplay = showWord ? result.censoredText : '***';
306
+ // Format: "You triggered a forbidden word check: {0}\nCurrent violations: {1}/{2}\n{3} more violations will result in a {4} min mute."
307
+ const warningMsg = session.text('commands.temporaryban.messages.violation_detail', [
308
+ wordDisplay,
309
+ currentCount,
310
+ maxCount,
311
+ remaining,
312
+ muteMinutes
313
+ ]);
314
+ await session.send(koishi_1.h.at(userId) + ' ' + warningMsg);
315
+ }
316
+ catch (err) {
317
+ logger.error(`[Send Failed] Group: ${groupId}, Error: ${err}`);
318
+ }
319
+ }
320
+ // 4. Record Violation (Mail)
321
+ await mailer.recordViolation(userId, groupId, words, session.content);
322
+ // 5. Mute Execution
323
+ if (currentCount >= maxCount) {
324
+ const muteSeconds = Math.floor(muteMinutes * 60);
294
325
  try {
295
326
  if (session.bot.muteGuildMember) {
296
327
  // Ensure parameters are correct: guildId, userId, milliseconds
@@ -34,11 +34,25 @@ declare const _default: {
34
34
  history_list: string;
35
35
  cleanup_info: string;
36
36
  group_info: string;
37
+ violation_detail: string;
38
+ ignored_word_added: string;
39
+ ignored_word_removed: string;
40
+ no_ignored_words: string;
41
+ ignored_words_list: string;
37
42
  };
38
43
  };
39
44
  'temporaryban.info': {
40
45
  description: string;
41
46
  };
47
+ 'temporaryban.whitelist.word.add': {
48
+ description: string;
49
+ };
50
+ 'temporaryban.whitelist.word.remove': {
51
+ description: string;
52
+ };
53
+ 'temporaryban.whitelist.word.list': {
54
+ description: string;
55
+ };
42
56
  'temporaryban.report': {
43
57
  description: string;
44
58
  };
@@ -35,12 +35,26 @@ exports.default = {
35
35
  no_history: 'No recent history for user {0}.',
36
36
  history_list: 'Recent history for user {0}:\n{1}',
37
37
  cleanup_info: 'Cache cleanup is mainly handled automatically by the system.',
38
- group_info: 'Group Info ({0}):\nStatus: {1}\nMethods: {2}\nSmart Verify: {3}\nThreshold: {4}\nMute: {5}min\nWhitelist: {6}'
38
+ group_info: 'Group Info ({0}):\nStatus: {1}\nMethods: {2}\nSmart Verify: {3}\nThreshold: {4}\nMute: {5}min\nWhitelist: {6}',
39
+ violation_detail: 'You triggered a forbidden word check: {0}\nCurrent violations: {1}/{2}\n{3} more violations will result in a {4} min mute.',
40
+ ignored_word_added: 'Added "{0}" to ignored words list.',
41
+ ignored_word_removed: 'Removed "{0}" from ignored words list.',
42
+ no_ignored_words: 'No ignored words.',
43
+ ignored_words_list: 'Ignored words ({0}):\n{1}'
39
44
  }
40
45
  },
41
46
  'temporaryban.info': {
42
47
  description: 'View current group configuration'
43
48
  },
49
+ 'temporaryban.whitelist.word.add': {
50
+ description: 'Add ignored word to group'
51
+ },
52
+ 'temporaryban.whitelist.word.remove': {
53
+ description: 'Remove ignored word from group'
54
+ },
55
+ 'temporaryban.whitelist.word.list': {
56
+ description: 'List ignored words in group'
57
+ },
44
58
  'temporaryban.report': {
45
59
  description: 'Manually trigger violation report (Global Admin only)'
46
60
  },
@@ -34,11 +34,27 @@ declare const _default: {
34
34
  history_list: string;
35
35
  cleanup_info: string;
36
36
  group_info: string;
37
+ violation_detail: string;
38
+ ignored_word_added: string;
39
+ ignored_word_removed: string;
40
+ no_ignored_words: string;
41
+ ignored_words_list: string;
42
+ no_whitelist_users: string;
43
+ whitelist_users_list: string;
37
44
  };
38
45
  };
39
46
  'temporaryban.info': {
40
47
  description: string;
41
48
  };
49
+ 'temporaryban.whitelist.word.add': {
50
+ description: string;
51
+ };
52
+ 'temporaryban.whitelist.word.remove': {
53
+ description: string;
54
+ };
55
+ 'temporaryban.whitelist.word.list': {
56
+ description: string;
57
+ };
42
58
  'temporaryban.report': {
43
59
  description: string;
44
60
  };
@@ -35,12 +35,28 @@ exports.default = {
35
35
  no_history: '用户 {0} 没有最近的历史记录。',
36
36
  history_list: '用户 {0} 的最近历史记录:\n{1}',
37
37
  cleanup_info: '缓存清理主要由系统自动进行。',
38
- group_info: '群组信息 ({0}):\n状态: {1}\n检测方式: {2}\n智能验证: {3}\n触发阈值: {4}次\n禁言时长: {5}分钟\n白名单人数: {6}'
38
+ group_info: '群组信息 ({0}):\n状态: {1}\n检测方式: {2}\n智能验证: {3}\n触发阈值: {4}次\n禁言时长: {5}分钟\n白名单人数: {6}',
39
+ violation_detail: '您触发了违禁词检测:违禁词:({0})\n当前违规次数: {1}/{2}\n再违规 {3} 次将被禁言 {4} 分钟。',
40
+ ignored_word_added: '已添加 "{0}" 到本群忽略词列表。',
41
+ ignored_word_removed: '已从本群忽略词列表移除 "{0}"。',
42
+ no_ignored_words: '没有忽略词。',
43
+ ignored_words_list: '忽略词 ({0}):\n{1}',
44
+ no_whitelist_users: '白名单为空。',
45
+ whitelist_users_list: '白名单用户 ({0}):\n{1}'
39
46
  }
40
47
  },
41
48
  'temporaryban.info': {
42
49
  description: '查看当前群组的配置信息'
43
50
  },
51
+ 'temporaryban.whitelist.word.add': {
52
+ description: '添加本群忽略词'
53
+ },
54
+ 'temporaryban.whitelist.word.remove': {
55
+ description: '移除本群忽略词'
56
+ },
57
+ 'temporaryban.whitelist.word.list': {
58
+ description: '列出本群忽略词'
59
+ },
44
60
  'temporaryban.report': {
45
61
  description: '手动触发违规报告(仅限全局管理员)'
46
62
  },
@@ -11,6 +11,7 @@ export declare class DetectorService {
11
11
  private config;
12
12
  private logger;
13
13
  private localDictCache;
14
+ private ignoredWordsCache;
14
15
  private history;
15
16
  constructor(ctx: Context, config: Config, history: HistoryService);
16
17
  private initCache;
@@ -18,6 +19,10 @@ export declare class DetectorService {
18
19
  addWord(groupId: string, word: string): Promise<boolean>;
19
20
  removeWord(groupId: string, word: string): Promise<boolean>;
20
21
  getWords(groupId: string): BadWordItem[];
22
+ addIgnoredWord(groupId: string, word: string): Promise<boolean>;
23
+ removeIgnoredWord(groupId: string, word: string): Promise<boolean>;
24
+ getIgnoredWords(groupId: string): string[];
25
+ isIgnored(groupId: string, word: string): boolean;
21
26
  parseLocalDict(dictStr: string): BadWordItem[];
22
27
  reloadGroup(groupId: string): void;
23
28
  check(content: string, groupId: string, userId: string, groupConfig: any): Promise<CheckResult>;
@@ -40,6 +40,7 @@ const prompt_1 = require("../utils/prompt");
40
40
  class DetectorService {
41
41
  constructor(ctx, config, history) {
42
42
  this.localDictCache = new Map();
43
+ this.ignoredWordsCache = new Map(); // groupId -> Set<word>
43
44
  this.ctx = ctx;
44
45
  this.config = config;
45
46
  this.history = history;
@@ -84,7 +85,17 @@ class DetectorService {
84
85
  }
85
86
  }
86
87
  this.localDictCache = map;
88
+ // 3. Load Ignored Words
89
+ const allIgnored = await this.ctx.database.get('temporaryban_ignored_words', {});
90
+ this.ignoredWordsCache.clear();
91
+ for (const item of allIgnored) {
92
+ if (!this.ignoredWordsCache.has(item.groupId)) {
93
+ this.ignoredWordsCache.set(item.groupId, new Set());
94
+ }
95
+ this.ignoredWordsCache.get(item.groupId)?.add(item.word);
96
+ }
87
97
  this.logger.info(`Loaded bad words for ${map.size} groups from database.`);
98
+ this.logger.info(`Loaded ignored words for ${this.ignoredWordsCache.size} groups.`);
88
99
  }
89
100
  async addWord(groupId, word) {
90
101
  const exists = await this.ctx.database.get('temporaryban_badwords', { groupId, word });
@@ -119,6 +130,39 @@ class DetectorService {
119
130
  getWords(groupId) {
120
131
  return this.localDictCache.get(groupId) || [];
121
132
  }
133
+ async addIgnoredWord(groupId, word) {
134
+ const exists = await this.ctx.database.get('temporaryban_ignored_words', { groupId, word });
135
+ if (exists.length > 0)
136
+ return false;
137
+ await this.ctx.database.create('temporaryban_ignored_words', {
138
+ groupId,
139
+ word,
140
+ createdAt: new Date()
141
+ });
142
+ if (!this.ignoredWordsCache.has(groupId)) {
143
+ this.ignoredWordsCache.set(groupId, new Set());
144
+ }
145
+ this.ignoredWordsCache.get(groupId)?.add(word);
146
+ return true;
147
+ }
148
+ async removeIgnoredWord(groupId, word) {
149
+ const result = await this.ctx.database.remove('temporaryban_ignored_words', { groupId, word });
150
+ if (result.matched === 0)
151
+ return false;
152
+ if (this.ignoredWordsCache.has(groupId)) {
153
+ this.ignoredWordsCache.get(groupId)?.delete(word);
154
+ }
155
+ return true;
156
+ }
157
+ getIgnoredWords(groupId) {
158
+ return Array.from(this.ignoredWordsCache.get(groupId) || []);
159
+ }
160
+ isIgnored(groupId, word) {
161
+ const ignoredSet = this.ignoredWordsCache.get(groupId);
162
+ if (!ignoredSet || ignoredSet.size === 0)
163
+ return false;
164
+ return ignoredSet.has(word);
165
+ }
122
166
  // Deprecated/Helper for migration only
123
167
  parseLocalDict(dictStr) {
124
168
  const result = [];
@@ -249,7 +293,7 @@ class DetectorService {
249
293
  const aiRes = await this.checkWithAI(contextPrompt, groupConfig.aiThreshold);
250
294
  if (aiRes.detected) {
251
295
  this.logger.info(`[Smart Verification] Confirmed violation.`);
252
- return aiRes; // Return AI result (usually more accurate/detailed)
296
+ initialDetection = aiRes; // Update result with AI confirmation
253
297
  }
254
298
  else {
255
299
  this.logger.info(`[Smart Verification] False positive dismissed by AI.`);
@@ -257,11 +301,23 @@ class DetectorService {
257
301
  }
258
302
  }
259
303
  }
304
+ // 3. Ignored Words Filter (Whitelist)
305
+ if (initialDetection.detected && initialDetection.detectedWords) {
306
+ const filteredWords = initialDetection.detectedWords.filter(w => !this.isIgnored(groupId, w));
307
+ if (filteredWords.length === 0) {
308
+ if (this.config.debug && groupConfig.detailedLog) {
309
+ this.logger.debug(`[Check] Ignored: All detected words are in whitelist. (${initialDetection.detectedWords.join(', ')})`);
310
+ }
311
+ return { detected: false };
312
+ }
313
+ // Update with filtered words
314
+ initialDetection.detectedWords = filteredWords;
315
+ }
260
316
  return initialDetection;
261
317
  }
262
318
  // --- OpenAI / SiliconFlow ---
263
319
  async checkWithAI(content, threshold = 0.6) {
264
- if (!this.config.openai.apiKey) {
320
+ if (!this.config.openai?.apiKey) {
265
321
  this.logger.warn('OpenAI/SiliconFlow API Key is missing.');
266
322
  return { detected: false };
267
323
  }
@@ -315,7 +371,7 @@ class DetectorService {
315
371
  }
316
372
  // --- Baidu Cloud ---
317
373
  async checkWithBaidu(content) {
318
- if (!this.config.baidu.apiKey || !this.config.baidu.secretKey) {
374
+ if (!this.config.baidu?.apiKey || !this.config.baidu?.secretKey) {
319
375
  this.logger.warn('Baidu API Key or Secret Key is missing.');
320
376
  return { detected: false };
321
377
  }
@@ -358,11 +414,12 @@ class DetectorService {
358
414
  }
359
415
  // --- Aliyun (Green 2.0 / Enhanced) ---
360
416
  async checkWithAliyun(content) {
361
- const { accessKeyId, accessKeySecret, endpoint } = this.config.aliyun;
362
- if (!accessKeyId || !accessKeySecret) {
417
+ const aliyunConfig = this.config.aliyun;
418
+ if (!aliyunConfig?.accessKeyId || !aliyunConfig?.accessKeySecret) {
363
419
  this.logger.warn('Aliyun AccessKey ID or Secret is missing.');
364
420
  return { detected: false };
365
421
  }
422
+ const { accessKeyId, accessKeySecret, endpoint } = aliyunConfig;
366
423
  // RPC Signature Implementation
367
424
  try {
368
425
  const params = {
@@ -419,11 +476,12 @@ class DetectorService {
419
476
  }
420
477
  // --- Tencent Cloud (TMS) ---
421
478
  async checkWithTencent(content) {
422
- const { secretId, secretKey, region } = this.config.tencent;
423
- if (!secretId || !secretKey) {
479
+ const tencentConfig = this.config.tencent;
480
+ if (!tencentConfig?.secretId || !tencentConfig?.secretKey) {
424
481
  this.logger.warn('Tencent SecretId or SecretKey is missing.');
425
482
  return { detected: false };
426
483
  }
484
+ const { secretId, secretKey, region } = tencentConfig;
427
485
  try {
428
486
  const endpoint = 'tms.tencentcloudapi.com';
429
487
  const service = 'tms';
@@ -453,6 +511,11 @@ class DetectorService {
453
511
  const signature = crypto.createHmac('sha256', kSigning).update(stringToSign).digest('hex');
454
512
  // 4. Authorization Header
455
513
  const authorization = `${algorithm} Credential=${secretId}/${credentialScope}, SignedHeaders=${signedHeaders}, Signature=${signature}`;
514
+ if (this.config.debug) {
515
+ this.logger.debug(`[Tencent Request] URL: https://${endpoint}`);
516
+ this.logger.debug(`[Tencent Request] Payload: ${payloadStr}`);
517
+ this.logger.debug(`[Tencent Request] Headers: X-TC-Action=${action}, X-TC-Version=${version}, Authorization=${authorization}`);
518
+ }
456
519
  const response = await this.ctx.http.post(`https://${endpoint}`, payload, {
457
520
  headers: {
458
521
  'Authorization': authorization,
@@ -484,7 +547,7 @@ class DetectorService {
484
547
  }
485
548
  }
486
549
  async checkWithApi(content) {
487
- if (!this.config.api.apiId || !this.config.api.apiKey) {
550
+ if (!this.config.api?.apiId || !this.config.api?.apiKey) {
488
551
  this.logger.warn('API ID or Key is missing. Skipping API detection.');
489
552
  return { detected: false };
490
553
  }
@@ -1,13 +1,10 @@
1
1
  import { Context } from 'koishi';
2
2
  import { Config } from '../config';
3
3
  export declare class MailerService {
4
+ private ctx;
4
5
  private logger;
5
6
  private config;
6
- private records;
7
- private storagePath;
8
7
  constructor(ctx: Context, config: Config);
9
- private loadRecords;
10
- private saveRecords;
11
8
  recordViolation(userId: string, groupId: string, words: string[], content: string): Promise<void>;
12
9
  private sendImmediate;
13
10
  sendSummaryReport(hours: number): Promise<{
@@ -36,72 +36,47 @@ Object.defineProperty(exports, "__esModule", { value: true });
36
36
  exports.MailerService = void 0;
37
37
  const koishi_1 = require("koishi");
38
38
  const nodemailer = __importStar(require("nodemailer"));
39
- const fs = __importStar(require("fs"));
40
- const path = __importStar(require("path"));
41
39
  class MailerService {
42
40
  constructor(ctx, config) {
43
- this.records = [];
41
+ this.ctx = ctx;
44
42
  this.config = config;
45
43
  this.logger = new koishi_1.Logger('temporaryban:mailer');
46
- // Setup storage for persistence (simple JSON file)
47
- this.storagePath = path.resolve(ctx.baseDir, 'data', 'temporaryban_records.json');
48
- this.loadRecords();
49
44
  // Start periodic task if summary interval is set
50
- if (this.config.smtp.summaryIntervalDays > 0) {
45
+ if (this.config.smtp?.summaryIntervalDays && this.config.smtp.summaryIntervalDays > 0) {
51
46
  // Check every hour if it's time to send (this is a simplified scheduler)
52
- // For production, a more robust scheduler might be needed, but this works for now.
53
- // We send reports based on interval from the *last send time* or just accumulate.
54
- // Since we don't store "last sent time", we'll just run a check every day?
55
- // Better: use setInterval for the configured days.
56
47
  ctx.setInterval(() => {
57
- this.sendSummaryReport(this.config.smtp.summaryIntervalDays * 24);
48
+ if (this.config.smtp) {
49
+ this.sendSummaryReport(this.config.smtp.summaryIntervalDays * 24);
50
+ }
58
51
  }, this.config.smtp.summaryIntervalDays * 24 * 60 * 60 * 1000);
59
52
  }
60
53
  }
61
- loadRecords() {
62
- try {
63
- if (fs.existsSync(this.storagePath)) {
64
- const data = fs.readFileSync(this.storagePath, 'utf-8');
65
- this.records = JSON.parse(data);
66
- }
67
- }
68
- catch (err) {
69
- this.logger.warn(`Failed to load records: ${err}`);
70
- this.records = [];
71
- }
72
- }
73
- saveRecords() {
74
- try {
75
- const dir = path.dirname(this.storagePath);
76
- if (!fs.existsSync(dir)) {
77
- fs.mkdirSync(dir, { recursive: true });
78
- }
79
- fs.writeFileSync(this.storagePath, JSON.stringify(this.records, null, 2));
80
- }
81
- catch (err) {
82
- this.logger.error(`Failed to save records: ${err}`);
83
- }
84
- }
85
54
  async recordViolation(userId, groupId, words, content) {
55
+ if (!this.config.smtp)
56
+ return;
86
57
  // If summary interval is 0, send immediately (legacy mode)
87
58
  if (this.config.smtp.summaryIntervalDays === 0) {
88
59
  await this.sendImmediate(userId, groupId, words, content);
89
60
  return;
90
61
  }
91
- // Otherwise, record it
92
- this.records.push({
93
- userId,
94
- groupId,
95
- words,
96
- content,
97
- timestamp: Date.now()
98
- });
99
- this.saveRecords();
100
- this.logger.debug(`Violation recorded for user ${userId}. Total pending: ${this.records.length}`);
62
+ // Otherwise, record it to Database
63
+ try {
64
+ await this.ctx.database.create('temporaryban_violations', {
65
+ userId,
66
+ groupId,
67
+ words,
68
+ content,
69
+ timestamp: new Date()
70
+ });
71
+ this.logger.debug(`Violation recorded for user ${userId}.`);
72
+ }
73
+ catch (err) {
74
+ this.logger.error(`Failed to record violation to DB: ${err}`);
75
+ }
101
76
  }
102
77
  // Old method for immediate sending
103
78
  async sendImmediate(userId, groupId, words, content) {
104
- if (!this.config.smtp.host || this.config.smtp.receivers.length === 0)
79
+ if (!this.config.smtp?.host || !this.config.smtp?.receivers?.length)
105
80
  return;
106
81
  const transporter = this.createTransporter();
107
82
  const html = `
@@ -150,13 +125,22 @@ class MailerService {
150
125
  }
151
126
  // New method for summary report
152
127
  async sendSummaryReport(hours) {
153
- if (!this.config.smtp.host || this.config.smtp.receivers.length === 0) {
128
+ if (!this.config.smtp?.host || !this.config.smtp?.receivers?.length) {
154
129
  return { success: false, error: 'smtp_not_configured' };
155
130
  }
156
- const now = Date.now();
157
- const cutoff = now - hours * 60 * 60 * 1000;
158
- // Filter records within the time window
159
- const targetRecords = this.records.filter(r => r.timestamp >= cutoff);
131
+ const now = new Date();
132
+ const cutoff = new Date(now.getTime() - hours * 60 * 60 * 1000);
133
+ // Filter records within the time window from Database
134
+ let targetRecords = [];
135
+ try {
136
+ targetRecords = await this.ctx.database.get('temporaryban_violations', {
137
+ timestamp: { $gte: cutoff }
138
+ });
139
+ }
140
+ catch (err) {
141
+ this.logger.error(`Failed to fetch violations from DB: ${err}`);
142
+ return { success: false, error: 'db_error' };
143
+ }
160
144
  if (targetRecords.length === 0) {
161
145
  return { success: true, count: 0 };
162
146
  }
@@ -164,7 +148,7 @@ class MailerService {
164
148
  // Generate HTML Table
165
149
  const tableRows = targetRecords.map((r, index) => `
166
150
  <tr style="border-bottom: 1px solid #eee; background-color: ${index % 2 === 0 ? '#ffffff' : '#fcfcfc'};">
167
- <td style="padding: 12px 8px; color: #666; font-size: 13px;">${new Date(r.timestamp).toLocaleString()}</td>
151
+ <td style="padding: 12px 8px; color: #666; font-size: 13px;">${r.timestamp.toLocaleString()}</td>
168
152
  <td style="padding: 12px 8px; font-weight: 500;">${r.groupId}</td>
169
153
  <td style="padding: 12px 8px;">${r.userId}</td>
170
154
  <td style="padding: 12px 8px;"><span style="background-color: #ffebee; color: #c62828; padding: 2px 6px; border-radius: 4px; font-size: 12px;">${r.words.join(', ')}</span></td>
@@ -235,16 +219,21 @@ class MailerService {
235
219
  return { success: false, error: String(err) };
236
220
  }
237
221
  }
238
- cleanupOldRecords(days) {
239
- const cutoff = Date.now() - days * 24 * 60 * 60 * 1000;
240
- const initialLen = this.records.length;
241
- this.records = this.records.filter(r => r.timestamp >= cutoff);
242
- if (this.records.length !== initialLen) {
243
- this.saveRecords();
244
- this.logger.info(`Cleaned up ${initialLen - this.records.length} old records.`);
222
+ async cleanupOldRecords(days) {
223
+ const cutoff = new Date(Date.now() - days * 24 * 60 * 60 * 1000);
224
+ try {
225
+ await this.ctx.database.remove('temporaryban_violations', {
226
+ timestamp: { $lt: cutoff }
227
+ });
228
+ }
229
+ catch (err) {
230
+ this.logger.error(`Failed to cleanup old records: ${err}`);
245
231
  }
246
232
  }
247
233
  createTransporter() {
234
+ if (!this.config.smtp) {
235
+ throw new Error('SMTP configuration is missing');
236
+ }
248
237
  return nodemailer.createTransport({
249
238
  host: this.config.smtp.host,
250
239
  port: this.config.smtp.port,
@@ -15,6 +15,7 @@ export declare class WhitelistService {
15
15
  init(): Promise<void>;
16
16
  add(groupId: string, userId: string): Promise<boolean>;
17
17
  remove(groupId: string, userId: string): Promise<boolean>;
18
+ private syncToConfig;
19
+ getWhitelist(groupId: string): string[];
18
20
  isWhitelisted(groupId: string, userId: string): boolean;
19
- getList(groupId: string): string[];
20
21
  }
@@ -56,6 +56,8 @@ class WhitelistService {
56
56
  this.cache.set(groupId, new Set());
57
57
  }
58
58
  this.cache.get(groupId)?.add(userId);
59
+ // Sync to Config
60
+ await this.syncToConfig(groupId, userId, 'add');
59
61
  return true;
60
62
  }
61
63
  async remove(groupId, userId) {
@@ -65,21 +67,39 @@ class WhitelistService {
65
67
  if (this.cache.has(groupId)) {
66
68
  this.cache.get(groupId)?.delete(userId);
67
69
  }
68
- // Note: If user is in Config whitelist, they are still effectively whitelisted unless we handle that.
69
- // Ideally, we should warn or handle this. But for now, DB removal only affects DB entries.
70
- // If the user was in Config, `isWhitelisted` will still return true because of the merge in `init`.
71
- // BUT `init` is only called at start. So runtime cache is what matters.
72
- // Actually, `init` merges config into cache. If we remove from cache here, it is removed from runtime check.
73
- // But on restart, it will come back from Config.
74
- // This is a known limitation of mixing Config and DB.
75
- // We can just proceed.
70
+ // Sync to Config
71
+ await this.syncToConfig(groupId, userId, 'remove');
76
72
  return (result.matched || 0) > 0;
77
73
  }
78
- isWhitelisted(groupId, userId) {
79
- return this.cache.get(groupId)?.has(userId) || false;
74
+ async syncToConfig(groupId, userId, action) {
75
+ try {
76
+ const config = this.ctx.scope.config;
77
+ const groups = config.groups || [];
78
+ const groupConfig = groups.find((g) => g.groupId === groupId);
79
+ if (groupConfig) {
80
+ if (!groupConfig.whitelist)
81
+ groupConfig.whitelist = [];
82
+ if (action === 'add') {
83
+ if (!groupConfig.whitelist.some((w) => w.userId === userId)) {
84
+ groupConfig.whitelist.push({ userId });
85
+ }
86
+ }
87
+ else {
88
+ groupConfig.whitelist = groupConfig.whitelist.filter((w) => w.userId !== userId);
89
+ }
90
+ // Update scope config
91
+ await this.ctx.scope.update(config);
92
+ }
93
+ }
94
+ catch (err) {
95
+ this.logger.warn(`Failed to sync whitelist to config: ${err}`);
96
+ }
80
97
  }
81
- getList(groupId) {
98
+ getWhitelist(groupId) {
82
99
  return Array.from(this.cache.get(groupId) || []);
83
100
  }
101
+ isWhitelisted(groupId, userId) {
102
+ return this.cache.get(groupId)?.has(userId) || false;
103
+ }
84
104
  }
85
105
  exports.WhitelistService = WhitelistService;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "koishi-plugin-temporaryban",
3
- "version": "1.0.6",
3
+ "version": "1.0.7-beta1",
4
4
  "description": "Koishi 违禁词检测与禁言插件,支持本地词库与在线API检测,违规邮件通知。",
5
5
  "main": "lib/index.js",
6
6
  "typings": "lib/index.d.ts",
@@ -41,7 +41,8 @@
41
41
  },
42
42
  "service": {
43
43
  "required": [
44
- "database"
44
+ "database",
45
+ "http"
45
46
  ],
46
47
  "optional": []
47
48
  }