koishi-plugin-dwrgbwiki 2.2.1 → 2.2.3

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 (3) hide show
  1. package/index.js +154 -40
  2. package/package.json +1 -1
  3. package/readme.md +4 -0
package/index.js CHANGED
@@ -52,6 +52,8 @@ const MAIN_HELP = [
52
52
  ' 查询自己的抽卡资源量',
53
53
  ' 反馈 <内容>',
54
54
  ' 反馈问题或建议(私聊推送给管理员)',
55
+ ' 公告 [源]',
56
+ ' 查看最新完整公告图片,源可选 正式/共研/全部,例:公告',
55
57
  '',
56
58
  '发送“胜率”“抽卡”等单独指令可查看对应完整帮助',
57
59
  '数据均根据第五人格官方小程序数据计算',
@@ -299,6 +301,43 @@ module.exports = {
299
301
  startNoticeMonitor(ctx, config)
300
302
  }
301
303
 
304
+ ctx.command('公告 [源:text]', '查看最新完整公告(渲染为图片发送)')
305
+ .alias('最新公告')
306
+ .example('公告')
307
+ .example('公告 共研')
308
+ .example('公告 全部')
309
+ .action(async ({ session }, source) => {
310
+ logCmd(ctx, session, '公告', { source })
311
+ const q = String(source || '').trim()
312
+ const wantAll = /^(全部|所有|both|all)$/i.test(q)
313
+ const wantGongyan = /^(共研|共研服|gongyan)$/i.test(q)
314
+ // 不指定时默认正式服;共研/全部 可切换
315
+ const items = wantAll ? NOTICE_URLS : (wantGongyan ? [NOTICE_URLS[1]] : [NOTICE_URLS[0]])
316
+ const logger = ctx.logger('dwrgbwiki')
317
+ const parts = []
318
+ const errors = []
319
+ for (const item of items) {
320
+ try {
321
+ const text = await fetchNotice(item.url, Math.max(config.timeout || 15000, 20000))
322
+ const parsed = notice.parseNoticeCached(text)
323
+ const title = parsed.title || '公告'
324
+ const image = await renderNoticeFull(ctx, config, parsed, item.name, logger)
325
+ if (image) {
326
+ parts.push('【' + item.name + ' · ' + title + '】')
327
+ parts.push(h.image(image, 'image/png'))
328
+ } else {
329
+ // 无 puppeteer 时回退完整文本
330
+ parts.push('【' + item.name + ' · ' + title + '】\n' + noticeToText(parsed))
331
+ }
332
+ } catch (e) {
333
+ errors.push('「' + item.name + '」获取失败:' + (e && e.message ? e.message : e))
334
+ }
335
+ }
336
+ if (!parts.length) return errors.join('\n') || '公告获取失败,请稍后再试'
337
+ if (errors.length) parts.push('', errors.join('\n'))
338
+ return parts
339
+ })
340
+
302
341
  ctx.command('BWIKI机器人', '显示当前支持的指令列表')
303
342
  .action(async ({ session }) => {
304
343
  logCmd(ctx, session, 'BWIKI机器人')
@@ -576,6 +615,9 @@ module.exports = {
576
615
 
577
616
  ctx.command('反馈 <内容:text>', '反馈问题或建议(会私聊推送给管理员)')
578
617
  .usage('反馈 <内容>')
618
+ .usage('反馈 <回复内容> -i <编号>(管理员:回复该反馈)')
619
+ .usage('反馈 拉黑 <用户id> [原因] / 反馈 拉黑 -i <编号> [原因](管理员:拉黑用户)')
620
+ .usage('反馈 解封 <用户id> / 反馈 黑名单(管理员)')
579
621
  .example('反馈 抽卡图片显示异常')
580
622
  .example('反馈 问题已处理 -i 12')
581
623
  .check(async ({ session }) => {
@@ -583,7 +625,7 @@ module.exports = {
583
625
  // (feedbackAdminIds 中配置的用户 id,或 koishi 权限 ≥2;koishi 新用户默认权限只有 1)
584
626
  const body = session.content ? String(session.content).replace(/^反馈\s*/, '') : ''
585
627
  const clean = body.trim()
586
- const isAdminOp = /^(拉黑|解封|黑名单)\b/.test(clean) || /\s+-i\s+\d+\s*$/.test(clean) || /^-i\s+\d+/.test(clean)
628
+ const isAdminOp = /^(拉黑|解封|黑名单)(?=\s|$)/.test(clean) || /\s+-i\s+\d+\s*$/.test(clean) || /^-i\s+\d+/.test(clean)
587
629
  if (isAdminOp) {
588
630
  const user = await session.observeUser(['authority']).catch(() => null)
589
631
  const adminIds = Array.isArray(config.feedbackAdminIds) ? config.feedbackAdminIds : []
@@ -595,7 +637,7 @@ module.exports = {
595
637
  const raw0 = session.content ? String(session.content).trim() : ''
596
638
  const clean0 = raw0.replace(/^反馈\s*/, '').trim()
597
639
  const isReply = /\s+-i\s+\d+\s*$/.test(clean0) || /^-i\s+\d+/.test(clean0)
598
- const adminOp = (clean0.match(/^(拉黑|解封|黑名单)\b/) || [])[1] || ''
640
+ const adminOp = (clean0.match(/^(拉黑|解封|黑名单)(?=\s|$)/) || [])[1] || ''
599
641
  logCmd(ctx, session, '反馈' + (adminOp ? '(' + adminOp + ')' : (isReply ? '(回复)' : '')), { content })
600
642
  // 从原始消息提取完整反馈文字(保留空格;text 类型会去掉空格)
601
643
  const raw = session.content ? String(session.content).trim() : ''
@@ -609,7 +651,7 @@ module.exports = {
609
651
  return '反馈黑名单(' + list.length + ' 人):\n' + lines.join('\n')
610
652
  }
611
653
  if (adminOp === '拉黑') {
612
- const rest = clean0.replace(/^拉黑\b/, '').trim()
654
+ const rest = clean0.replace(/^拉黑\s*/, '').trim()
613
655
  if (!rest) return '请指定要拉黑的用户,格式:反馈 拉黑 <用户id> [原因],或 反馈 拉黑 -i <反馈编号> [原因]'
614
656
  let target = null
615
657
  let reason = ''
@@ -634,10 +676,10 @@ module.exports = {
634
676
  }
635
677
  const ok = await feedbackStore.addBlacklist(target.platform, target.userId, reason, session.userId)
636
678
  if (!ok) return '该用户已在反馈黑名单中'
637
- return '已将 ' + target.platform + '/' + target.userId + ' 加入反馈黑名单' + (reason ? '(原因:' + reason + ')' : '')
679
+ return '已将 ' + target.platform + '/' + target.userId + ' 加入反馈黑名单' + (reason ? '(原因:' + reason + ')。反馈 解封 [id]可解封' : '')
638
680
  }
639
681
  if (adminOp === '解封') {
640
- const id = clean0.replace(/^解封\b/, '').trim()
682
+ const id = clean0.replace(/^解封\s*/, '').trim()
641
683
  if (!id) return '请指定要解封的用户 id,格式:反馈 解封 <用户id>'
642
684
  const ok = await feedbackStore.removeBlacklist(session.platform, id)
643
685
  if (!ok) return '该用户不在反馈黑名单中'
@@ -1477,16 +1519,34 @@ function parseNotice(text) {
1477
1519
  return { title, body }
1478
1520
  }
1479
1521
 
1522
+ // 获取公告文本:带浏览器 UA 与退避重试,抵御偶发网络抖动 / CDN 限流导致的单次失败
1480
1523
  async function fetchNotice(url, timeout) {
1481
- const ctrl = new AbortController()
1482
- const timer = setTimeout(() => ctrl.abort(), timeout)
1483
- try {
1484
- const res = await fetch(url, { signal: ctrl.signal })
1485
- if (!res.ok) throw new Error('HTTP ' + res.status)
1486
- return await res.text()
1487
- } finally {
1488
- clearTimeout(timer)
1524
+ const headers = {
1525
+ 'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/126.0.0.0 Safari/537.36',
1526
+ 'Accept': 'text/plain,text/*;q=0.9,*/*;q=0.8',
1527
+ }
1528
+ const MAX_ATTEMPTS = 3
1529
+ const DELAYS = [1000, 3000] // 重试间隔:1s、3s(远小于公告检查间隔,不影响整体节奏)
1530
+ let lastErr = null
1531
+ for (let attempt = 1; attempt <= MAX_ATTEMPTS; attempt++) {
1532
+ const ctrl = new AbortController()
1533
+ const timer = setTimeout(() => ctrl.abort(), timeout)
1534
+ try {
1535
+ const res = await fetch(url, { signal: ctrl.signal, headers, redirect: 'follow' })
1536
+ if (!res.ok) throw new Error('HTTP ' + res.status)
1537
+ return await res.text()
1538
+ } catch (e) {
1539
+ lastErr = e
1540
+ if (attempt < MAX_ATTEMPTS) await new Promise(r => setTimeout(r, DELAYS[attempt - 1]))
1541
+ } finally {
1542
+ clearTimeout(timer)
1543
+ }
1489
1544
  }
1545
+ // 解出最里层原因(Node undici 的 fetch 失败原因在 e.cause,如 ECONNRESET / ETIMEDOUT / TLS 握手失败等)
1546
+ let cause = lastErr && lastErr.cause
1547
+ while (cause && cause.cause) cause = cause.cause
1548
+ const detail = cause ? String(cause.message || cause) : (lastErr && lastErr.message) || String(lastErr)
1549
+ throw new Error('fetch failed after ' + MAX_ATTEMPTS + ' attempts: ' + detail)
1490
1550
  }
1491
1551
 
1492
1552
  // 汇总公告推送目标列表(noticeTargets:多目标,群/频道与私聊可混用)
@@ -1543,43 +1603,97 @@ async function sendNoticeImage(ctx, config, parsed, header, logger, sourceName,
1543
1603
  }
1544
1604
  }
1545
1605
 
1606
+ // 将解析后的公告渲染为完整图片(changed 传 null 即完整渲染,供「公告」指令使用)
1607
+ // 渲染失败或没有 puppeteer 时返回 null,由调用方回退文本
1608
+ async function renderNoticeFull(ctx, config, parsed, sourceName, logger) {
1609
+ if (!ctx.puppeteer) return null
1610
+ try {
1611
+ const width = config.width || 1300
1612
+ const html = notice.buildNoticeHtml(parsed, { ...config, width, sourceName }, null)
1613
+ return await capture(ctx, html, { ...config, width })
1614
+ } catch (e) {
1615
+ logger.warn('公告图片渲染失败: ' + (e && e.message ? e.message : e))
1616
+ return null
1617
+ }
1618
+ }
1619
+
1620
+ // 公告结构化对象 → 完整纯文本(无 puppeteer 时的回退,去色码并限制长度防刷屏)
1621
+ function noticeToText(parsed) {
1622
+ const clean = s => String(s == null ? '' : s)
1623
+ .replace(/#c[0-9a-fA-F]{6}/g, '')
1624
+ .replace(/#n|#r/g, ' ')
1625
+ .replace(/\s+/g, ' ').trim()
1626
+ const lines = []
1627
+ if (parsed.title) lines.push('『' + clean(parsed.title) + '』')
1628
+ if (parsed.intro) lines.push('', clean(parsed.intro))
1629
+ parsed.sections.forEach((sec, i) => {
1630
+ lines.push('', '【' + (i + 1) + '. ' + clean(sec.title) + '】')
1631
+ sec.items.forEach(it => {
1632
+ if (it.type === 'sub') lines.push('▪ ' + clean(it.text))
1633
+ else if (it.type === 'h3') lines.push('▪ ' + clean(it.text))
1634
+ else if (it.type === 'table') {
1635
+ if (it.title) lines.push('▪ ' + clean(it.title))
1636
+ if (it.headers && it.headers.length) lines.push(' ' + it.headers.join(' | '))
1637
+ it.rows.forEach(r => lines.push(' ' + r.join(' | ')))
1638
+ } else {
1639
+ lines.push('▪ ' + clean(it.text))
1640
+ }
1641
+ })
1642
+ })
1643
+ if (parsed.endTitle) lines.push('', '『' + clean(parsed.endTitle) + '』')
1644
+ if (parsed.endBody) lines.push(clean(parsed.endBody))
1645
+ let text = lines.filter(l => l !== '').join('\n')
1646
+ if (text.length > 3000) text = text.slice(0, 3000) + '\n…(内容过长已截断)'
1647
+ return text
1648
+ }
1649
+
1650
+ // 检查单个公告源(自动轮询共用):更新哈希/解析缓存,有变动则推送
1651
+ // 返回 { ok, changed, first, title, error, name, key }
1652
+ async function checkNoticeItem(ctx, config, item, state, logger) {
1653
+ const timeout = Math.max(config.timeout || 15000, 20000)
1654
+ try {
1655
+ const text = await fetchNotice(item.url, timeout)
1656
+ const hash = require('crypto').createHash('sha256').update(text).digest('hex')
1657
+ const prev = state.lastHash[item.key]
1658
+ state.lastHash[item.key] = hash
1659
+ const parsed = notice.parseNoticeCached(text)
1660
+ const prevParsed = state.lastParsed[item.key] || null
1661
+ state.lastParsed[item.key] = parsed
1662
+ const { title, body } = parseNotice(text)
1663
+ // 首次运行只记录,不推送;之后变动才推送
1664
+ if (prev !== undefined && prev !== hash) {
1665
+ const header = '【' + item.name + '公告更新】' + (title ? '「' + title + '」' : '')
1666
+ // 优先渲染为图片推送(仅变化部分),失败时回退纯文本摘要
1667
+ const changed = prevParsed ? notice.diffNotice(prevParsed, parsed) : null
1668
+ const sent = await sendNoticeImage(ctx, config, parsed, header, logger, item.name, changed && changed.hasChange ? changed : null)
1669
+ if (!sent) {
1670
+ const content = [header, '', body].join('\n')
1671
+ await sendNoticeMessage(ctx, config, content, logger)
1672
+ }
1673
+ return { ok: true, changed: true, title, name: item.name, key: item.key }
1674
+ }
1675
+ return { ok: true, changed: false, first: prev === undefined, title, name: item.name, key: item.key }
1676
+ } catch (e) {
1677
+ logger.warn('公告检查失败(' + item.key + '): ' + (e && e.message ? e.message : e) + ' | ' + item.url)
1678
+ return { ok: false, error: (e && e.message ? e.message : e), name: item.name, key: item.key }
1679
+ }
1680
+ }
1681
+
1546
1682
  function startNoticeMonitor(ctx, config) {
1547
1683
  const logger = ctx.logger('dwrgbwiki')
1548
- const timeout = Math.max(config.timeout || 15000, 20000)
1549
1684
  // 上次内容哈希:key -> hash;上次解析结果:key -> parsed(用于增量 diff)
1550
- let lastHash = {}
1551
- let lastParsed = {}
1685
+ const state = { lastHash: {}, lastParsed: {} }
1552
1686
  let running = false
1553
1687
 
1554
1688
  const check = async () => {
1555
- if (running) return
1689
+ if (running) return []
1556
1690
  running = true
1557
1691
  try {
1692
+ const results = []
1558
1693
  for (const item of NOTICE_URLS) {
1559
- try {
1560
- const text = await fetchNotice(item.url, timeout)
1561
- const hash = require('crypto').createHash('sha256').update(text).digest('hex')
1562
- const prev = lastHash[item.key]
1563
- lastHash[item.key] = hash
1564
- const parsed = notice.parseNoticeCached(text)
1565
- const prevParsed = lastParsed[item.key] || null
1566
- lastParsed[item.key] = parsed
1567
- // 首次运行只记录,不推送;之后变动才推送
1568
- if (prev !== undefined && prev !== hash) {
1569
- const { title, body } = parseNotice(text)
1570
- const header = '【' + item.name + '公告更新】' + (title ? '「' + title + '」' : '')
1571
- // 优先渲染为图片推送(仅变化部分),失败时回退纯文本摘要
1572
- const changed = prevParsed ? notice.diffNotice(prevParsed, parsed) : null
1573
- const sent = await sendNoticeImage(ctx, config, parsed, header, logger, item.name, changed && changed.hasChange ? changed : null)
1574
- if (!sent) {
1575
- const content = [header, '', body].join('\n')
1576
- await sendNoticeMessage(ctx, config, content, logger)
1577
- }
1578
- }
1579
- } catch (e) {
1580
- logger.warn('公告检查失败(' + item.key + '): ' + (e && e.message ? e.message : e))
1581
- }
1694
+ results.push(await checkNoticeItem(ctx, config, item, state, logger))
1582
1695
  }
1696
+ return results
1583
1697
  } finally {
1584
1698
  running = false
1585
1699
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "koishi-plugin-dwrgbwiki",
3
- "version": "2.2.1",
3
+ "version": "2.2.3",
4
4
  "description": "第五人格BWIKI编辑组开发,所有代码由DeepSeek V4 Flash 0731编写。支持胜率/地图/模仿者/勋章/基础数据查询、图鉴查询、wiki页面截图、藏头诗、抽卡模拟、加页手记探索、赛事数据(选手/对决)、公告变动推送、反馈系统等功能。",
5
5
  "main": "index.js",
6
6
  "files": [
package/readme.md CHANGED
@@ -322,6 +322,10 @@ WIKI页面 <页面名>
322
322
 
323
323
  每次内容变动(以内容哈希判断)时,通过 `noticePlatform` 平台的机器人推送到 `noticeTargets` 列表中的全部目标(每条目标可单独指定 `channel` 群/频道或 `private` 私聊,可同时推送)。推送内容为「公告标题」+ 正文摘要(去除 HTML 标签与颜色代码,截断 500 字)。首次启动只记录基线,不推送。
324
324
 
325
+ 检查请求带浏览器 User-Agent,并在网络抖动时自动重试 3 次(间隔 1s、3s),连续失败才会记录「公告检查失败」日志(日志会附上公告地址与真实原因)。
326
+
327
+ 任何成员可发送 `公告`(或 `最新公告`)查看最新完整公告:默认渲染并发送正式服完整公告图片,可加参数切换来源——`公告 共研`(共研服)、`公告 全部`(两个都发);无 puppeteer 环境时自动回退为完整文本。
328
+
325
329
  变更推送优先渲染为图片(仅包含发生变化的部分):当「致诸位尊敬的访客」等引言段落没有变动时,不再将其渲染进图中,避免每次推送都带上固定引言;若引言本身有变动则会正常展示。图片渲染失败时自动回退为纯文本摘要推送。
326
330
 
327
331
  ## 图片内容