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.
- package/index.js +154 -40
- package/package.json +1 -1
- 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 = /^(拉黑|解封|黑名单)
|
|
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(/^(拉黑|解封|黑名单)
|
|
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(/^拉黑\
|
|
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(/^解封\
|
|
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
|
|
1482
|
-
|
|
1483
|
-
|
|
1484
|
-
|
|
1485
|
-
|
|
1486
|
-
|
|
1487
|
-
|
|
1488
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
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
|
## 图片内容
|