koishi-plugin-ll-mc 2.1.0 → 2.2.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 +278 -46
- package/package.json +3 -2
- package/templates/rank_template.html +410 -0
- package/templates/rank_template_cartoon_dark.html +455 -0
- package/templates/rank_template_cartoon_light.html +457 -0
- package/templates/rank_template_liquid_glass.html +431 -0
- package/templates/rank_template_liquid_glass_dark.html +431 -0
package/index.js
CHANGED
|
@@ -1,15 +1,18 @@
|
|
|
1
1
|
'use strict'
|
|
2
2
|
|
|
3
|
-
// koishi-plugin-ll-mc v2.
|
|
3
|
+
// koishi-plugin-ll-mc v2.2.0(照抄 astrbot_plugin_message_stats 功能 + 新增群排行)
|
|
4
4
|
// 参考:https://github.com/xiaoruange39/astrbot_plugin_message_stats
|
|
5
5
|
// 移植到 Koishi 的功能:
|
|
6
|
-
// -
|
|
6
|
+
// - 发言榜(今日/昨日/本周/本月/本年/去年/总榜,文字或图片,5套主题卡片)
|
|
7
7
|
// - 群发言榜(新增:跨群排行,群名正确)
|
|
8
8
|
// - 我的发言 / 发言榜帮助
|
|
9
9
|
// - 设置发言榜数量 / 设置发言榜图片 / 清除发言榜单
|
|
10
10
|
// - 定时推送(自动 + 手动)
|
|
11
11
|
// 官机(官方QQ)适配:昵称取消息自带,群名走 getGuild + groupNameMap 手动指定兜底
|
|
12
|
+
// 排行图片:移植原版 templates/*.html(Jinja2 → 本地迷你渲染器),puppeteer 截图
|
|
12
13
|
|
|
14
|
+
const fs = require('fs')
|
|
15
|
+
const path = require('path')
|
|
13
16
|
const { Schema, h } = require('koishi')
|
|
14
17
|
|
|
15
18
|
exports.name = 'll-mc'
|
|
@@ -51,6 +54,13 @@ const Config = Schema.object({
|
|
|
51
54
|
Schema.const('text').description('始终文字'),
|
|
52
55
|
Schema.const('image').description('始终图片(无服务时降级文字)'),
|
|
53
56
|
]).default('auto').description('排行显示模式'),
|
|
57
|
+
rankTheme: Schema.union([
|
|
58
|
+
Schema.const('default').description('默认(浅蓝卡片)'),
|
|
59
|
+
Schema.const('cartoon_dark').description('卡通深色'),
|
|
60
|
+
Schema.const('cartoon_light').description('卡通浅色'),
|
|
61
|
+
Schema.const('liquid_glass').description('液态玻璃(浅)'),
|
|
62
|
+
Schema.const('liquid_glass_dark').description('液态玻璃(深)'),
|
|
63
|
+
]).default('default').description('排行图片主题'),
|
|
54
64
|
ignoredGroups: Schema.array(String)
|
|
55
65
|
.default([])
|
|
56
66
|
.description('不统计的群(填群ID,官机填 openid)'),
|
|
@@ -84,7 +94,7 @@ const Config = Schema.object({
|
|
|
84
94
|
|
|
85
95
|
exports.Config = Config
|
|
86
96
|
|
|
87
|
-
exports.inject = { required: ['database'] }
|
|
97
|
+
exports.inject = { required: ['database'], optional: ['puppeteer'] }
|
|
88
98
|
|
|
89
99
|
function apply(ctx, config) {
|
|
90
100
|
const logger = ctx.logger('ll-mc')
|
|
@@ -94,6 +104,7 @@ function apply(ctx, config) {
|
|
|
94
104
|
channelId: 'string',
|
|
95
105
|
userId: 'string',
|
|
96
106
|
nick: 'string',
|
|
107
|
+
avatar: 'string',
|
|
97
108
|
date: 'string',
|
|
98
109
|
count: 'unsigned',
|
|
99
110
|
}, { primary: ['channelId', 'userId', 'date'] })
|
|
@@ -286,8 +297,9 @@ function apply(ctx, config) {
|
|
|
286
297
|
const rows = await ctx.database.get('ll_mc_stats', { channelId: guildId, userId, date })
|
|
287
298
|
const count = (rows[0]?.count || 0) + 1
|
|
288
299
|
const nick = await resolveNick(session, userId)
|
|
300
|
+
const avatar = session.author?.avatar || rows[0]?.avatar || ''
|
|
289
301
|
await ctx.database.upsert('ll_mc_stats', [{
|
|
290
|
-
channelId: guildId, userId, date, nick, count,
|
|
302
|
+
channelId: guildId, userId, date, nick, avatar, count,
|
|
291
303
|
}], ['channelId', 'userId', 'date'])
|
|
292
304
|
if (config.verbose) logger.info(`[ll-mc] ${guildId} ${userId} +1 (${count})`)
|
|
293
305
|
}
|
|
@@ -309,47 +321,212 @@ function apply(ctx, config) {
|
|
|
309
321
|
const map = new Map()
|
|
310
322
|
for (const row of rows) {
|
|
311
323
|
const key = groupBy === 'user' ? `${row.channelId}:${row.userId}` : row.channelId
|
|
312
|
-
const item = map.get(key) || { count: 0, nick:
|
|
324
|
+
const item = map.get(key) || { count: 0, nick: '', avatar: '', lastDate: '' }
|
|
313
325
|
item.count += row.count || 0
|
|
314
326
|
if (row.nick) item.nick = row.nick
|
|
327
|
+
if (row.avatar) item.avatar = row.avatar
|
|
328
|
+
if (row.date > item.lastDate) item.lastDate = row.date
|
|
315
329
|
map.set(key, item)
|
|
316
330
|
}
|
|
317
331
|
return [...map.entries()].map(([key, item]) => ({ key, ...item }))
|
|
318
332
|
.sort((a, b) => b.count - a.count)
|
|
319
333
|
}
|
|
320
334
|
|
|
321
|
-
// =====
|
|
322
|
-
|
|
335
|
+
// ===== 迷你 Jinja2 渲染器(原版模板用 Jinja2 语法) =====
|
|
336
|
+
function renderJinja(tpl, ctx) {
|
|
337
|
+
tpl = tpl.replace(/\{% raw %\}([\s\S]*?)\{% endraw %\}/g, '$1')
|
|
338
|
+
const out = []
|
|
339
|
+
let i = 0
|
|
340
|
+
while (i < tpl.length) {
|
|
341
|
+
const open = tpl.indexOf('{%', i)
|
|
342
|
+
const expr = tpl.indexOf('{{', i)
|
|
343
|
+
if (open === -1 && expr === -1) { out.push(tpl.slice(i)); break }
|
|
344
|
+
let next, isBlock
|
|
345
|
+
if (open !== -1 && (expr === -1 || open < expr)) { next = open; isBlock = true }
|
|
346
|
+
else { next = expr; isBlock = false }
|
|
347
|
+
out.push(tpl.slice(i, next))
|
|
348
|
+
if (isBlock) {
|
|
349
|
+
const close = tpl.indexOf('%}', next)
|
|
350
|
+
if (close === -1) { out.push(tpl.slice(next)); break }
|
|
351
|
+
const tag = tpl.slice(next + 2, close).trim()
|
|
352
|
+
const m = tag.match(/^(\w+)\s*([\s\S]*)$/)
|
|
353
|
+
const kind = m[1]
|
|
354
|
+
const rest = (m[2] || '').trim()
|
|
355
|
+
if (kind === 'if') {
|
|
356
|
+
const endIdx = findEndif(tpl, close + 2)
|
|
357
|
+
if (endIdx === -1) { out.push(tpl.slice(next)); break }
|
|
358
|
+
const body = tpl.slice(close + 2, endIdx)
|
|
359
|
+
const branches = splitIfBranches(rest, body)
|
|
360
|
+
for (const [cond, branchBody] of branches) {
|
|
361
|
+
if (cond === null || evalExpr(cond, ctx)) {
|
|
362
|
+
out.push(renderJinja(branchBody, ctx))
|
|
363
|
+
break
|
|
364
|
+
}
|
|
365
|
+
}
|
|
366
|
+
i = endIdx + 8
|
|
367
|
+
continue
|
|
368
|
+
}
|
|
369
|
+
if (kind === 'for') {
|
|
370
|
+
const endIdx = tpl.indexOf('{% endfor %}', close)
|
|
371
|
+
if (endIdx === -1) { out.push(tpl.slice(next)); break }
|
|
372
|
+
const body = tpl.slice(close + 2, endIdx)
|
|
373
|
+
const fm = rest.match(/^(\w+)\s+in\s+(\w+)$/)
|
|
374
|
+
const arr = fm ? (ctx[fm[2]] || []) : []
|
|
375
|
+
for (const it of arr) {
|
|
376
|
+
out.push(renderJinja(body, { ...ctx, [fm[1]]: it }))
|
|
377
|
+
}
|
|
378
|
+
i = endIdx + 12
|
|
379
|
+
continue
|
|
380
|
+
}
|
|
381
|
+
if (kind === 'set') {
|
|
382
|
+
const sm = rest.match(/^(\w+)\s*=\s*([\s\S]+)$/)
|
|
383
|
+
if (sm) ctx[sm[1]] = evalExpr(sm[2], ctx)
|
|
384
|
+
i = close + 2
|
|
385
|
+
continue
|
|
386
|
+
}
|
|
387
|
+
i = close + 2
|
|
388
|
+
continue
|
|
389
|
+
}
|
|
390
|
+
else {
|
|
391
|
+
const close = tpl.indexOf('}}', next)
|
|
392
|
+
if (close === -1) { out.push(tpl.slice(next)); break }
|
|
393
|
+
const ex = tpl.slice(next + 2, close).trim()
|
|
394
|
+
out.push(String(evalExpr(ex, ctx)))
|
|
395
|
+
i = close + 2
|
|
396
|
+
}
|
|
397
|
+
}
|
|
398
|
+
return out.join('')
|
|
399
|
+
}
|
|
400
|
+
function findEndif(tpl, from) {
|
|
401
|
+
let depth = 1
|
|
402
|
+
const re = /\{%\s*(if(?:\s+[\s\S]*?)?|endif)\s*%\}/g
|
|
403
|
+
re.lastIndex = from
|
|
404
|
+
let m
|
|
405
|
+
while ((m = re.exec(tpl))) {
|
|
406
|
+
if (m[1].startsWith('if')) depth++
|
|
407
|
+
else { depth--; if (depth === 0) return m.index }
|
|
408
|
+
}
|
|
409
|
+
return -1
|
|
410
|
+
}
|
|
411
|
+
function splitIfBranches(firstCond, body) {
|
|
412
|
+
const branches = [[firstCond, '']]
|
|
413
|
+
const re = /\{%\s*elif\s+([\s\S]*?)\s*%\}|\{%\s*else\s*%\}/g
|
|
414
|
+
let last = 0
|
|
415
|
+
let m
|
|
416
|
+
while ((m = re.exec(body))) {
|
|
417
|
+
branches[branches.length - 1][1] = body.slice(last, m.index)
|
|
418
|
+
if (m[1] !== undefined) branches.push([m[1].trim(), ''])
|
|
419
|
+
else branches.push([null, ''])
|
|
420
|
+
last = re.lastIndex
|
|
421
|
+
}
|
|
422
|
+
branches[branches.length - 1][1] = body.slice(last)
|
|
423
|
+
return branches
|
|
424
|
+
}
|
|
425
|
+
function resolvePath(ctx, p) {
|
|
426
|
+
const parts = p.replace(/\[(\d+)\]/g, '.$1').split('.')
|
|
427
|
+
let v = ctx
|
|
428
|
+
for (const part of parts) {
|
|
429
|
+
if (v == null) return undefined
|
|
430
|
+
v = v[part]
|
|
431
|
+
}
|
|
432
|
+
return v
|
|
433
|
+
}
|
|
434
|
+
function evalExpr(expr, ctx) {
|
|
435
|
+
expr = expr.trim()
|
|
436
|
+
if (expr.startsWith('(') && expr.endsWith(')')) expr = expr.slice(1, -1).trim()
|
|
437
|
+
// | format 过滤器: "%.2f"|format(x)
|
|
438
|
+
const fmt = expr.match(/^"([^"]*)"\s*\|\s*format\(([\s\S]*)\)$/)
|
|
439
|
+
if (fmt) {
|
|
440
|
+
const v = evalExpr(fmt[2], ctx)
|
|
441
|
+
const fm = fmt[1].match(/%\.(\d+)f/)
|
|
442
|
+
return fm ? Number(v).toFixed(parseInt(fm[1], 10)) : String(v)
|
|
443
|
+
}
|
|
444
|
+
if (expr.endsWith('| safe')) expr = expr.slice(0, -6).trim()
|
|
445
|
+
// 三元:A if COND else B
|
|
446
|
+
const ternary = expr.match(/^([\s\S]*?)\s+if\s+([\s\S]+?)\s+else\s+([\s\S]+)$/)
|
|
447
|
+
if (ternary) return evalExpr(ternary[2], ctx) ? evalExpr(ternary[1], ctx) : evalExpr(ternary[3], ctx)
|
|
448
|
+
// 拼接 A + B(在 or 之前处理,支持 (a or b) + 'x')
|
|
449
|
+
const plus = expr.match(/^([\s\S]*?)\s*\+\s*([\s\S]+)$/)
|
|
450
|
+
if (plus) return String(evalExpr(plus[1], ctx)) + String(evalExpr(plus[2], ctx))
|
|
451
|
+
// or 回退
|
|
452
|
+
const orM = expr.match(/^([\s\S]*?)\s+or\s+([\s\S]+)$/)
|
|
453
|
+
if (orM) return evalExpr(orM[1], ctx) || evalExpr(orM[2], ctx)
|
|
454
|
+
// in 子串
|
|
455
|
+
const inM = expr.match(/^([\s\S]*?)\s+in\s+([\s\S]+)$/)
|
|
456
|
+
if (inM) return String(evalExpr(inM[2], ctx)).includes(String(evalExpr(inM[1], ctx)))
|
|
457
|
+
// 比较
|
|
458
|
+
const cmp = expr.match(/^([\s\S]*?)\s*(==|!=|>=|<=|>|<)\s*([\s\S]+)$/)
|
|
459
|
+
if (cmp) {
|
|
460
|
+
const a = evalExpr(cmp[1], ctx)
|
|
461
|
+
const b = evalExpr(cmp[3], ctx)
|
|
462
|
+
switch (cmp[2]) {
|
|
463
|
+
case '==': return a == b
|
|
464
|
+
case '!=': return a != b
|
|
465
|
+
case '>=': return a >= b
|
|
466
|
+
case '<=': return a <= b
|
|
467
|
+
case '>': return a > b
|
|
468
|
+
case '<': return a < b
|
|
469
|
+
}
|
|
470
|
+
}
|
|
471
|
+
// 字符串字面量
|
|
472
|
+
const str = expr.match(/^(['"])([\s\S]*?)\1$/)
|
|
473
|
+
if (str) return str[2]
|
|
474
|
+
// 方法调用:title.split(']', 1)
|
|
475
|
+
const call = expr.match(/^([\w$]+(?:\.[\w$]+)*)\(([\s\S]*)\)$/)
|
|
476
|
+
if (call) {
|
|
477
|
+
const dot = call[1].lastIndexOf('.')
|
|
478
|
+
const method = dot === -1 ? '' : call[1].slice(dot + 1)
|
|
479
|
+
const objPath = dot === -1 ? call[1] : call[1].slice(0, dot)
|
|
480
|
+
const base = resolvePath(ctx, objPath)
|
|
481
|
+
const args = call[2].split(',').map(a => evalExpr(a.trim(), ctx))
|
|
482
|
+
if (method === 'split') {
|
|
483
|
+
const parts = String(base).split(args[0])
|
|
484
|
+
const maxsplit = args.length > 1 ? parseInt(args[1], 10) : undefined
|
|
485
|
+
if (maxsplit > 0 && parts.length > maxsplit) {
|
|
486
|
+
return parts.slice(0, maxsplit).concat([parts.slice(maxsplit).join(args[0])])
|
|
487
|
+
}
|
|
488
|
+
return parts
|
|
489
|
+
}
|
|
490
|
+
return ''
|
|
491
|
+
}
|
|
492
|
+
// 数字
|
|
493
|
+
if (/^-?\d+$/.test(expr)) return parseInt(expr, 10)
|
|
494
|
+
// 路径/裸变量
|
|
495
|
+
return resolvePath(ctx, expr) ?? ''
|
|
496
|
+
}
|
|
497
|
+
|
|
498
|
+
// ===== 模板加载 + 图片渲染(puppeteer,可选) =====
|
|
499
|
+
const templateCache = new Map()
|
|
500
|
+
function loadTemplate(theme) {
|
|
501
|
+
const name = theme === 'default' ? 'rank_template' : `rank_template_${theme}`
|
|
502
|
+
const cached = templateCache.get(name)
|
|
503
|
+
if (cached) return cached
|
|
504
|
+
try {
|
|
505
|
+
const html = fs.readFileSync(path.join(__dirname, 'templates', `${name}.html`), 'utf8')
|
|
506
|
+
templateCache.set(name, html)
|
|
507
|
+
return html
|
|
508
|
+
}
|
|
509
|
+
catch (e) {
|
|
510
|
+
logger.warn(`[ll-mc] 模板 ${name} 加载失败,回退默认:`, e?.message)
|
|
511
|
+
return null
|
|
512
|
+
}
|
|
513
|
+
}
|
|
514
|
+
async function renderRankImage(theme, data) {
|
|
323
515
|
try {
|
|
324
516
|
if (typeof ctx.puppeteer?.page !== 'function') return null
|
|
517
|
+
const tpl = loadTemplate(theme)
|
|
518
|
+
if (!tpl) return null
|
|
519
|
+
const html = renderJinja(tpl, data)
|
|
325
520
|
const page = await ctx.puppeteer.page()
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
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
|
|
521
|
+
try {
|
|
522
|
+
await page.setContent(html, { waitUntil: 'networkidle0', timeout: 20000 })
|
|
523
|
+
}
|
|
524
|
+
catch { /* 图片加载超时等,用当前内容截图 */ }
|
|
525
|
+
await page.waitForSelector('.rank-card').catch(() => {})
|
|
526
|
+
const buf = await page.screenshot({ type: 'png', fullPage: true })
|
|
527
|
+
// 官机适配器只支持 data:image 内联图片;h.image(Buffer) 会生成 base64:// 协议导致发送失败
|
|
528
|
+
const src = `data:image/png;base64,${buf.toString('base64')}`
|
|
529
|
+
return { src, close: () => page.close().catch(() => {}) }
|
|
353
530
|
}
|
|
354
531
|
catch (e) {
|
|
355
532
|
logger.warn('[ll-mc] 排行图片渲染失败,降级文字:', e?.message)
|
|
@@ -361,6 +538,10 @@ function apply(ctx, config) {
|
|
|
361
538
|
'&': '&', '<': '<', '>': '>', '"': '"', "'": ''',
|
|
362
539
|
}[c]))
|
|
363
540
|
}
|
|
541
|
+
// 默认头像(灰色圆形 SVG)
|
|
542
|
+
const PLACEHOLDER_AVATAR = 'data:image/svg+xml;base64,' + Buffer.from(
|
|
543
|
+
'<svg xmlns="http://www.w3.org/2000/svg" width="75" height="75"><circle cx="37.5" cy="37.5" r="37.5" fill="#cbd5e1"/><text x="37.5" y="46" font-size="28" text-anchor="middle" fill="#64748b">?</text></svg>'
|
|
544
|
+
).toString('base64')
|
|
364
545
|
|
|
365
546
|
// ===== 命令:发言榜(成员) =====
|
|
366
547
|
async function memberRanking(session, period, limit) {
|
|
@@ -376,15 +557,43 @@ function apply(ctx, config) {
|
|
|
376
557
|
const groupRows = await ctx.database.get('ll_mc_stats', { channelId: session.guildId })
|
|
377
558
|
const totalAll = groupRows.reduce((s, r) => s + (r.count || 0), 0)
|
|
378
559
|
const totalRange = inGroup.reduce((s, x) => s + x.count, 0)
|
|
560
|
+
const groupName = await getGroupName(session.guildId)
|
|
379
561
|
|
|
380
562
|
const title = `${range.label}发言排行 TOP ${Math.min(n, inGroup.length)}`
|
|
381
563
|
const subtitle = `本群${range.label}共 ${totalRange} 条 · 历史累计 ${totalAll} 条`
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
564
|
+
// 当前用户不在榜单内时,追加一行(带分隔线)
|
|
565
|
+
const items = top.map((x, i) => ({
|
|
566
|
+
rank: i + 1,
|
|
567
|
+
nickname: x.nick || x.key.split(':')[1],
|
|
568
|
+
avatar_url: x.avatar || PLACEHOLDER_AVATAR,
|
|
569
|
+
total: x.count,
|
|
570
|
+
percentage: totalRange ? (x.count / totalRange * 100) : 0,
|
|
571
|
+
last_date: x.lastDate || '未知',
|
|
572
|
+
is_current_user: x.key === `${session.guildId}:${session.userId}`,
|
|
573
|
+
is_separator: false,
|
|
574
|
+
title: '',
|
|
575
|
+
title_color: '',
|
|
576
|
+
}))
|
|
577
|
+
if (mine && !items.some(it => it.is_current_user)) {
|
|
578
|
+
items.push({
|
|
579
|
+
rank: inGroup.indexOf(mine) + 1,
|
|
580
|
+
nickname: mine.nick || session.userId,
|
|
581
|
+
avatar_url: mine.avatar || PLACEHOLDER_AVATAR,
|
|
582
|
+
total: mine.count,
|
|
583
|
+
percentage: totalRange ? (mine.count / totalRange * 100) : 0,
|
|
584
|
+
last_date: mine.lastDate || '未知',
|
|
585
|
+
is_current_user: true,
|
|
586
|
+
is_separator: true,
|
|
587
|
+
title: '',
|
|
588
|
+
title_color: '',
|
|
589
|
+
})
|
|
590
|
+
}
|
|
591
|
+
const img = await renderRankImage(config.rankTheme, {
|
|
592
|
+
title, group_name: groupName, group_id: session.guildId, show_group_id: false,
|
|
593
|
+
total_messages: totalRange, current_time: new Date().toLocaleString('zh-CN'),
|
|
594
|
+
user_items: items, llm_token_info: '', custom_font_css: '',
|
|
385
595
|
})
|
|
386
|
-
|
|
387
|
-
if (buf) return h.image(buf)
|
|
596
|
+
if (img) { img.close(); return h.image(img.src) }
|
|
388
597
|
const lines = [`📊 ${title}`]
|
|
389
598
|
top.forEach((x, i) => {
|
|
390
599
|
const mark = x.key === `${session.guildId}:${session.userId}` ? ' ★' : ''
|
|
@@ -404,15 +613,31 @@ function apply(ctx, config) {
|
|
|
404
613
|
const list = await aggregateStats(range, 'group')
|
|
405
614
|
const top = list.slice(0, n)
|
|
406
615
|
const current = session.guildId
|
|
407
|
-
const
|
|
408
|
-
for (
|
|
616
|
+
const items = []
|
|
617
|
+
for (let i = 0; i < top.length; i++) {
|
|
618
|
+
const x = top[i]
|
|
409
619
|
const name = await getGroupName(x.key)
|
|
410
|
-
|
|
620
|
+
items.push({
|
|
621
|
+
rank: i + 1,
|
|
622
|
+
nickname: name,
|
|
623
|
+
avatar_url: groupAvatarUrl(x.key) || PLACEHOLDER_AVATAR,
|
|
624
|
+
total: x.count,
|
|
625
|
+
percentage: top[0]?.count ? (x.count / top[0].count * 100) : 0,
|
|
626
|
+
last_date: x.lastDate || '未知',
|
|
627
|
+
is_current_user: x.key === current,
|
|
628
|
+
is_separator: false,
|
|
629
|
+
title: '',
|
|
630
|
+
title_color: '',
|
|
631
|
+
})
|
|
411
632
|
}
|
|
412
633
|
const title = `群${range.label}发言排行 TOP ${Math.min(n, top.length)}`
|
|
413
|
-
const
|
|
414
|
-
|
|
415
|
-
|
|
634
|
+
const img = await renderRankImage(config.rankTheme, {
|
|
635
|
+
title, group_name: '跨群排行', group_id: '', show_group_id: false,
|
|
636
|
+
total_messages: top.reduce((s, x) => s + x.count, 0),
|
|
637
|
+
current_time: new Date().toLocaleString('zh-CN'),
|
|
638
|
+
user_items: items, llm_token_info: '', custom_font_css: '',
|
|
639
|
+
})
|
|
640
|
+
if (img) { img.close(); return h.image(img.src) }
|
|
416
641
|
const lines = [`🏆 ${title}`]
|
|
417
642
|
for (let i = 0; i < top.length; i++) {
|
|
418
643
|
const x = top[i]
|
|
@@ -420,9 +645,16 @@ function apply(ctx, config) {
|
|
|
420
645
|
const mark = x.key === current ? ' ★当前群' : ''
|
|
421
646
|
lines.push(`${i + 1}. ${name} · ${x.count}条${mark}`)
|
|
422
647
|
}
|
|
423
|
-
lines.push(
|
|
648
|
+
lines.push('—— 群名来自 getGuild(需"获取群信息"权限)或 groupNameMap')
|
|
424
649
|
return lines.join('\n')
|
|
425
650
|
}
|
|
651
|
+
// 群头像:数字群号走 p.qlogo;openid 尝试 qqapp;失败用占位
|
|
652
|
+
function groupAvatarUrl(guildId) {
|
|
653
|
+
if (/^\d+$/.test(guildId)) return `https://p.qlogo.cn/gh/${guildId}/${guildId}/100`
|
|
654
|
+
const qqBot = (ctx.bots || []).find(b => b.platform === 'qq' && b.config?.id)
|
|
655
|
+
if (qqBot?.config?.id) return `https://q.qlogo.cn/qqapp/${qqBot.config.id}/${guildId}/640`
|
|
656
|
+
return PLACEHOLDER_AVATAR
|
|
657
|
+
}
|
|
426
658
|
|
|
427
659
|
// ===== 命令:我的发言 =====
|
|
428
660
|
async function myStats(session) {
|
package/package.json
CHANGED
|
@@ -1,10 +1,11 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "koishi-plugin-ll-mc",
|
|
3
3
|
"description": "消息统计插件(重做版):群成员发言排行 + 跨群发言排行,官方QQ机器人原生适配",
|
|
4
|
-
"version": "2.
|
|
4
|
+
"version": "2.2.0",
|
|
5
5
|
"main": "index.js",
|
|
6
6
|
"files": [
|
|
7
|
-
"index.js"
|
|
7
|
+
"index.js",
|
|
8
|
+
"templates"
|
|
8
9
|
],
|
|
9
10
|
"koishi": {
|
|
10
11
|
"description": {
|