koishi-plugin-dwrgbwiki 2.2.0 → 2.2.2

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 +164 -13
  2. package/package.json +1 -1
  3. package/readme.md +10 -1
package/index.js CHANGED
@@ -188,6 +188,9 @@ module.exports = {
188
188
  feedbackChannelId: Schema.string()
189
189
  .description('接收反馈的群/频道 id(推送反馈到该群,QQ 平台推荐使用群推送以避免私聊限制),优先于私聊推送')
190
190
  .default(''),
191
+ feedbackAdminIds: Schema.array(Schema.string())
192
+ .description('反馈管理账号 id 列表(可回复反馈 / 拉黑 / 解封 / 查看黑名单)。koishi 默认新用户权限只有 1 级,不满足回复所需的 2 级,请在这里填入你自己的用户 id(如 QQ 号);留空则仅允许 koishi 权限 ≥2 的账号')
193
+ .default([]),
191
194
  noticeEnable: Schema.boolean()
192
195
  .description('启用网易公告变动监听(正式服+共研服)')
193
196
  .default(false),
@@ -573,23 +576,77 @@ module.exports = {
573
576
 
574
577
  ctx.command('反馈 <内容:text>', '反馈问题或建议(会私聊推送给管理员)')
575
578
  .usage('反馈 <内容>')
579
+ .usage('反馈 <回复内容> -i <编号>(管理员:回复该反馈)')
580
+ .usage('反馈 拉黑 <用户id> [原因] / 反馈 拉黑 -i <编号> [原因](管理员:拉黑用户)')
581
+ .usage('反馈 解封 <用户id> / 反馈 黑名单(管理员)')
576
582
  .example('反馈 抽卡图片显示异常')
577
583
  .example('反馈 问题已处理 -i 12')
578
584
  .check(async ({ session }) => {
579
- // 含 -i 的回复操作需权限 2 级(管理员)
585
+ // 含 -i 的回复操作以及 拉黑/解封/黑名单 管理操作需管理员权限
586
+ // (feedbackAdminIds 中配置的用户 id,或 koishi 权限 ≥2;koishi 新用户默认权限只有 1)
580
587
  const body = session.content ? String(session.content).replace(/^反馈\s*/, '') : ''
581
- if (/\s+-i\s+\d+\s*$/.test(body.trim()) || /^-i\s+\d+/.test(body.trim())) {
588
+ const clean = body.trim()
589
+ const isAdminOp = /^(拉黑|解封|黑名单)(?=\s|$)/.test(clean) || /\s+-i\s+\d+\s*$/.test(clean) || /^-i\s+\d+/.test(clean)
590
+ if (isAdminOp) {
582
591
  const user = await session.observeUser(['authority']).catch(() => null)
583
- if (!user || (user.authority || 0) < 2) return '权限不足:回复反馈需要权限 2 级(管理员)'
592
+ const adminIds = Array.isArray(config.feedbackAdminIds) ? config.feedbackAdminIds : []
593
+ const isAdmin = (user && (user.authority || 0) >= 2) || adminIds.includes(String(session.userId))
594
+ if (!isAdmin) return '权限不足:回复反馈/拉黑/解封/黑名单需要管理员权限。请在插件配置「feedbackAdminIds」中添加你的用户 id,或设置 koishi 用户权限 ≥2'
584
595
  }
585
596
  })
586
597
  .action(async ({ session, options }, content) => {
587
598
  const raw0 = session.content ? String(session.content).trim() : ''
588
- const isReply = /\s+-i\s+\d+\s*$/.test(raw0.replace(/^反馈\s*/, '').trim()) || /^-i\s+\d+/.test(raw0.replace(/^反馈\s*/, '').trim())
589
- logCmd(ctx, session, '反馈' + (isReply ? '(回复)' : ''), { content })
599
+ const clean0 = raw0.replace(/^反馈\s*/, '').trim()
600
+ const isReply = /\s+-i\s+\d+\s*$/.test(clean0) || /^-i\s+\d+/.test(clean0)
601
+ const adminOp = (clean0.match(/^(拉黑|解封|黑名单)(?=\s|$)/) || [])[1] || ''
602
+ logCmd(ctx, session, '反馈' + (adminOp ? '(' + adminOp + ')' : (isReply ? '(回复)' : '')), { content })
590
603
  // 从原始消息提取完整反馈文字(保留空格;text 类型会去掉空格)
591
604
  const raw = session.content ? String(session.content).trim() : ''
592
605
  const body = raw.replace(/^反馈\s*/, '').trim()
606
+ // 黑名单管理(权限已在 check 校验):反馈 拉黑 <id> [原因] / 反馈 拉黑 -i <编号> [原因] / 反馈 解封 <id> / 反馈 黑名单
607
+ if (adminOp) {
608
+ if (adminOp === '黑名单') {
609
+ const list = await feedbackStore.listBlacklist()
610
+ if (!list.length) return '反馈黑名单为空'
611
+ const lines = list.map(e => '· ' + e.platform + ' / ' + e.userId + (e.reason ? '(' + e.reason + ')' : '') + ' — ' + e.time + (e.operator ? '(操作者 ' + e.operator + ')' : ''))
612
+ return '反馈黑名单(' + list.length + ' 人):\n' + lines.join('\n')
613
+ }
614
+ if (adminOp === '拉黑') {
615
+ const rest = clean0.replace(/^拉黑\s*/, '').trim()
616
+ if (!rest) return '请指定要拉黑的用户,格式:反馈 拉黑 <用户id> [原因],或 反馈 拉黑 -i <反馈编号> [原因]'
617
+ let target = null
618
+ let reason = ''
619
+ const viaFeedback = rest.match(/^-i\s+(\d+)(?:[\s ]*(.*))?$/)
620
+ if (viaFeedback) {
621
+ const record = await feedbackStore.get(viaFeedback[1])
622
+ if (!record) return '未找到编号 ' + viaFeedback[1] + ' 的反馈'
623
+ target = { platform: record.platform, userId: record.userId }
624
+ reason = (viaFeedback[2] || '').trim()
625
+ } else {
626
+ const direct = rest.match(/^(\S+)(?:[\s ]*(.*))?$/)
627
+ if (!direct) return '无法识别的拉黑目标'
628
+ target = { platform: session.platform, userId: direct[1] }
629
+ reason = (direct[2] || '').trim()
630
+ }
631
+ // 防止误拉管理员:检查被拉黑目标的 koishi 权限与反馈管理员配置
632
+ const adminIds = Array.isArray(config.feedbackAdminIds) ? config.feedbackAdminIds : []
633
+ if (adminIds.includes(String(target.userId))) return '不能拉黑反馈管理员账号'
634
+ if (ctx.database) {
635
+ const targetUser = await ctx.database.getUser(target.platform, target.userId, ['authority']).catch(() => null)
636
+ if (targetUser && (targetUser.authority || 0) >= 2) return '不能拉黑管理员账号'
637
+ }
638
+ const ok = await feedbackStore.addBlacklist(target.platform, target.userId, reason, session.userId)
639
+ if (!ok) return '该用户已在反馈黑名单中'
640
+ return '已将 ' + target.platform + '/' + target.userId + ' 加入反馈黑名单' + (reason ? '(原因:' + reason + ')' : '')
641
+ }
642
+ if (adminOp === '解封') {
643
+ const id = clean0.replace(/^解封\s*/, '').trim()
644
+ if (!id) return '请指定要解封的用户 id,格式:反馈 解封 <用户id>'
645
+ const ok = await feedbackStore.removeBlacklist(session.platform, id)
646
+ if (!ok) return '该用户不在反馈黑名单中'
647
+ return '已解除 ' + session.platform + '/' + id + ' 的反馈拉黑'
648
+ }
649
+ }
593
650
  // 解析回复编号:反馈 <回复内容> -i <编号>
594
651
  const optMatch = body.match(/\s+-i\s+(\d+)\s*$/)
595
652
  const replyId = optMatch ? optMatch[1] : (options && options.i ? String(options.i).trim() : '')
@@ -612,7 +669,7 @@ module.exports = {
612
669
  await feedbackStore.markReplied(replyId)
613
670
  return '已回复编号 ' + replyId + '(已发到原反馈群)'
614
671
  } catch (e) {
615
- ctx.logger('dwrgbwiki').warn('回复反馈到原群失败,尝试私聊: ' + (e && e.message ? e.message : e) + ' | channelId=' + record.channelId + ' guildId=' + record.guildId + ' | stack: ' + (e && e.stack ? String(e.stack).slice(0, 200) : ''))
672
+ ctx.logger('dwrgbwiki').warn('回复反馈到原群失败,尝试私聊: ' + errDetail(e) + ' | channelId=' + record.channelId + ' guildId=' + record.guildId)
616
673
  }
617
674
  }
618
675
  try {
@@ -627,18 +684,24 @@ module.exports = {
627
684
  await feedbackStore.markReplied(replyId)
628
685
  return '已回复编号 ' + replyId + '(私聊失败,已转发到反馈群)'
629
686
  } catch (e2) {
630
- ctx.logger('dwrgbwiki').warn('回复反馈失败: ' + (e2 && e2.message ? e2.message : e2))
631
- return '回复失败:' + (e2 && e2.message ? e2.message : e2)
687
+ ctx.logger('dwrgbwiki').warn('回复反馈兜底转发失败: ' + errDetail(e2))
688
+ return '回复失败:' + errDetail(e2)
632
689
  }
633
690
  }
634
- ctx.logger('dwrgbwiki').warn('回复反馈失败: ' + (e && e.message ? e.message : e) + ' | stack: ' + (e && e.stack ? String(e.stack).slice(0, 300) : '') + ' | target: ' + record.platform + '/' + record.userId + ' | bot: ' + (bot.platform || '?') + ':' + (bot.selfId || bot.sid) + ' status=' + bot.status)
635
- return '回复失败:' + (e && e.message ? e.message : e)
691
+ ctx.logger('dwrgbwiki').warn('回复反馈失败: ' + errDetail(e) + ' | target: ' + record.platform + '/' + record.userId + ' | bot: ' + (bot.platform || '?') + ':' + (bot.selfId || bot.sid) + ' status=' + bot.status)
692
+ return '回复失败:' + errDetail(e)
636
693
  }
637
694
  }
638
695
  // 提交反馈模式
639
696
  let text = body
640
697
  if (!text && content) text = String(content).trim()
641
698
  if (!text) return '请发送要反馈的内容,格式:反馈 <内容>'
699
+ // 黑名单拦截:被拉黑用户提交时直接告知并拒绝(不记录、不推送)
700
+ const banned = await feedbackStore.isBlacklisted(session.platform, session.userId)
701
+ if (banned) {
702
+ ctx.logger('dwrgbwiki').warn('反馈拦截: 黑名单用户 ' + session.platform + '/' + session.userId + ' 尝试提交反馈: ' + String(text).slice(0, 200))
703
+ return '你已被加入反馈黑名单,无法使用反馈功能' + (banned.reason ? '(原因:' + banned.reason + ')' : '')
704
+ }
642
705
  const time = new Date().toLocaleString('zh-CN', { hour12: false })
643
706
  const senderName = session.username || session.user?.name || session.userId || '未知用户'
644
707
  const record = await feedbackStore.create({
@@ -665,15 +728,15 @@ module.exports = {
665
728
  } else {
666
729
  await bot.sendPrivateMessage(config.feedbackUserId, feedbackMsg)
667
730
  }
668
- return '已收到反馈(编号 #' + record.id + ')。本机器人由玩家制作,并非网易第五人格官方机器人,请勿反馈第五人格相关问题。'
731
+ return '已收到反馈(编号 #' + record.id + ')。如需收到回复,请联系群主打开机器人主动在群聊发言功能。本机器人由玩家制作,并非网易第五人格官方机器人,请勿反馈第五人格相关问题。'
669
732
  } catch (e) {
670
- ctx.logger('dwrgbwiki').warn('反馈推送失败: ' + (e && e.message ? e.message : e) + ' | stack: ' + (e && e.stack ? String(e.stack).slice(0, 300) : '') + ' | target: ' + config.feedbackPlatform + '/' + (config.feedbackChannelId || config.feedbackUserId) + ' | bot: ' + (bot.platform || '?') + ':' + (bot.selfId || bot.sid) + ' status=' + bot.status)
733
+ ctx.logger('dwrgbwiki').warn('反馈推送失败: ' + errDetail(e) + ' | target: ' + config.feedbackPlatform + '/' + (config.feedbackChannelId || config.feedbackUserId) + ' | bot: ' + (bot.platform || '?') + ':' + (bot.selfId || bot.sid) + ' status=' + bot.status)
671
734
  return '已收到反馈(编号 #' + record.id + ',但推送给管理员失败,请稍后重试)'
672
735
  }
673
736
  }
674
737
  return '已收到反馈(编号 #' + record.id + ',但未找到可用的推送机器人,请稍后重试)'
675
738
  }
676
- return '已收到反馈(编号 #' + record.id + ')。本机器人由玩家制作,并非网易第五人格官方机器人,请勿反馈第五人格相关问题。'
739
+ return '已收到反馈(编号 #' + record.id + ')。如需收到回复,请联系群主打开机器人主动在群聊发言功能。本机器人由玩家制作,并非网易第五人格官方机器人,请勿反馈第五人格相关问题。'
677
740
  })
