@yunzai-ng/core 0.1.0
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/LICENSE +661 -0
- package/README.md +51 -0
- package/dist/adapter/accounts.d.ts +174 -0
- package/dist/adapter/accounts.d.ts.map +1 -0
- package/dist/adapter/accounts.js +653 -0
- package/dist/adapter/accounts.js.map +1 -0
- package/dist/adapter/bots.d.ts +142 -0
- package/dist/adapter/bots.d.ts.map +1 -0
- package/dist/adapter/bots.js +454 -0
- package/dist/adapter/bots.js.map +1 -0
- package/dist/adapter/host.d.ts +114 -0
- package/dist/adapter/host.d.ts.map +1 -0
- package/dist/adapter/host.js +90 -0
- package/dist/adapter/host.js.map +1 -0
- package/dist/adapter/login.d.ts +174 -0
- package/dist/adapter/login.d.ts.map +1 -0
- package/dist/adapter/login.js +345 -0
- package/dist/adapter/login.js.map +1 -0
- package/dist/adapter/registry.d.ts +102 -0
- package/dist/adapter/registry.d.ts.map +1 -0
- package/dist/adapter/registry.js +150 -0
- package/dist/adapter/registry.js.map +1 -0
- package/dist/config/core-config.d.ts +151 -0
- package/dist/config/core-config.d.ts.map +1 -0
- package/dist/config/core-config.js +338 -0
- package/dist/config/core-config.js.map +1 -0
- package/dist/config/schema.d.ts +449 -0
- package/dist/config/schema.d.ts.map +1 -0
- package/dist/config/schema.js +871 -0
- package/dist/config/schema.js.map +1 -0
- package/dist/config/store.d.ts +184 -0
- package/dist/config/store.d.ts.map +1 -0
- package/dist/config/store.js +428 -0
- package/dist/config/store.js.map +1 -0
- package/dist/config/yaml.d.ts +23 -0
- package/dist/config/yaml.d.ts.map +1 -0
- package/dist/config/yaml.js +108 -0
- package/dist/config/yaml.js.map +1 -0
- package/dist/http/client.d.ts +82 -0
- package/dist/http/client.d.ts.map +1 -0
- package/dist/http/client.js +658 -0
- package/dist/http/client.js.map +1 -0
- package/dist/index.d.ts +77 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +94 -0
- package/dist/index.js.map +1 -0
- package/dist/kernel/app.d.ts +244 -0
- package/dist/kernel/app.d.ts.map +1 -0
- package/dist/kernel/app.js +654 -0
- package/dist/kernel/app.js.map +1 -0
- package/dist/kernel/kv-sink.d.ts +34 -0
- package/dist/kernel/kv-sink.d.ts.map +1 -0
- package/dist/kernel/kv-sink.js +25 -0
- package/dist/kernel/kv-sink.js.map +1 -0
- package/dist/kernel/policy.d.ts +77 -0
- package/dist/kernel/policy.d.ts.map +1 -0
- package/dist/kernel/policy.js +99 -0
- package/dist/kernel/policy.js.map +1 -0
- package/dist/kernel/runtime.d.ts +117 -0
- package/dist/kernel/runtime.d.ts.map +1 -0
- package/dist/kernel/runtime.js +197 -0
- package/dist/kernel/runtime.js.map +1 -0
- package/dist/kernel/sql-sink.d.ts +36 -0
- package/dist/kernel/sql-sink.d.ts.map +1 -0
- package/dist/kernel/sql-sink.js +114 -0
- package/dist/kernel/sql-sink.js.map +1 -0
- package/dist/logger/format.d.ts +67 -0
- package/dist/logger/format.d.ts.map +1 -0
- package/dist/logger/format.js +218 -0
- package/dist/logger/format.js.map +1 -0
- package/dist/logger/index.d.ts +97 -0
- package/dist/logger/index.d.ts.map +1 -0
- package/dist/logger/index.js +363 -0
- package/dist/logger/index.js.map +1 -0
- package/dist/logger/rotate.d.ts +47 -0
- package/dist/logger/rotate.d.ts.map +1 -0
- package/dist/logger/rotate.js +242 -0
- package/dist/logger/rotate.js.map +1 -0
- package/dist/message/segment.d.ts +255 -0
- package/dist/message/segment.d.ts.map +1 -0
- package/dist/message/segment.js +510 -0
- package/dist/message/segment.js.map +1 -0
- package/dist/message/split.d.ts +34 -0
- package/dist/message/split.d.ts.map +1 -0
- package/dist/message/split.js +79 -0
- package/dist/message/split.js.map +1 -0
- package/dist/message/target.d.ts +29 -0
- package/dist/message/target.d.ts.map +1 -0
- package/dist/message/target.js +34 -0
- package/dist/message/target.js.map +1 -0
- package/dist/pipeline/cooldown.d.ts +84 -0
- package/dist/pipeline/cooldown.d.ts.map +1 -0
- package/dist/pipeline/cooldown.js +94 -0
- package/dist/pipeline/cooldown.js.map +1 -0
- package/dist/pipeline/dispatch.d.ts +98 -0
- package/dist/pipeline/dispatch.d.ts.map +1 -0
- package/dist/pipeline/dispatch.js +323 -0
- package/dist/pipeline/dispatch.js.map +1 -0
- package/dist/pipeline/event.d.ts +130 -0
- package/dist/pipeline/event.d.ts.map +1 -0
- package/dist/pipeline/event.js +538 -0
- package/dist/pipeline/event.js.map +1 -0
- package/dist/pipeline/middleware.d.ts +63 -0
- package/dist/pipeline/middleware.d.ts.map +1 -0
- package/dist/pipeline/middleware.js +174 -0
- package/dist/pipeline/middleware.js.map +1 -0
- package/dist/pipeline/prompt.d.ts +80 -0
- package/dist/pipeline/prompt.d.ts.map +1 -0
- package/dist/pipeline/prompt.js +155 -0
- package/dist/pipeline/prompt.js.map +1 -0
- package/dist/pipeline/router.d.ts +98 -0
- package/dist/pipeline/router.d.ts.map +1 -0
- package/dist/pipeline/router.js +489 -0
- package/dist/pipeline/router.js.map +1 -0
- package/dist/platform/detect.d.ts +24 -0
- package/dist/platform/detect.d.ts.map +1 -0
- package/dist/platform/detect.js +178 -0
- package/dist/platform/detect.js.map +1 -0
- package/dist/platform/paths.d.ts +38 -0
- package/dist/platform/paths.d.ts.map +1 -0
- package/dist/platform/paths.js +129 -0
- package/dist/platform/paths.js.map +1 -0
- package/dist/platform/system.d.ts +71 -0
- package/dist/platform/system.d.ts.map +1 -0
- package/dist/platform/system.js +242 -0
- package/dist/platform/system.js.map +1 -0
- package/dist/plugin/context.d.ts +92 -0
- package/dist/plugin/context.d.ts.map +1 -0
- package/dist/plugin/context.js +573 -0
- package/dist/plugin/context.js.map +1 -0
- package/dist/plugin/define.d.ts +92 -0
- package/dist/plugin/define.d.ts.map +1 -0
- package/dist/plugin/define.js +82 -0
- package/dist/plugin/define.js.map +1 -0
- package/dist/plugin/discover.d.ts +106 -0
- package/dist/plugin/discover.d.ts.map +1 -0
- package/dist/plugin/discover.js +254 -0
- package/dist/plugin/discover.js.map +1 -0
- package/dist/plugin/events.d.ts +109 -0
- package/dist/plugin/events.d.ts.map +1 -0
- package/dist/plugin/events.js +209 -0
- package/dist/plugin/events.js.map +1 -0
- package/dist/plugin/hooks.d.ts +266 -0
- package/dist/plugin/hooks.d.ts.map +1 -0
- package/dist/plugin/hooks.js +84 -0
- package/dist/plugin/hooks.js.map +1 -0
- package/dist/plugin/host.d.ts +160 -0
- package/dist/plugin/host.d.ts.map +1 -0
- package/dist/plugin/host.js +546 -0
- package/dist/plugin/host.js.map +1 -0
- package/dist/plugin/market.d.ts +264 -0
- package/dist/plugin/market.d.ts.map +1 -0
- package/dist/plugin/market.js +700 -0
- package/dist/plugin/market.js.map +1 -0
- package/dist/plugin/services.d.ts +116 -0
- package/dist/plugin/services.d.ts.map +1 -0
- package/dist/plugin/services.js +213 -0
- package/dist/plugin/services.js.map +1 -0
- package/dist/plugin/tar.d.ts +65 -0
- package/dist/plugin/tar.d.ts.map +1 -0
- package/dist/plugin/tar.js +277 -0
- package/dist/plugin/tar.js.map +1 -0
- package/dist/render/registry.d.ts +127 -0
- package/dist/render/registry.d.ts.map +1 -0
- package/dist/render/registry.js +306 -0
- package/dist/render/registry.js.map +1 -0
- package/dist/scheduler/index.d.ts +67 -0
- package/dist/scheduler/index.d.ts.map +1 -0
- package/dist/scheduler/index.js +279 -0
- package/dist/scheduler/index.js.map +1 -0
- package/dist/server/api.d.ts +179 -0
- package/dist/server/api.d.ts.map +1 -0
- package/dist/server/api.js +602 -0
- package/dist/server/api.js.map +1 -0
- package/dist/server/auth.d.ts +83 -0
- package/dist/server/auth.d.ts.map +1 -0
- package/dist/server/auth.js +178 -0
- package/dist/server/auth.js.map +1 -0
- package/dist/server/browse.d.ts +60 -0
- package/dist/server/browse.d.ts.map +1 -0
- package/dist/server/browse.js +187 -0
- package/dist/server/browse.js.map +1 -0
- package/dist/server/files.d.ts +27 -0
- package/dist/server/files.d.ts.map +1 -0
- package/dist/server/files.js +76 -0
- package/dist/server/files.js.map +1 -0
- package/dist/server/index.d.ts +189 -0
- package/dist/server/index.d.ts.map +1 -0
- package/dist/server/index.js +1123 -0
- package/dist/server/index.js.map +1 -0
- package/dist/server/table.d.ts +105 -0
- package/dist/server/table.d.ts.map +1 -0
- package/dist/server/table.js +289 -0
- package/dist/server/table.js.map +1 -0
- package/dist/store/index.d.ts +50 -0
- package/dist/store/index.d.ts.map +1 -0
- package/dist/store/index.js +120 -0
- package/dist/store/index.js.map +1 -0
- package/dist/store/json.d.ts +73 -0
- package/dist/store/json.d.ts.map +1 -0
- package/dist/store/json.js +174 -0
- package/dist/store/json.js.map +1 -0
- package/dist/store/kv.d.ts +126 -0
- package/dist/store/kv.d.ts.map +1 -0
- package/dist/store/kv.js +232 -0
- package/dist/store/kv.js.map +1 -0
- package/dist/store/level.d.ts +103 -0
- package/dist/store/level.d.ts.map +1 -0
- package/dist/store/level.js +119 -0
- package/dist/store/level.js.map +1 -0
- package/dist/store/memory.d.ts +61 -0
- package/dist/store/memory.d.ts.map +1 -0
- package/dist/store/memory.js +94 -0
- package/dist/store/memory.js.map +1 -0
- package/dist/store/sql.d.ts +142 -0
- package/dist/store/sql.d.ts.map +1 -0
- package/dist/store/sql.js +246 -0
- package/dist/store/sql.js.map +1 -0
- package/dist/testing/fake.d.ts +109 -0
- package/dist/testing/fake.d.ts.map +1 -0
- package/dist/testing/fake.js +196 -0
- package/dist/testing/fake.js.map +1 -0
- package/dist/testing/index.d.ts +16 -0
- package/dist/testing/index.d.ts.map +1 -0
- package/dist/testing/index.js +16 -0
- package/dist/testing/index.js.map +1 -0
- package/dist/testing/mock-adapter.d.ts +205 -0
- package/dist/testing/mock-adapter.d.ts.map +1 -0
- package/dist/testing/mock-adapter.js +309 -0
- package/dist/testing/mock-adapter.js.map +1 -0
- package/dist/util/deep.d.ts +108 -0
- package/dist/util/deep.d.ts.map +1 -0
- package/dist/util/deep.js +275 -0
- package/dist/util/deep.js.map +1 -0
- package/dist/util/defer.d.ts +107 -0
- package/dist/util/defer.d.ts.map +1 -0
- package/dist/util/defer.js +163 -0
- package/dist/util/defer.js.map +1 -0
- package/dist/util/dispose.d.ts +88 -0
- package/dist/util/dispose.d.ts.map +1 -0
- package/dist/util/dispose.js +143 -0
- package/dist/util/dispose.js.map +1 -0
- package/dist/util/duration.d.ts +28 -0
- package/dist/util/duration.d.ts.map +1 -0
- package/dist/util/duration.js +77 -0
- package/dist/util/duration.js.map +1 -0
- package/dist/util/fs.d.ts +110 -0
- package/dist/util/fs.d.ts.map +1 -0
- package/dist/util/fs.js +269 -0
- package/dist/util/fs.js.map +1 -0
- package/dist/util/id.d.ts +49 -0
- package/dist/util/id.d.ts.map +1 -0
- package/dist/util/id.js +87 -0
- package/dist/util/id.js.map +1 -0
- package/dist/util/lru.d.ts +74 -0
- package/dist/util/lru.d.ts.map +1 -0
- package/dist/util/lru.js +156 -0
- package/dist/util/lru.js.map +1 -0
- package/dist/util/queue.d.ts +64 -0
- package/dist/util/queue.d.ts.map +1 -0
- package/dist/util/queue.js +147 -0
- package/dist/util/queue.js.map +1 -0
- package/dist/util/text.d.ts +86 -0
- package/dist/util/text.d.ts.map +1 -0
- package/dist/util/text.js +182 -0
- package/dist/util/text.js.map +1 -0
- package/package.json +44 -0
- package/src/adapter/accounts.ts +758 -0
- package/src/adapter/bots.ts +582 -0
- package/src/adapter/host.ts +224 -0
- package/src/adapter/login.ts +481 -0
- package/src/adapter/registry.ts +202 -0
- package/src/config/core-config.test.ts +60 -0
- package/src/config/core-config.ts +401 -0
- package/src/config/schema.test.ts +179 -0
- package/src/config/schema.ts +1036 -0
- package/src/config/store.test.ts +245 -0
- package/src/config/store.ts +506 -0
- package/src/config/yaml.ts +119 -0
- package/src/http/client.test.ts +568 -0
- package/src/http/client.ts +777 -0
- package/src/index.ts +106 -0
- package/src/kernel/app.test.ts +267 -0
- package/src/kernel/app.ts +843 -0
- package/src/kernel/kv-sink.ts +58 -0
- package/src/kernel/policy.ts +120 -0
- package/src/kernel/runtime.test.ts +542 -0
- package/src/kernel/runtime.ts +307 -0
- package/src/kernel/sql-sink.ts +154 -0
- package/src/logger/format.ts +259 -0
- package/src/logger/index.ts +427 -0
- package/src/logger/rotate.ts +265 -0
- package/src/message/segment.test.ts +335 -0
- package/src/message/segment.ts +569 -0
- package/src/message/split.ts +97 -0
- package/src/message/target.ts +48 -0
- package/src/pipeline/cooldown.ts +136 -0
- package/src/pipeline/dispatch.ts +419 -0
- package/src/pipeline/event.ts +735 -0
- package/src/pipeline/middleware.test.ts +426 -0
- package/src/pipeline/middleware.ts +199 -0
- package/src/pipeline/prompt.ts +212 -0
- package/src/pipeline/router.test.ts +432 -0
- package/src/pipeline/router.ts +575 -0
- package/src/platform/detect.ts +178 -0
- package/src/platform/paths.ts +147 -0
- package/src/platform/system.test.ts +102 -0
- package/src/platform/system.ts +289 -0
- package/src/plugin/context.test.ts +459 -0
- package/src/plugin/context.ts +757 -0
- package/src/plugin/define.test.ts +120 -0
- package/src/plugin/define.ts +142 -0
- package/src/plugin/discover.test.ts +253 -0
- package/src/plugin/discover.ts +353 -0
- package/src/plugin/events.test.ts +162 -0
- package/src/plugin/events.ts +266 -0
- package/src/plugin/hooks.ts +373 -0
- package/src/plugin/host.test.ts +696 -0
- package/src/plugin/host.ts +738 -0
- package/src/plugin/market.test.ts +781 -0
- package/src/plugin/market.ts +870 -0
- package/src/plugin/services.test.ts +135 -0
- package/src/plugin/services.ts +261 -0
- package/src/plugin/tar.test.ts +158 -0
- package/src/plugin/tar.ts +309 -0
- package/src/render/registry.test.ts +127 -0
- package/src/render/registry.ts +439 -0
- package/src/scheduler/index.ts +357 -0
- package/src/server/api.test.ts +802 -0
- package/src/server/api.ts +869 -0
- package/src/server/auth.ts +201 -0
- package/src/server/browse.test.ts +193 -0
- package/src/server/browse.ts +240 -0
- package/src/server/files.ts +97 -0
- package/src/server/index.test.ts +566 -0
- package/src/server/index.ts +1386 -0
- package/src/server/table.ts +351 -0
- package/src/store/index.ts +156 -0
- package/src/store/json.ts +198 -0
- package/src/store/kv.test.ts +307 -0
- package/src/store/kv.ts +256 -0
- package/src/store/level.ts +167 -0
- package/src/store/memory.ts +109 -0
- package/src/store/sql.test.ts +204 -0
- package/src/store/sql.ts +324 -0
- package/src/testing/fake.ts +297 -0
- package/src/testing/index.ts +15 -0
- package/src/testing/mock-adapter.ts +588 -0
- package/src/util/deep.ts +272 -0
- package/src/util/defer.ts +211 -0
- package/src/util/dispose.ts +169 -0
- package/src/util/duration.ts +82 -0
- package/src/util/fs.ts +279 -0
- package/src/util/id.ts +94 -0
- package/src/util/lru.ts +185 -0
- package/src/util/queue.ts +167 -0
- package/src/util/text.ts +189 -0
|
@@ -0,0 +1,569 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 模块职责:消息段构造器(`seg.*`)、消息内容归一化、媒体引用归一化
|
|
3
|
+
* 依赖方向:仅依赖类型包与 util/text;不依赖任何子系统
|
|
4
|
+
* 生命周期:纯函数,无状态
|
|
5
|
+
* 注意事项:**媒体不默认转成 base64。** 一张 2MB 的图片会因此多占 2.7MB 内存(膨胀 4/3)并多一次
|
|
6
|
+
* 全量 CPU 编码,而适配器与 Bot 同机部署时本可直接读盘。`toMediaRef` 保留来源种类,由
|
|
7
|
+
* 适配器按 `id > url > path > buffer > base64` 选开销最低的通道。
|
|
8
|
+
*
|
|
9
|
+
* **相邻文本段合并。** `e.reply(["体力:", 160, "/160"])` 若原样产出三段,平台侧会视为三条
|
|
10
|
+
* 内容拼接,部分平台还按段计费或限流。合并也使 `e.text` 与实际展示文本一致。
|
|
11
|
+
*
|
|
12
|
+
* **`null` / `undefined` / `false` 丢弃**,故条件拼装可直接写 `[e.isGroup && seg.at(uid), "x"]`。
|
|
13
|
+
* 注意 `0` 与 `""` 的区别:`0` 是有意义的文本,只有空串才丢。
|
|
14
|
+
*/
|
|
15
|
+
import { fileURLToPath } from "node:url"
|
|
16
|
+
import { isAbsolute } from "node:path"
|
|
17
|
+
import type {
|
|
18
|
+
AtAllSegment,
|
|
19
|
+
AtSegment,
|
|
20
|
+
ContactSegment,
|
|
21
|
+
DiceSegment,
|
|
22
|
+
FaceSegment,
|
|
23
|
+
FileSegment,
|
|
24
|
+
ForwardNode,
|
|
25
|
+
ForwardSegment,
|
|
26
|
+
ImageSegment,
|
|
27
|
+
JsonSegment,
|
|
28
|
+
KeyboardButton,
|
|
29
|
+
KeyboardSegment,
|
|
30
|
+
LocationSegment,
|
|
31
|
+
MarkdownSegment,
|
|
32
|
+
MediaInput,
|
|
33
|
+
MediaRef,
|
|
34
|
+
MessageContent,
|
|
35
|
+
MusicSegment,
|
|
36
|
+
PokeSegment,
|
|
37
|
+
RawSegment,
|
|
38
|
+
RecordSegment,
|
|
39
|
+
ReplySegment,
|
|
40
|
+
RpsSegment,
|
|
41
|
+
Segment,
|
|
42
|
+
SegmentType,
|
|
43
|
+
ShareSegment,
|
|
44
|
+
TextSegment,
|
|
45
|
+
VideoSegment,
|
|
46
|
+
XmlSegment
|
|
47
|
+
} from "@yunzai-ng/types"
|
|
48
|
+
import { truncate } from "../util/text.js"
|
|
49
|
+
|
|
50
|
+
/** `data:` URI 的头部形式 */
|
|
51
|
+
const DATA_URI_RE = /^data:([^;,]*)(;charset=[^;,]*)?;base64,(.*)$/s
|
|
52
|
+
|
|
53
|
+
/** 日志里描述消息时,文本段最多展示多少字 */
|
|
54
|
+
const DESCRIBE_TEXT_MAX = 40
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* 判断是否已经是归一化后的媒体引用
|
|
58
|
+
* @param value 任意值
|
|
59
|
+
* @returns 是否为 MediaRef
|
|
60
|
+
*/
|
|
61
|
+
function isMediaRef(value: unknown): value is MediaRef {
|
|
62
|
+
if (typeof value !== "object" || value === null) return false
|
|
63
|
+
const kind = (value as { kind?: unknown }).kind
|
|
64
|
+
return kind === "url" || kind === "path" || kind === "buffer" || kind === "base64" || kind === "id"
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* 把宽松的媒体输入归一化成 `MediaRef`
|
|
69
|
+
*
|
|
70
|
+
* 识别顺序:已归一化对象 → 字节 → URL 对象 → 字符串前缀。字符串规则:
|
|
71
|
+
* - `http://` / `https://` → `url`
|
|
72
|
+
* - `file://` → `path`(经 `fileURLToPath`,正确处理 Windows 盘符与转义)
|
|
73
|
+
* - `base64://` / `data:*;base64,` → `base64`
|
|
74
|
+
* - 绝对路径 → `path`
|
|
75
|
+
*
|
|
76
|
+
* 相对路径**不予接受**:内核无法确定其解析基准(插件根目录、工作目录,或渲染产物目录),
|
|
77
|
+
* 判定错误的表现为"图片无法发出但未产生报错"。插件应使用 `ctx.resource()` 拼接绝对路径。
|
|
78
|
+
* @param input 媒体输入
|
|
79
|
+
* @returns 归一化后的媒体引用
|
|
80
|
+
* @throws 传入相对路径或空串时
|
|
81
|
+
*/
|
|
82
|
+
export function toMediaRef(input: MediaInput): MediaRef {
|
|
83
|
+
if (isMediaRef(input)) return input
|
|
84
|
+
|
|
85
|
+
if (input instanceof Uint8Array) return { kind: "buffer", data: input }
|
|
86
|
+
if (input instanceof ArrayBuffer) return { kind: "buffer", data: new Uint8Array(input) }
|
|
87
|
+
|
|
88
|
+
if (input instanceof URL) {
|
|
89
|
+
if (input.protocol === "file:") return { kind: "path", path: fileURLToPath(input) }
|
|
90
|
+
return { kind: "url", url: input.href }
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
const text: string = input
|
|
94
|
+
if (text === "") throw new Error("媒体引用不能为空串")
|
|
95
|
+
|
|
96
|
+
if (text.startsWith("http://") || text.startsWith("https://")) return { kind: "url", url: text }
|
|
97
|
+
if (text.startsWith("file://")) return { kind: "path", path: fileURLToPath(text) }
|
|
98
|
+
if (text.startsWith("base64://")) return { kind: "base64", base64: text.slice("base64://".length) }
|
|
99
|
+
|
|
100
|
+
const dataUri = DATA_URI_RE.exec(text)
|
|
101
|
+
if (dataUri) {
|
|
102
|
+
const mime = dataUri[1]
|
|
103
|
+
return mime ? { kind: "base64", base64: dataUri[3] ?? "", mime } : { kind: "base64", base64: dataUri[3] ?? "" }
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
if (isAbsolute(text)) return { kind: "path", path: text }
|
|
107
|
+
|
|
108
|
+
throw new Error(
|
|
109
|
+
`无法识别的媒体引用 ${truncate(text, 60)}:请传入 http(s) 链接、绝对路径、Buffer 或 base64:// 开头的字符串。` +
|
|
110
|
+
`若是插件内的文件,请用 ctx.resource("...") 取绝对路径`
|
|
111
|
+
)
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* 给媒体引用生成一句简短描述(日志用,绝不打印正文)
|
|
116
|
+
*
|
|
117
|
+
* base64 与 buffer 只打长度:把 2MB 的 base64 写进日志文件既没用又会把
|
|
118
|
+
* 日志轮转打满,而 CK/token 若混在其中还会泄露。
|
|
119
|
+
* @param ref 媒体引用
|
|
120
|
+
* @returns 描述文本
|
|
121
|
+
*/
|
|
122
|
+
export function describeMedia(ref: MediaRef): string {
|
|
123
|
+
switch (ref.kind) {
|
|
124
|
+
case "url":
|
|
125
|
+
return truncate(ref.url, 60)
|
|
126
|
+
case "path":
|
|
127
|
+
return ref.path
|
|
128
|
+
case "buffer":
|
|
129
|
+
return `${ref.data.byteLength} 字节`
|
|
130
|
+
case "base64":
|
|
131
|
+
return `base64 ${ref.base64.length} 字符`
|
|
132
|
+
case "id":
|
|
133
|
+
return `id:${ref.id}`
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/**
|
|
138
|
+
* 消息段构造器
|
|
139
|
+
*
|
|
140
|
+
* 挂在导出对象上而非做成全局:全局构造器会让每个文件都成为潜在的消息构造点,
|
|
141
|
+
* 热重载时无从判断谁还在引用。
|
|
142
|
+
*/
|
|
143
|
+
export const seg = {
|
|
144
|
+
/**
|
|
145
|
+
* 纯文本
|
|
146
|
+
* @param text 文本内容(数字会被转成字符串)
|
|
147
|
+
* @returns 文本段
|
|
148
|
+
*/
|
|
149
|
+
text(text: string | number): TextSegment {
|
|
150
|
+
return { type: "text", text: String(text) }
|
|
151
|
+
},
|
|
152
|
+
|
|
153
|
+
/**
|
|
154
|
+
* 图片
|
|
155
|
+
* @param file 图片来源(链接 / 绝对路径 / Buffer / base64)
|
|
156
|
+
* @param opts 附加信息
|
|
157
|
+
* @returns 图片段
|
|
158
|
+
*/
|
|
159
|
+
image(file: MediaInput, opts: Omit<ImageSegment, "type" | "file"> = {}): ImageSegment {
|
|
160
|
+
return { type: "image", file: toMediaRef(file), ...opts }
|
|
161
|
+
},
|
|
162
|
+
|
|
163
|
+
/**
|
|
164
|
+
* @ 某人
|
|
165
|
+
* @param uid 用户 id
|
|
166
|
+
* @param name 展示名(仅回显用)
|
|
167
|
+
* @returns at 段
|
|
168
|
+
*/
|
|
169
|
+
at(uid: string, name?: string): AtSegment {
|
|
170
|
+
return name === undefined ? { type: "at", uid } : { type: "at", uid, name }
|
|
171
|
+
},
|
|
172
|
+
|
|
173
|
+
/**
|
|
174
|
+
* @ 全体成员
|
|
175
|
+
* @returns atAll 段
|
|
176
|
+
*/
|
|
177
|
+
atAll(): AtAllSegment {
|
|
178
|
+
return { type: "atAll" }
|
|
179
|
+
},
|
|
180
|
+
|
|
181
|
+
/**
|
|
182
|
+
* QQ 原生表情
|
|
183
|
+
* @param id 表情 id
|
|
184
|
+
* @param big 是否超级表情
|
|
185
|
+
* @returns 表情段
|
|
186
|
+
*/
|
|
187
|
+
face(id: number, big?: boolean): FaceSegment {
|
|
188
|
+
return big === undefined ? { type: "face", id } : { type: "face", id, big }
|
|
189
|
+
},
|
|
190
|
+
|
|
191
|
+
/**
|
|
192
|
+
* 引用回复
|
|
193
|
+
* @param messageId 被引用的消息 id
|
|
194
|
+
* @returns 回复段
|
|
195
|
+
*/
|
|
196
|
+
reply(messageId: string): ReplySegment {
|
|
197
|
+
return { type: "reply", messageId }
|
|
198
|
+
},
|
|
199
|
+
|
|
200
|
+
/**
|
|
201
|
+
* 语音
|
|
202
|
+
* @param file 音频来源
|
|
203
|
+
* @param opts 附加信息
|
|
204
|
+
* @returns 语音段
|
|
205
|
+
*/
|
|
206
|
+
record(file: MediaInput, opts: Omit<RecordSegment, "type" | "file"> = {}): RecordSegment {
|
|
207
|
+
return { type: "record", file: toMediaRef(file), ...opts }
|
|
208
|
+
},
|
|
209
|
+
|
|
210
|
+
/**
|
|
211
|
+
* 短视频
|
|
212
|
+
* @param file 视频来源
|
|
213
|
+
* @param thumb 封面
|
|
214
|
+
* @returns 视频段
|
|
215
|
+
*/
|
|
216
|
+
video(file: MediaInput, thumb?: MediaInput): VideoSegment {
|
|
217
|
+
return thumb === undefined
|
|
218
|
+
? { type: "video", file: toMediaRef(file) }
|
|
219
|
+
: { type: "video", file: toMediaRef(file), thumb: toMediaRef(thumb) }
|
|
220
|
+
},
|
|
221
|
+
|
|
222
|
+
/**
|
|
223
|
+
* 文件
|
|
224
|
+
* @param file 文件来源
|
|
225
|
+
* @param opts 附加信息
|
|
226
|
+
* @returns 文件段
|
|
227
|
+
*/
|
|
228
|
+
file(file: MediaInput, opts: Omit<FileSegment, "type" | "file"> = {}): FileSegment {
|
|
229
|
+
return { type: "file", file: toMediaRef(file), ...opts }
|
|
230
|
+
},
|
|
231
|
+
|
|
232
|
+
/**
|
|
233
|
+
* 位置
|
|
234
|
+
* @param lat 纬度
|
|
235
|
+
* @param lon 经度
|
|
236
|
+
* @param opts 标题与描述
|
|
237
|
+
* @returns 位置段
|
|
238
|
+
*/
|
|
239
|
+
location(lat: number, lon: number, opts: Omit<LocationSegment, "type" | "lat" | "lon"> = {}): LocationSegment {
|
|
240
|
+
return { type: "location", lat, lon, ...opts }
|
|
241
|
+
},
|
|
242
|
+
|
|
243
|
+
/**
|
|
244
|
+
* 链接分享
|
|
245
|
+
* @param url 目标地址
|
|
246
|
+
* @param title 标题
|
|
247
|
+
* @param opts 摘要与缩略图
|
|
248
|
+
* @returns 分享段
|
|
249
|
+
*/
|
|
250
|
+
share(url: string, title: string, opts: Omit<ShareSegment, "type" | "url" | "title"> = {}): ShareSegment {
|
|
251
|
+
return { type: "share", url, title, ...opts }
|
|
252
|
+
},
|
|
253
|
+
|
|
254
|
+
/**
|
|
255
|
+
* 推荐好友/群
|
|
256
|
+
* @param scene 对象类别
|
|
257
|
+
* @param id 对象 id
|
|
258
|
+
* @returns 推荐段
|
|
259
|
+
*/
|
|
260
|
+
contact(scene: "user" | "group", id: string): ContactSegment {
|
|
261
|
+
return { type: "contact", scene, id }
|
|
262
|
+
},
|
|
263
|
+
|
|
264
|
+
/**
|
|
265
|
+
* JSON 卡片
|
|
266
|
+
* @param data 卡片数据;传对象时自动序列化
|
|
267
|
+
* @returns JSON 段
|
|
268
|
+
*/
|
|
269
|
+
json(data: string | object): JsonSegment {
|
|
270
|
+
return { type: "json", data: typeof data === "string" ? data : JSON.stringify(data) }
|
|
271
|
+
},
|
|
272
|
+
|
|
273
|
+
/**
|
|
274
|
+
* XML 卡片
|
|
275
|
+
* @param data 卡片 XML
|
|
276
|
+
* @returns XML 段
|
|
277
|
+
*/
|
|
278
|
+
xml(data: string): XmlSegment {
|
|
279
|
+
return { type: "xml", data }
|
|
280
|
+
},
|
|
281
|
+
|
|
282
|
+
/**
|
|
283
|
+
* 戳一戳
|
|
284
|
+
* @param uid 目标用户(群聊内必填)
|
|
285
|
+
* @param pokeType 戳的类型
|
|
286
|
+
* @returns 戳一戳段
|
|
287
|
+
*/
|
|
288
|
+
poke(uid?: string, pokeType?: number): PokeSegment {
|
|
289
|
+
const out: PokeSegment = { type: "poke" }
|
|
290
|
+
if (uid !== undefined) out.uid = uid
|
|
291
|
+
if (pokeType !== undefined) out.pokeType = pokeType
|
|
292
|
+
return out
|
|
293
|
+
},
|
|
294
|
+
|
|
295
|
+
/**
|
|
296
|
+
* 骰子
|
|
297
|
+
* @param result 点数,留空由平台随机
|
|
298
|
+
* @returns 骰子段
|
|
299
|
+
*/
|
|
300
|
+
dice(result?: number): DiceSegment {
|
|
301
|
+
return result === undefined ? { type: "dice" } : { type: "dice", result }
|
|
302
|
+
},
|
|
303
|
+
|
|
304
|
+
/**
|
|
305
|
+
* 猜拳
|
|
306
|
+
* @param result 结果,留空由平台随机
|
|
307
|
+
* @returns 猜拳段
|
|
308
|
+
*/
|
|
309
|
+
rps(result?: number): RpsSegment {
|
|
310
|
+
return result === undefined ? { type: "rps" } : { type: "rps", result }
|
|
311
|
+
},
|
|
312
|
+
|
|
313
|
+
/**
|
|
314
|
+
* 平台歌曲分享
|
|
315
|
+
* @param platform 音乐平台
|
|
316
|
+
* @param id 平台歌曲 id
|
|
317
|
+
* @returns 音乐段
|
|
318
|
+
*/
|
|
319
|
+
music(platform: Exclude<MusicSegment["platform"], "custom">, id: string): MusicSegment {
|
|
320
|
+
return { type: "music", platform, id }
|
|
321
|
+
},
|
|
322
|
+
|
|
323
|
+
/**
|
|
324
|
+
* 自定义音乐卡片
|
|
325
|
+
* @param opts 卡片字段
|
|
326
|
+
* @returns 音乐段
|
|
327
|
+
*/
|
|
328
|
+
musicCustom(opts: Omit<MusicSegment, "type" | "platform" | "id">): MusicSegment {
|
|
329
|
+
return { type: "music", platform: "custom", ...opts }
|
|
330
|
+
},
|
|
331
|
+
|
|
332
|
+
/**
|
|
333
|
+
* 合并转发
|
|
334
|
+
* @param nodes 转发节点
|
|
335
|
+
* @param opts 外显字段
|
|
336
|
+
* @returns 合并转发段
|
|
337
|
+
*/
|
|
338
|
+
forward(nodes: ForwardNode[], opts: Omit<ForwardSegment, "type" | "nodes" | "id"> = {}): ForwardSegment {
|
|
339
|
+
return { type: "forward", nodes, ...opts }
|
|
340
|
+
},
|
|
341
|
+
|
|
342
|
+
/**
|
|
343
|
+
* 合并转发中的一条
|
|
344
|
+
* @param message 该条内容(支持宽松形式)
|
|
345
|
+
* @param opts 显示用的发送者信息
|
|
346
|
+
* @returns 转发节点
|
|
347
|
+
*/
|
|
348
|
+
node(message: MessageContent, opts: Omit<ForwardNode, "message" | "messageId"> = {}): ForwardNode {
|
|
349
|
+
return { message: toSegments(message), ...opts }
|
|
350
|
+
},
|
|
351
|
+
|
|
352
|
+
/**
|
|
353
|
+
* Markdown
|
|
354
|
+
* @param content 正文
|
|
355
|
+
* @returns Markdown 段
|
|
356
|
+
*/
|
|
357
|
+
markdown(content: string): MarkdownSegment {
|
|
358
|
+
return { type: "markdown", content }
|
|
359
|
+
},
|
|
360
|
+
|
|
361
|
+
/**
|
|
362
|
+
* 按钮键盘
|
|
363
|
+
* @param rows 按行组织的按钮
|
|
364
|
+
* @returns 键盘段
|
|
365
|
+
*/
|
|
366
|
+
keyboard(rows: KeyboardButton[][]): KeyboardSegment {
|
|
367
|
+
return { type: "keyboard", rows }
|
|
368
|
+
},
|
|
369
|
+
|
|
370
|
+
/**
|
|
371
|
+
* 平台私有段(兼容出口)
|
|
372
|
+
* @param platform 来源平台
|
|
373
|
+
* @param platformType 平台侧段类型名
|
|
374
|
+
* @param data 原始数据
|
|
375
|
+
* @returns raw 段
|
|
376
|
+
*/
|
|
377
|
+
raw(platform: string, platformType: string, data: unknown): RawSegment {
|
|
378
|
+
return { type: "raw", platform, platformType, data }
|
|
379
|
+
}
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
/**
|
|
383
|
+
* 判断一个值是否为消息段
|
|
384
|
+
* @param value 任意值
|
|
385
|
+
* @returns 是否为 Segment
|
|
386
|
+
*/
|
|
387
|
+
export function isSegment(value: unknown): value is Segment {
|
|
388
|
+
return typeof value === "object" && value !== null && typeof (value as { type?: unknown }).type === "string"
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
/**
|
|
392
|
+
* 把宽松的消息内容归一化成消息段数组
|
|
393
|
+
*
|
|
394
|
+
* 展平任意层嵌套数组、把裸字符串/数字转成文本段、丢掉 `null`/`undefined`/`false`
|
|
395
|
+
* 与空文本,并合并相邻文本段(见文件头第 2、3 条)。
|
|
396
|
+
* @param content 消息内容
|
|
397
|
+
* @returns 消息段数组;内容为空时返回空数组
|
|
398
|
+
*/
|
|
399
|
+
export function toSegments(content: MessageContent): Segment[] {
|
|
400
|
+
const out: Segment[] = []
|
|
401
|
+
collect(content, out)
|
|
402
|
+
return out
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
/**
|
|
406
|
+
* 递归展平并写入结果数组
|
|
407
|
+
* @param content 消息内容
|
|
408
|
+
* @param out 结果数组(就地追加)
|
|
409
|
+
*/
|
|
410
|
+
function collect(content: MessageContent, out: Segment[]): void {
|
|
411
|
+
if (content === null || content === undefined || content === false) return
|
|
412
|
+
|
|
413
|
+
if (Array.isArray(content)) {
|
|
414
|
+
for (const item of content) collect(item, out)
|
|
415
|
+
return
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
if (typeof content === "string" || typeof content === "number") {
|
|
419
|
+
pushText(out, String(content))
|
|
420
|
+
return
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
if (isSegment(content)) {
|
|
424
|
+
if (content.type === "text") pushText(out, content.text)
|
|
425
|
+
else out.push(content)
|
|
426
|
+
return
|
|
427
|
+
}
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
/**
|
|
431
|
+
* 追加文本,与前一个文本段合并
|
|
432
|
+
* @param out 结果数组
|
|
433
|
+
* @param text 文本
|
|
434
|
+
*/
|
|
435
|
+
function pushText(out: Segment[], text: string): void {
|
|
436
|
+
if (text === "") return
|
|
437
|
+
const last = out[out.length - 1]
|
|
438
|
+
if (last && last.type === "text") last.text += text
|
|
439
|
+
else out.push({ type: "text", text })
|
|
440
|
+
}
|
|
441
|
+
|
|
442
|
+
/**
|
|
443
|
+
* 取消息的纯文本视图
|
|
444
|
+
* @param segments 消息段
|
|
445
|
+
* @returns 拼接后 trim 的文本;无文本段时为空串
|
|
446
|
+
*/
|
|
447
|
+
export function textOf(segments: readonly Segment[]): string {
|
|
448
|
+
let out = ""
|
|
449
|
+
for (const s of segments) {
|
|
450
|
+
if (s.type === "text") out += s.text
|
|
451
|
+
}
|
|
452
|
+
return out.trim()
|
|
453
|
+
}
|
|
454
|
+
|
|
455
|
+
/**
|
|
456
|
+
* 取消息里的全部图片段
|
|
457
|
+
* @param segments 消息段
|
|
458
|
+
* @returns 图片段数组
|
|
459
|
+
*/
|
|
460
|
+
export function imagesOf(segments: readonly Segment[]): ImageSegment[] {
|
|
461
|
+
return segments.filter((s): s is ImageSegment => s.type === "image")
|
|
462
|
+
}
|
|
463
|
+
|
|
464
|
+
/**
|
|
465
|
+
* 取消息里被 @ 的用户 id
|
|
466
|
+
* @param segments 消息段
|
|
467
|
+
* @returns 用户 id 数组,已去重且保持出现顺序
|
|
468
|
+
*/
|
|
469
|
+
export function atUsersOf(segments: readonly Segment[]): string[] {
|
|
470
|
+
const seen = new Set<string>()
|
|
471
|
+
for (const s of segments) {
|
|
472
|
+
if (s.type === "at" && s.uid !== "") seen.add(s.uid)
|
|
473
|
+
}
|
|
474
|
+
return [...seen]
|
|
475
|
+
}
|
|
476
|
+
|
|
477
|
+
/**
|
|
478
|
+
* 判断消息里是否有 @全体成员
|
|
479
|
+
* @param segments 消息段
|
|
480
|
+
* @returns 是否含 atAll
|
|
481
|
+
*/
|
|
482
|
+
export function hasAtAll(segments: readonly Segment[]): boolean {
|
|
483
|
+
return segments.some(s => s.type === "atAll")
|
|
484
|
+
}
|
|
485
|
+
|
|
486
|
+
/**
|
|
487
|
+
* 取消息里第一个 reply 段指向的消息 id
|
|
488
|
+
* @param segments 消息段
|
|
489
|
+
* @returns 消息 id;没有 reply 段时 undefined
|
|
490
|
+
*/
|
|
491
|
+
export function quotedIdOf(segments: readonly Segment[]): string | undefined {
|
|
492
|
+
for (const s of segments) {
|
|
493
|
+
if (s.type === "reply") return s.messageId
|
|
494
|
+
}
|
|
495
|
+
return undefined
|
|
496
|
+
}
|
|
497
|
+
|
|
498
|
+
/**
|
|
499
|
+
* 统计各类型段的数量
|
|
500
|
+
* @param segments 消息段
|
|
501
|
+
* @returns 类型到数量的映射
|
|
502
|
+
*/
|
|
503
|
+
export function countByType(segments: readonly Segment[]): Partial<Record<SegmentType, number>> {
|
|
504
|
+
const out: Partial<Record<SegmentType, number>> = {}
|
|
505
|
+
for (const s of segments) out[s.type] = (out[s.type] ?? 0) + 1
|
|
506
|
+
return out
|
|
507
|
+
}
|
|
508
|
+
|
|
509
|
+
/**
|
|
510
|
+
* 将消息渲染成一行日志文本
|
|
511
|
+
*
|
|
512
|
+
* 媒体只出摘要不出正文,见 `describeMedia`。这是日志中"收到消息"一行的来源,
|
|
513
|
+
* 也是排障时查阅频率最高的内容,因此格式刻意贴近使用者在聊天窗口中看到的形态。
|
|
514
|
+
* @param segments 消息段
|
|
515
|
+
* @returns 单行描述
|
|
516
|
+
*/
|
|
517
|
+
export function describeMessage(segments: readonly Segment[]): string {
|
|
518
|
+
const parts: string[] = []
|
|
519
|
+
for (const s of segments) {
|
|
520
|
+
switch (s.type) {
|
|
521
|
+
case "text":
|
|
522
|
+
parts.push(truncate(s.text.replace(/\s+/g, " "), DESCRIBE_TEXT_MAX))
|
|
523
|
+
break
|
|
524
|
+
case "image":
|
|
525
|
+
parts.push(`[图片 ${describeMedia(s.file)}]`)
|
|
526
|
+
break
|
|
527
|
+
case "at":
|
|
528
|
+
parts.push(`[@${s.name ?? s.uid}]`)
|
|
529
|
+
break
|
|
530
|
+
case "atAll":
|
|
531
|
+
parts.push("[@全体]")
|
|
532
|
+
break
|
|
533
|
+
case "face":
|
|
534
|
+
parts.push(`[表情${s.id}]`)
|
|
535
|
+
break
|
|
536
|
+
case "reply":
|
|
537
|
+
parts.push(`[回复${s.messageId}]`)
|
|
538
|
+
break
|
|
539
|
+
case "record":
|
|
540
|
+
parts.push("[语音]")
|
|
541
|
+
break
|
|
542
|
+
case "video":
|
|
543
|
+
parts.push("[视频]")
|
|
544
|
+
break
|
|
545
|
+
case "file":
|
|
546
|
+
parts.push(`[文件 ${s.name ?? ""}]`)
|
|
547
|
+
break
|
|
548
|
+
case "forward":
|
|
549
|
+
parts.push(`[合并转发 ${s.nodes?.length ?? s.id ?? ""}]`)
|
|
550
|
+
break
|
|
551
|
+
case "json":
|
|
552
|
+
case "xml":
|
|
553
|
+
parts.push("[卡片]")
|
|
554
|
+
break
|
|
555
|
+
case "markdown":
|
|
556
|
+
parts.push("[Markdown]")
|
|
557
|
+
break
|
|
558
|
+
case "keyboard":
|
|
559
|
+
parts.push("[按钮]")
|
|
560
|
+
break
|
|
561
|
+
case "raw":
|
|
562
|
+
parts.push(`[${s.platform}:${s.platformType}]`)
|
|
563
|
+
break
|
|
564
|
+
default:
|
|
565
|
+
parts.push(`[${s.type}]`)
|
|
566
|
+
}
|
|
567
|
+
}
|
|
568
|
+
return parts.join("")
|
|
569
|
+
}
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 模块职责:超长消息切分
|
|
3
|
+
* 依赖方向:依赖类型包与 util/text
|
|
4
|
+
* 生命周期:纯函数
|
|
5
|
+
* 注意事项:平台对单条消息长度设有硬性限制(QQ 约 4500 字,超出后整条无法发出,
|
|
6
|
+
* 而非被截断),故切分由内核统一承担,不交由插件各自实现。
|
|
7
|
+
*
|
|
8
|
+
* 本模块仅按**文本长度**切分。图片、语音、卡片一律原样传递,因
|
|
9
|
+
* "单条消息最多包含几张图"属平台差异,应由适配器判定,内核不作推断。
|
|
10
|
+
*
|
|
11
|
+
* 切分点优先选取换行,其次空白,最后为强制切分(`splitText` 的策略)——
|
|
12
|
+
* 在句中强制切分会使长文本回复的可读性显著下降。
|
|
13
|
+
*/
|
|
14
|
+
import type { Segment } from "@yunzai-ng/types"
|
|
15
|
+
import { splitText } from "../util/text.js"
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* 统计消息里的文本总长度(按码点)
|
|
19
|
+
*
|
|
20
|
+
* 只算文本段:`[图片]` 这类占位在不同平台上的实际开销天差地别,
|
|
21
|
+
* 用一个假定的权重去估反而会在某个平台上恰好算错。
|
|
22
|
+
* @param segments 消息段
|
|
23
|
+
* @returns 文本总长度
|
|
24
|
+
*/
|
|
25
|
+
export function textLength(segments: readonly Segment[]): number {
|
|
26
|
+
let n = 0
|
|
27
|
+
for (const s of segments) {
|
|
28
|
+
if (s.type === "text") n += [...s.text].length
|
|
29
|
+
}
|
|
30
|
+
return n
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* 把一条消息按文本长度切成多条
|
|
35
|
+
*
|
|
36
|
+
* 不需要切时**原样返回同一个数组**(不复制):这是绝大多数消息走的路径,
|
|
37
|
+
* 一次多余的数组分配乘以每条回复也是可观的。
|
|
38
|
+
* @param segments 消息段
|
|
39
|
+
* @param maxLength 单条最大文本长度;`<= 0` 表示不切
|
|
40
|
+
* @returns 切分后的消息数组,至少一项
|
|
41
|
+
*/
|
|
42
|
+
export function splitMessage(segments: Segment[], maxLength: number): Segment[][] {
|
|
43
|
+
if (maxLength <= 0 || textLength(segments) <= maxLength) return [segments]
|
|
44
|
+
|
|
45
|
+
const out: Segment[][] = []
|
|
46
|
+
let current: Segment[] = []
|
|
47
|
+
let used = 0
|
|
48
|
+
|
|
49
|
+
/** 收束当前一条,开始下一条 */
|
|
50
|
+
const flush = (): void => {
|
|
51
|
+
if (current.length > 0) out.push(current)
|
|
52
|
+
current = []
|
|
53
|
+
used = 0
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
for (const s of segments) {
|
|
57
|
+
if (s.type !== "text") {
|
|
58
|
+
// 非文本段不计长度,但要跟在它前面的文本同一条里 —— 图文顺序不能乱
|
|
59
|
+
current.push(s)
|
|
60
|
+
continue
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
let rest = s.text
|
|
64
|
+
while (rest !== "") {
|
|
65
|
+
const room = maxLength - used
|
|
66
|
+
if (room <= 0) {
|
|
67
|
+
flush()
|
|
68
|
+
continue
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
const pieces = splitText(rest, room)
|
|
72
|
+
const head = pieces[0]
|
|
73
|
+
// rest 只剩空白时 splitText 返回空数组;这段没有信息量,丢掉
|
|
74
|
+
if (head === undefined) break
|
|
75
|
+
if (head === "") {
|
|
76
|
+
// 理论上不会发生(splitText 的硬切兜底保证非空)。留这一手是因为
|
|
77
|
+
// 一旦发生就是死循环,而死循环比多发一条消息严重得多。
|
|
78
|
+
rest = rest.slice(1)
|
|
79
|
+
continue
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
current.push({ type: "text", text: head })
|
|
83
|
+
used += [...head].length
|
|
84
|
+
if (pieces.length === 1) break
|
|
85
|
+
|
|
86
|
+
// head 可能被 trimEnd 过,因此按长度回切后再抹掉断点处的空白,
|
|
87
|
+
// 否则下一条会以空格或换行开头
|
|
88
|
+
rest = rest.slice(head.length).replace(/^\s+/, "")
|
|
89
|
+
flush()
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
flush()
|
|
94
|
+
// 全是空白文本的极端输入会把所有内容都丢掉,此时退回原样:
|
|
95
|
+
// 宁可让平台报一次长度错,也不要静默发出一条空消息
|
|
96
|
+
return out.length > 0 ? out : [segments]
|
|
97
|
+
}
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 模块职责:发送目标的键与可读描述
|
|
3
|
+
* 依赖方向:仅依赖类型包
|
|
4
|
+
* 生命周期:纯函数
|
|
5
|
+
* 注意事项:`targetKey` 有两个消费者 —— 同会话串行发送的队列键(adapter/bots)与
|
|
6
|
+
* `e.prompt()` 的会话键(pipeline/prompt)。两者对"什么算同一个会话"
|
|
7
|
+
* 必须有完全一致的理解,否则会出现"用户在 A 会话回答、B 会话的等待者被唤醒"
|
|
8
|
+
* 这类只在多账号 + 频道场景下才复现的问题。放在一处就没有走样的机会。
|
|
9
|
+
*
|
|
10
|
+
* 键里**不含**平台与 selfId:那是调用方的作用域问题(Bot 的发送队列天然
|
|
11
|
+
* 按账号隔离,而 prompt 需要自己加前缀)。这里只回答"哪个会话"。
|
|
12
|
+
*/
|
|
13
|
+
import type { SendTarget } from "@yunzai-ng/types"
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* 算出发送目标的会话键
|
|
17
|
+
*
|
|
18
|
+
* 私聊**刻意忽略** `gid`:群临时会话与直接私聊是同一个人的同一个聊天窗口,
|
|
19
|
+
* 分成两个键会让"从群里发起的对话"与"私聊里的对话"各排一条队。
|
|
20
|
+
* @param target 发送目标
|
|
21
|
+
* @returns 会话键
|
|
22
|
+
*/
|
|
23
|
+
export function targetKey(target: SendTarget): string {
|
|
24
|
+
switch (target.scene) {
|
|
25
|
+
case "group":
|
|
26
|
+
return `g:${target.gid}`
|
|
27
|
+
case "guild":
|
|
28
|
+
return `c:${target.guildId}/${target.channelId}`
|
|
29
|
+
case "private":
|
|
30
|
+
return `p:${target.uid}`
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* 将发送目标渲染为一段可读描述
|
|
36
|
+
* @param target 发送目标
|
|
37
|
+
* @returns 描述文本,如 `群 123456`
|
|
38
|
+
*/
|
|
39
|
+
export function describeTarget(target: SendTarget): string {
|
|
40
|
+
switch (target.scene) {
|
|
41
|
+
case "group":
|
|
42
|
+
return `群 ${target.gid}`
|
|
43
|
+
case "guild":
|
|
44
|
+
return `频道 ${target.guildId}/${target.channelId}`
|
|
45
|
+
case "private":
|
|
46
|
+
return target.gid === undefined ? `私聊 ${target.uid}` : `临时会话 ${target.uid}(来自群 ${target.gid})`
|
|
47
|
+
}
|
|
48
|
+
}
|