@fengye_nobug/dsh-qq-bot 0.2.6 → 0.2.8

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/client/client.js CHANGED
@@ -32,13 +32,13 @@ window.__ModuleLoader__.load({
32
32
  const NS = 'qq-bridge'
33
33
 
34
34
  // ── 表单描述 ──────────────────────────────────────────────────────────────
35
- // 加一个设置项 = 在这里加一行。kind: text | list | number | boolean | select
35
+ // 加一个设置项 = 在这里加一行。kind: text | list | group-list | number | boolean | select | session
36
36
  const FIELDS = [
37
37
  {
38
38
  key: 'checkUpdateOnStart',
39
39
  label: '启动时检查一次',
40
40
  kind: 'boolean',
41
- hint: '默认关。打开并保存后,下次启动 dsh-web 会先问 npm 上的 @fengye_nobug/dsh-qq-bot 有没有新版本;npm 上还没有更新时再看 GitHub。只写在日志里,不会自动安装。要装新版本,用检查之后出现的更新按钮。',
41
+ hint: '默认关。打开并保存后,下次启动只检查有没有新版本,写在日志里,不会自动安装。',
42
42
  },
43
43
  {
44
44
  key: 'sessionScope',
@@ -84,8 +84,14 @@ window.__ModuleLoader__.load({
84
84
  {
85
85
  key: 'groupWhitelist',
86
86
  label: '哪些群可以叫醒',
87
+ kind: 'group-list',
88
+ hint: '填群号,用逗号分开。留空 = 任何群都不会叫醒机器人。查到群名时,群号旁边会显示名字和头像;没连上或查不到就只显示群号,照样能保存。',
89
+ },
90
+ {
91
+ key: 'ownerWhitelist',
92
+ label: '主人 QQ',
87
93
  kind: 'list',
88
- hint: '填群号,用逗号分开。留空 = 任何群都不会叫醒机器人。',
94
+ hint: '填主人的 QQ 号,用逗号分开。只有这些号能在 QQ 里用「白名单」命令。留空 = 这些命令不会生效。',
89
95
  },
90
96
  {
91
97
  key: 'atOnlyInGroup',
@@ -181,8 +187,58 @@ window.__ModuleLoader__.load({
181
187
  { key: 'qqNumber', label: '快速登录用的 QQ 号', kind: 'text', hint: '需要先在 NapCat 里成功登录过一次。' },
182
188
  ]
183
189
 
190
+ function isIdList(kind) {
191
+ return kind === 'list' || kind === 'group-list'
192
+ }
193
+
194
+ function splitIdText(raw) {
195
+ return String(raw == null ? '' : raw)
196
+ .split(/[,,\s]+/)
197
+ .map((s) => s.trim())
198
+ .filter((s) => s !== '')
199
+ }
200
+
201
+ /** 全角数字换成半角。对得上群号才返回,否则空串。和宿主的 normalizeGroupId 同一条规则。 */
202
+ function canonicalGroupId(raw) {
203
+ const id = String(raw ?? '')
204
+ .trim()
205
+ .replace(/[0-9]/g, (ch) => String.fromCharCode(ch.charCodeAt(0) - 0xfee0))
206
+ return /^\d{1,16}$/.test(id) ? id : ''
207
+ }
208
+
209
+ /** 只接受我们自己拼出来的群头像地址,别的来源一律不当图片。 */
210
+ function safeGroupAvatar(url) {
211
+ const text = String(url ?? '').trim()
212
+ const matched = text.match(/^https:\/\/p\.qlogo\.cn\/gh\/(\d{1,16})\/(\d{1,16})\/100$/)
213
+ if (!matched || matched[1] !== matched[2]) return ''
214
+ return text
215
+ }
216
+
217
+ /**
218
+ * 群白名单每一行怎么显示。
219
+ * 有群名才带头像;没有资料、或头像地址对不上,就只留用户填的群号。
220
+ */
221
+ function groupDisplayRows(raw, metas) {
222
+ const byId = new Map()
223
+ for (const item of Array.isArray(metas) ? metas : []) {
224
+ if (!item || typeof item !== 'object') continue
225
+ const id = String(item.id ?? '').trim()
226
+ if (id === '') continue
227
+ byId.set(id, {
228
+ name: String(item.name ?? '').trim(),
229
+ avatar: safeGroupAvatar(item.avatar),
230
+ })
231
+ }
232
+ return splitIdText(raw).map((id) => {
233
+ const key = canonicalGroupId(id) || id
234
+ const meta = byId.get(key) || byId.get(id)
235
+ const name = meta?.name || ''
236
+ return { id, name, avatar: name !== '' ? meta?.avatar || '' : '' }
237
+ })
238
+ }
239
+
184
240
  function format(field, raw) {
185
- if (field.kind === 'list') return Array.isArray(raw) ? raw.join(', ') : raw == null ? '' : String(raw)
241
+ if (isIdList(field.kind)) return Array.isArray(raw) ? raw.join(', ') : raw == null ? '' : String(raw)
186
242
  if (field.kind === 'boolean') return raw === true
187
243
  if (field.kind === 'number') return raw == null || raw === '' ? '' : String(raw)
188
244
  return raw == null ? '' : String(raw)
@@ -190,12 +246,7 @@ window.__ModuleLoader__.load({
190
246
 
191
247
  /** 表单值 → 要写进设置的 JSON;数字不合法返回 undefined(拦住保存)。 */
192
248
  function parse(field, raw) {
193
- if (field.kind === 'list') {
194
- return String(raw == null ? '' : raw)
195
- .split(/[,,\s]+/)
196
- .map((s) => s.trim())
197
- .filter((s) => s !== '')
198
- }
249
+ if (isIdList(field.kind)) return splitIdText(raw)
199
250
  if (field.kind === 'boolean') return raw === true
200
251
  if (field.kind === 'number') {
201
252
  const text = String(raw ?? '').trim()
@@ -332,6 +383,27 @@ window.__ModuleLoader__.load({
332
383
  fontSize: '11px',
333
384
  },
334
385
  ops: { display: 'flex', alignItems: 'center', gap: '8px', flexWrap: 'wrap' },
386
+ groupList: {
387
+ display: 'flex',
388
+ flexDirection: 'column',
389
+ gap: '6px',
390
+ marginTop: '8px',
391
+ maxHeight: '240px',
392
+ overflowY: 'auto',
393
+ },
394
+ groupRow: { display: 'flex', alignItems: 'center', gap: '8px', minHeight: '28px' },
395
+ groupAvatar: {
396
+ width: '28px',
397
+ height: '28px',
398
+ borderRadius: '6px',
399
+ objectFit: 'cover',
400
+ flex: '0 0 auto',
401
+ background: 'rgba(127,127,127,0.15)',
402
+ },
403
+ groupText: { display: 'flex', flexDirection: 'column', minWidth: 0 },
404
+ groupName: { fontSize: '13px', lineHeight: 1.3 },
405
+ groupId: { fontSize: '11px', opacity: 0.55, lineHeight: 1.3 },
406
+ groupIdOnly: { fontSize: '13px', lineHeight: 1.3 },
335
407
  }
336
408
 
337
409
  function disabled(btn, off) {
@@ -362,6 +434,8 @@ window.__ModuleLoader__.load({
362
434
  const [updateOffer, setUpdateOffer] = react.useState(null)
363
435
  const [updateNote, setUpdateNote] = react.useState('')
364
436
  const [updateIsError, setUpdateIsError] = react.useState(false)
437
+ // 群白名单旁边的群名和头像。拿不到就空着,输入框照常能改、能保存。
438
+ const [groupMetas, setGroupMetas] = react.useState([])
365
439
 
366
440
  const current = react.useMemo(() => draftFrom(snap.value), [snap.value])
367
441
  const shown = draft ?? current
@@ -402,6 +476,38 @@ window.__ModuleLoader__.load({
402
476
  }, [wsStore])
403
477
  const workspaceSnapshot = react.useSyncExternalStore(wsSubscribe, wsGetSnapshot)
404
478
 
479
+ // 群白名单的群名和头像。失败就清空,输入框不禁用,保存也不等这次请求。
480
+ const groupListText = String(shown.groupWhitelist ?? '')
481
+ react.useEffect(() => {
482
+ if (!open) return undefined
483
+ const ids = splitIdText(groupListText)
484
+ if (ids.length === 0) {
485
+ setGroupMetas([])
486
+ return undefined
487
+ }
488
+ let alive = true
489
+ const timer = setTimeout(() => {
490
+ if (typeof fetch !== 'function') return
491
+ fetch(`${GROUP_META_API}?ids=${encodeURIComponent(ids.join(','))}`, {
492
+ credentials: 'include',
493
+ headers: { accept: 'application/json' },
494
+ })
495
+ .then((res) => res.json().catch(() => null))
496
+ .then((body) => {
497
+ if (!alive) return
498
+ if (body && body.ok === true && Array.isArray(body.groups)) setGroupMetas(body.groups)
499
+ else setGroupMetas([])
500
+ })
501
+ .catch(() => {
502
+ if (alive) setGroupMetas([])
503
+ })
504
+ }, 300)
505
+ return () => {
506
+ alive = false
507
+ clearTimeout(timer)
508
+ }
509
+ }, [open, groupListText])
510
+
405
511
  /**
406
512
  * 拉一次 NapCat 状态(WebUI 地址 + 是否在运行)。
407
513
  * 没有 fetch 或响应不是 ok:true 时返回 undefined,调用方保持上一次的状态。
@@ -709,6 +815,50 @@ window.__ModuleLoader__.load({
709
815
  h('option', { key: o.value || '(none)', value: o.value }, o.label),
710
816
  ),
711
817
  )
818
+ } else if (f.kind === 'group-list') {
819
+ const groupRows = groupDisplayRows(value, groupMetas)
820
+ const groupInput = h('input', {
821
+ type: 'text',
822
+ style: S.input,
823
+ value: String(value),
824
+ disabled: !snap.writable || busy,
825
+ onChange: change(f.key),
826
+ spellCheck: false,
827
+ })
828
+ const groupList =
829
+ groupRows.length === 0
830
+ ? null
831
+ : h(
832
+ 'div',
833
+ { style: S.groupList },
834
+ groupRows.map((row, index) =>
835
+ h(
836
+ 'div',
837
+ { key: `${row.id}-${index}`, style: S.groupRow },
838
+ row.avatar
839
+ ? h('img', {
840
+ src: row.avatar,
841
+ alt: '',
842
+ width: 28,
843
+ height: 28,
844
+ referrerPolicy: 'no-referrer',
845
+ style: S.groupAvatar,
846
+ onError: (event) => {
847
+ const el = event && event.currentTarget
848
+ if (el && el.style) el.style.display = 'none'
849
+ },
850
+ })
851
+ : null,
852
+ h(
853
+ 'div',
854
+ { style: S.groupText },
855
+ row.name ? h('div', { style: S.groupName }, row.name) : null,
856
+ h('div', { style: row.name ? S.groupId : S.groupIdOnly }, row.id),
857
+ ),
858
+ ),
859
+ ),
860
+ )
861
+ control = h('div', null, groupInput, groupList)
712
862
  } else {
713
863
  control = h('input', {
714
864
  type: f.kind === 'number' ? 'number' : 'text',
@@ -860,9 +1010,9 @@ window.__ModuleLoader__.load({
860
1010
  )
861
1011
  : null,
862
1012
  ),
863
- h('div', { style: S.hint }, '先问 npm 上有没有新版本。这里只检查,不会安装。'),
1013
+ h('div', { style: S.hint }, '只检查,不安装。'),
864
1014
  updateOffer
865
- ? h('div', { style: S.hint }, '优先从 npm 安装 @fengye_nobug/dsh-qq-bot 的这一版并重启面板。这一版还没发到 npm 时,会改从 GitHub 下载。设置还在,不改 NapCat。重启后请刷新,登录口令可能会变。安装如果超过三分钟没有动静,会停掉卡住的进程,并用白话说明。')
1015
+ ? h('div', { style: S.hint }, '装完会重启面板。请刷新;登录口令可能会变。不改 NapCat。')
866
1016
  : null,
867
1017
  updateNote
868
1018
  ? h('div', { style: { ...(updateIsError ? S.err : S.note), whiteSpace: 'pre-wrap' } }, updateNote)
@@ -934,6 +1084,8 @@ window.__ModuleLoader__.load({
934
1084
  const NAPCAT_API = '/api/qq-bridge/napcat'
935
1085
  /** 检查更新。GET 只读当前版本;POST 不带 action 只检查。安装要另发 action 为 apply。浏览器不直接访问 npm 或 GitHub。 */
936
1086
  const UPDATE_API = '/api/qq-bridge/update'
1087
+ /** 群白名单的群名和头像。只读;保存仍走设置接口。 */
1088
+ const GROUP_META_API = '/api/qq-bridge/groups'
937
1089
  /** 稳定引用:useSyncExternalStore 的 getSnapshot 每次返回新对象会死循环。 */
938
1090
  const LOADING_SNAPSHOT = { status: 'loading' }
939
1091
  const UNAVAILABLE_SNAPSHOT = { status: 'unavailable' }
@@ -1263,7 +1415,7 @@ window.__ModuleLoader__.load({
1263
1415
  exports.name = '@fengye_nobug/dsh-qq-bot'
1264
1416
  // 纯函数给离线测试用(test-smoke.mjs 会在假 ctx 里真跑这个 bundle)。
1265
1417
  // Cordis 只认 apply / inject / name / Config,多余的导出它不管。
1266
- exports.__test = { sessionOptions, draftFrom, parse, fields: FIELDS, bindSettings }
1418
+ exports.__test = { sessionOptions, draftFrom, parse, fields: FIELDS, bindSettings, groupDisplayRows }
1267
1419
  return module.exports
1268
1420
  },
1269
1421
  })
package/cordis.patch.yml CHANGED
@@ -25,7 +25,7 @@
25
25
  onebotUrl: ''
26
26
  # 存放 access_token 的**凭据引用名**(就是一个环境变量名),不要明文写 token。
27
27
  # 它会经 ctx.credentials 解析(进程环境 → 凭据存储 → .env),每次建连重新解析。
28
- # 设置方式见 README“配置 access_token”。
28
+ # 设置方式见 README「配置」。细节在 DESIGN.md「配置 access_token」。
29
29
  accessTokenEnv: ''
30
30
  # 机器人自己的 QQ 号;留空则连上后自动调 get_login_info 获取。
31
31
  selfId: ''
@@ -67,5 +67,7 @@
67
67
  # 正式使用时填入实际的 QQ openid。
68
68
  whitelist: []
69
69
  groupWhitelist: []
70
+ # 哪些 QQ 号能在 QQ 里用「白名单」命令改群白名单。留空 = 谁都不能用。
71
+ ownerWhitelist: []
70
72
  # 启动时向 GitHub 检查一次有没有新版本。默认关。只写日志,不自动安装。
71
73
  checkUpdateOnStart: false