koishi-plugin-ll-mc 1.0.7 → 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 +569 -0
- package/package.json +26 -45
- package/assets/fallbackBase64.json +0 -3
- package/assets/fonts/HarmonyOS_Sans_Medium.ttf +0 -0
- package/lib/index.d.ts +0 -136
- package/lib/index.js +0 -2838
package/index.js
ADDED
|
@@ -0,0 +1,569 @@
|
|
|
1
|
+
'use strict'
|
|
2
|
+
|
|
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')
|
|
14
|
+
|
|
15
|
+
exports.name = 'll-mc'
|
|
16
|
+
|
|
17
|
+
exports.usage = `消息统计插件(移植自 astrbot_plugin_message_stats,并新增群排行)。
|
|
18
|
+
|
|
19
|
+
## 命令
|
|
20
|
+
|
|
21
|
+
- \`发言榜 [周期] [人数]\`:本群成员发言排行。周期:今日/昨日/本周/本月/本年/去年/总榜(默认总榜)
|
|
22
|
+
- \`群发言榜 [周期] [人数]\`:**所有群的发言排行(群名正确显示)**,当前群带 ★
|
|
23
|
+
- \`今日发言榜\` / \`本周发言榜\` / \`本月发言榜\`:快捷查看
|
|
24
|
+
- \`我的发言\`:自己的发言统计
|
|
25
|
+
- \`设置发言榜数量 <N>\`:设置本群排行显示人数
|
|
26
|
+
- \`设置发言榜图片 <auto|文字|图片>\`:设置本群排行显示模式(auto=有图片服务用图片,否则文字)
|
|
27
|
+
- \`清除发言榜单\`:清空本群统计
|
|
28
|
+
- \`手动推送发言榜 [周期]\`:立即向定时目标群推送排行
|
|
29
|
+
- \`发言榜定时状态\`:查看定时推送配置
|
|
30
|
+
|
|
31
|
+
## 定时推送(设置页配置)
|
|
32
|
+
|
|
33
|
+
- \`timerEnabled\`:启用后每天到点自动推送排行
|
|
34
|
+
- \`timerTime\`:推送时间(HH:MM)
|
|
35
|
+
- \`timerType\`:推送内容类型(day=昨日 / week=本周 / month=本月)
|
|
36
|
+
- \`timerGroups\`:推送目标群(留空=推送给所有有数据的群)
|
|
37
|
+
|
|
38
|
+
## 官机说明
|
|
39
|
+
|
|
40
|
+
- **群名**:官机消息不带群名,插件用 \`getGuild\` 获取(需在 QQ 开放平台申请「获取群信息」权限);也可在 \`groupNameMap\` 手动指定(openid → 群名),保证群排行群名正确
|
|
41
|
+
- **昵称**:官机消息自带昵称,零接口权限
|
|
42
|
+
- **图片排行**:需要安装 \`koishi-plugin-puppeteer\` 才显示图片,否则自动降级为文字
|
|
43
|
+
`
|
|
44
|
+
|
|
45
|
+
const Config = Schema.object({
|
|
46
|
+
rankLimit: Schema.number()
|
|
47
|
+
.min(1).max(100).default(20)
|
|
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('排行显示模式'),
|
|
54
|
+
ignoredGroups: Schema.array(String)
|
|
55
|
+
.default([])
|
|
56
|
+
.description('不统计的群(填群ID,官机填 openid)'),
|
|
57
|
+
groupNameMap: Schema.dict(String)
|
|
58
|
+
.default({})
|
|
59
|
+
.description('手动指定群名:键=群ID(官机 openid),值=群名。保证群排行群名正确'),
|
|
60
|
+
refreshGroupNameInterval: Schema.number()
|
|
61
|
+
.min(60).default(3600)
|
|
62
|
+
.description('群名自动刷新间隔(秒)'),
|
|
63
|
+
countBots: Schema.boolean()
|
|
64
|
+
.default(false)
|
|
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('定时推送目标群(留空=所有有数据的群)'),
|
|
80
|
+
verbose: Schema.boolean()
|
|
81
|
+
.default(false)
|
|
82
|
+
.description('调试:控制台打印统计详情'),
|
|
83
|
+
})
|
|
84
|
+
|
|
85
|
+
exports.Config = Config
|
|
86
|
+
|
|
87
|
+
exports.inject = { required: ['database'] }
|
|
88
|
+
|
|
89
|
+
function apply(ctx, config) {
|
|
90
|
+
const logger = ctx.logger('ll-mc')
|
|
91
|
+
|
|
92
|
+
// ===== 数据表 =====
|
|
93
|
+
ctx.model.extend('ll_mc_stats', {
|
|
94
|
+
channelId: 'string',
|
|
95
|
+
userId: 'string',
|
|
96
|
+
nick: 'string',
|
|
97
|
+
date: 'string',
|
|
98
|
+
count: 'unsigned',
|
|
99
|
+
}, { primary: ['channelId', 'userId', 'date'] })
|
|
100
|
+
ctx.model.extend('ll_mc_groups', {
|
|
101
|
+
channelId: 'string',
|
|
102
|
+
name: 'string',
|
|
103
|
+
platform: 'string',
|
|
104
|
+
botSid: 'string',
|
|
105
|
+
updatedAt: 'timestamp',
|
|
106
|
+
}, { primary: ['channelId'] })
|
|
107
|
+
ctx.model.extend('ll_mc_settings', {
|
|
108
|
+
channelId: 'string',
|
|
109
|
+
rankLimit: 'unsigned',
|
|
110
|
+
imageMode: 'string',
|
|
111
|
+
}, { primary: ['channelId'] })
|
|
112
|
+
|
|
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: '' }
|
|
119
|
+
|
|
120
|
+
function throttledWarn(key, message) {
|
|
121
|
+
const now = Date.now()
|
|
122
|
+
if ((warnThrottle.get(key) || 0) > now - 10 * 60 * 1000) return
|
|
123
|
+
warnThrottle.set(key, now)
|
|
124
|
+
logger.warn(message)
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
// ===== 日期工具 =====
|
|
128
|
+
function dateStr(d) {
|
|
129
|
+
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`
|
|
130
|
+
}
|
|
131
|
+
function nowHM() {
|
|
132
|
+
const d = new Date()
|
|
133
|
+
return `${String(d.getHours()).padStart(2, '0')}:${String(d.getMinutes()).padStart(2, '0')}`
|
|
134
|
+
}
|
|
135
|
+
// 解析周期 → { key, label, start, end }(end 可空)
|
|
136
|
+
function parsePeriod(period) {
|
|
137
|
+
const p = String(period || '').trim()
|
|
138
|
+
const now = new Date()
|
|
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)) {
|
|
148
|
+
const d = new Date(now)
|
|
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 }
|
|
154
|
+
}
|
|
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) }
|
|
159
|
+
}
|
|
160
|
+
if (/今|今?日|天/.test(p)) return { key: 'day', label: '今日', start: dateStr(now), end: dateStr(now) }
|
|
161
|
+
return null
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
// ===== 昵称解析 =====
|
|
165
|
+
async function resolveNick(session, userId) {
|
|
166
|
+
const author = session.author
|
|
167
|
+
if (author?.nick) return String(author.nick)
|
|
168
|
+
if (author?.name) return String(author.name)
|
|
169
|
+
const raw = session.event?._data?.d
|
|
170
|
+
if (raw?.member?.nick) return String(raw.member.nick)
|
|
171
|
+
if (raw?.author?.username) return String(raw.author.username)
|
|
172
|
+
if (session.guildId && !session.isDirect && typeof session.bot?.getGuildMember === 'function') {
|
|
173
|
+
try {
|
|
174
|
+
const member = await session.bot.getGuildMember(session.guildId, userId)
|
|
175
|
+
const name = member?.name || member?.user?.name
|
|
176
|
+
if (name) return String(name)
|
|
177
|
+
}
|
|
178
|
+
catch (e) {
|
|
179
|
+
throttledWarn(`member:${session.guildId}:${userId}`, `[ll-mc] 获取成员昵称失败 ${userId}: ${e?.message}(官机需申请"获取群成员信息"权限)`)
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
return String(userId)
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
// ===== 群名 =====
|
|
186
|
+
async function setGroupName(guildId, name, botSid) {
|
|
187
|
+
if (!name || name === guildId) return
|
|
188
|
+
nameCache.set(guildId, { name, t: Date.now() })
|
|
189
|
+
try {
|
|
190
|
+
await ctx.database.upsert('ll_mc_groups', [{
|
|
191
|
+
channelId: guildId, name, platform: '', botSid: botSid || '', updatedAt: Date.now(),
|
|
192
|
+
}], ['channelId'])
|
|
193
|
+
}
|
|
194
|
+
catch { /* 忽略 */ }
|
|
195
|
+
}
|
|
196
|
+
async function getGroupName(guildId) {
|
|
197
|
+
if (config.groupNameMap[guildId]) return config.groupNameMap[guildId]
|
|
198
|
+
const cached = nameCache.get(guildId)
|
|
199
|
+
if (cached?.name) return cached.name
|
|
200
|
+
try {
|
|
201
|
+
const rows = await ctx.database.get('ll_mc_groups', { channelId: guildId })
|
|
202
|
+
if (rows[0]?.name) {
|
|
203
|
+
nameCache.set(guildId, { name: rows[0].name, t: Date.now() })
|
|
204
|
+
return rows[0].name
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
catch { /* 忽略 */ }
|
|
208
|
+
return guildId
|
|
209
|
+
}
|
|
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
|
+
}
|
|
217
|
+
async function refreshGroupName(session) {
|
|
218
|
+
const guildId = session.guildId
|
|
219
|
+
const now = Date.now()
|
|
220
|
+
if ((lastNameRefresh.get(guildId) || 0) > now - config.refreshGroupNameInterval * 1000) return
|
|
221
|
+
lastNameRefresh.set(guildId, now)
|
|
222
|
+
if (session.event?.channel?.name) {
|
|
223
|
+
await setGroupName(guildId, session.event.channel.name, session.bot?.sid)
|
|
224
|
+
return
|
|
225
|
+
}
|
|
226
|
+
if (config.groupNameMap[guildId]) {
|
|
227
|
+
await setGroupName(guildId, config.groupNameMap[guildId], session.bot?.sid)
|
|
228
|
+
return
|
|
229
|
+
}
|
|
230
|
+
if (typeof session.bot?.getGuild === 'function') {
|
|
231
|
+
try {
|
|
232
|
+
const g = await session.bot.getGuild(guildId)
|
|
233
|
+
if (g?.name) {
|
|
234
|
+
await setGroupName(guildId, g.name, session.bot?.sid)
|
|
235
|
+
return
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
catch (e) {
|
|
239
|
+
throttledWarn(`guild:${guildId}`, `[ll-mc] 获取群名失败 ${guildId}: ${e?.message}(官机需在QQ开放平台申请"获取群信息"权限,或在配置 groupNameMap 手动指定群名)`)
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
}
|
|
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
|
+
|
|
277
|
+
// ===== 消息统计 =====
|
|
278
|
+
ctx.on('message', async (session) => {
|
|
279
|
+
try {
|
|
280
|
+
if (session.isDirect || !session.guildId || !session.userId) return
|
|
281
|
+
const guildId = session.guildId
|
|
282
|
+
const userId = session.userId
|
|
283
|
+
if (!config.countBots && session.selfId === session.userId) return
|
|
284
|
+
if (config.ignoredGroups.includes(guildId)) return
|
|
285
|
+
const date = todayStr()
|
|
286
|
+
const rows = await ctx.database.get('ll_mc_stats', { channelId: guildId, userId, date })
|
|
287
|
+
const count = (rows[0]?.count || 0) + 1
|
|
288
|
+
const nick = await resolveNick(session, userId)
|
|
289
|
+
await ctx.database.upsert('ll_mc_stats', [{
|
|
290
|
+
channelId: guildId, userId, date, nick, count,
|
|
291
|
+
}], ['channelId', 'userId', 'date'])
|
|
292
|
+
if (config.verbose) logger.info(`[ll-mc] ${guildId} ${userId} +1 (${count})`)
|
|
293
|
+
}
|
|
294
|
+
catch (e) {
|
|
295
|
+
logger.warn('[ll-mc] 消息统计失败:', e?.message)
|
|
296
|
+
}
|
|
297
|
+
refreshGroupName(session).catch(() => {})
|
|
298
|
+
})
|
|
299
|
+
function todayStr() {
|
|
300
|
+
return dateStr(new Date())
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
// ===== 聚合 =====
|
|
304
|
+
async function aggregateStats(range, groupBy) {
|
|
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)
|
|
309
|
+
const map = new Map()
|
|
310
|
+
for (const row of rows) {
|
|
311
|
+
const key = groupBy === 'user' ? `${row.channelId}:${row.userId}` : row.channelId
|
|
312
|
+
const item = map.get(key) || { count: 0, nick: row.nick }
|
|
313
|
+
item.count += row.count || 0
|
|
314
|
+
if (row.nick) item.nick = row.nick
|
|
315
|
+
map.set(key, item)
|
|
316
|
+
}
|
|
317
|
+
return [...map.entries()].map(([key, item]) => ({ key, ...item }))
|
|
318
|
+
.sort((a, b) => b.count - a.count)
|
|
319
|
+
}
|
|
320
|
+
|
|
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
|
+
// ===== 命令:发言榜(成员) =====
|
|
366
|
+
async function memberRanking(session, period, limit) {
|
|
367
|
+
if (session.isDirect || !session.guildId) return '❌ 请在群聊中使用'
|
|
368
|
+
const range = parsePeriod(period)
|
|
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)
|
|
372
|
+
const list = await aggregateStats(range, 'user')
|
|
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)
|
|
376
|
+
const groupRows = await ctx.database.get('ll_mc_stats', { channelId: session.guildId })
|
|
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) => {
|
|
390
|
+
const mark = x.key === `${session.guildId}:${session.userId}` ? ' ★' : ''
|
|
391
|
+
lines.push(`${i + 1}. ${x.nick || x.key.split(':')[1]} · ${x.count}条${mark}`)
|
|
392
|
+
})
|
|
393
|
+
lines.push(`—— ${subtitle}`)
|
|
394
|
+
if (mine) lines.push(`我的排名:第 ${inGroup.indexOf(mine) + 1} 名(${mine.count}条)`)
|
|
395
|
+
return lines.join('\n')
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
// ===== 命令:群发言榜(新增) =====
|
|
399
|
+
async function groupRanking(session, period, limit) {
|
|
400
|
+
const range = parsePeriod(period)
|
|
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)
|
|
404
|
+
const list = await aggregateStats(range, 'group')
|
|
405
|
+
const top = list.slice(0, n)
|
|
406
|
+
const current = session.guildId
|
|
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]
|
|
419
|
+
const name = await getGroupName(x.key)
|
|
420
|
+
const mark = x.key === current ? ' ★当前群' : ''
|
|
421
|
+
lines.push(`${i + 1}. ${name} · ${x.count}条${mark}`)
|
|
422
|
+
}
|
|
423
|
+
lines.push(`—— ${subtitle}`)
|
|
424
|
+
return lines.join('\n')
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
// ===== 命令:我的发言 =====
|
|
428
|
+
async function myStats(session) {
|
|
429
|
+
if (session.isDirect || !session.guildId) return '❌ 请在群聊中使用'
|
|
430
|
+
const guildId = session.guildId
|
|
431
|
+
const userId = session.userId
|
|
432
|
+
const labels = [['day', '今日'], ['yesterday', '昨日'], ['week', '本周'], ['month', '本月'], ['year', '本年'], ['all', '总榜']]
|
|
433
|
+
const lines = ['👤 我的发言统计(本群)']
|
|
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)} 条`)
|
|
441
|
+
}
|
|
442
|
+
return lines.join('\n')
|
|
443
|
+
}
|
|
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
|
+
|
|
504
|
+
// ===== 命令注册 =====
|
|
505
|
+
function parseArgs(text) {
|
|
506
|
+
const parts = String(text || '').trim().split(/\s+/).filter(Boolean)
|
|
507
|
+
let period = ''
|
|
508
|
+
let limit = 0
|
|
509
|
+
for (const part of parts) {
|
|
510
|
+
if (/^\d+$/.test(part)) limit = parseInt(part, 10)
|
|
511
|
+
else period = part
|
|
512
|
+
}
|
|
513
|
+
return { period, limit }
|
|
514
|
+
}
|
|
515
|
+
|
|
516
|
+
ctx.command('发言榜 [args:text]', '本群成员发言排行(今日/昨日/本周/本月/本年/去年/总榜)')
|
|
517
|
+
.alias('水群榜', '发言排行', 'mc.排行')
|
|
518
|
+
.action(async ({ session }, args) => {
|
|
519
|
+
const { period, limit } = parseArgs(args)
|
|
520
|
+
return await memberRanking(session, period, limit)
|
|
521
|
+
})
|
|
522
|
+
ctx.command('群发言榜 [args:text]', '所有群的发言排行(群名正确)')
|
|
523
|
+
.alias('群排行', 'mc.群排行')
|
|
524
|
+
.action(async ({ session }, args) => {
|
|
525
|
+
const { period, limit } = parseArgs(args)
|
|
526
|
+
return await groupRanking(session, period, limit)
|
|
527
|
+
})
|
|
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('清除发言榜单', '清空本群统计')
|
|
551
|
+
.action(async ({ session }) => {
|
|
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(', ') : '所有有数据的群'}`
|
|
564
|
+
})
|
|
565
|
+
|
|
566
|
+
logger.info('[ll-mc] 已启动:发言榜 / 群发言榜 / 我的发言 / 定时推送')
|
|
567
|
+
}
|
|
568
|
+
|
|
569
|
+
exports.apply = apply
|
package/package.json
CHANGED
|
@@ -1,45 +1,26 @@
|
|
|
1
|
-
{
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
"
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
"author": "lee",
|
|
28
|
-
"license": "MIT",
|
|
29
|
-
"keywords": [
|
|
30
|
-
"koishi",
|
|
31
|
-
"koishi-plugin",
|
|
32
|
-
"message-counter",
|
|
33
|
-
"rank"
|
|
34
|
-
],
|
|
35
|
-
"peerDependencies": {
|
|
36
|
-
"koishi": "^4.18.9"
|
|
37
|
-
},
|
|
38
|
-
"devDependencies": {
|
|
39
|
-
"koishi": "^4.18.11",
|
|
40
|
-
"koishi-plugin-cron": "^3.1.0",
|
|
41
|
-
"koishi-plugin-markdown-to-image-service": "^1.3.6",
|
|
42
|
-
"koishi-plugin-puppeteer": "^3.9.0",
|
|
43
|
-
"typescript": "^5.4.0"
|
|
44
|
-
}
|
|
45
|
-
}
|
|
1
|
+
{
|
|
2
|
+
"name": "koishi-plugin-ll-mc",
|
|
3
|
+
"description": "消息统计插件(重做版):群成员发言排行 + 跨群发言排行,官方QQ机器人原生适配",
|
|
4
|
+
"version": "2.1.0",
|
|
5
|
+
"main": "index.js",
|
|
6
|
+
"files": [
|
|
7
|
+
"index.js"
|
|
8
|
+
],
|
|
9
|
+
"koishi": {
|
|
10
|
+
"description": {
|
|
11
|
+
"zh": "消息统计插件:群成员发言排行 + 跨群发言排行(群名正确),官机原生适配,纯文本无需图片依赖"
|
|
12
|
+
}
|
|
13
|
+
},
|
|
14
|
+
"author": "lee",
|
|
15
|
+
"license": "MIT",
|
|
16
|
+
"keywords": [
|
|
17
|
+
"koishi",
|
|
18
|
+
"koishi-plugin",
|
|
19
|
+
"message-counter",
|
|
20
|
+
"rank",
|
|
21
|
+
"stats"
|
|
22
|
+
],
|
|
23
|
+
"peerDependencies": {
|
|
24
|
+
"koishi": "^4.18.0"
|
|
25
|
+
}
|
|
26
|
+
}
|