@napuketto/adapter 0.0.1 → 0.0.3
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 +15 -0
- package/dist/core/index.d.mts +1 -1
- package/dist/core-Bj5L8WeZ.mjs.map +1 -1
- package/dist/gray-tip-CcpfI1Og.d.mts +1 -0
- package/dist/{index-C1nIfPiU.d.mts → index-CmhGncOe.d.mts} +6 -4
- package/dist/index-CmhGncOe.d.mts.map +1 -0
- package/dist/onebot11/index.d.mts +110 -105
- package/dist/onebot11/index.d.mts.map +1 -1
- package/dist/onebot11/index.mjs +417 -431
- package/dist/onebot11/index.mjs.map +1 -1
- package/dist/raw-message-Du7tvR1D.mjs +59 -0
- package/dist/raw-message-Du7tvR1D.mjs.map +1 -0
- package/dist/satori/index.d.mts +488 -0
- package/dist/satori/index.d.mts.map +1 -0
- package/dist/satori/index.mjs +1891 -0
- package/dist/satori/index.mjs.map +1 -0
- package/package.json +8 -4
- package/dist/index-C1nIfPiU.d.mts.map +0 -1
|
@@ -0,0 +1,1891 @@
|
|
|
1
|
+
import { a as ActionRegistry, n as BaseProtocolAdapter } from "../core-Bj5L8WeZ.mjs";
|
|
2
|
+
import { i as createWsServerSchema, n as collectGrayTipUids, r as createHttpServerSchema, t as forEachRawMessage } from "../raw-message-Du7tvR1D.mjs";
|
|
3
|
+
import { ChatType, GrayTipSubType, NTGroupRequestOperateTypes, TipGroupElementType, isKernelError, kernelError, toCanonicalElements } from "@napuketto/kernel";
|
|
4
|
+
import { z } from "zod";
|
|
5
|
+
import { join } from "node:path";
|
|
6
|
+
import { HttpServer, WsServer } from "@napuketto/network";
|
|
7
|
+
import { mkdir, open, writeFile } from "node:fs/promises";
|
|
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
|
|
34
|
+
//#region src/satori/helper/error.ts
|
|
35
|
+
/** KernelErrorCode → HTTP 状态码映射表。 */
|
|
36
|
+
const satoriHttpStatusMap = {
|
|
37
|
+
SEND_FAILED: 500,
|
|
38
|
+
PERMISSION_DENIED: 403,
|
|
39
|
+
NOT_FOUND: 404,
|
|
40
|
+
TIMEOUT: 500,
|
|
41
|
+
NOT_LOGIN: 403,
|
|
42
|
+
INVALID_PARAM: 400,
|
|
43
|
+
INVALID_STATE: 500,
|
|
44
|
+
UNKNOWN: 500
|
|
45
|
+
};
|
|
46
|
+
/** HTTP 状态码常量。 */
|
|
47
|
+
const HTTP_STATUS = {
|
|
48
|
+
ok: 200,
|
|
49
|
+
badRequest: 400,
|
|
50
|
+
unauthorized: 401,
|
|
51
|
+
forbidden: 403,
|
|
52
|
+
notFound: 404,
|
|
53
|
+
notImplemented: 501,
|
|
54
|
+
serverError: 500
|
|
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
|
+
}
|
|
113
|
+
//#endregion
|
|
114
|
+
//#region src/satori/helper/element/canonical.ts
|
|
115
|
+
/** 收集 at uid → 批量 uidToUin(失败不阻塞,at 原样 uid)。 */
|
|
116
|
+
async function resolveAtUids(elements, uidToUin) {
|
|
117
|
+
const atUids = [];
|
|
118
|
+
for (const el of elements) if (el.type === "at" && el.target !== "all") atUids.push(el.target);
|
|
119
|
+
if (atUids.length === 0 || uidToUin === void 0) return /* @__PURE__ */ new Map();
|
|
120
|
+
try {
|
|
121
|
+
return await uidToUin([...new Set(atUids)]);
|
|
122
|
+
} catch {
|
|
123
|
+
return /* @__PURE__ */ new Map();
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
/** canonical at → Satori at(uid → uin,name = display)。 */
|
|
127
|
+
function atToSatori(el, uinMap) {
|
|
128
|
+
if (el.target === "all") return {
|
|
129
|
+
type: "at",
|
|
130
|
+
attrs: { id: "all" }
|
|
131
|
+
};
|
|
132
|
+
const attrs = { id: uinMap.get(el.target) ?? el.target };
|
|
133
|
+
if (el.display !== void 0 && el.display !== "") attrs["name"] = el.display;
|
|
134
|
+
return {
|
|
135
|
+
type: "at",
|
|
136
|
+
attrs
|
|
137
|
+
};
|
|
138
|
+
}
|
|
139
|
+
/** canonical file → Satori file(title = name)。 */
|
|
140
|
+
function fileToSatori(el) {
|
|
141
|
+
const attrs = { src: el.path };
|
|
142
|
+
if (el.name !== void 0 && el.name !== "") attrs["title"] = el.name;
|
|
143
|
+
return {
|
|
144
|
+
type: "file",
|
|
145
|
+
attrs
|
|
146
|
+
};
|
|
147
|
+
}
|
|
148
|
+
/** canonical forward → Satori message(取首个消息 id)。 */
|
|
149
|
+
function forwardToSatori(el) {
|
|
150
|
+
const first = el.messageIds[0];
|
|
151
|
+
if (first === void 0 || first === "") return null;
|
|
152
|
+
return {
|
|
153
|
+
type: "message",
|
|
154
|
+
attrs: {
|
|
155
|
+
forward: "",
|
|
156
|
+
id: first
|
|
157
|
+
}
|
|
158
|
+
};
|
|
159
|
+
}
|
|
160
|
+
/** 单个 canonical → Satori(null = 宽容忽略,如 json/xml/unknown)。 */
|
|
161
|
+
function canonicalToSatori(el, uinMap) {
|
|
162
|
+
switch (el.type) {
|
|
163
|
+
case "text": return el.text === "" ? null : {
|
|
164
|
+
type: "text",
|
|
165
|
+
text: el.text
|
|
166
|
+
};
|
|
167
|
+
case "at": return atToSatori(el, uinMap);
|
|
168
|
+
case "image": return {
|
|
169
|
+
type: "img",
|
|
170
|
+
attrs: { src: el.url ?? el.path }
|
|
171
|
+
};
|
|
172
|
+
case "face": return {
|
|
173
|
+
type: "emoji",
|
|
174
|
+
attrs: { id: el.id }
|
|
175
|
+
};
|
|
176
|
+
case "voice": return {
|
|
177
|
+
type: "audio",
|
|
178
|
+
attrs: { src: el.path }
|
|
179
|
+
};
|
|
180
|
+
case "video": return {
|
|
181
|
+
type: "video",
|
|
182
|
+
attrs: { src: el.url ?? el.path }
|
|
183
|
+
};
|
|
184
|
+
case "file": return fileToSatori(el);
|
|
185
|
+
case "reply": return {
|
|
186
|
+
type: "quote",
|
|
187
|
+
attrs: { id: el.messageId }
|
|
188
|
+
};
|
|
189
|
+
case "forward": return forwardToSatori(el);
|
|
190
|
+
case "json":
|
|
191
|
+
case "xml":
|
|
192
|
+
case "unknown": return null;
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
/**
|
|
196
|
+
* 收方向:kernel canonical 元素 → Satori 元素树(纯函数映射,宽容忽略 unknown)。
|
|
197
|
+
* at 目标为 uid 时经 uidToUin 转换(异步);无转换器时原样。
|
|
198
|
+
*/
|
|
199
|
+
async function canonicalToSatoriElements(elements, deps = {}) {
|
|
200
|
+
const uinMap = await resolveAtUids(elements, deps.uidToUin);
|
|
201
|
+
const out = [];
|
|
202
|
+
for (const el of elements) {
|
|
203
|
+
const converted = canonicalToSatori(el, uinMap);
|
|
204
|
+
if (converted !== null) out.push(converted);
|
|
205
|
+
}
|
|
206
|
+
return out;
|
|
207
|
+
}
|
|
208
|
+
//#endregion
|
|
209
|
+
//#region src/satori/helper/element/parse-tag.ts
|
|
210
|
+
/**
|
|
211
|
+
* Satori 标签读取(元素名 / 属性解析,从 element.ts 拆分,2026-08-08 FTA 优化)
|
|
212
|
+
*
|
|
213
|
+
* - decodeEntity/decodeEntities:命名/十进制/十六进制实体解码(属性值用)
|
|
214
|
+
* - readTagName:读取元素名(小写字母/数字/连字符,以字母开头)
|
|
215
|
+
* - readTagAttrs / readAttr / skipSpaces:标签属性区解析(无值属性 / 引号值 / 无引号值)
|
|
216
|
+
* - readTag:完整标签(名 + 属性 + 自闭合指示)
|
|
217
|
+
*/
|
|
218
|
+
/** 命名实体(规范规定的四个 + apos 宽容)。 */
|
|
219
|
+
const NAMED_ENTITIES = {
|
|
220
|
+
quot: "\"",
|
|
221
|
+
amp: "&",
|
|
222
|
+
lt: "<",
|
|
223
|
+
gt: ">",
|
|
224
|
+
apos: "'"
|
|
225
|
+
};
|
|
226
|
+
/** 解码单个实体(命名/十进制/十六进制)。 */
|
|
227
|
+
function decodeEntity(body) {
|
|
228
|
+
const named = NAMED_ENTITIES[body];
|
|
229
|
+
if (named !== void 0) return named;
|
|
230
|
+
if (body.startsWith("#x") || body.startsWith("#X")) {
|
|
231
|
+
const code = Number.parseInt(body.slice(2), 16);
|
|
232
|
+
if (Number.isInteger(code)) return String.fromCodePoint(code);
|
|
233
|
+
}
|
|
234
|
+
if (body.startsWith("#")) {
|
|
235
|
+
const code = Number.parseInt(body.slice(1), 10);
|
|
236
|
+
if (Number.isInteger(code)) return String.fromCodePoint(code);
|
|
237
|
+
}
|
|
238
|
+
return `&${body};`;
|
|
239
|
+
}
|
|
240
|
+
/** 解码字符串中的全部实体。 */
|
|
241
|
+
function decodeEntities(input) {
|
|
242
|
+
return input.replace(/&([^;]+);/g, (_full, body) => decodeEntity(body));
|
|
243
|
+
}
|
|
244
|
+
/** 元素名:小写字母/数字/连字符,以字母开头(规范)。 */
|
|
245
|
+
const NAME_RE = /^[a-z][a-z0-9-]*/i;
|
|
246
|
+
/** 读取元素名(pos 起);未匹配返回空名。 */
|
|
247
|
+
function readTagName(source, pos) {
|
|
248
|
+
const rest = source.slice(pos);
|
|
249
|
+
const m = NAME_RE.exec(rest);
|
|
250
|
+
if (m === null) return {
|
|
251
|
+
name: "",
|
|
252
|
+
end: pos
|
|
253
|
+
};
|
|
254
|
+
return {
|
|
255
|
+
name: m[0],
|
|
256
|
+
end: pos + m[0].length
|
|
257
|
+
};
|
|
258
|
+
}
|
|
259
|
+
/** 跳过空白;返回下一个非空白位置。 */
|
|
260
|
+
function skipSpaces(source, start) {
|
|
261
|
+
let i = start;
|
|
262
|
+
while (i < source.length && /\s/.test(source.charAt(i))) i += 1;
|
|
263
|
+
return i;
|
|
264
|
+
}
|
|
265
|
+
/** 读取单个属性(名 / = / 值 / 无值属性),更新 attrs,返回下一个位置。 */
|
|
266
|
+
function readAttr(source, start, attrs) {
|
|
267
|
+
const keyMatch = /^[a-zA-Z0-9:_-]+/.exec(source.slice(start));
|
|
268
|
+
if (keyMatch === null) return start + 1;
|
|
269
|
+
const key = keyMatch[0];
|
|
270
|
+
let i = skipSpaces(source, start + key.length);
|
|
271
|
+
if (source.charAt(i) !== "=") {
|
|
272
|
+
attrs[key] = "";
|
|
273
|
+
return i;
|
|
274
|
+
}
|
|
275
|
+
i = skipSpaces(source, i + 1);
|
|
276
|
+
const quote = source.charAt(i);
|
|
277
|
+
if (quote === "\"" || quote === "'") {
|
|
278
|
+
i += 1;
|
|
279
|
+
const valueStart = i;
|
|
280
|
+
while (i < source.length && source.charAt(i) !== quote) i += 1;
|
|
281
|
+
attrs[key] = decodeEntities(source.slice(valueStart, i));
|
|
282
|
+
if (i < source.length) i += 1;
|
|
283
|
+
} else {
|
|
284
|
+
const valueStart = i;
|
|
285
|
+
while (i < source.length && !/[\s>]/.test(source.charAt(i))) i += 1;
|
|
286
|
+
attrs[key] = decodeEntities(source.slice(valueStart, i));
|
|
287
|
+
}
|
|
288
|
+
return i;
|
|
289
|
+
}
|
|
290
|
+
/** 读取标签属性区(到 > 或 /> 为止),返回属性表与闭合指示、结束位置。 */
|
|
291
|
+
function readTagAttrs(source, start) {
|
|
292
|
+
const attrs = {};
|
|
293
|
+
let i = start;
|
|
294
|
+
let selfClosing = false;
|
|
295
|
+
while (i < source.length) {
|
|
296
|
+
const ch = source.charAt(i);
|
|
297
|
+
if (ch === ">") {
|
|
298
|
+
i += 1;
|
|
299
|
+
break;
|
|
300
|
+
}
|
|
301
|
+
if (ch === "/") {
|
|
302
|
+
if (source.charAt(i + 1) === ">") {
|
|
303
|
+
selfClosing = true;
|
|
304
|
+
i += 2;
|
|
305
|
+
break;
|
|
306
|
+
}
|
|
307
|
+
i += 1;
|
|
308
|
+
continue;
|
|
309
|
+
}
|
|
310
|
+
if (/\s/.test(ch)) {
|
|
311
|
+
i += 1;
|
|
312
|
+
continue;
|
|
313
|
+
}
|
|
314
|
+
i = readAttr(source, i, attrs);
|
|
315
|
+
}
|
|
316
|
+
return {
|
|
317
|
+
attrs,
|
|
318
|
+
selfClosing,
|
|
319
|
+
end: i
|
|
320
|
+
};
|
|
321
|
+
}
|
|
322
|
+
/** 读取完整标签(元素名 + 属性 + 闭合指示),返回属性表与结束位置。 */
|
|
323
|
+
function readTag(source, pos) {
|
|
324
|
+
const { name, end } = readTagName(source, pos);
|
|
325
|
+
if (name === "") return {
|
|
326
|
+
name: "",
|
|
327
|
+
attrs: {},
|
|
328
|
+
selfClosing: false,
|
|
329
|
+
end: pos
|
|
330
|
+
};
|
|
331
|
+
const { attrs, selfClosing, end: attrEnd } = readTagAttrs(source, end);
|
|
332
|
+
return {
|
|
333
|
+
name,
|
|
334
|
+
attrs,
|
|
335
|
+
selfClosing,
|
|
336
|
+
end: attrEnd
|
|
337
|
+
};
|
|
338
|
+
}
|
|
339
|
+
//#endregion
|
|
340
|
+
//#region src/satori/helper/element/element.ts
|
|
341
|
+
/**
|
|
342
|
+
* Satori 消息元素解析/渲染(协议 v1,自研实现,规范参考 satori.chat/zh-CN/protocol/message.html)
|
|
343
|
+
*
|
|
344
|
+
* - parseElements:XML 风格字符串 → 元素树(注释/转义/无值属性/未配对标签容错)
|
|
345
|
+
* - renderElements:元素树 → 字符串(消息 content 序列化)
|
|
346
|
+
*
|
|
347
|
+
* canonical ↔ Satori 双向转换不在本文件:收方向(canonical → Satori)在
|
|
348
|
+
* canonical.ts,发方向(Satori → canonical)在 element-convert.ts,调用方直引。
|
|
349
|
+
*
|
|
350
|
+
* 语法要点(对齐规范):
|
|
351
|
+
* - 转义:" & < > ' ' 与十进制/十六进制数字实体
|
|
352
|
+
* - 注释 <!-- --> 不渲染
|
|
353
|
+
* - 无值属性(key)与 key="value" / key='value' 均合法
|
|
354
|
+
* - 未配对的标签视为文本内容的一部分
|
|
355
|
+
* - 文本内容前后包含换行符的连续空白会被忽略
|
|
356
|
+
*/
|
|
357
|
+
/** 文本转义(& < >)。 */
|
|
358
|
+
function encodeText(input) {
|
|
359
|
+
return input.replace(/[&<>]/g, (ch) => {
|
|
360
|
+
if (ch === "&") return "&";
|
|
361
|
+
if (ch === "<") return "<";
|
|
362
|
+
return ">";
|
|
363
|
+
});
|
|
364
|
+
}
|
|
365
|
+
/** 属性值转义(& " < >)。 */
|
|
366
|
+
function encodeAttr(input) {
|
|
367
|
+
return input.replace(/[&"<>]/g, (ch) => {
|
|
368
|
+
if (ch === "&") return "&";
|
|
369
|
+
if (ch === "\"") return """;
|
|
370
|
+
if (ch === "<") return "<";
|
|
371
|
+
return ">";
|
|
372
|
+
});
|
|
373
|
+
}
|
|
374
|
+
/**
|
|
375
|
+
* 文本规范化:开头/结尾的「包含换行符的连续空白」被忽略(规范),
|
|
376
|
+
* 中间的空白与不含换行的首尾空白保留。
|
|
377
|
+
*/
|
|
378
|
+
function normalizeText(raw) {
|
|
379
|
+
const leading = /^\s+/.exec(raw);
|
|
380
|
+
const trailing = /\s+$/.exec(raw);
|
|
381
|
+
let out = raw;
|
|
382
|
+
if (leading !== null && /[\r\n]/.test(leading[0])) out = out.slice(leading[0].length);
|
|
383
|
+
if (trailing !== null && /[\r\n]/.test(trailing[0])) out = out.slice(0, -trailing[0].length);
|
|
384
|
+
return out;
|
|
385
|
+
}
|
|
386
|
+
/** Satori 消息解析器状态(挂载点 / 文本缓冲 / 栈管理,拆分主循环复杂度)。 */
|
|
387
|
+
var ElementParser = class {
|
|
388
|
+
root = [];
|
|
389
|
+
stack = [];
|
|
390
|
+
text = "";
|
|
391
|
+
/** 当前挂载数组(栈顶元素 children 或根数组)。 */
|
|
392
|
+
mountPoint() {
|
|
393
|
+
const top = this.stack[this.stack.length - 1];
|
|
394
|
+
if (top === void 0) return this.root;
|
|
395
|
+
top.children ??= [];
|
|
396
|
+
return top.children;
|
|
397
|
+
}
|
|
398
|
+
/** 归一化文本落位到当前挂载点。 */
|
|
399
|
+
pushText() {
|
|
400
|
+
const normalized = normalizeText(this.text);
|
|
401
|
+
this.text = "";
|
|
402
|
+
if (normalized !== "") this.mountPoint().push({
|
|
403
|
+
type: "text",
|
|
404
|
+
text: normalized
|
|
405
|
+
});
|
|
406
|
+
}
|
|
407
|
+
/** 解析完成的元素树。 */
|
|
408
|
+
get elements() {
|
|
409
|
+
return this.root;
|
|
410
|
+
}
|
|
411
|
+
/** 追加原始文本(解析间隙累积)。 */
|
|
412
|
+
appendRaw(raw) {
|
|
413
|
+
this.text += raw;
|
|
414
|
+
}
|
|
415
|
+
/** 起始/自闭合标签落位(压栈或仅挂载)。 */
|
|
416
|
+
openTag(name, attrs, selfClosing) {
|
|
417
|
+
const el = {
|
|
418
|
+
type: name,
|
|
419
|
+
attrs
|
|
420
|
+
};
|
|
421
|
+
this.mountPoint().push(el);
|
|
422
|
+
if (!selfClosing) this.stack.push(el);
|
|
423
|
+
}
|
|
424
|
+
/** 收尾:剩余文本落位(栈剩余未闭合元素:文本落在栈顶元素内,宽容)。 */
|
|
425
|
+
flush() {
|
|
426
|
+
this.pushText();
|
|
427
|
+
}
|
|
428
|
+
/** 注释:返回跳过后的位置;-1 = 未闭合终止解析(注释不渲染)。 */
|
|
429
|
+
skipComment(source, pos) {
|
|
430
|
+
const end = source.indexOf("-->", pos);
|
|
431
|
+
return end === -1 ? -1 : end + 3;
|
|
432
|
+
}
|
|
433
|
+
/**
|
|
434
|
+
* 结束标签:匹配栈顶则闭合,否则按文本保留。
|
|
435
|
+
* @returns 下一个解析位置;-1 = 终止解析。
|
|
436
|
+
*/
|
|
437
|
+
closeTag(source, lt, slashPos) {
|
|
438
|
+
const { name, end } = readTagName(source, slashPos + 1);
|
|
439
|
+
if (name === "") {
|
|
440
|
+
this.text += "<";
|
|
441
|
+
return lt + 1;
|
|
442
|
+
}
|
|
443
|
+
const gt = source.indexOf(">", end);
|
|
444
|
+
if (gt === -1) {
|
|
445
|
+
this.text += source.slice(lt);
|
|
446
|
+
return -1;
|
|
447
|
+
}
|
|
448
|
+
const top = this.stack[this.stack.length - 1];
|
|
449
|
+
if (top !== void 0 && top.type === name) {
|
|
450
|
+
this.pushText();
|
|
451
|
+
this.stack.pop();
|
|
452
|
+
} else this.text += source.slice(lt, gt + 1);
|
|
453
|
+
return gt + 1;
|
|
454
|
+
}
|
|
455
|
+
};
|
|
456
|
+
/**
|
|
457
|
+
* 解析 Satori 消息字符串 → 元素树。
|
|
458
|
+
* 容错:未配对标签/非法标签按文本保留;解析不抛错(消息内容宽容)。
|
|
459
|
+
*/
|
|
460
|
+
function parseElements(source) {
|
|
461
|
+
const parser = new ElementParser();
|
|
462
|
+
let i = 0;
|
|
463
|
+
while (i < source.length) {
|
|
464
|
+
const lt = source.indexOf("<", i);
|
|
465
|
+
if (lt === -1) {
|
|
466
|
+
parser.appendRaw(source.slice(i));
|
|
467
|
+
break;
|
|
468
|
+
}
|
|
469
|
+
parser.appendRaw(source.slice(i, lt));
|
|
470
|
+
i = lt + 1;
|
|
471
|
+
if (source.startsWith("!--", i)) {
|
|
472
|
+
const next = parser.skipComment(source, i);
|
|
473
|
+
if (next === -1) break;
|
|
474
|
+
i = next;
|
|
475
|
+
continue;
|
|
476
|
+
}
|
|
477
|
+
if (source.startsWith("/", i)) {
|
|
478
|
+
const next = parser.closeTag(source, lt, i);
|
|
479
|
+
if (next === -1) break;
|
|
480
|
+
i = next;
|
|
481
|
+
continue;
|
|
482
|
+
}
|
|
483
|
+
const tag = readTag(source, i);
|
|
484
|
+
if (tag.name === "") {
|
|
485
|
+
parser.appendRaw("<");
|
|
486
|
+
continue;
|
|
487
|
+
}
|
|
488
|
+
parser.pushText();
|
|
489
|
+
parser.openTag(tag.name, tag.attrs, tag.selfClosing);
|
|
490
|
+
i = tag.end;
|
|
491
|
+
}
|
|
492
|
+
parser.flush();
|
|
493
|
+
return parser.elements;
|
|
494
|
+
}
|
|
495
|
+
/** 元素树 → Satori 消息字符串。 */
|
|
496
|
+
function renderElements(elements) {
|
|
497
|
+
let out = "";
|
|
498
|
+
for (const el of elements) {
|
|
499
|
+
if (el.type === "text") {
|
|
500
|
+
out += encodeText(el.text ?? "");
|
|
501
|
+
continue;
|
|
502
|
+
}
|
|
503
|
+
out += renderElement(el);
|
|
504
|
+
}
|
|
505
|
+
return out;
|
|
506
|
+
}
|
|
507
|
+
/** 渲染单个元素(含子元素递归)。 */
|
|
508
|
+
function renderElement(el) {
|
|
509
|
+
let attrs = "";
|
|
510
|
+
for (const [key, value] of Object.entries(el.attrs ?? {})) if (value === "") attrs += ` ${key}`;
|
|
511
|
+
else attrs += ` ${key}="${encodeAttr(value)}"`;
|
|
512
|
+
const children = el.children ?? [];
|
|
513
|
+
if (children.length === 0) return `<${el.type}${attrs}/>`;
|
|
514
|
+
const inner = renderElements(children);
|
|
515
|
+
return `<${el.type}${attrs}>${inner}</${el.type}>`;
|
|
516
|
+
}
|
|
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);
|
|
534
|
+
}
|
|
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;
|
|
545
|
+
}
|
|
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
|
|
617
|
+
};
|
|
618
|
+
if (title !== void 0 && title !== "") return [{
|
|
619
|
+
...base,
|
|
620
|
+
name: title
|
|
621
|
+
}];
|
|
622
|
+
return [base];
|
|
623
|
+
}
|
|
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;
|
|
651
|
+
}
|
|
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
|
|
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);
|
|
723
|
+
}
|
|
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);
|
|
733
|
+
}
|
|
734
|
+
//#endregion
|
|
735
|
+
//#region src/satori/helper/translate.ts
|
|
736
|
+
/**
|
|
737
|
+
* Satori 资源翻译(RawMessage → Satori Message / 频道 / 群组,纯函数 + 注入转换)
|
|
738
|
+
*
|
|
739
|
+
* 收方向 ID 空间:canonical at.target 是 uid,Satori 规范是 uin——经
|
|
740
|
+
* uidToUin 批量转换(依赖注入,与 OB11 的 ReceiveTranslateContext 同思路)。
|
|
741
|
+
*/
|
|
742
|
+
/** RawMessage → Satori Message(资源提升:message 不嵌套 user/member/channel)。 */
|
|
743
|
+
async function toSatoriMessage(raw, deps) {
|
|
744
|
+
const elementDeps = {};
|
|
745
|
+
if (deps.uidToUin !== void 0) elementDeps.uidToUin = deps.uidToUin;
|
|
746
|
+
const content = renderElements(await canonicalToSatoriElements(toCanonicalElements(raw), elementDeps));
|
|
747
|
+
const isGroup = raw.chatType === ChatType.GROUP;
|
|
748
|
+
const peerUin = String(raw.peerUin ?? "");
|
|
749
|
+
const senderUin = String(raw.senderUin ?? "");
|
|
750
|
+
const nickname = raw.sendNickName ?? "";
|
|
751
|
+
const memberName = raw.sendMemberName ?? "";
|
|
752
|
+
const message = { id: raw.msgId };
|
|
753
|
+
if (content !== "") message.content = content;
|
|
754
|
+
if (isGroup) {
|
|
755
|
+
message.guild = toGuild(peerUin, raw.peerName);
|
|
756
|
+
message.channel = toGroupChannel(peerUin, raw.peerName);
|
|
757
|
+
message.user = toUser(senderUin, nickname);
|
|
758
|
+
message.member = { user: message.user };
|
|
759
|
+
if (memberName !== "" && memberName !== nickname) message.member.nick = memberName;
|
|
760
|
+
} else {
|
|
761
|
+
const peerId = senderUin !== "" ? senderUin : peerUin;
|
|
762
|
+
message.channel = toDirectChannel(peerId, nickname);
|
|
763
|
+
message.user = toUser(peerId, nickname);
|
|
764
|
+
}
|
|
765
|
+
const created = Number(raw.msgTime);
|
|
766
|
+
if (Number.isFinite(created) && created > 0) message.created_at = created;
|
|
767
|
+
return message;
|
|
768
|
+
}
|
|
769
|
+
/** 按频道 id 构造频道(isGroup 由调用方从事件/缓存得知)。 */
|
|
770
|
+
function toChannelById(channelId, isGroup, name) {
|
|
771
|
+
if (isGroup) return toGroupChannel(channelId, name);
|
|
772
|
+
return toDirectChannel(channelId, name);
|
|
773
|
+
}
|
|
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
|
|
814
|
+
//#region src/satori/action/channel.ts
|
|
815
|
+
/**
|
|
816
|
+
* Satori 频道动作:channel.get / channel.list
|
|
817
|
+
* (channel.create / update / delete / mute:QQ 不支持 → 501,见 registry)
|
|
818
|
+
*/
|
|
819
|
+
/** channel.get 参数。 */
|
|
820
|
+
const channelGetSchema = z.object({ channel_id: z.string() });
|
|
821
|
+
/** 获取频道(群聊 = 群号频道;私聊 = uin 频道)。 */
|
|
822
|
+
var ChannelGetAction = class extends BaseSatoriAction {
|
|
823
|
+
name = "channel.get";
|
|
824
|
+
schema = channelGetSchema;
|
|
825
|
+
deps;
|
|
826
|
+
constructor(deps) {
|
|
827
|
+
super();
|
|
828
|
+
this.deps = deps;
|
|
829
|
+
}
|
|
830
|
+
async _handle(payload) {
|
|
831
|
+
const { channel_id: channelId } = payload;
|
|
832
|
+
const isGroup = (await this.deps.resolvePeer(channelId)).chatType === 2;
|
|
833
|
+
this.deps.setChannelType(channelId, isGroup);
|
|
834
|
+
if (isGroup) return toChannelById(channelId, true, (await this.deps.groupCache?.getGroupDetail(channelId))?.groupName);
|
|
835
|
+
return toChannelById(channelId, false);
|
|
836
|
+
}
|
|
837
|
+
};
|
|
838
|
+
/** channel.list 参数。 */
|
|
839
|
+
const channelListSchema = z.object({
|
|
840
|
+
guild_id: z.string(),
|
|
841
|
+
next: z.string().optional()
|
|
842
|
+
});
|
|
843
|
+
/** 获取群组频道列表(QQ:群组与群聊频道重合 → 单元素列表)。 */
|
|
844
|
+
var ChannelListAction = class extends BaseSatoriAction {
|
|
845
|
+
name = "channel.list";
|
|
846
|
+
schema = channelListSchema;
|
|
847
|
+
deps;
|
|
848
|
+
constructor(deps) {
|
|
849
|
+
super();
|
|
850
|
+
this.deps = deps;
|
|
851
|
+
}
|
|
852
|
+
async _handle(payload) {
|
|
853
|
+
const { guild_id: guildId } = payload;
|
|
854
|
+
const name = (await this.deps.groupCache?.getGroupDetail(guildId))?.groupName;
|
|
855
|
+
return { data: [toChannelById(guildId, true, name)] };
|
|
856
|
+
}
|
|
857
|
+
};
|
|
858
|
+
//#endregion
|
|
859
|
+
//#region src/satori/action/friend.ts
|
|
860
|
+
/** friend.list 参数。 */
|
|
861
|
+
const friendListSchema = z.object({ next: z.string().optional() });
|
|
862
|
+
/** 获取好友列表。 */
|
|
863
|
+
var FriendListAction = class extends BaseSatoriAction {
|
|
864
|
+
name = "friend.list";
|
|
865
|
+
schema = friendListSchema;
|
|
866
|
+
deps;
|
|
867
|
+
constructor(deps) {
|
|
868
|
+
super();
|
|
869
|
+
this.deps = deps;
|
|
870
|
+
}
|
|
871
|
+
async _handle(_payload) {
|
|
872
|
+
return { data: (await this.deps.friendApi.getFriendList()).map((f) => {
|
|
873
|
+
const out = { user: toUser(f.uin, f.nickname) };
|
|
874
|
+
if (f.remark !== "" && f.remark !== f.nickname) out.nick = f.remark;
|
|
875
|
+
return out;
|
|
876
|
+
}) };
|
|
877
|
+
}
|
|
878
|
+
};
|
|
879
|
+
/** friend.delete 参数。 */
|
|
880
|
+
const friendDeleteSchema = z.object({ user_id: z.string() });
|
|
881
|
+
/** 删除好友。 */
|
|
882
|
+
var FriendDeleteAction = class extends BaseSatoriAction {
|
|
883
|
+
name = "friend.delete";
|
|
884
|
+
schema = friendDeleteSchema;
|
|
885
|
+
deps;
|
|
886
|
+
constructor(deps) {
|
|
887
|
+
super();
|
|
888
|
+
this.deps = deps;
|
|
889
|
+
}
|
|
890
|
+
async _handle(payload) {
|
|
891
|
+
const { user_id: userId } = payload;
|
|
892
|
+
const uid = (await this.deps.uinToUid([userId])).get(userId) ?? userId;
|
|
893
|
+
await this.deps.friendApi.deleteFriend(uid);
|
|
894
|
+
}
|
|
895
|
+
};
|
|
896
|
+
/** friend.approve 参数(处理好友申请)。 */
|
|
897
|
+
const friendApproveSchema = z.object({
|
|
898
|
+
message_id: z.string(),
|
|
899
|
+
approve: z.boolean(),
|
|
900
|
+
comment: z.string().optional()
|
|
901
|
+
});
|
|
902
|
+
/** 处理好友申请(message_id = 申请方 uin)。 */
|
|
903
|
+
var FriendApproveAction = class extends BaseSatoriAction {
|
|
904
|
+
name = "friend.approve";
|
|
905
|
+
schema = friendApproveSchema;
|
|
906
|
+
deps;
|
|
907
|
+
constructor(deps) {
|
|
908
|
+
super();
|
|
909
|
+
this.deps = deps;
|
|
910
|
+
}
|
|
911
|
+
async _handle(payload) {
|
|
912
|
+
const { message_id: messageId, approve } = payload;
|
|
913
|
+
const notify = await findBuddyReq(this.deps, messageId);
|
|
914
|
+
await this.deps.friendApi.handleFriendRequest(notify, approve);
|
|
915
|
+
}
|
|
916
|
+
};
|
|
917
|
+
/** 按 message_id(申请方 uin)查找好友申请。 */
|
|
918
|
+
async function findBuddyReq(deps, messageId) {
|
|
919
|
+
const reqs = await deps.friendApi.getBuddyReqList();
|
|
920
|
+
if (reqs.length === 0) throw new Error("好友申请不存在");
|
|
921
|
+
const uids = reqs.map((r) => r.friendUid);
|
|
922
|
+
const uinMap = await deps.uidToUin(uids);
|
|
923
|
+
const found = reqs.find((r) => uinMap.get(r.friendUid) === messageId);
|
|
924
|
+
if (found === void 0) throw new Error("好友申请不存在");
|
|
925
|
+
return found;
|
|
926
|
+
}
|
|
927
|
+
//#endregion
|
|
928
|
+
//#region src/satori/action/guild.ts
|
|
929
|
+
/** 成员列表默认条数。 */
|
|
930
|
+
const DEFAULT_MEMBER_LIMIT = 100;
|
|
931
|
+
/** guild.get 参数。 */
|
|
932
|
+
const guildGetSchema = z.object({ guild_id: z.string() });
|
|
933
|
+
/** 获取群组。 */
|
|
934
|
+
var GuildGetAction = class extends BaseSatoriAction {
|
|
935
|
+
name = "guild.get";
|
|
936
|
+
schema = guildGetSchema;
|
|
937
|
+
deps;
|
|
938
|
+
constructor(deps) {
|
|
939
|
+
super();
|
|
940
|
+
this.deps = deps;
|
|
941
|
+
}
|
|
942
|
+
async _handle(payload) {
|
|
943
|
+
const { guild_id: guildId } = payload;
|
|
944
|
+
const detail = await this.deps.groupApi.getGroupInfo(guildId);
|
|
945
|
+
return toGuild(detail.groupCode, detail.groupName);
|
|
946
|
+
}
|
|
947
|
+
};
|
|
948
|
+
/** guild.list 参数。 */
|
|
949
|
+
const guildListSchema = z.object({ next: z.string().optional() });
|
|
950
|
+
/** 获取群组列表。 */
|
|
951
|
+
var GuildListAction = class extends BaseSatoriAction {
|
|
952
|
+
name = "guild.list";
|
|
953
|
+
schema = guildListSchema;
|
|
954
|
+
deps;
|
|
955
|
+
constructor(deps) {
|
|
956
|
+
super();
|
|
957
|
+
this.deps = deps;
|
|
958
|
+
}
|
|
959
|
+
async _handle(_payload) {
|
|
960
|
+
return { data: (await this.deps.groupApi.getGroupList(true)).map((g) => toGuild(g.groupCode, g.groupName)) };
|
|
961
|
+
}
|
|
962
|
+
};
|
|
963
|
+
/** guild.approve 参数(处理入群邀请)。 */
|
|
964
|
+
const guildApproveSchema = z.object({
|
|
965
|
+
message_id: z.string(),
|
|
966
|
+
approve: z.boolean(),
|
|
967
|
+
comment: z.string().optional()
|
|
968
|
+
});
|
|
969
|
+
/** 群请求 approve 基类(guild.approve / guild.member.approve 共用实现)。 */
|
|
970
|
+
var GroupRequestApproveBase = class extends BaseSatoriAction {
|
|
971
|
+
schema = guildApproveSchema;
|
|
972
|
+
deps;
|
|
973
|
+
constructor(deps) {
|
|
974
|
+
super();
|
|
975
|
+
this.deps = deps;
|
|
976
|
+
}
|
|
977
|
+
async _handle(payload) {
|
|
978
|
+
const { message_id: messageId, approve, comment } = payload;
|
|
979
|
+
const notify = await findGroupNotify(this.deps, messageId);
|
|
980
|
+
const operateType = approve ? NTGroupRequestOperateTypes.KAGREE : NTGroupRequestOperateTypes.KREFUSE;
|
|
981
|
+
await this.deps.groupNotifyApi.handleGroupRequest(false, notify, operateType, comment ?? "");
|
|
982
|
+
}
|
|
983
|
+
};
|
|
984
|
+
/** 处理群邀请(message_id = 群通知 seq)。 */
|
|
985
|
+
var GuildApproveAction = class extends GroupRequestApproveBase {
|
|
986
|
+
name = "guild.approve";
|
|
987
|
+
};
|
|
988
|
+
/** guild.member.get 参数。 */
|
|
989
|
+
const guildMemberGetSchema = z.object({
|
|
990
|
+
guild_id: z.string(),
|
|
991
|
+
user_id: z.string()
|
|
992
|
+
});
|
|
993
|
+
/** 获取群成员。 */
|
|
994
|
+
var GuildMemberGetAction = class extends BaseSatoriAction {
|
|
995
|
+
name = "guild.member.get";
|
|
996
|
+
schema = guildMemberGetSchema;
|
|
997
|
+
deps;
|
|
998
|
+
constructor(deps) {
|
|
999
|
+
super();
|
|
1000
|
+
this.deps = deps;
|
|
1001
|
+
}
|
|
1002
|
+
async _handle(payload) {
|
|
1003
|
+
const { guild_id: guildId, user_id: userId } = payload;
|
|
1004
|
+
const uid = (await this.deps.uinToUid([userId])).get(userId) ?? userId;
|
|
1005
|
+
const member = await this.deps.groupCache?.getMember(guildId, uid);
|
|
1006
|
+
if (member !== void 0) return toGuildMember(member, userId);
|
|
1007
|
+
const found = (await this.deps.groupApi.getGroupMemberInfo(guildId, [uid])).find((m) => m.uid === uid);
|
|
1008
|
+
if (found === void 0) throw new Error("群成员不存在");
|
|
1009
|
+
return toGuildMember(found, userId);
|
|
1010
|
+
}
|
|
1011
|
+
};
|
|
1012
|
+
/** guild.member.list 参数。 */
|
|
1013
|
+
const guildMemberListSchema = z.object({
|
|
1014
|
+
guild_id: z.string(),
|
|
1015
|
+
next: z.string().optional()
|
|
1016
|
+
});
|
|
1017
|
+
/** 获取群成员列表。 */
|
|
1018
|
+
var GuildMemberListAction = class extends BaseSatoriAction {
|
|
1019
|
+
name = "guild.member.list";
|
|
1020
|
+
schema = guildMemberListSchema;
|
|
1021
|
+
deps;
|
|
1022
|
+
constructor(deps) {
|
|
1023
|
+
super();
|
|
1024
|
+
this.deps = deps;
|
|
1025
|
+
}
|
|
1026
|
+
async _handle(payload) {
|
|
1027
|
+
const { guild_id: guildId } = payload;
|
|
1028
|
+
let members;
|
|
1029
|
+
const cached = await this.deps.groupCache?.getMembers(guildId);
|
|
1030
|
+
if (cached !== void 0 && cached.length > 0) members = cached;
|
|
1031
|
+
else members = await this.deps.groupApi.getGroupMemberList(guildId, true);
|
|
1032
|
+
const limited = members.slice(0, DEFAULT_MEMBER_LIMIT);
|
|
1033
|
+
const uids = limited.map((m) => m.uid);
|
|
1034
|
+
const uinMap = await this.deps.uidToUin(uids);
|
|
1035
|
+
const out = { data: limited.map((m) => toGuildMember(m, uinMap.get(m.uid) ?? m.uin)) };
|
|
1036
|
+
if (members.length > DEFAULT_MEMBER_LIMIT) out.next = String(DEFAULT_MEMBER_LIMIT);
|
|
1037
|
+
return out;
|
|
1038
|
+
}
|
|
1039
|
+
};
|
|
1040
|
+
/** guild.member.kick 参数。 */
|
|
1041
|
+
const guildMemberKickSchema = z.object({
|
|
1042
|
+
guild_id: z.string(),
|
|
1043
|
+
user_id: z.string(),
|
|
1044
|
+
/** 是否永久踢出(无法再次加入)。 */
|
|
1045
|
+
permanent: z.boolean().optional()
|
|
1046
|
+
});
|
|
1047
|
+
/** 踢出群成员。 */
|
|
1048
|
+
var GuildMemberKickAction = class extends BaseSatoriAction {
|
|
1049
|
+
name = "guild.member.kick";
|
|
1050
|
+
schema = guildMemberKickSchema;
|
|
1051
|
+
deps;
|
|
1052
|
+
constructor(deps) {
|
|
1053
|
+
super();
|
|
1054
|
+
this.deps = deps;
|
|
1055
|
+
}
|
|
1056
|
+
async _handle(payload) {
|
|
1057
|
+
const { guild_id: guildId, user_id: userId, permanent } = payload;
|
|
1058
|
+
const uid = (await this.deps.uinToUid([userId])).get(userId) ?? userId;
|
|
1059
|
+
await this.deps.groupApi.kickMember(guildId, [uid], permanent === true);
|
|
1060
|
+
}
|
|
1061
|
+
};
|
|
1062
|
+
/** guild.member.approve 参数(处理加群申请)。 */
|
|
1063
|
+
const guildMemberApproveSchema = z.object({
|
|
1064
|
+
message_id: z.string(),
|
|
1065
|
+
approve: z.boolean(),
|
|
1066
|
+
comment: z.string().optional()
|
|
1067
|
+
});
|
|
1068
|
+
/** 处理加群申请(message_id = 群通知 seq)。 */
|
|
1069
|
+
var GuildMemberApproveAction = class extends GroupRequestApproveBase {
|
|
1070
|
+
name = "guild.member.approve";
|
|
1071
|
+
schema = guildMemberApproveSchema;
|
|
1072
|
+
};
|
|
1073
|
+
/** 按 message_id(通知 seq)查找群通知。 */
|
|
1074
|
+
async function findGroupNotify(deps, messageId) {
|
|
1075
|
+
const found = (await deps.groupNotifyApi.getSingleScreenNotifies(false, 50)).find((n) => n.seq === messageId);
|
|
1076
|
+
if (found === void 0) throw new Error("群通知不存在");
|
|
1077
|
+
return found;
|
|
1078
|
+
}
|
|
1079
|
+
/** GroupMember → Satori GuildMember(user.id = uin;nick = 群名片优先)。 */
|
|
1080
|
+
function toGuildMember(member, uin) {
|
|
1081
|
+
const nick = member.cardName !== "" ? member.cardName : member.nick;
|
|
1082
|
+
const out = { user: toUser(uin, member.nick) };
|
|
1083
|
+
if (nick !== "" && nick !== member.nick) out.nick = nick;
|
|
1084
|
+
return out;
|
|
1085
|
+
}
|
|
1086
|
+
//#endregion
|
|
1087
|
+
//#region src/satori/action/login.ts
|
|
1088
|
+
/**
|
|
1089
|
+
* Satori 登录动作:login.get
|
|
1090
|
+
*/
|
|
1091
|
+
/** login.get 参数(无)。 */
|
|
1092
|
+
const loginGetSchema = z.object({});
|
|
1093
|
+
/** 获取登录信息。 */
|
|
1094
|
+
var LoginGetAction = class extends BaseSatoriAction {
|
|
1095
|
+
name = "login.get";
|
|
1096
|
+
schema = loginGetSchema;
|
|
1097
|
+
deps;
|
|
1098
|
+
constructor(deps) {
|
|
1099
|
+
super();
|
|
1100
|
+
this.deps = deps;
|
|
1101
|
+
}
|
|
1102
|
+
async _handle(_payload) {
|
|
1103
|
+
return toLogin(this.deps.self, 0, true);
|
|
1104
|
+
}
|
|
1105
|
+
};
|
|
1106
|
+
//#endregion
|
|
1107
|
+
//#region src/satori/action/message.ts
|
|
1108
|
+
/**
|
|
1109
|
+
* Satori 消息动作:message.create / get / list / delete
|
|
1110
|
+
* (message.update:QQ 不支持编辑 → 501,见 registry)
|
|
1111
|
+
*/
|
|
1112
|
+
/** 消息列表默认条数(规范推荐 50)。 */
|
|
1113
|
+
const DEFAULT_LIST_LIMIT = 50;
|
|
1114
|
+
/** message.create 参数。 */
|
|
1115
|
+
const messageCreateSchema = z.object({
|
|
1116
|
+
channel_id: z.string(),
|
|
1117
|
+
content: z.string(),
|
|
1118
|
+
/** 被动请求来源(第一版透传忽略,QQ 不区分主动/被动)。 */
|
|
1119
|
+
referrer: z.unknown().optional()
|
|
1120
|
+
});
|
|
1121
|
+
/** 发送消息:返回 Message 数组(规范)。 */
|
|
1122
|
+
var MessageCreateAction = class extends BaseSatoriAction {
|
|
1123
|
+
name = "message.create";
|
|
1124
|
+
schema = messageCreateSchema;
|
|
1125
|
+
deps;
|
|
1126
|
+
constructor(deps) {
|
|
1127
|
+
super();
|
|
1128
|
+
this.deps = deps;
|
|
1129
|
+
}
|
|
1130
|
+
async _handle(payload) {
|
|
1131
|
+
const { channel_id: channelId, content } = payload;
|
|
1132
|
+
const peer = await this.deps.resolvePeer(channelId);
|
|
1133
|
+
this.deps.setChannelType(channelId, peer.chatType === 2);
|
|
1134
|
+
const elements = await parseContentToCanonical(content, this.deps.toCanonicalDeps());
|
|
1135
|
+
const { msgId } = await this.deps.msgApi.sendMessage(peer, elements);
|
|
1136
|
+
const message = {
|
|
1137
|
+
id: msgId,
|
|
1138
|
+
channel: toChannelById(channelId, peer.chatType === 2)
|
|
1139
|
+
};
|
|
1140
|
+
message.content = content;
|
|
1141
|
+
return [message];
|
|
1142
|
+
}
|
|
1143
|
+
};
|
|
1144
|
+
/** message.get 参数。 */
|
|
1145
|
+
const messageGetSchema = z.object({
|
|
1146
|
+
channel_id: z.string(),
|
|
1147
|
+
message_id: z.string()
|
|
1148
|
+
});
|
|
1149
|
+
/** 获取单条消息。 */
|
|
1150
|
+
var MessageGetAction = class extends BaseSatoriAction {
|
|
1151
|
+
name = "message.get";
|
|
1152
|
+
schema = messageGetSchema;
|
|
1153
|
+
deps;
|
|
1154
|
+
constructor(deps) {
|
|
1155
|
+
super();
|
|
1156
|
+
this.deps = deps;
|
|
1157
|
+
}
|
|
1158
|
+
async _handle(payload) {
|
|
1159
|
+
const { channel_id: channelId, message_id: messageId } = payload;
|
|
1160
|
+
const peer = await this.deps.resolvePeer(channelId);
|
|
1161
|
+
const first = (await this.deps.msgApi.fetchMsgsByMsgId(peer, [messageId]))[0];
|
|
1162
|
+
if (first === void 0) throw new Error("消息不存在");
|
|
1163
|
+
this.deps.setChannelType(channelId, peer.chatType === 2);
|
|
1164
|
+
return toSatoriMessage(first, {
|
|
1165
|
+
selfUin: this.deps.selfUin,
|
|
1166
|
+
uidToUin: (uids) => this.deps.uidToUin(uids)
|
|
1167
|
+
});
|
|
1168
|
+
}
|
|
1169
|
+
};
|
|
1170
|
+
/** message.list 参数。 */
|
|
1171
|
+
const messageListSchema = z.object({
|
|
1172
|
+
channel_id: z.string(),
|
|
1173
|
+
/** 分页令牌(缺省从最新开始)。 */
|
|
1174
|
+
next: z.string().optional(),
|
|
1175
|
+
/** 查询方向(缺省 before)。 */
|
|
1176
|
+
direction: z.enum([
|
|
1177
|
+
"before",
|
|
1178
|
+
"after",
|
|
1179
|
+
"around"
|
|
1180
|
+
]).optional(),
|
|
1181
|
+
/** 消息数量限制(缺省平台默认,推荐 50)。 */
|
|
1182
|
+
limit: z.number().int().optional(),
|
|
1183
|
+
/** 排序(缺省 asc)。 */
|
|
1184
|
+
order: z.enum(["asc", "desc"]).optional()
|
|
1185
|
+
});
|
|
1186
|
+
/** 获取消息列表(双向分页;第一版仅支持 before 方向)。 */
|
|
1187
|
+
var MessageListAction = class extends BaseSatoriAction {
|
|
1188
|
+
name = "message.list";
|
|
1189
|
+
schema = messageListSchema;
|
|
1190
|
+
deps;
|
|
1191
|
+
constructor(deps) {
|
|
1192
|
+
super();
|
|
1193
|
+
this.deps = deps;
|
|
1194
|
+
}
|
|
1195
|
+
async _handle(payload) {
|
|
1196
|
+
const { channel_id: channelId, next, direction, limit, order } = payload;
|
|
1197
|
+
if (direction === "around") throw new Error("暂不支持 around 方向");
|
|
1198
|
+
const peer = await this.deps.resolvePeer(channelId);
|
|
1199
|
+
const count = limit ?? DEFAULT_LIST_LIMIT;
|
|
1200
|
+
const raw = await this.deps.msgApi.fetchMessages(peer, {
|
|
1201
|
+
count,
|
|
1202
|
+
msgId: next ?? ""
|
|
1203
|
+
});
|
|
1204
|
+
this.deps.setChannelType(channelId, peer.chatType === 2);
|
|
1205
|
+
const ordered = order !== "desc" ? [...raw].reverse() : raw;
|
|
1206
|
+
const messages = [];
|
|
1207
|
+
for (const msg of ordered) messages.push(await toSatoriMessage(msg, {
|
|
1208
|
+
selfUin: this.deps.selfUin,
|
|
1209
|
+
uidToUin: (uids) => this.deps.uidToUin(uids)
|
|
1210
|
+
}));
|
|
1211
|
+
const last = messages[messages.length - 1];
|
|
1212
|
+
const out = { data: messages };
|
|
1213
|
+
if (last !== void 0 && raw.length >= count) out.next = last.id;
|
|
1214
|
+
return out;
|
|
1215
|
+
}
|
|
1216
|
+
};
|
|
1217
|
+
/** message.delete 参数。 */
|
|
1218
|
+
const messageDeleteSchema = z.object({
|
|
1219
|
+
channel_id: z.string(),
|
|
1220
|
+
message_id: z.string()
|
|
1221
|
+
});
|
|
1222
|
+
/** 撤回消息。 */
|
|
1223
|
+
var MessageDeleteAction = class extends BaseSatoriAction {
|
|
1224
|
+
name = "message.delete";
|
|
1225
|
+
schema = messageDeleteSchema;
|
|
1226
|
+
deps;
|
|
1227
|
+
constructor(deps) {
|
|
1228
|
+
super();
|
|
1229
|
+
this.deps = deps;
|
|
1230
|
+
}
|
|
1231
|
+
async _handle(payload) {
|
|
1232
|
+
const { channel_id: channelId, message_id: messageId } = payload;
|
|
1233
|
+
const peer = await this.deps.resolvePeer(channelId);
|
|
1234
|
+
await this.deps.msgApi.recallMessage(peer, [messageId]);
|
|
1235
|
+
}
|
|
1236
|
+
};
|
|
1237
|
+
//#endregion
|
|
1238
|
+
//#region src/satori/action/not-implemented.ts
|
|
1239
|
+
/**
|
|
1240
|
+
* 未实现动作(501):平台支持但适配器未实现(规范:501 而非 404)。
|
|
1241
|
+
*/
|
|
1242
|
+
/** 未实现动作:schema 接受任意对象,执行抛 501。 */
|
|
1243
|
+
var NotImplementedAction = class extends BaseSatoriAction {
|
|
1244
|
+
name;
|
|
1245
|
+
schema = z.object({}).passthrough();
|
|
1246
|
+
constructor(name) {
|
|
1247
|
+
super();
|
|
1248
|
+
this.name = name;
|
|
1249
|
+
}
|
|
1250
|
+
async _handle(_payload) {
|
|
1251
|
+
throw new SatoriActionError(501, `平台不支持该操作: ${this.name}`);
|
|
1252
|
+
}
|
|
1253
|
+
};
|
|
1254
|
+
//#endregion
|
|
1255
|
+
//#region src/satori/action/reaction.ts
|
|
1256
|
+
/**
|
|
1257
|
+
* Satori 表态动作:reaction.create / reaction.delete
|
|
1258
|
+
* (reaction.clear / reaction.list:501,见 registry)
|
|
1259
|
+
*/
|
|
1260
|
+
/** 表情 ID 默认类型(QQ 表情 id 为 qq 表情索引)。 */
|
|
1261
|
+
const EMOJI_TYPE = "1";
|
|
1262
|
+
/** reaction.create 参数。 */
|
|
1263
|
+
const reactionCreateSchema = z.object({
|
|
1264
|
+
channel_id: z.string(),
|
|
1265
|
+
message_id: z.string(),
|
|
1266
|
+
emoji_id: z.string()
|
|
1267
|
+
});
|
|
1268
|
+
/** 添加表态(QQ 表情表态,message_id = NT msgId)。 */
|
|
1269
|
+
var ReactionCreateAction = class extends BaseSatoriAction {
|
|
1270
|
+
name = "reaction.create";
|
|
1271
|
+
schema = reactionCreateSchema;
|
|
1272
|
+
deps;
|
|
1273
|
+
constructor(deps) {
|
|
1274
|
+
super();
|
|
1275
|
+
this.deps = deps;
|
|
1276
|
+
}
|
|
1277
|
+
async _handle(payload) {
|
|
1278
|
+
const { channel_id: channelId, message_id: messageId, emoji_id: emojiId } = payload;
|
|
1279
|
+
const peer = await this.deps.resolvePeer(channelId);
|
|
1280
|
+
await this.deps.msgApi.setMsgEmojiLike(peer, {
|
|
1281
|
+
msgSeq: messageId,
|
|
1282
|
+
emojiId,
|
|
1283
|
+
emojiType: EMOJI_TYPE,
|
|
1284
|
+
like: true
|
|
1285
|
+
});
|
|
1286
|
+
}
|
|
1287
|
+
};
|
|
1288
|
+
/** reaction.delete 参数。 */
|
|
1289
|
+
const reactionDeleteSchema = z.object({
|
|
1290
|
+
channel_id: z.string(),
|
|
1291
|
+
message_id: z.string(),
|
|
1292
|
+
emoji_id: z.string(),
|
|
1293
|
+
/** 删除指定用户的表态(缺省删除自己的)。 */
|
|
1294
|
+
user_id: z.string().optional()
|
|
1295
|
+
});
|
|
1296
|
+
/** 删除表态(第一版仅支持删除自己的,与 QQ 表情表态能力对齐)。 */
|
|
1297
|
+
var ReactionDeleteAction = class extends BaseSatoriAction {
|
|
1298
|
+
name = "reaction.delete";
|
|
1299
|
+
schema = reactionDeleteSchema;
|
|
1300
|
+
deps;
|
|
1301
|
+
constructor(deps) {
|
|
1302
|
+
super();
|
|
1303
|
+
this.deps = deps;
|
|
1304
|
+
}
|
|
1305
|
+
async _handle(payload) {
|
|
1306
|
+
const { channel_id: channelId, message_id: messageId, emoji_id: emojiId, user_id: userId } = payload;
|
|
1307
|
+
if (userId !== void 0) throw new Error("暂不支持删除他人表态");
|
|
1308
|
+
const peer = await this.deps.resolvePeer(channelId);
|
|
1309
|
+
await this.deps.msgApi.setMsgEmojiLike(peer, {
|
|
1310
|
+
msgSeq: messageId,
|
|
1311
|
+
emojiId,
|
|
1312
|
+
emojiType: EMOJI_TYPE,
|
|
1313
|
+
like: false
|
|
1314
|
+
});
|
|
1315
|
+
}
|
|
1316
|
+
};
|
|
1317
|
+
//#endregion
|
|
1318
|
+
//#region src/satori/action/user.ts
|
|
1319
|
+
/**
|
|
1320
|
+
* Satori 用户动作:user.get / user.channel.create
|
|
1321
|
+
*/
|
|
1322
|
+
/** user.get 参数。 */
|
|
1323
|
+
const userGetSchema = z.object({ user_id: z.string() });
|
|
1324
|
+
/** 获取用户信息(QQ 平台 user.id = uin)。 */
|
|
1325
|
+
var UserGetAction = class extends BaseSatoriAction {
|
|
1326
|
+
name = "user.get";
|
|
1327
|
+
schema = userGetSchema;
|
|
1328
|
+
deps;
|
|
1329
|
+
constructor(deps) {
|
|
1330
|
+
super();
|
|
1331
|
+
this.deps = deps;
|
|
1332
|
+
}
|
|
1333
|
+
async _handle(payload) {
|
|
1334
|
+
const { user_id: userId } = payload;
|
|
1335
|
+
let nickname;
|
|
1336
|
+
try {
|
|
1337
|
+
const info = await this.deps.profileApi.getStrangerInfo(userId);
|
|
1338
|
+
nickname = info.nickname ?? info.long_nick ?? void 0;
|
|
1339
|
+
} catch {}
|
|
1340
|
+
return toUser(userId, nickname);
|
|
1341
|
+
}
|
|
1342
|
+
};
|
|
1343
|
+
/** user.channel.create 参数。 */
|
|
1344
|
+
const userChannelCreateSchema = z.object({
|
|
1345
|
+
user_id: z.string(),
|
|
1346
|
+
guild_id: z.string().optional()
|
|
1347
|
+
});
|
|
1348
|
+
/** 创建私聊频道(QQ:私聊频道 id = 对端 uin,无需实际创建)。 */
|
|
1349
|
+
var UserChannelCreateAction = class extends BaseSatoriAction {
|
|
1350
|
+
name = "user.channel.create";
|
|
1351
|
+
schema = userChannelCreateSchema;
|
|
1352
|
+
async _handle(payload) {
|
|
1353
|
+
const { user_id: userId } = payload;
|
|
1354
|
+
return toChannelById(userId, false);
|
|
1355
|
+
}
|
|
1356
|
+
};
|
|
1357
|
+
//#endregion
|
|
1358
|
+
//#region src/satori/action/index.ts
|
|
1359
|
+
/**
|
|
1360
|
+
* Satori 动作注册表(ADR-013 延伸):动作依赖收敛为 { api: SatoriApi } 单聚合对象。
|
|
1361
|
+
*
|
|
1362
|
+
* 501 动作:规范「平台支持但适配器未实现 → 501」——QQ 平台不支持的
|
|
1363
|
+
* 能力(消息编辑/频道管理/角色管理等)统一注册 NotImplementedAction。
|
|
1364
|
+
*/
|
|
1365
|
+
/** QQ 平台不支持的 Satori 标准动作(501)。 */
|
|
1366
|
+
const NOT_IMPLEMENTED = [
|
|
1367
|
+
"message.update",
|
|
1368
|
+
"channel.create",
|
|
1369
|
+
"channel.update",
|
|
1370
|
+
"channel.delete",
|
|
1371
|
+
"channel.mute",
|
|
1372
|
+
"guild.member.mute",
|
|
1373
|
+
"guild.member.role.set",
|
|
1374
|
+
"guild.member.role.unset",
|
|
1375
|
+
"guild.role.list",
|
|
1376
|
+
"guild.role.create",
|
|
1377
|
+
"guild.role.update",
|
|
1378
|
+
"guild.role.delete",
|
|
1379
|
+
"reaction.clear",
|
|
1380
|
+
"reaction.list"
|
|
1381
|
+
];
|
|
1382
|
+
/** 创建 Satori 动作注册表。 */
|
|
1383
|
+
function createSatoriActionRegistry(deps) {
|
|
1384
|
+
const registry = new ActionRegistry();
|
|
1385
|
+
const { api } = deps;
|
|
1386
|
+
const messageDeps = api;
|
|
1387
|
+
registry.register(new MessageCreateAction(messageDeps));
|
|
1388
|
+
registry.register(new MessageGetAction(messageDeps));
|
|
1389
|
+
registry.register(new MessageListAction(messageDeps));
|
|
1390
|
+
registry.register(new MessageDeleteAction(messageDeps));
|
|
1391
|
+
const channelDeps = api;
|
|
1392
|
+
registry.register(new ChannelGetAction(channelDeps));
|
|
1393
|
+
registry.register(new ChannelListAction(channelDeps));
|
|
1394
|
+
const userDeps = api;
|
|
1395
|
+
registry.register(new UserGetAction(userDeps));
|
|
1396
|
+
registry.register(new UserChannelCreateAction());
|
|
1397
|
+
const guildDeps = api;
|
|
1398
|
+
registry.register(new GuildGetAction(guildDeps));
|
|
1399
|
+
registry.register(new GuildListAction(guildDeps));
|
|
1400
|
+
registry.register(new GuildApproveAction(guildDeps));
|
|
1401
|
+
registry.register(new GuildMemberGetAction(guildDeps));
|
|
1402
|
+
registry.register(new GuildMemberListAction(guildDeps));
|
|
1403
|
+
registry.register(new GuildMemberKickAction(guildDeps));
|
|
1404
|
+
registry.register(new GuildMemberApproveAction(guildDeps));
|
|
1405
|
+
const friendDeps = api;
|
|
1406
|
+
registry.register(new FriendListAction(friendDeps));
|
|
1407
|
+
registry.register(new FriendDeleteAction(friendDeps));
|
|
1408
|
+
registry.register(new FriendApproveAction(friendDeps));
|
|
1409
|
+
const loginDeps = api;
|
|
1410
|
+
registry.register(new LoginGetAction(loginDeps));
|
|
1411
|
+
const reactionDeps = api;
|
|
1412
|
+
registry.register(new ReactionCreateAction(reactionDeps));
|
|
1413
|
+
registry.register(new ReactionDeleteAction(reactionDeps));
|
|
1414
|
+
for (const name of NOT_IMPLEMENTED) registry.register(new NotImplementedAction(name));
|
|
1415
|
+
return registry;
|
|
1416
|
+
}
|
|
1417
|
+
//#endregion
|
|
1418
|
+
//#region src/satori/api/satori-api.ts
|
|
1419
|
+
/** Satori 动作统一依赖聚合。 */
|
|
1420
|
+
var SatoriApi = class {
|
|
1421
|
+
msgApi;
|
|
1422
|
+
groupApi;
|
|
1423
|
+
groupNotifyApi;
|
|
1424
|
+
friendApi;
|
|
1425
|
+
profileApi;
|
|
1426
|
+
self;
|
|
1427
|
+
selfUin;
|
|
1428
|
+
cacheDir;
|
|
1429
|
+
groupCache;
|
|
1430
|
+
/**
|
|
1431
|
+
* 频道类型缓存:channel_id → isGroup(群聊频道 id 与私聊 uin 无法从数字
|
|
1432
|
+
* 规则区分,需从事件/查询回填——QQ 平台群号与用户 uin 同为数字)。
|
|
1433
|
+
*/
|
|
1434
|
+
channelTypeCache = /* @__PURE__ */ new Map();
|
|
1435
|
+
constructor(opts) {
|
|
1436
|
+
this.msgApi = opts.msgApi;
|
|
1437
|
+
this.groupApi = opts.groupApi;
|
|
1438
|
+
this.groupNotifyApi = opts.groupNotifyApi;
|
|
1439
|
+
this.friendApi = opts.friendApi;
|
|
1440
|
+
this.profileApi = opts.profileApi;
|
|
1441
|
+
this.self = opts.self;
|
|
1442
|
+
this.selfUin = opts.self.uin;
|
|
1443
|
+
this.cacheDir = opts.cacheDir;
|
|
1444
|
+
this.groupCache = opts.groupCache;
|
|
1445
|
+
}
|
|
1446
|
+
/** 记录频道类型(消息事件翻译时回填:chatType GROUP → true)。 */
|
|
1447
|
+
setChannelType(channelId, isGroup) {
|
|
1448
|
+
this.channelTypeCache.set(channelId, isGroup);
|
|
1449
|
+
}
|
|
1450
|
+
/**
|
|
1451
|
+
* 解析 channel_id → Peer(消息动作共用)。
|
|
1452
|
+
* 缓存命中直接取类型;未命中先试群号(getGroupInfo 成功 = 群聊),
|
|
1453
|
+
* 失败按私聊(uin → uid)。
|
|
1454
|
+
*/
|
|
1455
|
+
async resolvePeer(channelId) {
|
|
1456
|
+
const cached = this.channelTypeCache.get(channelId);
|
|
1457
|
+
if (cached === true) return {
|
|
1458
|
+
chatType: ChatType.GROUP,
|
|
1459
|
+
peerUid: channelId
|
|
1460
|
+
};
|
|
1461
|
+
if (cached === false) return this.toC2cPeer(channelId);
|
|
1462
|
+
const isGroup = await this.groupApi.getGroupInfo(channelId).then(() => true).catch(() => false);
|
|
1463
|
+
this.channelTypeCache.set(channelId, isGroup);
|
|
1464
|
+
if (isGroup) return {
|
|
1465
|
+
chatType: ChatType.GROUP,
|
|
1466
|
+
peerUid: channelId
|
|
1467
|
+
};
|
|
1468
|
+
return this.toC2cPeer(channelId);
|
|
1469
|
+
}
|
|
1470
|
+
/** uin → 私聊 Peer(uin→uid 转换;转换失败原样 uin 兜底)。 */
|
|
1471
|
+
async toC2cPeer(uin) {
|
|
1472
|
+
const uid = (await this.groupApi.uinToUid([uin])).get(uin) ?? uin;
|
|
1473
|
+
return {
|
|
1474
|
+
chatType: ChatType.C2C,
|
|
1475
|
+
peerUid: uid
|
|
1476
|
+
};
|
|
1477
|
+
}
|
|
1478
|
+
/** uin → uid(委托 groupApi;私聊发送 / at 目标用)。 */
|
|
1479
|
+
uinToUid(uins) {
|
|
1480
|
+
return this.groupApi.uinToUid(uins);
|
|
1481
|
+
}
|
|
1482
|
+
/** uid → uin(委托 groupApi;收方向 at / 用户 ID 用)。 */
|
|
1483
|
+
uidToUin(uids) {
|
|
1484
|
+
return this.groupApi.uidToUin(uids);
|
|
1485
|
+
}
|
|
1486
|
+
/** 发方向元素转换依赖(at uin→uid + 资源下载目录)。 */
|
|
1487
|
+
toCanonicalDeps() {
|
|
1488
|
+
return {
|
|
1489
|
+
uinToUid: (uins) => this.groupApi.uinToUid(uins),
|
|
1490
|
+
cacheDir: this.cacheDir
|
|
1491
|
+
};
|
|
1492
|
+
}
|
|
1493
|
+
};
|
|
1494
|
+
//#endregion
|
|
1495
|
+
//#region src/satori/event/translate.ts
|
|
1496
|
+
/**
|
|
1497
|
+
* Satori 事件翻译(kernel RawMessage → Satori Event 内容,纯函数 + 注入转换)
|
|
1498
|
+
*
|
|
1499
|
+
* 第一版支持:
|
|
1500
|
+
* - message-created:普通消息(Msg/onRecvMsg)
|
|
1501
|
+
* - message-deleted:grayTip REVOKE(撤回)
|
|
1502
|
+
* - guild-member-added / guild-member-removed:grayTip GROUP(TipGroupElement)
|
|
1503
|
+
* (friend-request / guild-request 事件留待后续:grayTip 的 jsonGrayTipElement
|
|
1504
|
+
* 结构待运行时探测,OB11 走主动查询 get_group_system_msg 不广播。)
|
|
1505
|
+
*
|
|
1506
|
+
* 收方向 ID 空间:uid → uin 批量转换(deps.uidToUin,与消息翻译同依赖)。
|
|
1507
|
+
*/
|
|
1508
|
+
/** 普通消息 → message-created 事件。 */
|
|
1509
|
+
async function toSatoriMessageEvent(raw, deps) {
|
|
1510
|
+
const message = await toSatoriMessage(raw, deps);
|
|
1511
|
+
const content = {
|
|
1512
|
+
type: "message-created",
|
|
1513
|
+
timestamp: Number(raw.msgTime)
|
|
1514
|
+
};
|
|
1515
|
+
if (message.channel !== void 0) content.channel = message.channel;
|
|
1516
|
+
if (message.guild !== void 0) content.guild = message.guild;
|
|
1517
|
+
if (message.user !== void 0) content.user = message.user;
|
|
1518
|
+
if (message.member !== void 0) content.member = message.member;
|
|
1519
|
+
content.message = message;
|
|
1520
|
+
return content;
|
|
1521
|
+
}
|
|
1522
|
+
/** 检查消息是否含可翻译的 grayTip(撤回 / 群成员变动)。 */
|
|
1523
|
+
function hasSatoriGrayTip(raw) {
|
|
1524
|
+
for (const el of raw.elements) {
|
|
1525
|
+
const g = el.grayTipElement;
|
|
1526
|
+
if (g === void 0) continue;
|
|
1527
|
+
if (g.subElementType === GrayTipSubType.REVOKE || g.subElementType === GrayTipSubType.GROUP) return true;
|
|
1528
|
+
}
|
|
1529
|
+
return false;
|
|
1530
|
+
}
|
|
1531
|
+
/** 收集 grayTip 涉及的 uid(批量 uidToUin 用,core 共享实现)。 */
|
|
1532
|
+
const collectSatoriGrayTipUids = collectGrayTipUids;
|
|
1533
|
+
/** 判断消息是否为群聊 grayTip(peerUid 是群号)。 */
|
|
1534
|
+
function isGroupMessage(raw) {
|
|
1535
|
+
return raw.chatType === ChatType.GROUP;
|
|
1536
|
+
}
|
|
1537
|
+
/** grayTip → 事件(撤回 / 群成员变动;无匹配返回 null)。 */
|
|
1538
|
+
async function toSatoriGrayTipEvent(raw, uidToUinMap) {
|
|
1539
|
+
for (const el of raw.elements) {
|
|
1540
|
+
const g = el.grayTipElement;
|
|
1541
|
+
if (g === void 0) continue;
|
|
1542
|
+
if (g.subElementType === GrayTipSubType.REVOKE) {
|
|
1543
|
+
const revoked = toRevokeEvent(raw, uidToUinMap);
|
|
1544
|
+
if (revoked !== null) return revoked;
|
|
1545
|
+
}
|
|
1546
|
+
if (g.subElementType === GrayTipSubType.GROUP && isGroupMessage(raw)) {
|
|
1547
|
+
const member = toMemberChangeEvent(raw, uidToUinMap);
|
|
1548
|
+
if (member !== null) return member;
|
|
1549
|
+
}
|
|
1550
|
+
}
|
|
1551
|
+
return null;
|
|
1552
|
+
}
|
|
1553
|
+
/** uid → uin(映射缺省退化为 uid)。 */
|
|
1554
|
+
function toUin(uid, uidToUinMap) {
|
|
1555
|
+
return uidToUinMap.get(uid) ?? uid;
|
|
1556
|
+
}
|
|
1557
|
+
/** 撤回 → message-deleted 事件。 */
|
|
1558
|
+
function toRevokeEvent(raw, uidToUinMap) {
|
|
1559
|
+
for (const el of raw.elements) {
|
|
1560
|
+
const revoke = el.grayTipElement?.revokeElement;
|
|
1561
|
+
if (revoke === void 0) continue;
|
|
1562
|
+
const operatorUin = toUin(revoke.operatorUid, uidToUinMap);
|
|
1563
|
+
const peerUin = String(raw.peerUin ?? "");
|
|
1564
|
+
const content = {
|
|
1565
|
+
type: "message-deleted",
|
|
1566
|
+
timestamp: Number(raw.msgTime)
|
|
1567
|
+
};
|
|
1568
|
+
content.channel = {
|
|
1569
|
+
id: peerUin,
|
|
1570
|
+
type: isGroupMessage(raw) ? 0 : 1
|
|
1571
|
+
};
|
|
1572
|
+
if (isGroupMessage(raw)) content.guild = {
|
|
1573
|
+
id: peerUin,
|
|
1574
|
+
name: raw.peerName
|
|
1575
|
+
};
|
|
1576
|
+
content.message = { id: raw.msgSeq };
|
|
1577
|
+
content.operator = toUser(operatorUin, revoke.operatorNick);
|
|
1578
|
+
return content;
|
|
1579
|
+
}
|
|
1580
|
+
return null;
|
|
1581
|
+
}
|
|
1582
|
+
/** 群成员变动 → guild-member-added / guild-member-removed 事件。 */
|
|
1583
|
+
function toMemberChangeEvent(raw, uidToUinMap) {
|
|
1584
|
+
for (const el of raw.elements) {
|
|
1585
|
+
const grp = el.grayTipElement?.groupElement;
|
|
1586
|
+
if (grp === void 0 || grp.type === void 0) continue;
|
|
1587
|
+
const type = memberChangeType(grp.type);
|
|
1588
|
+
if (type === null) return null;
|
|
1589
|
+
const memberUid = grp.memberUid ?? "";
|
|
1590
|
+
const adminUid = grp.adminUid ?? "";
|
|
1591
|
+
const peerUin = String(raw.peerUin ?? "");
|
|
1592
|
+
const content = {
|
|
1593
|
+
type,
|
|
1594
|
+
timestamp: Number(raw.msgTime),
|
|
1595
|
+
guild: toGroupGuild(peerUin, raw.peerName),
|
|
1596
|
+
channel: {
|
|
1597
|
+
id: peerUin,
|
|
1598
|
+
type: 0
|
|
1599
|
+
},
|
|
1600
|
+
member: toMember(memberUid, grp.memberNick, grp.memberRemark, uidToUinMap),
|
|
1601
|
+
user: toUser(toUin(memberUid, uidToUinMap), grp.memberNick)
|
|
1602
|
+
};
|
|
1603
|
+
if (adminUid !== "" && adminUid !== memberUid) content.operator = toUser(toUin(adminUid, uidToUinMap), grp.adminNick);
|
|
1604
|
+
return content;
|
|
1605
|
+
}
|
|
1606
|
+
return null;
|
|
1607
|
+
}
|
|
1608
|
+
/** 成员变动类型(MEMBER_ADD=added / QUIT=removed;其余 null)。 */
|
|
1609
|
+
function memberChangeType(type) {
|
|
1610
|
+
if (type === TipGroupElementType.MEMBER_ADD) return "guild-member-added";
|
|
1611
|
+
if (type === TipGroupElementType.QUIT) return "guild-member-removed";
|
|
1612
|
+
return null;
|
|
1613
|
+
}
|
|
1614
|
+
/** 构造群组(带可选群名)。 */
|
|
1615
|
+
function toGroupGuild(id, name) {
|
|
1616
|
+
const guild = { id };
|
|
1617
|
+
if (name !== void 0 && name !== "") guild.name = name;
|
|
1618
|
+
return guild;
|
|
1619
|
+
}
|
|
1620
|
+
/** 构造群成员(user + 可选备注 nick)。 */
|
|
1621
|
+
function toMember(uid, nick, remark, uidToUinMap) {
|
|
1622
|
+
const member = { user: toUser(toUin(uid, uidToUinMap), nick) };
|
|
1623
|
+
if (remark !== void 0 && remark !== "" && remark !== nick) member.nick = remark;
|
|
1624
|
+
return member;
|
|
1625
|
+
}
|
|
1626
|
+
//#endregion
|
|
1627
|
+
//#region src/satori/types/event.ts
|
|
1628
|
+
/** 信令类型。 */
|
|
1629
|
+
const Opcode = {
|
|
1630
|
+
EVENT: 0,
|
|
1631
|
+
PING: 1,
|
|
1632
|
+
PONG: 2,
|
|
1633
|
+
IDENTIFY: 3,
|
|
1634
|
+
READY: 4,
|
|
1635
|
+
META: 5
|
|
1636
|
+
};
|
|
1637
|
+
//#endregion
|
|
1638
|
+
//#region src/satori/transport.ts
|
|
1639
|
+
/** WS 事件服务路径(规范固定)。 */
|
|
1640
|
+
const WS_EVENTS_PATH = "/v1/events";
|
|
1641
|
+
/** HTTP RPC 版本前缀。 */
|
|
1642
|
+
const V1_PREFIX = "/v1/";
|
|
1643
|
+
/** Authorization 头前缀。 */
|
|
1644
|
+
const BEARER_PREFIX = "Bearer ";
|
|
1645
|
+
/** 按配置装配 Satori 传输。 */
|
|
1646
|
+
function assembleSatoriTransports(opts) {
|
|
1647
|
+
const { config, broadcaster, registry, login } = opts;
|
|
1648
|
+
const adapters = [];
|
|
1649
|
+
for (const item of config.httpServers) {
|
|
1650
|
+
if (!item.enabled) continue;
|
|
1651
|
+
const token = item.token ?? config.token;
|
|
1652
|
+
const server = new HttpServer({
|
|
1653
|
+
host: item.host,
|
|
1654
|
+
port: item.port,
|
|
1655
|
+
...token !== void 0 && token !== "" ? { authorize: makeBearerAuthorize(token) } : {}
|
|
1656
|
+
});
|
|
1657
|
+
server.onPathRequest = (ctx, req, respond) => {
|
|
1658
|
+
handleHttpRequest(ctx, req, respond, registry, login);
|
|
1659
|
+
};
|
|
1660
|
+
adapters.push(server);
|
|
1661
|
+
}
|
|
1662
|
+
for (const item of config.wsServers) {
|
|
1663
|
+
if (!item.enabled) continue;
|
|
1664
|
+
const token = item.token ?? config.token;
|
|
1665
|
+
const server = new WsServer({
|
|
1666
|
+
host: item.host,
|
|
1667
|
+
port: item.port,
|
|
1668
|
+
path: WS_EVENTS_PATH
|
|
1669
|
+
});
|
|
1670
|
+
server.onRequest = (req, respond) => {
|
|
1671
|
+
handleWsSignal(req, respond, token, login);
|
|
1672
|
+
};
|
|
1673
|
+
adapters.push(server);
|
|
1674
|
+
}
|
|
1675
|
+
for (const adapter of adapters) broadcaster.register(adapter);
|
|
1676
|
+
return {
|
|
1677
|
+
adapters,
|
|
1678
|
+
close: async () => {
|
|
1679
|
+
await Promise.all(adapters.map((a) => a.close()));
|
|
1680
|
+
for (const adapter of adapters) broadcaster.unregister(adapter);
|
|
1681
|
+
}
|
|
1682
|
+
};
|
|
1683
|
+
}
|
|
1684
|
+
/** Bearer token 鉴权钩子。 */
|
|
1685
|
+
function makeBearerAuthorize(token) {
|
|
1686
|
+
return (ctx) => {
|
|
1687
|
+
const auth = ctx.headers["authorization"] ?? ctx.headers["Authorization"];
|
|
1688
|
+
if (auth?.startsWith(BEARER_PREFIX)) return auth.slice(7) === token;
|
|
1689
|
+
return false;
|
|
1690
|
+
};
|
|
1691
|
+
}
|
|
1692
|
+
/** 解析路径:/v1/{resource}.{method} → 动作名;不匹配返回 null。 */
|
|
1693
|
+
function parseActionPath(path) {
|
|
1694
|
+
if (!path.startsWith(V1_PREFIX)) return null;
|
|
1695
|
+
const rest = path.slice(4);
|
|
1696
|
+
if (/^([a-z][a-z0-9-]*)(?:\.[a-z][a-z0-9-]*)+$/i.exec(rest) === null) return null;
|
|
1697
|
+
return rest;
|
|
1698
|
+
}
|
|
1699
|
+
/** HTTP RPC 分发。 */
|
|
1700
|
+
function handleHttpRequest(ctx, req, respond, registry, login) {
|
|
1701
|
+
if (ctx.method !== "POST") {
|
|
1702
|
+
respond({ error: "method not allowed" }, 405);
|
|
1703
|
+
return;
|
|
1704
|
+
}
|
|
1705
|
+
const action = parseActionPath(ctx.path);
|
|
1706
|
+
if (action === null) {
|
|
1707
|
+
respond({ error: "api not found" }, HTTP_STATUS.notFound);
|
|
1708
|
+
return;
|
|
1709
|
+
}
|
|
1710
|
+
if (action === "meta") {
|
|
1711
|
+
respond({
|
|
1712
|
+
logins: [login()],
|
|
1713
|
+
proxy_urls: []
|
|
1714
|
+
}, HTTP_STATUS.ok);
|
|
1715
|
+
return;
|
|
1716
|
+
}
|
|
1717
|
+
const act = registry.get(action);
|
|
1718
|
+
if (act === void 0) {
|
|
1719
|
+
respond({ error: "api not found" }, HTTP_STATUS.notFound);
|
|
1720
|
+
return;
|
|
1721
|
+
}
|
|
1722
|
+
act.run(req).then((data) => {
|
|
1723
|
+
respond(data ?? {}, HTTP_STATUS.ok);
|
|
1724
|
+
}).catch((err) => {
|
|
1725
|
+
if (err instanceof SatoriActionError) {
|
|
1726
|
+
respond({ error: err.message }, err.status);
|
|
1727
|
+
return;
|
|
1728
|
+
}
|
|
1729
|
+
respond({ error: err instanceof Error ? err.message : "internal error" }, HTTP_STATUS.serverError);
|
|
1730
|
+
});
|
|
1731
|
+
}
|
|
1732
|
+
/** WS 信令处理(IDENTIFY → READY;PING → PONG)。 */
|
|
1733
|
+
function handleWsSignal(signal, respond, token, login) {
|
|
1734
|
+
const parsed = signal ?? {};
|
|
1735
|
+
const op = parsed.op;
|
|
1736
|
+
if (op === Opcode.IDENTIFY) {
|
|
1737
|
+
const body = parsed.body ?? {};
|
|
1738
|
+
if (token !== void 0 && token !== "" && body.token !== token) return;
|
|
1739
|
+
respond({
|
|
1740
|
+
op: Opcode.READY,
|
|
1741
|
+
body: {
|
|
1742
|
+
logins: [login()],
|
|
1743
|
+
proxy_urls: []
|
|
1744
|
+
}
|
|
1745
|
+
});
|
|
1746
|
+
return;
|
|
1747
|
+
}
|
|
1748
|
+
if (op === Opcode.PING) respond({
|
|
1749
|
+
op: Opcode.PONG,
|
|
1750
|
+
body: null
|
|
1751
|
+
});
|
|
1752
|
+
}
|
|
1753
|
+
/** 事件信令(adapter 广播用)。 */
|
|
1754
|
+
function toEventSignal(event) {
|
|
1755
|
+
return {
|
|
1756
|
+
op: Opcode.EVENT,
|
|
1757
|
+
body: event
|
|
1758
|
+
};
|
|
1759
|
+
}
|
|
1760
|
+
//#endregion
|
|
1761
|
+
//#region src/satori/adapter.ts
|
|
1762
|
+
/** Satori 协议适配器。 */
|
|
1763
|
+
var NapukettoSatoriAdapter = class extends BaseProtocolAdapter {
|
|
1764
|
+
protocol = "satori";
|
|
1765
|
+
configSchema = satoriConfigSchema;
|
|
1766
|
+
msgChannel;
|
|
1767
|
+
api;
|
|
1768
|
+
registry;
|
|
1769
|
+
transports = null;
|
|
1770
|
+
unsubscribe = null;
|
|
1771
|
+
eventSn = 0;
|
|
1772
|
+
constructor(opts) {
|
|
1773
|
+
super({
|
|
1774
|
+
config: opts.config,
|
|
1775
|
+
broadcaster: opts.broadcaster,
|
|
1776
|
+
hooks: {
|
|
1777
|
+
onStart: (config) => this.startTransports(config),
|
|
1778
|
+
onStop: () => this.stopAll(),
|
|
1779
|
+
onReload: () => {
|
|
1780
|
+
return Promise.resolve();
|
|
1781
|
+
}
|
|
1782
|
+
}
|
|
1783
|
+
});
|
|
1784
|
+
this.msgChannel = opts.msgChannel;
|
|
1785
|
+
this.api = new SatoriApi(opts);
|
|
1786
|
+
this.registry = createSatoriActionRegistry({ api: this.api });
|
|
1787
|
+
}
|
|
1788
|
+
/** 启动传输:装配(HTTP RPC + WS 事件服务)+ 打开 server + 订阅消息事件。 */
|
|
1789
|
+
async startTransports(config) {
|
|
1790
|
+
const broadcaster = this.getBroadcaster();
|
|
1791
|
+
if (broadcaster !== void 0) {
|
|
1792
|
+
this.transports = assembleSatoriTransports({
|
|
1793
|
+
config,
|
|
1794
|
+
broadcaster,
|
|
1795
|
+
registry: this.registry,
|
|
1796
|
+
login: () => this.currentLogin()
|
|
1797
|
+
});
|
|
1798
|
+
await Promise.all(this.transports.adapters.map((t) => t.open()));
|
|
1799
|
+
}
|
|
1800
|
+
this.subscribe();
|
|
1801
|
+
this.broadcastLoginUpdated(true);
|
|
1802
|
+
}
|
|
1803
|
+
/** 当前登录信息(READY logins / meta / login.get 用)。 */
|
|
1804
|
+
currentLogin() {
|
|
1805
|
+
return toLogin(this.api.self, 0, true);
|
|
1806
|
+
}
|
|
1807
|
+
/** 广播 login-updated 事件(适配器启停时)。 */
|
|
1808
|
+
broadcastLoginUpdated(online) {
|
|
1809
|
+
const login = toLogin(this.api.self, 0, online);
|
|
1810
|
+
this.broadcastEvent(toEventSignal({
|
|
1811
|
+
sn: this.nextSn(),
|
|
1812
|
+
type: "login-updated",
|
|
1813
|
+
timestamp: Date.now(),
|
|
1814
|
+
login
|
|
1815
|
+
}));
|
|
1816
|
+
}
|
|
1817
|
+
/** 停止:退订 + 关闭传输 + 广播 login-updated(离线)。 */
|
|
1818
|
+
async stopAll() {
|
|
1819
|
+
this.unsubscribeAll();
|
|
1820
|
+
if (this.transports !== null) {
|
|
1821
|
+
await this.transports.close();
|
|
1822
|
+
this.transports = null;
|
|
1823
|
+
}
|
|
1824
|
+
this.broadcastLoginUpdated(false);
|
|
1825
|
+
}
|
|
1826
|
+
/** 订阅 kernel 消息事件(幂等)。 */
|
|
1827
|
+
subscribe() {
|
|
1828
|
+
if (this.unsubscribe !== null) return;
|
|
1829
|
+
this.unsubscribe = this.msgChannel.on("Msg/onRecvMsg", (msgs) => {
|
|
1830
|
+
forEachRawMessage(msgs, (msg) => {
|
|
1831
|
+
if (hasSatoriGrayTip(msg)) {
|
|
1832
|
+
this.broadcastGrayTip(msg).catch(() => {});
|
|
1833
|
+
return;
|
|
1834
|
+
}
|
|
1835
|
+
this.broadcastMessageCreated(msg);
|
|
1836
|
+
});
|
|
1837
|
+
});
|
|
1838
|
+
}
|
|
1839
|
+
/** 广播消息事件(message-created)。 */
|
|
1840
|
+
async broadcastMessageCreated(msg) {
|
|
1841
|
+
const content = await toSatoriMessageEvent(msg, {
|
|
1842
|
+
selfUin: this.api.selfUin,
|
|
1843
|
+
uidToUin: (uids) => this.api.uidToUin(uids)
|
|
1844
|
+
});
|
|
1845
|
+
this.api.setChannelType(String(msg.peerUin ?? ""), msg.chatType === 2);
|
|
1846
|
+
this.broadcastContent(content);
|
|
1847
|
+
}
|
|
1848
|
+
/** 广播 grayTip 事件(message-deleted / guild-member-added / removed)。 */
|
|
1849
|
+
async broadcastGrayTip(msg) {
|
|
1850
|
+
const uids = collectSatoriGrayTipUids(msg);
|
|
1851
|
+
let uidToUin = /* @__PURE__ */ new Map();
|
|
1852
|
+
if (uids.length > 0) uidToUin = await this.api.uidToUin(uids);
|
|
1853
|
+
const content = await toSatoriGrayTipEvent(msg, uidToUin);
|
|
1854
|
+
if (content !== null) this.broadcastContent(content);
|
|
1855
|
+
}
|
|
1856
|
+
/** 广播事件内容(sn 递增 + 最小 login 包装 + EVENT 信令)。 */
|
|
1857
|
+
broadcastContent(content) {
|
|
1858
|
+
this.broadcastEvent(toEventSignal({
|
|
1859
|
+
sn: this.nextSn(),
|
|
1860
|
+
type: content.type,
|
|
1861
|
+
timestamp: content.timestamp,
|
|
1862
|
+
login: toMinimalLogin(this.eventSn, this.api.selfUin),
|
|
1863
|
+
...pickEventResources(content)
|
|
1864
|
+
}));
|
|
1865
|
+
}
|
|
1866
|
+
/** 事件 sn(递增,从 1 起)。 */
|
|
1867
|
+
nextSn() {
|
|
1868
|
+
this.eventSn += 1;
|
|
1869
|
+
return this.eventSn;
|
|
1870
|
+
}
|
|
1871
|
+
/** 退订(幂等)。 */
|
|
1872
|
+
unsubscribeAll() {
|
|
1873
|
+
this.unsubscribe?.();
|
|
1874
|
+
this.unsubscribe = null;
|
|
1875
|
+
}
|
|
1876
|
+
};
|
|
1877
|
+
/** 从事件内容提取可选资源字段(channel/guild/message/user/member/operator)。 */
|
|
1878
|
+
function pickEventResources(content) {
|
|
1879
|
+
const out = {};
|
|
1880
|
+
if (content.channel !== void 0) out.channel = content.channel;
|
|
1881
|
+
if (content.guild !== void 0) out.guild = content.guild;
|
|
1882
|
+
if (content.message !== void 0) out.message = content.message;
|
|
1883
|
+
if (content.user !== void 0) out.user = content.user;
|
|
1884
|
+
if (content.member !== void 0) out.member = content.member;
|
|
1885
|
+
if (content.operator !== void 0) out.operator = content.operator;
|
|
1886
|
+
return out;
|
|
1887
|
+
}
|
|
1888
|
+
//#endregion
|
|
1889
|
+
export { HTTP_STATUS, NapukettoSatoriAdapter, SatoriActionError, SatoriApi, assembleSatoriTransports, canonicalToSatoriElements, createSatoriActionRegistry, parseContentToCanonical, parseElements, renderElements, satoriConfigSchema, satoriHttpStatusMap, satoriToCanonicalElements, toEventSignal };
|
|
1890
|
+
|
|
1891
|
+
//# sourceMappingURL=index.mjs.map
|