koishi-plugin-dwrgbwiki 2.1.3 → 2.2.1

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 (4) hide show
  1. package/index.js +248 -71
  2. package/notice.js +4 -1
  3. package/package.json +1 -1
  4. package/readme.md +16 -14
package/index.js CHANGED
@@ -117,36 +117,20 @@ module.exports = {
117
117
  width: Schema.number().min(400).max(4000)
118
118
  .description('图片宽度(px),其他指令未单独设置时使用该值')
119
119
  .default(1300),
120
- winrateWidth: Schema.number().min(0).max(4000)
121
- .description('胜率指令图片宽度(px),0=使用默认宽度')
122
- .default(0),
123
- mapWidth: Schema.number().min(0).max(4000)
124
- .description('地图指令图片宽度(px),0=使用默认宽度')
125
- .default(0),
126
- mimicWidth: Schema.number().min(0).max(4000)
127
- .description('模仿者指令图片宽度(px),0=使用默认宽度')
128
- .default(0),
129
- matchWidth: Schema.number().min(0).max(4000)
130
- .description('赛事指令图片宽度(px),0=使用默认宽度')
131
- .default(0),
120
+ commandWidths: Schema.array(Schema.object({
121
+ command: Schema.union(['胜率', '地图', '模仿者', '赛事', '勋章', '基础数据', '抽卡', '图鉴', 'WIKI'])
122
+ .required()
123
+ .description('指令名称'),
124
+ width: Schema.number().min(0).max(4000)
125
+ .description('该指令图片宽度(px),0=使用默认宽度')
126
+ .default(0),
127
+ }))
128
+ .role('table')
129
+ .description('各指令图片宽度设置(px),以表格形式通过「添加项目」新增;0=使用默认宽度')
130
+ .default([]),
132
131
  matchMerge: Schema.union(['team', 'player', 'all', 'none'])
133
132
  .description('赛事指令默认合并模式:team=合并队伍前身,player=合并选手多个id,all=两者都合并,none=完全不合并')
134
133
  .default('team'),
135
- badgeWidth: Schema.number().min(0).max(4000)
136
- .description('勋章指令图片宽度(px),0=使用默认宽度')
137
- .default(0),
138
- basicWidth: Schema.number().min(0).max(4000)
139
- .description('基础数据指令图片宽度(px),0=使用默认宽度')
140
- .default(0),
141
- gachaWidth: Schema.number().min(0).max(4000)
142
- .description('抽卡指令图片宽度(px),0=使用默认宽度')
143
- .default(0),
144
- codexWidth: Schema.number().min(0).max(4000)
145
- .description('图鉴指令图片宽度(px),0=使用默认宽度')
146
- .default(0),
147
- wikiWidth: Schema.number().min(400).max(4000)
148
- .description('WIKI页面指令截图宽度(px)')
149
- .default(1300),
150
134
  wikiMaxHeight: Schema.number().min(200).max(12000)
151
135
  .description('WIKI页面指令截图最大高度(px)')
152
136
  .default(5000),
@@ -204,18 +188,25 @@ module.exports = {
204
188
  feedbackChannelId: Schema.string()
205
189
  .description('接收反馈的群/频道 id(推送反馈到该群,QQ 平台推荐使用群推送以避免私聊限制),优先于私聊推送')
206
190
  .default(''),
191
+ feedbackAdminIds: Schema.array(Schema.string())
192
+ .description('反馈管理账号 id 列表(可回复反馈 / 拉黑 / 解封 / 查看黑名单)。koishi 默认新用户权限只有 1 级,不满足回复所需的 2 级,请在这里填入你自己的用户 id(如 QQ 号);留空则仅允许 koishi 权限 ≥2 的账号')
193
+ .default([]),
207
194
  noticeEnable: Schema.boolean()
208
195
  .description('启用网易公告变动监听(正式服+共研服)')
209
196
  .default(false),
210
197
  noticePlatform: Schema.string()
211
198
  .description('公告推送的平台(如 qq),留空禁用公告推送')
212
199
  .default('qq'),
213
- noticeChannelType: Schema.union(['channel', 'private'])
214
- .description('公告推送目标类型:channel=群/频道,private=私聊')
215
- .default('channel'),
216
- noticeChannelId: Schema.string()
217
- .description('公告推送目标:群/频道 id(channel 类型)或用户 id(private 类型)')
218
- .default(''),
200
+ noticeTargets: Schema.array(Schema.object({
201
+ type: Schema.union(['channel', 'private'])
202
+ .description('目标类型:channel=群/频道,private=私聊')
203
+ .default('channel'),
204
+ id: Schema.string().required()
205
+ .description('群/频道 id(channel 类型)或用户 id(private 类型)'),
206
+ }))
207
+ .role('table')
208
+ .description('公告推送目标列表,以表格形式通过「添加项目」新增推送目标(每行一个目标),可添加多个,群/频道与私聊可同时推送')
209
+ .default([]),
219
210
  noticeInterval: Schema.number().min(1).max(1440)
220
211
  .description('公告检查间隔(分钟)')
221
212
  .default(10),
@@ -233,10 +224,10 @@ module.exports = {
233
224
  key: Schema.string().required()
234
225
  .description('模板参数名'),
235
226
  label: Schema.string()
236
- .description('显示名,留空用参数名')
237
- .default(''),
227
+ .description('显示名,留空则显示参数名'),
238
228
  }))
