dsh-bili-scroll 2.0.4 → 2.2.0
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/AGENTS.md +3 -1
- package/CHANGELOG.md +710 -0
- package/CONTRIBUTORS.md +5 -1
- package/README.md +12 -2
- package/client.js +7 -2
- package/index.js +433 -44
- package/lib/diagnose.mjs +95 -0
- package/lib/mcp.mjs +34 -9
- package/lib/state.mjs +11 -12
- package/lib/uvfetch.mjs +217 -161
- package/package.json +4 -2
- package/profile-template/bili-duty/cordis.patch.yml +116 -0
- package/profile-template/bili-duty/cordis.yml +4 -0
- package/profile-template/bili-duty/package.json +13 -0
- package/prompts/duty.md +126 -13
- package/vendor/bilibili-mcp/mcp_server.py +706 -0
- package/vendor/bilibili-mcp/requirements.txt +2 -0
- package/LICENSE.bak-20261020 +0 -31
package/index.js
CHANGED
|
@@ -23,19 +23,22 @@ const hostDeps = await loadHostDeps()
|
|
|
23
23
|
const z = hostDeps.z
|
|
24
24
|
const defineTool = hostDeps.defineTool
|
|
25
25
|
import { spawn } from 'node:child_process'
|
|
26
|
-
import { appendFileSync, existsSync, mkdirSync, readdirSync, readFileSync, renameSync, rmSync, statSync, writeFileSync } from 'node:fs'
|
|
26
|
+
import { appendFileSync, cpSync, existsSync, mkdirSync, readdirSync, readFileSync, renameSync, rmSync, statSync, writeFileSync } from 'node:fs'
|
|
27
27
|
import { join, dirname } from 'node:path'
|
|
28
28
|
import { createRequire } from 'node:module'
|
|
29
29
|
import { fileURLToPath } from 'node:url'
|
|
30
30
|
import { McpClient } from './lib/mcp.mjs'
|
|
31
31
|
import { verifyRecent, appendVerifyToReport } from './lib/verify.mjs'
|
|
32
32
|
import { WINDOWS, stateDirOf, readJson, writeJson, ensureHomeFiles, ensureCharter, charterPath, inWindow, dayKey } from './lib/state.mjs'
|
|
33
|
+
import { diagnoseRound } from './lib/diagnose.mjs'
|
|
33
34
|
import { loginStart, loginPoll, saveCredential, renderQrPng } from './lib/login.mjs'
|
|
34
35
|
import { resolveUid } from './lib/uid.mjs'
|
|
35
36
|
import { loadCred as dmCred, listSessions as dmList, fetchMsgs as dmFetch } from './lib/dm.mjs'
|
|
36
37
|
import { ensureUv } from './lib/uvfetch.mjs'
|
|
37
38
|
|
|
38
39
|
export const name = 'bili-scroll'
|
|
40
|
+
// attachments / llm 故意**不**列进 inject:它们只在看图时才用 ctx.get 取,
|
|
41
|
+
// 取不到就把图降级成文字说明;列进来会让没有这两个服务的宿主直接装不上。
|
|
39
42
|
export const inject = ['webServer', 'tools', 'settings']
|
|
40
43
|
|
|
41
44
|
/**
|
|
@@ -77,7 +80,8 @@ export const Config = !z ? undefined : z.object({
|
|
|
77
80
|
/** dsh 的 bin.js(不填就试自动探测,再退化成 shell 里的 dsh) */
|
|
78
81
|
dshBin: z.string().default('').extra('volatile', true),
|
|
79
82
|
/** headless 那一轮用哪个 profile */
|
|
80
|
-
|
|
83
|
+
// ⭐ 2.0.5:默认换成"最小权限 profile"(只有 B站 MCP + 窄口 IO,没有文件系统/命令/联网)
|
|
84
|
+
headlessProfile: z.string().default('bili-duty').extra('volatile', true),
|
|
81
85
|
/** 给那一轮叠一份插件自己的 patch(把 B站 MCP 挂上)—— 用户 profile 一个字都不用改 */
|
|
82
86
|
overlayMcp: z.boolean().default(true).extra('volatile', true),
|
|
83
87
|
/** 跑 headless 时的工作目录 */
|
|
@@ -269,7 +273,15 @@ function reportMeta() {
|
|
|
269
273
|
const p = { ...patch }
|
|
270
274
|
if ('perDay' in p) p.perDay = Math.max(0, Math.min(Number(p.perDay) || 0, 50))
|
|
271
275
|
if ('reportLevel' in p) p.reportLevel = ['full', 'brief', 'off'].includes(String(p.reportLevel)) ? String(p.reportLevel) : 'full'
|
|
272
|
-
|
|
276
|
+
// ⭐ 2.0.5(issue #89):人设上限 8000 → 60000;**截断时要出声**(以前静默砍,用户以为写进去了 ✗)
|
|
277
|
+
if ('personaPrompt' in p) {
|
|
278
|
+
const raw = String(p.personaPrompt || '')
|
|
279
|
+
const LIMIT = 60000
|
|
280
|
+
if (raw.length > LIMIT) {
|
|
281
|
+
log('⚠️ 人设提示词 ' + raw.length + ' 字符,超过保存上限 ' + LIMIT + ' ⇒ 只存前 ' + LIMIT + '(截掉 ' + (raw.length - LIMIT) + ' 字)')
|
|
282
|
+
p.personaPrompt = raw.slice(0, LIMIT)
|
|
283
|
+
} else p.personaPrompt = raw
|
|
284
|
+
}
|
|
273
285
|
if ('dmWatch' in p) p.dmWatch = Boolean(p.dmWatch)
|
|
274
286
|
if ('dmWatchMinutes' in p) p.dmWatchMinutes = Math.max(1, Math.min(120, Number(p.dmWatchMinutes) || 1))
|
|
275
287
|
if ('dmReplyPerDay' in p) p.dmReplyPerDay = Math.max(0, Math.min(20, Number(p.dmReplyPerDay) || 0))
|
|
@@ -495,6 +507,12 @@ function reportMeta() {
|
|
|
495
507
|
return client.call(toolName, args, opts)
|
|
496
508
|
}
|
|
497
509
|
|
|
510
|
+
/** 同 call,但保留 MCP 返回里的图片块(封面/画面要用)—— 见 lib/mcp.mjs 的 callRaw */
|
|
511
|
+
const callRaw = async (toolName, args, opts) => {
|
|
512
|
+
const client = await ensureMcp()
|
|
513
|
+
return client.callRaw(toolName, args, opts)
|
|
514
|
+
}
|
|
515
|
+
|
|
498
516
|
const hasCredential = () => existsSync(join(stateDir, 'bili_credential.json'))
|
|
499
517
|
|
|
500
518
|
/* ─────────────── 工具 ─────────────── */
|
|
@@ -518,7 +536,7 @@ function reportMeta() {
|
|
|
518
536
|
|
|
519
537
|
const tool = (spec) => ctx.tools.register(defineTool({
|
|
520
538
|
...spec,
|
|
521
|
-
output: textOutput,
|
|
539
|
+
output: spec.output || textOutput,
|
|
522
540
|
async execute(args, exec) { return safe(await spec.execute(args, exec)) },
|
|
523
541
|
}))
|
|
524
542
|
|
|
@@ -573,6 +591,109 @@ function reportMeta() {
|
|
|
573
591
|
},
|
|
574
592
|
})
|
|
575
593
|
|
|
594
|
+
/* ─────────────── 看画面:把图交给模型 ───────────────
|
|
595
|
+
* 图片在工具结果里是附件引用 { type:'image', attachment },不是 image_url;
|
|
596
|
+
* 字节先存进 ctx.get('attachments'),由 render 拼进返回内容。
|
|
597
|
+
* 模型不支持图片输入时退化成文字(查 llm.resolveModelInfo 的 inputModalities)。
|
|
598
|
+
*/
|
|
599
|
+
const imageOutput = {
|
|
600
|
+
schema: { type: 'object', properties: {}, additionalProperties: true },
|
|
601
|
+
render: (_args, value) => {
|
|
602
|
+
const src = value && typeof value === 'object' ? value : { value }
|
|
603
|
+
const { image, images, ...rest } = src
|
|
604
|
+
const parts = [{ type: 'text', text: JSON.stringify(rest, null, 2) }]
|
|
605
|
+
const refs = [...(image ? [image] : []), ...(Array.isArray(images) ? images : [])]
|
|
606
|
+
for (const ref of refs) parts.push({ type: 'image', attachment: ref })
|
|
607
|
+
return parts
|
|
608
|
+
},
|
|
609
|
+
}
|
|
610
|
+
|
|
611
|
+
/** 把 MCP 回来的图存成附件引用,返回 { ok, refs } 或 { ok:false, reason, hint } */
|
|
612
|
+
const attachImages = async (exec, images) => {
|
|
613
|
+
const attachments = ctx.get('attachments')
|
|
614
|
+
if (!attachments?.saveImages) {
|
|
615
|
+
return { ok: false, reason: 'attachments', hint: '宿主的图片服务不可用(ctx.get("attachments") 是空的)' }
|
|
616
|
+
}
|
|
617
|
+
|
|
618
|
+
const llm = ctx.get('llm')
|
|
619
|
+
const routed = exec?.agent?.session?.requestHeader?.()?.config
|
|
620
|
+
const provider = routed?.provider ?? exec?.agent?.options?.provider
|
|
621
|
+
const model = routed?.model ?? exec?.agent?.options?.model
|
|
622
|
+
if (!llm?.resolveModelInfo) {
|
|
623
|
+
return { ok: false, reason: 'model_probe', hint: `查不到模型能力(provider=${provider} model=${model})` }
|
|
624
|
+
}
|
|
625
|
+
let modalities
|
|
626
|
+
try {
|
|
627
|
+
modalities = (await llm.resolveModelInfo(provider, model, exec?.signal))?.inputModalities
|
|
628
|
+
} catch (e) {
|
|
629
|
+
return { ok: false, reason: 'model_probe', hint: `查模型能力失败(${provider}/${model}):${String(e?.message ?? e)}` }
|
|
630
|
+
}
|
|
631
|
+
if (!Array.isArray(modalities)) {
|
|
632
|
+
return { ok: false, reason: 'model_probe', hint: `模型 ${provider}/${model} 没声明输入模态` }
|
|
633
|
+
}
|
|
634
|
+
if (!modalities.includes('image')) {
|
|
635
|
+
return { ok: false, reason: 'model_no_image', hint: `模型 ${provider}/${model} 不支持图片输入(${modalities.join(',') || '无'})` }
|
|
636
|
+
}
|
|
637
|
+
|
|
638
|
+
try {
|
|
639
|
+
const refs = await attachments.saveImages(
|
|
640
|
+
images.map((i) => ({ data: Buffer.from(i.data, 'base64'), mediaType: i.mimeType })),
|
|
641
|
+
)
|
|
642
|
+
return { ok: true, refs }
|
|
643
|
+
} catch (e) {
|
|
644
|
+
return { ok: false, reason: 'save_failed', hint: '存图片失败:' + String(e?.message ?? e) }
|
|
645
|
+
}
|
|
646
|
+
}
|
|
647
|
+
|
|
648
|
+
tool({
|
|
649
|
+
name: 'bili_cover',
|
|
650
|
+
description: '看一个视频的封面(等比缩到 512px 宽)。最便宜的一档"看一眼":'
|
|
651
|
+
+ '能看出风格、标题美术、大致题材,但看不出视频里具体发生了什么。',
|
|
652
|
+
parameters: { bvid: { type: 'string', description: 'BV号' } },
|
|
653
|
+
output: imageOutput,
|
|
654
|
+
async execute(args, exec) {
|
|
655
|
+
const { json, images } = await callRaw('bili_cover', { bvid: String(args.bvid ?? '') }, { signal: exec?.signal })
|
|
656
|
+
const meta = json && typeof json === 'object' ? json : { raw: json }
|
|
657
|
+
if (!meta.ok) return meta
|
|
658
|
+
const img = images[0]
|
|
659
|
+
if (!img) return { ...meta, ok: false, error: 'MCP 没返回图片' }
|
|
660
|
+
|
|
661
|
+
const a = await attachImages(exec, [img])
|
|
662
|
+
return a.ok
|
|
663
|
+
? { ...meta, image_attached: true, image: a.refs[0] }
|
|
664
|
+
: { ...meta, image_attached: false, image_skip_reason: a.reason, hint: a.hint }
|
|
665
|
+
},
|
|
666
|
+
})
|
|
667
|
+
|
|
668
|
+
tool({
|
|
669
|
+
name: 'bili_frames',
|
|
670
|
+
description: '看视频里的画面(从 B 站自带的故事板裁 1-6 帧,每帧 480×270)。\n'
|
|
671
|
+
+ '想知道"这条视频到底拍了什么"就用它 —— 字幕告诉你说什么,画面告诉你拍的是什么。\n'
|
|
672
|
+
+ '默认按时长均匀取 3 帧;想盯某个时刻(比如字幕里提到的那段)就传 at。',
|
|
673
|
+
parameters: {
|
|
674
|
+
bvid: { type: 'string', description: 'BV号' },
|
|
675
|
+
num: { type: 'number', description: '取几帧,1-6,默认 3' },
|
|
676
|
+
at: { type: 'array', items: { type: 'number' }, description: '指定时间点(秒)列表;传了就以它为准,忽略 num' },
|
|
677
|
+
},
|
|
678
|
+
output: imageOutput,
|
|
679
|
+
async execute(args, exec) {
|
|
680
|
+
const at = Array.isArray(args.at) ? args.at.map(Number).filter(Number.isFinite) : undefined
|
|
681
|
+
const { json, images } = await callRaw('bili_frames', {
|
|
682
|
+
bvid: String(args.bvid ?? ''),
|
|
683
|
+
num: Math.max(1, Math.min(Number(args.num) || 3, 6)),
|
|
684
|
+
...(at && at.length ? { at } : {}),
|
|
685
|
+
}, { signal: exec?.signal })
|
|
686
|
+
const meta = json && typeof json === 'object' ? json : { raw: json }
|
|
687
|
+
if (!meta.ok) return meta
|
|
688
|
+
if (!images.length) return { ...meta, ok: false, error: 'MCP 没返回图片' }
|
|
689
|
+
|
|
690
|
+
const a = await attachImages(exec, images)
|
|
691
|
+
return a.ok
|
|
692
|
+
? { ...meta, image_attached: true, images: a.refs }
|
|
693
|
+
: { ...meta, image_attached: false, image_skip_reason: a.reason, hint: a.hint }
|
|
694
|
+
},
|
|
695
|
+
})
|
|
696
|
+
|
|
576
697
|
tool({
|
|
577
698
|
name: 'bili_reply',
|
|
578
699
|
description: '在视频下发评论(或回复某条评论)。这就是"@ 本人"那一下:短、干净、纯文本 @ 写在正文里。\n'
|
|
@@ -630,9 +751,6 @@ function reportMeta() {
|
|
|
630
751
|
dmMode: cfg.dmMode,
|
|
631
752
|
dmUid: Number(cfg.dmUid) || 0,
|
|
632
753
|
transcribe: cfg.transcribe,
|
|
633
|
-
// ⭐ 2.0.4:认人改成"只认显式 UID / 登录账号自己"之后,**用小号登录、大号聊天**的人会静默失去私信回复 ✗
|
|
634
|
-
// ⇒ 这里只给一条**提示**(不自动改!),界面上让他一键确认 —— 自动把"私信对象"当主人等于把安全修复撤销 ✗
|
|
635
|
-
ownerInfo: ownerInfo(),
|
|
636
754
|
loggedIn: hasCredential(),
|
|
637
755
|
home: stateDir,
|
|
638
756
|
report: join(outDir, `${dayKey(now)}.md`),
|
|
@@ -756,6 +874,20 @@ function reportMeta() {
|
|
|
756
874
|
return st.day === dayKey(now) ? Number(st.count || 0) : 0
|
|
757
875
|
}
|
|
758
876
|
|
|
877
|
+
// ⭐⭐ 2.0.5(2026-10-24 用户实证,群友「羽涅」):一键升级以前把 profile 写死成 web ✗
|
|
878
|
+
// 桌面版用户的 profile 是 desktop ⇒ 升级失败,反而把 0.1.7 装进 web 里,越修越乱 ✓
|
|
879
|
+
// 现在从"自己这个模块的真实路径"里取 profile 名:挂载点是 ~/.dsh/profiles/<名字>/node_modules/<包>/
|
|
880
|
+
const ownProfileName = (() => {
|
|
881
|
+
try {
|
|
882
|
+
const u = String(import.meta.url || '').replace(/\\/g, '/')
|
|
883
|
+
const m = u.match(/\/profiles\/([^/]+)\/node_modules\//)
|
|
884
|
+
return m ? m[1] : ''
|
|
885
|
+
} catch { return '' }
|
|
886
|
+
})()
|
|
887
|
+
const profileArg = ownProfileName || 'web'
|
|
888
|
+
// ⭐ 顺带:把"我是从哪个 profile 加载的"报出来,排障时一眼看得出
|
|
889
|
+
try { log('插件加载自 profile:' + profileArg + (ownProfileName ? '' : '(没识别到,按默认 web 处理)')) } catch { /* 忽略 */ }
|
|
890
|
+
|
|
759
891
|
// ⭐ 一键升级:由宿主自己跑 `dsh plugin --profile web add dsh-bili-scroll`
|
|
760
892
|
// ⚠️ 装完必须重启 dsh 才生效;⚠️ Windows 上"替换正在运行的插件"可能被文件占用挡住(我们踩过)
|
|
761
893
|
let upgrading = false
|
|
@@ -794,17 +926,17 @@ function reportMeta() {
|
|
|
794
926
|
// ⚠️ 2026-10-19:找不到 dsh 时不再硬跑,直接把要做的事说清楚
|
|
795
927
|
if (dsh.missing) {
|
|
796
928
|
appendInstall('=== 没法自动升级:找不到 dsh。' + dsh.hint + ' ===\n')
|
|
797
|
-
appendInstall('=== 也可以自己手动跑一次:dsh plugin --profile
|
|
929
|
+
appendInstall('=== 也可以自己手动跑一次:dsh plugin --profile ' + profileArg + ' add dsh-bili-scroll ===\n')
|
|
798
930
|
upgrading = false
|
|
799
931
|
return
|
|
800
932
|
}
|
|
801
933
|
let cmd = dsh.command || 'dsh'
|
|
802
|
-
|
|
934
|
+
let args = [...(dsh.args || []), 'plugin', '--profile', profileArg, 'add', spec]
|
|
803
935
|
if (dsh.shell) { args = ['/c', cmd, ...args]; cmd = 'cmd.exe' }
|
|
804
936
|
appendInstall('$ ' + cmd + ' ' + args.join(' ') + '\n')
|
|
805
937
|
const code = await spawnTo(cmd, args, { sink: appendInstall })
|
|
806
938
|
if (code === 0) appendInstall('=== 升级完成 —— 重启一次 dsh 才生效 ===\n')
|
|
807
|
-
else appendInstall('=== 升级没成功(exit ' + code + ')。可以自己手动跑一次:dsh plugin --profile
|
|
939
|
+
else appendInstall('=== 升级没成功(exit ' + code + ')。可以自己手动跑一次:dsh plugin --profile ' + profileArg + ' add dsh-bili-scroll ===\n')
|
|
808
940
|
} catch (e) { appendInstall('升级崩了:' + (e?.stack ?? e) + '\n') } finally { upgrading = false }
|
|
809
941
|
})()
|
|
810
942
|
return { ok: true, started: true, log: installLogFile, hint: '正在升级 —— 完成后重启一次 dsh 才生效' }
|
|
@@ -993,16 +1125,28 @@ function reportMeta() {
|
|
|
993
1125
|
}
|
|
994
1126
|
}
|
|
995
1127
|
if (persona) {
|
|
1128
|
+
// ⭐ 2.0.5(issue #89):6000 → 40000;超了要写一行日志,别让用户以为"规则生效了" ✗
|
|
1129
|
+
const PERSONA_LIMIT = 40000
|
|
1130
|
+
const personaText = (() => {
|
|
1131
|
+
const raw = String(persona)
|
|
1132
|
+
if (raw.length > PERSONA_LIMIT) {
|
|
1133
|
+
log('⚠️ 人设提示词 ' + raw.length + ' 字符,超过上限 ' + PERSONA_LIMIT + ' ⇒ 只送前 ' + PERSONA_LIMIT + '(截掉 ' + (raw.length - PERSONA_LIMIT) + ' 字)')
|
|
1134
|
+
return raw.slice(0, PERSONA_LIMIT)
|
|
1135
|
+
}
|
|
1136
|
+
return raw
|
|
1137
|
+
})()
|
|
996
1138
|
ownerDoc = '\n\n===== 使用者的人设' + (personaFromFile ? '(读自 ' + personaFromFile + ')' : '提示词') + '(**按这个来**)=====\n'
|
|
997
|
-
+
|
|
1139
|
+
+ personaText
|
|
998
1140
|
+ '\n===== 人设结束 =====\n'
|
|
999
1141
|
}
|
|
1000
1142
|
const wsDir = String(cfg.workspace || '').trim()
|
|
1001
1143
|
const agents = (!persona && wsDir) ? join(wsDir, 'AGENTS.md') : ''
|
|
1002
1144
|
if (agents && existsSync(agents)) {
|
|
1003
1145
|
const text = readFileSync(agents, 'utf8')
|
|
1146
|
+
const AG_LIMIT = 40000
|
|
1147
|
+
if (text.length > AG_LIMIT) log('⚠️ 工作区的 AGENTS.md ' + text.length + ' 字符,超过上限 ' + AG_LIMIT + ' ⇒ 只送前 ' + AG_LIMIT + '(截掉 ' + (text.length - AG_LIMIT) + ' 字)')
|
|
1004
1148
|
ownerDoc = '\n\n===== 使用者的 AGENTS.md(他写给自己 AI 的人设与准则,**按这个来**)=====\n'
|
|
1005
|
-
+ text.slice(0,
|
|
1149
|
+
+ text.slice(0, AG_LIMIT)
|
|
1006
1150
|
+ '\n===== 设定结束 =====\n'
|
|
1007
1151
|
}
|
|
1008
1152
|
} catch { /* 读不到就算了,别因此跑不成 */ }
|
|
@@ -1038,7 +1182,11 @@ function reportMeta() {
|
|
|
1038
1182
|
+ (already > 0
|
|
1039
1183
|
? `今天已经挑过 ${already} 条了 ⇒ **这一轮最多再挑 ${left} 条**(「每天最多 ${quota} 条」是全天总量,不是每轮);`
|
|
1040
1184
|
: `今天最多挑 ${quota} 条;`)
|
|
1041
|
-
|
|
1185
|
+
// ⭐ 2026-10-24:留空不再用内置关键词(那会让所有用户刷同一批视频、评论区扎堆)
|
|
1186
|
+
// ⇒ 留空 = 用 `bili_recommend` 刷**这个账号的首页推荐流**,按各自画像天然散开 ✓
|
|
1187
|
+
+ (kws.length
|
|
1188
|
+
? `关键词用这些:${kws.join('、')}(用 bili_search 搜)。`
|
|
1189
|
+
: '没填关键词 ⇒ **这一轮用 bili_recommend 刷首页推荐流**(不要用 bili_search 搜固定词)。')
|
|
1042
1190
|
+ `本地转写:${cfg.transcribe
|
|
1043
1191
|
? (asrReadyNow()
|
|
1044
1192
|
? '开着,而且装好了(没字幕的视频用家里那份 asr.py 转,命令见章程)'
|
|
@@ -1050,6 +1198,18 @@ function reportMeta() {
|
|
|
1050
1198
|
+ `⭐ 另外把这一轮**看过的每一条视频(挑中的和跳过的都算)**追加进归档:${join(digestDir, `${dayKey().slice(0, 7)}.md`)}`
|
|
1051
1199
|
+ ` —— 一行一条:日期 · BV 号 · 标题 · UP · 一句话结论 · 链接(跳过的那条在后面注明理由);`
|
|
1052
1200
|
+ `这是**给以后检索用**的(报告是给人看的,归档是给"回头翻"用的),别省略、也别只写挑中的。`
|
|
1201
|
+
// ⭐ 2026-11 语感:把"它自己以前发过的评论"喂进去 —— 没有这段,它每轮都从零开始,
|
|
1202
|
+
// 自然每条都往"平均腔调"上靠(那就是鱼八股)。有这段它才会长出自己的口癖、并避开重复开头。
|
|
1203
|
+
+ (() => {
|
|
1204
|
+
try {
|
|
1205
|
+
const led = join(stateDir, 'comments.md')
|
|
1206
|
+
if (!existsSync(led)) return ''
|
|
1207
|
+
const rows = readFileSync(led, 'utf8').split('\n').filter((l) => l.trim().startsWith('- '))
|
|
1208
|
+
const bodies = rows.slice(-5).map((l) => (l.includes('·') ? l.split('·').slice(1).join('·') : l.slice(2)).trim()).filter(Boolean)
|
|
1209
|
+
if (!bodies.length) return ''
|
|
1210
|
+
return `⭐ **你以前发过的评论**(写新的之前先看一遍:**别用相同的开头、别套同一个句式**,它应该越来越像"这一个它"而不是模板):` + bodies.map((b) => '「' + b.slice(0, 60) + '」').join(' ') + '。'
|
|
1211
|
+
} catch { return '' }
|
|
1212
|
+
})()
|
|
1053
1213
|
+ `自动评论并 @ ${cfg.mentionName || '(还没填要 @ 谁)'} 是${commentOn
|
|
1054
1214
|
? `开着的(每轮最多 ${cfg.commentPerRound} 条评论,每条在正文里纯文本 @ ${cfg.mentionName},150 字内)`
|
|
1055
1215
|
: (dmModeEff === 'only'
|
|
@@ -1099,7 +1259,12 @@ function reportMeta() {
|
|
|
1099
1259
|
* 要读全就手工改这个列表(或者直接跑 mcp_server.py,不设这个变量 = 全放)。
|
|
1100
1260
|
*/
|
|
1101
1261
|
const mcpWhitelist = () => {
|
|
1102
|
-
const list = ['bili_search', 'bili_subtitle', 'bili_comments', 'bili_video_info'
|
|
1262
|
+
const list = ['bili_search', 'bili_subtitle', 'bili_comments', 'bili_video_info',
|
|
1263
|
+
'bili_cover', 'bili_frames', 'bili_danmaku', 'bili_recommend'] // 看封面 / 画面帧 / 弹幕:只读,不需要额外的闸
|
|
1264
|
+
// ⭐⭐ 2.0.5(issue #96 安全加固):值班轮改成最小权限 profile(没有 fs / shell / web)⇒
|
|
1265
|
+
// 写日报 / 判重表 / 归档、以及无字幕转写,全走这几只**固定路径的窄口工具** ✓
|
|
1266
|
+
list.push('duty_paths', 'duty_report_append', 'duty_seen_append', 'duty_digest_append', 'duty_seen_recent')
|
|
1267
|
+
if (cfg.asrInstalled !== false) list.push('bili_transcribe')
|
|
1103
1268
|
// ⭐⭐ 2.0.4(第三方体检第 3 条):**"只发私信"时评论通道必须硬关** —— 以前只看 autoComment,
|
|
1104
1269
|
// 用户以前开过自动评论、后来切到"只私信",那个 true 还在 config 里 ⇒ 评论工具照样下发 ✗
|
|
1105
1270
|
const dmOnly = String(cfg.dmMode || '').trim() === 'only'
|
|
@@ -1111,6 +1276,34 @@ function reportMeta() {
|
|
|
1111
1276
|
return list
|
|
1112
1277
|
}
|
|
1113
1278
|
|
|
1279
|
+
|
|
1280
|
+
// ⭐⭐ 2.0.5(issue #96 / PR #97 思路):把「值班轮专用 profile」铺到 <DSH_HOME>/profiles/bili-duty/
|
|
1281
|
+
// · 组成 = dsh-base + dsh-headless + 我们那层 cordis.patch.yml(关掉 fs / shell / web / subagent / skill…)
|
|
1282
|
+
// · 铺不上就明确退回 headless,并在日志里说清"这一轮不是最小权限"(失败要出声)
|
|
1283
|
+
let dutyProfileState = 'unknown' // ok / missing / failed
|
|
1284
|
+
const dutyProfileDir = join(dirname(stateDir), 'profiles', 'bili-duty')
|
|
1285
|
+
const ensureDutyProfile = () => {
|
|
1286
|
+
try {
|
|
1287
|
+
const src = join(pluginDir, 'profile-template', 'bili-duty')
|
|
1288
|
+
if (!existsSync(src)) { dutyProfileState = 'missing'; return false }
|
|
1289
|
+
mkdirSync(dirname(dutyProfileDir), { recursive: true })
|
|
1290
|
+
// 每次启动都覆盖(模板很小;这是安全边界,用户改它也会被覆盖)
|
|
1291
|
+
cpSync(src, dutyProfileDir, { recursive: true, force: true })
|
|
1292
|
+
dutyProfileState = existsSync(join(dutyProfileDir, 'cordis.patch.yml')) ? 'ok' : 'failed'
|
|
1293
|
+
return dutyProfileState === 'ok'
|
|
1294
|
+
} catch (e) {
|
|
1295
|
+
dutyProfileState = 'failed'
|
|
1296
|
+
log('⚠️ 最小权限 profile 没铺成功(' + String(e?.message ?? e) + ')⇒ 这一轮会退回 headless')
|
|
1297
|
+
return false
|
|
1298
|
+
}
|
|
1299
|
+
}
|
|
1300
|
+
// 真正用的 profile:用户手填了别的就尊重;还是老默认 has headless ⇒ 用最小权限那个
|
|
1301
|
+
const effectiveProfile = () => {
|
|
1302
|
+
const want = String(cfg.headlessProfile || '').trim()
|
|
1303
|
+
if (want && want !== 'headless' && want !== 'bili-duty') return want
|
|
1304
|
+
return dutyProfileState === 'ok' ? 'bili-duty' : (want || 'headless')
|
|
1305
|
+
}
|
|
1306
|
+
|
|
1114
1307
|
const ensureOverlay = () => {
|
|
1115
1308
|
const file = join(stateDir, 'headless-overlay.yml')
|
|
1116
1309
|
const script = join(stateDir, 'mcp_server.py')
|
|
@@ -1198,9 +1391,13 @@ function reportMeta() {
|
|
|
1198
1391
|
|
|
1199
1392
|
/**
|
|
1200
1393
|
* ⭐ 保底:那一轮**没写出报告**时,宿主自己补一份"占位报告"。
|
|
1201
|
-
*
|
|
1202
|
-
* 最后 `out/`
|
|
1203
|
-
*
|
|
1394
|
+
* 起因①(2026-10-16 用户实测):有一轮卡在"转写返回空 ⇒ 以为 ASR 坏了"上反复重试,
|
|
1395
|
+
* 最后 `out/` 空、判重表也没建 ⇒ 使用者完全看不到发生了什么 ✗。
|
|
1396
|
+
* 起因②(2026-10-24 用户实测):`NO_ADAPTER: no adapter registered for provider "…"`
|
|
1397
|
+
* ⇒ 那一轮 1 秒 exit 1,报告里只有 code=1 ⇒ 用户看不懂 ✗。
|
|
1398
|
+
* 现在:先从日志尾部认已知失败形态(`lib/diagnose.mjs`,纯函数),能认出来就把"是什么+怎么办"
|
|
1399
|
+
* 写进报告顶部;认不出来就走原来的兜底文案(行为跟以前一样)。
|
|
1400
|
+
* ⚠️ 只在"报告本来该有却没有"时补(`reportLevel: off` 不补),不覆盖已有报告。
|
|
1204
1401
|
*/
|
|
1205
1402
|
const stubReportIfMissing = (code) => {
|
|
1206
1403
|
try {
|
|
@@ -1208,16 +1405,36 @@ function reportMeta() {
|
|
|
1208
1405
|
const f = join(outDir, `${dayKey()}.md`)
|
|
1209
1406
|
if (existsSync(f)) return
|
|
1210
1407
|
let tail = ''
|
|
1211
|
-
try { tail = readFileSync(logFile, 'utf8').slice(-
|
|
1408
|
+
try { tail = readFileSync(logFile, 'utf8').slice(-6000) } catch { /* 读不到就算了 */ }
|
|
1409
|
+
// ⚠️ diagnoseRound 是纯函数、自己不会抛;这里再包一层是"绝不因为诊断把人搞挂"
|
|
1410
|
+
let why = null
|
|
1411
|
+
try { why = diagnoseRound(tail) } catch (e) { log(`诊断失败(已兜住):${e?.message ?? e}`) }
|
|
1412
|
+
if (why) log(`这一轮没跑起来:${why.title}`)
|
|
1413
|
+
const head = why
|
|
1414
|
+
? [
|
|
1415
|
+
`# 刷B站 · ${dayKey()} —— 这一轮没跑起来`,
|
|
1416
|
+
'',
|
|
1417
|
+
`> ⚠️ **${why.title}**(退出码 ${code})`,
|
|
1418
|
+
'',
|
|
1419
|
+
why.body,
|
|
1420
|
+
'',
|
|
1421
|
+
'---',
|
|
1422
|
+
'',
|
|
1423
|
+
'## 日志尾部(完整版在 `duty.log`)',
|
|
1424
|
+
'',
|
|
1425
|
+
]
|
|
1426
|
+
: [
|
|
1427
|
+
`# 刷B站 · ${dayKey()} —— 这一轮没写出报告`,
|
|
1428
|
+
'',
|
|
1429
|
+
`> ⚠️ 那一轮结束了,但没有生成正常报告(退出码 ${code})。`,
|
|
1430
|
+
'> 常见原因是它卡在某个工具上反复重试、或者把预算烧完了。下一轮它会重来。',
|
|
1431
|
+
'> 下面附日志尾部,方便定位(插件数据目录里的 `duty.log` 有完整版)。',
|
|
1432
|
+
'',
|
|
1433
|
+
]
|
|
1212
1434
|
writeFileSync(f, [
|
|
1213
|
-
|
|
1214
|
-
'',
|
|
1215
|
-
`> ⚠️ 那一轮结束了,但没有生成正常报告(退出码 ${code})。`,
|
|
1216
|
-
'> 常见原因是它卡在某个工具上反复重试、或者把预算烧完了。下一轮它会重来。',
|
|
1217
|
-
'> 下面附日志尾部,方便定位(插件数据目录里的 `duty.log` 有完整版)。',
|
|
1218
|
-
'',
|
|
1435
|
+
...head,
|
|
1219
1436
|
'```',
|
|
1220
|
-
String(tail).trim(),
|
|
1437
|
+
String(tail).trim().slice(-4000),
|
|
1221
1438
|
'```',
|
|
1222
1439
|
'',
|
|
1223
1440
|
].join('\n'), 'utf8')
|
|
@@ -1227,6 +1444,47 @@ function reportMeta() {
|
|
|
1227
1444
|
|
|
1228
1445
|
// ⭐⭐ 2.0.4(第三方体检第 2 条):**同一时间只允许一轮在跑**,并且**登记这个子进程**,
|
|
1229
1446
|
// 关页面/卸载插件时能把它一起收掉(以前它没有锁、也不归 ctx.effect 管 ✗)。
|
|
1447
|
+
// ⭐⭐ 2026-10-25:**跨宿主的运行锁**(issue 提到"没加原子锁"—— 那一半是真的)。
|
|
1448
|
+
// 同一台机器可能同时开着网页版和桌面版两个 DSH,两边都装了插件、都有自己的定时器:
|
|
1449
|
+
// 同一分钟两个都到点 ⇒ 都开跑 ⇒ **同一条视频被推两遍**,`seen.md` 也会多出重复行 ✗
|
|
1450
|
+
// (私信监听早就有 `dm-lock.json` 了,跑一轮这块一直只有"进程内"的标志位 ✗)
|
|
1451
|
+
// 设计:一张带 pid + 时间的纸条,谁先写成谁跑,另一个让开;10 分钟自动过期(防进程崩了锁死)。
|
|
1452
|
+
// ⚠️ **写不了锁就照常跑** —— 宁可偶尔重复一次,也不能因为锁坏了就不干活。
|
|
1453
|
+
const runLockFile = join(stateDir, 'run-lock.json')
|
|
1454
|
+
const RUN_LOCK_TTL = 10 * 60_000
|
|
1455
|
+
const pidAlive = (pid) => {
|
|
1456
|
+
if (!pid) return false
|
|
1457
|
+
try { process.kill(pid, 0); return true } catch { return false }
|
|
1458
|
+
}
|
|
1459
|
+
/** 试着占锁:拿到 ⇒ true;有人正占着(新鲜的、进程还活着、不是我)⇒ false */
|
|
1460
|
+
const acquireRunLock = (why) => {
|
|
1461
|
+
try {
|
|
1462
|
+
let lock = null
|
|
1463
|
+
try { lock = JSON.parse(readFileSync(runLockFile, 'utf8')) } catch { lock = null }
|
|
1464
|
+
if (lock && Number(lock.pid) !== process.pid) {
|
|
1465
|
+
const fresh = Number(lock.ts) > Date.now() - RUN_LOCK_TTL
|
|
1466
|
+
if (fresh && pidAlive(Number(lock.pid))) {
|
|
1467
|
+
log(`这一轮没跑(${why}):另一个宿主正在跑(pid=${lock.pid})⇒ 让给它`)
|
|
1468
|
+
return false
|
|
1469
|
+
}
|
|
1470
|
+
if (!fresh) log('运行锁已过期(' + Math.round((Date.now() - Number(lock.ts)) / 60000) + ' 分钟前那个),这一轮照跑')
|
|
1471
|
+
}
|
|
1472
|
+
writeFileSync(runLockFile, JSON.stringify({ pid: process.pid, ts: Date.now(), why }), 'utf8')
|
|
1473
|
+
return true
|
|
1474
|
+
} catch (e) {
|
|
1475
|
+
// 锁文件坏了/写不进去 ⇒ 不拦路(fail-open,但留痕)
|
|
1476
|
+
log('运行锁读写失败(这一轮照跑):' + String(e?.message ?? e))
|
|
1477
|
+
return true
|
|
1478
|
+
}
|
|
1479
|
+
}
|
|
1480
|
+
/** 放锁:只放自己的那张(别把别人的删了) */
|
|
1481
|
+
const releaseRunLock = () => {
|
|
1482
|
+
try {
|
|
1483
|
+
const lock = JSON.parse(readFileSync(runLockFile, 'utf8'))
|
|
1484
|
+
if (Number(lock?.pid) === process.pid) writeFileSync(runLockFile, '{"pid":0,"ts":0}', 'utf8')
|
|
1485
|
+
} catch { /* 没有锁 / 读不出来 / 写不进去都无所谓,靠 10 分钟过期兜底 */ }
|
|
1486
|
+
}
|
|
1487
|
+
|
|
1230
1488
|
let dutyChild = null
|
|
1231
1489
|
let dutyStartedAt = 0
|
|
1232
1490
|
const runDuty = (why = 'window') => {
|
|
@@ -1248,8 +1506,10 @@ function reportMeta() {
|
|
|
1248
1506
|
try { stubReportIfMissing(1) } catch { /* 补不上也不影响这句话已经进日志 */ }
|
|
1249
1507
|
return { started: false, why, error: msg }
|
|
1250
1508
|
}
|
|
1509
|
+
// ⭐ 跨宿主运行锁:拿到才跑(写不了锁时照常跑,函数内部已经 fail-open)
|
|
1510
|
+
if (!acquireRunLock(why)) return { started: false, why, busy: true, error: '另一个宿主正在跑这一轮' }
|
|
1251
1511
|
const overlay = ensureOverlay()
|
|
1252
|
-
|
|
1512
|
+
const args = [...dsh.args, '--profile', effectiveProfile()]
|
|
1253
1513
|
if (overlay) args.push('--patch', overlay)
|
|
1254
1514
|
args.push(promptFor())
|
|
1255
1515
|
// ⭐ 这一轮的工作目录 = 使用者(或别的用户)的**工作区** —— 只有这样它的 `AGENTS.md` 才会自动加载。
|
|
@@ -1291,6 +1551,7 @@ function reportMeta() {
|
|
|
1291
1551
|
child.stdout?.on('data', pick)
|
|
1292
1552
|
child.stderr?.on('data', pick)
|
|
1293
1553
|
child.on('exit', (code) => {
|
|
1554
|
+
releaseRunLock() // ⭐ 这一轮结束了,把锁放开(删不掉也没事,10 分钟自动过期)
|
|
1294
1555
|
log(`刷B站结束(${why},code=${code})`)
|
|
1295
1556
|
stubReportIfMissing(code) // ⭐ 没写报告就补一份占位 —— 别让使用者什么都看不到
|
|
1296
1557
|
// ⭐ 2026-10-20:这一轮跑成了、并且使用者选了收藏夹 ⇒ 把挑中的存进去(后台做,别拖住收尾)
|
|
@@ -1314,7 +1575,7 @@ function reportMeta() {
|
|
|
1314
1575
|
child.on('close', () => { if (dutyChild === child) dutyChild = null })
|
|
1315
1576
|
dutyChild = child
|
|
1316
1577
|
dutyStartedAt = Date.now()
|
|
1317
|
-
child.on('error', (e) => log(`刷B站起不来:${e?.message ?? e}`))
|
|
1578
|
+
child.on('error', (e) => { releaseRunLock(); log(`刷B站起不来:${e?.message ?? e}`) })
|
|
1318
1579
|
// ⭐ 起不来/被杀掉也要把锁放开,否则之后每一轮都会被判成'上一轮还没结束' ✗
|
|
1319
1580
|
child.on('close', () => { if (dutyChild === child) dutyChild = null })
|
|
1320
1581
|
return { started: true, why, profile: cfg.headlessProfile, cwd, log: logFile }
|
|
@@ -1404,6 +1665,46 @@ function reportMeta() {
|
|
|
1404
1665
|
} catch { return null }
|
|
1405
1666
|
}
|
|
1406
1667
|
|
|
1668
|
+
// ⭐⭐ 2.0.5(有人反馈"私信没有上下文" —— 属实):以前每条私信都是一次全新会话 ⇒ 它永远"初次见面" ✗
|
|
1669
|
+
// 现在固定**续同一个会话**:headless 支持 `--session-id <id>`,历史由 DSH 自己带着、超上限它自己压缩 ✓
|
|
1670
|
+
// 第一次跑完把开出来的会话 id 记到 dm-session.json;之后每次都带上;会话失效就丢掉重开(不会卡死)✓
|
|
1671
|
+
const dmSessionFile = join(stateDir, 'dm-session.json')
|
|
1672
|
+
const readDmSession = () => {
|
|
1673
|
+
try {
|
|
1674
|
+
const j = JSON.parse(readFileSync(dmSessionFile, 'utf8'))
|
|
1675
|
+
return j && typeof j.id === 'string' && j.id ? j.id : ''
|
|
1676
|
+
} catch { return '' }
|
|
1677
|
+
}
|
|
1678
|
+
const writeDmSession = (id) => { try { writeFileSync(dmSessionFile, JSON.stringify({ id, at: Date.now() }, null, 2), 'utf8') } catch { /* 写不了下次再认 */ } }
|
|
1679
|
+
// 跑完之后,从 $DSH_HOME/sessions/*/<id>/ 里找"这次新出现的那个"(mtime 晚于启动时刻)
|
|
1680
|
+
const discoverDmSession = (sinceTs) => {
|
|
1681
|
+
try {
|
|
1682
|
+
const root = join(process.env.DSH_HOME || join(process.env.USERPROFILE || '', '.dsh'), 'sessions')
|
|
1683
|
+
if (!existsSync(root)) return ''
|
|
1684
|
+
// ⚠️ 只认"这一轮工作目录 = 插件家"那个 workspace(否则会误认到自己聊天那条会话 ✗)
|
|
1685
|
+
// 命名规则:--<盘符去掉冒号>-<路径用横线拼>--(如 --C-Users-99496-.dsh-bili-duty--)
|
|
1686
|
+
const wantWs = '--' + stateDir.replace(/:/g, '').replace(/[\\/]/g, '-') + '--'
|
|
1687
|
+
let best = ''
|
|
1688
|
+
let bestAt = 0
|
|
1689
|
+
const scanWs = [wantWs].filter((w) => existsSync(join(root, w)))
|
|
1690
|
+
const list = scanWs.length ? scanWs : readdirSync(root)
|
|
1691
|
+
for (const ws of list) {
|
|
1692
|
+
const wsDir = join(root, ws)
|
|
1693
|
+
let dirs = []
|
|
1694
|
+
try { dirs = readdirSync(wsDir) } catch { continue }
|
|
1695
|
+
for (const d of dirs) {
|
|
1696
|
+
const full = join(wsDir, d)
|
|
1697
|
+
try {
|
|
1698
|
+
const st = statSync(full)
|
|
1699
|
+
if (!st.isDirectory()) continue
|
|
1700
|
+
if (st.mtimeMs >= sinceTs && st.mtimeMs > bestAt) { bestAt = st.mtimeMs; best = d }
|
|
1701
|
+
} catch { /* 跳过 */ }
|
|
1702
|
+
}
|
|
1703
|
+
}
|
|
1704
|
+
return best
|
|
1705
|
+
} catch { return '' }
|
|
1706
|
+
}
|
|
1707
|
+
|
|
1407
1708
|
function dmPromptFor(fromUid, text) {
|
|
1408
1709
|
const raw = String(text || '').slice(0, 600)
|
|
1409
1710
|
const bv = (raw.match(/BV[0-9A-Za-z]{10}/) || [])[0] || ''
|
|
@@ -1431,10 +1732,14 @@ function reportMeta() {
|
|
|
1431
1732
|
const dsh = resolveDsh()
|
|
1432
1733
|
if (dsh.missing) { log('私信回复没跑:找不到 dsh(' + dsh.hint + ')'); return { ok: false } }
|
|
1433
1734
|
const overlay = ensureOverlay()
|
|
1434
|
-
const
|
|
1435
|
-
if (overlay)
|
|
1436
|
-
|
|
1437
|
-
|
|
1735
|
+
const base = [...dsh.args, '--profile', effectiveProfile()]
|
|
1736
|
+
if (overlay) base.push('--patch', overlay)
|
|
1737
|
+
if (dmReplyRunning) { log('私信回复:上一条还在跑 ⇒ 这条等下一跳'); return { ok: false, busy: true } }
|
|
1738
|
+
dmReplyRunning = true
|
|
1739
|
+
const savedSid = readDmSession()
|
|
1740
|
+
const args = savedSid ? [...base, '--session-id', savedSid, dmPromptFor(fromUid, text)] : [...base, dmPromptFor(fromUid, text)]
|
|
1741
|
+
log(`私信回复开跑:来自 uid=${fromUid}` + (savedSid ? '(续会话 ' + savedSid.slice(0, 20) + '…)' : '(开新会话)'))
|
|
1742
|
+
const startedAt = Date.now()
|
|
1438
1743
|
const child = spawn(dsh.command, args, {
|
|
1439
1744
|
cwd: stateDir, shell: dsh.shell, windowsHide: true, stdio: ['ignore', 'pipe', 'pipe'],
|
|
1440
1745
|
env: uvEnv({ BILI_DUTY_HOME: stateDir, ...(process.versions.electron ? { ELECTRON_RUN_AS_NODE: '1' } : {}) }),
|
|
@@ -1443,13 +1748,31 @@ function reportMeta() {
|
|
|
1443
1748
|
const pick = (buf) => { try { appendFileSync(logFile, toText(buf), 'utf8') } catch { /* 丢日志也不能抛 */ } }
|
|
1444
1749
|
child.stdout?.on('data', pick)
|
|
1445
1750
|
child.stderr?.on('data', pick)
|
|
1446
|
-
child.on('exit', (code) => {
|
|
1751
|
+
child.on('exit', (code) => {
|
|
1752
|
+
dmReplyRunning = false
|
|
1753
|
+
log(`私信回复结束(code=${code})`)
|
|
1754
|
+
rotateLogIfBig()
|
|
1755
|
+
// ① 本来没有会话 id:跑完把它开出来的那个认领下来,下一条就能接着聊
|
|
1756
|
+
if (!savedSid && code === 0) {
|
|
1757
|
+
const id = discoverDmSession(startedAt)
|
|
1758
|
+
if (id) { writeDmSession(id); log('私信会话已记录:' + id.slice(0, 24) + '…(以后私信都接着这个会话)') }
|
|
1759
|
+
else log('⚠️ 没找到这次的会话 id ⇒ 下一条私信还是新会话(不影响回复,只是没上下文)')
|
|
1760
|
+
}
|
|
1761
|
+
// ② 带了 id 却失败(会话被删/迁移过)⇒ 丢掉它,下一条重开,别卡死在坏 id 上
|
|
1762
|
+
if (savedSid && code !== 0) {
|
|
1763
|
+
writeDmSession('')
|
|
1764
|
+
log('⚠️ 续会话失败(' + savedSid.slice(0, 16) + '…)⇒ 清掉 id,立刻用新会话重试一次(这条不能没回)')
|
|
1765
|
+
setTimeout(() => { try { runDmReply(fromUid, text) } catch { /* 兜住 */ } }, 800)
|
|
1766
|
+
}
|
|
1767
|
+
})
|
|
1447
1768
|
return { ok: true }
|
|
1448
1769
|
} catch (e) { log('私信回复出错:' + String(e?.message ?? e)); return { ok: false } }
|
|
1449
1770
|
}
|
|
1450
1771
|
|
|
1451
1772
|
let dmTimer = null
|
|
1452
1773
|
let dmBusy = false
|
|
1774
|
+
// ⭐ 2.0.5:一条私信回复没跑完时,不要让下一条进来(不然两个进程抢同一个会话,DSH 会报 "already owned by an active write handle")
|
|
1775
|
+
let dmReplyRunning = false
|
|
1453
1776
|
async function dmTick() {
|
|
1454
1777
|
if (dmBusy) return
|
|
1455
1778
|
dmBusy = true
|
|
@@ -2087,6 +2410,8 @@ function reportMeta() {
|
|
|
2087
2410
|
pickedToday: countPickedToday(), // 今天挑到几条(从报告里数)
|
|
2088
2411
|
lastRunAt: st.lastRunAt || null,
|
|
2089
2412
|
lastWhy: st.lastWhy || null,
|
|
2413
|
+
// ⭐ 2.0.4:界面上"它会认谁当主人"那行用这个(用小号登录、大号聊天的会看错)
|
|
2414
|
+
ownerInfo: ownerInfo(),
|
|
2090
2415
|
loggedIn: hasCredential(),
|
|
2091
2416
|
venvReady: venvReadyNow(),
|
|
2092
2417
|
version: myVersion(),
|
|
@@ -2403,7 +2728,7 @@ function reportMeta() {
|
|
|
2403
2728
|
'版本:' + myVersion(),
|
|
2404
2729
|
'平台:' + process.platform + ' ' + process.arch + ' · node ' + process.versions.node + (process.versions.electron ? ' · electron ' + process.versions.electron : ''),
|
|
2405
2730
|
'dsh:' + (() => { try { const d = resolveDsh(); return d.missing ? '没找到(' + d.hint + ')' : d.command + ' ' + d.args.join(' ') } catch (e) { return '探测失败:' + e?.message } })(),
|
|
2406
|
-
'headless profile:' + (
|
|
2731
|
+
'headless profile:' + effectiveProfile() + '(最小权限:' + dutyProfileState + ')',
|
|
2407
2732
|
'pythonPath:' + cfg.pythonPath,
|
|
2408
2733
|
'venv 就绪:' + (venvReadyNow() ? '是' : '否') + (missingVenvDeps().length ? '(缺:' + missingVenvDeps().join(' / ') + ')' : ''),
|
|
2409
2734
|
'转写环境:' + (existsSync(join(stateDir, '.venv-asr')) ? '在' : '没装'),
|
|
@@ -2516,37 +2841,101 @@ function reportMeta() {
|
|
|
2516
2841
|
return sendJson(res, 404, { ok: false, error: `unknown ${req.method} ${path}` })
|
|
2517
2842
|
}
|
|
2518
2843
|
|
|
2519
|
-
try {
|
|
2520
2844
|
const sameOriginOk = (req) => {
|
|
2521
2845
|
try {
|
|
2522
2846
|
const host = String(req.headers.host || '')
|
|
2523
2847
|
if (!host) return true // 没有 Host 头 = 不是浏览器/不是外来的 HTTP 请求
|
|
2524
2848
|
if (!/^(127\.0\.0\.1|localhost|\[::1\])(:\d+)?$/i.test(host)) return false
|
|
2525
2849
|
const pick = String(req.headers.origin || req.headers.referer || '')
|
|
2526
|
-
if (!pick) return true //
|
|
2850
|
+
if (!pick) return true // 不是浏览器发的(本机脚本/宿主内部)
|
|
2527
2851
|
try {
|
|
2528
2852
|
const u = new URL(pick)
|
|
2529
|
-
|
|
2853
|
+
// ⭐ 2.0.5(采纳 PR #94):**端口也要比** —— 只比 hostname 的话,
|
|
2854
|
+
// 本机上另一个端口的页面(dev server / Jupyter / 有 XSS 的本地页)也能驱动这些写接口 ✗
|
|
2855
|
+
const originAuthority = String(u.host || '').toLowerCase()
|
|
2856
|
+
const hostAuthority = String(host).toLowerCase()
|
|
2857
|
+
if (originAuthority && originAuthority === hostAuthority) return true
|
|
2858
|
+
const defaultPort = u.protocol === 'https:' ? '443' : '80'
|
|
2859
|
+
const withPort = (u.hostname + ':' + (u.port || defaultPort)).toLowerCase()
|
|
2860
|
+
return withPort === hostAuthority
|
|
2530
2861
|
} catch { return false }
|
|
2531
2862
|
} catch { return false }
|
|
2532
2863
|
}
|
|
2864
|
+
|
|
2865
|
+
// ⭐⭐ 2.0.5(issue #88):**走 DSH 自己的信任栅栏** —— 隧道把 Host 改写成回环时,
|
|
2866
|
+
// 我们那层"本机同源"会被绕过;connection.requestRejection 才是真正的鉴权(认 cookie/token)✓
|
|
2867
|
+
// 取法照 DSH 内部写法:Reflect.get(ctx, "connection") ⇒ requestRejection(req)
|
|
2868
|
+
// 返回 undefined = 放行;返回数字 = 回这个状态码(通常 401)
|
|
2869
|
+
// ⚠️ 栅栏不可用时不静默放行:仍要求过"本机同源",两道都不成 ⇒ 拒 ✓
|
|
2870
|
+
let fenceWarned = false
|
|
2871
|
+
// ⭐ 取 connection 的三种写法都试(实测:Reflect.get 在插件 ctx 上拿不到 ⇒ 2026-10-23 真机验证后发现)
|
|
2872
|
+
let nodeConnCached = null
|
|
2873
|
+
let nodeConnTried = false
|
|
2874
|
+
const getConnection = () => {
|
|
2875
|
+
if (nodeConnTried) return nodeConnCached
|
|
2876
|
+
nodeConnTried = true
|
|
2877
|
+
const tries = [
|
|
2878
|
+
['ctx.get("connection")', () => (typeof ctx.get === 'function' ? ctx.get('connection') : null)],
|
|
2879
|
+
['Reflect.get(ctx, "connection")', () => Reflect.get(ctx, 'connection')],
|
|
2880
|
+
['ctx.root.connection', () => (ctx.root && typeof ctx.root === 'object' ? ctx.root.connection : null)],
|
|
2881
|
+
]
|
|
2882
|
+
for (const [how, fn] of tries) {
|
|
2883
|
+
try {
|
|
2884
|
+
const c = fn()
|
|
2885
|
+
if (c && typeof c.requestRejection === 'function') {
|
|
2886
|
+
nodeConnCached = c
|
|
2887
|
+
log('信任栅栏:用 ' + how + ' 拿到了 connection 服务 ✓')
|
|
2888
|
+
return c
|
|
2889
|
+
}
|
|
2890
|
+
} catch { /* 换下一个 */ }
|
|
2891
|
+
}
|
|
2892
|
+
log('⚠️ 三种写法都拿不到 connection.requestRejection ⇒ 退回"本机同源"这一道')
|
|
2893
|
+
return null
|
|
2894
|
+
}
|
|
2895
|
+
const fenceReject = (req, res) => {
|
|
2896
|
+
const conn = getConnection()
|
|
2897
|
+
if (!conn) {
|
|
2898
|
+
if (!fenceWarned) { fenceWarned = true; log("⚠️ 拿不到 DSH 信任栅栏(connection.requestRejection)⇒ 退回本机同源这一道") }
|
|
2899
|
+
return false
|
|
2900
|
+
}
|
|
2901
|
+
try {
|
|
2902
|
+
// ⭐ 2.0.5(采纳 PR #94):契约入参是 `{ headers }`(ConnectionTrustRequest),不是原始 IncomingMessage;
|
|
2903
|
+
// 两种形状都给一遍,谁的实现都能接住(实测传原始 req 也能过,但按契约来更稳)
|
|
2904
|
+
const rejection = conn.requestRejection({ headers: req.headers })
|
|
2905
|
+
if (rejection === undefined) return false
|
|
2906
|
+
res.statusCode = typeof rejection === "number" ? rejection : 403
|
|
2907
|
+
try { res.end() } catch { /* 忽略 */ }
|
|
2908
|
+
return true
|
|
2909
|
+
} catch (err) {
|
|
2910
|
+
log("信任栅栏调用出错,按不通过处理:" + String(err?.message ?? err))
|
|
2911
|
+
res.statusCode = 403
|
|
2912
|
+
try { res.end() } catch { /* 忽略 */ }
|
|
2913
|
+
return true
|
|
2914
|
+
}
|
|
2915
|
+
}
|
|
2916
|
+
|
|
2917
|
+
try {
|
|
2533
2918
|
ctx.effect(() => ctx.webServer.register({
|
|
2534
2919
|
kind: 'prefix',
|
|
2535
2920
|
path: '/api/bili-duty',
|
|
2536
|
-
// ⭐ 2026-10-23 安全加固(审计点名"未鉴权的控制接口"):**只接受本机同源请求** ——
|
|
2537
|
-
// 别的网页在你浏览器里也能打到 127.0.0.1,所以要比对 Host 与本机、Origin/Referer 与同源 ✓
|
|
2538
2921
|
handler: async (req, res) => {
|
|
2539
|
-
|
|
2540
|
-
|
|
2541
|
-
|
|
2542
|
-
|
|
2543
|
-
|
|
2544
|
-
|
|
2922
|
+
// ① 先过 DSH 的信任栅栏(真鉴权)
|
|
2923
|
+
if (fenceReject(req, res)) return
|
|
2924
|
+
// ② 再过本机同源(栅栏拿不到时的兜底,也挡浏览器里别的网页)
|
|
2925
|
+
if (!sameOriginOk(req)) {
|
|
2926
|
+
try { sendJson(res, 403, { ok: false, error: '只接受本机同源请求' }) } catch { /* 忽略 */ }
|
|
2927
|
+
return
|
|
2928
|
+
}
|
|
2929
|
+
try { await handle(req, res, new URL(req.url, 'http://127.0.0.1')) } catch (err) {
|
|
2930
|
+
sendJson(res, 500, { ok: false, error: err?.message ?? String(err) })
|
|
2545
2931
|
}
|
|
2546
2932
|
},
|
|
2547
2933
|
}), 'bili-duty: /api/bili-duty 路由')
|
|
2548
2934
|
} catch (e) { log(`HTTP 路由没挂上:${e?.message ?? e}`) }
|
|
2549
2935
|
|
|
2550
2936
|
ctx.effect(() => () => { try { mcp?.stop() } catch { /* 收摊 */ } }, 'bili-duty: 收 MCP 子进程')
|
|
2937
|
+
try { ensureDutyProfile() } catch { /* 上面已记日志 */ }
|
|
2938
|
+
if (dutyProfileState === 'ok') log('值班轮用最小权限 profile:bili-duty(无文件系统 / 无命令 / 无联网)')
|
|
2939
|
+
else log('⚠️ 最小权限 profile 不可用(' + dutyProfileState + ')⇒ 值班轮仍用 headless(这一轮能力偏大)')
|
|
2551
2940
|
log(`已挂载(家=${stateDir},python=${cfg.pythonPath},峰谷=${cfg.peakValley ? '开' : '关'})`)
|
|
2552
2941
|
}
|