koishi-plugin-class-score-system 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.
@@ -0,0 +1,93 @@
1
+ import { Context } from 'koishi'
2
+ import { ServerConfig } from '../models/database'
3
+ import { CsmsApiService } from './api'
4
+
5
+ export class ServerConfigService {
6
+ private ctx: Context
7
+
8
+ constructor(ctx: Context) {
9
+ this.ctx = ctx
10
+ }
11
+
12
+ async saveConfig(guildId: string, name: string, address: string, username: string, token: string): Promise<ServerConfig> {
13
+ const now = new Date()
14
+
15
+ // 检查该群聊是否已有配置
16
+ const existingConfigs = await this.ctx.database.get('server_configs', { guildId })
17
+
18
+ // 自动移除 /api/global_api.php 后缀
19
+ const baseUrl = address.replace(/\/api\/global_api\.php$/, '').replace(/\/$/, '')
20
+
21
+ if (existingConfigs.length > 0) {
22
+ // 更新现有配置
23
+ await this.ctx.database.set('server_configs', { guildId }, {
24
+ name,
25
+ address: baseUrl,
26
+ username,
27
+ token,
28
+ updatedAt: now,
29
+ })
30
+ return { ...existingConfigs[0], name, address: baseUrl, username, token, updatedAt: now }
31
+ }
32
+
33
+ // 创建新配置
34
+ const config = await this.ctx.database.create('server_configs', {
35
+ guildId,
36
+ name,
37
+ address: baseUrl,
38
+ username,
39
+ token,
40
+ createdAt: now,
41
+ updatedAt: now,
42
+ })
43
+
44
+ return config
45
+ }
46
+
47
+ async getConfigByGuild(guildId: string): Promise<ServerConfig | null> {
48
+ const configs = await this.ctx.database.get('server_configs', { guildId })
49
+ return configs[0] || null
50
+ }
51
+
52
+ async getAllConfigs(): Promise<ServerConfig[]> {
53
+ return this.ctx.database.get('server_configs', {})
54
+ }
55
+
56
+ async deleteConfig(guildId: string): Promise<boolean> {
57
+ await this.ctx.database.remove('server_configs', { guildId })
58
+ return true
59
+ }
60
+
61
+ async createApiService(guildId: string): Promise<CsmsApiService | null> {
62
+ const config = await this.getConfigByGuild(guildId)
63
+ if (!config) return null
64
+
65
+ return new CsmsApiService(this.ctx, config.address, config.token)
66
+ }
67
+
68
+ async validateConfig(address: string, username: string, token: string): Promise<{ valid: boolean; error?: string; actualUsername?: string }> {
69
+ try {
70
+ // 自动移除 /api/global_api.php 后缀
71
+ const baseUrl = address.replace(/\/api\/global_api\.php$/, '').replace(/\/$/, '')
72
+ const api = new CsmsApiService(this.ctx, baseUrl, token)
73
+ const result = await api.validateToken()
74
+
75
+ if (result.valid) {
76
+ // 如果提供的用户名与实际登录的管理员用户名不同,给出提示
77
+ if (result.username && result.username !== username) {
78
+ return {
79
+ valid: true,
80
+ actualUsername: result.username,
81
+ error: `警告:提供的用户名 "${username}" 与实际登录的管理员 "${result.username}" 不一致`
82
+ }
83
+ }
84
+ return { valid: true, actualUsername: result.username }
85
+ }
86
+
87
+ return { valid: false, error: 'Token 验证失败,请检查 Token 是否正确' }
88
+ } catch (error: any) {
89
+ this.ctx.logger.error('验证服务器配置失败:', error)
90
+ return { valid: false, error: error.message }
91
+ }
92
+ }
93
+ }
@@ -0,0 +1,51 @@
1
+ export const CONSTANTS = {
2
+ TOKEN_PATTERN: /^[A-Z0-9]+$/,
3
+ QQ_PATTERN: /^\d{5,12}$/,
4
+ SCORE_MIN: -1000,
5
+ SCORE_MAX: 1000,
6
+ RANKING_DEFAULT_LIMIT: 10,
7
+ RANKING_MAX_LIMIT: 50,
8
+ SCORE_LOGS_DEFAULT_LIMIT: 20,
9
+ SCORE_LOGS_MAX_LIMIT: 100,
10
+ USERNAME_MAX_LENGTH: 50,
11
+ DESCRIPTION_MAX_LENGTH: 255,
12
+ }
13
+
14
+ export const ERROR_MESSAGES = {
15
+ NO_SERVER_CONFIG: '未配置CSMS服务器,请先使用 /绑定服务器 命令配置',
16
+ INVALID_TOKEN: '无效的Token格式,Token必须由数字和英文大写字母组成',
17
+ INVALID_QQ: '无效的QQ号码格式',
18
+ INVALID_SCORE: '分数必须在 -1000 到 1000 之间',
19
+ INVALID_USERNAME: '用户名不能为空且长度不能超过50个字符',
20
+ INVALID_DESCRIPTION: '描述信息长度不能超过255个字符',
21
+ INVALID_LIMIT: '数量必须在 1 到 50 之间',
22
+ SERVER_NOT_FOUND: '服务器未找到',
23
+ USER_NOT_FOUND: '用户未找到',
24
+ USER_ALREADY_BOUND: '该QQ号已绑定其他用户',
25
+ USER_NOT_BOUND: '该QQ号未绑定任何用户',
26
+ API_REQUEST_FAILED: 'API请求失败',
27
+ INSUFFICIENT_PERMISSION: '权限不足,仅管理员可执行此操作',
28
+ BINDING_FAILED: '绑定失败',
29
+ UNBINDING_FAILED: '解除绑定失败',
30
+ CONFIGURATION_FAILED: '配置保存失败',
31
+ } as const
32
+
33
+ export const SUCCESS_MESSAGES = {
34
+ SERVER_CONFIGURED: '服务器配置成功',
35
+ SERVER_VALIDATED: '服务器配置验证成功',
36
+ QQ_BOUND: 'QQ绑定成功',
37
+ QQ_UNBOUND: 'QQ解除绑定成功',
38
+ SCORE_ADJUSTED: '积分调整成功',
39
+ USER_CREATED: '用户创建成功',
40
+ } as const
41
+
42
+ export const HELP_MESSAGES = {
43
+ BIND_SERVER: '绑定CSMS服务器\n用法:/绑定服务器 <服务器地址> <管理员用户名> <管理员Token>\n示例:/绑定服务器 https://example.com admin ABC123',
44
+ QUERY_SCORE: '查询积分\n用法:/查询积分 [用户名]\n示例:/查询积分 张三',
45
+ ADJUST_SCORE: '调整积分\n用法:/调整积分 <用户名> <分数> <原因>\n示例:/调整积分 张三 +5 表现优秀',
46
+ BIND_QQ: '绑定QQ\n用法:/绑定QQ <用户名>\n示例:/绑定QQ 张三',
47
+ VIEW_BINDING: '查看绑定\n用法:/查看绑定 [用户名]\n示例:/查看绑定 张三',
48
+ UNBIND_QQ: '解除绑定\n用法:/解除绑定 <QQ号>\n示例:/解除绑定 123456789',
49
+ RANKING: '排行榜\n用法:/排行榜 [数量]\n示例:/排行榜 10',
50
+ STATISTICS: '统计\n用法:/统计 [用户名]\n示例:/统计 张三',
51
+ } as const
@@ -0,0 +1,172 @@
1
+ import { CsmsUser, CsmsScoreLog } from '../services/api'
2
+
3
+ export class Formatter {
4
+ // QQ 消息单条最大长度限制(留有余量)
5
+ private static readonly MAX_MESSAGE_LENGTH = 1800
6
+
7
+ static formatScoreInfo(user: CsmsUser, ranking?: number): string {
8
+ return [
9
+ `用户: ${user.username}`,
10
+ `排名: ${ranking || '未知'}`,
11
+ `总积分: ${user.total_score}`,
12
+ `累计加分: ${user.add_score}`,
13
+ `累计扣分: ${user.deduct_score}`,
14
+ `记录数: ${user.score_count}`,
15
+ ].join('\n')
16
+ }
17
+
18
+ static formatRanking(users: CsmsUser[]): string {
19
+ if (users.length === 0) {
20
+ return '暂无排名数据'
21
+ }
22
+
23
+ const lines = ['【积分排行榜】']
24
+ const displayUsers = users.slice(0, 20) // 最多显示20个
25
+
26
+ displayUsers.forEach((user, index) => {
27
+ const medal = index < 3 ? ['🥇', '🥈', '🥉'][index] : `${index + 1}.`
28
+ lines.push(`${medal} ${user.username}: ${user.total_score}分`)
29
+ })
30
+
31
+ if (users.length > 20) {
32
+ lines.push(`... 共 ${users.length} 人,显示前20名`)
33
+ }
34
+
35
+ return lines.join('\n')
36
+ }
37
+
38
+ /**
39
+ * 格式化排行榜(带实际排名,用于分页)
40
+ */
41
+ static formatRankingWithRank(users: CsmsUser[], startRank: number): string {
42
+ if (users.length === 0) {
43
+ return '暂无排名数据'
44
+ }
45
+
46
+ const lines = ['【积分排行榜】']
47
+
48
+ users.forEach((user, index) => {
49
+ const rank = startRank + index
50
+ const medal = rank <= 3 ? ['🥇', '🥈', '🥉'][rank - 1] : `${rank}.`
51
+ lines.push(`${medal} ${user.username}: ${user.total_score}分`)
52
+ })
53
+
54
+ return lines.join('\n')
55
+ }
56
+
57
+ static getMedal(index: number): string {
58
+ const medals = ['🥇', '🥈', '🥉']
59
+ return medals[index] || `${index + 1}.`
60
+ }
61
+
62
+ static formatScoreLogs(logs: CsmsScoreLog[]): string {
63
+ if (logs.length === 0) {
64
+ return '暂无积分记录'
65
+ }
66
+
67
+ const lines = ['【积分记录】']
68
+ const displayLogs = logs.slice(0, 10)
69
+
70
+ displayLogs.forEach((log) => {
71
+ const change = log.score_change > 0 ? `+${log.score_change}` : log.score_change
72
+ lines.push(`${change}分 - ${log.description}`)
73
+ })
74
+
75
+ if (logs.length > 10) {
76
+ lines.push(`... 共 ${logs.length} 条记录`)
77
+ }
78
+
79
+ return lines.join('\n')
80
+ }
81
+
82
+ static formatStatistics(user: CsmsUser, logs: CsmsScoreLog[]): string {
83
+ const lines = [
84
+ `【${user.username} 的统计信息】`,
85
+ `总积分: ${user.total_score}`,
86
+ `累计加分: ${user.add_score}`,
87
+ `累计扣分: ${user.deduct_score}`,
88
+ `记录数: ${user.score_count}`,
89
+ '最近积分记录:',
90
+ ]
91
+
92
+ const recentLogs = logs.slice(0, 5)
93
+ if (recentLogs.length > 0) {
94
+ recentLogs.forEach((log) => {
95
+ const change = log.score_change > 0 ? `+${log.score_change}` : log.score_change
96
+ lines.push(`${change}分 - ${log.description}`)
97
+ })
98
+ } else {
99
+ lines.push('暂无记录')
100
+ }
101
+
102
+ return lines.join('\n')
103
+ }
104
+
105
+ static formatAddScoreResult(result: any): string {
106
+ const status = result.success ? '积分调整成功' : '积分调整失败'
107
+ const summary = `成功: ${result.summary.success_count} 条,失败: ${result.summary.failed_count} 条`
108
+
109
+ const lines = [status, summary]
110
+
111
+ // 失败时显示错误原因
112
+ if (!result.success && result.message) {
113
+ lines.push(`原因: ${result.message}`)
114
+ }
115
+
116
+ if (result.details && result.details.length > 0) {
117
+ lines.push('详情:')
118
+ result.details.forEach((detail: any) => {
119
+ if (detail.success) {
120
+ const change = detail.score_change > 0 ? '+' : ''
121
+ lines.push(`[OK] ${detail.username}: ${change}${detail.score_change}分`)
122
+ } else {
123
+ lines.push(`[FAIL] ${detail.username}: ${detail.error || '失败'}`)
124
+ }
125
+ })
126
+ }
127
+
128
+ return lines.join('\n')
129
+ }
130
+
131
+ static formatDate(dateString: string): string {
132
+ const date = new Date(dateString)
133
+ return date.toLocaleString('zh-CN', {
134
+ year: 'numeric',
135
+ month: '2-digit',
136
+ day: '2-digit',
137
+ hour: '2-digit',
138
+ minute: '2-digit',
139
+ })
140
+ }
141
+
142
+ /**
143
+ * 安全发送消息,自动分割过长的消息
144
+ * 返回消息片段数组,由调用者处理发送
145
+ */
146
+ static splitMessage(message: string): string[] {
147
+ if (message.length <= this.MAX_MESSAGE_LENGTH) {
148
+ return [message]
149
+ }
150
+
151
+ const parts = message.split('\n')
152
+ const result: string[] = []
153
+ let currentPart = ''
154
+
155
+ for (const line of parts) {
156
+ if ((currentPart + '\n' + line).length > this.MAX_MESSAGE_LENGTH) {
157
+ if (currentPart) {
158
+ result.push(currentPart)
159
+ }
160
+ currentPart = line
161
+ } else {
162
+ currentPart = currentPart ? currentPart + '\n' + line : line
163
+ }
164
+ }
165
+
166
+ if (currentPart) {
167
+ result.push(currentPart)
168
+ }
169
+
170
+ return result
171
+ }
172
+ }
@@ -0,0 +1,114 @@
1
+ import { Context, h } from 'koishi'
2
+
3
+ // 通用竖版图片生成函数
4
+ export async function generateImage(
5
+ ctx: Context,
6
+ html: string,
7
+ options: { width?: number; height?: number } = {}
8
+ ): Promise<Buffer | null> {
9
+ const width = options.width || 450
10
+ const height = options.height || 800
11
+
12
+ try {
13
+ const page = await (ctx as any).puppeteer.page()
14
+
15
+ await page.setViewport({
16
+ width,
17
+ height,
18
+ deviceScaleFactor: 2,
19
+ })
20
+
21
+ await page.setContent(html, { waitUntil: 'networkidle0' })
22
+ await page.evaluateHandle('document.fonts.ready')
23
+
24
+ // 等待背景图片加载
25
+ await page.evaluate(() => {
26
+ return new Promise<void>((resolve) => {
27
+ const img = document.querySelector('body') as HTMLElement
28
+ if (img && img.style.backgroundImage) {
29
+ const bgImg = new Image()
30
+ bgImg.onload = () => resolve()
31
+ bgImg.onerror = () => resolve()
32
+ bgImg.src = img.style.backgroundImage.replace(/url\(['"]?(.+?)['"]?\)/, '$1')
33
+ } else {
34
+ resolve()
35
+ }
36
+ })
37
+ })
38
+
39
+ const screenshot = await page.screenshot({
40
+ type: 'png',
41
+ clip: { x: 0, y: 0, width, height },
42
+ })
43
+
44
+ return screenshot as Buffer
45
+ } catch (error) {
46
+ ctx.logger.error('生成图片失败:', error)
47
+ return null
48
+ }
49
+ }
50
+
51
+ // 发送图片(优先图片,失败回退文字)
52
+ export async function sendImageOrText(
53
+ ctx: Context,
54
+ session: any,
55
+ html: string,
56
+ fallbackText: string,
57
+ options: { width?: number; height?: number } = {}
58
+ ): Promise<void> {
59
+ const image = await generateImage(ctx, html, options)
60
+ if (image) {
61
+ await session.send(h.image(image, 'image/png'))
62
+ } else {
63
+ await session.send(fallbackText)
64
+ }
65
+ }
66
+
67
+ // 通用基础模板样式
68
+ export function getBaseStyles(): string {
69
+ return `
70
+ * { margin: 0; padding: 0; box-sizing: border-box; }
71
+ body {
72
+ font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'PingFang SC', 'Microsoft YaHei', sans-serif;
73
+ background: url('https://api.yppp.net/pe.php') center/cover no-repeat;
74
+ width: 450px;
75
+ min-height: 800px;
76
+ display: flex;
77
+ justify-content: center;
78
+ align-items: flex-start;
79
+ padding: 40px 0;
80
+ }
81
+ .container {
82
+ background: rgba(255, 255, 255, 0.5);
83
+ border-radius: 24px;
84
+ padding: 28px 24px;
85
+ width: 380px;
86
+ box-shadow: 0 10px 40px rgba(0, 0, 0, 0.15);
87
+ margin: 0 auto;
88
+ }
89
+ .title {
90
+ text-align: center;
91
+ font-size: 22px;
92
+ font-weight: bold;
93
+ color: #2c3e50;
94
+ margin-bottom: 20px;
95
+ padding-bottom: 12px;
96
+ border-bottom: 2px solid #3498db;
97
+ }
98
+ .content {
99
+ color: #555;
100
+ font-size: 14px;
101
+ line-height: 1.8;
102
+ white-space: pre-wrap;
103
+ word-break: break-all;
104
+ }
105
+ .footer {
106
+ text-align: center;
107
+ margin-top: 16px;
108
+ padding-top: 12px;
109
+ border-top: 1px solid rgba(0,0,0,0.1);
110
+ color: #95a5a6;
111
+ font-size: 10px;
112
+ }
113
+ `
114
+ }
@@ -0,0 +1,3 @@
1
+ export * from './constants'
2
+ export * from './validator'
3
+ export * from './formatter'
@@ -0,0 +1,71 @@
1
+ import { CONSTANTS, ERROR_MESSAGES } from './constants'
2
+
3
+ export class Validator {
4
+ static validateToken(token: string): boolean {
5
+ return CONSTANTS.TOKEN_PATTERN.test(token)
6
+ }
7
+
8
+ static validateQq(qqNumber: string): boolean {
9
+ // 更宽松的验证:只要是数字或者包含数字的字符串即可
10
+ // 支持沙盒环境的用户ID格式
11
+ return qqNumber && qqNumber.length > 0 && /^\d+$/.test(qqNumber)
12
+ }
13
+
14
+ static validateScore(score: number): boolean {
15
+ return score >= CONSTANTS.SCORE_MIN && score <= CONSTANTS.SCORE_MAX
16
+ }
17
+
18
+ static validateUsername(username: string): boolean {
19
+ return username.length > 0 && username.length <= CONSTANTS.USERNAME_MAX_LENGTH
20
+ }
21
+
22
+ static validateDescription(description: string): boolean {
23
+ return description.length <= CONSTANTS.DESCRIPTION_MAX_LENGTH
24
+ }
25
+
26
+ static validateLimit(limit: number, max: number = CONSTANTS.RANKING_MAX_LIMIT): boolean {
27
+ return limit > 0 && limit <= max
28
+ }
29
+
30
+ static validateScoreWithMessage(score: number): string | null {
31
+ if (!this.validateScore(score)) {
32
+ return ERROR_MESSAGES.INVALID_SCORE
33
+ }
34
+ return null
35
+ }
36
+
37
+ static validateQqWithMessage(qqNumber: string): string | null {
38
+ if (!this.validateQq(qqNumber)) {
39
+ return ERROR_MESSAGES.INVALID_QQ
40
+ }
41
+ return null
42
+ }
43
+
44
+ static validateTokenWithMessage(token: string): string | null {
45
+ if (!this.validateToken(token)) {
46
+ return ERROR_MESSAGES.INVALID_TOKEN
47
+ }
48
+ return null
49
+ }
50
+
51
+ static validateUsernameWithMessage(username: string): string | null {
52
+ if (!this.validateUsername(username)) {
53
+ return ERROR_MESSAGES.INVALID_USERNAME
54
+ }
55
+ return null
56
+ }
57
+
58
+ static validateDescriptionWithMessage(description: string): string | null {
59
+ if (!this.validateDescription(description)) {
60
+ return ERROR_MESSAGES.INVALID_DESCRIPTION
61
+ }
62
+ return null
63
+ }
64
+
65
+ static validateLimitWithMessage(limit: number, max: number = CONSTANTS.RANKING_MAX_LIMIT): string | null {
66
+ if (!this.validateLimit(limit, max)) {
67
+ return `数量必须在 1 到 ${max} 之间`
68
+ }
69
+ return null
70
+ }
71
+ }