@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/index.js
CHANGED
|
@@ -4,6 +4,7 @@
|
|
|
4
4
|
// 设计见同目录 DESIGN.md。核心两点:
|
|
5
5
|
// 1) 唤醒策略:白名单(fail-closed) → 命中昵称/@ 必唤醒 → 否则按概率(默认 5%)唤醒
|
|
6
6
|
// → 其余只进“最近聊天缓冲”,在下一次唤醒时作为上下文附上(期间零模型调用)。
|
|
7
|
+
// 主人用整句「白名单」命令改 groupWhitelist 时,发生在白名单闸门之前。
|
|
7
8
|
// 2) 目标会话显式加载:agents.get(id) 拿不到就 agents.resume(resumeSessionId) 拉起来,
|
|
8
9
|
// 并且【绝不】回退到别的会话(dsh-cron 的已知坑:roots() 找不到就投给活动会话)。
|
|
9
10
|
//
|
|
@@ -169,6 +170,7 @@ export const Config = Schema.object({
|
|
|
169
170
|
wakeProbability: Schema.number().default(0.05).description('没点名时的回应概率(0 到 1)。'),
|
|
170
171
|
whitelist: Schema.array(Schema.string()).default([]).description('哪些私聊可以叫醒。留空 = 任何私聊都不叫醒。'),
|
|
171
172
|
groupWhitelist: Schema.array(Schema.string()).default([]).description('哪些群可以叫醒。留空 = 任何群都不叫醒。'),
|
|
173
|
+
ownerWhitelist: Schema.array(Schema.string()).default([]).description('主人 QQ。这些号才能在 QQ 里用「白名单」命令。留空 = 谁都不能用。'),
|
|
172
174
|
checkUpdateOnStart: Schema.boolean().default(false).description('启动时向 GitHub 检查一次有没有新版本。默认关。只在日志里提示,不会自动安装。'),
|
|
173
175
|
})
|
|
174
176
|
|
|
@@ -183,6 +185,7 @@ const BridgeSettings = Schema.object({
|
|
|
183
185
|
wakeProbability: Schema.number().default(0.05).description('没点名时的回应概率(0 到 1)。默认 0.05。'),
|
|
184
186
|
whitelist: Schema.array(Schema.string()).default([]).description('哪些私聊可以叫醒。留空 = 任何私聊都不叫醒。'),
|
|
185
187
|
groupWhitelist: Schema.array(Schema.string()).default([]).description('哪些群可以叫醒。留空 = 任何群都不叫醒。'),
|
|
188
|
+
ownerWhitelist: Schema.array(Schema.string()).default([]).description('主人 QQ。这些号才能在 QQ 里用「白名单」命令。留空 = 谁都不能用。'),
|
|
186
189
|
checkUpdateOnStart: Schema.boolean().default(false).description('启动时向 GitHub 检查一次有没有新版本。默认关。只在日志里提示,不会自动安装。'),
|
|
187
190
|
attachRecentChat: Schema.boolean().default(true).description('把没叫醒时的聊天一并带上。'),
|
|
188
191
|
recentChatLimit: Schema.number().default(DEFAULT_RECENT_CHAT_LIMIT).description('附带最近几条(0–200)。'),
|
|
@@ -479,6 +482,211 @@ export function mapOneBotMessage(event, selfId) {
|
|
|
479
482
|
}
|
|
480
483
|
}
|
|
481
484
|
|
|
485
|
+
/** 去掉空项和重复项,保留原来的顺序。 */
|
|
486
|
+
export function normalizeIdList(list) {
|
|
487
|
+
const out = []
|
|
488
|
+
const seen = new Set()
|
|
489
|
+
for (const item of Array.isArray(list) ? list : []) {
|
|
490
|
+
const id = String(item ?? '').trim()
|
|
491
|
+
if (id === '' || seen.has(id)) continue
|
|
492
|
+
seen.add(id)
|
|
493
|
+
out.push(id)
|
|
494
|
+
}
|
|
495
|
+
return out
|
|
496
|
+
}
|
|
497
|
+
|
|
498
|
+
/** QQ 群号:只要数字。全角数字先换成半角。对不上就返回空串。 */
|
|
499
|
+
export function normalizeGroupId(value) {
|
|
500
|
+
const id = String(value ?? '')
|
|
501
|
+
.trim()
|
|
502
|
+
.replace(/[0-9]/g, (ch) => String.fromCharCode(ch.charCodeAt(0) - 0xfee0))
|
|
503
|
+
return /^\d{1,16}$/.test(id) ? id : ''
|
|
504
|
+
}
|
|
505
|
+
|
|
506
|
+
/** 设置卡片里的群头像边长。NapCat 文档里的尺寸是 100、640 或 0。 */
|
|
507
|
+
const GROUP_AVATAR_SIZE = '100'
|
|
508
|
+
/** 成功拿到的群列表可以复用这么久,避免卡片一打开就反复打 OneBot。 */
|
|
509
|
+
const GROUP_META_TTL_MS = 5 * 60 * 1000
|
|
510
|
+
/** 列表没拿到时,多久之内不再打同一次失败的查询。 */
|
|
511
|
+
const GROUP_META_RETRY_MS = 15 * 1000
|
|
512
|
+
/** 一次最多解析多少个群号。再多的只显示群号,避免超长白名单把 OneBot 打满。 */
|
|
513
|
+
const GROUP_META_MAX_IDS = 100
|
|
514
|
+
/** 不在群列表里的号,最多再单独问几次 get_group_info。 */
|
|
515
|
+
const GROUP_INFO_FALLBACK_MAX = 20
|
|
516
|
+
/** 单次查询的上限。到点就放弃,卡片改只显示群号。 */
|
|
517
|
+
const GROUP_META_CALL_MS = 4000
|
|
518
|
+
|
|
519
|
+
/**
|
|
520
|
+
* 群头像地址。
|
|
521
|
+
* get_group_info / get_group_list 只有群名,没有头像字段。
|
|
522
|
+
* NapCat 维护者说明直接用公开群头像地址,不另做接口。
|
|
523
|
+
* 不是群号就返回空串。查没查到名字由 resolveGroupMetas 决定要不要带上这个地址。
|
|
524
|
+
*/
|
|
525
|
+
export function groupAvatarUrl(groupId) {
|
|
526
|
+
const id = normalizeGroupId(groupId)
|
|
527
|
+
if (id === '') return ''
|
|
528
|
+
return `https://p.qlogo.cn/gh/${id}/${id}/${GROUP_AVATAR_SIZE}`
|
|
529
|
+
}
|
|
530
|
+
|
|
531
|
+
function groupMetaFromRow(row) {
|
|
532
|
+
if (!row || typeof row !== 'object') return null
|
|
533
|
+
const id = normalizeGroupId(row.group_id)
|
|
534
|
+
const name = String(row.group_name ?? '').trim() || String(row.group_remark ?? '').trim()
|
|
535
|
+
if (id === '' || name === '') return null
|
|
536
|
+
return { id, name }
|
|
537
|
+
}
|
|
538
|
+
|
|
539
|
+
function withTimeout(promise, ms) {
|
|
540
|
+
return new Promise((resolve, reject) => {
|
|
541
|
+
const timer = setTimeout(() => reject(new Error('timeout')), ms)
|
|
542
|
+
Promise.resolve(promise).then(
|
|
543
|
+
(value) => {
|
|
544
|
+
clearTimeout(timer)
|
|
545
|
+
resolve(value)
|
|
546
|
+
},
|
|
547
|
+
(error) => {
|
|
548
|
+
clearTimeout(timer)
|
|
549
|
+
reject(error)
|
|
550
|
+
},
|
|
551
|
+
)
|
|
552
|
+
})
|
|
553
|
+
}
|
|
554
|
+
|
|
555
|
+
/** 从数组或逗号分隔的文本里抽出群号。非法项丢掉,重复的只留第一次,最多 100 个。 */
|
|
556
|
+
function wantedGroupIds(ids) {
|
|
557
|
+
const out = []
|
|
558
|
+
const seen = new Set()
|
|
559
|
+
const list = Array.isArray(ids) ? ids : String(ids ?? '').split(/[,,\s]+/)
|
|
560
|
+
for (const raw of list) {
|
|
561
|
+
const id = normalizeGroupId(raw)
|
|
562
|
+
if (id === '' || seen.has(id)) continue
|
|
563
|
+
seen.add(id)
|
|
564
|
+
out.push(id)
|
|
565
|
+
if (out.length >= GROUP_META_MAX_IDS) break
|
|
566
|
+
}
|
|
567
|
+
return out
|
|
568
|
+
}
|
|
569
|
+
|
|
570
|
+
function blankGroupMeta(id) {
|
|
571
|
+
return { id, name: '', avatar: '' }
|
|
572
|
+
}
|
|
573
|
+
|
|
574
|
+
function ensureGroupMetaCache(cache) {
|
|
575
|
+
const box = cache && typeof cache === 'object' ? cache : {}
|
|
576
|
+
if (!(box.byId instanceof Map)) box.byId = new Map()
|
|
577
|
+
if (!(box.info instanceof Map)) box.info = new Map()
|
|
578
|
+
if (typeof box.at !== 'number') box.at = 0
|
|
579
|
+
if (typeof box.triedAt !== 'number') box.triedAt = 0
|
|
580
|
+
if (box.ok !== true) box.ok = false
|
|
581
|
+
return box
|
|
582
|
+
}
|
|
583
|
+
|
|
584
|
+
function groupMetaClock(source) {
|
|
585
|
+
if (typeof source?.now !== 'function') return Date.now()
|
|
586
|
+
const value = Number(source.now())
|
|
587
|
+
return Number.isFinite(value) ? value : Date.now()
|
|
588
|
+
}
|
|
589
|
+
|
|
590
|
+
/**
|
|
591
|
+
* 给一组群号补上当前群名和头像。
|
|
592
|
+
*
|
|
593
|
+
* 桥没连上、查询失败、或这个号不在结果里时,那一项只有 id,name 和 avatar 都是空串。
|
|
594
|
+
* 不抛错,也不写设置。成功的群列表缓存 5 分钟;失败 15 秒内不重复打同一下。
|
|
595
|
+
* `source.cache` 会被就地更新。
|
|
596
|
+
*/
|
|
597
|
+
export async function resolveGroupMetas(ids, source = {}) {
|
|
598
|
+
const wanted = wantedGroupIds(ids)
|
|
599
|
+
if (wanted.length === 0) return []
|
|
600
|
+
const open = typeof source.isOpen === 'function' && source.isOpen() === true
|
|
601
|
+
const call = typeof source.call === 'function' ? source.call : null
|
|
602
|
+
if (!open || !call) return wanted.map(blankGroupMeta)
|
|
603
|
+
|
|
604
|
+
const cache = ensureGroupMetaCache(source.cache)
|
|
605
|
+
const clock = groupMetaClock(source)
|
|
606
|
+
const listFresh = cache.ok === true && clock - cache.at < GROUP_META_TTL_MS
|
|
607
|
+
if (!listFresh && clock - cache.triedAt >= GROUP_META_RETRY_MS) {
|
|
608
|
+
cache.triedAt = clock
|
|
609
|
+
try {
|
|
610
|
+
const list = await withTimeout(call('get_group_list', { no_cache: false }), GROUP_META_CALL_MS)
|
|
611
|
+
const next = new Map()
|
|
612
|
+
for (const row of Array.isArray(list) ? list : []) {
|
|
613
|
+
const meta = groupMetaFromRow(row)
|
|
614
|
+
if (meta) next.set(meta.id, meta)
|
|
615
|
+
}
|
|
616
|
+
cache.byId = next
|
|
617
|
+
cache.at = clock
|
|
618
|
+
cache.ok = true
|
|
619
|
+
} catch {
|
|
620
|
+
cache.ok = false
|
|
621
|
+
}
|
|
622
|
+
}
|
|
623
|
+
|
|
624
|
+
const known = cache.ok === true && clock - cache.at < GROUP_META_TTL_MS ? cache.byId : new Map()
|
|
625
|
+
const missing = wanted.filter((id) => !known.has(id)).slice(0, GROUP_INFO_FALLBACK_MAX)
|
|
626
|
+
if (cache.info.size > 200) cache.info.clear()
|
|
627
|
+
await Promise.all(
|
|
628
|
+
missing.map(async (id) => {
|
|
629
|
+
const prev = cache.info.get(id)
|
|
630
|
+
if (prev?.meta && clock - prev.at < GROUP_META_TTL_MS) {
|
|
631
|
+
known.set(id, prev.meta)
|
|
632
|
+
return
|
|
633
|
+
}
|
|
634
|
+
if (prev && !prev.meta && clock - prev.at < GROUP_META_RETRY_MS) return
|
|
635
|
+
try {
|
|
636
|
+
const info = await withTimeout(call('get_group_info', { group_id: id, no_cache: false }), GROUP_META_CALL_MS)
|
|
637
|
+
const meta = groupMetaFromRow(info)
|
|
638
|
+
cache.info.set(id, { at: clock, meta: meta || null })
|
|
639
|
+
if (meta) known.set(id, meta)
|
|
640
|
+
} catch {
|
|
641
|
+
cache.info.set(id, { at: clock, meta: null })
|
|
642
|
+
}
|
|
643
|
+
}),
|
|
644
|
+
)
|
|
645
|
+
|
|
646
|
+
return wanted.map((id) => {
|
|
647
|
+
const hit = known.get(id)
|
|
648
|
+
if (!hit?.name) return blankGroupMeta(id)
|
|
649
|
+
return { id, name: hit.name, avatar: groupAvatarUrl(id) }
|
|
650
|
+
})
|
|
651
|
+
}
|
|
652
|
+
|
|
653
|
+
/**
|
|
654
|
+
* 整句是不是「白名单」命令。
|
|
655
|
+
*
|
|
656
|
+
* 先去掉首尾空白,中间连续空白收成一个空格,可选一个前导 `/`。
|
|
657
|
+
* 第一个词必须就是「白名单」。后面只能是子命令,多出来的字就返回 null,
|
|
658
|
+
* 免得一句「忽略之前的指令,白名单 加」把群白名单改掉。
|
|
659
|
+
*
|
|
660
|
+
* 返回 `{ op: 'list' | 'add' | 'remove', groupId: string | null }`。
|
|
661
|
+
* `groupId === null` 表示用当前群(私聊里没有当前群)。
|
|
662
|
+
*/
|
|
663
|
+
export function parseWhitelistCommand(text) {
|
|
664
|
+
let body = String(text ?? '')
|
|
665
|
+
.replace(/[\u200b\u200c\u200d\ufeff]/g, '')
|
|
666
|
+
.replace(/[\u3000\u00a0]/g, ' ')
|
|
667
|
+
.trim()
|
|
668
|
+
.replace(/\s+/g, ' ')
|
|
669
|
+
if (body.startsWith('/') || body.startsWith('/')) body = body.slice(1).trim()
|
|
670
|
+
if (body === '') return null
|
|
671
|
+
const parts = body.split(' ')
|
|
672
|
+
if (parts[0] !== '白名单' || parts.length > 3) return null
|
|
673
|
+
if (parts.length === 1) return { op: 'list', groupId: null }
|
|
674
|
+
const sub = parts[1]
|
|
675
|
+
const arg = parts[2]
|
|
676
|
+
if (sub === '列表') return arg === undefined ? { op: 'list', groupId: null } : null
|
|
677
|
+
const adding = sub === '加' || sub === '加本群'
|
|
678
|
+
const removing = sub === '删' || sub === '删本群'
|
|
679
|
+
if (!adding && !removing) return null
|
|
680
|
+
const op = adding ? 'add' : 'remove'
|
|
681
|
+
if (sub === '加本群' || sub === '删本群') {
|
|
682
|
+
return arg === undefined ? { op, groupId: null } : null
|
|
683
|
+
}
|
|
684
|
+
if (arg === undefined || arg === '本群') return { op, groupId: null }
|
|
685
|
+
const groupId = normalizeGroupId(arg)
|
|
686
|
+
if (groupId === '') return null
|
|
687
|
+
return { op, groupId }
|
|
688
|
+
}
|
|
689
|
+
|
|
482
690
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
483
691
|
|
|
484
692
|
export function apply(ctx, config) {
|
|
@@ -552,6 +760,7 @@ export function apply(ctx, config) {
|
|
|
552
760
|
wakeProbability: config.wakeProbability ?? 0.05,
|
|
553
761
|
whitelist: config.whitelist ?? [],
|
|
554
762
|
groupWhitelist: config.groupWhitelist ?? [],
|
|
763
|
+
ownerWhitelist: config.ownerWhitelist ?? [],
|
|
555
764
|
checkUpdateOnStart: config.checkUpdateOnStart === true,
|
|
556
765
|
onebotUrl: config.onebotUrl ?? '',
|
|
557
766
|
accessTokenEnv: config.accessTokenEnv ?? '',
|
|
@@ -598,6 +807,7 @@ export function apply(ctx, config) {
|
|
|
598
807
|
wakeProbability: clamp01(Number(s.wakeProbability ?? config.wakeProbability ?? 0.05)),
|
|
599
808
|
whitelist: (s.whitelist ?? config.whitelist ?? []).map(String),
|
|
600
809
|
groupWhitelist: (s.groupWhitelist ?? config.groupWhitelist ?? []).map(String),
|
|
810
|
+
ownerWhitelist: (s.ownerWhitelist ?? config.ownerWhitelist ?? []).map(String),
|
|
601
811
|
checkUpdateOnStart: (s.checkUpdateOnStart ?? config.checkUpdateOnStart) === true,
|
|
602
812
|
attachRecentChat: (s.attachRecentChat ?? config.attachRecentChat) !== false,
|
|
603
813
|
recentChatLimit: Math.max(
|
|
@@ -660,6 +870,60 @@ export function apply(ctx, config) {
|
|
|
660
870
|
settingsFileCache = { mtimeMs: -1, values: {} } // 让下次读取重新加载
|
|
661
871
|
}
|
|
662
872
|
|
|
873
|
+
/**
|
|
874
|
+
* 把若干设置写成和设置卡片同一条存储,并让下一次 readSettings() 读到新值。
|
|
875
|
+
*
|
|
876
|
+
* 0.2:写入 qq-bridge-settings.json(比 cordis 里的安装配置优先,重启还在)。
|
|
877
|
+
* 更早的宿主:改 settings.get 返回的那份用户层,并在服务提供 update / set 时写回去。
|
|
878
|
+
* 不改 apply() 拿到的 composition config,这样「撤销覆盖」仍回到原来的安装配置。
|
|
879
|
+
*/
|
|
880
|
+
async function saveSettingsPatch(patch) {
|
|
881
|
+
if (legacySettings) {
|
|
882
|
+
const svc = ctx.settings
|
|
883
|
+
let stored
|
|
884
|
+
try {
|
|
885
|
+
stored = typeof svc?.get === 'function' ? svc.get(NS) : undefined
|
|
886
|
+
} catch {
|
|
887
|
+
stored = undefined
|
|
888
|
+
}
|
|
889
|
+
const backup = {}
|
|
890
|
+
if (stored && typeof stored === 'object') {
|
|
891
|
+
for (const key of Object.keys(patch)) backup[key] = stored[key]
|
|
892
|
+
Object.assign(stored, patch)
|
|
893
|
+
}
|
|
894
|
+
try {
|
|
895
|
+
if (typeof svc?.update === 'function') await svc.update(NS, patch)
|
|
896
|
+
else if (typeof svc?.set === 'function') {
|
|
897
|
+
for (const key of Object.keys(patch)) await svc.set(NS, key, patch[key])
|
|
898
|
+
}
|
|
899
|
+
} catch (error) {
|
|
900
|
+
if (stored && typeof stored === 'object') {
|
|
901
|
+
for (const key of Object.keys(patch)) {
|
|
902
|
+
if (Object.prototype.hasOwnProperty.call(backup, key)) stored[key] = backup[key]
|
|
903
|
+
else delete stored[key]
|
|
904
|
+
}
|
|
905
|
+
}
|
|
906
|
+
throw error
|
|
907
|
+
}
|
|
908
|
+
applySettingChanges()
|
|
909
|
+
return
|
|
910
|
+
}
|
|
911
|
+
|
|
912
|
+
const known = knownSettingKeys()
|
|
913
|
+
const next = { ...readSettingsFile() }
|
|
914
|
+
const validate = typeof BridgeSettings === 'function' ? BridgeSettings : (v) => v
|
|
915
|
+
const merged = validate({ ...readSettings(), ...patch })
|
|
916
|
+
let wrote = false
|
|
917
|
+
for (const key of Object.keys(patch)) {
|
|
918
|
+
if (!known.has(key)) continue
|
|
919
|
+
next[key] = merged[key]
|
|
920
|
+
wrote = true
|
|
921
|
+
}
|
|
922
|
+
if (!wrote) throw new Error('没有可写的设置')
|
|
923
|
+
writeSettingsFile(next)
|
|
924
|
+
applySettingChanges()
|
|
925
|
+
}
|
|
926
|
+
|
|
663
927
|
/** 只认 schema 里有的键 —— 接口不写白名单之外的任何东西。 */
|
|
664
928
|
function knownSettingKeys() {
|
|
665
929
|
return new Set(Object.keys(BridgeSettings?.dict ?? {}))
|
|
@@ -2142,12 +2406,112 @@ export function apply(ctx, config) {
|
|
|
2142
2406
|
return agent?.session?.id || agent?.id || null
|
|
2143
2407
|
}
|
|
2144
2408
|
|
|
2409
|
+
function formatGroupWhitelist(ids) {
|
|
2410
|
+
if (ids.length === 0) return '群白名单是空的,共 0 个。留空时任何群都不会叫醒。'
|
|
2411
|
+
const shown = ids.slice(0, 30)
|
|
2412
|
+
const extra = ids.length > shown.length ? ` ……等共 ${ids.length} 个` : ''
|
|
2413
|
+
return `群白名单共 ${ids.length} 个:${shown.join(' ')}${extra}`
|
|
2414
|
+
}
|
|
2415
|
+
|
|
2416
|
+
/** 管理命令的回复直接发回这条消息所在的群或私聊,不经过唤醒,也不查群白名单。 */
|
|
2417
|
+
async function replyAdmin(msg, text) {
|
|
2418
|
+
const dest = destFromMsg(msg)
|
|
2419
|
+
if (!dest) return { sent: 0, dropped: true, reason: 'no-destination' }
|
|
2420
|
+
return sendToQQ(String(text), dest, { quoteMessageId: messageIdOf(msg) })
|
|
2421
|
+
}
|
|
2422
|
+
|
|
2423
|
+
/**
|
|
2424
|
+
* 主人改群白名单。调用前已经确认发送者在 ownerWhitelist 里,且整句是命令。
|
|
2425
|
+
* 失败时配置保持原样。回复里可以有群号;日志不展开整份名单。
|
|
2426
|
+
*/
|
|
2427
|
+
async function handleWhitelistAdmin(msg, command) {
|
|
2428
|
+
const current = normalizeGroupId(msg?.groupId)
|
|
2429
|
+
let target = command.groupId
|
|
2430
|
+
if (command.op !== 'list' && target == null) {
|
|
2431
|
+
if (current === '') {
|
|
2432
|
+
const text = msg?.groupId
|
|
2433
|
+
? '没认出群号,没有改白名单。'
|
|
2434
|
+
: '私聊里要写上群号。例如:白名单 加 群号'
|
|
2435
|
+
const sent = await replyAdmin(msg, text)
|
|
2436
|
+
pushLog({
|
|
2437
|
+
kind: 'admin',
|
|
2438
|
+
action: command.op,
|
|
2439
|
+
result: 'need-group',
|
|
2440
|
+
who: msg?.userId ?? null,
|
|
2441
|
+
group: msg?.groupId ?? null,
|
|
2442
|
+
})
|
|
2443
|
+
return { action: 'admin', reason: 'need-group', delivered: false, changed: false, replied: sent.sent > 0 }
|
|
2444
|
+
}
|
|
2445
|
+
target = current
|
|
2446
|
+
}
|
|
2447
|
+
|
|
2448
|
+
const before = normalizeIdList(readSettings().groupWhitelist)
|
|
2449
|
+
let next = before
|
|
2450
|
+
let changed = false
|
|
2451
|
+
let reply
|
|
2452
|
+
if (command.op === 'list') {
|
|
2453
|
+
reply = formatGroupWhitelist(before)
|
|
2454
|
+
} else if (command.op === 'add') {
|
|
2455
|
+
if (before.includes(target)) {
|
|
2456
|
+
reply = target === current ? `本群已经在白名单里。共 ${before.length} 个。` : `${target} 已经在白名单里。共 ${before.length} 个。`
|
|
2457
|
+
} else {
|
|
2458
|
+
next = [...before, target]
|
|
2459
|
+
changed = true
|
|
2460
|
+
reply = target === current ? `已把本群加入白名单。现在共 ${next.length} 个。` : `已加入 ${target}。现在共 ${next.length} 个。`
|
|
2461
|
+
}
|
|
2462
|
+
} else if (before.includes(target)) {
|
|
2463
|
+
next = before.filter((id) => id !== target)
|
|
2464
|
+
changed = true
|
|
2465
|
+
const tail = next.length === 0 ? '现在任何群都不会叫醒。' : ''
|
|
2466
|
+
reply = `已移出 ${target}。现在共 ${next.length} 个。${tail}`
|
|
2467
|
+
} else {
|
|
2468
|
+
reply = target === current ? `本群本来就不在白名单里。共 ${before.length} 个。` : `${target} 本来就不在白名单里。共 ${before.length} 个。`
|
|
2469
|
+
}
|
|
2470
|
+
|
|
2471
|
+
if (changed) {
|
|
2472
|
+
try {
|
|
2473
|
+
await saveSettingsPatch({ groupWhitelist: next })
|
|
2474
|
+
} catch (error) {
|
|
2475
|
+
logger.warn(`群白名单没有保存:${error?.message ?? error}`)
|
|
2476
|
+
const sent = await replyAdmin(msg, '没保存成,群白名单没改。')
|
|
2477
|
+
pushLog({
|
|
2478
|
+
kind: 'admin',
|
|
2479
|
+
action: command.op,
|
|
2480
|
+
result: 'save-failed',
|
|
2481
|
+
who: msg?.userId ?? null,
|
|
2482
|
+
group: msg?.groupId ?? null,
|
|
2483
|
+
})
|
|
2484
|
+
return { action: 'admin', reason: 'save-failed', delivered: false, changed: false, replied: sent.sent > 0 }
|
|
2485
|
+
}
|
|
2486
|
+
}
|
|
2487
|
+
|
|
2488
|
+
const sent = await replyAdmin(msg, reply)
|
|
2489
|
+
logger.info(`群白名单命令 ${command.op}${changed ? ' 已保存' : ''}`)
|
|
2490
|
+
pushLog({
|
|
2491
|
+
kind: 'admin',
|
|
2492
|
+
action: command.op,
|
|
2493
|
+
result: changed ? 'saved' : 'unchanged',
|
|
2494
|
+
who: msg?.userId ?? null,
|
|
2495
|
+
group: msg?.groupId ?? null,
|
|
2496
|
+
})
|
|
2497
|
+
return { action: 'admin', reason: command.op, delivered: false, changed, replied: sent.sent > 0 }
|
|
2498
|
+
}
|
|
2499
|
+
|
|
2145
2500
|
/**
|
|
2146
2501
|
* 入站总入口(OneBot 传输与调试工具都调它)。
|
|
2147
2502
|
* msg: { text, userId, nickname?, userName?, groupId?, groupName?, atSelf?, messageId? }
|
|
2148
2503
|
*/
|
|
2149
2504
|
async function handleInbound(msg) {
|
|
2150
2505
|
const s = readSettings()
|
|
2506
|
+
// 主人的整句命令发生在白名单闸门之前,这样新群还没被允许时也能加进去。
|
|
2507
|
+
// 不是主人、或不是整句命令:照旧走 decide(),不另外回复。
|
|
2508
|
+
// 只有传输层打上 fromTransport 的消息才算。调试工具模拟的入站改不了白名单。
|
|
2509
|
+
if (msg?.fromTransport === true) {
|
|
2510
|
+
const command = parseWhitelistCommand(msg.text)
|
|
2511
|
+
if (command && normalizeIdList(s.ownerWhitelist).includes(String(msg.userId ?? '').trim())) {
|
|
2512
|
+
return handleWhitelistAdmin(msg, command)
|
|
2513
|
+
}
|
|
2514
|
+
}
|
|
2151
2515
|
const d = decide(msg, s)
|
|
2152
2516
|
// 保留引用:唤醒失败时把这一条就地改成 `wake-failed`。修之前失败也写着 `wake`,事后查不出问题。
|
|
2153
2517
|
const inboundLog = pushLog({ kind: 'inbound', decision: d.action, reason: d.reason, who: msg.userId, group: msg.groupId ?? null, text: String(msg.text ?? '').slice(0, 200) })
|
|
@@ -2394,6 +2758,8 @@ export function apply(ctx, config) {
|
|
|
2394
2758
|
}
|
|
2395
2759
|
|
|
2396
2760
|
const groupNames = new Map() // groupId -> 群名(懒加载缓存)
|
|
2761
|
+
/** 设置卡片用的群列表缓存。和 groupNames 分开:这边有过期时间,过期或没连上就只显示群号。 */
|
|
2762
|
+
const groupMetaCache = { at: 0, triedAt: 0, ok: false, byId: new Map(), info: new Map() }
|
|
2397
2763
|
|
|
2398
2764
|
const transport = createOneBotTransport({
|
|
2399
2765
|
getUrl: () => readSettings().onebotUrl,
|
|
@@ -2445,6 +2811,7 @@ export function apply(ctx, config) {
|
|
|
2445
2811
|
: await loadGroupName(inbound.groupId)
|
|
2446
2812
|
}
|
|
2447
2813
|
|
|
2814
|
+
inbound.fromTransport = true
|
|
2448
2815
|
await handleInbound(inbound)
|
|
2449
2816
|
}
|
|
2450
2817
|
|
|
@@ -2476,6 +2843,7 @@ export function apply(ctx, config) {
|
|
|
2476
2843
|
? groupNames.get(inbound.groupId)
|
|
2477
2844
|
: await loadGroupName(inbound.groupId)
|
|
2478
2845
|
}
|
|
2846
|
+
inbound.fromTransport = true
|
|
2479
2847
|
await handleInbound(inbound)
|
|
2480
2848
|
}
|
|
2481
2849
|
|
|
@@ -3088,7 +3456,7 @@ export function apply(ctx, config) {
|
|
|
3088
3456
|
|
|
3089
3457
|
ctx.tools.register(defineTool({
|
|
3090
3458
|
name: 'qq_bridge_simulate',
|
|
3091
|
-
description: '模拟一条 QQ 入站消息,走完整唤醒策略(不改白名单的话默认会被丢弃)。用于验证昵称唤醒 / 概率唤醒 /
|
|
3459
|
+
description: '模拟一条 QQ 入站消息,走完整唤醒策略(不改白名单的话默认会被丢弃)。用于验证昵称唤醒 / 概率唤醒 / 只记录三条分支。不会执行「白名单」命令,也不会改群白名单。',
|
|
3092
3460
|
parameters: {
|
|
3093
3461
|
text: { type: 'string', required: true, description: 'QQ 消息文本。' },
|
|
3094
3462
|
userId: { type: 'string', description: '私聊发送者 id(需在白名单内)。' },
|
|
@@ -3982,6 +4350,42 @@ export function apply(ctx, config) {
|
|
|
3982
4350
|
return sendJson(res, 200, presentUpdate(result))
|
|
3983
4351
|
}
|
|
3984
4352
|
|
|
4353
|
+
/**
|
|
4354
|
+
* GET `/api/qq-bridge/groups?ids=群号,群号` —— 设置卡片给群白名单配群名和头像。
|
|
4355
|
+
* 只读。保存白名单仍走设置接口。桥没连上或查不到时仍返回 200,对应项只有群号。
|
|
4356
|
+
*/
|
|
4357
|
+
async function handleGroupMetaRequest(req, res) {
|
|
4358
|
+
if (rejectLocalRequest(req, res)) return
|
|
4359
|
+
const method = String(req?.method ?? '').toUpperCase()
|
|
4360
|
+
if (method !== 'GET') {
|
|
4361
|
+
return sendJson(res, 405, { ok: false, error: '这个接口只接受 GET。群白名单用设置接口保存。' })
|
|
4362
|
+
}
|
|
4363
|
+
let raw = ''
|
|
4364
|
+
try {
|
|
4365
|
+
const query = String(req?.url ?? '')
|
|
4366
|
+
const mark = query.indexOf('?')
|
|
4367
|
+
raw = new URLSearchParams(mark === -1 ? '' : query.slice(mark + 1)).get('ids') ?? ''
|
|
4368
|
+
} catch {
|
|
4369
|
+
raw = ''
|
|
4370
|
+
}
|
|
4371
|
+
const tokens = String(raw).slice(0, 8000).split(/[,,\s]+/)
|
|
4372
|
+
let groups
|
|
4373
|
+
try {
|
|
4374
|
+
groups = await resolveGroupMetas(tokens, {
|
|
4375
|
+
isOpen: () => transport.isOpen(),
|
|
4376
|
+
call: (action, params) => transport.call(action, params),
|
|
4377
|
+
cache: groupMetaCache,
|
|
4378
|
+
})
|
|
4379
|
+
} catch (error) {
|
|
4380
|
+
logger.warn(`群资料没有取到:${error?.message ?? error}`)
|
|
4381
|
+
groups = wantedGroupIds(tokens).map((id) => ({ id, name: '', avatar: '' }))
|
|
4382
|
+
}
|
|
4383
|
+
for (const row of groups) {
|
|
4384
|
+
if (row.name) groupNames.set(row.id, row.name)
|
|
4385
|
+
}
|
|
4386
|
+
return sendJson(res, 200, { ok: true, groups })
|
|
4387
|
+
}
|
|
4388
|
+
|
|
3985
4389
|
// TODO: accessTokenEnv 留空时,可回退读 NapCat 的 onebot11*.json 里的 token(用户说可做可不做)。
|
|
3986
4390
|
// 可选注入:没有 webServer 服务的部署照常工作(只是卡片上的按钮拿不到地址)。
|
|
3987
4391
|
if (typeof ctx.inject === 'function') ctx.inject(['webServer'], (scoped) => {
|
|
@@ -4015,6 +4419,7 @@ export function apply(ctx, config) {
|
|
|
4015
4419
|
}),
|
|
4016
4420
|
],
|
|
4017
4421
|
['/api/qq-bridge/update', handleUpdateRequest],
|
|
4422
|
+
['/api/qq-bridge/groups', handleGroupMetaRequest],
|
|
4018
4423
|
]
|
|
4019
4424
|
for (const [path, handler] of routes) disposes.push(server.register({ kind: 'exact', path, handler }))
|
|
4020
4425
|
scoped.effect(() => () => {
|
|
@@ -4026,7 +4431,7 @@ export function apply(ctx, config) {
|
|
|
4026
4431
|
}
|
|
4027
4432
|
}
|
|
4028
4433
|
})
|
|
4029
|
-
logger.info('NapCat 操作接口已挂载:/api/qq-bridge/napcat{,/launch,/reveal};检查更新:/api/qq-bridge/update(本机,或已登录的 DSH 面板)')
|
|
4434
|
+
logger.info('NapCat 操作接口已挂载:/api/qq-bridge/napcat{,/launch,/reveal};群资料:/api/qq-bridge/groups;检查更新:/api/qq-bridge/update(本机,或已登录的 DSH 面板)')
|
|
4030
4435
|
} catch (error) {
|
|
4031
4436
|
for (const dispose of disposes) {
|
|
4032
4437
|
try {
|
package/onebot.js
CHANGED
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
// 设计要点(都是被环境逼出来的,改之前先读):
|
|
4
4
|
//
|
|
5
5
|
// 1) **用 Node 内置的全局 WebSocket,不引 `ws` 依赖。**
|
|
6
|
-
// 插件目录里只挂了一个 `@deepseek-ai` junction(见
|
|
6
|
+
// 插件目录里只挂了一个 `@deepseek-ai` junction(见 DESIGN.md「开发期」),
|
|
7
7
|
// `ws` 从这个目录解析不到(ERR_MODULE_NOT_FOUND)。Node >= 22 自带
|
|
8
8
|
// WHATWG WebSocket 客户端,零依赖。
|
|
9
9
|
//
|
package/package.json
CHANGED