239
- .description('要解析展示的参数,可增减')
229
+ .role('table')
230
+ .description('要解析展示的参数,以表格形式通过「添加项目」新增(每行一个参数);新增时默认只填 key,label 可留空或按需填写')
240
231
  .default([]),
241
232
  image: Schema.string()
242
233
  .description('配图文件名模板(用 {{参数名}} 占位,如 {{角色}}{{品质}}品质时装_{{名称}}.png),留空不显示图片')
@@ -253,17 +244,17 @@ module.exports = {
253
244
  templates: ['单时装', '商城信息'],
254
245
  image: '{{角色}}{{品质}}品质时装_{{名称}}.png',
255
246
  fields: [
256
- { key: '名称', label: '名称' },
257
- { key: '角色', label: '角色' },
258
- { key: '品质', label: '品质' },
259
- { key: '获取方式', label: '获取方式' },
260
- { key: '价格', label: '价格' },
261
- { key: '上架时间', label: '上架时间' },
262
- { key: '下架时间', label: '下架时间' },
263
- { key: '具体描述', label: '具体描述' },
264
- { key: '备注', label: '备注' },
265
- { key: '限时限定', label: '限时限定' },
266
- { key: '英文名称', label: '英文名称' },
247
+ { key: '名称' },
248
+ { key: '角色' },
249
+ { key: '品质' },
250
+ { key: '获取方式' },
251
+ { key: '价格' },
252
+ { key: '上架时间' },
253
+ { key: '下架时间' },
254
+ { key: '具体描述' },
255
+ { key: '备注' },
256
+ { key: '限时限定' },
257
+ { key: '英文名称' },
267
258
  ],
268
259
  },
269
260
  ]),
