koishi-plugin-ll-mc 2.0.0 → 2.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/index.js +333 -105
- package/package.json +1 -1
package/index.js
CHANGED
|
@@ -1,55 +1,82 @@
|
|
|
1
1
|
'use strict'
|
|
2
2
|
|
|
3
|
-
// koishi-plugin-ll-mc v2.
|
|
4
|
-
//
|
|
5
|
-
//
|
|
6
|
-
// -
|
|
7
|
-
// -
|
|
8
|
-
//
|
|
9
|
-
|
|
10
|
-
|
|
3
|
+
// koishi-plugin-ll-mc v2.1.0(照抄 astrbot_plugin_message_stats 功能 + 新增群排行)
|
|
4
|
+
// 参考:https://github.com/xiaoruange39/astrbot_plugin_message_stats
|
|
5
|
+
// 移植到 Koishi 的功能:
|
|
6
|
+
// - 发言榜(今日/昨日/本周/本月/本年/去年/总榜,文字或图片)
|
|
7
|
+
// - 群发言榜(新增:跨群排行,群名正确)
|
|
8
|
+
// - 我的发言 / 发言榜帮助
|
|
9
|
+
// - 设置发言榜数量 / 设置发言榜图片 / 清除发言榜单
|
|
10
|
+
// - 定时推送(自动 + 手动)
|
|
11
|
+
// 官机(官方QQ)适配:昵称取消息自带,群名走 getGuild + groupNameMap 手动指定兜底
|
|
12
|
+
|
|
13
|
+
const { Schema, h } = require('koishi')
|
|
11
14
|
|
|
12
15
|
exports.name = 'll-mc'
|
|
13
16
|
|
|
14
|
-
exports.usage =
|
|
17
|
+
exports.usage = `消息统计插件(移植自 astrbot_plugin_message_stats,并新增群排行)。
|
|
15
18
|
|
|
16
19
|
## 命令
|
|
17
20
|
|
|
18
|
-
-
|
|
19
|
-
-
|
|
20
|
-
-
|
|
21
|
+
- \`发言榜 [周期] [人数]\`:本群成员发言排行。周期:今日/昨日/本周/本月/本年/去年/总榜(默认总榜)
|
|
22
|
+
- \`群发言榜 [周期] [人数]\`:**所有群的发言排行(群名正确显示)**,当前群带 ★
|
|
23
|
+
- \`今日发言榜\` / \`本周发言榜\` / \`本月发言榜\`:快捷查看
|
|
24
|
+
- \`我的发言\`:自己的发言统计
|
|
25
|
+
- \`设置发言榜数量 <N>\`:设置本群排行显示人数
|
|
26
|
+
- \`设置发言榜图片 <auto|文字|图片>\`:设置本群排行显示模式(auto=有图片服务用图片,否则文字)
|
|
27
|
+
- \`清除发言榜单\`:清空本群统计
|
|
28
|
+
- \`手动推送发言榜 [周期]\`:立即向定时目标群推送排行
|
|
29
|
+
- \`发言榜定时状态\`:查看定时推送配置
|
|
21
30
|
|
|
22
|
-
##
|
|
31
|
+
## 定时推送(设置页配置)
|
|
23
32
|
|
|
24
|
-
-
|
|
25
|
-
-
|
|
26
|
-
-
|
|
33
|
+
- \`timerEnabled\`:启用后每天到点自动推送排行
|
|
34
|
+
- \`timerTime\`:推送时间(HH:MM)
|
|
35
|
+
- \`timerType\`:推送内容类型(day=昨日 / week=本周 / month=本月)
|
|
36
|
+
- \`timerGroups\`:推送目标群(留空=推送给所有有数据的群)
|
|
27
37
|
|
|
28
|
-
##
|
|
38
|
+
## 官机说明
|
|
29
39
|
|
|
30
|
-
- \`
|
|
31
|
-
-
|
|
32
|
-
- \`
|
|
33
|
-
- \`refreshGroupNameInterval\`:群名刷新间隔(秒,默认3600)
|
|
34
|
-
- \`countBots\`:是否统计机器人自己的发言(默认否)
|
|
40
|
+
- **群名**:官机消息不带群名,插件用 \`getGuild\` 获取(需在 QQ 开放平台申请「获取群信息」权限);也可在 \`groupNameMap\` 手动指定(openid → 群名),保证群排行群名正确
|
|
41
|
+
- **昵称**:官机消息自带昵称,零接口权限
|
|
42
|
+
- **图片排行**:需要安装 \`koishi-plugin-puppeteer\` 才显示图片,否则自动降级为文字
|
|
35
43
|
`
|
|
36
44
|
|
|
37
45
|
const Config = Schema.object({
|
|
38
46
|
rankLimit: Schema.number()
|
|
39
47
|
.min(1).max(100).default(20)
|
|
40
48
|
.description('排行默认显示人数'),
|
|
49
|
+
imageMode: Schema.union([
|
|
50
|
+
Schema.const('auto').description('自动:有图片服务用图片,否则文字'),
|
|
51
|
+
Schema.const('text').description('始终文字'),
|
|
52
|
+
Schema.const('image').description('始终图片(无服务时降级文字)'),
|
|
53
|
+
]).default('auto').description('排行显示模式'),
|
|
41
54
|
ignoredGroups: Schema.array(String)
|
|
42
55
|
.default([])
|
|
43
56
|
.description('不统计的群(填群ID,官机填 openid)'),
|
|
44
57
|
groupNameMap: Schema.dict(String)
|
|
45
58
|
.default({})
|
|
46
|
-
.description('手动指定群名:键=群ID
|
|
59
|
+
.description('手动指定群名:键=群ID(官机 openid),值=群名。保证群排行群名正确'),
|
|
47
60
|
refreshGroupNameInterval: Schema.number()
|
|
48
61
|
.min(60).default(3600)
|
|
49
|
-
.description('
|
|
62
|
+
.description('群名自动刷新间隔(秒)'),
|
|
50
63
|
countBots: Schema.boolean()
|
|
51
64
|
.default(false)
|
|
52
65
|
.description('是否统计机器人自己的发言'),
|
|
66
|
+
timerEnabled: Schema.boolean()
|
|
67
|
+
.default(false)
|
|
68
|
+
.description('定时推送:启用后每天到点自动推送排行'),
|
|
69
|
+
timerTime: Schema.string()
|
|
70
|
+
.default('00:00')
|
|
71
|
+
.description('定时推送时间(HH:MM,24小时制)'),
|
|
72
|
+
timerType: Schema.union([
|
|
73
|
+
Schema.const('day').description('昨日发言榜'),
|
|
74
|
+
Schema.const('week').description('本周发言榜'),
|
|
75
|
+
Schema.const('month').description('本月发言榜'),
|
|
76
|
+
]).default('day').description('定时推送的内容类型'),
|
|
77
|
+
timerGroups: Schema.array(String)
|
|
78
|
+
.default([])
|
|
79
|
+
.description('定时推送目标群(留空=所有有数据的群)'),
|
|
53
80
|
verbose: Schema.boolean()
|
|
54
81
|
.default(false)
|
|
55
82
|
.description('调试:控制台打印统计详情'),
|
|
@@ -63,7 +90,6 @@ function apply(ctx, config) {
|
|
|
63
90
|
const logger = ctx.logger('ll-mc')
|
|
64
91
|
|
|
65
92
|
// ===== 数据表 =====
|
|
66
|
-
// ll_mc_stats: 每群每人每天计数
|
|
67
93
|
ctx.model.extend('ll_mc_stats', {
|
|
68
94
|
channelId: 'string',
|
|
69
95
|
userId: 'string',
|
|
@@ -71,18 +97,25 @@ function apply(ctx, config) {
|
|
|
71
97
|
date: 'string',
|
|
72
98
|
count: 'unsigned',
|
|
73
99
|
}, { primary: ['channelId', 'userId', 'date'] })
|
|
74
|
-
// ll_mc_groups: 群名缓存(channelId -> 群名)
|
|
75
100
|
ctx.model.extend('ll_mc_groups', {
|
|
76
101
|
channelId: 'string',
|
|
77
102
|
name: 'string',
|
|
78
103
|
platform: 'string',
|
|
104
|
+
botSid: 'string',
|
|
79
105
|
updatedAt: 'timestamp',
|
|
80
106
|
}, { primary: ['channelId'] })
|
|
107
|
+
ctx.model.extend('ll_mc_settings', {
|
|
108
|
+
channelId: 'string',
|
|
109
|
+
rankLimit: 'unsigned',
|
|
110
|
+
imageMode: 'string',
|
|
111
|
+
}, { primary: ['channelId'] })
|
|
81
112
|
|
|
82
|
-
// =====
|
|
83
|
-
const nameCache = new Map()
|
|
84
|
-
const
|
|
85
|
-
const
|
|
113
|
+
// ===== 缓存 =====
|
|
114
|
+
const nameCache = new Map()
|
|
115
|
+
const settingsCache = new Map()
|
|
116
|
+
const lastNameRefresh = new Map()
|
|
117
|
+
const warnThrottle = new Map()
|
|
118
|
+
let firedTimer = { date: '' }
|
|
86
119
|
|
|
87
120
|
function throttledWarn(key, message) {
|
|
88
121
|
const now = Date.now()
|
|
@@ -95,36 +128,47 @@ function apply(ctx, config) {
|
|
|
95
128
|
function dateStr(d) {
|
|
96
129
|
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`
|
|
97
130
|
}
|
|
98
|
-
function
|
|
99
|
-
|
|
131
|
+
function nowHM() {
|
|
132
|
+
const d = new Date()
|
|
133
|
+
return `${String(d.getHours()).padStart(2, '0')}:${String(d.getMinutes()).padStart(2, '0')}`
|
|
100
134
|
}
|
|
101
|
-
//
|
|
135
|
+
// 解析周期 → { key, label, start, end }(end 可空)
|
|
102
136
|
function parsePeriod(period) {
|
|
103
137
|
const p = String(period || '').trim()
|
|
104
138
|
const now = new Date()
|
|
105
|
-
if (!p || /总|全部|all/i.test(p)) return { key: 'all', label: '总榜', start: null }
|
|
106
|
-
if (
|
|
139
|
+
if (!p || /总|全部|all/i.test(p)) return { key: 'all', label: '总榜', start: null, end: null }
|
|
140
|
+
if (/去年/.test(p)) {
|
|
141
|
+
const y = now.getFullYear() - 1
|
|
142
|
+
return { key: 'lastyear', label: '去年', start: `${y}-01-01`, end: `${y}-12-31` }
|
|
143
|
+
}
|
|
144
|
+
if (/本年|今年|年/.test(p)) {
|
|
145
|
+
return { key: 'year', label: '本年', start: `${now.getFullYear()}-01-01`, end: null }
|
|
146
|
+
}
|
|
147
|
+
if (/本?周/.test(p)) {
|
|
107
148
|
const d = new Date(now)
|
|
108
|
-
d.setDate(d.getDate() - ((d.getDay() + 6) % 7))
|
|
109
|
-
return { key: 'week', label: '本周', start: dateStr(d) }
|
|
149
|
+
d.setDate(d.getDate() - ((d.getDay() + 6) % 7))
|
|
150
|
+
return { key: 'week', label: '本周', start: dateStr(d), end: null }
|
|
151
|
+
}
|
|
152
|
+
if (/本?月/.test(p)) {
|
|
153
|
+
return { key: 'month', label: '本月', start: `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, '0')}-01`, end: null }
|
|
110
154
|
}
|
|
111
|
-
if (
|
|
112
|
-
|
|
155
|
+
if (/昨|昨天|昨日/.test(p)) {
|
|
156
|
+
const d = new Date(now)
|
|
157
|
+
d.setDate(d.getDate() - 1)
|
|
158
|
+
return { key: 'yesterday', label: '昨日', start: dateStr(d), end: dateStr(d) }
|
|
113
159
|
}
|
|
114
|
-
if (
|
|
160
|
+
if (/今|今?日|天/.test(p)) return { key: 'day', label: '今日', start: dateStr(now), end: dateStr(now) }
|
|
115
161
|
return null
|
|
116
162
|
}
|
|
117
163
|
|
|
118
|
-
// =====
|
|
164
|
+
// ===== 昵称解析 =====
|
|
119
165
|
async function resolveNick(session, userId) {
|
|
120
166
|
const author = session.author
|
|
121
167
|
if (author?.nick) return String(author.nick)
|
|
122
168
|
if (author?.name) return String(author.name)
|
|
123
|
-
// 官机:原始事件自带 member.nick / author.username,零 API
|
|
124
169
|
const raw = session.event?._data?.d
|
|
125
170
|
if (raw?.member?.nick) return String(raw.member.nick)
|
|
126
171
|
if (raw?.author?.username) return String(raw.author.username)
|
|
127
|
-
// 兜底:getGuildMember(需"获取群成员信息"权限,失败降级为 ID)
|
|
128
172
|
if (session.guildId && !session.isDirect && typeof session.bot?.getGuildMember === 'function') {
|
|
129
173
|
try {
|
|
130
174
|
const member = await session.bot.getGuildMember(session.guildId, userId)
|
|
@@ -139,15 +183,15 @@ function apply(ctx, config) {
|
|
|
139
183
|
}
|
|
140
184
|
|
|
141
185
|
// ===== 群名 =====
|
|
142
|
-
async function setGroupName(guildId, name) {
|
|
186
|
+
async function setGroupName(guildId, name, botSid) {
|
|
143
187
|
if (!name || name === guildId) return
|
|
144
188
|
nameCache.set(guildId, { name, t: Date.now() })
|
|
145
189
|
try {
|
|
146
190
|
await ctx.database.upsert('ll_mc_groups', [{
|
|
147
|
-
channelId: guildId, name, platform: '', updatedAt: Date.now(),
|
|
191
|
+
channelId: guildId, name, platform: '', botSid: botSid || '', updatedAt: Date.now(),
|
|
148
192
|
}], ['channelId'])
|
|
149
193
|
}
|
|
150
|
-
catch { /*
|
|
194
|
+
catch { /* 忽略 */ }
|
|
151
195
|
}
|
|
152
196
|
async function getGroupName(guildId) {
|
|
153
197
|
if (config.groupNameMap[guildId]) return config.groupNameMap[guildId]
|
|
@@ -163,28 +207,31 @@ function apply(ctx, config) {
|
|
|
163
207
|
catch { /* 忽略 */ }
|
|
164
208
|
return guildId
|
|
165
209
|
}
|
|
166
|
-
|
|
210
|
+
async function getGroupBotSid(guildId) {
|
|
211
|
+
try {
|
|
212
|
+
const rows = await ctx.database.get('ll_mc_groups', { channelId: guildId })
|
|
213
|
+
return rows[0]?.botSid || ''
|
|
214
|
+
}
|
|
215
|
+
catch { return '' }
|
|
216
|
+
}
|
|
167
217
|
async function refreshGroupName(session) {
|
|
168
218
|
const guildId = session.guildId
|
|
169
219
|
const now = Date.now()
|
|
170
220
|
if ((lastNameRefresh.get(guildId) || 0) > now - config.refreshGroupNameInterval * 1000) return
|
|
171
221
|
lastNameRefresh.set(guildId, now)
|
|
172
|
-
// 1. OneBot:消息自带群名
|
|
173
222
|
if (session.event?.channel?.name) {
|
|
174
|
-
await setGroupName(guildId, session.event.channel.name)
|
|
223
|
+
await setGroupName(guildId, session.event.channel.name, session.bot?.sid)
|
|
175
224
|
return
|
|
176
225
|
}
|
|
177
|
-
// 2. 手动指定
|
|
178
226
|
if (config.groupNameMap[guildId]) {
|
|
179
|
-
await setGroupName(guildId, config.groupNameMap[guildId])
|
|
227
|
+
await setGroupName(guildId, config.groupNameMap[guildId], session.bot?.sid)
|
|
180
228
|
return
|
|
181
229
|
}
|
|
182
|
-
// 3. 官机:getGuild(内部 getGuildInfo,需"获取群信息"权限)
|
|
183
230
|
if (typeof session.bot?.getGuild === 'function') {
|
|
184
231
|
try {
|
|
185
232
|
const g = await session.bot.getGuild(guildId)
|
|
186
233
|
if (g?.name) {
|
|
187
|
-
await setGroupName(guildId, g.name)
|
|
234
|
+
await setGroupName(guildId, g.name, session.bot?.sid)
|
|
188
235
|
return
|
|
189
236
|
}
|
|
190
237
|
}
|
|
@@ -194,6 +241,39 @@ function apply(ctx, config) {
|
|
|
194
241
|
}
|
|
195
242
|
}
|
|
196
243
|
|
|
244
|
+
// ===== 群设置 =====
|
|
245
|
+
async function getGroupSettings(guildId) {
|
|
246
|
+
const cached = settingsCache.get(guildId)
|
|
247
|
+
if (cached) return cached
|
|
248
|
+
let s = { rankLimit: config.rankLimit, imageMode: config.imageMode }
|
|
249
|
+
try {
|
|
250
|
+
const rows = await ctx.database.get('ll_mc_settings', { channelId: guildId })
|
|
251
|
+
if (rows[0]) {
|
|
252
|
+
s = {
|
|
253
|
+
rankLimit: rows[0].rankLimit || config.rankLimit,
|
|
254
|
+
imageMode: rows[0].imageMode || config.imageMode,
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
catch { /* 忽略 */ }
|
|
259
|
+
settingsCache.set(guildId, s)
|
|
260
|
+
return s
|
|
261
|
+
}
|
|
262
|
+
async function setGroupSettings(guildId, patch) {
|
|
263
|
+
const cur = await getGroupSettings(guildId)
|
|
264
|
+
const next = { ...cur, ...patch }
|
|
265
|
+
settingsCache.set(guildId, next)
|
|
266
|
+
try {
|
|
267
|
+
await ctx.database.upsert('ll_mc_settings', [{
|
|
268
|
+
channelId: guildId,
|
|
269
|
+
rankLimit: next.rankLimit,
|
|
270
|
+
imageMode: next.imageMode,
|
|
271
|
+
}], ['channelId'])
|
|
272
|
+
}
|
|
273
|
+
catch { /* 忽略 */ }
|
|
274
|
+
return next
|
|
275
|
+
}
|
|
276
|
+
|
|
197
277
|
// ===== 消息统计 =====
|
|
198
278
|
ctx.on('message', async (session) => {
|
|
199
279
|
try {
|
|
@@ -202,7 +282,6 @@ function apply(ctx, config) {
|
|
|
202
282
|
const userId = session.userId
|
|
203
283
|
if (!config.countBots && session.selfId === session.userId) return
|
|
204
284
|
if (config.ignoredGroups.includes(guildId)) return
|
|
205
|
-
|
|
206
285
|
const date = todayStr()
|
|
207
286
|
const rows = await ctx.database.get('ll_mc_stats', { channelId: guildId, userId, date })
|
|
208
287
|
const count = (rows[0]?.count || 0) + 1
|
|
@@ -215,15 +294,18 @@ function apply(ctx, config) {
|
|
|
215
294
|
catch (e) {
|
|
216
295
|
logger.warn('[ll-mc] 消息统计失败:', e?.message)
|
|
217
296
|
}
|
|
218
|
-
// 群名刷新独立于计数,失败不影响统计
|
|
219
297
|
refreshGroupName(session).catch(() => {})
|
|
220
298
|
})
|
|
299
|
+
function todayStr() {
|
|
300
|
+
return dateStr(new Date())
|
|
301
|
+
}
|
|
221
302
|
|
|
222
|
-
// =====
|
|
223
|
-
// 返回 [{ key, nick/name, count }] 按 count 降序
|
|
303
|
+
// ===== 聚合 =====
|
|
224
304
|
async function aggregateStats(range, groupBy) {
|
|
225
|
-
const
|
|
226
|
-
|
|
305
|
+
const cond = {}
|
|
306
|
+
if (range.start) cond.date = { $gte: range.start }
|
|
307
|
+
if (range.end) cond.date = { ...(cond.date || {}), $lte: range.end }
|
|
308
|
+
const rows = await ctx.database.get('ll_mc_stats', cond)
|
|
227
309
|
const map = new Map()
|
|
228
310
|
for (const row of rows) {
|
|
229
311
|
const key = groupBy === 'user' ? `${row.channelId}:${row.userId}` : row.channelId
|
|
@@ -236,74 +318,190 @@ function apply(ctx, config) {
|
|
|
236
318
|
.sort((a, b) => b.count - a.count)
|
|
237
319
|
}
|
|
238
320
|
|
|
239
|
-
// =====
|
|
321
|
+
// ===== 图片渲染(puppeteer,可选) =====
|
|
322
|
+
async function renderRankImage(title, subtitle, rows) {
|
|
323
|
+
try {
|
|
324
|
+
if (typeof ctx.puppeteer?.page !== 'function') return null
|
|
325
|
+
const page = await ctx.puppeteer.page()
|
|
326
|
+
const body = rows.map((r, i) => `
|
|
327
|
+
<div class="row">
|
|
328
|
+
<div class="no">${i + 1}</div>
|
|
329
|
+
<div class="name">${escapeHtml(r.name)}${r.mark ? ' <span class="star">★</span>' : ''}</div>
|
|
330
|
+
<div class="bar"><div class="fill" style="width:${r.percent}%"></div></div>
|
|
331
|
+
<div class="count">${r.count} 条</div>
|
|
332
|
+
</div>`).join('')
|
|
333
|
+
await page.setContent(`<!DOCTYPE html><html><head><meta charset="utf-8"><style>
|
|
334
|
+
body{margin:0;background:linear-gradient(135deg,#1a1a2e,#16213e);font-family:sans-serif;color:#fff;padding:24px;width:900px}
|
|
335
|
+
.title{font-size:28px;font-weight:bold;margin-bottom:4px}
|
|
336
|
+
.sub{font-size:14px;color:#8a94b8;margin-bottom:16px}
|
|
337
|
+
.row{display:flex;align-items:center;background:rgba(255,255,255,.06);border-radius:10px;padding:10px 14px;margin-bottom:8px}
|
|
338
|
+
.no{width:36px;font-size:18px;font-weight:bold;color:#ffd166;flex-shrink:0}
|
|
339
|
+
.name{width:220px;font-size:16px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;flex-shrink:0}
|
|
340
|
+
.star{color:#ffd166}
|
|
341
|
+
.bar{flex:1;height:10px;background:rgba(255,255,255,.12);border-radius:5px;margin:0 12px;overflow:hidden}
|
|
342
|
+
.fill{height:100%;background:linear-gradient(90deg,#06b6d4,#3b82f6);border-radius:5px}
|
|
343
|
+
.count{width:80px;text-align:right;font-size:15px;color:#a5f3fc;flex-shrink:0}
|
|
344
|
+
</style></head><body>
|
|
345
|
+
<div class="title">${escapeHtml(title)}</div>
|
|
346
|
+
<div class="sub">${escapeHtml(subtitle)}</div>
|
|
347
|
+
${body}
|
|
348
|
+
</body></html>`, { waitUntil: 'networkidle0' })
|
|
349
|
+
await page.waitForSelector('.title')
|
|
350
|
+
const buf = await page.screenshot({ type: 'png' })
|
|
351
|
+
await page.close().catch(() => {})
|
|
352
|
+
return buf
|
|
353
|
+
}
|
|
354
|
+
catch (e) {
|
|
355
|
+
logger.warn('[ll-mc] 排行图片渲染失败,降级文字:', e?.message)
|
|
356
|
+
return null
|
|
357
|
+
}
|
|
358
|
+
}
|
|
359
|
+
function escapeHtml(s) {
|
|
360
|
+
return String(s == null ? '' : s).replace(/[&<>"']/g, c => ({
|
|
361
|
+
'&': '&', '<': '<', '>': '>', '"': '"', "'": ''',
|
|
362
|
+
}[c]))
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
// ===== 命令:发言榜(成员) =====
|
|
240
366
|
async function memberRanking(session, period, limit) {
|
|
241
367
|
if (session.isDirect || !session.guildId) return '❌ 请在群聊中使用'
|
|
242
368
|
const range = parsePeriod(period)
|
|
243
|
-
if (!range) return '❌
|
|
244
|
-
const
|
|
369
|
+
if (!range) return '❌ 周期参数无效,可用:今日/昨日/本周/本月/本年/去年/总榜'
|
|
370
|
+
const gs = await getGroupSettings(session.guildId)
|
|
371
|
+
const n = Math.min(Math.max(limit || gs.rankLimit || config.rankLimit, 1), 100)
|
|
245
372
|
const list = await aggregateStats(range, 'user')
|
|
246
|
-
const
|
|
247
|
-
const mine =
|
|
248
|
-
const
|
|
373
|
+
const inGroup = list.filter(x => x.key.startsWith(`${session.guildId}:`))
|
|
374
|
+
const mine = inGroup.find(x => x.key === `${session.guildId}:${session.userId}`)
|
|
375
|
+
const top = inGroup.slice(0, n)
|
|
249
376
|
const groupRows = await ctx.database.get('ll_mc_stats', { channelId: session.guildId })
|
|
250
|
-
const
|
|
251
|
-
const
|
|
252
|
-
|
|
253
|
-
const
|
|
254
|
-
|
|
377
|
+
const totalAll = groupRows.reduce((s, r) => s + (r.count || 0), 0)
|
|
378
|
+
const totalRange = inGroup.reduce((s, x) => s + x.count, 0)
|
|
379
|
+
|
|
380
|
+
const title = `${range.label}发言排行 TOP ${Math.min(n, inGroup.length)}`
|
|
381
|
+
const subtitle = `本群${range.label}共 ${totalRange} 条 · 历史累计 ${totalAll} 条`
|
|
382
|
+
const rows = top.map(x => {
|
|
383
|
+
const isMine = x.key === `${session.guildId}:${session.userId}`
|
|
384
|
+
return { name: x.nick || x.key.split(':')[1], count: x.count, percent: Math.max(top[0].count ? Math.round(x.count / top[0].count * 100) : 0, 2), mark: isMine }
|
|
385
|
+
})
|
|
386
|
+
const buf = await renderRankImage(title, subtitle, rows)
|
|
387
|
+
if (buf) return h.image(buf)
|
|
388
|
+
const lines = [`📊 ${title}`]
|
|
389
|
+
top.forEach((x, i) => {
|
|
255
390
|
const mark = x.key === `${session.guildId}:${session.userId}` ? ' ★' : ''
|
|
256
391
|
lines.push(`${i + 1}. ${x.nick || x.key.split(':')[1]} · ${x.count}条${mark}`)
|
|
257
392
|
})
|
|
258
|
-
lines.push(`——
|
|
259
|
-
if (
|
|
393
|
+
lines.push(`—— ${subtitle}`)
|
|
394
|
+
if (mine) lines.push(`我的排名:第 ${inGroup.indexOf(mine) + 1} 名(${mine.count}条)`)
|
|
260
395
|
return lines.join('\n')
|
|
261
396
|
}
|
|
262
397
|
|
|
263
|
-
// =====
|
|
398
|
+
// ===== 命令:群发言榜(新增) =====
|
|
264
399
|
async function groupRanking(session, period, limit) {
|
|
265
400
|
const range = parsePeriod(period)
|
|
266
|
-
if (!range) return '❌
|
|
267
|
-
const
|
|
401
|
+
if (!range) return '❌ 周期参数无效,可用:今日/昨日/本周/本月/本年/去年/总榜'
|
|
402
|
+
const gs = session.guildId ? await getGroupSettings(session.guildId) : { rankLimit: config.rankLimit }
|
|
403
|
+
const n = Math.min(Math.max(limit || gs.rankLimit || config.rankLimit, 1), 100)
|
|
268
404
|
const list = await aggregateStats(range, 'group')
|
|
405
|
+
const top = list.slice(0, n)
|
|
269
406
|
const current = session.guildId
|
|
270
|
-
const
|
|
271
|
-
for (
|
|
272
|
-
const
|
|
407
|
+
const rows = []
|
|
408
|
+
for (const x of top) {
|
|
409
|
+
const name = await getGroupName(x.key)
|
|
410
|
+
rows.push({ name, count: x.count, percent: Math.max(top[0].count ? Math.round(x.count / top[0].count * 100) : 0, 2), mark: x.key === current })
|
|
411
|
+
}
|
|
412
|
+
const title = `群${range.label}发言排行 TOP ${Math.min(n, top.length)}`
|
|
413
|
+
const subtitle = '跨群排行 · 群名来自 getGuild(需"获取群信息"权限)或 groupNameMap'
|
|
414
|
+
const buf = await renderRankImage(title, subtitle, rows)
|
|
415
|
+
if (buf) return h.image(buf)
|
|
416
|
+
const lines = [`🏆 ${title}`]
|
|
417
|
+
for (let i = 0; i < top.length; i++) {
|
|
418
|
+
const x = top[i]
|
|
273
419
|
const name = await getGroupName(x.key)
|
|
274
420
|
const mark = x.key === current ? ' ★当前群' : ''
|
|
275
421
|
lines.push(`${i + 1}. ${name} · ${x.count}条${mark}`)
|
|
276
422
|
}
|
|
277
|
-
lines.push(
|
|
423
|
+
lines.push(`—— ${subtitle}`)
|
|
278
424
|
return lines.join('\n')
|
|
279
425
|
}
|
|
280
426
|
|
|
281
|
-
// =====
|
|
427
|
+
// ===== 命令:我的发言 =====
|
|
282
428
|
async function myStats(session) {
|
|
283
429
|
if (session.isDirect || !session.guildId) return '❌ 请在群聊中使用'
|
|
284
430
|
const guildId = session.guildId
|
|
285
431
|
const userId = session.userId
|
|
286
|
-
const
|
|
287
|
-
{ key: 'day', label: '今日' },
|
|
288
|
-
{ key: 'week', label: '本周' },
|
|
289
|
-
{ key: 'month', label: '本月' },
|
|
290
|
-
{ key: 'all', label: '总榜' },
|
|
291
|
-
]
|
|
432
|
+
const labels = [['day', '今日'], ['yesterday', '昨日'], ['week', '本周'], ['month', '本月'], ['year', '本年'], ['all', '总榜']]
|
|
292
433
|
const lines = ['👤 我的发言统计(本群)']
|
|
293
|
-
for (const
|
|
294
|
-
const range = parsePeriod(
|
|
295
|
-
const
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
const rows = await ctx.database.get('ll_mc_stats',
|
|
299
|
-
|
|
300
|
-
lines.push(`${p.label}:${total} 条`)
|
|
434
|
+
for (const [key, label] of labels) {
|
|
435
|
+
const range = parsePeriod(label === '总榜' ? '' : label)
|
|
436
|
+
const cond = { channelId: guildId, userId }
|
|
437
|
+
if (range.start) cond.date = { $gte: range.start }
|
|
438
|
+
if (range.end) cond.date = { ...(cond.date || {}), $lte: range.end }
|
|
439
|
+
const rows = await ctx.database.get('ll_mc_stats', cond)
|
|
440
|
+
lines.push(`${label}:${rows.reduce((s, r) => s + (r.count || 0), 0)} 条`)
|
|
301
441
|
}
|
|
302
442
|
return lines.join('\n')
|
|
303
443
|
}
|
|
304
444
|
|
|
445
|
+
// ===== 命令:定时推送 =====
|
|
446
|
+
async function pushRanking(period) {
|
|
447
|
+
const range = parsePeriod(period || (config.timerType === 'week' ? '本周' : config.timerType === 'month' ? '本月' : '昨日'))
|
|
448
|
+
if (!range) return '❌ 周期参数无效'
|
|
449
|
+
const list = await aggregateStats(range, 'group')
|
|
450
|
+
const targets = config.timerGroups && config.timerGroups.length
|
|
451
|
+
? config.timerGroups
|
|
452
|
+
: list.map(x => x.key)
|
|
453
|
+
let sent = 0
|
|
454
|
+
const failed = []
|
|
455
|
+
for (const guildId of targets) {
|
|
456
|
+
try {
|
|
457
|
+
const botSid = await getGroupBotSid(guildId)
|
|
458
|
+
const bot = botSid ? (ctx.bots || []).find(b => b.sid === botSid) : (ctx.bots || []).find(b => b.online)
|
|
459
|
+
if (!bot || typeof bot.sendMessage !== 'function') {
|
|
460
|
+
failed.push(guildId)
|
|
461
|
+
continue
|
|
462
|
+
}
|
|
463
|
+
const gs = await getGroupSettings(guildId)
|
|
464
|
+
const n = gs.rankLimit || config.rankLimit
|
|
465
|
+
const rows = await ctx.database.get('ll_mc_stats', {
|
|
466
|
+
channelId: guildId,
|
|
467
|
+
...(range.start ? { date: { $gte: range.start } } : {}),
|
|
468
|
+
...(range.end ? { date: { $lte: range.end } } : {}),
|
|
469
|
+
})
|
|
470
|
+
const perUser = new Map()
|
|
471
|
+
for (const r of rows) {
|
|
472
|
+
const item = perUser.get(r.userId) || { count: 0, nick: r.nick }
|
|
473
|
+
item.count += r.count || 0
|
|
474
|
+
if (r.nick) item.nick = r.nick
|
|
475
|
+
perUser.set(r.userId, item)
|
|
476
|
+
}
|
|
477
|
+
const sorted = [...perUser.entries()].map(([uid, item]) => ({ uid, ...item }))
|
|
478
|
+
.sort((a, b) => b.count - a.count)
|
|
479
|
+
.slice(0, n)
|
|
480
|
+
const name = await getGroupName(guildId)
|
|
481
|
+
const lines = [`📊 ${name} ${range.label}发言榜 TOP ${Math.min(n, sorted.length)}`]
|
|
482
|
+
sorted.forEach((x, i) => lines.push(`${i + 1}. ${x.nick || x.uid} · ${x.count}条`))
|
|
483
|
+
await bot.sendMessage(guildId, lines.join('\n'))
|
|
484
|
+
sent++
|
|
485
|
+
}
|
|
486
|
+
catch (e) {
|
|
487
|
+
logger.warn(`[ll-mc] 推送 ${guildId} 失败: ${e?.message}`)
|
|
488
|
+
failed.push(guildId)
|
|
489
|
+
}
|
|
490
|
+
}
|
|
491
|
+
return `✅ 定时推送完成:成功 ${sent} 群${failed.length ? `,失败 ${failed.length} 群` : ''}`
|
|
492
|
+
}
|
|
493
|
+
const timer = setInterval(async () => {
|
|
494
|
+
const hm = nowHM()
|
|
495
|
+
if (!config.timerEnabled || config.timerTime !== hm) return
|
|
496
|
+
const today = todayStr()
|
|
497
|
+
if (firedTimer.date === today) return
|
|
498
|
+
firedTimer.date = today
|
|
499
|
+
logger.info(`[ll-mc] 定时推送触发 @ ${hm}`)
|
|
500
|
+
await pushRanking('')
|
|
501
|
+
}, 30000)
|
|
502
|
+
ctx.on('dispose', () => clearInterval(timer))
|
|
503
|
+
|
|
305
504
|
// ===== 命令注册 =====
|
|
306
|
-
// 解析 "周期 [人数]" 或 "[人数]",两种顺序都支持
|
|
307
505
|
function parseArgs(text) {
|
|
308
506
|
const parts = String(text || '').trim().split(/\s+/).filter(Boolean)
|
|
309
507
|
let period = ''
|
|
@@ -315,27 +513,57 @@ function apply(ctx, config) {
|
|
|
315
513
|
return { period, limit }
|
|
316
514
|
}
|
|
317
515
|
|
|
318
|
-
ctx.command('
|
|
319
|
-
.alias('
|
|
516
|
+
ctx.command('发言榜 [args:text]', '本群成员发言排行(今日/昨日/本周/本月/本年/去年/总榜)')
|
|
517
|
+
.alias('水群榜', '发言排行', 'mc.排行')
|
|
320
518
|
.action(async ({ session }, args) => {
|
|
321
519
|
const { period, limit } = parseArgs(args)
|
|
322
520
|
return await memberRanking(session, period, limit)
|
|
323
521
|
})
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
.alias('群排行')
|
|
522
|
+
ctx.command('群发言榜 [args:text]', '所有群的发言排行(群名正确)')
|
|
523
|
+
.alias('群排行', 'mc.群排行')
|
|
327
524
|
.action(async ({ session }, args) => {
|
|
328
525
|
const { period, limit } = parseArgs(args)
|
|
329
526
|
return await groupRanking(session, period, limit)
|
|
330
527
|
})
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
528
|
+
for (const [kw, period] of [['今日发言榜', '今日'], ['本周发言榜', '本周'], ['本月发言榜', '本月']]) {
|
|
529
|
+
ctx.command(kw, `查看${period}发言排行`).alias(`mc.${kw}`)
|
|
530
|
+
.action(async ({ session }) => memberRanking(session, period, 0))
|
|
531
|
+
}
|
|
532
|
+
ctx.command('我的发言', '我的发言统计').alias('查看发言', 'mc.我的')
|
|
533
|
+
.action(async ({ session }) => myStats(session))
|
|
534
|
+
ctx.command('发言榜帮助', '查看帮助').alias('发言帮助', '发言榜菜单')
|
|
535
|
+
.action(() => exports.usage.replace(/^`|`$/g, ''))
|
|
536
|
+
ctx.command('设置发言榜数量 <limit:number>', '设置本群排行显示人数')
|
|
537
|
+
.action(async ({ session }, limit) => {
|
|
538
|
+
if (session.isDirect || !session.guildId) return '❌ 请在群聊中使用'
|
|
539
|
+
const n = Math.min(Math.max(limit || config.rankLimit, 1), 100)
|
|
540
|
+
await setGroupSettings(session.guildId, { rankLimit: n })
|
|
541
|
+
return `✅ 本群排行显示人数已设为 ${n}`
|
|
542
|
+
})
|
|
543
|
+
ctx.command('设置发言榜图片 <mode:text>', '设置本群排行显示模式(auto/文字/图片)')
|
|
544
|
+
.action(async ({ session }, mode) => {
|
|
545
|
+
if (session.isDirect || !session.guildId) return '❌ 请在群聊中使用'
|
|
546
|
+
const m = /图|image/i.test(mode) ? 'image' : /文|text/i.test(mode) ? 'text' : 'auto'
|
|
547
|
+
await setGroupSettings(session.guildId, { imageMode: m })
|
|
548
|
+
return `✅ 本群排行模式已设为 ${m}(auto=有图片服务用图片)`
|
|
549
|
+
})
|
|
550
|
+
ctx.command('清除发言榜单', '清空本群统计')
|
|
334
551
|
.action(async ({ session }) => {
|
|
335
|
-
|
|
552
|
+
if (session.isDirect || !session.guildId) return '❌ 请在群聊中使用'
|
|
553
|
+
await ctx.database.remove('ll_mc_stats', { channelId: session.guildId })
|
|
554
|
+
return '✅ 本群发言统计已清空'
|
|
555
|
+
})
|
|
556
|
+
ctx.command('手动推送发言榜 [period:text]', '立即向定时目标群推送排行')
|
|
557
|
+
.action(async ({ session }, period) => {
|
|
558
|
+
return await pushRanking(period || '')
|
|
559
|
+
})
|
|
560
|
+
ctx.command('发言榜定时状态', '查看定时推送配置')
|
|
561
|
+
.action(() => {
|
|
562
|
+
const t = config.timerType === 'week' ? '本周' : config.timerType === 'month' ? '本月' : '昨日'
|
|
563
|
+
return `⏰ 定时推送:${config.timerEnabled ? '已启用' : '未启用'}\n时间:${config.timerTime}\n内容:${t}发言榜\n目标群:${config.timerGroups.length ? config.timerGroups.join(', ') : '所有有数据的群'}`
|
|
336
564
|
})
|
|
337
565
|
|
|
338
|
-
logger.info('[ll-mc]
|
|
566
|
+
logger.info('[ll-mc] 已启动:发言榜 / 群发言榜 / 我的发言 / 定时推送')
|
|
339
567
|
}
|
|
340
568
|
|
|
341
569
|
exports.apply = apply
|