dsh-pisces 0.2.6 → 0.2.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 +16 -1
- package/README.md +14 -5
- package/THIRD-PARTY-NOTICES.md +12 -3
- package/client.js +109 -13
- package/index.js +86 -5
- package/lib/attach.js +1 -1
- package/lib/bili.js +179 -4
- package/lib/biliqr.js +341 -0
- package/lib/console.js +164 -1
- package/lib/qr.js +450 -0
- package/lib/settings.js +3 -11
- package/package.json +2 -2
package/CHANGELOG.md
CHANGED
|
@@ -1,7 +1,22 @@
|
|
|
1
|
-
|
|
1
|
+
# 更新记录
|
|
2
2
|
|
|
3
3
|
本项目遵循语义化版本;`0.x` 期间小版本可能带行为变化。
|
|
4
4
|
|
|
5
|
+
## 0.2.7
|
|
6
|
+
|
|
7
|
+
- 补完 B 站登录:设置页的「扫码登录(下一步接)」换成真的登录卡。
|
|
8
|
+
- **手机扫码**(默认):宿主现画二维码(`lib/qr.js`,零依赖),轮询到「扫到了」→「确认登录」,
|
|
9
|
+
先写清「登的是 谁(uid)」,人点「就是这个号,保存」才落盘。码 3 分钟过期,可换一张。
|
|
10
|
+
- **粘贴 cookie**(退路):`SESSDATA` / `bili_jct`,后端用 `nav` 接口验一次再落盘 `owner.json`
|
|
11
|
+
(0600 原子写,失败一个字节都不写)。
|
|
12
|
+
- 扫码登录移植自 dsh-fish(MIT,@Bacon-01 贡献):`lib/qr.js` 原样、`lib/biliqr.js` 按本项目改写,
|
|
13
|
+
来源与许可写进 `THIRD-PARTY-NOTICES.md`。cookie 原文只活在宿主内存里,页面与日志都拿不到。
|
|
14
|
+
- 新增 `/bili` 接口:`GET` 只回「有没有登录 / 名字」,`POST {cookie}` 验证并保存,`DELETE` 清掉;
|
|
15
|
+
扫码四个动作在 `/bili/qr`、`/bili/qr/save`、`/bili/qr/drop`。
|
|
16
|
+
- 去掉 settings 里只读的 `bili.qrLogin` 占位项(它永远不会变成 logged-in,登录态现在由 `owner.json` 决定)。
|
|
17
|
+
- 面板:B 站与百度贴吧共用同一张登录卡(`CookieCard`);扫码只给 B 站,贴吧不做扫码。
|
|
18
|
+
- 覆盖登录态前先把旧的留成 `owner.json.bak`:点错、扫错号还能翻回来(留备份失败不拦写盘)。
|
|
19
|
+
|
|
5
20
|
## 0.2.6
|
|
6
21
|
|
|
7
22
|
- 好感封顶:`utterance` 累计 ≤15、`heard` ≤20、`diary` ≤10。之前把旧数据一次灌进来会直接顶到 100(5 天就「离不开」),现在 705 条、4 天算下来是 47(亲近)。
|
package/README.md
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
|
|
1
|
+
# 鱼肥生双(dsh-pisces)
|
|
2
2
|
|
|
3
3
|
一只会长大的本地小鱼,DSH 插件。事件流记忆、渐进揭开、好感、新手引导;派希丝是养成目标。
|
|
4
4
|
|
|
@@ -22,6 +22,15 @@ dsh plugin add dsh-pisces
|
|
|
22
22
|
- 工具面:普通鱼 7 个动作;成年鱼 12 个(额外自查 / 教人 / 引导 / 体检 / 昵称)。
|
|
23
23
|
- 领养之后需要重启一次,工具面才会多出成年鱼的动作。
|
|
24
24
|
|
|
25
|
+
## 登录(可选)
|
|
26
|
+
|
|
27
|
+
- **设置 → 鱼肥生双 → 设置**里,B 站和百度贴吧各有一张卡。
|
|
28
|
+
- B 站:点「用手机扫码登录」,手机 B 站扫一下、在手机上点「确认登录」,卡片上会先写清「登的是谁」,
|
|
29
|
+
再点「就是这个号,保存」才写盘。不想扫码的话,另一格「粘贴 cookie」贴 `SESSDATA` / `bili_jct` 也行。
|
|
30
|
+
- 贴吧:把 `BDUSS` / `STOKEN` 贴进去,验过才存。
|
|
31
|
+
- 只存在本机(B 站 `owner.json`、贴吧 `baidu.json`,0600);面板、接口、日志都不回显 cookie,验证不过一个字节都不写。
|
|
32
|
+
- 不需要登录也能用:看热门是公开接口;说话、贴吧相关动作要登录。
|
|
33
|
+
|
|
25
34
|
## 观测台(可选)
|
|
26
35
|
|
|
27
36
|
- 地址:`http://127.0.0.1:<端口>/dsh-pisces-api/`(端口就是 DSH 自己的端口)。
|
|
@@ -33,12 +42,12 @@ dsh plugin add dsh-pisces
|
|
|
33
42
|
|
|
34
43
|
- 所有数据都在本机:事件流(SQLite 或 JSONL)、日记、人设卡、问题、梦、学到的东西。
|
|
35
44
|
- 插件不上传、不收集统计,也没有遥测。
|
|
36
|
-
- 只有你主动触发的联网动作才会出网:B
|
|
45
|
+
- 只有你主动触发的联网动作才会出网:B 站公开接口(看热门、说一句)、登录时各验一次(B 站 `nav` / 贴吧 `tbs`)、自学搜索(需要 key)、夜间模型(本地或你自己配的)。
|
|
37
46
|
- 发布版不含开发者钥匙,也没有 `/devkey` 接口;本机路径、Cookie、私聊都不会进发布包。
|
|
38
47
|
|
|
39
48
|
## 鸣谢
|
|
40
49
|
|
|
41
|
-
|
|
50
|
+
这些是**思路借鉴**,不是代码依赖;除 B 站扫码登录(`lib/qr.js` / `lib/biliqr.js` 移植自 dsh-fish)外,每个项目的代码各自独立,本仓库没有复制它们的实现。完整的来源说明见 [THIRD-PARTY-NOTICES.md](./THIRD-PARTY-NOTICES.md);版本变化见 [CHANGELOG.md](./CHANGELOG.md)。
|
|
42
51
|
|
|
43
52
|
- **dsh-fish** —— 事件流 + 纯函数派生 + 面板形态,本项目的骨架从它长出来。npm:<https://www.npmjs.com/package/@ssamurias/dsh-fish>
|
|
44
53
|
- **dsh-fish-memory** —— 记忆鲜活度与半衰期、中文检索。
|
|
@@ -50,5 +59,5 @@ dsh plugin add dsh-pisces
|
|
|
50
59
|
- **所有养过鱼的人** —— 社区里"把 AI 当室友养"的尝试。
|
|
51
60
|
|
|
52
61
|
如果你觉得某个想法该被写在这里,发反馈告诉我们(反馈页有加群方式)。
|
|
53
|
-
|
|
54
|
-
|
|
62
|
+
|
|
63
|
+
|
package/THIRD-PARTY-NOTICES.md
CHANGED
|
@@ -5,12 +5,21 @@
|
|
|
5
5
|
|
|
6
6
|
## 代码来源
|
|
7
7
|
|
|
8
|
-
|
|
9
|
-
|
|
8
|
+
`lib/` 下的实现(事件流、派生、分流、双流、人设卡八道闸、夜间流水线、渐进揭开、好感推导、
|
|
9
|
+
新手引导等)均在本项目中新写,插件没有任何运行时依赖。**唯一的例外是 B 站扫码登录**:
|
|
10
|
+
|
|
11
|
+
- `lib/qr.js`:**原样移植**自 dsh-fish 的 `lib/qr.js`(MIT,Copyright (c) 2026 大肥鱼(dsh-fish)作者)。
|
|
12
|
+
零依赖二维码编码器,由 @Bacon-01 贡献(dsh-fish PR #17)。
|
|
13
|
+
- `lib/biliqr.js`:**改写移植**自 dsh-fish 的 `lib/biliqr.js`(同一 MIT 许可与版权)。
|
|
14
|
+
状态机、Set-Cookie 解析与轮询节流沿用原实现;落盘改走本项目的 `readBili`/`writeBili`,
|
|
15
|
+
认账号改走 `checkBili`,并要求 `bili_jct` 才允许保存。
|
|
16
|
+
- 两个文件顶部都保留了来源说明。dsh-fish 的 MIT 全文见
|
|
17
|
+
<https://github.com/ssamurias/dsh-fish/blob/main/LICENSE>。
|
|
18
|
+
- 其余部分(`lib/bili.js` 的热门/评论、`lib/baidu.js` 的贴吧登录)为本项目新写。
|
|
10
19
|
|
|
11
20
|
## 思想引用
|
|
12
21
|
|
|
13
|
-
|
|
22
|
+
以下项目影响了本项目的设计,但除上面写明的扫码登录外,**没有复制它们的代码或文本**,实现均为重新编写:
|
|
14
23
|
|
|
15
24
|
- **dsh-fish**(@ssamurias/dsh-fish,MIT)—— 事件流 + 纯函数派生 + 设置面板形态。
|
|
16
25
|
- **dsh-fish-memory** —— 记忆鲜活度与半衰期、中文双字组检索。
|
package/client.js
CHANGED
|
@@ -533,8 +533,6 @@ window.__ModuleLoader__.load({
|
|
|
533
533
|
h('p', { className: data ? 'err' : 'empty' }, data ? `设置没读到:${data.settingsError}` : '正在读设置…'))
|
|
534
534
|
}
|
|
535
535
|
|
|
536
|
-
const bili = cur.bili || {}
|
|
537
|
-
const logged = bili.qrLogin === 'logged-in'
|
|
538
536
|
const set = (path, value) => setDraft((d) => ({ ...d, [path]: value }))
|
|
539
537
|
|
|
540
538
|
async function save() {
|
|
@@ -597,27 +595,43 @@ window.__ModuleLoader__.load({
|
|
|
597
595
|
h('div', { className: 'row' }, h('button', { type: 'button', disabled: busy, onClick: save }, '保存设置')),
|
|
598
596
|
note ? h('p', { className: note === '存好了,重启后生效' ? 'hint' : 'err' }, note) : null,
|
|
599
597
|
),
|
|
600
|
-
h(
|
|
601
|
-
h('p', { className: 'hint' }, `登录状态:${logged ? '已登录 _小苍兰_' : '未登录'}`),
|
|
602
|
-
h('div', { className: 'row' }, h('button', { type: 'button', disabled: true }, '扫码登录(下一步接)'))),
|
|
598
|
+
h(BiliCard, null),
|
|
603
599
|
h(BaiduCard, null),
|
|
604
600
|
)
|
|
605
601
|
}
|
|
606
602
|
|
|
607
|
-
//
|
|
608
|
-
|
|
603
|
+
// 登录卡(B 站 / 百度贴吧共用):扫码或粘贴 cookie → 后端验证 → 状态就地刷新。
|
|
604
|
+
// B 站那张多给一条扫码路:二维码由宿主现画(lib/qr.js),页面只拿 SVG 和「登的是谁」。
|
|
605
|
+
function CookieCard({ title, api, placeholder, hint, qr }) {
|
|
609
606
|
const [status, setStatus] = useState(null)
|
|
610
607
|
const [text, setText] = useState('')
|
|
611
608
|
const [busy, setBusy] = useState(false)
|
|
612
609
|
const [note, setNote] = useState('')
|
|
610
|
+
const [q, setQ] = useState(null)
|
|
613
611
|
|
|
614
612
|
useEffect(() => {
|
|
615
613
|
let alive = true
|
|
616
|
-
getJson(
|
|
614
|
+
getJson(api)
|
|
617
615
|
.then((d) => { if (alive) setStatus(d) })
|
|
618
616
|
.catch(() => { if (alive) setStatus({ loggedIn: false }) })
|
|
619
617
|
return () => { alive = false }
|
|
620
|
-
}, [])
|
|
618
|
+
}, [api])
|
|
619
|
+
|
|
620
|
+
const key = q && q.key ? String(q.key) : ''
|
|
621
|
+
const state = q && q.state ? String(q.state) : ''
|
|
622
|
+
const polling = Boolean(key) && (state === 'waiting' || state === 'scanned')
|
|
623
|
+
|
|
624
|
+
// 轮询只在这一张码还活着的时候跑;状态变了(confirmed/expired)就停。
|
|
625
|
+
useEffect(() => {
|
|
626
|
+
if (!polling) return undefined
|
|
627
|
+
let alive = true
|
|
628
|
+
const timer = setInterval(() => {
|
|
629
|
+
getJson(`${api}/qr?key=${encodeURIComponent(key)}`)
|
|
630
|
+
.then((r) => { if (alive) setQ((prev) => (prev && prev.key === key ? { ...prev, ...r } : prev)) })
|
|
631
|
+
.catch(() => {})
|
|
632
|
+
}, 1200)
|
|
633
|
+
return () => { alive = false; clearInterval(timer) }
|
|
634
|
+
}, [api, key, polling])
|
|
621
635
|
|
|
622
636
|
const logged = !!(status && status.loggedIn)
|
|
623
637
|
const name = status && status.name ? String(status.name) : ''
|
|
@@ -627,7 +641,7 @@ window.__ModuleLoader__.load({
|
|
|
627
641
|
setBusy(true)
|
|
628
642
|
setNote('')
|
|
629
643
|
try {
|
|
630
|
-
const r = await postJson(
|
|
644
|
+
const r = await postJson(api, { cookie: text })
|
|
631
645
|
setStatus(r)
|
|
632
646
|
setText('')
|
|
633
647
|
setNote(r.name ? `登上了:${r.name}` : '登上了')
|
|
@@ -638,22 +652,104 @@ window.__ModuleLoader__.load({
|
|
|
638
652
|
}
|
|
639
653
|
}
|
|
640
654
|
|
|
655
|
+
async function startQr() {
|
|
656
|
+
if (busy) return
|
|
657
|
+
setBusy(true)
|
|
658
|
+
setNote('')
|
|
659
|
+
try {
|
|
660
|
+
setQ({ ...(await postJson(`${api}/qr`, {})), state: 'waiting' })
|
|
661
|
+
} catch (e) {
|
|
662
|
+
setNote(String((e && e.message) || e))
|
|
663
|
+
} finally {
|
|
664
|
+
setBusy(false)
|
|
665
|
+
}
|
|
666
|
+
}
|
|
667
|
+
|
|
668
|
+
async function saveQr() {
|
|
669
|
+
if (busy || !key) return
|
|
670
|
+
setBusy(true)
|
|
671
|
+
try {
|
|
672
|
+
const r = await postJson(`${api}/qr/save`, { key })
|
|
673
|
+
setStatus(r)
|
|
674
|
+
setQ(null)
|
|
675
|
+
setNote(r.name ? `登上了:${r.name}` : '登上了')
|
|
676
|
+
} catch (e) {
|
|
677
|
+
setNote(String((e && e.message) || e))
|
|
678
|
+
} finally {
|
|
679
|
+
setBusy(false)
|
|
680
|
+
}
|
|
681
|
+
}
|
|
682
|
+
|
|
683
|
+
function dropQr() {
|
|
684
|
+
const k = key
|
|
685
|
+
setQ(null)
|
|
686
|
+
if (k) postJson(`${api}/qr/drop`, { key: k }).catch(() => {})
|
|
687
|
+
}
|
|
688
|
+
|
|
689
|
+
const qrLine = !q
|
|
690
|
+
? ''
|
|
691
|
+
: state === 'confirmed'
|
|
692
|
+
? (q.account && q.account.name
|
|
693
|
+
? `登的是 ${q.account.name}${q.account.mid ? `(uid ${q.account.mid})` : ''}`
|
|
694
|
+
: '扫码成功,认不出是哪个号')
|
|
695
|
+
: state === 'scanned'
|
|
696
|
+
? '扫到了,在手机上点一下「确认登录」'
|
|
697
|
+
: state === 'expired'
|
|
698
|
+
? '二维码过期了,点「换一张」'
|
|
699
|
+
: (q.why || '用手机 B 站扫一下')
|
|
700
|
+
|
|
641
701
|
return h(
|
|
642
702
|
Card,
|
|
643
|
-
{ title
|
|
703
|
+
{ title, right: 'Cookie' },
|
|
644
704
|
h('p', { className: 'hint' }, `登录状态:${logged ? (name ? `已登录 ${name}` : '已登录') : '未登录'}`),
|
|
705
|
+
qr ? h('div', { className: 'row' }, h('button', { type: 'button', disabled: busy, onClick: startQr }, q ? '换一张' : '用手机扫码登录')) : null,
|
|
706
|
+
q ? h('div', { className: 'qr' },
|
|
707
|
+
q.svg ? h('img', {
|
|
708
|
+
className: 'qrimg',
|
|
709
|
+
src: `data:image/svg+xml;charset=utf-8,${encodeURIComponent(String(q.svg))}`,
|
|
710
|
+
alt: '登录二维码',
|
|
711
|
+
width: 180,
|
|
712
|
+
height: 180,
|
|
713
|
+
}) : null,
|
|
714
|
+
h('p', { className: 'hint' }, qrLine),
|
|
715
|
+
h('div', { className: 'row' },
|
|
716
|
+
state === 'confirmed' ? h('button', { type: 'button', disabled: busy, onClick: saveQr }, '就是这个号,保存') : null,
|
|
717
|
+
h('button', { type: 'button', onClick: dropQr }, '收起'),
|
|
718
|
+
),
|
|
719
|
+
) : null,
|
|
645
720
|
h('textarea', {
|
|
646
721
|
value: text,
|
|
647
722
|
rows: 3,
|
|
648
|
-
placeholder
|
|
723
|
+
placeholder,
|
|
649
724
|
onChange: (e) => setText(e.target.value),
|
|
650
725
|
}),
|
|
651
726
|
h('div', { className: 'row' }, h('button', { type: 'button', disabled: busy, onClick: save }, '保存并验证')),
|
|
652
727
|
note ? h('p', { className: note.startsWith('登上了') ? 'hint' : 'err' }, note) : null,
|
|
653
|
-
h('p', { className: 'hint' },
|
|
728
|
+
h('p', { className: 'hint' }, hint),
|
|
654
729
|
)
|
|
655
730
|
}
|
|
656
731
|
|
|
732
|
+
// B 站登录:手机扫码为主,粘贴 SESSDATA/bili_jct 是退路;登录态落 owner.json。
|
|
733
|
+
function BiliCard() {
|
|
734
|
+
return h(CookieCard, {
|
|
735
|
+
title: 'B 站',
|
|
736
|
+
api: '/bili',
|
|
737
|
+
qr: true,
|
|
738
|
+
placeholder: '把浏览器的 SESSDATA=…; bili_jct=… 贴进来',
|
|
739
|
+
hint: '不想扫码:浏览器 F12 → Application → Cookies → bilibili.com → 复制 SESSDATA 和 bili_jct',
|
|
740
|
+
})
|
|
741
|
+
}
|
|
742
|
+
|
|
743
|
+
// 百度贴吧登录:粘贴 BDUSS/STOKEN;只影响贴吧,不碰 B 站。
|
|
744
|
+
function BaiduCard() {
|
|
745
|
+
return h(CookieCard, {
|
|
746
|
+
title: '百度贴吧',
|
|
747
|
+
api: '/baidu',
|
|
748
|
+
placeholder: '把浏览器的 BDUSS=…; STOKEN=… 贴进来',
|
|
749
|
+
hint: '浏览器 F12 → Application → Cookies → tieba.baidu.com → 复制 BDUSS 和 STOKEN',
|
|
750
|
+
})
|
|
751
|
+
}
|
|
752
|
+
|
|
657
753
|
// -------------------------------------------------------------------------
|
|
658
754
|
// 页签:心理活动
|
|
659
755
|
// -------------------------------------------------------------------------
|
package/index.js
CHANGED
|
@@ -52,7 +52,8 @@ import { nicknameOf, setNickname as writeNickname } from './lib/nickname.js'
|
|
|
52
52
|
import { clearFixedOverride, readFixedOverride, writeFixedOverride } from './lib/personafile.js'
|
|
53
53
|
import { ensureHomeWithRetry, HOME_FILE } from './lib/home.js'
|
|
54
54
|
import { SCHEMA as SETTINGS_SCHEMA, mergeConfig, publicSettings, readSettings, writeSettings } from './lib/settings.js'
|
|
55
|
-
import { hot } from './lib/bili.js'
|
|
55
|
+
import { clearBili, hot, loginBili, readBili } from './lib/bili.js'
|
|
56
|
+
import { createQrLogin } from './lib/biliqr.js'
|
|
56
57
|
import { clearBaidu, loginBaidu, readBaidu } from './lib/baidu.js'
|
|
57
58
|
import { speak as speakOnBili } from './lib/speak.js'
|
|
58
59
|
import { guideFor as teachFor } from './lib/teach.js'
|
|
@@ -235,6 +236,9 @@ export function apply(ctx, config = {}) {
|
|
|
235
236
|
if (voiceAdapter?.ok) log(`语音回复:已接 ${voiceAdapter.kind}`)
|
|
236
237
|
else log('语音回复:未接适配器(休眠)')
|
|
237
238
|
|
|
239
|
+
// B 站扫码登录:key 与 cookie 只活在内存里,停用时清掉(不落盘、不进日志)。
|
|
240
|
+
const qrLogin = createQrLogin({ log })
|
|
241
|
+
|
|
238
242
|
let closed = false
|
|
239
243
|
let disposed = false
|
|
240
244
|
let store = null
|
|
@@ -659,7 +663,7 @@ export function apply(ctx, config = {}) {
|
|
|
659
663
|
* 失败只回 {ok:false, reason}、只 log 一行,不阻塞启动(宿主没有 workspaceRegistry 也照跑)。
|
|
660
664
|
*/
|
|
661
665
|
async function ensureHomeOnce() {
|
|
662
|
-
if (cfg.home?.enabled === false) return { ok: false, reason: '家没开(config.home.enabled=false)' }
|
|
666
|
+
if (cfg.home?.enabled === false || cfg.home?.auto === false) return { ok: false, reason: '家没开(config.home.enabled=false 或 home.auto=false)' }
|
|
663
667
|
let fish = null
|
|
664
668
|
try {
|
|
665
669
|
const s = await live()
|
|
@@ -1144,6 +1148,42 @@ export function apply(ctx, config = {}) {
|
|
|
1144
1148
|
return { ok: true, removed: clearBaidu(root) }
|
|
1145
1149
|
}
|
|
1146
1150
|
|
|
1151
|
+
/** B 站登录状态:只读本地 owner.json(保存时已经验过一次,不每次开面板都联网)。 */
|
|
1152
|
+
async function biliStatus() {
|
|
1153
|
+
const saved = readBili(root)
|
|
1154
|
+
if (!saved) return { ok: true, loggedIn: false, name: null, hasCookie: false }
|
|
1155
|
+
return { ok: true, loggedIn: true, name: saved.name || null, hasCookie: true }
|
|
1156
|
+
}
|
|
1157
|
+
|
|
1158
|
+
/** B 站登录:粘贴 SESSDATA/bili_jct → 验证 → 落盘;失败原因由 lib/bili.js 给,不打印 cookie。 */
|
|
1159
|
+
function biliLogin(text) {
|
|
1160
|
+
return loginBili({ root, text, log })
|
|
1161
|
+
}
|
|
1162
|
+
|
|
1163
|
+
function biliLogout() {
|
|
1164
|
+
return { ok: true, removed: clearBili(root) }
|
|
1165
|
+
}
|
|
1166
|
+
|
|
1167
|
+
/**
|
|
1168
|
+
* B 站扫码登录:生成码 → 轮询 → (人点保存)才落盘。
|
|
1169
|
+
* 四个动作都在 lib/biliqr.js 的状态机里;这里只把 root 和库边界接上。
|
|
1170
|
+
*/
|
|
1171
|
+
function biliQrStart() {
|
|
1172
|
+
return qrLogin.start()
|
|
1173
|
+
}
|
|
1174
|
+
|
|
1175
|
+
function biliQrPoll(key) {
|
|
1176
|
+
return qrLogin.poll(key)
|
|
1177
|
+
}
|
|
1178
|
+
|
|
1179
|
+
async function biliQrSave(key) {
|
|
1180
|
+
return qrLogin.save(key, root)
|
|
1181
|
+
}
|
|
1182
|
+
|
|
1183
|
+
function biliQrDrop(key) {
|
|
1184
|
+
return qrLogin.drop(key)
|
|
1185
|
+
}
|
|
1186
|
+
|
|
1147
1187
|
/**
|
|
1148
1188
|
* 语音回复状态:只回有没有适配器、是哪种。
|
|
1149
1189
|
* available 同时受隐藏开关约束;没有适配器时恒为 false(宿主现在就是这样)。
|
|
@@ -1177,6 +1217,13 @@ export function apply(ctx, config = {}) {
|
|
|
1177
1217
|
baiduStatus,
|
|
1178
1218
|
baiduLogin,
|
|
1179
1219
|
baiduLogout,
|
|
1220
|
+
biliStatus,
|
|
1221
|
+
biliLogin,
|
|
1222
|
+
biliLogout,
|
|
1223
|
+
biliQrStart,
|
|
1224
|
+
biliQrPoll,
|
|
1225
|
+
biliQrSave,
|
|
1226
|
+
biliQrDrop,
|
|
1180
1227
|
// 语音回复:状态 + 条件触发。不进面板;未来桥只调 voiceReply。
|
|
1181
1228
|
voice: voiceInfo,
|
|
1182
1229
|
voiceReply,
|
|
@@ -1422,8 +1469,18 @@ export function apply(ctx, config = {}) {
|
|
|
1422
1469
|
// 读不到 home.json 就跳过这条判据,不影响启动。
|
|
1423
1470
|
// 两条都不满足 → 不挂。
|
|
1424
1471
|
if (cfg.attachToAgents !== false) {
|
|
1425
|
-
const herHome = join(root, 'home')
|
|
1426
1472
|
const homeFile = join(root, HOME_FILE)
|
|
1473
|
+
/** 家目录:home.json 的 workspacePath 优先(搬家后 living 就是她家);没有就退回 <root>/home。 */
|
|
1474
|
+
const readHomeWorkspace = () => {
|
|
1475
|
+
try {
|
|
1476
|
+
const raw = readFileSync(homeFile, 'utf8').replace(/^\uFEFF/, '')
|
|
1477
|
+
const j = JSON.parse(raw)
|
|
1478
|
+
return String(j?.workspacePath ?? j?.cwd ?? '')
|
|
1479
|
+
} catch {
|
|
1480
|
+
return ''
|
|
1481
|
+
}
|
|
1482
|
+
}
|
|
1483
|
+
const herHome = readHomeWorkspace() || join(root, 'home')
|
|
1427
1484
|
/** 每次现读:home.json 可能在插件启动之后才由 ensureHome 写出来。 */
|
|
1428
1485
|
const readHomeSessionId = () => {
|
|
1429
1486
|
try {
|
|
@@ -1494,7 +1551,16 @@ export function apply(ctx, config = {}) {
|
|
|
1494
1551
|
// 聊天落库的门控:llm/stream 是全局 waterfall,别的会话的生成也会经过观察器。
|
|
1495
1552
|
// 只有她家里那条会话(home.json 的 sessionId)才记 heard/utterance;每次现读,
|
|
1496
1553
|
// 读不到 home.json → false。shouldAttach 只管挂人设段,不决定记不记事件。
|
|
1497
|
-
isHerSession: (sid) =>
|
|
1554
|
+
isHerSession: (sid, key) => {
|
|
1555
|
+
if (sid && sid === readHomeSessionId()) return true
|
|
1556
|
+
// 宿主没给 sessionId 时 attach 会退回 cwd 当会话 key:家目录(living)下的会话也算她屋里。
|
|
1557
|
+
if (key && String(key).startsWith('cwd:')) {
|
|
1558
|
+
const cwd = flatPath(String(key).slice(4))
|
|
1559
|
+
const home = flatPath(herHome)
|
|
1560
|
+
if (cwd && home && (cwd === home || cwd.startsWith(`${home}/`))) return true
|
|
1561
|
+
}
|
|
1562
|
+
return false
|
|
1563
|
+
},
|
|
1498
1564
|
config: cfg,
|
|
1499
1565
|
log,
|
|
1500
1566
|
})
|
|
@@ -1561,6 +1627,15 @@ export function apply(ctx, config = {}) {
|
|
|
1561
1627
|
baiduStatus: () => service.baiduStatus(),
|
|
1562
1628
|
baiduLogin: (text) => service.baiduLogin(text),
|
|
1563
1629
|
baiduLogout: () => service.baiduLogout(),
|
|
1630
|
+
// B 站登录:GET /bili 只回状态(绝不回 cookie),POST 粘贴验证,DELETE 清掉。
|
|
1631
|
+
biliStatus: () => service.biliStatus(),
|
|
1632
|
+
biliLogin: (text) => service.biliLogin(text),
|
|
1633
|
+
biliLogout: () => service.biliLogout(),
|
|
1634
|
+
// 扫码登录:POST /bili/qr 生成,GET ?key= 轮询,POST /bili/qr/save 保存,drop 取消。
|
|
1635
|
+
biliQrStart: () => service.biliQrStart(),
|
|
1636
|
+
biliQrPoll: (key) => service.biliQrPoll(key),
|
|
1637
|
+
biliQrSave: (key) => service.biliQrSave(key),
|
|
1638
|
+
biliQrDrop: (key) => service.biliQrDrop(key),
|
|
1564
1639
|
readStore: () => ensureLive(),
|
|
1565
1640
|
seed: service.seed,
|
|
1566
1641
|
writeSeed: service.writeSeed,
|
|
@@ -1689,7 +1764,7 @@ export function apply(ctx, config = {}) {
|
|
|
1689
1764
|
// 11. 家:已经领养/写过起点的话,在宿主侧栏里建一个工作区 + 一条会话(幂等)。
|
|
1690
1765
|
// 只在事件流里有 demo 或 seed 时才建;config.home.enabled === false 时整块跳过。
|
|
1691
1766
|
// 失败只 log 一行,不阻塞启动——宿主没有这些服务时插件照样跑。
|
|
1692
|
-
if (cfg.home?.enabled !== false) {
|
|
1767
|
+
if (cfg.home?.enabled !== false && cfg.home?.auto !== false) {
|
|
1693
1768
|
ready.then((s) => {
|
|
1694
1769
|
if (!s) return
|
|
1695
1770
|
try {
|
|
@@ -1725,6 +1800,12 @@ export function apply(ctx, config = {}) {
|
|
|
1725
1800
|
const first = !disposed
|
|
1726
1801
|
disposed = true
|
|
1727
1802
|
closed = true
|
|
1803
|
+
// 内存里可能还攥着扫码登录的 cookie:停用就清掉,不靠进程退出兜底。
|
|
1804
|
+
try {
|
|
1805
|
+
qrLogin.clear()
|
|
1806
|
+
} catch {
|
|
1807
|
+
/* 清不掉就算了 */
|
|
1808
|
+
}
|
|
1728
1809
|
if (first) {
|
|
1729
1810
|
log(`已停用(apply#${n})`)
|
|
1730
1811
|
}
|
package/lib/attach.js
CHANGED
|
@@ -812,7 +812,7 @@ export function attachToAgents(ctx, {
|
|
|
812
812
|
let her = false
|
|
813
813
|
if (typeof isHerSession === 'function') {
|
|
814
814
|
try {
|
|
815
|
-
her = Boolean(isHerSession(sid))
|
|
815
|
+
her = Boolean(isHerSession(sid, sessionKeyOf(options)))
|
|
816
816
|
} catch (err) {
|
|
817
817
|
log(`聊天落库:会话判据出错(${errText(err)}),这条跳过`)
|
|
818
818
|
her = false
|
package/lib/bili.js
CHANGED
|
@@ -1,19 +1,24 @@
|
|
|
1
|
-
// B
|
|
1
|
+
// B 站:登录(cookie)+ 只读热门 + 在视频下说一句。网络与凭据都在这里,纯函数、可注入 fetch。
|
|
2
2
|
//
|
|
3
|
-
//
|
|
3
|
+
// 四条口径(README「说话」、知识库 n23 / n64):
|
|
4
4
|
// 1. bvid 不能直接发评论,要先 `/x/web-interface/view?bvid=` 拿 aid;
|
|
5
5
|
// 2. 凭据只从 `<root>/owner.json` 读,SESSDATA 与 bili_jct 缺任一都是"cookie 不完整";
|
|
6
|
-
// 3.
|
|
7
|
-
|
|
6
|
+
// 3. 登录只收 SESSDATA + bili_jct 两个键,保存前用 nav 接口验一次;0600 原子写,绝不回显;
|
|
7
|
+
// 4. 所有失败都不抛,折成 { ok:false, reason },reason 只写人话、不打印 cookie。
|
|
8
|
+
import { chmodSync, copyFileSync, existsSync, mkdirSync, readFileSync, renameSync, unlinkSync, writeFileSync } from 'node:fs'
|
|
8
9
|
import path from 'node:path'
|
|
9
10
|
|
|
10
11
|
export const POPULAR_API = 'https://api.bilibili.com/x/web-interface/popular'
|
|
11
12
|
export const VIEW_API = 'https://api.bilibili.com/x/web-interface/view'
|
|
12
13
|
export const REPLY_API = 'https://api.bilibili.com/x/v2/reply/add'
|
|
14
|
+
export const NAV_API = 'https://api.bilibili.com/x/web-interface/nav'
|
|
13
15
|
export const OWNER_FILE = 'owner.json'
|
|
16
|
+
export const OWNER_MODE = 0o600
|
|
14
17
|
|
|
15
18
|
const BVID_RE = /^BV[0-9A-Za-z]{10}$/
|
|
16
19
|
const SPEAK_MAX = 300
|
|
20
|
+
// 登录只认这两个键:SESSDATA 证明身份,bili_jct 是写操作的 csrf。
|
|
21
|
+
const LOGIN_KEYS = ['SESSDATA', 'bili_jct']
|
|
17
22
|
const UA =
|
|
18
23
|
'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36'
|
|
19
24
|
|
|
@@ -61,6 +66,176 @@ export function readOwner(root) {
|
|
|
61
66
|
}
|
|
62
67
|
}
|
|
63
68
|
|
|
69
|
+
/**
|
|
70
|
+
* 从浏览器复制的整段 cookie 里只挑 SESSDATA / bili_jct,并拼回规范形状。
|
|
71
|
+
* 缺哪个就说清缺哪个;换行/分号分隔都认。
|
|
72
|
+
*/
|
|
73
|
+
export function parseBiliCookie(text) {
|
|
74
|
+
const raw = typeof text === 'string' ? text : String(text ?? '')
|
|
75
|
+
const values = new Map()
|
|
76
|
+
for (const piece of raw.split(/[;\r\n]+/)) {
|
|
77
|
+
const part = piece.trim()
|
|
78
|
+
const eq = part.indexOf('=')
|
|
79
|
+
if (eq <= 0) continue
|
|
80
|
+
const key = part.slice(0, eq).trim()
|
|
81
|
+
const value = part.slice(eq + 1).trim()
|
|
82
|
+
if (!LOGIN_KEYS.includes(key) || !value) continue
|
|
83
|
+
values.set(key, value)
|
|
84
|
+
}
|
|
85
|
+
const missing = LOGIN_KEYS.filter((key) => !values.has(key))
|
|
86
|
+
if (missing.length) return { ok: false, reason: `cookie 不完整:缺 ${missing.join('、')}` }
|
|
87
|
+
return {
|
|
88
|
+
ok: true,
|
|
89
|
+
cookie: `SESSDATA=${values.get('SESSDATA')}; bili_jct=${values.get('bili_jct')}`,
|
|
90
|
+
missing: [],
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* 读登录态(登录卡片用):文件不在 / 坏 JSON / 缺键一律 null(调用方翻成人话)。
|
|
96
|
+
* 比 readOwner 多认可选的名字与 uid;cookie 本身不回显、不打印。
|
|
97
|
+
*/
|
|
98
|
+
export function readBili(root) {
|
|
99
|
+
if (!root) return null
|
|
100
|
+
try {
|
|
101
|
+
const file = path.join(String(root), OWNER_FILE)
|
|
102
|
+
const raw = readFileSync(file, 'utf8').replace(/^\uFEFF/, '')
|
|
103
|
+
const j = JSON.parse(raw)
|
|
104
|
+
if (!j || typeof j !== 'object' || Array.isArray(j)) return null
|
|
105
|
+
const parsed = parseBiliCookie(typeof j.cookie === 'string' ? j.cookie : '')
|
|
106
|
+
if (!parsed.ok) return null
|
|
107
|
+
const at = Number(j.at)
|
|
108
|
+
const out = { cookie: parsed.cookie, at: Number.isFinite(at) && at > 0 ? at : 0 }
|
|
109
|
+
const name = typeof j.name === 'string' ? j.name.trim() : ''
|
|
110
|
+
if (name) out.name = name
|
|
111
|
+
const mid = Number(j.mid)
|
|
112
|
+
if (Number.isFinite(mid) && mid > 0) out.mid = Math.trunc(mid)
|
|
113
|
+
return out
|
|
114
|
+
} catch {
|
|
115
|
+
return null
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/** 原子写:tmp → rename,tmp 用 0600 建;失败清掉 tmp 再抛。 */
|
|
120
|
+
export function writeBili(root, { cookie, name, mid } = {}) {
|
|
121
|
+
const parsed = parseBiliCookie(cookie)
|
|
122
|
+
if (!parsed.ok) throw new Error(parsed.reason)
|
|
123
|
+
if (!root) throw new Error('没有地方存')
|
|
124
|
+
|
|
125
|
+
const file = path.join(String(root), OWNER_FILE)
|
|
126
|
+
const cleanName = typeof name === 'string' ? name.trim().slice(0, 64) : ''
|
|
127
|
+
const id = Number(mid)
|
|
128
|
+
const record = {
|
|
129
|
+
cookie: parsed.cookie,
|
|
130
|
+
name: cleanName,
|
|
131
|
+
...(Number.isFinite(id) && id > 0 ? { mid: Math.trunc(id) } : {}),
|
|
132
|
+
at: Date.now(),
|
|
133
|
+
}
|
|
134
|
+
const text = `${JSON.stringify(record, null, 2)}\n`
|
|
135
|
+
mkdirSync(path.dirname(file), { recursive: true })
|
|
136
|
+
// 覆盖之前先把旧文件留成 .bak:里面是上一份登录态,点错一次只能重新扫码。
|
|
137
|
+
// 备份失败不拦写盘(宁可写成功,也不能因为留副本失败就登不上)。
|
|
138
|
+
try {
|
|
139
|
+
if (existsSync(file)) copyFileSync(file, `${file}.bak`)
|
|
140
|
+
} catch {
|
|
141
|
+
/* 留不下备份就算了 */
|
|
142
|
+
}
|
|
143
|
+
const tmp = `${file}.${process.pid}.${Date.now()}.tmp`
|
|
144
|
+
try {
|
|
145
|
+
writeFileSync(tmp, text, { encoding: 'utf8', mode: OWNER_MODE })
|
|
146
|
+
try {
|
|
147
|
+
chmodSync(tmp, OWNER_MODE)
|
|
148
|
+
} catch {
|
|
149
|
+
/* Windows 不保留 POSIX 位,尽力而为 */
|
|
150
|
+
}
|
|
151
|
+
renameSync(tmp, file)
|
|
152
|
+
} catch (e) {
|
|
153
|
+
try {
|
|
154
|
+
unlinkSync(tmp)
|
|
155
|
+
} catch {
|
|
156
|
+
/* 没有 tmp 可清就算了 */
|
|
157
|
+
}
|
|
158
|
+
throw e
|
|
159
|
+
}
|
|
160
|
+
return record
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/** 清掉登录文件;返回是否真的删掉了。 */
|
|
164
|
+
export function clearBili(root) {
|
|
165
|
+
if (!root) return false
|
|
166
|
+
try {
|
|
167
|
+
unlinkSync(path.join(String(root), OWNER_FILE))
|
|
168
|
+
return true
|
|
169
|
+
} catch {
|
|
170
|
+
return false
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
/** nav 接口的错误码 → 人话。 */
|
|
175
|
+
function navReason(code) {
|
|
176
|
+
if (code === -101) return '没登录'
|
|
177
|
+
if (code === -412 || code === -352 || code === -403) return '被风控了'
|
|
178
|
+
return `B 站回了个看不懂的(code ${code})`
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
/**
|
|
182
|
+
* 用 nav 接口验一次:`data.isLogin === true` 才算登录,顺手取 uname / mid。
|
|
183
|
+
* 失败只回「没登录 / cookie 不完整 / 被风控了 / 连不上 B 站」,不打印 cookie。
|
|
184
|
+
* @returns {Promise<{ok:true, name:string, mid:number}|{ok:false, reason:string}>}
|
|
185
|
+
*/
|
|
186
|
+
export async function checkBili({ cookie, fetchImpl = globalThis.fetch } = {}) {
|
|
187
|
+
const parsed = parseBiliCookie(cookie)
|
|
188
|
+
if (!parsed.ok) return { ok: false, reason: 'cookie 不完整' }
|
|
189
|
+
|
|
190
|
+
const f = typeof fetchImpl === 'function' ? fetchImpl : globalThis.fetch
|
|
191
|
+
if (typeof f !== 'function') return { ok: false, reason: '连不上 B 站' }
|
|
192
|
+
|
|
193
|
+
let nav
|
|
194
|
+
try {
|
|
195
|
+
const res = await f(NAV_API, {
|
|
196
|
+
headers: {
|
|
197
|
+
'user-agent': UA,
|
|
198
|
+
cookie: parsed.cookie,
|
|
199
|
+
referer: 'https://www.bilibili.com/',
|
|
200
|
+
},
|
|
201
|
+
})
|
|
202
|
+
nav = await readJson(res)
|
|
203
|
+
} catch {
|
|
204
|
+
return { ok: false, reason: '连不上 B 站' }
|
|
205
|
+
}
|
|
206
|
+
if (!nav || typeof nav !== 'object') return { ok: false, reason: '连不上 B 站' }
|
|
207
|
+
|
|
208
|
+
const code = Number(nav.code)
|
|
209
|
+
if (code !== 0) return { ok: false, reason: navReason(code) }
|
|
210
|
+
// code 0 也可能是"没登录":没有有效 SESSDATA 时 web 端回 isLogin:false。
|
|
211
|
+
if (nav.data?.isLogin !== true) return { ok: false, reason: '没登录' }
|
|
212
|
+
|
|
213
|
+
const name = typeof nav.data?.uname === 'string' ? nav.data.uname.trim() : ''
|
|
214
|
+
const mid = Number(nav.data?.mid)
|
|
215
|
+
return { ok: true, name, mid: Number.isFinite(mid) && mid > 0 ? Math.trunc(mid) : 0 }
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
/**
|
|
219
|
+
* 粘贴 → 解析 → 验证 → 写盘。验证不过一个字节都不写。
|
|
220
|
+
* @returns {Promise<{ok:true, name:string}|{ok:false, reason:string}>}
|
|
221
|
+
*/
|
|
222
|
+
export async function loginBili({ root, text, fetchImpl = globalThis.fetch, log } = {}) {
|
|
223
|
+
if (!root) return { ok: false, reason: '没有地方存' }
|
|
224
|
+
const parsed = parseBiliCookie(text)
|
|
225
|
+
if (!parsed.ok) return { ok: false, reason: parsed.reason }
|
|
226
|
+
|
|
227
|
+
const chk = await checkBili({ cookie: parsed.cookie, fetchImpl })
|
|
228
|
+
if (!chk.ok) return chk
|
|
229
|
+
|
|
230
|
+
try {
|
|
231
|
+
writeBili(root, { cookie: parsed.cookie, name: chk.name, mid: chk.mid })
|
|
232
|
+
} catch {
|
|
233
|
+
return { ok: false, reason: '存不进去' }
|
|
234
|
+
}
|
|
235
|
+
safeLog(log, 'B 站登录:已保存(只在本机)')
|
|
236
|
+
return { ok: true, name: chk.name }
|
|
237
|
+
}
|
|
238
|
+
|
|
64
239
|
async function readJson(res) {
|
|
65
240
|
try {
|
|
66
241
|
if (!res || typeof res.json !== 'function') return null
|