@@ -588,20 +579,71 @@ module.exports = {
588
579
  .example('反馈 抽卡图片显示异常')
589
580
  .example('反馈 问题已处理 -i 12')
590
581
  .check(async ({ session }) => {
591
- // 含 -i 的回复操作需权限 2 级(管理员)
582
+ // 含 -i 的回复操作以及 拉黑/解封/黑名单 管理操作需管理员权限
583
+ // (feedbackAdminIds 中配置的用户 id,或 koishi 权限 ≥2;koishi 新用户默认权限只有 1)
592
584
  const body = session.content ? String(session.content).replace(/^反馈\s*/, '') : ''
593
- if (/\s+-i\s+\d+\s*$/.test(body.trim()) || /^-i\s+\d+/.test(body.trim())) {
585
+ const clean = body.trim()
586
+ const isAdminOp = /^(拉黑|解封|黑名单)\b/.test(clean) || /\s+-i\s+\d+\s*$/.test(clean) || /^-i\s+\d+/.test(clean)
587
+ if (isAdminOp) {
594
588
  const user = await session.observeUser(['authority']).catch(() => null)
595
- if (!user || (user.authority || 0) < 2) return '权限不足:回复反馈需要权限 2 级(管理员)'
589
+ const adminIds = Array.isArray(config.feedbackAdminIds) ? config.feedbackAdminIds : []
590
+ const isAdmin = (user && (user.authority || 0) >= 2) || adminIds.includes(String(session.userId))
591
+ if (!isAdmin) return '权限不足:回复反馈/拉黑/解封/黑名单需要管理员权限。请在插件配置「feedbackAdminIds」中添加你的用户 id,或设置 koishi 用户权限 ≥2'
596
592
  }
597
593
  })
598
594
  .action(async ({ session, options }, content) => {
599
595
  const raw0 = session.content ? String(session.content).trim() : ''
600
- const isReply = /\s+-i\s+\d+\s*$/.test(raw0.replace(/^反馈\s*/, '').trim()) || /^-i\s+\d+/.test(raw0.replace(/^反馈\s*/, '').trim())
601
- logCmd(ctx, session, '反馈' + (isReply ? '(回复)' : ''), { content })
596
+ const clean0 = raw0.replace(/^反馈\s*/, '').trim()
597
+ const isReply = /\s+-i\s+\d+\s*$/.test(clean0) || /^-i\s+\d+/.test(clean0)
598
+ const adminOp = (clean0.match(/^(拉黑|解封|黑名单)\b/) || [])[1] || ''
599
+ logCmd(ctx, session, '反馈' + (adminOp ? '(' + adminOp + ')' : (isReply ? '(回复)' : '')), { content })
602
600
  // 从原始消息提取完整反馈文字(保留空格;text 类型会去掉空格)
603
601
  const raw = session.content ? String(session.content).trim() : ''
604
602
  const body = raw.replace(/^反馈\s*/, '').trim()
603
+ // 黑名单管理(权限已在 check 校验):反馈 拉黑 <id> [原因] / 反馈 拉黑 -i <编号> [原因] / 反馈 解封 <id> / 反馈 黑名单
604
+ if (adminOp) {
605
+ if (adminOp === '黑名单') {
606
+ const list = await feedbackStore.listBlacklist()
607
+ if (!list.length) return '反馈黑名单为空'
608
+ const lines = list.map(e => '· ' + e.platform + ' / ' + e.userId + (e.reason ? '(' + e.reason + ')' : '') + ' — ' + e.time + (e.operator ? '(操作者 ' + e.operator + ')' : ''))
609
+ return '反馈黑名单(' + list.length + ' 人):\n' + lines.join('\n')
610
+ }
611
+ if (adminOp === '拉黑') {
612
+ const rest = clean0.replace(/^拉黑\b/, '').trim()
613
+ if (!rest) return '请指定要拉黑的用户,格式:反馈 拉黑 <用户id> [原因],或 反馈 拉黑 -i <反馈编号> [原因]'
614
+ let target = null
615
+ let reason = ''
616
+ const viaFeedback = rest.match(/^-i\s+(\d+)(?:[\s ]*(.*))?$/)
617
+ if (viaFeedback) {
618
+ const record = await feedbackStore.get(viaFeedback[1])
619
+ if (!record) return '未找到编号 ' + viaFeedback[1] + ' 的反馈'
620
+ target = { platform: record.platform, userId: record.userId }
621
+ reason = (viaFeedback[2] || '').trim()
622
+ } else {
623
+ const direct = rest.match(/^(\S+)(?:[\s ]*(.*))?$/)
624
+ if (!direct) return '无法识别的拉黑目标'
625
+ target = { platform: session.platform, userId: direct[1] }
626
+ reason = (direct[2] || '').trim()
627
+ }
628
+ // 防止误拉管理员:检查被拉黑目标的 koishi 权限与反馈管理员配置
629
+ const adminIds = Array.isArray(config.feedbackAdminIds) ? config.feedbackAdminIds : []
630
+ if (adminIds.includes(String(target.userId))) return '不能拉黑反馈管理员账号'
631
+ if (ctx.database) {
632
+ const targetUser = await ctx.database.getUser(target.platform, target.userId, ['authority']).catch(() => null)
633
+ if (targetUser && (targetUser.authority || 0) >= 2) return '不能拉黑管理员账号'
634
+ }
635
+ const ok = await feedbackStore.addBlacklist(target.platform, target.userId, reason, session.userId)
636
+ if (!ok) return '该用户已在反馈黑名单中'
637
+ return '已将 ' + target.platform + '/' + target.userId + ' 加入反馈黑名单' + (reason ? '(原因:' + reason + ')' : '')
638
+ }
639
+ if (adminOp === '解封') {
640
+ const id = clean0.replace(/^解封\b/, '').trim()
641
+ if (!id) return '请指定要解封的用户 id,格式:反馈 解封 <用户id>'
642
+ const ok = await feedbackStore.removeBlacklist(session.platform, id)
643
+ if (!ok) return '该用户不在反馈黑名单中'
644
+ return '已解除 ' + session.platform + '/' + id + ' 的反馈拉黑'
645
+ }
646
+ }
605
647
  // 解析回复编号:反馈 <回复内容> -i <编号>
606
648
  const optMatch = body.match(/\s+-i\s+(\d+)\s*$/)
607
649
  const replyId = optMatch ? optMatch[1] : (options && options.i ? String(options.i).trim() : '')
@@ -624,7 +666,7 @@ module.exports = {
624
666
  await feedbackStore.markReplied(replyId)
625
667
  return '已回复编号 ' + replyId + '(已发到原反馈群)'
626
668
  } catch (e) {
627
- 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) : ''))
669
+ ctx.logger('dwrgbwiki').warn('回复反馈到原群失败,尝试私聊: ' + errDetail(e) + ' | channelId=' + record.channelId + ' guildId=' + record.guildId)
628
670
  }
629
671
  }