678
741
  },
679
742
  }
@@ -692,6 +755,29 @@ function logCmd(ctx, session, name, args, extra) {
692
755
  } catch (e) { /* 日志失败不影响主流程 */ }
693
756
  }
694
757
 
758
+ // 提取错误详情(解包 AggregateError 与 HTTP 响应体,便于定位 QQ 等平台 API 报错)
759
+ function errDetail(e) {
760
+ try {
761
+ if (!e) return 'unknown'
762
+ const parts = []
763
+ const msg = e.message ? String(e.message) : ''
764
+ if (msg) parts.push(msg)
765
+ if (e.errors && e.errors.length) {
766
+ parts.push('inner[' + e.errors.length + ']: ' + e.errors.map(er => {
767
+ let s = (er && er.message) ? String(er.message) : String(er)
768
+ const body = er && er.response && er.response.data ? JSON.stringify(er.response.data) : ''
769
+ if (body) s += ' [' + body.slice(0, 400) + ']'
770
+ return s
771
+ }).join(' || '))
772
+ }
773
+ const resp = e.response && e.response.data ? JSON.stringify(e.response.data).slice(0, 400) : ''
774
+ if (resp) parts.push('resp: ' + resp)
775
+ return parts.join(' | ') || 'Error'
776
+ } catch (_) {
777
+ return 'Error'
778
+ }
779
+ }
780
+
695
781
  // 指令宽度表:旧配置键 -> 表格中的指令名(commandWidths)
