@fengye_nobug/dsh-qq-bot 0.2.7 → 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/CHANGELOG.md +8 -0
- package/DESIGN.md +570 -1
- package/README.md +60 -605
- package/client/client.js +162 -10
- package/cordis.patch.yml +3 -1
- package/index.js +407 -2
- package/onebot.js +1 -1
- package/package.json +1 -1
package/client/client.js
CHANGED
|
@@ -32,7 +32,7 @@ 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',
|
|
@@ -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
|
|
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
|
|
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',
|
|
@@ -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
|
|
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
|