630
672
  try {
@@ -639,18 +681,24 @@ module.exports = {
639
681
  await feedbackStore.markReplied(replyId)
640
682
  return '已回复编号 ' + replyId + '(私聊失败,已转发到反馈群)'
641
683
  } catch (e2) {
642
- ctx.logger('dwrgbwiki').warn('回复反馈失败: ' + (e2 && e2.message ? e2.message : e2))
643
- return '回复失败:' + (e2 && e2.message ? e2.message : e2)
684
+ ctx.logger('dwrgbwiki').warn('回复反馈兜底转发失败: ' + errDetail(e2))
685
+ return '回复失败:' + errDetail(e2)
644
686
  }
645
687
  }
646
- 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)
647
- return '回复失败:' + (e && e.message ? e.message : e)
688
+ ctx.logger('dwrgbwiki').warn('回复反馈失败: ' + errDetail(e) + ' | target: ' + record.platform + '/' + record.userId + ' | bot: ' + (bot.platform || '?') + ':' + (bot.selfId || bot.sid) + ' status=' + bot.status)
689
+ return '回复失败:' + errDetail(e)
648
690
  }
649
691
  }
650
692
  // 提交反馈模式
651
693
  let text = body
652
694
  if (!text && content) text = String(content).trim()
653
695
  if (!text) return '请发送要反馈的内容,格式:反馈 <内容>'
696
+ // 黑名单拦截:被拉黑用户提交时直接告知并拒绝(不记录、不推送)
697
+ const banned = await feedbackStore.isBlacklisted(session.platform, session.userId)
698
+ if (banned) {
699
+ ctx.logger('dwrgbwiki').warn('反馈拦截: 黑名单用户 ' + session.platform + '/' + session.userId + ' 尝试提交反馈: ' + String(text).slice(0, 200))
700
+ return '你已被加入反馈黑名单,无法使用反馈功能' + (banned.reason ? '(原因:' + banned.reason + ')' : '')
701
+ }
654
702
  const time = new Date().toLocaleString('zh-CN', { hour12: false })
655
703
  const senderName = session.username || session.user?.name || session.userId || '未知用户'
656
704
  const record = await feedbackStore.create({
@@ -677,15 +725,15 @@ module.exports = {
677
725
  } else {
678
726
  await bot.sendPrivateMessage(config.feedbackUserId, feedbackMsg)
679
727
  }
680
- return '已收到反馈(编号 #' + record.id + ')。本机器人由玩家制作,并非网易第五人格官方机器人,请勿反馈第五人格相关问题。'
728
+ return '已收到反馈(编号 #' + record.id + ')。如需收到回复,请联系群主打开机器人主动在群聊发言功能。本机器人由玩家制作,并非网易第五人格官方机器人,请勿反馈第五人格相关问题。'
681
729
  } catch (e) {
682
- 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)
730
+ ctx.logger('dwrgbwiki').warn('反馈推送失败: ' + errDetail(e) + ' | target: ' + config.feedbackPlatform + '/' + (config.feedbackChannelId || config.feedbackUserId) + ' | bot: ' + (bot.platform || '?') + ':' + (bot.selfId || bot.sid) + ' status=' + bot.status)
683
731
  return '已收到反馈(编号 #' + record.id + ',但推送给管理员失败,请稍后重试)'
684
732
  }
685
733
  }
686
734
  return '已收到反馈(编号 #' + record.id + ',但未找到可用的推送机器人,请稍后重试)'
687
735
  }
688
- return '已收到反馈(编号 #' + record.id + ')。本机器人由玩家制作,并非网易第五人格官方机器人,请勿反馈第五人格相关问题。'
736
+ return '已收到反馈(编号 #' + record.id + ')。如需收到回复,请联系群主打开机器人主动在群聊发言功能。本机器人由玩家制作,并非网易第五人格官方机器人,请勿反馈第五人格相关问题。'
689
737
  })
690
738
  },
691
739
  }
@@ -704,7 +752,48 @@ function logCmd(ctx, session, name, args, extra) {
704
752
  } catch (e) { /* 日志失败不影响主流程 */ }
705
753
  }
706
754
 