696
782
  const WIDTH_COMMANDS = {
697
783
  winrateWidth: '胜率',
@@ -1242,6 +1328,8 @@ class FeedbackStore {
1242
1328
  this.ctx = ctx || null
1243
1329
  this.cache = new Map() // id -> 反馈记录
1244
1330
  this.next = null
1331
+ this.blacklist = [] // 黑名单:[{ platform, userId, reason, time, operator }]
1332
+ this.blacklistLoaded = false
1245
1333
  }
1246
1334
 
1247
1335
  async loadNext() {
@@ -1264,6 +1352,69 @@ class FeedbackStore {
1264
1352
  } catch (e) { /* 忽略 */ }
1265
1353
  }
1266
1354
 
1355
+ // ===== 黑名单(持久化在 feedback_data 表的 _blacklist 记录中) =====
1356
+
1357
+ async loadBlacklist() {
1358
+ if (this.blacklistLoaded) return
1359
+ this.blacklist = []
1360
+ this.blacklistLoaded = true
1361
+ if (this.ctx && this.ctx.database) {
1362
+ try {
1363
+ const rows = await this.ctx.database.get('feedback_data', { id: '_blacklist' })
1364
+ if (rows && rows.length && rows[0].data) {
1365
+ const parsed = JSON.parse(rows[0].data)
1366
+ if (Array.isArray(parsed.entries)) this.blacklist = parsed.entries
1367
+ }
1368
+ } catch (e) { /* 忽略 */ }
1369
+ }
1370
+ }
1371
+
1372
+ async saveBlacklist() {
1373
+ if (!this.ctx || !this.ctx.database) return
1374
+ try {
1375
+ await this.ctx.database.upsert('feedback_data', [{ id: '_blacklist', data: JSON.stringify({ entries: this.blacklist }) }])
1376
+ } catch (e) { /* 忽略 */ }
1377
+ }
1378
+
1379
+ // 返回命中项(含 reason),未命中返回 null
1380
+ async isBlacklisted(platform, userId) {
1381
+ await this.loadBlacklist()
1382
+ const id = String(userId == null ? '' : userId)
1383
+ if (!id) return null
1384
+ return this.blacklist.find(e => e.platform === platform && String(e.userId) === id) || null
1385
+ }
1386
+
1387
+ async addBlacklist(platform, userId, reason, operator) {
1388
+ await this.loadBlacklist()
1389
+ const id = String(userId == null ? '' : userId)
1390
+ if (!id) return false
1391
+ if (this.blacklist.some(e => e.platform === platform && String(e.userId) === id)) return false
1392
+ this.blacklist.push({
1393
+ platform,
1394
+ userId: id,
1395
+ reason: reason || '',
1396
+ time: new Date().toLocaleString('zh-CN', { hour12: false }),
1397
+ operator: operator || '',
1398
+ })
1399
+ await this.saveBlacklist()
1400
+ return true
1401
+ }
1402
+
1403
+ async removeBlacklist(platform, userId) {
1404
+ await this.loadBlacklist()
1405
+ const id = String(userId == null ? '' : userId)
1406
+ const before = this.blacklist.length
1407
+ this.blacklist = this.blacklist.filter(e => !(e.platform === platform && String(e.userId) === id))
1408
+ if (this.blacklist.length === before) return false
1409
+ await this.saveBlacklist()
1410
+ return true
1411
+ }
1412
+
1413
+ async listBlacklist() {
1414
+ await this.loadBlacklist()
1415
+ return this.blacklist.slice()
1416
+ }
1417
+
1267
1418
  async get(id) {
1268
1419
  const key = String(id)
1269
1420
  if (this.cache.has(key)) return this.cache.get(key)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "koishi-plugin-dwrgbwiki",
3
- "version": "2.2.0",
3
+ "version": "2.2.2",
4
4
  "description": "第五人格BWIKI编辑组开发,所有代码由DeepSeek V4 Flash 0731编写。支持胜率/地图/模仿者/勋章/基础数据查询、图鉴查询、wiki页面截图、藏头诗、抽卡模拟、加页手记探索、赛事数据(选手/对决)、公告变动推送、反馈系统等功能。",
5
5
  "main": "index.js",
6
6
  "files": [
package/readme.md CHANGED
@@ -253,10 +253,17 @@ WIKI页面 <页面名>
253
253
  | 指令 | 含义 |
254
254
  | --- | --- |
255
255
  | `反馈 抽卡图片显示异常` | 提交反馈并私聊推送给管理员 |
256
- | `反馈 问题已处理 -i 12` | 管理员按编号回复反馈者(需权限 2 级) |
256
+ | `反馈 问题已处理 -i 12` | 管理员按编号回复反馈者 |
257
+ | `反馈 拉黑 <用户id> [原因]` | 管理员拉黑用户,被拉黑者提交反馈时会收到拉黑提示 |
258
+ | `反馈 拉黑 -i <编号> [原因]` | 管理员直接拉黑某条反馈的作者 |
259
+ | `反馈 解封 <用户id>` | 管理员解除用户的拉黑 |
260
+ | `反馈 黑名单` | 管理员查看当前反馈黑名单列表 |
257
261
 
258
262
  - 提交反馈后生成递增编号,回复「已收到反馈(编号 #N)」
263
+ - 管理员判定:用户 id 在配置项 `feedbackAdminIds` 中,或 koishi 权限 ≥2。**注意:koishi 新用户默认权限只有 1 级**,请在插件配置 `feedbackAdminIds` 中填入你自己的用户 id(如 QQ 号),否则回复/拉黑操作会提示权限不足
259
264
  - 管理员用 `反馈 <回复内容> -i <编号>` 通过机器人**私聊**回复反馈者,并标记该反馈已回复
265
+ - **QQ 官方机器人注意**:回复/推送属于机器人**主动发消息**,需在 QQ 开放平台的机器人设置中开通「主动消息」能力(群主动消息/私聊主动消息),否则会提示回复失败(日志中的错误码可通过插件日志查看);若私聊受限,回复会兜底转发到配置的反馈群
266
+ - 被拉黑的用户提交反馈时会被拦截并直接告知「你已被加入反馈黑名单」(不生成记录、不推送给管理员);黑名单按「平台 + 用户 id」匹配,持久化到数据库(`feedback_data` 表),管理员账号(`feedbackAdminIds` 或权限 ≥2)不能被拉黑
260
267
  - 反馈记录持久化到数据库(`feedback_data` 表)
261
268
 
262
269
  ## 配置
@@ -291,6 +298,8 @@ WIKI页面 <页面名>
291
298
  | `wecomPushDelay` | 断连宽限期(秒):断开不超过该时长不会推送,期间恢复则静默 | `60` |
292
299
  | `feedbackPlatform` | 接收反馈的平台(如 `qq`),留空禁用反馈推送 | 空 |
293
300
  | `feedbackUserId` | 接收反馈的账号 id | 空 |
301
+ | `feedbackChannelId` | 接收反馈的群/频道 id(优先于私聊推送) | 空 |
302
+ | `feedbackAdminIds` | 反馈管理账号 id 列表(可回复/拉黑/解封/查看黑名单)。koishi 新用户默认权限只有 1 级,达不到回复要求的 2 级,请在此填入你自己的用户 id;留空则仅允许 koishi 权限 ≥2 的账号 | 空 |
294
303
  | `noticeEnable` | 启用网易公告变动监听 | `false` |
295
304
  | `noticePlatform` | 公告推送的平台(如 `qq`),留空禁用公告推送 | `qq` |
296
305
  | `noticeTargets` | 公告推送目标列表,以表格形式通过「添加项目」新增(每行一个目标:类型 channel=群/频道 或 private=私聊、目标 id),可添加多个,群/频道与私聊可同时推送 | 空 |