dsh-fish-soul 0.13.8 → 0.13.9
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/attachments.js +187 -0
- package/lib/channels/onebot.js +18 -4
- package/lib/config-schema.js +7 -0
- package/lib/gateway.js +106 -1
- package/lib/index.js +2 -2
- package/package.json +3 -3
package/README.md
CHANGED
|
@@ -0,0 +1,187 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* attachments.js —— QQ 附件(文件消息)处理:文本类直接读进正文,看不了的格式存到插件数据目录再报路径。
|
|
3
|
+
*
|
|
4
|
+
* 只碰白名单目录里的文件(工作区 / 插件数据目录):路径不在里面、文件已经没了、读失败,
|
|
5
|
+
* 一律保留原来的【文件:名字】占位,不往提示词里塞来路不明的东西。
|
|
6
|
+
*/
|
|
7
|
+
import { copyFileSync, existsSync, mkdirSync, readFileSync, realpathSync, statSync, writeFileSync } from 'node:fs';
|
|
8
|
+
import { basename, extname, join, resolve } from 'node:path';
|
|
9
|
+
import { isInside } from './gateway-util.js';
|
|
10
|
+
|
|
11
|
+
/** 能直接读进提示词的扩展名。 */
|
|
12
|
+
const TEXT_EXT = new Set(['.txt', '.md', '.log', '.csv', '.json']);
|
|
13
|
+
/** 超过这个大小就不读(提示词放不下)。 */
|
|
14
|
+
const MAX_TEXT_BYTES = 256 * 1024;
|
|
15
|
+
/** 直读最多读前多少字节。 */
|
|
16
|
+
const MAX_READ_BYTES = 8 * 1024;
|
|
17
|
+
/** 二进制落盘单文件上限(超过就保留占位)。 */
|
|
18
|
+
const MAX_BINARY_BYTES = 32 * 1024 * 1024;
|
|
19
|
+
/** 正文里附件的占位(onebot.js 生成的);用户自己打的【】会被换成〔〕,撞不上。 */
|
|
20
|
+
const PLACEHOLDER_RE = /【文件[^】]*】/g;
|
|
21
|
+
|
|
22
|
+
/** 附件开关:读文本 / 存二进制。 */
|
|
23
|
+
export function attachmentConfig(cfg) {
|
|
24
|
+
const a = cfg?.attachments ?? {};
|
|
25
|
+
return { readText: a.readText !== false, saveBinary: a.saveBinary === true };
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** 文件名里不能有的字符(Windows 也不能有),顺手去掉开头的小点。 */
|
|
29
|
+
function safeName(name) {
|
|
30
|
+
return String(name ?? '').replace(/[\\/:*?"<>|\u0000-\u001f]/g, '_').replace(/^\.+/, '').trim().slice(0, 60);
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** 读前 8KB;切在多字节字符中间会多出一个替换字符,丢掉。 */
|
|
34
|
+
function readHead(abs) {
|
|
35
|
+
const buf = readFileSync(abs);
|
|
36
|
+
const truncated = buf.length > MAX_READ_BYTES;
|
|
37
|
+
const slice = buf.subarray(0, MAX_READ_BYTES);
|
|
38
|
+
// 先检测是否合法 UTF-8;非法就抛异常(调用方 catch 保留占位)
|
|
39
|
+
new TextDecoder('utf-8', { fatal: true }).decode(slice);
|
|
40
|
+
const text = slice.toString('utf8');
|
|
41
|
+
// 切在多字节字符中间会多出一个替换字符(U+FFFD),丢掉
|
|
42
|
+
const clean = text.length > 0 && text.codePointAt(text.length - 1) === 0xfffd ? text.slice(0, -1) : text;
|
|
43
|
+
return { text: clean, truncated };
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* fileId 路线(SnowLuma 这类不给 path 的):get_private_file_url 换直链 → 下载 → 按扩展名读进正文或落盘。
|
|
48
|
+
* 拿不到链接 / 下载失败 / 超上限:返回 null 保留占位。
|
|
49
|
+
*/
|
|
50
|
+
async function handleDownload(f, ctx, meta) {
|
|
51
|
+
const { name, ext, size, label, warn } = meta;
|
|
52
|
+
const fileId = String(f?.fileId ?? '').trim();
|
|
53
|
+
const fileHash = String(f?.fileHash ?? '').trim();
|
|
54
|
+
if (typeof ctx.fetchFile !== 'function') { warn('没有文件下载通道'); return null; }
|
|
55
|
+
if (size > MAX_BINARY_BYTES) { warn('太大了,没拉'); return null; }
|
|
56
|
+
let bytes = null;
|
|
57
|
+
try {
|
|
58
|
+
bytes = await ctx.fetchFile({ fileId, fileHash, url: String(f?.url ?? '') });
|
|
59
|
+
} catch {
|
|
60
|
+
warn('下载失败');
|
|
61
|
+
return null;
|
|
62
|
+
}
|
|
63
|
+
if (bytes === null || bytes.length === 0) { warn('没拉到内容'); return null; }
|
|
64
|
+
if (bytes.length > MAX_BINARY_BYTES) { warn('太大了,没拉'); return null; }
|
|
65
|
+
|
|
66
|
+
if (TEXT_EXT.has(ext)) {
|
|
67
|
+
if (!ctx.readText) return null;
|
|
68
|
+
if (bytes.length > MAX_TEXT_BYTES) { warn('太大了,没读'); return null; }
|
|
69
|
+
const slice = bytes.subarray(0, MAX_READ_BYTES);
|
|
70
|
+
try { new TextDecoder('utf-8', { fatal: true }).decode(slice); } catch { warn('不是有效 UTF-8'); return null; }
|
|
71
|
+
const text = slice.toString('utf8');
|
|
72
|
+
const clean = text.length > 0 && text.codePointAt(text.length - 1) === 0xfffd ? text.slice(0, -1) : text;
|
|
73
|
+
ctx.read.push(label);
|
|
74
|
+
const body = bytes.length > MAX_READ_BYTES ? `${clean}\n…(内容太长,只读了前 8KB)` : clean;
|
|
75
|
+
return `【文件 ${label} 内容】\n${body}`;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
if (!ctx.saveBinary) return null;
|
|
79
|
+
const dir = String(ctx.dataDir ?? '');
|
|
80
|
+
if (dir === '') { warn('没有存放目录'); return null; }
|
|
81
|
+
const base = safeName(name);
|
|
82
|
+
const dest = join(dir, `${safeName(ctx.messageId) || 'file'}-${base !== '' ? base : '附件'}`);
|
|
83
|
+
try {
|
|
84
|
+
mkdirSync(dir, { recursive: true });
|
|
85
|
+
writeFileSync(dest, bytes);
|
|
86
|
+
} catch {
|
|
87
|
+
warn('存不下来');
|
|
88
|
+
return null;
|
|
89
|
+
}
|
|
90
|
+
ctx.saved.push(dest);
|
|
91
|
+
return `【文件 ${label}:我看不了这种格式,已存到 ${dest}】`;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* 一个附件怎么处理:返回替换占位的新文本,处理不了返回 null(占位原样留着)。
|
|
96
|
+
* SnowLuma 这类不给本地 path 的:先用 fetchFile(get_private_file_url 换链接再下载)拿到 bytes 再走同一套逻辑。
|
|
97
|
+
* @param {{name?: string, path?: string, url?: string, size?: number, fileId?: string, fileHash?: string}} f
|
|
98
|
+
* @param {object} ctx
|
|
99
|
+
*/
|
|
100
|
+
async function handleOne(f, ctx) {
|
|
101
|
+
const name = String(f?.name ?? '').trim();
|
|
102
|
+
const rawPath = String(f?.path ?? '').trim();
|
|
103
|
+
const abs = rawPath === '' ? '' : resolve(rawPath);
|
|
104
|
+
const ext = extname(name !== '' ? name : rawPath).toLowerCase();
|
|
105
|
+
const size = Number(f?.size) || 0;
|
|
106
|
+
const label = name !== '' ? name : rawPath !== '' ? basename(rawPath) : '未命名';
|
|
107
|
+
const warn = (why) => ctx.log?.('warn', `[fish-soul] 附件没读进去(${why}):${label}`);
|
|
108
|
+
|
|
109
|
+
// SnowLuma 路线:没有本地 path,但有 fileId——先用 OneBot API 换链接下载
|
|
110
|
+
const fileId = String(f?.fileId ?? '').trim();
|
|
111
|
+
if (abs === '' && fileId !== '') return await handleDownload(f, ctx, { name, ext, size, label, warn });
|
|
112
|
+
|
|
113
|
+
if (abs === '') { warn('没有本地路径'); return null; }
|
|
114
|
+
if (!ctx.roots.some((root) => isInside(root, abs))) { warn('路径不在允许的目录里'); return null; }
|
|
115
|
+
if (!existsSync(abs)) { warn('文件不在了'); return null; }
|
|
116
|
+
|
|
117
|
+
// P0 修复:白名单和源文件都做 realpath,防符号链接 / Windows junction 绕过
|
|
118
|
+
let realRoots;
|
|
119
|
+
try { realRoots = ctx.roots.filter((r) => { try { realpathSync.native(r); return true; } catch { return false; } }).map((r) => realpathSync.native(r)); } catch { realRoots = []; }
|
|
120
|
+
let realAbs;
|
|
121
|
+
try { realAbs = realpathSync.native(abs); } catch { warn('解析真实路径失败'); return null; }
|
|
122
|
+
if (!realRoots.some((root) => isInside(root, realAbs))) { warn('真实路径越界(符号链接/junction)'); return null; }
|
|
123
|
+
|
|
124
|
+
if (TEXT_EXT.has(ext)) {
|
|
125
|
+
if (!ctx.readText) return null; // 开关关着:跟以前一样,只留占位
|
|
126
|
+
if (size > MAX_TEXT_BYTES) { warn('太大了,没读'); return null; }
|
|
127
|
+
let st;
|
|
128
|
+
try { st = statSync(realAbs); } catch { warn('拿不到文件信息'); return null; }
|
|
129
|
+
if (!st.isFile()) { warn('不是文件'); return null; }
|
|
130
|
+
if (st.size > MAX_TEXT_BYTES) { warn('太大了,没读'); return null; }
|
|
131
|
+
let head;
|
|
132
|
+
try { head = readHead(realAbs); } catch { warn('读取失败'); return null; }
|
|
133
|
+
ctx.read.push(label);
|
|
134
|
+
const body = head.truncated ? `${head.text}\n…(内容太长,只读了前 8KB)` : head.text;
|
|
135
|
+
return `【文件 ${label} 内容】\n${body}`;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
if (!ctx.saveBinary) return null; // 不存二进制:跟以前一样,只留占位
|
|
139
|
+
// P1 修复:二进制也设上限,防大文件占满磁盘
|
|
140
|
+
if (size > MAX_BINARY_BYTES) { warn('二进制太大,没存'); return null; }
|
|
141
|
+
let bst;
|
|
142
|
+
try { bst = statSync(realAbs); } catch { warn('拿不到文件信息'); return null; }
|
|
143
|
+
if (!bst.isFile()) { warn('不是普通文件'); return null; }
|
|
144
|
+
if (bst.size > MAX_BINARY_BYTES) { warn('二进制太大,没存'); return null; }
|
|
145
|
+
const dir = String(ctx.dataDir ?? '');
|
|
146
|
+
if (dir === '') { warn('没有存放目录'); return null; }
|
|
147
|
+
const base = safeName(name) !== '' ? safeName(name) : safeName(basename(realAbs));
|
|
148
|
+
const dest = join(dir, `${safeName(ctx.messageId) || 'file'}-${base !== '' ? base : '附件'}`);
|
|
149
|
+
try {
|
|
150
|
+
mkdirSync(dir, { recursive: true });
|
|
151
|
+
copyFileSync(realAbs, dest);
|
|
152
|
+
} catch {
|
|
153
|
+
warn('存不下来');
|
|
154
|
+
return null;
|
|
155
|
+
}
|
|
156
|
+
ctx.saved.push(dest);
|
|
157
|
+
return `【文件 ${label}:我看不了这种格式,已存到 ${dest}】`;
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/**
|
|
161
|
+
* 把附件处理进消息正文:占位换成内容(或存下来的路径),处理不了的占位原样留着。
|
|
162
|
+
* @param {string} text 原正文(里面有【文件:名字】占位)
|
|
163
|
+
* @param {Array<{name?: string, path?: string, url?: string, size?: number}>} files 附件(顺序与占位一致)
|
|
164
|
+
* @param {{readText?: boolean, saveBinary?: boolean, roots?: string[], dataDir?: string, messageId?: string, log?: (level: string, msg: string) => void}} opts
|
|
165
|
+
* @returns {{text: string, read: string[], saved: string[]}} read = 读进正文的附件名,saved = 落盘的绝对路径
|
|
166
|
+
*/
|
|
167
|
+
export async function applyAttachments(text, files, opts = {}) {
|
|
168
|
+
const src = String(text ?? '');
|
|
169
|
+
const list = Array.isArray(files) ? files : [];
|
|
170
|
+
if (list.length === 0) return { text: src, read: [], saved: [] };
|
|
171
|
+
const marks = [...src.matchAll(PLACEHOLDER_RE)];
|
|
172
|
+
if (marks.length === 0) return { text: src, read: [], saved: [] };
|
|
173
|
+
const ctx = { readText: true, saveBinary: false, roots: [], dataDir: '', messageId: '', log: () => {}, ...opts };
|
|
174
|
+
const read = [];
|
|
175
|
+
const saved = [];
|
|
176
|
+
ctx.read = read;
|
|
177
|
+
ctx.saved = saved;
|
|
178
|
+
const repl = await Promise.all(marks.map((mark, i) => (list[i] === undefined ? null : handleOne(list[i], ctx))));
|
|
179
|
+
// 从后往前替换,前面占位的位置不受影响
|
|
180
|
+
let out = src;
|
|
181
|
+
for (let i = repl.length - 1; i >= 0; i--) {
|
|
182
|
+
if (repl[i] === null) continue;
|
|
183
|
+
const at = marks[i].index;
|
|
184
|
+
out = out.slice(0, at) + repl[i] + out.slice(at + marks[i][0].length);
|
|
185
|
+
}
|
|
186
|
+
return { text: out, read, saved };
|
|
187
|
+
}
|
package/lib/channels/onebot.js
CHANGED
|
@@ -46,7 +46,7 @@ function parseCqString(raw) {
|
|
|
46
46
|
|
|
47
47
|
/**
|
|
48
48
|
* 消息段 → 可读正文 + 图片 + 引用。@ 我本身从正文里去掉(模型不需要看到"@鱼"),@ 别人保留为"@昵称"。
|
|
49
|
-
* @returns {{text: string, images: Array<{url?: string, file?: string, summary?: string}>, replyId: string|null}}
|
|
49
|
+
* @returns {{text: string, images: Array<{url?: string, file?: string, summary?: string}>, files: Array<{name: string, path: string, url: string, size: number}>, replyId: string|null}}
|
|
50
50
|
*/
|
|
51
51
|
export function segmentsToText(segments, { selfId = '' } = {}) {
|
|
52
52
|
const parts = [];
|
|
@@ -54,6 +54,7 @@ export function segmentsToText(segments, { selfId = '' } = {}) {
|
|
|
54
54
|
const links = [];
|
|
55
55
|
const records = [];
|
|
56
56
|
const forwards = [];
|
|
57
|
+
const files = [];
|
|
57
58
|
let replyId = null;
|
|
58
59
|
for (const seg of Array.isArray(segments) ? segments : []) {
|
|
59
60
|
if (!seg || typeof seg !== 'object') continue;
|
|
@@ -81,7 +82,19 @@ export function segmentsToText(segments, { selfId = '' } = {}) {
|
|
|
81
82
|
parts.push('【语音(暂时听不了)】');
|
|
82
83
|
break;
|
|
83
84
|
case 'video': parts.push('【视频】'); break;
|
|
84
|
-
case 'file':
|
|
85
|
+
case 'file':
|
|
86
|
+
// 附件:正文里留占位,能不能读由网关那边看路径和开关决定
|
|
87
|
+
// SnowLuma 不给 path,给的是 file_id(d.id / d.file_id)+ file_hash,要用 get_private_file_url 换链接
|
|
88
|
+
files.push({
|
|
89
|
+
name: sanitizeName(d.name, ''),
|
|
90
|
+
path: d.path ?? '',
|
|
91
|
+
url: d.url ?? '',
|
|
92
|
+
size: Number(d.size) || 0,
|
|
93
|
+
fileId: String(d.id ?? d.file_id ?? '').trim(),
|
|
94
|
+
fileHash: String(d.file_hash ?? '').trim()
|
|
95
|
+
});
|
|
96
|
+
parts.push(`【文件${d.name ? `:${sanitizeName(d.name, '')}` : ''}】`);
|
|
97
|
+
break;
|
|
85
98
|
case 'forward':
|
|
86
99
|
// 网关回复前会用 get_forward_msg 展开(一层,限条数);展开不了就保持这个占位
|
|
87
100
|
forwards.push({ id: String(d.id ?? ''), content: Array.isArray(d.content) ? d.content : null });
|
|
@@ -105,7 +118,7 @@ export function segmentsToText(segments, { selfId = '' } = {}) {
|
|
|
105
118
|
default: parts.push(`【${sanitizeName(seg.type, '消息')}】`);
|
|
106
119
|
}
|
|
107
120
|
}
|
|
108
|
-
return { text: parts.join('').replace(/[ \t]+\n/g, '\n').trim(), images, replyId, links, records, forwards };
|
|
121
|
+
return { text: parts.join('').replace(/[ \t]+\n/g, '\n').trim(), images, replyId, links, records, forwards, files };
|
|
109
122
|
}
|
|
110
123
|
|
|
111
124
|
/**
|
|
@@ -119,7 +132,7 @@ export function normalizeMessageEvent(ev, { selfId = '', ownMessageIds } = {}) {
|
|
|
119
132
|
if (chatType === null) return null;
|
|
120
133
|
const segments = Array.isArray(ev.message) ? ev.message : parseCqString(ev.message ?? ev.raw_message ?? '');
|
|
121
134
|
const self = String(selfId || ev.self_id || '');
|
|
122
|
-
const { text, images, replyId, links, records, forwards } = segmentsToText(segments, { selfId: self });
|
|
135
|
+
const { text, images, replyId, links, records, forwards, files } = segmentsToText(segments, { selfId: self });
|
|
123
136
|
const userId = String(ev.user_id ?? ev.sender?.user_id ?? '');
|
|
124
137
|
const groupId = chatType === 'group' ? String(ev.group_id ?? '') : null;
|
|
125
138
|
const sender = ev.sender ?? {};
|
|
@@ -140,6 +153,7 @@ export function normalizeMessageEvent(ev, { selfId = '', ownMessageIds } = {}) {
|
|
|
140
153
|
links,
|
|
141
154
|
records,
|
|
142
155
|
forwards,
|
|
156
|
+
files,
|
|
143
157
|
timeMs: Number.isFinite(Number(ev.time)) && Number(ev.time) > 0 ? Number(ev.time) * 1000 : Date.now()
|
|
144
158
|
};
|
|
145
159
|
}
|
package/lib/config-schema.js
CHANGED
|
@@ -24,6 +24,10 @@ export function fishSoulConfigSchema(z) {
|
|
|
24
24
|
keywords: z.array(z.string()).default([])
|
|
25
25
|
}).default({})
|
|
26
26
|
}).default({}),
|
|
27
|
+
attachments: z.object({ // QQ 附件:txt/md 这类直接读进提示词;pdf/docx 存到插件数据目录再报路径
|
|
28
|
+
readText: z.boolean().default(true),
|
|
29
|
+
saveBinary: z.boolean().default(false)
|
|
30
|
+
}).default({}),
|
|
27
31
|
lore: z.object({ // 世界书:扫描最近几条消息、每轮最多带几条、总字数预算(条目自己写了 scanDepth 的以条目为准)
|
|
28
32
|
scanDepth: z.number().default(4),
|
|
29
33
|
maxEntries: z.number().default(6),
|
|
@@ -59,6 +63,9 @@ export function fishSoulConfigSchema(z) {
|
|
|
59
63
|
collect: z.boolean().default(true), // 她觉得好玩的表情包存进 _stickers
|
|
60
64
|
perDay: z.number().default(10)
|
|
61
65
|
}).default({}),
|
|
66
|
+
images: z.object({ // 发图:打开后模型可以发工作区里任意图片(默认只发 _stickers 里的表情包)
|
|
67
|
+
sendAny: z.boolean().default(false)
|
|
68
|
+
}).default({}),
|
|
62
69
|
slang: z.object({
|
|
63
70
|
enabled: z.boolean().default(true), // 学群里的梗 / 说话风格
|
|
64
71
|
minLines: z.number().default(60),
|
package/lib/gateway.js
CHANGED
|
@@ -20,13 +20,14 @@
|
|
|
20
20
|
* 连发按字数估打字时间;每个聊天能单独暂停(设置页 / 主人发"/鱼 暂停");每一步判定记进"动态"。
|
|
21
21
|
*/
|
|
22
22
|
import { randomUUID } from 'node:crypto';
|
|
23
|
-
import { existsSync, mkdirSync, readdirSync } from 'node:fs';
|
|
23
|
+
import { existsSync, mkdirSync, readdirSync, statSync } from 'node:fs';
|
|
24
24
|
import { extname, join, resolve } from 'node:path';
|
|
25
25
|
import {
|
|
26
26
|
fishSoulDir, findChatSession, findChatSessionBySessionId, insertChatSession, listChatSessions,
|
|
27
27
|
findPersonByQq, findPersonByBili, findPersonByDouyin, getKv, setKv
|
|
28
28
|
} from './db.js';
|
|
29
29
|
import { normalizeMessageEvent, sanitizeName, sanitizeUserText } from './channels/onebot.js';
|
|
30
|
+
import { applyAttachments, attachmentConfig } from './attachments.js';
|
|
30
31
|
import { parseCollect } from './stickers.js';
|
|
31
32
|
import { UNHEARD } from './voice.js';
|
|
32
33
|
import { typingDelayMs, interjectScore, interjectConfig, platformOf, splitChannelId, sessionChannelOf, service } from './gateway-util.js';
|
|
@@ -52,6 +53,8 @@ export const OWN_WEB_TOOL = 'fish_search';
|
|
|
52
53
|
const VOICE_RE = /^\s*\[语音\]\s*/;
|
|
53
54
|
const SILENT_RE = /^\s*\[(不回|沉默|skip)\]/i; // 开头就是 [不回]:这轮不发(后面跟的解释也不发)
|
|
54
55
|
const IMAGE_EXT = new Set(['.png', '.jpg', '.jpeg', '.gif', '.webp']);
|
|
56
|
+
/** 发图清单最多给模型多少张。 */
|
|
57
|
+
const IMAGE_LIST_LIMIT = 64;
|
|
55
58
|
const MAX_PARAGRAPHS = 5;
|
|
56
59
|
const SEEN_LIMIT = 4000;
|
|
57
60
|
const OWN_LIMIT = 500;
|
|
@@ -69,6 +72,41 @@ export function dedicatedWorkspacePath() {
|
|
|
69
72
|
return join(fishSoulDir(), 'workspace');
|
|
70
73
|
}
|
|
71
74
|
|
|
75
|
+
/** 文件修改时间(拿不到就当最旧,排序时沉底)。 */
|
|
76
|
+
function fileMtime(p) {
|
|
77
|
+
try { return statSync(p).mtimeMs; } catch { return 0; }
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* 扫工作区根目录 + 一级子目录里的图片:隐藏目录不看,_stickers 归表情包管不重复收。
|
|
82
|
+
* 返回 [{ rel, mtimeMs }],rel 是相对工作区根的路径(模型照着写就能发出去)。
|
|
83
|
+
*/
|
|
84
|
+
function scanWorkspaceImages(workspacePath) {
|
|
85
|
+
const out = [];
|
|
86
|
+
const walk = (dir, prefix, depth) => {
|
|
87
|
+
let entries = [];
|
|
88
|
+
try { entries = readdirSync(dir, { withFileTypes: true }); } catch { return; }
|
|
89
|
+
for (const e of entries) {
|
|
90
|
+
const name = e.name;
|
|
91
|
+
if (name.startsWith('.')) continue;
|
|
92
|
+
const full = join(dir, name);
|
|
93
|
+
if (e.isDirectory()) {
|
|
94
|
+
if (depth <= 0) continue;
|
|
95
|
+
if (prefix === '' && name === '_stickers') continue;
|
|
96
|
+
walk(full, `${prefix}${name}/`, depth - 1);
|
|
97
|
+
continue;
|
|
98
|
+
}
|
|
99
|
+
if (!e.isFile()) continue;
|
|
100
|
+
if (!IMAGE_EXT.has(extname(name).toLowerCase())) continue;
|
|
101
|
+
// 文件名带空格/括号发不出去(markdown  解析不了),不进清单
|
|
102
|
+
if (/[\s()#]/.test(name)) continue;
|
|
103
|
+
out.push({ rel: `${prefix}${name}`, mtimeMs: fileMtime(full) });
|
|
104
|
+
}
|
|
105
|
+
};
|
|
106
|
+
walk(workspacePath, '', 1);
|
|
107
|
+
return out;
|
|
108
|
+
}
|
|
109
|
+
|
|
72
110
|
export class ChatGateway {
|
|
73
111
|
/**
|
|
74
112
|
* @param {object} deps
|
|
@@ -345,6 +383,23 @@ export class ChatGateway {
|
|
|
345
383
|
}
|
|
346
384
|
}
|
|
347
385
|
|
|
386
|
+
/**
|
|
387
|
+
* 发图清单(给模型看的):_stickers/ 里的表情包 + (images.sendAny 打开时)工作区里任意图片。
|
|
388
|
+
* 都是相对工作区根的路径,模型照着写  就能发出去。
|
|
389
|
+
*/
|
|
390
|
+
listImages(workspacePath) {
|
|
391
|
+
const ws = String(workspacePath ?? '').trim();
|
|
392
|
+
const stickers = this.listStickers(ws).map((f) => ({ rel: `_stickers/${f}`, mtimeMs: fileMtime(join(ws, '_stickers', f)) }));
|
|
393
|
+
const sendAny = this.getConfig?.()?.images?.sendAny === true;
|
|
394
|
+
if (!sendAny || ws === '') return stickers.map((x) => x.rel);
|
|
395
|
+
let extra = [];
|
|
396
|
+
try { extra = scanWorkspaceImages(ws); } catch { return stickers.map((x) => x.rel); }
|
|
397
|
+
// 表情包保底收录:它们和新图赛 mtime 会被挤出去(老逻辑保证表情包一直在清单里)
|
|
398
|
+
const keep = Math.min(stickers.length, IMAGE_LIST_LIMIT);
|
|
399
|
+
const rest = [...extra].sort((a, b) => b.mtimeMs - a.mtimeMs).slice(0, IMAGE_LIST_LIMIT - keep);
|
|
400
|
+
return [...stickers.slice(0, keep), ...rest].map((x) => x.rel);
|
|
401
|
+
}
|
|
402
|
+
|
|
348
403
|
// ───────────────────────── 会话 ─────────────────────────
|
|
349
404
|
|
|
350
405
|
agentsService() {
|
|
@@ -662,6 +717,7 @@ export class ChatGateway {
|
|
|
662
717
|
return;
|
|
663
718
|
}
|
|
664
719
|
if ((m.records ?? []).length > 0) await this.hearVoice(m);
|
|
720
|
+
if ((m.files ?? []).length > 0) await this.readAttachments(m);
|
|
665
721
|
if ((m.forwards ?? []).length > 0) await this.expandForwards(m);
|
|
666
722
|
if (!interject) void this.reactTo(m, person);
|
|
667
723
|
this.note(interject ? 'interject' : 'reply', { channelId: m.channelId, who, text: m.text, reason });
|
|
@@ -682,6 +738,55 @@ export class ChatGateway {
|
|
|
682
738
|
m.text = String(m.text ?? '').split(UNHEARD).map((seg, idx, arr) => (idx < arr.length - 1 ? seg + (parts[i++] ?? UNHEARD) : seg)).join('');
|
|
683
739
|
}
|
|
684
740
|
|
|
741
|
+
/**
|
|
742
|
+
* QQ 附件 → 正文:txt/md 这类直接读内容,pdf/docx 存到插件数据目录再报路径。
|
|
743
|
+
* SnowLuma 这类不给本地 path 的,用 file_id 走 get_private_file_url 换链接下载。
|
|
744
|
+
* 读不了 / 下载失败 / 路径不在白名单目录里的,保留原来的【文件:名字】占位。
|
|
745
|
+
*/
|
|
746
|
+
async readAttachments(m) {
|
|
747
|
+
const cfg = attachmentConfig(this.getConfig());
|
|
748
|
+
const { text } = await applyAttachments(m.text, m.files, {
|
|
749
|
+
readText: cfg.readText,
|
|
750
|
+
saveBinary: cfg.saveBinary,
|
|
751
|
+
roots: [this.workspacePath(), fishSoulDir()],
|
|
752
|
+
dataDir: join(fishSoulDir(), 'files'),
|
|
753
|
+
messageId: m.messageId,
|
|
754
|
+
log: this.log,
|
|
755
|
+
fetchFile: ({ fileId, fileHash, url }) => this.fetchQqFile(m, { fileId, fileHash, url })
|
|
756
|
+
});
|
|
757
|
+
m.text = text;
|
|
758
|
+
}
|
|
759
|
+
|
|
760
|
+
/** 用 OneBot API 把 QQ 文件拉下来(SnowLuma:get_private_file_url → 直链下载)。 */
|
|
761
|
+
async fetchQqFile(m, { fileId, fileHash, url }) {
|
|
762
|
+
if (typeof this.channel?.call !== 'function') return null;
|
|
763
|
+
// d.url 有就直接用(有些实现会带)
|
|
764
|
+
let link = String(url ?? '').trim();
|
|
765
|
+
if (link === '' && fileId !== '') {
|
|
766
|
+
try {
|
|
767
|
+
const isGroup = m.chatType === 'group';
|
|
768
|
+
const res = await this.channel.call(isGroup ? 'get_group_file_url' : 'get_private_file_url', {
|
|
769
|
+
user_id: Number(m.userId) || m.userId,
|
|
770
|
+
group_id: Number(m.channelId.replace(/^group:/, '')) || undefined,
|
|
771
|
+
file_id: fileId,
|
|
772
|
+
file_hash: fileHash ?? ''
|
|
773
|
+
});
|
|
774
|
+
link = String(res?.url ?? res?.data?.url ?? '').trim();
|
|
775
|
+
} catch {
|
|
776
|
+
return null;
|
|
777
|
+
}
|
|
778
|
+
}
|
|
779
|
+
if (link === '' || !/^https?:/i.test(link)) return null;
|
|
780
|
+
try {
|
|
781
|
+
const res = await fetch(link, { signal: AbortSignal.timeout(20000) });
|
|
782
|
+
if (!res.ok) return null;
|
|
783
|
+
const bytes = Buffer.from(await res.arrayBuffer());
|
|
784
|
+
return bytes.length > 0 ? bytes : null;
|
|
785
|
+
} catch {
|
|
786
|
+
return null;
|
|
787
|
+
}
|
|
788
|
+
}
|
|
789
|
+
|
|
685
790
|
/** 群里没点名的话攒起来(被叫到时一起给她看);同时数一分钟内的条数(判断是不是在刷屏)。 */
|
|
686
791
|
pushBacklog(m) {
|
|
687
792
|
const list = (this.backlog.get(m.channelId) ?? []).filter((x) => Date.now() - x.timeMs < BACKLOG_MAX_AGE_MS);
|
package/lib/index.js
CHANGED
|
@@ -614,13 +614,13 @@ async function startup(ctx, config, { log, gate }) {
|
|
|
614
614
|
const lore = matchLore(loreEntries, [...loreMsgs, ...speakers.map((s) => s.query)], loreCfg);
|
|
615
615
|
if (lore.length > 0) lines.push('[设定补充]', ...lore.map((e) => `- ${applyMacros(e.content, { charName: persona.name, userName })}`));
|
|
616
616
|
if (isQq) {
|
|
617
|
-
const
|
|
617
|
+
const images = gateway?.listImages(session.workspacePath) ?? [];
|
|
618
618
|
lines.push('[QQ 回复规则] 你的最后一条回复会原样发到 QQ' +
|
|
619
619
|
(session.channel === 'qq-group'
|
|
620
620
|
? '群里(群消息格式是"昵称:内容",你回的时候直接说、前面别加自己的名字;记的是谁的事,remember 时 speaker 就填谁的署名)'
|
|
621
621
|
: '私聊') +
|
|
622
622
|
';空一行会拆成多条消息;不想接话就只输出 [不回]。' +
|
|
623
|
-
(
|
|
623
|
+
(images.length > 0 ? `发图写 ,可用:${images.join('、')}` : ''));
|
|
624
624
|
lines.push(...voiceLines({ v: voiceConfig(currentConfig.voice, currentConfig.embedding), ttsReady: voice.readyFor('tts'), session, speakers, where: 'QQ' }));
|
|
625
625
|
}
|
|
626
626
|
if (isQq || isBili || isDouyin) {
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dsh-fish-soul",
|
|
3
|
-
"version": "0.13.
|
|
4
|
-
"description": "鱼 · 人格、按人记忆(可纠正、有印象、能导入旧聊天记录)、日程自走与主动开口,直连 SnowLuma 收发 QQ(群里会自己插话),B站 / 抖音小号私信与自己刷视频;按功能选模型并统计用量花费,能听会说 QQ 语音、收藏表情包、学群里的梗,可选上网查;心情影响说话、去 AI 味、每个群的设定、按聊天选模型、每晚写日记(DSH 插件,单角色 feiyu)",
|
|
3
|
+
"version": "0.13.9",
|
|
4
|
+
"description": "鱼 · 人格、按人记忆(可纠正、有印象、能导入旧聊天记录)、日程自走与主动开口,直连 SnowLuma 收发 QQ(群里会自己插话),B站 / 抖音小号私信与自己刷视频;按功能选模型并统计用量花费,能听会说 QQ 语音、收藏表情包、学群里的梗,可选上网查;心情影响说话、去 AI 味、每个群的设定、按聊天选模型、每晚写日记(DSH 插件,单角色 feiyu ,v0.13.9)",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./lib/index.js",
|
|
7
7
|
"files": [
|
|
@@ -62,7 +62,7 @@
|
|
|
62
62
|
}
|
|
63
63
|
},
|
|
64
64
|
"scripts": {
|
|
65
|
-
"test": "node test/smoke.mjs && node test/douyin-browser.mjs && node test/startup-gate.mjs && node test/outbox.mjs && node test/import-coverage.mjs && node test/update-check.mjs && node test/issue-fixes.mjs && node test/import-resume.mjs && node test/maibot-extras.mjs && node test/worldinfo.mjs && node test/memory-bulk.mjs && node test/backup-restore.mjs && node test/douyin-group.mjs && node test/review-fixes.mjs && node test/at-reads-video.mjs && node test/search-source.mjs && node test/followups.mjs && node test/detail-config.mjs && node test/client-lint.mjs",
|
|
65
|
+
"test": "node test/smoke.mjs && node test/douyin-browser.mjs && node test/startup-gate.mjs && node test/outbox.mjs && node test/import-coverage.mjs && node test/update-check.mjs && node test/issue-fixes.mjs && node test/import-resume.mjs && node test/maibot-extras.mjs && node test/worldinfo.mjs && node test/memory-bulk.mjs && node test/backup-restore.mjs && node test/douyin-group.mjs && node test/review-fixes.mjs && node test/at-reads-video.mjs && node test/search-source.mjs && node test/followups.mjs && node test/detail-config.mjs && node test/qq-send-any-image.mjs && node test/qq-read-attachment.mjs && node test/client-lint.mjs",
|
|
66
66
|
"eval": "node test/eval/persona-eval.mjs"
|
|
67
67
|
}
|
|
68
68
|
}
|