dsh-qq-link 1.4.5 → 1.4.7

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 CHANGED
@@ -1,3 +1,39 @@
1
+ ## 1.4.7 — 2026-11-01
2
+
3
+ **「只回 QQ 唤醒」开着时,QQ 叫醒的那一轮也发不出去(1.4.4 带进来的)**
4
+
5
+ - 🐞 现象:开着「只回 QQ 唤醒」,从 QQ 发一句,DSH 里正常回,**QQ 那边什么都没有**;
6
+ 把这个开关关掉就正常。手动点「试发」也正常 —— 说明发送通道没问题,是**判定把这一轮拦了**。
7
+ - 根因:1.4.4 为了修「自动触发的轮次也被挡」,把判据从「最近有没有 QQ 消息」改成「这一轮是谁起的头」,
8
+ 用一张 `轮来源` 表记 `source.kind`。但那行是 `轮来源.set(键, 类 || 'user')` —— **每来一条 user/message 就覆盖**。
9
+ 而同一轮里 DSH 还会再插一条**没有 `source.kind`** 的注入(每轮的 runtime context 就是独立的 user/message),
10
+ 它把 `qq` 覆盖成 `user`,于是这一轮被判成「你在窗口里自己打的」→ 整轮丢掉。
11
+ - ✅ 现在:**带 kind 才覆盖**;没有 kind 的只在「这一轮什么来源都还没记过」时才当成本地手打。
12
+ 另外单独记「这一轮里出现过 qq」,判定时只要出现过就算 QQ 唤醒,后来的注入盖不掉它。
13
+
14
+ **顺带:连着发的几条攒成一条**
15
+
16
+ - ✅ 默认值 **1 秒 → 3 秒**(界面文案一直写的是「默认 3」,代码里却是 1 —— 新装的人拿到 1 秒,
17
+ 人手打两条字常隔 2~4 秒,于是看起来就是"连着发不合并")。
18
+ - 🐞 窗口以前按 **QQ 消息自带的时间戳**算(`现在 - 条.time`):服务器时间只要比本机快一点,差值就是负的,
19
+ 代码直接不等 ⇒ **永远不合并**,窗口设多大都没用。现在按**本机第一次看到这条消息的时刻**算。
20
+ - ⚠ 水位(下次从哪条开始读)仍然用消息自己的时间,没有被上面的改动带偏。
21
+
22
+ ## 1.4.6 — 2026-11-01
23
+
24
+ **「检查更新」以前会报错版本,而且只给一条命令让你自己手动复制、然后手动装**
25
+
26
+ - 🐞 **"最新版本"会骗人**:以前是「哪个地址先返回就用哪个」。GitHub 一超时就落到 jsDelivr ——
27
+ 那个地址**带 CDN 缓存、会滞后好几天**,于是同一分钟内能报出好几个不同的"最新",
28
+ 甚至比你本机装的还旧,看着像"该降级"。
29
+ - ✅ 现在**三个源全查**(GitHub / jsDelivr / npm registry),**取版本号最大的那个**:
30
+ 版本只会往前走,哪个源报小了就是它滞后。三个都失败才说"查不到",绝不把旧值当成"最新"。
31
+ - ✨ **检测到新版可以直接装**:那一块多了一个「一键更新到 vX」按钮,点它走 DSH 自己的插件管理装最新版,
32
+ 装完会提示你重启(或重载)一次。以前只有一个「复制命令」,得你自己去终端敲两行。
33
+ - 🧹 **界面上那些命令行全删了**:以前那两行 `dsh plugin --profile web remove/add` 和「复制命令」按钮不再显示 —— 很多人看不懂,还得跑来问。现在有新版就只有「一键更新到 vX」一个按钮。
34
+ - ⚠ **本地开发副本(link: 进来的那种)不给一键装** —— 那会把正在改的目录换成仓库里的版本;
35
+ 这种会明确写出来,让你手动更新。
36
+
1
37
  ## 1.4.5 — 2026-10-31
2
38
 
3
39
  **正文里写文件链接,就把那个文件传到 QQ**
