@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/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(见 README 的 link 安装坑),
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@fengye_nobug/dsh-qq-bot",
3
- "version": "0.2.7",
3
+ "version": "0.2.8",
4
4
  "type": "module",
5
5
  "main": "index.js",
6
6
  "description": "把 DSH 会话接到 QQ(OneBot/NapCat)的插件。白名单默认全关;被 @、叫到昵称或被戳一定叫醒,其余消息按可调概率抽样。Fork 自 sj244/dsh-qq-bridge(MIT)。",