755
+ // 提取错误详情(解包 AggregateError 与 HTTP 响应体,便于定位 QQ 等平台 API 报错)
756
+ function errDetail(e) {
757
+ try {
758
+ if (!e) return 'unknown'
759
+ const parts = []
760
+ const msg = e.message ? String(e.message) : ''
761
+ if (msg) parts.push(msg)
762
+ if (e.errors && e.errors.length) {
763
+ parts.push('inner[' + e.errors.length + ']: ' + e.errors.map(er => {
764
+ let s = (er && er.message) ? String(er.message) : String(er)
765
+ const body = er && er.response && er.response.data ? JSON.stringify(er.response.data) : ''
766
+ if (body) s += ' [' + body.slice(0, 400) + ']'
767
+ return s
768
+ }).join(' || '))
769
+ }
770
+ const resp = e.response && e.response.data ? JSON.stringify(e.response.data).slice(0, 400) : ''
771
+ if (resp) parts.push('resp: ' + resp)
772
+ return parts.join(' | ') || 'Error'
773
+ } catch (_) {
774
+ return 'Error'
775
+ }
776
+ }
777
+
778
+ // 指令宽度表:旧配置键 -> 表格中的指令名(commandWidths)
779
+ const WIDTH_COMMANDS = {
780
+ winrateWidth: '胜率',
781
+ mapWidth: '地图',
782
+ mimicWidth: '模仿者',
783
+ matchWidth: '赛事',
784
+ badgeWidth: '勋章',
785
+ basicWidth: '基础数据',
786
+ gachaWidth: '抽卡',
787
+ codexWidth: '图鉴',
788
+ wikiWidth: 'WIKI',
789
+ }
790
+
791
+ // 读取指令图片宽度:优先 commandWidths 表格,其次回退旧版独立配置键,最后使用默认宽度
707
792
  function cmdWidth(config, key) {
793
+ const command = WIDTH_COMMANDS[key]
794
+ const list = Array.isArray(config.commandWidths) ? config.commandWidths : []
795
+ const row = command && list.find(t => t && t.command === command)
796
+ if (row && row.width > 0) return row.width
708
797
  const v = config[key]
709
798
  return (v && v > 0) ? v : (config.width || 1300)
710
799
  }