package/lib/client.js CHANGED
@@ -613,6 +613,18 @@ window.__ModuleLoader__.load({
613
613
  设更新忙(false)
614
614
  设说(!r ? '查不到(网络)' : r.hasUpdate ? '有新版本 v' + r.latest : r.ok ? '已经是最新版' : '查不到:' + (r.error || '网络'))
615
615
  }
616
+ // ⭐ 2026-11-01:查到新版就一键装(宿主的 pluginManager.installBundle)
617
+ const 一键更新 = async () => {
618
+ 设更新忙(true)
619
+ 设说('正在装…')
620
+ try {
621
+ const r = await post('/install', {})
622
+ 设说(r && r.ok ? '已经开始装了 —— 装完重启(或重载)一次 dsh 才生效' : '没装成:' + ((r && r.error) || '不知道原因'))
623
+ } catch (e) {
624
+ 设说('没装成:' + ((e && e.message) || e))
625
+ }
626
+ 设更新忙(false)
627
+ }
616
628
  const 更新页 = h('div', null,
617
629
  h('div', { style: S.card },
618
630
  h('div', { style: { ...S.h, margin: '0 0 8px' } }, '检查更新'),
@@ -623,9 +635,10 @@ window.__ModuleLoader__.load({
623
635
  h('div', { style: { marginTop: 6 } }, 按钮(更新忙 ? '查…' : '检查更新', 查更新, false, 更新忙)),
624
636
  更新 && 更新.hasUpdate
625
637
  ? h('div', { style: { marginTop: 8 } },
626
- h('div', { style: S.dim }, '更新方法:先卸旧版再装新版(直接装可能拿到缓存里的旧副本)。'),
627
- h('div', { style: S.日志行 }, ['dsh plugin --profile web remove dsh-qq-link', 'dsh plugin --profile web add dsh-qq-link'].join(String.fromCharCode(10))),
628
- 按钮('复制命令', () => navigator.clipboard.writeText(['dsh plugin --profile web remove dsh-qq-link', 'dsh plugin --profile web add dsh-qq-link'].join(String.fromCharCode(10))).then(() => 设说('命令已复制')).catch(() => 设说('复制不了,手动选中吧')), false, false))
638
+ (更新 && 更新.canInstall)
639
+ ? 按钮(更新忙 ? '装…' : '一键更新到 v' + 更新.latest, 一键更新, false, 更新忙)
640
+ : h('div', { style: S.dim }, '这是本地开发副本(link: 进来的),请手动更新。'),
641
+ h('div', { style: S.dim }, '装完重启(或重载)一次 dsh 才生效。'))
629
642
  : null,
630
643
  ),
631
644
  )
package/lib/host.js CHANGED
@@ -116,7 +116,7 @@ const 默认配置 = {
116
116
  // · >0 = 同一个人在这段时间内连着发的,合成一条(私聊、群都管用)
117
117
  // 实现上是「等一小会儿再收」:最后一条没过去这么久,就先不消费,下一跳连新的那条一起收。
118
118
  // ⚠ 别开大:一轮拉历史里本来就能看到「已经发完的那一串」,这里只兜「正好撞上轮询」的那一下。
119
- 合并窗口秒: 1,
119
+ 合并窗口秒: 3,
120
120
  // 最多攒多久(秒):有人一直发个不停,到点也得先转进来,不能无限等
121
121
  合并最多秒: 20,
122
122
  // ⭐ 1.1.0(别人要的):人设(选填)。**塞进每条转进去的消息最前面**,每轮都带。
@@ -1007,6 +1007,9 @@ export function apply(ctx) {
1007
1007
  // ⭐ 0.6.0:每个「聊天」各自记「看到哪儿了」(键 = 类型:号码)—— 多群多私聊才不会互相串
1008
1008
  const 上次时间 = new Map() // 水位必须先初始化,再恢复磁盘状态
1009
1009
  const 已转消息 = new Set() // 已经转发进 DSH 的 QQ 消息 id(防重复转)
1010
+ // ⭐ 1.4.7:每条消息「本机第一次看到它」的时间(秒)—— 攒条窗口按它算,
1011
+ // 这样既不依赖 QQ 服务器的时间戳,也不会因为每一跳重读同一批而把窗口无限延长。
1012
+ const 见时 = new Map()
1010
1013
  // ⚠️ 2026-10-25:
1011
1014
  // 这两个东西以前**只存在内存里** —— 插件一重启(今天改了鱼的心情 / 换插件都要重启),
1012
1015
  // 「看到哪儿了」就归零 ⇒ 最近那十几条历史消息会**被当成新消息重投一遍**,
@@ -1424,7 +1427,16 @@ export function apply(ctx) {
1424
1427
  const 类 = String((源 && 源.kind) || '')
1425
1428
  if (!类 || 类 === 'user') 尾巴.delete(键)
1426
1429
  // ⭐ 1.4.4:把「这一轮是谁起的头」记下来 —— turn/end 判定要不要推到手机时用
1427
- 轮来源.set(键, 类 || 'user')
1430
+ // ⚠⚠ 1.4.7:以前是 `轮来源.set(键, 类 || 'user')` —— **无条件覆盖**。而同一轮里 DSH 还会再插
1431
+ // 一条没有 source.kind 的注入(每轮的 runtime context 就是独立的一条 user/message),
1432
+ // 于是「QQ 叫醒的那一轮」被改判成「你在窗口里自己打的」,「只回 QQ 唤醒」开着时整轮被丢掉。
1433
+ // 现在:**带 kind 才覆盖**;没有 kind 的只在「这一轮什么来源都还没记过」时才当成本地手打。
1434
+ if (类) {
1435
+ 轮来源.set(键, 类)
1436
+ if (类 === 'qq') 本轮有QQ.set(键, true)
1437
+ } else if (!轮来源.has(键)) {
1438
+ 轮来源.set(键, 'user')
1439
+ }
1428
1440
  return
1429
1441
  }
1430
1442
 
@@ -1454,8 +1466,11 @@ export function apply(ctx) {
1454
1466
  // ⚠ 只有**真的收到过 user/message 事件**才下结论;没记录就按老逻辑走(不能误挡)。
1455
1467
  const 有来源 = 轮来源.has(键)
1456
1468
  const 来源 = 轮来源.get(键) || ''
1469
+ // ⭐ 1.4.7:这一轮里只要**出现过** qq 来源,后面来的注入就盖不掉它
1470
+ const 这轮来过QQ = 本轮有QQ.get(键) === true
1457
1471
  轮来源.delete(键)
1458
- const 是本地手打 = 有来源 && (来源 === 'user' || 来源 === '')
1472
+ 本轮有QQ.delete(键)
1473
+ const 是本地手打 = !这轮来过QQ && 有来源 && (来源 === 'user' || 来源 === '')
1459
1474
  // 门槛判了「不接」→ 这一轮一个字都不往外发(但会话里照常留着)
1460
1475
  if (静默轮.has(键)) {
1461
1476
  静默轮.delete(键)
@@ -2375,6 +2390,7 @@ export function apply(ctx) {
2375
2390
  // 'qq' = 桥转进来的 / 'speak-up' = 主动搭话 / 'user' 或空 = 你自己在 DSH 窗口里打的 / 别的 = 定时任务之类。
2376
2391
  // 用途:「只回 QQ 唤醒」开着时,只有**你亲手打的**那几轮才留在本地,自动触发的照样推手机。
2377
2392
  const 轮来源 = new Map() // 会话键 -> source.kind
2393
+ const 本轮有QQ = new Map() // 会话键 -> 这一轮里出现过 qq 来源(1.4.7:注入盖不掉它)
2378
2394
  // ⭐ 1.4.1:哪个群现在「接着听」(键 = 类型:号码,比如 `群:456`)→ { 剩, 到 }
2379
2395
  // ⚠ 按聊天分开记(跟上面那条尾巴一个道理):A 群被点了名,不能让 B 群也开始接听。
2380
2396
  // 规则在纯函数 该收这条 里,这里只存状态。
@@ -3377,7 +3393,7 @@ export function apply(ctx) {
3377
3393
  const 丢 = 攒
3378
3394
  攒 = null
3379
3395
  for (const id of 丢.ids) 已转消息.add(id)
3380
- 上次 = Math.max(上次, 丢.末 || 上次)
3396
+ 上次 = Math.max(上次, 丢.末时 || 丢.末 || 上次)
3381
3397
  记账('群里这 ' + 丢.条目.length + ' 条没掷中(回复概率 ' + Number(配置.回复概率) + '%),这次不接')
3382
3398
  return true
3383
3399
  }
@@ -3404,7 +3420,7 @@ export function apply(ctx) {
3404
3420
  if (一批.ids.length > 1) 记账('连着发的 ' + 一批.ids.length + ' 条攒成一条转进去')
3405
3421
  const 结果 = await 转入一条(文字, 一批.图们, 一批.ids[0], 行, 是群行 ? null : 一批.发送者, 一批.点名, 信号, () => {
3406
3422
  for (const id of 一批.ids) 已转消息.add(id)
3407
- 上次 = Math.max(上次, 一批.末 || 上次)
3423
+ 上次 = Math.max(上次, 一批.末时 || 一批.末 || 上次)
3408
3424
  存水位({ 键: 键2, 时间: 上次, ids: 一批.ids })
3409
3425
  if (是群行) 记一发(键2)
3410
3426
  })
@@ -3490,11 +3506,31 @@ export function apply(ctx) {
3490
3506
  if (信号.aborted) return
3491
3507
  if (!好) break
3492
3508
  }
3509
+ // ⭐ 1.4.7:窗口按**本机第一次看到这条消息的时刻**算。
3510
+ // 以前用 `条.time`(QQ 服务器时间戳):服务器比本机快一点,差就是负的,
3511
+ // 落袋里 `差 >= 0` 不成立 ⇒ 永远不合并。
3512
+ // ⚠ 但不能直接用 `Date.now()`:每一跳都会把这一批重新读一遍,
3513
+ // 那样"最后一条"永远是刚刚,窗口永远等不到头。所以要记「第一次见到它的时间」。
3514
+ const 收到秒 = Math.floor(Date.now() / 1000)
3515
+ // ⚠ 本来就已经是旧消息(服务器时间戳比现在早)→ 用它的时间,这种不该再等窗口;
3516
+ // 未来时间戳(服务器比本机快)取不到就退回本机现在,免得差值为负、永远不合并。
3517
+ const 条时 = Number(条.time) > 0 ? Number(条.time) : 收到秒
3518
+ const 初见估 = Math.min(收到秒, 条时)
3519
+ if (!见时.has(条.message_id)) 见时.set(条.message_id, 初见估)
3520
+ const 初见 = 见时.get(条.message_id) || 初见估
3521
+ if (见时.size > 500) { // 别无限长大:超了就丢最老的一半
3522
+ const 键们 = [...见时.keys()]
3523
+ for (const k of 键们.slice(0, 键们.length - 250)) 见时.delete(k)
3524
+ }
3493
3525
  if (!攒) {
3494
3526
  攒 = {
3495
3527
  条目: [], 图们: [], ids: [], 点名: false, 发送者,
3496
- 起: 条.time || 0, 末: 条.time || 0,
3528
+ 起: 初见, 末: 初见, 末时: 条时, // 末 = 本机初见(算窗口);末时 = 消息自己的时间(算水位)
3497
3529
  }
3530
+ } else {
3531
+ 攒.起 = Math.min(攒.起, 初见)
3532
+ 攒.末 = Math.max(攒.末, 初见)
3533
+ 攒.末时 = Math.max(攒.末时 || 0, 条时)
3498
3534
  }
3499
3535
  // ⭐ 1.2.0:私聊默认不标来源;开了就给它一个能一眼认出的标记
3500
3536
  let 这行文字 = 文字
@@ -3516,7 +3552,6 @@ export function apply(ctx) {
3516
3552
  if (图们.length) 攒.图们.push(...图们)
3517
3553
  攒.ids.push(条.message_id)
3518
3554
  攒.点名 = 攒.点名 || Boolean(点名)
3519
- 攒.末 = 条.time || 攒.末
3520
3555
  if (!(Number(配置.合并窗口秒) > 0)) {
3521
3556
  const 好 = await 落袋()
3522
3557
  if (信号.aborted) return
@@ -3641,7 +3676,7 @@ export function apply(ctx) {
3641
3676
  记唤醒: (会话, 类型, 点名) => 唤醒时间.set(会话键(会话), { 时: Date.now(), 类型: String(类型 || '群'), 点名: Boolean(点名) }),
3642
3677
  清唤醒: (会话) => 唤醒时间.delete(会话键(会话)),
3643
3678
  清尾巴: (会话) => 尾巴.delete(会话键(会话)),
3644
- 清来源: (会话) => 轮来源.delete(会话键(会话)),
3679
+ 清来源: (会话) => { 轮来源.delete(会话键(会话)); 本轮有QQ.delete(会话键(会话)) },
3645
3680
  有尾巴: (会话) => Boolean((尾巴.get(会话键(会话)) || 0) > Date.now()),
3646
3681
  接听: (群号) => 接听中.get('群:' + String(群号)) || null,
3647
3682
  记己方: (id) => 记己方消息(id),
@@ -1,17 +1,22 @@
1
- // 插件自查更新 —— 查的是**这个插件自己的仓库**
1
+ // 插件自查更新 —— 查的是**这个插件自己的仓库**,并且能一键装
2
2
  //
3
- // 每个插件在自己设置页放一个「检查更新」,点一下就去自己的仓库看 package.json 里的版本。
3
+ // 每个插件在自己设置页放一个「检查更新」:点一下去自己的仓库 / npm 看最新版本。
4
4
  //
5
5
  // 原则:
6
6
  // · 仓库地址从包里自己的 package.json 读(`repository`),不在代码里写死
7
- // · 只查,不装不删 —— 更新命令交给用户自己复制去跑
8
- // · **查不到要如实说"查不到",绝不能显示成"已是最新"**
7
+ // · **多源全查、取最大的那个版本** —— 以前是"谁先返回用谁",GitHub 一超时就落到
8
+ // jsDelivr 那个**带 CDN 缓存**的源上,于是同一分钟能报出两个不同的"最新",甚至比本机还旧
9
+ // · 查不到要如实说"查不到",绝不能显示成"已是最新"
9
10
  // · 结果缓存 6 小时;点按钮(refresh)就绕过缓存
11
+ // · 「一键更新」走宿主自己的 pluginManager.installBundle —— 装完由宿主决定要不要重载;
12
+ // **本地开发副本(link:)不给装**,那会把她正在改的目录换成仓库里的版本
10
13
  //
11
- // ⚠ 内容会进客户端的一份说明:这个文件是**复制多份**到各个插件里的同一份实现,
12
- // 只在这一个地方改了,记得同步给其它插件(别各改各的)。
14
+ // ⚠ 这个文件是**复制多份**到各个插件里的同一份实现,只在这一个地方改了要同步给其它插件。
15
+ //
16
+ // 2026-11-01:多源取最大 + 一键更新(install)+ 装完把结果原样带回界面。
13
17
 
14
- import { readFileSync } from 'node:fs'
18
+ import { readFileSync, readdirSync, existsSync } from 'node:fs'
19
+ import { homedir } from 'node:os'
15
20
  import { join } from 'node:path'
16
21
 
17
22
  const TTL = 6 * 3600 * 1000
@@ -39,17 +44,23 @@ export function cmpVer(a, b) {
39
44
  return x.pre > y.pre ? 1 : -1
40
45
  }
41
46
 
42
- function rawUrls(repo) {
47
+ // 三个源都要查:GitHub raw(真值)· jsDelivr(有缓存,可能滞后)· npm registry(发过就有)
48
+ function sources(meta, repo) {
49
+ const out = []
43
50
  const s = String(repo || '').trim()
44
51
  const m = /github\.com[/:]([^/]+)\/([^/#?]+)/i.exec(s)
45
- if (!m) return []
46
- const owner = m[1]
47
- const name = m[2].replace(/\.git$/i, '')
48
- return [
49
- `https://raw.githubusercontent.com/${owner}/${name}/HEAD/package.json`,
50
- // 备用:jsDelivr 也能读 GitHub,但**有缓存**,可能滞后 —— 消息里会标出来
51
- `https://cdn.jsdelivr.net/gh/${owner}/${name}@main/package.json`,
52
- ]
52
+ if (m) {
53
+ const owner = m[1]
54
+ const name = m[2].replace(/\.git$/i, '')
55
+ out.push({ url: `https://raw.githubusercontent.com/${owner}/${name}/HEAD/package.json`, from: 'GitHub' })
56
+ out.push({ url: `https://cdn.jsdelivr.net/gh/${owner}/${name}@main/package.json`, from: 'jsDelivr' })
57
+ }
58
+ const pkg = String((meta && meta.name) || '').trim()
59
+ if (pkg) {
60
+ const enc = pkg.startsWith('@') ? '@' + pkg.slice(1).replace('/', '%2F') : encodeURIComponent(pkg)
61
+ out.push({ url: `https://registry.npmjs.org/${enc}/latest`, from: 'npm' })
62
+ }
63
+ return out
53
64
  }
54
65
 
55
66
  export function createSelfUpdate({ packageRoot, diag }) {
@@ -73,30 +84,40 @@ export function createSelfUpdate({ packageRoot, diag }) {
73
84
  .replace(/\.git$/, '')
74
85
  .replace(/#readme$/i, '')
75
86
 
87
+ // 装在自己的 node_modules 里 = 正常安装;不在 = 本地 link 的开发副本
88
+ const 是开发副本 = !/[\\/]node_modules[\\/]/.test(String(packageRoot))
89
+
76
90
  let cache = null
77
91
 
78
92
  async function fetchLatest() {
79
- const urls = rawUrls(repo)
80
- if (!urls.length) return { latest: null, error: '包里没写 GitHub 仓库地址,没法查' }
81
- let lastErr = ''
82
- for (const url of urls) {
93
+ const list = sources(meta, repo)
94
+ if (!list.length) return { latest: null, error: '包里没写仓库地址,也不像在 npm 上发过,没法查' }
95
+ const 结果 = await Promise.all(list.map(async (s) => {
83
96
  try {
84
- const res = await fetch(url, {
97
+ const res = await fetch(s.url, {
85
98
  signal: AbortSignal.timeout(TIMEOUT),
86
99
  headers: { accept: 'application/json', 'user-agent': 'dsh-plugin-selfupdate' },
87
100
  })
88
- if (!res.ok) { lastErr = 'HTTP ' + res.status; continue }
101
+ if (!res.ok) return { err: s.from + ' HTTP ' + res.status }
89
102
  const j = await res.json()
90
- if (!j || !j.version) { lastErr = '仓库里的 package.json 没有 version'; continue }
91
- return {
92
- latest: String(j.version),
93
- from: url.includes('jsdelivr') ? 'jsDelivr(有缓存,可能滞后)' : 'GitHub',
94
- }
103
+ const v = String((j && j.version) || '')
104
+ if (!v) return { err: s.from + ' 没有 version' }
105
+ return { v, from: s.from }
95
106
  } catch (e) {
96
- lastErr = String((e && e.message) || e)
107
+ return { err: s.from + ' ' + String((e && e.message) || e) }
97
108
  }
109
+ }))
110
+ // ⭐ 取**最大**的那个:版本只会往前走,任何一个源报小了都只是它滞后
111
+ let best = null
112
+ let bestFrom = null
113
+ for (const r of 结果) {
114
+ if (!r.v) continue
115
+ if (best === null || cmpVer(r.v, best) > 0) { best = r.v; bestFrom = r.from }
98
116
  }
99
- return { latest: null, error: lastErr || '查询失败' }
117
+ if (best === null) {
118
+ return { latest: null, error: 结果.map((r) => r.err).filter(Boolean).join(' · ') || '查询失败' }
119
+ }
120
+ return { latest: best, from: bestFrom }
100
121
  }
101
122
 
102
123
  async function check(force) {
@@ -113,32 +134,90 @@ export function createSelfUpdate({ packageRoot, diag }) {
113
134
  error: r.error || null,
114
135
  from: r.from || null,
115
136
  repoUrl: repoUrl || null,
137
+ devCopy: 是开发副本,
138
+ canInstall: !是开发副本,
116
139
  checkedAt: Date.now(),
117
140
  }
118
- log('查更新:当前 ' + current + ' / 最新 ' + (r.latest || '查不到') + (r.error ? '(' + r.error + ')' : ''))
141
+ log('查更新:当前 ' + current + ' / 最新 ' + (r.latest || '查不到') + (r.from ? '(' + r.from + ')' : '') + (r.error ? '(' + r.error + ')' : ''))
119
142
  return cache
120
143
  }
121
144
 
145
+ // 这个插件是以什么身份装的?从 profile 的 package.json 里把它那行依赖原样取出来 ——
146
+ // 用它当 spec 才是"同源更新",不会把 github 装的换成 npm 版。
147
+ function 取安装来源() {
148
+ const HOME = process.env.DSH_HOME || join(homedir(), '.dsh')
149
+ const name = String(meta.name || '')
150
+ if (!name) return ''
151
+ let profiles = []
152
+ try { profiles = readdirSync(join(HOME, 'profiles')) } catch (e) { return '' }
153
+ for (const p of profiles) {
154
+ const f = join(HOME, 'profiles', p, 'package.json')
155
+ if (!existsSync(f)) continue
156
+ try {
157
+ const j = JSON.parse(readFileSync(f, 'utf8'))
158
+ const dep = String((j.dependencies && j.dependencies[name]) || '')
159
+ if (/^(github:|git\+|https?:)/i.test(dep)) return dep
160
+ } catch (e) { /* 读不了就下一个 */ }
161
+ }
162
+ // 兜底:从 package.json 的仓库地址拼
163
+ const m = /github\.com[/:]([^/]+)\/([^/#?]+)/i.exec(repoUrl)
164
+ if (m) return 'github:' + m[1] + '/' + m[2].replace(/\.git$/i, '')
165
+ return ''
166
+ }
167
+
168
+ async function install(ctx) {
169
+ if (是开发副本) {
170
+ return { ok: false, error: '这是本地开发副本(link: 进来的),一键更新会把你正在改的目录换成仓库里的版本 —— 要更新请在仓库里 git pull' }
171
+ }
172
+ const pm = ctx && ctx.pluginManager
173
+ if (!pm || typeof pm.installBundle !== 'function') {
174
+ return { ok: false, error: '这版 DSH 没有插件管理接口,装不了;请手动更新' }
175
+ }
176
+ const spec = 取安装来源() || (meta.name ? String(meta.name) : '')
177
+ if (!spec) return { ok: false, error: '不知道该从哪儿装(既没有仓库地址,也没记录安装来源)' }
178
+ try {
179
+ log('一键更新:开始装 ' + spec)
180
+ const r = await pm.installBundle(spec)
181
+ log('一键更新:' + JSON.stringify(r && (r.applied ?? r.application ?? r.reason ?? r)))
182
+ return { ok: true, spec, result: r || null }
183
+ } catch (e) {
184
+ log('一键更新失败:' + String((e && e.message) || e))
185
+ return { ok: false, spec, error: String((e && e.message) || e) }
186
+ }
187
+ }
188
+
122
189
  function register(ctx, route) {
190
+ const send = (res, code, obj) => {
191
+ res.writeHead(code, { 'content-type': 'application/json; charset=utf-8', 'cache-control': 'no-store' })
192
+ res.end(JSON.stringify(obj))
193
+ }
123
194
  try {
124
195
  ctx.effect(() => ctx.webServer.register({
125
196
  kind: 'exact',
126
197
  path: route + '/update',
127
198
  handler: async (req, res) => {
128
- const send = (code, obj) => {
129
- res.writeHead(code, {
130
- 'content-type': 'application/json; charset=utf-8',
131
- 'cache-control': 'no-store',
132
- })
133
- res.end(JSON.stringify(obj))
134
- }
135
199
  try {
136
200
  const url = new URL(req.url, 'http://127.0.0.1')
137
201
  const force = url.searchParams.get('refresh') === '1'
138
- send(200, { ok: true, ...(await check(force)) })
202
+ send(res, 200, { ok: true, ...(await check(force)) })
139
203
  } catch (e) {
140
204
  log('查更新路由失败:' + (e && e.message))
141
- send(200, { ok: false, current, latest: null, error: String((e && e.message) || e) })
205
+ send(res, 200, { ok: false, current, latest: null, error: String((e && e.message) || e) })
206
+ }
207
+ },
208
+ }))
209
+ // ⭐ 一键更新:POST。装完的结果原样带回去(可能要重启才生效)
210
+ ctx.effect(() => ctx.webServer.register({
211
+ kind: 'exact',
212
+ path: route + '/install',
213
+ handler: async (req, res) => {
214
+ if (req.method !== 'POST') { send(res, 405, { ok: false, error: '只接受 POST' }); return }
215
+ try {
216
+ const r = await install(ctx)
217
+ send(res, 200, r)
218
+ } catch (e) {
219
+ log('一键更新路由失败:' + (e && e.message))
220
+ send(res, 200, { ok: false, error: String((e && e.message) || e) })
142
221
  }
143
222
  },
144
223
  }))
@@ -147,5 +226,5 @@ export function createSelfUpdate({ packageRoot, diag }) {
147
226
  }
148
227
  }
149
228
 
150
- return { current, repo, repoUrl, check, register }
229
+ return { current, repo, repoUrl, check, install, register }
151
230
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-qq-link",
3
- "version": "1.4.5",
3
+ "version": "1.4.7",
4
4
  "description": "通到 QQ:DSH 里的 AI 伙伴和 QQ 双向互通 —— 它说完话自动发到 QQ,你在 QQ 里说的话自动转进 DSH。走 OneBot 11 HTTP(SnowLuma / NapCat 都行)。",
5
5
  "license": "SEE LICENSE IN LICENSE",
6
6
  "engines": {