koishi-plugin-ll-mc 2.1.1 → 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 +272 -41
- 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)'),
|
|
@@ -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,45 +321,209 @@ 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
|
-
</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' })
|
|
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 })
|
|
351
527
|
// 官机适配器只支持 data:image 内联图片;h.image(Buffer) 会生成 base64:// 协议导致发送失败
|
|
352
528
|
const src = `data:image/png;base64,${buf.toString('base64')}`
|
|
353
529
|
return { src, close: () => page.close().catch(() => {}) }
|
|
@@ -362,6 +538,10 @@ function apply(ctx, config) {
|
|
|
362
538
|
'&': '&', '<': '<', '>': '>', '"': '"', "'": ''',
|
|
363
539
|
}[c]))
|
|
364
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')
|
|
365
545
|
|
|
366
546
|
// ===== 命令:发言榜(成员) =====
|
|
367
547
|
async function memberRanking(session, period, limit) {
|
|
@@ -377,14 +557,42 @@ function apply(ctx, config) {
|
|
|
377
557
|
const groupRows = await ctx.database.get('ll_mc_stats', { channelId: session.guildId })
|
|
378
558
|
const totalAll = groupRows.reduce((s, r) => s + (r.count || 0), 0)
|
|
379
559
|
const totalRange = inGroup.reduce((s, x) => s + x.count, 0)
|
|
560
|
+
const groupName = await getGroupName(session.guildId)
|
|
380
561
|
|
|
381
562
|
const title = `${range.label}发言排行 TOP ${Math.min(n, inGroup.length)}`
|
|
382
563
|
const subtitle = `本群${range.label}共 ${totalRange} 条 · 历史累计 ${totalAll} 条`
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
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: '',
|
|
386
595
|
})
|
|
387
|
-
const img = await renderRankImage(title, subtitle, rows)
|
|
388
596
|
if (img) { img.close(); return h.image(img.src) }
|
|
389
597
|
const lines = [`📊 ${title}`]
|
|
390
598
|
top.forEach((x, i) => {
|
|
@@ -405,14 +613,30 @@ function apply(ctx, config) {
|
|
|
405
613
|
const list = await aggregateStats(range, 'group')
|
|
406
614
|
const top = list.slice(0, n)
|
|
407
615
|
const current = session.guildId
|
|
408
|
-
const
|
|
409
|
-
for (
|
|
616
|
+
const items = []
|
|
617
|
+
for (let i = 0; i < top.length; i++) {
|
|
618
|
+
const x = top[i]
|
|
410
619
|
const name = await getGroupName(x.key)
|
|
411
|
-
|
|
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
|
+
})
|
|
412
632
|
}
|
|
413
633
|
const title = `群${range.label}发言排行 TOP ${Math.min(n, top.length)}`
|
|
414
|
-
const
|
|
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
|
+
})
|
|
416
640
|
if (img) { img.close(); return h.image(img.src) }
|
|
417
641
|
const lines = [`🏆 ${title}`]
|
|
418
642
|
for (let i = 0; i < top.length; i++) {
|
|
@@ -421,9 +645,16 @@ function apply(ctx, config) {
|
|
|
421
645
|
const mark = x.key === current ? ' ★当前群' : ''
|
|
422
646
|
lines.push(`${i + 1}. ${name} · ${x.count}条${mark}`)
|
|
423
647
|
}
|
|
424
|
-
lines.push(
|
|
648
|
+
lines.push('—— 群名来自 getGuild(需"获取群信息"权限)或 groupNameMap')
|
|
425
649
|
return lines.join('\n')
|
|
426
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
|
+
}
|
|
427
658
|
|
|
428
659
|
// ===== 命令:我的发言 =====
|
|
429
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": {
|