dsh-fish-soul 0.15.13 → 0.15.14
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/README.md +1 -1
- package/lib/channels/snowluma.js +35 -3
- package/lib/client.js +1 -1
- package/lib/snowluma-token.js +84 -0
- package/package.json +2 -2
package/README.md
CHANGED
package/lib/channels/snowluma.js
CHANGED
|
@@ -8,6 +8,11 @@
|
|
|
8
8
|
* - 只负责"收事件 / 调接口",不认人、不建会话、不做门槛——那些在 gateway.js。
|
|
9
9
|
*/
|
|
10
10
|
|
|
11
|
+
import { findSnowlumaToken } from '../snowluma-token.js';
|
|
12
|
+
|
|
13
|
+
/** 默认的「去 SnowLuma 配置里找令牌」实现。 */
|
|
14
|
+
const defaultFindToken = () => findSnowlumaToken();
|
|
15
|
+
|
|
11
16
|
const RECONNECT_STEPS_MS = [1000, 2000, 5000, 10_000, 30_000];
|
|
12
17
|
const CALL_TIMEOUT_MS = 15_000;
|
|
13
18
|
const FRIEND_REFRESH_MS = 10 * 60_000;
|
|
@@ -42,12 +47,16 @@ export class SnowLumaChannel {
|
|
|
42
47
|
* @param {() => void} [deps.onConnected]
|
|
43
48
|
* @param {typeof WebSocket} [deps.WebSocketImpl] 测试注入
|
|
44
49
|
*/
|
|
45
|
-
constructor({ getConfig, log, onEvent, onConnected, WebSocketImpl }) {
|
|
50
|
+
constructor({ getConfig, log, onEvent, onConnected, WebSocketImpl, findToken }) {
|
|
46
51
|
this.getConfig = getConfig;
|
|
47
52
|
this.log = log ?? (() => {});
|
|
48
53
|
this.onEvent = onEvent ?? (() => {});
|
|
49
54
|
this.onConnected = onConnected ?? (() => {});
|
|
50
55
|
this.WebSocketImpl = WebSocketImpl ?? globalThis.WebSocket;
|
|
56
|
+
/** 连不上时自己去 SnowLuma 配置里找令牌(默认实现见 snowluma-token.js;测试里可注入) */
|
|
57
|
+
this.findToken = findToken ?? defaultFindToken;
|
|
58
|
+
/** 连不上时自己去 SnowLuma 配置里找到的令牌(内存里,不落盘;'' = 没找到) */
|
|
59
|
+
this.autoToken = '';
|
|
51
60
|
this.ws = null;
|
|
52
61
|
this.connected = false;
|
|
53
62
|
this.selfId = '';
|
|
@@ -73,7 +82,9 @@ export class SnowLumaChannel {
|
|
|
73
82
|
nickname: this.nickname,
|
|
74
83
|
lastError: this.lastError,
|
|
75
84
|
connectedAt: this.connectedAt,
|
|
76
|
-
friendCount: this.friends.size
|
|
85
|
+
friendCount: this.friends.size,
|
|
86
|
+
/** 现在用的是不是「从 SnowLuma 配置里自动读到的」那份令牌 */
|
|
87
|
+
autoToken: this.autoToken !== ''
|
|
77
88
|
};
|
|
78
89
|
}
|
|
79
90
|
|
|
@@ -97,6 +108,8 @@ export class SnowLumaChannel {
|
|
|
97
108
|
|
|
98
109
|
/** 配置(地址/令牌/开关)变了:断开重连。 */
|
|
99
110
|
restart() {
|
|
111
|
+
// 用户在设置页动过配置(多半就是改令牌):自动取来的那份作废,重新以填的为准
|
|
112
|
+
this.autoToken = '';
|
|
100
113
|
this.stop();
|
|
101
114
|
if (this.getConfig().enabled === true) this.start();
|
|
102
115
|
}
|
|
@@ -113,10 +126,26 @@ export class SnowLumaChannel {
|
|
|
113
126
|
buildUrl() {
|
|
114
127
|
const cfg = this.getConfig();
|
|
115
128
|
const url = new URL(String(cfg.wsUrl || 'ws://127.0.0.1:3001'));
|
|
116
|
-
|
|
129
|
+
// 平时用用户填的;一旦因为鉴权失败去 SnowLuma 配置里取过令牌,那份就优先
|
|
130
|
+
// (能走到那一步就说明填的这份已经不好使了)。用户在设置页一改,restart() 会清掉它。
|
|
131
|
+
const tok = this.autoToken !== '' ? this.autoToken : String(cfg.accessToken ?? '');
|
|
132
|
+
if (tok !== '') url.searchParams.set('access_token', tok);
|
|
117
133
|
return url.toString();
|
|
118
134
|
}
|
|
119
135
|
|
|
136
|
+
/**
|
|
137
|
+
* 连不上时去 SnowLuma 自己的配置里问一次令牌(只读)。
|
|
138
|
+
* 换了令牌才返回 true,让调用方立刻重连一次(不换就按正常退避,别抖)。
|
|
139
|
+
*/
|
|
140
|
+
askSnowLumaToken() {
|
|
141
|
+
let found = '';
|
|
142
|
+
try { found = String(this.findToken?.()?.token ?? ''); } catch { /* 找不到就当没有 */ }
|
|
143
|
+
if (found === '' || found === this.autoToken) return false;
|
|
144
|
+
this.autoToken = found;
|
|
145
|
+
this.log('info', `[fish-soul] QQ:从 SnowLuma 的配置里读到了访问令牌(${found.length} 字符),换个钥匙再连一次`);
|
|
146
|
+
return true;
|
|
147
|
+
}
|
|
148
|
+
|
|
120
149
|
connect() {
|
|
121
150
|
if (this.stopped) return;
|
|
122
151
|
if (typeof this.WebSocketImpl !== 'function') {
|
|
@@ -159,6 +188,9 @@ export class SnowLumaChannel {
|
|
|
159
188
|
this.lastError = closeReason(ev);
|
|
160
189
|
if (wasConnected) this.log('warn', `[fish-soul] QQ:和 SnowLuma 的连接断开(${ev?.code ?? '?'}),稍后重连`);
|
|
161
190
|
else if (this.attempt === 0) this.log('warn', `[fish-soul] QQ:${this.lastError}`); // 连不上的第一轮记一条,退避期间不刷屏
|
|
191
|
+
// 握手被拒(超额 403 / 令牌不对)或压根连不上:去 SnowLuma 配置里找一次真令牌。
|
|
192
|
+
// SnowLuma 更新/重建配置会重新生成令牌,手填的那份经常是旧的。
|
|
193
|
+
if (!wasConnected && (ev?.code === 1006 || ev?.code === 1008 || ev?.code === 403)) this.askSnowLumaToken();
|
|
162
194
|
this.scheduleReconnect();
|
|
163
195
|
};
|
|
164
196
|
}
|
package/lib/client.js
CHANGED
|
@@ -514,7 +514,7 @@ window.__ModuleLoader__.load({
|
|
|
514
514
|
|
|
515
515
|
const connected = qq.connected === true;
|
|
516
516
|
// 连上过又掉了和压根没连上要分得开:「连接中」只留给真的还在握手的第一次
|
|
517
|
-
const status = !form.enabled ? null : connected ? State("done", `${qq.nickname || qq.selfId || ""}${qq.friendCount != null ? ` · ${qq.friendCount} 位好友` : ""}`) : qq.lastError ? State(qq.connectedAt ? "error" : "ongoing", qq.lastError) : State("ongoing", "连接中");
|
|
517
|
+
const status = !form.enabled ? null : connected ? State("done", `${qq.nickname || qq.selfId || ""}${qq.friendCount != null ? ` · ${qq.friendCount} 位好友` : ""}${qq.autoToken ? "(令牌自动同步)" : ""}`) : qq.lastError ? State(qq.connectedAt ? "error" : "ongoing", qq.lastError) : State("ongoing", "连接中");
|
|
518
518
|
const field = (name, props) => Input({ value: form[name], onChange: (e) => patch({ [name]: e.target.value }), onBlur: () => save(), ...props });
|
|
519
519
|
const chats = ses.chats ?? [];
|
|
520
520
|
const ij = form.ij;
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* snowluma-token.js —— 从 SnowLuma 自己的配置里把「正向 WS 的访问令牌」读出来。
|
|
3
|
+
*
|
|
4
|
+
* 为什么要有它(2026-11-07 真机踩到):SnowLuma 每次更新 / 重建配置都会**重新生成**
|
|
5
|
+
* 访问令牌,而插件这边是手填的。结果就是「SnowLuma 明明活着、QQ 消息照收,插件却一直
|
|
6
|
+
* 连不上」,界面上还只显示「连接中」(那是另一个 bug,见 snowluma.js 的 closeReason)。
|
|
7
|
+
* 让插件自己去找那把钥匙,比让人每次手动同步靠谱。
|
|
8
|
+
*
|
|
9
|
+
* 🔴 只读,不写:绝不改 SnowLuma 的任何文件。
|
|
10
|
+
* 🔴 令牌值不进日志、不进状态接口 —— 调用方只拿它去连 WS。
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import { existsSync, readFileSync, readdirSync, statSync } from 'node:fs';
|
|
14
|
+
import { homedir } from 'node:os';
|
|
15
|
+
import { join } from 'node:path';
|
|
16
|
+
|
|
17
|
+
/** SnowLuma 的安装目录可能在哪(从最可信的指针往下退)。 */
|
|
18
|
+
function candidateRoots(env = process.env) {
|
|
19
|
+
const out = [];
|
|
20
|
+
const push = (p) => { const v = String(p ?? '').trim(); if (v !== '' && !out.includes(v)) out.push(v); };
|
|
21
|
+
// ① 显式配置(用户/别的插件指过路)
|
|
22
|
+
push(env.FISH_SOUL_SNOWLUMA_ROOT);
|
|
23
|
+
// ② snowluma-control 插件写的运行记录(里面 root 是它启动的 SnowLuma 目录)
|
|
24
|
+
try {
|
|
25
|
+
const home = String(env.DSH_HOME ?? '').trim() || join(homedir(), '.dsh');
|
|
26
|
+
const rec = join(home, 'snowluma-control.json');
|
|
27
|
+
if (existsSync(rec)) {
|
|
28
|
+
const j = JSON.parse(readFileSync(rec, 'utf8'));
|
|
29
|
+
push(j?.root);
|
|
30
|
+
}
|
|
31
|
+
} catch { /* 读不到就算了 */ }
|
|
32
|
+
// ③ 跟本插件同级(两个插件常挂在同一个父目录下)
|
|
33
|
+
try {
|
|
34
|
+
const proj = join(import.meta.dirname, '..', '..');
|
|
35
|
+
push(join(proj, 'SnowLuma'));
|
|
36
|
+
} catch { /* 忽略 */ }
|
|
37
|
+
return out;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** 这个目录看起来是不是 SnowLuma 的安装目录。 */
|
|
41
|
+
function looksLikeSnowLuma(dir) {
|
|
42
|
+
try {
|
|
43
|
+
return existsSync(join(dir, 'index.mjs')) && existsSync(join(dir, 'config'));
|
|
44
|
+
} catch { return false; }
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* 从 SnowLuma 的 onebot 配置里取正向 WS 的访问令牌(onebot_<uin>.json → networks.wsServers[])。
|
|
49
|
+
* @param {string} file
|
|
50
|
+
* @param {{port?: number}} [opts] 指定端口(默认取 3001 那条,取不到就用第一条带令牌的)
|
|
51
|
+
* @returns {string} 令牌;找不到返回 ''
|
|
52
|
+
*/
|
|
53
|
+
export function tokenFromOnebotConfig(file, { port = 3001 } = {}) {
|
|
54
|
+
let j;
|
|
55
|
+
try { j = JSON.parse(readFileSync(file, 'utf8')); } catch { return ''; }
|
|
56
|
+
const rows = j?.networks?.wsServers;
|
|
57
|
+
if (!Array.isArray(rows) || rows.length === 0) return '';
|
|
58
|
+
const want = rows.find((r) => Number(r?.port) === Number(port) && typeof r?.accessToken === 'string' && r.accessToken !== '');
|
|
59
|
+
const any = rows.find((r) => typeof r?.accessToken === 'string' && r.accessToken !== '');
|
|
60
|
+
return String((want ?? any)?.accessToken ?? '');
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* 找 SnowLuma 的正向 WS 的访问令牌。
|
|
65
|
+
* @param {{env?: NodeJS.ProcessEnv, port?: number, roots?: string[]}} [opts]
|
|
66
|
+
* @returns {{token: string, from: string}} token 为空表示没找到(from 仍给出线索)
|
|
67
|
+
*/
|
|
68
|
+
export function findSnowlumaToken({ env = process.env, port = 3001, roots } = {}) {
|
|
69
|
+
for (const root of roots ?? candidateRoots(env)) {
|
|
70
|
+
if (!looksLikeSnowLuma(root)) continue;
|
|
71
|
+
const dir = join(root, 'config');
|
|
72
|
+
let files = [];
|
|
73
|
+
try {
|
|
74
|
+
files = readdirSync(dir).filter((f) => /^onebot.*\.json$/i.test(f)).map((f) => join(dir, f));
|
|
75
|
+
} catch { continue; }
|
|
76
|
+
// 新的排前面(SnowLuma 重建配置时是覆盖写)
|
|
77
|
+
files.sort((a, b) => { try { return statSync(b).mtimeMs - statSync(a).mtimeMs; } catch { return 0; } });
|
|
78
|
+
for (const f of files) {
|
|
79
|
+
const t = tokenFromOnebotConfig(f, { port });
|
|
80
|
+
if (t !== '') return { token: t, from: f };
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
return { token: '', from: '' };
|
|
84
|
+
}
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dsh-fish-soul",
|
|
3
|
-
"version": "0.15.
|
|
4
|
-
"description": "鱼 · 人格、按人记忆(可纠正、有印象、能导入旧聊天记录)、日程自走与主动开口,直连 SnowLuma 收发 QQ(群里会自己插话),B站 / 抖音小号私信与自己刷视频;按功能选模型并统计用量花费,能听会说 QQ 语音、收藏表情包、学群里的梗,可选上网查;心情影响说话、去 AI 味、每个群的设定、按聊天选模型、每晚写日记、每周记忆存档与可选周记(DSH 插件,单角色 feiyu ,v0.15.
|
|
3
|
+
"version": "0.15.14",
|
|
4
|
+
"description": "鱼 · 人格、按人记忆(可纠正、有印象、能导入旧聊天记录)、日程自走与主动开口,直连 SnowLuma 收发 QQ(群里会自己插话),B站 / 抖音小号私信与自己刷视频;按功能选模型并统计用量花费,能听会说 QQ 语音、收藏表情包、学群里的梗,可选上网查;心情影响说话、去 AI 味、每个群的设定、按聊天选模型、每晚写日记、每周记忆存档与可选周记(DSH 插件,单角色 feiyu ,v0.15.14)",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./lib/index.js",
|
|
7
7
|
"files": [
|