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 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)'),
@@ -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: 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' })
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
  '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;',
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
- 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 }
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
- const buf = await renderRankImage(title, subtitle, rows)
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 rows = []
408
- for (const x of top) {
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
- 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
+ })
411
632
  }
412
633
  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)
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(`—— ${subtitle}`)
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.1.0",
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": {