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 CHANGED
@@ -1,15 +1,18 @@
1
1
  'use strict'
2
2
 
3
- // koishi-plugin-ll-mc v2.1.0(照抄 astrbot_plugin_message_stats 功能 + 新增群排行)
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: row.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
- // ===== 图片渲染(puppeteer,可选) =====
322
- async function renderRankImage(title, subtitle, rows) {
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
- 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' })
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
  '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;',
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
- const rows = top.map(x => {
384
- const isMine = x.key === `${session.guildId}:${session.userId}`
385
- 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 }
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 rows = []
409
- for (const x of top) {
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
- 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 })
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 subtitle = '跨群排行 · 群名来自 getGuild(需"获取群信息"权限)或 groupNameMap'
415
- const img = await renderRankImage(title, subtitle, rows)
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(`—— ${subtitle}`)
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.1.1",
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": {