@napuketto/adapter 0.0.2 → 0.0.4
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/dist/onebot11/index.d.mts +103 -103
- package/dist/onebot11/index.d.mts.map +1 -1
- package/dist/onebot11/index.mjs +145 -120
- package/dist/onebot11/index.mjs.map +1 -1
- package/dist/satori/index.d.mts +94 -85
- package/dist/satori/index.d.mts.map +1 -1
- package/dist/satori/index.mjs +334 -325
- package/dist/satori/index.mjs.map +1 -1
- package/package.json +3 -3
package/dist/satori/index.mjs
CHANGED
|
@@ -6,6 +6,31 @@ import { join } from "node:path";
|
|
|
6
6
|
import { HttpServer, WsServer } from "@napuketto/network";
|
|
7
7
|
import { mkdir, open, writeFile } from "node:fs/promises";
|
|
8
8
|
import { encodePcmToSilk } from "@napuketto/media";
|
|
9
|
+
//#region src/satori/helper/config.ts
|
|
10
|
+
/**
|
|
11
|
+
* Satori 协议配置(zod schema,归属本包,ADR-012)
|
|
12
|
+
* 协议配置 schema 在各自协议包,kernel 只提供 ConfigBase 基类。
|
|
13
|
+
*/
|
|
14
|
+
/** HTTP RPC 服务器默认端口(satori.chat 惯例 5500 区间,取 5500)。 */
|
|
15
|
+
const DEFAULT_HTTP_PORT = 5500;
|
|
16
|
+
/** WS 事件服务默认端口(satori.chat 惯例 5500 区间,取 5501)。 */
|
|
17
|
+
const DEFAULT_WS_PORT = 5501;
|
|
18
|
+
/** HTTP RPC 服务器实例(POST /v1/{resource}.{method})。 */
|
|
19
|
+
const httpServerSchema = createHttpServerSchema(DEFAULT_HTTP_PORT);
|
|
20
|
+
/** WS 事件服务实例(/v1/events 信令,第三方 SDK 连入)。 */
|
|
21
|
+
const wsServerSchema = createWsServerSchema(DEFAULT_WS_PORT);
|
|
22
|
+
/** Satori 配置 schema。 */
|
|
23
|
+
const satoriConfigSchema = z.object({
|
|
24
|
+
/** 鉴权 token(全局默认,实例未指定时继承)。 */
|
|
25
|
+
token: z.string().optional(),
|
|
26
|
+
/** 媒体资源(img/audio/video/file)下载缓存目录。 */
|
|
27
|
+
cacheDir: z.string().optional(),
|
|
28
|
+
/** HTTP RPC 服务器列表。 */
|
|
29
|
+
httpServers: z.array(httpServerSchema).default([]),
|
|
30
|
+
/** WS 事件服务列表。 */
|
|
31
|
+
wsServers: z.array(wsServerSchema).default([])
|
|
32
|
+
});
|
|
33
|
+
//#endregion
|
|
9
34
|
//#region src/satori/helper/error.ts
|
|
10
35
|
/** KernelErrorCode → HTTP 状态码映射表。 */
|
|
11
36
|
const satoriHttpStatusMap = {
|
|
@@ -28,47 +53,65 @@ const HTTP_STATUS = {
|
|
|
28
53
|
notImplemented: 501,
|
|
29
54
|
serverError: 500
|
|
30
55
|
};
|
|
56
|
+
/** 适配器名(固定 napuketto)。 */
|
|
57
|
+
const ADAPTER = "napuketto";
|
|
58
|
+
/** 平台特性(API 可用性判断;群聊频道与群组重合 → guild.plain)。 */
|
|
59
|
+
const FEATURES = ["guild.plain", "message.list.from"];
|
|
60
|
+
/** 构造用户(uin)。 */
|
|
61
|
+
function toUser(id, name, nick) {
|
|
62
|
+
const user = { id };
|
|
63
|
+
if (name !== void 0 && name !== "") user.name = name;
|
|
64
|
+
if (nick !== void 0 && nick !== "") user.nick = nick;
|
|
65
|
+
return user;
|
|
66
|
+
}
|
|
67
|
+
/** 构造群聊频道(id = 群号)。 */
|
|
68
|
+
function toGroupChannel(groupCode, name) {
|
|
69
|
+
const channel = {
|
|
70
|
+
id: String(groupCode),
|
|
71
|
+
type: 0
|
|
72
|
+
};
|
|
73
|
+
if (name !== void 0 && name !== "") channel.name = name;
|
|
74
|
+
return channel;
|
|
75
|
+
}
|
|
76
|
+
/** 构造私聊频道(id = 对端 uin)。 */
|
|
77
|
+
function toDirectChannel(uin, name) {
|
|
78
|
+
const channel = {
|
|
79
|
+
id: String(uin),
|
|
80
|
+
type: 1
|
|
81
|
+
};
|
|
82
|
+
if (name !== void 0 && name !== "") channel.name = name;
|
|
83
|
+
return channel;
|
|
84
|
+
}
|
|
85
|
+
/** 构造群组(id = 群号)。 */
|
|
86
|
+
function toGuild(groupCode, name, avatar) {
|
|
87
|
+
const guild = { id: String(groupCode) };
|
|
88
|
+
if (name !== void 0 && name !== "") guild.name = name;
|
|
89
|
+
if (avatar !== void 0 && avatar !== "") guild.avatar = avatar;
|
|
90
|
+
return guild;
|
|
91
|
+
}
|
|
92
|
+
/** 构造登录信息(login.get / READY logins)。 */
|
|
93
|
+
function toLogin(self, sn, online) {
|
|
94
|
+
return {
|
|
95
|
+
sn,
|
|
96
|
+
platform: "qq",
|
|
97
|
+
user: toUser(self.uin, self.nickname),
|
|
98
|
+
status: online ? 1 : 0,
|
|
99
|
+
adapter: ADAPTER,
|
|
100
|
+
features: [...FEATURES]
|
|
101
|
+
};
|
|
102
|
+
}
|
|
103
|
+
/** 构造非登录事件的最小 login(只带 sn/user/platform,规范)。 */
|
|
104
|
+
function toMinimalLogin(sn, selfUin) {
|
|
105
|
+
return {
|
|
106
|
+
sn,
|
|
107
|
+
platform: "qq",
|
|
108
|
+
user: toUser(selfUin),
|
|
109
|
+
status: 1,
|
|
110
|
+
adapter: ADAPTER
|
|
111
|
+
};
|
|
112
|
+
}
|
|
31
113
|
//#endregion
|
|
32
|
-
//#region src/satori/
|
|
33
|
-
/**
|
|
34
|
-
* Satori 动作基类(响应形态与 OB11 不同:直接返回资源对象,错误用 HTTP 状态码)
|
|
35
|
-
*
|
|
36
|
-
* - `run(payload)`:zod 校验失败 → SatoriActionError(400);_handle 抛的
|
|
37
|
-
* KernelError → satoriHttpStatusMap 映射 HTTP 状态码(ADR-017)
|
|
38
|
-
* - `SatoriActionError`:带 HTTP 状态码的动作错误(transport 层直接透传状态码)
|
|
39
|
-
*/
|
|
40
|
-
/** 动作错误(带 HTTP 状态码;由 transport 层透传)。 */
|
|
41
|
-
var SatoriActionError = class extends Error {
|
|
42
|
-
status;
|
|
43
|
-
constructor(status, message) {
|
|
44
|
-
super(message);
|
|
45
|
-
this.status = status;
|
|
46
|
-
}
|
|
47
|
-
};
|
|
48
|
-
/** 校验失败默认状态码。 */
|
|
49
|
-
const INVALID_PARAM_STATUS = 400;
|
|
50
|
-
/** 兜底错误状态码。 */
|
|
51
|
-
const UNKNOWN_STATUS = 500;
|
|
52
|
-
/**
|
|
53
|
-
* Satori 动作基类:校验 + 执行 + 错误映射。
|
|
54
|
-
* 成功返回资源对象(或数组/分页列表),失败抛 SatoriActionError。
|
|
55
|
-
*/
|
|
56
|
-
var BaseSatoriAction = class {
|
|
57
|
-
/** 校验并执行(HTTP/WS 共用)。 */
|
|
58
|
-
async run(payload) {
|
|
59
|
-
const parsed = this.schema.safeParse(payload);
|
|
60
|
-
if (!parsed.success) throw new SatoriActionError(INVALID_PARAM_STATUS, "参数校验失败");
|
|
61
|
-
try {
|
|
62
|
-
return await this._handle(parsed.data);
|
|
63
|
-
} catch (err) {
|
|
64
|
-
if (err instanceof SatoriActionError) throw err;
|
|
65
|
-
if (isKernelError(err)) throw new SatoriActionError(satoriHttpStatusMap[err.code] ?? UNKNOWN_STATUS, err.message);
|
|
66
|
-
throw new SatoriActionError(UNKNOWN_STATUS, err instanceof Error ? err.message : "未知错误");
|
|
67
|
-
}
|
|
68
|
-
}
|
|
69
|
-
};
|
|
70
|
-
//#endregion
|
|
71
|
-
//#region src/satori/helper/canonical.ts
|
|
114
|
+
//#region src/satori/helper/element/canonical.ts
|
|
72
115
|
/** 收集 at uid → 批量 uidToUin(失败不阻塞,at 原样 uid)。 */
|
|
73
116
|
async function resolveAtUids(elements, uidToUin) {
|
|
74
117
|
const atUids = [];
|
|
@@ -163,7 +206,7 @@ async function canonicalToSatoriElements(elements, deps = {}) {
|
|
|
163
206
|
return out;
|
|
164
207
|
}
|
|
165
208
|
//#endregion
|
|
166
|
-
//#region src/satori/helper/parse-tag.ts
|
|
209
|
+
//#region src/satori/helper/element/parse-tag.ts
|
|
167
210
|
/**
|
|
168
211
|
* Satori 标签读取(元素名 / 属性解析,从 element.ts 拆分,2026-08-08 FTA 优化)
|
|
169
212
|
*
|
|
@@ -294,7 +337,7 @@ function readTag(source, pos) {
|
|
|
294
337
|
};
|
|
295
338
|
}
|
|
296
339
|
//#endregion
|
|
297
|
-
//#region src/satori/helper/element.ts
|
|
340
|
+
//#region src/satori/helper/element/element.ts
|
|
298
341
|
/**
|
|
299
342
|
* Satori 消息元素解析/渲染(协议 v1,自研实现,规范参考 satori.chat/zh-CN/protocol/message.html)
|
|
300
343
|
*
|
|
@@ -471,62 +514,222 @@ function renderElement(el) {
|
|
|
471
514
|
const inner = renderElements(children);
|
|
472
515
|
return `<${el.type}${attrs}>${inner}</${el.type}>`;
|
|
473
516
|
}
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
/**
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
517
|
+
//#endregion
|
|
518
|
+
//#region src/satori/helper/element/asset.ts
|
|
519
|
+
/**
|
|
520
|
+
* Satori 发方向资源处理(从 element-convert.ts 拆分,2026-08-08 FTA 优化)
|
|
521
|
+
*
|
|
522
|
+
* - resolveAsset:internal: 路径 / http(s) 下载 / 本地路径原样
|
|
523
|
+
* - ensureSilk:非 silk 语音(wav/pcm)转 silk(QQ 语音格式,失败原样返回)
|
|
524
|
+
*/
|
|
525
|
+
/** internal:{platform}/{user.id}/{path} → 本地路径(取 _tmp 之后的路径,回落 cacheDir)。 */
|
|
526
|
+
function resolveInternalPath(src) {
|
|
527
|
+
const pathPart = src.slice(9);
|
|
528
|
+
const slash1 = pathPart.indexOf("/");
|
|
529
|
+
const slash2 = slash1 === -1 ? -1 : pathPart.indexOf("/", slash1 + 1);
|
|
530
|
+
if (slash2 === -1) return pathPart;
|
|
531
|
+
const rest = pathPart.slice(slash2 + 1);
|
|
532
|
+
const tmpIdx = rest.indexOf("_tmp/");
|
|
533
|
+
return tmpIdx === -1 ? rest : rest.slice(tmpIdx + 5);
|
|
484
534
|
}
|
|
485
|
-
/**
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
return
|
|
535
|
+
/**
|
|
536
|
+
* 解析资源 src:
|
|
537
|
+
* - internal:{platform}/{user.id}/{path} → 本地路径(第一版仅支持 _tmp 保留路径)
|
|
538
|
+
* - http(s):// → 下载到 cacheDir
|
|
539
|
+
* - 其他 → 视为本地路径原样返回
|
|
540
|
+
*/
|
|
541
|
+
async function resolveAsset(src, cacheDir) {
|
|
542
|
+
if (src.startsWith("internal:")) return resolveInternalPath(src);
|
|
543
|
+
if (/^https?:\/\//i.test(src)) return downloadAsset(src, cacheDir);
|
|
544
|
+
return src;
|
|
493
545
|
}
|
|
494
|
-
/**
|
|
495
|
-
function
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
546
|
+
/** 下载 URL 资源到缓存目录(返回本地路径)。 */
|
|
547
|
+
async function downloadAsset(url, cacheDir) {
|
|
548
|
+
await mkdir(cacheDir, { recursive: true });
|
|
549
|
+
const res = await fetch(url);
|
|
550
|
+
if (!res.ok) throw kernelError(`下载资源失败: ${url}`, "SEND_FAILED");
|
|
551
|
+
const buf = Buffer.from(await res.arrayBuffer());
|
|
552
|
+
const safeName = (url.split("/").pop() ?? `asset-${Date.now()}`).replace(/[^a-zA-Z0-9._-]/g, "_") || `asset-${Date.now()}`;
|
|
553
|
+
const filePath = join(cacheDir, safeName);
|
|
554
|
+
await writeFile(filePath, buf);
|
|
555
|
+
return filePath;
|
|
556
|
+
}
|
|
557
|
+
/** 语音转码:非 silk 输入(wav/pcm)转 silk(QQ 语音格式)。 */
|
|
558
|
+
async function ensureSilk(path) {
|
|
559
|
+
try {
|
|
560
|
+
if ((await readFileHead(path)).startsWith("#!SILK")) return path;
|
|
561
|
+
return await encodePcmToSilk(path);
|
|
562
|
+
} catch {
|
|
563
|
+
return path;
|
|
564
|
+
}
|
|
565
|
+
}
|
|
566
|
+
/** 读文件头 8 字节(判断格式)。 */
|
|
567
|
+
async function readFileHead(path) {
|
|
568
|
+
const handle = await open(path, "r");
|
|
569
|
+
try {
|
|
570
|
+
const buf = Buffer.alloc(8);
|
|
571
|
+
await handle.read(buf, 0, 8, 0);
|
|
572
|
+
return buf.toString("utf8");
|
|
573
|
+
} finally {
|
|
574
|
+
await handle.close();
|
|
575
|
+
}
|
|
576
|
+
}
|
|
577
|
+
//#endregion
|
|
578
|
+
//#region src/satori/helper/element/media-convert.ts
|
|
579
|
+
/** 解析元素媒体 src(空 → null 忽略),返回本地路径。 */
|
|
580
|
+
async function resolveMediaSrc(el, deps) {
|
|
581
|
+
const src = el.attrs?.["src"] ?? "";
|
|
582
|
+
if (src === "") return null;
|
|
583
|
+
return resolveAsset(src, deps.cacheDir);
|
|
584
|
+
}
|
|
585
|
+
/** Satori img → canonical image。 */
|
|
586
|
+
async function imgToCanonical(el, deps) {
|
|
587
|
+
const path = await resolveMediaSrc(el, deps);
|
|
588
|
+
return path === null ? [] : [{
|
|
589
|
+
type: "image",
|
|
590
|
+
path
|
|
591
|
+
}];
|
|
592
|
+
}
|
|
593
|
+
/** Satori audio → canonical voice(非 silk 转码)。 */
|
|
594
|
+
async function audioToCanonical(el, deps) {
|
|
595
|
+
const path = await resolveMediaSrc(el, deps);
|
|
596
|
+
return path === null ? [] : [{
|
|
597
|
+
type: "voice",
|
|
598
|
+
path: await ensureSilk(path)
|
|
599
|
+
}];
|
|
600
|
+
}
|
|
601
|
+
/** Satori video → canonical video。 */
|
|
602
|
+
async function videoToCanonical(el, deps) {
|
|
603
|
+
const path = await resolveMediaSrc(el, deps);
|
|
604
|
+
return path === null ? [] : [{
|
|
605
|
+
type: "video",
|
|
606
|
+
path
|
|
607
|
+
}];
|
|
608
|
+
}
|
|
609
|
+
/** Satori file → canonical file(title → name)。 */
|
|
610
|
+
async function fileToCanonical(el, deps) {
|
|
611
|
+
const path = await resolveMediaSrc(el, deps);
|
|
612
|
+
if (path === null) return [];
|
|
613
|
+
const title = el.attrs?.["title"];
|
|
614
|
+
const base = {
|
|
615
|
+
type: "file",
|
|
616
|
+
path
|
|
499
617
|
};
|
|
500
|
-
if (
|
|
501
|
-
|
|
618
|
+
if (title !== void 0 && title !== "") return [{
|
|
619
|
+
...base,
|
|
620
|
+
name: title
|
|
621
|
+
}];
|
|
622
|
+
return [base];
|
|
502
623
|
}
|
|
503
|
-
/**
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
624
|
+
/** 媒体元素转换器子表(并入 element-convert 主表)。 */
|
|
625
|
+
const MEDIA_CONVERTERS = {
|
|
626
|
+
img: imgToCanonical,
|
|
627
|
+
audio: audioToCanonical,
|
|
628
|
+
video: videoToCanonical,
|
|
629
|
+
file: fileToCanonical
|
|
630
|
+
};
|
|
631
|
+
//#endregion
|
|
632
|
+
//#region src/satori/helper/element/element-convert.ts
|
|
633
|
+
/** 发方向:Satori 元素树 → kernel canonical(at uin→uid + 资源下载/转码)。 */
|
|
634
|
+
async function satoriToCanonicalElements(elements, deps) {
|
|
635
|
+
const atUins = [];
|
|
636
|
+
const collectAt = (list) => {
|
|
637
|
+
for (const el of list) {
|
|
638
|
+
const id = el.attrs?.["id"];
|
|
639
|
+
if (el.type === "at" && id !== void 0 && id !== "all") atUins.push(id);
|
|
640
|
+
if (el.children !== void 0) collectAt(el.children);
|
|
641
|
+
}
|
|
642
|
+
};
|
|
643
|
+
collectAt(elements);
|
|
644
|
+
let uinToUidMap = /* @__PURE__ */ new Map();
|
|
645
|
+
if (atUins.length > 0) try {
|
|
646
|
+
uinToUidMap = await deps.uinToUid([...new Set(atUins)]);
|
|
647
|
+
} catch {}
|
|
648
|
+
const out = [];
|
|
649
|
+
for (const el of elements) out.push(...await elementToCanonical(el, deps, uinToUidMap));
|
|
650
|
+
return out;
|
|
509
651
|
}
|
|
510
|
-
/**
|
|
511
|
-
function
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
652
|
+
/** Satori at → canonical at(uin → uid,name → display)。 */
|
|
653
|
+
function atToCanonical(el, uinToUidMap) {
|
|
654
|
+
const id = el.attrs?.["id"] ?? "";
|
|
655
|
+
if (id === "all") return [{
|
|
656
|
+
type: "at",
|
|
657
|
+
target: "all"
|
|
658
|
+
}];
|
|
659
|
+
if (id === "") return [];
|
|
660
|
+
const target = uinToUidMap.get(id) ?? id;
|
|
661
|
+
const display = el.attrs?.["name"];
|
|
662
|
+
const base = {
|
|
663
|
+
type: "at",
|
|
664
|
+
target
|
|
519
665
|
};
|
|
666
|
+
if (display !== void 0 && display !== "") return [{
|
|
667
|
+
...base,
|
|
668
|
+
display
|
|
669
|
+
}];
|
|
670
|
+
return [base];
|
|
671
|
+
}
|
|
672
|
+
/** Satori message:forward 属性 → canonical forward;否则展开子元素。 */
|
|
673
|
+
async function messageToCanonical(el, deps, uinToUidMap) {
|
|
674
|
+
if (el.attrs?.["forward"] !== void 0) {
|
|
675
|
+
const id = el.attrs?.["id"] ?? "";
|
|
676
|
+
return id === "" ? [] : [{
|
|
677
|
+
type: "forward",
|
|
678
|
+
messageIds: [id]
|
|
679
|
+
}];
|
|
680
|
+
}
|
|
681
|
+
return flattenChildren(el.children, deps, uinToUidMap);
|
|
682
|
+
}
|
|
683
|
+
/** 元素 type → 转换器(未知/修饰/排版类型走 fallback,不在此表)。 */
|
|
684
|
+
const ELEMENT_CONVERTERS = {
|
|
685
|
+
text: async (el) => {
|
|
686
|
+
const text = el.text ?? "";
|
|
687
|
+
return text === "" ? [] : [{
|
|
688
|
+
type: "text",
|
|
689
|
+
text
|
|
690
|
+
}];
|
|
691
|
+
},
|
|
692
|
+
at: async (el, _deps, uinToUidMap) => atToCanonical(el, uinToUidMap),
|
|
693
|
+
emoji: async (el) => {
|
|
694
|
+
const id = el.attrs?.["id"] ?? "";
|
|
695
|
+
return id === "" ? [] : [{
|
|
696
|
+
type: "face",
|
|
697
|
+
id
|
|
698
|
+
}];
|
|
699
|
+
},
|
|
700
|
+
quote: async (el) => {
|
|
701
|
+
const id = el.attrs?.["id"] ?? "";
|
|
702
|
+
return id === "" ? [] : [{
|
|
703
|
+
type: "reply",
|
|
704
|
+
messageId: id
|
|
705
|
+
}];
|
|
706
|
+
},
|
|
707
|
+
message: async (el, deps, uinToUidMap) => messageToCanonical(el, deps, uinToUidMap),
|
|
708
|
+
br: async () => [{
|
|
709
|
+
type: "text",
|
|
710
|
+
text: "\n"
|
|
711
|
+
}],
|
|
712
|
+
author: async () => [],
|
|
713
|
+
sharp: async () => [],
|
|
714
|
+
a: async () => [],
|
|
715
|
+
button: async () => [],
|
|
716
|
+
...MEDIA_CONVERTERS
|
|
717
|
+
};
|
|
718
|
+
/** 单个元素 → canonical(修饰/排版元素展开子元素)。 */
|
|
719
|
+
async function elementToCanonical(el, deps, uinToUidMap) {
|
|
720
|
+
const converter = ELEMENT_CONVERTERS[el.type];
|
|
721
|
+
if (converter !== void 0) return converter(el, deps, uinToUidMap);
|
|
722
|
+
return flattenChildren(el.children, deps, uinToUidMap);
|
|
520
723
|
}
|
|
521
|
-
/**
|
|
522
|
-
function
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
724
|
+
/** 展开子元素为 canonical(递归)。 */
|
|
725
|
+
async function flattenChildren(children, deps, uinToUidMap) {
|
|
726
|
+
const out = [];
|
|
727
|
+
for (const child of children ?? []) out.push(...await elementToCanonical(child, deps, uinToUidMap));
|
|
728
|
+
return out;
|
|
729
|
+
}
|
|
730
|
+
/** 解析消息 content 字符串 → canonical(message.create 用)。 */
|
|
731
|
+
async function parseContentToCanonical(content, deps) {
|
|
732
|
+
return satoriToCanonicalElements(parseElements(content), deps);
|
|
530
733
|
}
|
|
531
734
|
//#endregion
|
|
532
735
|
//#region src/satori/helper/translate.ts
|
|
@@ -569,6 +772,45 @@ function toChannelById(channelId, isGroup, name) {
|
|
|
569
772
|
return toDirectChannel(channelId, name);
|
|
570
773
|
}
|
|
571
774
|
//#endregion
|
|
775
|
+
//#region src/satori/action/base-action.ts
|
|
776
|
+
/**
|
|
777
|
+
* Satori 动作基类(响应形态与 OB11 不同:直接返回资源对象,错误用 HTTP 状态码)
|
|
778
|
+
*
|
|
779
|
+
* - `run(payload)`:zod 校验失败 → SatoriActionError(400);_handle 抛的
|
|
780
|
+
* KernelError → satoriHttpStatusMap 映射 HTTP 状态码(ADR-017)
|
|
781
|
+
* - `SatoriActionError`:带 HTTP 状态码的动作错误(transport 层直接透传状态码)
|
|
782
|
+
*/
|
|
783
|
+
/** 动作错误(带 HTTP 状态码;由 transport 层透传)。 */
|
|
784
|
+
var SatoriActionError = class extends Error {
|
|
785
|
+
status;
|
|
786
|
+
constructor(status, message) {
|
|
787
|
+
super(message);
|
|
788
|
+
this.status = status;
|
|
789
|
+
}
|
|
790
|
+
};
|
|
791
|
+
/** 校验失败默认状态码。 */
|
|
792
|
+
const INVALID_PARAM_STATUS = 400;
|
|
793
|
+
/** 兜底错误状态码。 */
|
|
794
|
+
const UNKNOWN_STATUS = 500;
|
|
795
|
+
/**
|
|
796
|
+
* Satori 动作基类:校验 + 执行 + 错误映射。
|
|
797
|
+
* 成功返回资源对象(或数组/分页列表),失败抛 SatoriActionError。
|
|
798
|
+
*/
|
|
799
|
+
var BaseSatoriAction = class {
|
|
800
|
+
/** 校验并执行(HTTP/WS 共用)。 */
|
|
801
|
+
async run(payload) {
|
|
802
|
+
const parsed = this.schema.safeParse(payload);
|
|
803
|
+
if (!parsed.success) throw new SatoriActionError(INVALID_PARAM_STATUS, "参数校验失败");
|
|
804
|
+
try {
|
|
805
|
+
return await this._handle(parsed.data);
|
|
806
|
+
} catch (err) {
|
|
807
|
+
if (err instanceof SatoriActionError) throw err;
|
|
808
|
+
if (isKernelError(err)) throw new SatoriActionError(satoriHttpStatusMap[err.code] ?? UNKNOWN_STATUS, err.message);
|
|
809
|
+
throw new SatoriActionError(UNKNOWN_STATUS, err instanceof Error ? err.message : "未知错误");
|
|
810
|
+
}
|
|
811
|
+
}
|
|
812
|
+
};
|
|
813
|
+
//#endregion
|
|
572
814
|
//#region src/satori/action/channel.ts
|
|
573
815
|
/**
|
|
574
816
|
* Satori 频道动作:channel.get / channel.list
|
|
@@ -715,7 +957,7 @@ var GuildListAction = class extends BaseSatoriAction {
|
|
|
715
957
|
this.deps = deps;
|
|
716
958
|
}
|
|
717
959
|
async _handle(_payload) {
|
|
718
|
-
return { data: (await this.deps.groupApi.getGroupList(true)).map((g) => toGuild(g.groupCode, g.groupName)) };
|
|
960
|
+
return { data: (this.deps.groupCache === void 0 ? await this.deps.groupApi.getGroupList(true) : await this.deps.groupCache.listGroupsRefreshed()).map((g) => toGuild(g.groupCode, g.groupName)) };
|
|
719
961
|
}
|
|
720
962
|
};
|
|
721
963
|
/** guild.approve 参数(处理入群邀请)。 */
|
|
@@ -862,214 +1104,6 @@ var LoginGetAction = class extends BaseSatoriAction {
|
|
|
862
1104
|
}
|
|
863
1105
|
};
|
|
864
1106
|
//#endregion
|
|
865
|
-
//#region src/satori/helper/asset.ts
|
|
866
|
-
/**
|
|
867
|
-
* Satori 发方向资源处理(从 element-convert.ts 拆分,2026-08-08 FTA 优化)
|
|
868
|
-
*
|
|
869
|
-
* - resolveAsset:internal: 路径 / http(s) 下载 / 本地路径原样
|
|
870
|
-
* - ensureSilk:非 silk 语音(wav/pcm)转 silk(QQ 语音格式,失败原样返回)
|
|
871
|
-
*/
|
|
872
|
-
/** internal:{platform}/{user.id}/{path} → 本地路径(取 _tmp 之后的路径,回落 cacheDir)。 */
|
|
873
|
-
function resolveInternalPath(src) {
|
|
874
|
-
const pathPart = src.slice(9);
|
|
875
|
-
const slash1 = pathPart.indexOf("/");
|
|
876
|
-
const slash2 = slash1 === -1 ? -1 : pathPart.indexOf("/", slash1 + 1);
|
|
877
|
-
if (slash2 === -1) return pathPart;
|
|
878
|
-
const rest = pathPart.slice(slash2 + 1);
|
|
879
|
-
const tmpIdx = rest.indexOf("_tmp/");
|
|
880
|
-
return tmpIdx === -1 ? rest : rest.slice(tmpIdx + 5);
|
|
881
|
-
}
|
|
882
|
-
/**
|
|
883
|
-
* 解析资源 src:
|
|
884
|
-
* - internal:{platform}/{user.id}/{path} → 本地路径(第一版仅支持 _tmp 保留路径)
|
|
885
|
-
* - http(s):// → 下载到 cacheDir
|
|
886
|
-
* - 其他 → 视为本地路径原样返回
|
|
887
|
-
*/
|
|
888
|
-
async function resolveAsset(src, cacheDir) {
|
|
889
|
-
if (src.startsWith("internal:")) return resolveInternalPath(src);
|
|
890
|
-
if (/^https?:\/\//i.test(src)) return downloadAsset(src, cacheDir);
|
|
891
|
-
return src;
|
|
892
|
-
}
|
|
893
|
-
/** 下载 URL 资源到缓存目录(返回本地路径)。 */
|
|
894
|
-
async function downloadAsset(url, cacheDir) {
|
|
895
|
-
await mkdir(cacheDir, { recursive: true });
|
|
896
|
-
const res = await fetch(url);
|
|
897
|
-
if (!res.ok) throw kernelError(`下载资源失败: ${url}`, "SEND_FAILED");
|
|
898
|
-
const buf = Buffer.from(await res.arrayBuffer());
|
|
899
|
-
const safeName = (url.split("/").pop() ?? `asset-${Date.now()}`).replace(/[^a-zA-Z0-9._-]/g, "_") || `asset-${Date.now()}`;
|
|
900
|
-
const filePath = join(cacheDir, safeName);
|
|
901
|
-
await writeFile(filePath, buf);
|
|
902
|
-
return filePath;
|
|
903
|
-
}
|
|
904
|
-
/** 语音转码:非 silk 输入(wav/pcm)转 silk(QQ 语音格式)。 */
|
|
905
|
-
async function ensureSilk(path) {
|
|
906
|
-
try {
|
|
907
|
-
if ((await readFileHead(path)).startsWith("#!SILK")) return path;
|
|
908
|
-
return await encodePcmToSilk(path);
|
|
909
|
-
} catch {
|
|
910
|
-
return path;
|
|
911
|
-
}
|
|
912
|
-
}
|
|
913
|
-
/** 读文件头 8 字节(判断格式)。 */
|
|
914
|
-
async function readFileHead(path) {
|
|
915
|
-
const handle = await open(path, "r");
|
|
916
|
-
try {
|
|
917
|
-
const buf = Buffer.alloc(8);
|
|
918
|
-
await handle.read(buf, 0, 8, 0);
|
|
919
|
-
return buf.toString("utf8");
|
|
920
|
-
} finally {
|
|
921
|
-
await handle.close();
|
|
922
|
-
}
|
|
923
|
-
}
|
|
924
|
-
//#endregion
|
|
925
|
-
//#region src/satori/helper/element-convert.ts
|
|
926
|
-
/** 发方向:Satori 元素树 → kernel canonical(at uin→uid + 资源下载/转码)。 */
|
|
927
|
-
async function satoriToCanonicalElements(elements, deps) {
|
|
928
|
-
const atUins = [];
|
|
929
|
-
const collectAt = (list) => {
|
|
930
|
-
for (const el of list) {
|
|
931
|
-
const id = el.attrs?.["id"];
|
|
932
|
-
if (el.type === "at" && id !== void 0 && id !== "all") atUins.push(id);
|
|
933
|
-
if (el.children !== void 0) collectAt(el.children);
|
|
934
|
-
}
|
|
935
|
-
};
|
|
936
|
-
collectAt(elements);
|
|
937
|
-
let uinToUidMap = /* @__PURE__ */ new Map();
|
|
938
|
-
if (atUins.length > 0) try {
|
|
939
|
-
uinToUidMap = await deps.uinToUid([...new Set(atUins)]);
|
|
940
|
-
} catch {}
|
|
941
|
-
const out = [];
|
|
942
|
-
for (const el of elements) out.push(...await elementToCanonical(el, deps, uinToUidMap));
|
|
943
|
-
return out;
|
|
944
|
-
}
|
|
945
|
-
/** 解析元素媒体 src(空 → null 忽略),返回本地路径。 */
|
|
946
|
-
async function resolveMediaSrc(el, deps) {
|
|
947
|
-
const src = el.attrs?.["src"] ?? "";
|
|
948
|
-
if (src === "") return null;
|
|
949
|
-
return resolveAsset(src, deps.cacheDir);
|
|
950
|
-
}
|
|
951
|
-
/** Satori at → canonical at(uin → uid,name → display)。 */
|
|
952
|
-
function atToCanonical(el, uinToUidMap) {
|
|
953
|
-
const id = el.attrs?.["id"] ?? "";
|
|
954
|
-
if (id === "all") return [{
|
|
955
|
-
type: "at",
|
|
956
|
-
target: "all"
|
|
957
|
-
}];
|
|
958
|
-
if (id === "") return [];
|
|
959
|
-
const target = uinToUidMap.get(id) ?? id;
|
|
960
|
-
const display = el.attrs?.["name"];
|
|
961
|
-
const base = {
|
|
962
|
-
type: "at",
|
|
963
|
-
target
|
|
964
|
-
};
|
|
965
|
-
if (display !== void 0 && display !== "") return [{
|
|
966
|
-
...base,
|
|
967
|
-
display
|
|
968
|
-
}];
|
|
969
|
-
return [base];
|
|
970
|
-
}
|
|
971
|
-
/** Satori img → canonical image。 */
|
|
972
|
-
async function imgToCanonical(el, deps) {
|
|
973
|
-
const path = await resolveMediaSrc(el, deps);
|
|
974
|
-
return path === null ? [] : [{
|
|
975
|
-
type: "image",
|
|
976
|
-
path
|
|
977
|
-
}];
|
|
978
|
-
}
|
|
979
|
-
/** Satori audio → canonical voice(非 silk 转码)。 */
|
|
980
|
-
async function audioToCanonical(el, deps) {
|
|
981
|
-
const path = await resolveMediaSrc(el, deps);
|
|
982
|
-
return path === null ? [] : [{
|
|
983
|
-
type: "voice",
|
|
984
|
-
path: await ensureSilk(path)
|
|
985
|
-
}];
|
|
986
|
-
}
|
|
987
|
-
/** Satori video → canonical video。 */
|
|
988
|
-
async function videoToCanonical(el, deps) {
|
|
989
|
-
const path = await resolveMediaSrc(el, deps);
|
|
990
|
-
return path === null ? [] : [{
|
|
991
|
-
type: "video",
|
|
992
|
-
path
|
|
993
|
-
}];
|
|
994
|
-
}
|
|
995
|
-
/** Satori file → canonical file(title → name)。 */
|
|
996
|
-
async function fileToCanonical(el, deps) {
|
|
997
|
-
const path = await resolveMediaSrc(el, deps);
|
|
998
|
-
if (path === null) return [];
|
|
999
|
-
const title = el.attrs?.["title"];
|
|
1000
|
-
const base = {
|
|
1001
|
-
type: "file",
|
|
1002
|
-
path
|
|
1003
|
-
};
|
|
1004
|
-
if (title !== void 0 && title !== "") return [{
|
|
1005
|
-
...base,
|
|
1006
|
-
name: title
|
|
1007
|
-
}];
|
|
1008
|
-
return [base];
|
|
1009
|
-
}
|
|
1010
|
-
/** Satori message:forward 属性 → canonical forward;否则展开子元素。 */
|
|
1011
|
-
async function messageToCanonical(el, deps, uinToUidMap) {
|
|
1012
|
-
if (el.attrs?.["forward"] !== void 0) {
|
|
1013
|
-
const id = el.attrs?.["id"] ?? "";
|
|
1014
|
-
return id === "" ? [] : [{
|
|
1015
|
-
type: "forward",
|
|
1016
|
-
messageIds: [id]
|
|
1017
|
-
}];
|
|
1018
|
-
}
|
|
1019
|
-
return flattenChildren(el.children, deps, uinToUidMap);
|
|
1020
|
-
}
|
|
1021
|
-
/** 单个元素 → canonical(递归;修饰/排版元素展开子元素)。 */
|
|
1022
|
-
async function elementToCanonical(el, deps, uinToUidMap) {
|
|
1023
|
-
switch (el.type) {
|
|
1024
|
-
case "text": {
|
|
1025
|
-
const text = el.text ?? "";
|
|
1026
|
-
return text === "" ? [] : [{
|
|
1027
|
-
type: "text",
|
|
1028
|
-
text
|
|
1029
|
-
}];
|
|
1030
|
-
}
|
|
1031
|
-
case "at": return atToCanonical(el, uinToUidMap);
|
|
1032
|
-
case "img": return imgToCanonical(el, deps);
|
|
1033
|
-
case "audio": return audioToCanonical(el, deps);
|
|
1034
|
-
case "video": return videoToCanonical(el, deps);
|
|
1035
|
-
case "file": return fileToCanonical(el, deps);
|
|
1036
|
-
case "emoji": {
|
|
1037
|
-
const id = el.attrs?.["id"] ?? "";
|
|
1038
|
-
return id === "" ? [] : [{
|
|
1039
|
-
type: "face",
|
|
1040
|
-
id
|
|
1041
|
-
}];
|
|
1042
|
-
}
|
|
1043
|
-
case "quote": {
|
|
1044
|
-
const id = el.attrs?.["id"] ?? "";
|
|
1045
|
-
return id === "" ? [] : [{
|
|
1046
|
-
type: "reply",
|
|
1047
|
-
messageId: id
|
|
1048
|
-
}];
|
|
1049
|
-
}
|
|
1050
|
-
case "message": return messageToCanonical(el, deps, uinToUidMap);
|
|
1051
|
-
case "br": return [{
|
|
1052
|
-
type: "text",
|
|
1053
|
-
text: "\n"
|
|
1054
|
-
}];
|
|
1055
|
-
case "author":
|
|
1056
|
-
case "sharp":
|
|
1057
|
-
case "a":
|
|
1058
|
-
case "button": return [];
|
|
1059
|
-
default: return flattenChildren(el.children, deps, uinToUidMap);
|
|
1060
|
-
}
|
|
1061
|
-
}
|
|
1062
|
-
/** 展开子元素为 canonical(递归)。 */
|
|
1063
|
-
async function flattenChildren(children, deps, uinToUidMap) {
|
|
1064
|
-
const out = [];
|
|
1065
|
-
for (const child of children ?? []) out.push(...await elementToCanonical(child, deps, uinToUidMap));
|
|
1066
|
-
return out;
|
|
1067
|
-
}
|
|
1068
|
-
/** 解析消息 content 字符串 → canonical(message.create 用)。 */
|
|
1069
|
-
async function parseContentToCanonical(content, deps) {
|
|
1070
|
-
return satoriToCanonicalElements(parseElements(content), deps);
|
|
1071
|
-
}
|
|
1072
|
-
//#endregion
|
|
1073
1107
|
//#region src/satori/action/message.ts
|
|
1074
1108
|
/**
|
|
1075
1109
|
* Satori 消息动作:message.create / get / list / delete
|
|
@@ -1590,31 +1624,6 @@ function toMember(uid, nick, remark, uidToUinMap) {
|
|
|
1590
1624
|
return member;
|
|
1591
1625
|
}
|
|
1592
1626
|
//#endregion
|
|
1593
|
-
//#region src/satori/helper/config.ts
|
|
1594
|
-
/**
|
|
1595
|
-
* Satori 协议配置(zod schema,归属本包,ADR-012)
|
|
1596
|
-
* 协议配置 schema 在各自协议包,kernel 只提供 ConfigBase 基类。
|
|
1597
|
-
*/
|
|
1598
|
-
/** HTTP RPC 服务器默认端口(satori.chat 惯例 5500 区间,取 5500)。 */
|
|
1599
|
-
const DEFAULT_HTTP_PORT = 5500;
|
|
1600
|
-
/** WS 事件服务默认端口(satori.chat 惯例 5500 区间,取 5501)。 */
|
|
1601
|
-
const DEFAULT_WS_PORT = 5501;
|
|
1602
|
-
/** HTTP RPC 服务器实例(POST /v1/{resource}.{method})。 */
|
|
1603
|
-
const httpServerSchema = createHttpServerSchema(DEFAULT_HTTP_PORT);
|
|
1604
|
-
/** WS 事件服务实例(/v1/events 信令,第三方 SDK 连入)。 */
|
|
1605
|
-
const wsServerSchema = createWsServerSchema(DEFAULT_WS_PORT);
|
|
1606
|
-
/** Satori 配置 schema。 */
|
|
1607
|
-
const satoriConfigSchema = z.object({
|
|
1608
|
-
/** 鉴权 token(全局默认,实例未指定时继承)。 */
|
|
1609
|
-
token: z.string().optional(),
|
|
1610
|
-
/** 媒体资源(img/audio/video/file)下载缓存目录。 */
|
|
1611
|
-
cacheDir: z.string().optional(),
|
|
1612
|
-
/** HTTP RPC 服务器列表。 */
|
|
1613
|
-
httpServers: z.array(httpServerSchema).default([]),
|
|
1614
|
-
/** WS 事件服务列表。 */
|
|
1615
|
-
wsServers: z.array(wsServerSchema).default([])
|
|
1616
|
-
});
|
|
1617
|
-
//#endregion
|
|
1618
1627
|
//#region src/satori/types/event.ts
|
|
1619
1628
|
/** 信令类型。 */
|
|
1620
1629
|
const Opcode = {
|