@@ -881,6 +970,7 @@ async function codexImage(ctx, config, rest) {
881
970
  ctx.logger('dwrgbwiki').warn(e)
882
971
  const msg = e && e.message ? e.message : String(e)
883
972
  if (String(msg).includes('页面不存在')) {
973
+ await new Promise(r => setTimeout(r, 2000))
884
974
  try {
885
975
  const results = await codex.searchPages(q, config)
886
976
  if (results.length) {
@@ -974,7 +1064,7 @@ async function wikiPageImage(ctx, config, wikiStore, userId, name) {
974
1064
  } catch (e) {
975
1065
  ctx.logger('dwrgbwiki').warn('WIKI页面解析标题失败,按原样截图: ' + (e && e.message ? e.message : e))
976
1066
  }
977
- const width = config.wikiWidth || 1300
1067
+ const width = cmdWidth(config, 'wikiWidth')
978
1068
  const maxHeight = config.wikiMaxHeight || 5000
979
1069
  const image = await wiki.screenshot(ctx, target, {
980
1070
  width,
@@ -1235,6 +1325,8 @@ class FeedbackStore {
1235
1325
  this.ctx = ctx || null
1236
1326
  this.cache = new Map() // id -> 反馈记录
1237
1327
  this.next = null
1328
+ this.blacklist = [] // 黑名单:[{ platform, userId, reason, time, operator }]
1329
+ this.blacklistLoaded = false
1238
1330
  }
1239
1331
 
1240
1332
  async loadNext() {
@@ -1257,6 +1349,69 @@ class FeedbackStore {
1257
1349
  } catch (e) { /* 忽略 */ }
1258
1350
  }
1259
1351
 
1352
+ // ===== 黑名单(持久化在 feedback_data 表的 _blacklist 记录中) =====
1353
+
1354
+ async loadBlacklist() {
1355
+ if (this.blacklistLoaded) return
1356
+ this.blacklist = []
1357
+ this.blacklistLoaded = true
1358
+ if (this.ctx && this.ctx.database) {
1359
+ try {
1360
+ const rows = await this.ctx.database.get('feedback_data', { id: '_blacklist' })
1361
+ if (rows && rows.length && rows[0].data) {
1362
+ const parsed = JSON.parse(rows[0].data)
1363
+ if (Array.isArray(parsed.entries)) this.blacklist = parsed.entries
1364
+ }
1365
+ } catch (e) { /* 忽略 */ }
1366
+ }
1367
+ }
1368
+
1369
+ async saveBlacklist() {
1370
+ if (!this.ctx || !this.ctx.database) return
1371
+ try {
1372
+ await this.ctx.database.upsert('feedback_data', [{ id: '_blacklist', data: JSON.stringify({ entries: this.blacklist }) }])
1373
+ } catch (e) { /* 忽略 */ }
1374
+ }
1375
+
1376
+ // 返回命中项(含 reason),未命中返回 null
1377
+ async isBlacklisted(platform, userId) {
1378
+ await this.loadBlacklist()
1379
+ const id = String(userId == null ? '' : userId)
1380
+ if (!id) return null
1381
+ return this.blacklist.find(e => e.platform === platform && String(e.userId) === id) || null
1382
+ }
1383
+
1384
+ async addBlacklist(platform, userId, reason, operator) {
1385
+ await this.loadBlacklist()
1386
+ const id = String(userId == null ? '' : userId)
1387
+ if (!id) return false
1388
+ if (this.blacklist.some(e => e.platform === platform && String(e.userId) === id)) return false
1389
+ this.blacklist.push({
1390
+ platform,
1391
+ userId: id,
1392
+ reason: reason || '',
1393
+ time: new Date().toLocaleString('zh-CN', { hour12: false }),
1394
+ operator: operator || '',
1395
+ })
1396
+ await this.saveBlacklist()
1397
+ return true
1398
+ }
1399
+
1400
+ async removeBlacklist(platform, userId) {
1401
+ await this.loadBlacklist()
1402
+ const id = String(userId == null ? '' : userId)
1403
+ const before = this.blacklist.length
1404
+ this.blacklist = this.blacklist.filter(e => !(e.platform === platform && String(e.userId) === id))
1405
+ if (this.blacklist.length === before) return false
1406
+ await this.saveBlacklist()
1407
+ return true
1408
+ }
1409
+
1410
+ async listBlacklist() {
1411
+ await this.loadBlacklist()
1412
+ return this.blacklist.slice()
1413
+ }
1414
+
1260
1415
  async get(id) {
1261
1416
  const key = String(id)
1262
1417
  if (this.cache.has(key)) return this.cache.get(key)
@@ -1334,21 +1489,43 @@ async function fetchNotice(url, timeout) {
1334
1489
  }
1335
1490
  }
1336
1491
 
1492
+ // 汇总公告推送目标列表(noticeTargets:多目标,群/频道与私聊可混用)
1493
+ function buildNoticeTargets(config) {
1494
+ const list = Array.isArray(config.noticeTargets) ? config.noticeTargets : []
1495
+ const targets = []
1496
+ const seen = new Set()
1497
+ for (const t of list) {
1498
+ const id = String(t && t.id ? t.id : '').trim()
1499
+ if (!id) continue
1500
+ const type = t.type === 'private' ? 'private' : 'channel'
1501
+ const key = type + ':' + id
1502
+ if (seen.has(key)) continue
1503
+ seen.add(key)
1504
+ targets.push({ type, id })
1505
+ }
1506
+ return targets
1507
+ }
1508
+
1509
+ // 推送到全部目标,任一目标成功即视为成功(其余目标失败仅记录日志)
1337
1510
  async function sendNoticeMessage(ctx, config, content, logger) {
1338
- if (!config.noticePlatform || !config.noticeChannelId) return false
1511
+ const targets = buildNoticeTargets(config)
1512
+ if (!config.noticePlatform || !targets.length) return false
1339
1513
  const bot = ctx.bots && ctx.bots.find(b => b.platform === config.noticePlatform && b.status === 1)
1340
1514
  if (!bot) return false
1341
- try {
1342
- if (config.noticeChannelType === 'private') {
1343
- await bot.sendPrivateMessage(config.noticeChannelId, content)
1344
- } else {
1345
- await bot.sendMessage(config.noticeChannelId, content)
1515
+ let ok = false
1516
+ for (const t of targets) {
1517
+ try {
1518
+ if (t.type === 'private') {
1519
+ await bot.sendPrivateMessage(t.id, content)
1520
+ } else {
1521
+ await bot.sendMessage(t.id, content)
1522
+ }
1523
+ ok = true
1524
+ } catch (e) {
1525
+ logger.warn('公告推送失败(' + t.type + ':' + t.id + '): ' + (e && e.message ? e.message : e))
1346
1526
  }
1347
- return true
1348
- } catch (e) {
1349
- logger.warn('公告推送失败: ' + (e && e.message ? e.message : e))
1350
- return false
1351
1527
  }
1528
+ return ok
1352
1529
  }
1353
1530
 
1354
1531
  // 公告渲染为图片推送;失败(无 puppeteer / 渲染出错)返回 false,由调用方回退文本
package/notice.js CHANGED
@@ -216,7 +216,10 @@ function buildNoticeHtml(parsed, config, changed) {
216
216
  const width = config.width || 1300
217
217
  const sourceName = config.sourceName || '官方'
218
218
  const title = parsed.title || '更新公告'
219
- const introLines = parsed.intro.split('\n').map(l => l.trim()).filter(Boolean)
219
+ // 增量推送(changed 非空)时,引言(第 1 个板块前的“致诸位尊敬的访客”等段落)未变动则不渲染,
220
+ // 避免每次推送都把固定引言带进图里;完整渲染(changed 为空)时始终展示
221
+ const showIntro = !changed || changed.intro
222
+ const introLines = showIntro ? parsed.intro.split('\n').map(l => l.trim()).filter(Boolean) : []
220
223
  const introHtml = introLines.length
221
224
  ? '<div class="intro' + (changed ? ' update' : '') + '">' + introLines.map(l => inlineHtml(l)).join('<br>') + '</div>'
222
225
  : ''
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "koishi-plugin-dwrgbwiki",
3
- "version": "2.1.3",
3
+ "version": "2.2.1",
4
4
  "description": "第五人格BWIKI编辑组开发,所有代码由DeepSeek V4 Flash 0731编写。支持胜率/地图/模仿者/勋章/基础数据查询、图鉴查询、wiki页面截图、藏头诗、抽卡模拟、加页手记探索、赛事数据(选手/对决)、公告变动推送、反馈系统等功能。",
5
5
  "main": "index.js",
6
6
  "files": [
package/readme.md CHANGED
@@ -115,7 +115,7 @@ WIKI页面 <页面名>
115
115
  | `图鉴 时装 招风旗` | 查询时装「招风旗」并生成图片卡片 |
116
116
 
117
117
  - 数据来自第五人格 BWIKI(`wiki.biligame.com/dwrg`),通过 `query&prop=revisions` 获取页面 wikitext 后按模板解析
118
- - 二级指令、模板名(前缀匹配,可多个)、解析字段、配图文件名模板与输出宽度均可在插件配置「图鉴二级指令配置」中自定义增减
118
+ - 二级指令、模板名(前缀匹配,可多个)、解析字段、配图文件名模板与输出宽度均可在插件配置「图鉴二级指令配置」中自定义增减;字段以表格形式通过「添加项目」新增(每行一个参数),新增时默认只填 `key`,`label` 留空则显示参数名
119
119
  - 配图文件名模板用 `{{参数名}}` 占位,如 `{{角色}}{{品质}}品质时装_{{名称}}.png`,插件会按该文件名在 wiki 上查找图片并显示在卡片左侧(找不到则不显示);参数值会自动清洗 wiki 语法(`[[链接]]`、`[[File:..]]`、`<br>` 等)
120
120
  - 每个二级指令可单独设置 `width`(输出图片宽度,0=使用默认宽度),卡片为左侧图片、右侧字段表格的版式
121
121
  - **时装专属**:`图鉴 时装 <名称>` 会额外读取 `/WIKI/时装稀有度/` 的 `latest_price.csv`(最新价格),在卡片底部显示该时装的**占有率**(含数据更新日期,占有率为动态数据);当品质为「稀世」时额外显示**单金价格**与**多金价格**
@@ -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
  ## 配置
@@ -270,20 +277,12 @@ WIKI页面 <页面名>
270
277
  | `showBadge` | 显示勋章分数据 | `true` |
271
278
  | `badgeDays` | 勋章日增长率天数 | `7` |
272
279
  | `width` | 图片宽度(px),其他指令未单独设置时使用该值 | `1300` |
273
- | `winrateWidth` | 胜率指令图片宽度(px),0=使用默认宽度 | `0` |
274
- | `mapWidth` | 地图指令图片宽度(px),0=使用默认宽度 | `0` |
275
- | `mimicWidth` | 模仿者指令图片宽度(px),0=使用默认宽度 | `0` |
276
- | `badgeWidth` | 勋章指令图片宽度(px),0=使用默认宽度 | `0` |
277
- | `basicWidth` | 基础数据指令图片宽度(px),0=使用默认宽度 | `0` |
278
- | `gachaWidth` | 抽卡/探索/资源指令图片宽度(px),0=使用默认宽度 | `0` |
279
- | `codexWidth` | 图鉴指令图片宽度(px),0=使用默认宽度 | `0` |
280
+ | `commandWidths` | 各指令图片宽度表(表格形式,每行:指令名 + 宽度,0=使用默认宽度),可配置胜率/地图/模仿者/赛事/勋章/基础数据/抽卡/图鉴/WIKI | 空 |
280
281
  | `codexCommands` | 图鉴二级指令配置(名称/说明/模板名/解析字段,可增减) | 预置「时装」 |
281
- | `wikiWidth` | WIKI页面指令截图宽度(px) | `1300` |
282
282
  | `wikiMaxHeight` | WIKI页面指令截图最大高度(px) | `5000` |
283
283
  | `wikiDailyLimit` | WIKI页面指令每日使用次数上限 | `5` |
284
284
  | `cangtouDefaultMode` | 藏头指令默认模式 | `谐音` |
285
285
  | `cangtouDefaultType` | 藏头指令默认物品类型 | `所有` |
286
- | `matchWidth` | 赛事指令图片宽度(px),0=使用默认宽度 | `0` |
287
286
  | `matchMerge` | 赛事指令默认合并模式(team/player/all/none) | `team` |
288
287
  | `gachaDailyLimit` | 抽卡/探索每日次数上限 | `2` |
289
288
  | `gachaDefaultJinghua` | 抽卡-精华 默认卡池编号 | 空 |
@@ -299,10 +298,11 @@ WIKI页面 <页面名>
299
298
  | `wecomPushDelay` | 断连宽限期(秒):断开不超过该时长不会推送,期间恢复则静默 | `60` |
300
299
  | `feedbackPlatform` | 接收反馈的平台(如 `qq`),留空禁用反馈推送 | 空 |
301
300
  | `feedbackUserId` | 接收反馈的账号 id | 空 |
301
+ | `feedbackChannelId` | 接收反馈的群/频道 id(优先于私聊推送) | 空 |
302
+ | `feedbackAdminIds` | 反馈管理账号 id 列表(可回复/拉黑/解封/查看黑名单)。koishi 新用户默认权限只有 1 级,达不到回复要求的 2 级,请在此填入你自己的用户 id;留空则仅允许 koishi 权限 ≥2 的账号 | 空 |
302
303
  | `noticeEnable` | 启用网易公告变动监听 | `false` |
303
304
  | `noticePlatform` | 公告推送的平台(如 `qq`),留空禁用公告推送 | `qq` |
304
- | `noticeChannelType` | 公告推送目标类型(channel=群/频道,private=私聊) | `channel` |
305
- | `noticeChannelId` | 公告推送目标:群/频道 id 或用户 id | 空 |
305
+ | `noticeTargets` | 公告推送目标列表,以表格形式通过「添加项目」新增(每行一个目标:类型 channel=群/频道 或 private=私聊、目标 id),可添加多个,群/频道与私聊可同时推送 | 空 |
306
306
  | `noticeInterval` | 公告检查间隔(分钟) | `10` |
307
307
 
308
308
  数据源两个镜像:`https://website.homeworkkun.top` 与 `https://web.homeworkkun.top`,可直接在配置中切换。
@@ -320,7 +320,9 @@ WIKI页面 <页面名>
320
320
  - 正式服:`https://h55.update.netease.com/h55_notice_version2.txt`
321
321
  - 共研服:`https://h55.update.netease.com/h55_notice_gongyan_version2.txt`
322
322
 
323
- 每次内容变动(以内容哈希判断)时,通过 `noticePlatform` 平台的机器人推送到 `noticeChannelId`(`noticeChannelType` 为 `channel` 时是群/频道、`private` 时是私聊)。推送内容为「公告标题」+ 正文摘要(去除 HTML 标签与颜色代码,截断 500 字)。首次启动只记录基线,不推送。
323
+ 每次内容变动(以内容哈希判断)时,通过 `noticePlatform` 平台的机器人推送到 `noticeTargets` 列表中的全部目标(每条目标可单独指定 `channel` 群/频道或 `private` 私聊,可同时推送)。推送内容为「公告标题」+ 正文摘要(去除 HTML 标签与颜色代码,截断 500 字)。首次启动只记录基线,不推送。
324
+
325
+ 变更推送优先渲染为图片(仅包含发生变化的部分):当「致诸位尊敬的访客」等引言段落没有变动时,不再将其渲染进图中,避免每次推送都带上固定引言;若引言本身有变动则会正常展示。图片渲染失败时自动回退为纯文本摘要推送。
324
326
 
325
327
  ## 图片内容
326
328