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.
Files changed (2) hide show
  1. package/index.js +333 -105
  2. 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.0.0(重做版)
4
- // 消息统计插件:群成员发言排行 + 跨群发言排行(群名正确)
5
- // 官方QQ机器人(官机)原生适配:
6
- // - 昵称:优先取消息自带昵称(官机消息自带 member.nick / author.username,零API)
7
- // - 群名:getGuild(需在QQ开放平台申请"获取群信息"权限)+ 配置手动指定兜底
8
- // 纯文本排行,不依赖 puppeteer/canvas/图片生成,任何环境都能跑
9
-
10
- const { Schema } = require('koishi')
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
- - \`mc.排行 [周期] [人数]\`:本群成员发言排行。周期:今日/本周/本月/总榜(默认总榜),如 \`mc.排行 今日 10\`
19
- - \`mc.群排行 [周期] [人数]\`:所有群的发言排行(群名正确显示),当前群带
20
- - \`mc.我的\`:自己的发言统计(今日/本周/本月/总榜)
21
+ - \`发言榜 [周期] [人数]\`:本群成员发言排行。周期:今日/昨日/本周/本月/本年/去年/总榜(默认总榜)
22
+ - \`群发言榜 [周期] [人数]\`:**所有群的发言排行(群名正确显示)**,当前群带
23
+ - \`今日发言榜\` / \`本周发言榜\` / \`本月发言榜\`:快捷查看
24
+ - \`我的发言\`:自己的发言统计
25
+ - \`设置发言榜数量 <N>\`:设置本群排行显示人数
26
+ - \`设置发言榜图片 <auto|文字|图片>\`:设置本群排行显示模式(auto=有图片服务用图片,否则文字)
27
+ - \`清除发言榜单\`:清空本群统计
28
+ - \`手动推送发言榜 [周期]\`:立即向定时目标群推送排行
29
+ - \`发言榜定时状态\`:查看定时推送配置
21
30
 
22
- ## 官机(官方QQ机器人)说明
31
+ ## 定时推送(设置页配置)
23
32
 
24
- - **群名**:官机消息里不带群名,插件通过 \`getGuild\` 接口获取。需要在 QQ 开放平台申请「获取群信息」权限(否则群名显示为 openid);也可以在配置 \`groupNameMap\` 里手动指定(键=群openid,值=群名),一次填好永久正确
25
- - **昵称**:官机消息自带昵称,无需接口权限;申请「获取群成员信息」后更完整
26
- - **群ID**:官机显示的是 openid,非数字群号,属正常现象
33
+ - \`timerEnabled\`:启用后每天到点自动推送排行
34
+ - \`timerTime\`:推送时间(HH:MM)
35
+ - \`timerType\`:推送内容类型(day=昨日 / week=本周 / month=本月)
36
+ - \`timerGroups\`:推送目标群(留空=推送给所有有数据的群)
27
37
 
28
- ## 配置
38
+ ## 官机说明
29
39
 
30
- - \`rankLimit\`:排行默认显示人数
31
- - \`ignoredGroups\`:不统计的群(填群ID)
32
- - \`groupNameMap\`:手动指定群名(群openid → 群名)
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(官机填 openid),值=群名。用于官机无法自动获取群名时保证"群排行"群名正确'),
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() // channelId -> { name, t }
84
- const lastNameRefresh = new Map() // channelId -> timestamp
85
- const warnThrottle = new Map() // key -> timestamp(同类警告10分钟一次)
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 todayStr() {
99
- return dateStr(new Date())
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
- // 解析周期:返回 { key, label, start },start 为日期范围起点(含当天),null=全部
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 (/周/.test(p)) {
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 (/月/.test(p)) {
112
- return { key: 'month', label: '本月', start: `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, '0')}-01` }
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 (/日|天/.test(p)) return { key: 'day', label: '今日', start: todayStr() }
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
- // 收到消息时刷新群名(节流;官机走 getGuild)
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 query = range.start ? { date: { $gte: range.start } } : {}
226
- const rows = await ctx.database.get('ll_mc_stats', query)
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
+ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;',
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 n = Math.min(Math.max(limit || config.rankLimit, 1), 100)
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 inRange = list.filter(x => x.key.startsWith(`${session.guildId}:`))
247
- const mine = inRange.find(x => x.key === `${session.guildId}:${session.userId}`)
248
- const mineRank = mine ? inRange.indexOf(mine) + 1 : 0
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 total = groupRows.reduce((s, r) => s + (r.count || 0), 0)
251
- const totalInRange = inRange.reduce((s, x) => s + x.count, 0)
252
-
253
- const lines = [`📊 ${range.label}发言排行 TOP ${Math.min(n, inRange.length)}(本群)`]
254
- inRange.slice(0, n).forEach((x, i) => {
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(`—— 本群${range.label}共 ${totalInRange} 条发言,历史累计 ${total} 条`)
259
- if (mineRank) lines.push(`我的排名:第 ${mineRank} 名(${mine.count}条)`)
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 n = Math.min(Math.max(limit || config.rankLimit, 1), 100)
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 lines = [`🏆 群${range.label}发言排行 TOP ${Math.min(n, list.length)}`]
271
- for (let i = 0; i < Math.min(n, list.length); i++) {
272
- const x = list[i]
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('(群名来自 getGuild 接口,需申请"获取群信息"权限;或用 groupNameMap 手动指定)')
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 periods = [
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 p of periods) {
294
- const range = parsePeriod(p.label)
295
- const query = range.start
296
- ? { channelId: guildId, userId, date: { $gte: range.start } }
297
- : { channelId: guildId, userId }
298
- const rows = await ctx.database.get('ll_mc_stats', query)
299
- const total = rows.reduce((s, r) => s + (r.count || 0), 0)
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('mc.排行 [args:text]', '本群成员发言排行(今日/本周/本月/总榜)')
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
- ctx.command('mc.群排行 [args:text]', '跨群发言排行(群名正确)')
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
- ctx.command('mc.我的', '我的发言统计')
333
- .alias('我的发言')
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
- return await myStats(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(', ') : '所有有数据的群'}`
336
564
  })
337
565
 
338
- logger.info('[ll-mc] 已启动:mc.排行 / mc.群排行 / mc.我的')
566
+ logger.info('[ll-mc] 已启动:发言榜 / 群发言榜 / 我的发言 / 定时推送')
339
567
  }
340
568
 
341
569
  exports.apply = apply
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "koishi-plugin-ll-mc",
3
3
  "description": "消息统计插件(重做版):群成员发言排行 + 跨群发言排行,官方QQ机器人原生适配",
4
- "version": "2.0.0",
4
+ "version": "2.1.0",
5
5
  "main": "index.js",
6
6
  "files": [
7
7
  "index.js"