koishi-plugin-driver-exam 0.0.0-stage → 1.0.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/LICENSE +21 -0
- package/lib/api.d.ts +28 -0
- package/lib/api.js +118 -0
- package/lib/commands.d.ts +29 -0
- package/lib/commands.js +481 -0
- package/lib/database.d.ts +60 -0
- package/lib/database.js +53 -0
- package/lib/index.d.ts +38 -0
- package/lib/index.js +1549 -0
- package/lib/scheduler.d.ts +6 -0
- package/lib/scheduler.js +133 -0
- package/lib/session.d.ts +71 -0
- package/lib/session.js +179 -0
- package/lib/time.d.ts +31 -0
- package/lib/time.js +104 -0
- package/package.json +53 -4
- package/readme.md +157 -0
- package/README.md +0 -3
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 BYWled
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/lib/api.d.ts
ADDED
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import { Context } from 'koishi';
|
|
2
|
+
import { DriverExamQuestion } from './database';
|
|
3
|
+
/**
|
|
4
|
+
* 获取考题列表
|
|
5
|
+
*/
|
|
6
|
+
export declare function fetchQuestionsApi(appId: string, appSecret: string, options?: {
|
|
7
|
+
page?: number;
|
|
8
|
+
rank?: number;
|
|
9
|
+
subjectType?: number;
|
|
10
|
+
questionType?: number;
|
|
11
|
+
}): Promise<any>;
|
|
12
|
+
/**
|
|
13
|
+
* 批量获取考题答案与解析
|
|
14
|
+
*/
|
|
15
|
+
export declare function fetchAnswersApi(appId: string, appSecret: string, ids: string[]): Promise<any>;
|
|
16
|
+
/**
|
|
17
|
+
* 批量同步题目与答案入库(充实本地题库缓存池)
|
|
18
|
+
*/
|
|
19
|
+
export declare function syncQuestionsBatch(ctx: Context, appId: string, appSecret: string, options?: {
|
|
20
|
+
page?: number;
|
|
21
|
+
rank?: number;
|
|
22
|
+
subjectType?: number;
|
|
23
|
+
questionType?: number;
|
|
24
|
+
}): Promise<number>;
|
|
25
|
+
/**
|
|
26
|
+
* 获取随机考题,优先使用该用户未做过的题目,并支持指定车型(rank)、科目(subjectType)与题型(allowedTypes)
|
|
27
|
+
*/
|
|
28
|
+
export declare function getExamQuestions(ctx: Context, appId: string, appSecret: string, userId: string, count?: number, rank?: number, subjectType?: number, allowedTypes?: number[], excludeQuestionIds?: string[]): Promise<DriverExamQuestion[]>;
|
package/lib/api.js
ADDED
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
import { Logger } from 'koishi';
|
|
2
|
+
const logger = new Logger('driver-exam:api');
|
|
3
|
+
let lastRequestTime = 0;
|
|
4
|
+
/**
|
|
5
|
+
* 节流请求(保证调用间隔 >= 1200ms,严格遵守 QPS=1)
|
|
6
|
+
*/
|
|
7
|
+
async function throttledFetch(url) {
|
|
8
|
+
const now = Date.now();
|
|
9
|
+
const wait = Math.max(0, 1250 - (now - lastRequestTime));
|
|
10
|
+
if (wait > 0) {
|
|
11
|
+
await new Promise((resolve) => setTimeout(resolve, wait));
|
|
12
|
+
}
|
|
13
|
+
lastRequestTime = Date.now();
|
|
14
|
+
try {
|
|
15
|
+
const res = await fetch(url);
|
|
16
|
+
const json = await res.json();
|
|
17
|
+
return json;
|
|
18
|
+
}
|
|
19
|
+
catch (err) {
|
|
20
|
+
logger.error(`请求失败 [${url}]:`, err);
|
|
21
|
+
throw err;
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
/**
|
|
25
|
+
* 获取考题列表
|
|
26
|
+
*/
|
|
27
|
+
export async function fetchQuestionsApi(appId, appSecret, options = {}) {
|
|
28
|
+
const page = options.page ?? 1;
|
|
29
|
+
const rank = options.rank ?? 1;
|
|
30
|
+
const subjectType = options.subjectType ?? 1;
|
|
31
|
+
const questionType = options.questionType ?? 1;
|
|
32
|
+
const url = `https://www.mxnzp.com/api/driver_exam/v2/question/list?page=${page}&rank=${rank}&subject_type=${subjectType}&question_type=${questionType}&app_id=${appId}&app_secret=${appSecret}`;
|
|
33
|
+
const data = await throttledFetch(url);
|
|
34
|
+
return data;
|
|
35
|
+
}
|
|
36
|
+
/**
|
|
37
|
+
* 批量获取考题答案与解析
|
|
38
|
+
*/
|
|
39
|
+
export async function fetchAnswersApi(appId, appSecret, ids) {
|
|
40
|
+
if (ids.length === 0)
|
|
41
|
+
return { code: 1, data: [] };
|
|
42
|
+
const url = `https://www.mxnzp.com/api/driver_exam/v2/answer/list?ids=${ids.join(',')}&app_id=${appId}&app_secret=${appSecret}`;
|
|
43
|
+
const data = await throttledFetch(url);
|
|
44
|
+
return data;
|
|
45
|
+
}
|
|
46
|
+
/**
|
|
47
|
+
* 批量同步题目与答案入库(充实本地题库缓存池)
|
|
48
|
+
*/
|
|
49
|
+
export async function syncQuestionsBatch(ctx, appId, appSecret, options = {}) {
|
|
50
|
+
try {
|
|
51
|
+
const qRes = await fetchQuestionsApi(appId, appSecret, options);
|
|
52
|
+
if (qRes.code !== 1 || !qRes.data || !qRes.data.list || qRes.data.list.length === 0) {
|
|
53
|
+
logger.warn(`获取题目列表失败或为空: ${JSON.stringify(qRes)}`);
|
|
54
|
+
return 0;
|
|
55
|
+
}
|
|
56
|
+
const qList = qRes.data.list;
|
|
57
|
+
const ids = qList.map((q) => q.id);
|
|
58
|
+
const aRes = await fetchAnswersApi(appId, appSecret, ids);
|
|
59
|
+
const answerMap = new Map();
|
|
60
|
+
if (aRes.code === 1 && Array.isArray(aRes.data)) {
|
|
61
|
+
for (const item of aRes.data) {
|
|
62
|
+
answerMap.set(item.id, {
|
|
63
|
+
answer: item.answer ?? '',
|
|
64
|
+
explain: item.explain ?? '无解析',
|
|
65
|
+
});
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
let savedCount = 0;
|
|
69
|
+
const now = new Date();
|
|
70
|
+
for (const q of qList) {
|
|
71
|
+
const aInfo = answerMap.get(q.id) || { answer: '', explain: '暂无解析' };
|
|
72
|
+
let pic = q.titlePic || '';
|
|
73
|
+
if (pic.startsWith('/')) {
|
|
74
|
+
pic = `https://www.mxnzp.com${pic}`;
|
|
75
|
+
}
|
|
76
|
+
const record = {
|
|
77
|
+
id: q.id,
|
|
78
|
+
rank: q.rank || options.rank || 1,
|
|
79
|
+
subjectType: q.subjectType || options.subjectType || 1,
|
|
80
|
+
questionType: q.questionType || options.questionType || 1,
|
|
81
|
+
title: q.title || '',
|
|
82
|
+
op1: q.op1 || '',
|
|
83
|
+
op2: q.op2 || '',
|
|
84
|
+
op3: q.op3 || '',
|
|
85
|
+
op4: q.op4 || '',
|
|
86
|
+
titlePic: pic,
|
|
87
|
+
answer: aInfo.answer,
|
|
88
|
+
explain: aInfo.explain,
|
|
89
|
+
updatedAt: now,
|
|
90
|
+
};
|
|
91
|
+
await ctx.database.upsert('driver_exam_question', [record]);
|
|
92
|
+
savedCount++;
|
|
93
|
+
}
|
|
94
|
+
logger.info(`已成功同步并缓存 ${savedCount} 道考题到本地`);
|
|
95
|
+
return savedCount;
|
|
96
|
+
}
|
|
97
|
+
catch (err) {
|
|
98
|
+
logger.error('同步题库失败:', err);
|
|
99
|
+
return 0;
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
/**
|
|
103
|
+
* 从本地题库中随机抽取题目,若题库不足则自动远程抓取扩充
|
|
104
|
+
*/
|
|
105
|
+
export async function getExamQuestions(ctx, appId, appSecret, count = 10) {
|
|
106
|
+
let questions = await ctx.database.get('driver_exam_question', {});
|
|
107
|
+
// 本地题目数量少于需要数量时,主动抓取一批
|
|
108
|
+
if (questions.length < count * 2 && appId && appSecret) {
|
|
109
|
+
const randomPage = Math.floor(Math.random() * 20) + 1;
|
|
110
|
+
// 异步拉取单选题与判断题
|
|
111
|
+
await syncQuestionsBatch(ctx, appId, appSecret, { page: randomPage, questionType: 1 });
|
|
112
|
+
await syncQuestionsBatch(ctx, appId, appSecret, { page: randomPage, questionType: 2 });
|
|
113
|
+
questions = await ctx.database.get('driver_exam_question', {});
|
|
114
|
+
}
|
|
115
|
+
// 打乱并返回 count 道题
|
|
116
|
+
const shuffled = [...questions].sort(() => Math.random() - 0.5);
|
|
117
|
+
return shuffled.slice(0, count);
|
|
118
|
+
}
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import { Context, Session } from 'koishi';
|
|
2
|
+
/**
|
|
3
|
+
* 获取或创建用户状态记录
|
|
4
|
+
*/
|
|
5
|
+
export declare function getOrCreateUser(ctx: Context, guildId: string, userId: string, uid: number, initialBufferHours: number, activeStartHour: number, activeEndHour: number): Promise<import("./database").DriverExamUser>;
|
|
6
|
+
/**
|
|
7
|
+
* 提取目标用户 ID
|
|
8
|
+
*/
|
|
9
|
+
export declare function extractTargetUserId(raw: string, session: Session): string | null;
|
|
10
|
+
/**
|
|
11
|
+
* 获取用户平台对应的内部 uid
|
|
12
|
+
*/
|
|
13
|
+
export declare function resolveUid(ctx: Context, platform: string, pid: string): Promise<number>;
|
|
14
|
+
/**
|
|
15
|
+
* 获取货币现金余额
|
|
16
|
+
*/
|
|
17
|
+
export declare function getCashBalance(ctx: Context, uid: number, currency: string): Promise<number>;
|
|
18
|
+
/**
|
|
19
|
+
* 扣减货币
|
|
20
|
+
*/
|
|
21
|
+
export declare function costCash(ctx: Context, uid: number, amount: number, currency: string): Promise<boolean>;
|
|
22
|
+
/**
|
|
23
|
+
* 发放货币
|
|
24
|
+
*/
|
|
25
|
+
export declare function gainCash(ctx: Context, uid: number, amount: number, currency: string): Promise<void>;
|
|
26
|
+
/**
|
|
27
|
+
* 注册核心交互中间件与指令
|
|
28
|
+
*/
|
|
29
|
+
export declare function registerCommands(ctx: Context, config: any): void;
|
package/lib/commands.js
ADDED
|
@@ -0,0 +1,481 @@
|
|
|
1
|
+
import { h, Logger } from 'koishi';
|
|
2
|
+
import { activeSessions, sendCurrentQuestion, handleUserAnswer, } from './session';
|
|
3
|
+
import { getExamQuestions, syncQuestionsBatch } from './api';
|
|
4
|
+
import { addActiveHours, formatDateTime, getNaturalWeekString, } from './time';
|
|
5
|
+
const logger = new Logger('driver-exam:commands');
|
|
6
|
+
/**
|
|
7
|
+
* 获取或创建用户状态记录
|
|
8
|
+
*/
|
|
9
|
+
export async function getOrCreateUser(ctx, guildId, userId, uid, initialBufferHours, activeStartHour, activeEndHour) {
|
|
10
|
+
let [record] = await ctx.database.get('driver_exam_user', { guildId, userId });
|
|
11
|
+
if (!record) {
|
|
12
|
+
const now = new Date();
|
|
13
|
+
const exemptUntil = addActiveHours(now, initialBufferHours, activeStartHour, activeEndHour);
|
|
14
|
+
record = await ctx.database.create('driver_exam_user', {
|
|
15
|
+
guildId,
|
|
16
|
+
userId,
|
|
17
|
+
uid,
|
|
18
|
+
inList: false,
|
|
19
|
+
exemptUntil,
|
|
20
|
+
isMuted: false,
|
|
21
|
+
weekStreak: 0,
|
|
22
|
+
lastStreakWeek: getNaturalWeekString(now),
|
|
23
|
+
dailyPassCount: 0,
|
|
24
|
+
lastPassDate: now.toISOString().slice(0, 10),
|
|
25
|
+
});
|
|
26
|
+
}
|
|
27
|
+
return record;
|
|
28
|
+
}
|
|
29
|
+
/**
|
|
30
|
+
* 提取目标用户 ID
|
|
31
|
+
*/
|
|
32
|
+
export function extractTargetUserId(raw, session) {
|
|
33
|
+
const at = h.select(session.elements, 'at')[0];
|
|
34
|
+
if (at?.attrs?.id)
|
|
35
|
+
return String(at.attrs.id);
|
|
36
|
+
if (!raw)
|
|
37
|
+
return null;
|
|
38
|
+
const m = raw.match(/id="?(\w+)"?/);
|
|
39
|
+
if (m)
|
|
40
|
+
return m[1];
|
|
41
|
+
const clean = raw.replace(/[^0-9a-zA-Z_]/g, '');
|
|
42
|
+
return clean || null;
|
|
43
|
+
}
|
|
44
|
+
/**
|
|
45
|
+
* 获取用户平台对应的内部 uid
|
|
46
|
+
*/
|
|
47
|
+
export async function resolveUid(ctx, platform, pid) {
|
|
48
|
+
try {
|
|
49
|
+
const [binding] = await ctx.database.get('binding', { platform, pid });
|
|
50
|
+
if (binding?.aid)
|
|
51
|
+
return binding.aid;
|
|
52
|
+
const user = await ctx.database.getUser(platform, pid, ['id']);
|
|
53
|
+
return user?.id || 0;
|
|
54
|
+
}
|
|
55
|
+
catch {
|
|
56
|
+
return 0;
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
/**
|
|
60
|
+
* 获取货币现金余额
|
|
61
|
+
*/
|
|
62
|
+
export async function getCashBalance(ctx, uid, currency) {
|
|
63
|
+
if (!uid)
|
|
64
|
+
return 0;
|
|
65
|
+
const records = await ctx.database.get('monetary', { uid, currency });
|
|
66
|
+
return records && records.length > 0 ? Number(records[0].value || 0) : 0;
|
|
67
|
+
}
|
|
68
|
+
/**
|
|
69
|
+
* 扣减货币
|
|
70
|
+
*/
|
|
71
|
+
export async function costCash(ctx, uid, amount, currency) {
|
|
72
|
+
if (ctx.monetary) {
|
|
73
|
+
try {
|
|
74
|
+
await ctx.monetary.cost(uid, amount, currency);
|
|
75
|
+
return true;
|
|
76
|
+
}
|
|
77
|
+
catch {
|
|
78
|
+
return false;
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
const cur = await getCashBalance(ctx, uid, currency);
|
|
82
|
+
if (cur < amount)
|
|
83
|
+
return false;
|
|
84
|
+
await ctx.database.set('monetary', { uid, currency }, { value: cur - amount });
|
|
85
|
+
return true;
|
|
86
|
+
}
|
|
87
|
+
/**
|
|
88
|
+
* 发放货币
|
|
89
|
+
*/
|
|
90
|
+
export async function gainCash(ctx, uid, amount, currency) {
|
|
91
|
+
if (amount <= 0)
|
|
92
|
+
return;
|
|
93
|
+
if (ctx.monetary) {
|
|
94
|
+
await ctx.monetary.gain(uid, amount, currency);
|
|
95
|
+
return;
|
|
96
|
+
}
|
|
97
|
+
const cur = await getCashBalance(ctx, uid, currency);
|
|
98
|
+
await ctx.database.set('monetary', { uid, currency }, { value: cur + amount });
|
|
99
|
+
}
|
|
100
|
+
/**
|
|
101
|
+
* 注册核心交互中间件与指令
|
|
102
|
+
*/
|
|
103
|
+
export function registerCommands(ctx, config) {
|
|
104
|
+
// 1. 中间件:优先拦截进行中的答题会话消息
|
|
105
|
+
ctx.middleware(async (session, next) => {
|
|
106
|
+
const isPrivate = !session.guildId;
|
|
107
|
+
const key = isPrivate ? `private:${session.userId}` : `${session.guildId}:${session.userId}`;
|
|
108
|
+
const exam = activeSessions.get(key);
|
|
109
|
+
if (!exam)
|
|
110
|
+
return next();
|
|
111
|
+
// 群聊场景下严格校验频道一致
|
|
112
|
+
if (!isPrivate && session.channelId !== exam.channelId)
|
|
113
|
+
return next();
|
|
114
|
+
const text = session.content?.trim();
|
|
115
|
+
if (!text)
|
|
116
|
+
return next();
|
|
117
|
+
// 拦截并作答
|
|
118
|
+
await handleUserAnswer(exam, text);
|
|
119
|
+
}, true);
|
|
120
|
+
// 2. 答题指令:开启一轮答题
|
|
121
|
+
ctx
|
|
122
|
+
.command('驾考答题', '开启一轮驾考题目测评(10题)')
|
|
123
|
+
.alias('考驾照')
|
|
124
|
+
.userFields(['id'])
|
|
125
|
+
.action(async ({ session }) => {
|
|
126
|
+
const isPrivate = !session.guildId;
|
|
127
|
+
const userId = session.userId;
|
|
128
|
+
const uid = Number(session.user?.id) || 0;
|
|
129
|
+
// 判断目标生效群
|
|
130
|
+
let guildId = session.guildId || '';
|
|
131
|
+
if (isPrivate) {
|
|
132
|
+
// 如果是私信作答,寻找用户被加入名单或被禁言的群
|
|
133
|
+
const records = await ctx.database.get('driver_exam_user', { userId });
|
|
134
|
+
if (records.length === 0) {
|
|
135
|
+
guildId = 'private';
|
|
136
|
+
}
|
|
137
|
+
else {
|
|
138
|
+
// 优先选取已被禁言的群,或者有挑战的群
|
|
139
|
+
const mutedRecord = records.find((r) => r.isMuted);
|
|
140
|
+
guildId = mutedRecord ? mutedRecord.guildId : records[0].guildId;
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
const key = isPrivate ? `private:${userId}` : `${guildId}:${userId}`;
|
|
144
|
+
if (activeSessions.has(key)) {
|
|
145
|
+
return '⚠️ 你当前已有正在进行的驾考测评,请继续作答!';
|
|
146
|
+
}
|
|
147
|
+
// 获取考题
|
|
148
|
+
const questions = await getExamQuestions(ctx, config.appId, config.appSecret, config.questionsPerRound);
|
|
149
|
+
if (questions.length === 0) {
|
|
150
|
+
return '❌ 题库暂时为空,正在尝试同步中,请稍后再试。';
|
|
151
|
+
}
|
|
152
|
+
// 创建会话
|
|
153
|
+
const examSession = {
|
|
154
|
+
key,
|
|
155
|
+
guildId,
|
|
156
|
+
userId,
|
|
157
|
+
uid,
|
|
158
|
+
channelId: session.channelId,
|
|
159
|
+
isPrivate,
|
|
160
|
+
bot: session.bot,
|
|
161
|
+
questions,
|
|
162
|
+
currentIndex: 0,
|
|
163
|
+
answers: [],
|
|
164
|
+
timer: null,
|
|
165
|
+
timeoutSeconds: config.questionTimeout,
|
|
166
|
+
onFinish: async (s, passed) => {
|
|
167
|
+
await handleSessionFinished(ctx, config, s, passed);
|
|
168
|
+
},
|
|
169
|
+
};
|
|
170
|
+
activeSessions.set(key, examSession);
|
|
171
|
+
await session.bot.sendMessage(session.channelId, `🚦 驾考答题正式开始!共 ${questions.length} 道题,正确率达到 90% 及以上视为通关。祝你好运!`);
|
|
172
|
+
await sendCurrentQuestion(examSession);
|
|
173
|
+
});
|
|
174
|
+
// 3. 挑战指令:消耗货币指定人进行挑战
|
|
175
|
+
ctx
|
|
176
|
+
.command('驾考挑战 <target:string> <amount:number>', '消耗货币指定他人进行驾考禁言挑战')
|
|
177
|
+
.userFields(['id'])
|
|
178
|
+
.action(async ({ session }, rawTarget, amount) => {
|
|
179
|
+
if (!session.guildId) {
|
|
180
|
+
return '⚠️ 驾考挑战只能在群聊中发起!';
|
|
181
|
+
}
|
|
182
|
+
if (!amount || amount <= 0 || Number.isNaN(amount)) {
|
|
183
|
+
return '⚠️ 请输入有效的挑战货币数量,如:驾考挑战 @某人 100';
|
|
184
|
+
}
|
|
185
|
+
const targetUserId = extractTargetUserId(rawTarget, session);
|
|
186
|
+
if (!targetUserId) {
|
|
187
|
+
return '⚠️ 请指定被挑战人(可通过 @ 或输入对方ID)';
|
|
188
|
+
}
|
|
189
|
+
if (targetUserId === session.userId) {
|
|
190
|
+
return '⚠️ 不能挑战自己!';
|
|
191
|
+
}
|
|
192
|
+
const challengerUid = Number(session.user?.id) || 0;
|
|
193
|
+
const targetUid = await resolveUid(ctx, session.platform, targetUserId);
|
|
194
|
+
// 检查发起人余额
|
|
195
|
+
const balance = await getCashBalance(ctx, challengerUid, config.currency);
|
|
196
|
+
if (balance < amount) {
|
|
197
|
+
return `❌ 余额不足!当前现金余额为 ${balance} ${config.currency},发起本次挑战需要 ${amount} ${config.currency}。`;
|
|
198
|
+
}
|
|
199
|
+
// 折算禁言小时数
|
|
200
|
+
const banHours = Number((amount / config.currencyPerHour).toFixed(2));
|
|
201
|
+
if (banHours <= 0) {
|
|
202
|
+
return `⚠️ 投入货币过少,不足以折算禁言时长(当前比例为 ${config.currencyPerHour} 货币/小时)`;
|
|
203
|
+
}
|
|
204
|
+
// 获取被挑战人状态
|
|
205
|
+
const targetRecord = await getOrCreateUser(ctx, session.guildId, targetUserId, targetUid, config.initialBufferHours, config.activeStartHour, config.activeEndHour);
|
|
206
|
+
// 检查被挑战人合法性(需求 7.3 & 防套娃)
|
|
207
|
+
if (targetRecord.isMuted) {
|
|
208
|
+
return '⚠️ 目标玩家当前已被禁言,不可对其发起挑战!';
|
|
209
|
+
}
|
|
210
|
+
// 检查目标玩家是否已有进行中的挑战
|
|
211
|
+
const ongoingChallenges = await ctx.database.get('driver_exam_challenge', {
|
|
212
|
+
guildId: session.guildId,
|
|
213
|
+
targetUserId,
|
|
214
|
+
});
|
|
215
|
+
const hasPendingOrActive = ongoingChallenges.some((c) => c.status === 'pending' || c.status === 'active');
|
|
216
|
+
if (hasPendingOrActive) {
|
|
217
|
+
return '⚠️ 目标玩家当前正在接受其他挑战中,不可同时叠加多重挑战!';
|
|
218
|
+
}
|
|
219
|
+
const now = new Date();
|
|
220
|
+
// 检查名单豁免时间(若在名单中,未来 1小时缓冲 + 折算时长 内不能到达禁言)
|
|
221
|
+
if (targetRecord.inList) {
|
|
222
|
+
const requiredSafetyTime = addActiveHours(now, config.challengeBufferHours + banHours, config.activeStartHour, config.activeEndHour);
|
|
223
|
+
if (new Date(targetRecord.exemptUntil) < requiredSafetyTime) {
|
|
224
|
+
return `⚠️ 目标玩家当前所剩豁免时长不足(到期时间为 ${formatDateTime(targetRecord.exemptUntil)}),无法满足发起挑战所需的安全期要求!`;
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
// 执行扣款
|
|
228
|
+
const costOk = await costCash(ctx, challengerUid, amount, config.currency);
|
|
229
|
+
if (!costOk) {
|
|
230
|
+
return '❌ 扣款失败,请重试!';
|
|
231
|
+
}
|
|
232
|
+
// 计算需要通关的轮数
|
|
233
|
+
const requiredRounds = Math.max(1, Math.ceil(banHours / config.exemptHoursPerPass));
|
|
234
|
+
const bufferDeadline = addActiveHours(now, config.challengeBufferHours, config.activeStartHour, config.activeEndHour);
|
|
235
|
+
// 创建挑战
|
|
236
|
+
await ctx.database.create('driver_exam_challenge', {
|
|
237
|
+
guildId: session.guildId,
|
|
238
|
+
challengerUid,
|
|
239
|
+
challengerUserId: session.userId,
|
|
240
|
+
targetUid,
|
|
241
|
+
targetUserId,
|
|
242
|
+
costCurrency: amount,
|
|
243
|
+
banHours,
|
|
244
|
+
requiredRounds,
|
|
245
|
+
passedRounds: 0,
|
|
246
|
+
bufferDeadline,
|
|
247
|
+
banDeadline: bufferDeadline,
|
|
248
|
+
status: 'pending',
|
|
249
|
+
createdAt: now,
|
|
250
|
+
});
|
|
251
|
+
const rewardAmount = (amount * config.challengeRewardRate).toFixed(1);
|
|
252
|
+
return (`⚔️ 【驾考挑战发起成功】\n` +
|
|
253
|
+
`发起人: <at id="${session.userId}"/> 消耗了 ${amount} ${config.currency}\n` +
|
|
254
|
+
`目标玩家: <at id="${targetUserId}"/>\n` +
|
|
255
|
+
`折算禁言: ${banHours} 小时\n` +
|
|
256
|
+
`需通关数: ${requiredRounds} 轮驾考(每轮通过顺延 ${config.exemptHoursPerPass} 小时)\n` +
|
|
257
|
+
`⏳ 缓冲期截止: ${formatDateTime(bufferDeadline)}(${config.challengeBufferHours}小时缓冲)\n` +
|
|
258
|
+
`🎁 奖励提示: 若在缓冲期截止前全部答对通关,可夺得挑战奖励 ${rewardAmount} ${config.currency}(50%)!\n` +
|
|
259
|
+
`逾期未完成将开启群内禁言!`);
|
|
260
|
+
});
|
|
261
|
+
// 4. 状态查询指令
|
|
262
|
+
ctx
|
|
263
|
+
.command('驾考状态', '查询个人的驾考保释与挑战状态')
|
|
264
|
+
.userFields(['id'])
|
|
265
|
+
.action(async ({ session }) => {
|
|
266
|
+
const guildId = session.guildId || 'private';
|
|
267
|
+
const userId = session.userId;
|
|
268
|
+
const uid = Number(session.user?.id) || 0;
|
|
269
|
+
const record = await getOrCreateUser(ctx, guildId, userId, uid, config.initialBufferHours, config.activeStartHour, config.activeEndHour);
|
|
270
|
+
// 查询进行中的挑战
|
|
271
|
+
const challenges = await ctx.database.get('driver_exam_challenge', {
|
|
272
|
+
guildId,
|
|
273
|
+
targetUserId: userId,
|
|
274
|
+
});
|
|
275
|
+
const activeCh = challenges.find((c) => c.status === 'pending' || c.status === 'active');
|
|
276
|
+
const lines = [];
|
|
277
|
+
lines.push(`📋 【驾考状态报告 - ${userId}】`);
|
|
278
|
+
lines.push(`监控名单: ${record.inList ? '🚨 是 (需按期刷题)' : '🕊️ 否 (自由参与)'}`);
|
|
279
|
+
lines.push(`禁言状态: ${record.isMuted ? '🤐 禁言中 (可在私信答题保释)' : '🟢 正常发言'}`);
|
|
280
|
+
lines.push(`豁免截止时间: ${formatDateTime(record.exemptUntil)}`);
|
|
281
|
+
lines.push(`本周连胜进度: ${record.weekStreak} / ${config.weekStreakTarget} 轮`);
|
|
282
|
+
const maxDaily = config.enableDailyRewardLimit
|
|
283
|
+
? config.dailyRewardLimitMode === 'auto'
|
|
284
|
+
? Math.floor((config.activeEndHour - config.activeStartHour) / config.exemptHoursPerPass)
|
|
285
|
+
: config.dailyRewardLimitCustom
|
|
286
|
+
: '不限';
|
|
287
|
+
lines.push(`今日自由挑战领奖: ${record.dailyPassCount} / ${maxDaily} 次`);
|
|
288
|
+
if (activeCh) {
|
|
289
|
+
lines.push(`\n⚠️ 【正在进行的挑战】\n` +
|
|
290
|
+
`状态: ${activeCh.status === 'pending' ? '⏳ 1小时缓冲期中' : '🤐 挑战禁言惩罚中'}\n` +
|
|
291
|
+
`已通关轮数: ${activeCh.passedRounds} / ${activeCh.requiredRounds}\n` +
|
|
292
|
+
`缓冲期截止: ${formatDateTime(activeCh.bufferDeadline)}`);
|
|
293
|
+
}
|
|
294
|
+
return lines.join('\n');
|
|
295
|
+
});
|
|
296
|
+
// 5. 管理员名单查询与管理指令
|
|
297
|
+
ctx
|
|
298
|
+
.command('驾考名单', '查看当前群受监控的名单列表', { authority: 3 })
|
|
299
|
+
.action(async ({ session }) => {
|
|
300
|
+
if (!session.guildId)
|
|
301
|
+
return '请在群聊中使用此指令。';
|
|
302
|
+
const list = await ctx.database.get('driver_exam_user', {
|
|
303
|
+
guildId: session.guildId,
|
|
304
|
+
inList: true,
|
|
305
|
+
});
|
|
306
|
+
if (list.length === 0)
|
|
307
|
+
return '📝 当前群监控名单为空。';
|
|
308
|
+
const lines = ['📋 【驾考监控名单】'];
|
|
309
|
+
for (const u of list) {
|
|
310
|
+
lines.push(`- 用户 ${u.userId}: 豁免至 ${formatDateTime(u.exemptUntil)} | 状态: ${u.isMuted ? '🤐禁言' : '🟢正常'}`);
|
|
311
|
+
}
|
|
312
|
+
return lines.join('\n');
|
|
313
|
+
});
|
|
314
|
+
ctx
|
|
315
|
+
.command('驾考添加 <target:string> [bufferHours:number]', '将用户加入监控名单', { authority: 3 })
|
|
316
|
+
.action(async ({ session }, rawTarget, bufferHours) => {
|
|
317
|
+
if (!session.guildId)
|
|
318
|
+
return '请在群聊中使用此指令。';
|
|
319
|
+
const targetUserId = extractTargetUserId(rawTarget, session);
|
|
320
|
+
if (!targetUserId)
|
|
321
|
+
return '请指定要添加的目标用户。';
|
|
322
|
+
const hours = typeof bufferHours === 'number' && bufferHours >= 0 ? bufferHours : config.initialBufferHours;
|
|
323
|
+
const targetUid = await resolveUid(ctx, session.platform, targetUserId);
|
|
324
|
+
const record = await getOrCreateUser(ctx, session.guildId, targetUserId, targetUid, hours, config.activeStartHour, config.activeEndHour);
|
|
325
|
+
const exemptUntil = addActiveHours(new Date(), hours, config.activeStartHour, config.activeEndHour);
|
|
326
|
+
await ctx.database.set('driver_exam_user', { id: record.id }, {
|
|
327
|
+
inList: true,
|
|
328
|
+
exemptUntil,
|
|
329
|
+
});
|
|
330
|
+
return `✅ 已将用户 ${targetUserId} 加入监控名单,赋予 ${hours} 小时初始缓冲期(截止至 ${formatDateTime(exemptUntil)})`;
|
|
331
|
+
});
|
|
332
|
+
ctx
|
|
333
|
+
.command('驾考移除 <target:string>', '将用户移出监控名单', { authority: 3 })
|
|
334
|
+
.action(async ({ session }, rawTarget) => {
|
|
335
|
+
if (!session.guildId)
|
|
336
|
+
return '请在群聊中使用此指令。';
|
|
337
|
+
const targetUserId = extractTargetUserId(rawTarget, session);
|
|
338
|
+
if (!targetUserId)
|
|
339
|
+
return '请指定要移除的目标用户。';
|
|
340
|
+
const [record] = await ctx.database.get('driver_exam_user', {
|
|
341
|
+
guildId: session.guildId,
|
|
342
|
+
userId: targetUserId,
|
|
343
|
+
});
|
|
344
|
+
if (!record || !record.inList)
|
|
345
|
+
return `用户 ${targetUserId} 本就不在监控名单中。`;
|
|
346
|
+
await ctx.database.set('driver_exam_user', { id: record.id }, {
|
|
347
|
+
inList: false,
|
|
348
|
+
});
|
|
349
|
+
if (record.isMuted) {
|
|
350
|
+
try {
|
|
351
|
+
await session.bot.muteGuildMember(session.guildId, targetUserId, 0);
|
|
352
|
+
await ctx.database.set('driver_exam_user', { id: record.id }, { isMuted: false });
|
|
353
|
+
}
|
|
354
|
+
catch (_) { }
|
|
355
|
+
}
|
|
356
|
+
return `✅ 已将用户 ${targetUserId} 移出监控名单,并解除其禁言。`;
|
|
357
|
+
});
|
|
358
|
+
ctx
|
|
359
|
+
.command('驾考同步 [page:number]', '手动批量同步远程题库并入库缓存', { authority: 3 })
|
|
360
|
+
.action(async ({ session }, page) => {
|
|
361
|
+
const p = page || 1;
|
|
362
|
+
const count1 = await syncQuestionsBatch(ctx, config.appId, config.appSecret, { page: p, questionType: 1 });
|
|
363
|
+
const count2 = await syncQuestionsBatch(ctx, config.appId, config.appSecret, { page: p, questionType: 2 });
|
|
364
|
+
const total = await ctx.database.get('driver_exam_question', {});
|
|
365
|
+
return `📥 同步完成!本次同步新增/更新 ${count1 + count2} 题,本地题库现存 ${total.length} 题。`;
|
|
366
|
+
});
|
|
367
|
+
}
|
|
368
|
+
/**
|
|
369
|
+
* 答题结算统一后置逻辑
|
|
370
|
+
*/
|
|
371
|
+
async function handleSessionFinished(ctx, config, session, passed) {
|
|
372
|
+
const user = await getOrCreateUser(ctx, session.guildId, session.userId, session.uid, config.initialBufferHours, config.activeStartHour, config.activeEndHour);
|
|
373
|
+
const now = new Date();
|
|
374
|
+
// 1. 优先判定是否存在被挑战状态
|
|
375
|
+
const challenges = await ctx.database.get('driver_exam_challenge', {
|
|
376
|
+
guildId: session.guildId,
|
|
377
|
+
targetUserId: session.userId,
|
|
378
|
+
});
|
|
379
|
+
const ch = challenges.find((c) => c.status === 'pending' || c.status === 'active');
|
|
380
|
+
if (ch) {
|
|
381
|
+
if (passed) {
|
|
382
|
+
ch.passedRounds++;
|
|
383
|
+
if (ch.passedRounds >= ch.requiredRounds) {
|
|
384
|
+
if (ch.status === 'pending') {
|
|
385
|
+
// 缓冲期内全部完成!挑战化解!
|
|
386
|
+
ch.status = 'success';
|
|
387
|
+
const rewardAmount = ch.costCurrency * config.challengeRewardRate;
|
|
388
|
+
await gainCash(ctx, session.uid, rewardAmount, config.currency);
|
|
389
|
+
await session.bot.sendMessage(session.channelId, `🎉 挑战成功化解!你在生效前完成了全部 ${ch.requiredRounds} 轮考核,荣获挑战奖励 ${rewardAmount.toFixed(1)} ${config.currency}(50%)!`);
|
|
390
|
+
}
|
|
391
|
+
else {
|
|
392
|
+
// 在被禁言后私信完成全部考核!解除禁言!
|
|
393
|
+
ch.status = 'failed';
|
|
394
|
+
try {
|
|
395
|
+
await session.bot.muteGuildMember(session.guildId, session.userId, 0);
|
|
396
|
+
}
|
|
397
|
+
catch (_) { }
|
|
398
|
+
await session.bot.sendMessage(session.channelId, `🎉 恭喜!你已完成挑战所需的所有 ${ch.requiredRounds} 轮试题,已立即为你解除挑战禁言!`);
|
|
399
|
+
}
|
|
400
|
+
}
|
|
401
|
+
else {
|
|
402
|
+
await session.bot.sendMessage(session.channelId, `👍 挑战进度更新: [${ch.passedRounds}/${ch.requiredRounds}] 轮。请继续答题解围!`);
|
|
403
|
+
}
|
|
404
|
+
await ctx.database.set('driver_exam_challenge', { id: ch.id }, ch);
|
|
405
|
+
}
|
|
406
|
+
else {
|
|
407
|
+
await session.bot.sendMessage(session.channelId, `⚠️ 本轮未达到 90% 正确率,不计入挑战通关进度。当前进度: [${ch.passedRounds}/${ch.requiredRounds}] 轮。`);
|
|
408
|
+
}
|
|
409
|
+
return;
|
|
410
|
+
}
|
|
411
|
+
// 2. 名单成员处理逻辑
|
|
412
|
+
if (user.inList) {
|
|
413
|
+
if (user.isMuted) {
|
|
414
|
+
// 被禁言状态(私信答题)
|
|
415
|
+
if (passed) {
|
|
416
|
+
try {
|
|
417
|
+
await session.bot.muteGuildMember(session.guildId, session.userId, 0);
|
|
418
|
+
}
|
|
419
|
+
catch (_) { }
|
|
420
|
+
user.isMuted = false;
|
|
421
|
+
user.exemptUntil = addActiveHours(now, config.mutedPassExemptHours, config.activeStartHour, config.activeEndHour);
|
|
422
|
+
user.weekStreak++;
|
|
423
|
+
let streakNotice = `\n🔥 本周连续通关: ${user.weekStreak} / ${config.weekStreakTarget} 轮`;
|
|
424
|
+
if (user.weekStreak >= config.weekStreakTarget) {
|
|
425
|
+
const streakHours = config.weekStreakRewardDays * (config.activeEndHour - config.activeStartHour);
|
|
426
|
+
user.exemptUntil = addActiveHours(user.exemptUntil, streakHours, config.activeStartHour, config.activeEndHour);
|
|
427
|
+
user.weekStreak = 0;
|
|
428
|
+
streakNotice = `\n👑 达成自然周 10 连胜!额外获得 3 天 (${streakHours} 小时) 免禁顺延!`;
|
|
429
|
+
}
|
|
430
|
+
await session.bot.sendMessage(session.channelId, `🎉 私信答题通关成功!已为你解除群内禁言,豁免期基于当前时间后延 ${config.mutedPassExemptHours} 小时,有效至: ${formatDateTime(user.exemptUntil)}!${streakNotice}`);
|
|
431
|
+
}
|
|
432
|
+
else {
|
|
433
|
+
user.weekStreak = 0;
|
|
434
|
+
await session.bot.sendMessage(session.channelId, `❌ 本轮未达到 90% 及格线,解禁失败,本周连胜已清零。请重新私信“驾考答题”!`);
|
|
435
|
+
}
|
|
436
|
+
}
|
|
437
|
+
else {
|
|
438
|
+
// 正常未被禁言状态
|
|
439
|
+
if (passed) {
|
|
440
|
+
const base = new Date(user.exemptUntil) > now ? new Date(user.exemptUntil) : now;
|
|
441
|
+
user.exemptUntil = addActiveHours(base, config.exemptHoursPerPass, config.activeStartHour, config.activeEndHour);
|
|
442
|
+
user.weekStreak++;
|
|
443
|
+
let streakNotice = `\n🔥 本周连续通关: ${user.weekStreak} / ${config.weekStreakTarget} 轮`;
|
|
444
|
+
if (user.weekStreak >= config.weekStreakTarget) {
|
|
445
|
+
const streakHours = config.weekStreakRewardDays * (config.activeEndHour - config.activeStartHour);
|
|
446
|
+
user.exemptUntil = addActiveHours(user.exemptUntil, streakHours, config.activeStartHour, config.activeEndHour);
|
|
447
|
+
user.weekStreak = 0;
|
|
448
|
+
streakNotice = `\n👑 达成自然周 10 连胜!额外获得 3 天 (${streakHours} 小时) 免禁顺延!`;
|
|
449
|
+
}
|
|
450
|
+
await session.bot.sendMessage(session.channelId, `🎉 通关成功!开始禁言时间已向后顺延 ${config.exemptHoursPerPass} 小时,最新豁免截止时刻为: ${formatDateTime(user.exemptUntil)}!${streakNotice}`);
|
|
451
|
+
}
|
|
452
|
+
else {
|
|
453
|
+
user.weekStreak = 0;
|
|
454
|
+
await session.bot.sendMessage(session.channelId, `❌ 本轮考核未及格,未能获得时间顺延,本周连胜已打断重置。`);
|
|
455
|
+
}
|
|
456
|
+
}
|
|
457
|
+
await ctx.database.set('driver_exam_user', { id: user.id }, user);
|
|
458
|
+
return;
|
|
459
|
+
}
|
|
460
|
+
// 3. 自由挑战玩家(非名单且无挑战)
|
|
461
|
+
if (passed) {
|
|
462
|
+
const maxDaily = config.enableDailyRewardLimit
|
|
463
|
+
? config.dailyRewardLimitMode === 'auto'
|
|
464
|
+
? Math.floor((config.activeEndHour - config.activeStartHour) / config.exemptHoursPerPass)
|
|
465
|
+
: config.dailyRewardLimitCustom
|
|
466
|
+
: Infinity;
|
|
467
|
+
const unitReward = Number((config.currencyPerHour * config.challengeRewardRate).toFixed(1));
|
|
468
|
+
if (user.dailyPassCount < maxDaily) {
|
|
469
|
+
user.dailyPassCount++;
|
|
470
|
+
await gainCash(ctx, session.uid, unitReward, config.currency);
|
|
471
|
+
await session.bot.sendMessage(session.channelId, `🎉 自由挑战通关!奖励 ${unitReward} ${config.currency}!今日领奖进度: [${user.dailyPassCount}/${maxDaily}]`);
|
|
472
|
+
}
|
|
473
|
+
else {
|
|
474
|
+
await session.bot.sendMessage(session.channelId, `🎉 自由挑战通关!今日答题领奖次数已达上限 (${maxDaily}次),本次无货币奖励。`);
|
|
475
|
+
}
|
|
476
|
+
}
|
|
477
|
+
else {
|
|
478
|
+
await session.bot.sendMessage(session.channelId, `💡 自由挑战未达到 90% 正确率,失败无任何惩罚。欢迎随时再次挑战!`);
|
|
479
|
+
}
|
|
480
|
+
await ctx.database.set('driver_exam_user', { id: user.id }, user);
|
|
481
|
+
}
|