nagisa-types 0.12.0

Nagisa unified chat-bot domain model (protocol-agnostic types)
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
//! 统一消息段 [`Segment`] 及其附属类型(图片子类型、合并转发、音乐分享、媒体下载提示等)。
//! 业务收发用同一套 [`Segment`],wire 级的入/出差异由适配器私有承担;协议私有/未知段一律落到
//! [`Segment::Raw`] 逃生口,绝不丢弃。[`Segment`] 上的关联函数(`text` / `at` / `image_url` …)
//! 是常用段的便捷构造器。
use crate::capability::Protocol;
use crate::id::{MessageId, Uin};
use crate::resource::{Media, ResourceSource};
use serde_json::{Map, Value};

#[derive(Clone, Copy, PartialEq, Eq, Debug)]
pub enum ImageSubType {
    Normal,
    /// 大表情/原创表情(OneBot `subType` 非 0)。
    Sticker,
    /// 闪照(阅后即焚):OneBot `data.type = "flash"` / Mirai `FlashImage`。内容过滤、
    /// 反撤回类插件需按此分支。
    Flash,
}

/// `Contact` 段的推荐目标类型。
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
pub enum ContactKind {
    Friend,
    Group,
}

/// 音乐分享:平台预设(qq/163/kugou/migu/kuwo…)或自定义卡片。
#[derive(Clone, Debug)]
pub enum MusicShare {
    /// 平台预设:`ty` 为平台标识(qq/163/kugou/migu/kuwo),`id` 为歌曲 id。
    Platform { ty: String, id: String },
    /// 自定义分享卡片。
    Custom { url: String, audio: String, title: String, content: Option<String>, image: Option<String> },
}

/// 合并转发:接收为引用 + 预览元信息;发送为内联节点。
#[derive(Clone, Debug)]
pub enum Forward {
    Ref {
        id: String,
        title: Option<String>,
        preview: Vec<String>,
        summary: Option<String>,
    },
    Nodes {
        nodes: Vec<ForwardNode>,
        title: Option<String>,
        summary: Option<String>,
        prompt: Option<String>,
        /// 自定义合并转发卡片的预览行(gocq/NapCat `news`,形如 `[{text}]`)。
        /// 空 → 不写出该字段。
        news: Vec<String>,
        /// 自定义合并转发卡片来源标题(gocq/NapCat `source`)。缺省 → None。
        source: Option<String>,
    },
}

/// 媒体段发送侧下载行为提示(OneBot image/record/video 的 `cache`/`proxy`/`timeout`)。
/// 仅发送侧有意义;接收侧恒为默认。Milky 无对应字段(降级忽略)。
/// OFFICIAL: https://github.com/botuniverse/onebot-11/blob/master/message/segment.md (§图片/§语音/§短视频 cache/proxy/timeout)
#[derive(Clone, Debug, Default, PartialEq, Eq)]
pub struct MediaSendHints {
    /// 是否使用已缓存的文件(`false` → 不使用缓存)。
    pub cache: Option<bool>,
    /// 是否通过代理下载远程文件。
    pub proxy: Option<bool>,
    /// 远程文件下载超时秒数。
    pub timeout: Option<i32>,
}

/// 合并转发的单个节点(发送者 + 内容)。
///
/// **跨协议非对称(固有限制)**:[`user`](Self::user) 在 **Milky** 后端解码时恒为
/// `Uin(0)` 哨兵——Milky 的 `IncomingForwardedMessage` 只带 `sender_name`,wire 上
/// 没有发送者 `user_id`,故无法填充真实 uin(见 `nagisa-milky/src/decode.rs`
/// `forward_node_from_value`)。OneBot 的 `node` 段带 `user_id`,能正确填充。下游若在
/// Milky 后端按 `user` 寻址转发节点的发送者,须以 `name`(sender_name)为准——这是
/// Milky IR 的天然缺口,非解码缺陷。
#[derive(Clone, Debug)]
pub struct ForwardNode {
    /// 节点发送者 QQ 号。Milky 后端无此 wire 字段 → 恒为 `Uin(0)`(详见结构体级文档)。
    pub user: Uin,
    pub name: String,
    pub content: Vec<Segment>,
    /// 合并转发节点气泡时间戳(部分实现收发;可缺省)。
    pub time: Option<i64>,
}

/// 统一消息段。`#[non_exhaustive]`:加段不破坏下游。
#[derive(Clone, Debug)]
#[non_exhaustive]
pub enum Segment {
    Text(String),
    Mention {
        user: Uin,
        name: Option<String>,
    },
    MentionAll,
    /// QQ 表情段。`id`/`large` 为 OneBot v11 标准字段。
    /// `result_id`/`chain_count` 为 NapCat「超级表情」(super-face) 扩展(连发动画表情,
    /// 仅 NapCat 收发;其余厂商缺省 → None)。`sub_type` 为 LLOneBot 的 `FaceType`
    /// (表情子类型,如 0=普通 / 1=超级 / 2=原创,仅 LLOneBot 透传 → 其余 None)。
    /// 来源已核对(2026-06-04):NapCat `OB11MessageFace` data `resultId`(string)/`chainCount`(number);
    /// LLOneBot `OB11MessageFace` data `sub_type`(number, FaceType)。
    /// ENDPOINT: NapNeko/NapCatQQ packages/napcat-onebot/types/message.ts (OB11MessageFaceSchema)
    ///   + LLOneBot/LLOneBot src/onebot11/types.ts (OB11MessageFace)。
    Face {
        id: String,
        large: bool,
        result_id: Option<String>,
        chain_count: Option<i32>,
        sub_type: Option<i32>,
    },
    Reply {
        id: MessageId,
        sender: Option<Uin>,
        time: Option<i64>,
        quoted: Vec<Segment>,
    },
    Image {
        res: Media,
        sub_type: ImageSubType,
        hints: MediaSendHints,
    },
    Record {
        res: Media,
        magic: Option<i32>,
        hints: MediaSendHints,
    },
    /// 视频段。`thumb`(封面缩略图)为**跨协议非对称(固有限制)**字段:**标准 OneBot v11
    /// 的 video 段没有 thumb wire 字段**,故纯 v11 端 encode 时必然丢弃 `thumb`;nagisa 仍会
    /// 在 encode 时写出 `thumb` 键(LLOneBot/go-cqhttp 扩展接受它,标准端忽略多发键,无害——
    /// 见 `nagisa-onebot/src/encode.rs`),但**标准 v11 解码侧 `thumb` 恒为 `None`**。Milky 有
    /// 对称的 `thumb_uri` wire 字段,能完整收发。即:`thumb` 在标准 OneBot v11 下是只写不读
    /// 的有损字段,这是 v11 wire 的天然缺口,非编解码缺陷。
    Video {
        res: Media,
        hints: MediaSendHints,
        thumb: Option<ResourceSource>,
    },
    File {
        id: String,
        name: String,
        size: u64,
        hash: Option<String>,
        url: Option<String>,
    },
    Forward(Forward),
    MarketFace {
        package_id: i32,
        emoji_id: String,
        key: String,
        summary: Option<String>,
        url: Option<String>,
    },
    LightApp {
        app_name: Option<String>,
        payload: String,
    },
    /// XML 卡片(OneBot `xml` / Milky 入站 `xml`)。与 `LightApp` 对称,避免 json 有
    /// 类型而 xml 退化成 `Raw` 的不对称。
    Xml {
        service_id: Option<i32>,
        payload: String,
    },
    /// 戳一戳消息段(OneBot)。注意区别于 `send_nudge` 动作与 nudge 通知。
    Poke {
        kind: i32,
        id: i32,
        strength: Option<i32>,
        name: Option<String>,
    },
    /// 推荐好友/群名片。
    Contact {
        kind: ContactKind,
        id: Uin,
    },
    /// 位置分享。
    Location {
        lat: f64,
        lon: f64,
        title: Option<String>,
        content: Option<String>,
    },
    /// 音乐分享(发送向;接收通常表现为 `LightApp`)。
    Music(MusicShare),
    /// 链接分享卡片(OneBot `share`)。`url`/`title` 必填,`content`/`image` 可选。
    /// 与已建模的 LightApp/Xml/Music 卡片并列;Milky 发送侧无对应段(降级跳过)。
    Share {
        url: String,
        title: String,
        content: Option<String>,
        image: Option<String>,
    },
    /// 猜拳魔法表情(OneBot `rps`,收发皆有)。标准 v11 为空 data;NapCat 额外带
    /// `result`(1=布 / 2=剪刀 / 3=石头,随机数已定)→ `result` 保留(缺省 → None)。
    /// ENDPOINT: NapNeko/NapCatQQ packages/napcat-onebot/types/message.ts (OB11MessageRPS data `result`)
    Rps {
        result: Option<i32>,
    },
    /// 掷骰子魔法表情(OneBot `dice`,收发皆有)。标准 v11 为空 data;NapCat 额外带
    /// `result`(1–6 点数)→ `result` 保留(缺省 → None)。
    /// ENDPOINT: NapNeko/NapCatQQ packages/napcat-onebot/types/message.ts (OB11MessageDice data `result`)
    Dice {
        result: Option<i32>,
    },
    /// 窗口抖动 / 戳一戳快捷(OneBot `shake`,空 data,仅发送)。
    Shake,
    /// 匿名发送(OneBot `anonymous`,仅发送)。`ignore=Some(true)` 表示无法匿名时
    /// 继续以普通身份发送;`None` = 不带该字段。
    Anonymous {
        ignore: Option<bool>,
    },
    /// QQ-Bot 内联键盘(Lagrange `keyboard` 段)。`content` 为 `KeyboardData` JSON
    /// 对象(按钮行/列)。Milky 无对应段(出站降级跳过)。
    /// 来源已核对(2026-06-04):type=`"keyboard"`、data 字段 `content`(KeyboardData JSON)。
    /// ENDPOINT: LagrangeDev/Lagrange.Core Lagrange.OneBot/Message/Entity/KeyboardSegment.cs
    Keyboard {
        content: Value,
    },
    /// Markdown 卡片(Lagrange `markdown` 段)。`content` 为 Markdown 文本字符串。
    /// Milky 无对应段(出站降级跳过)。
    /// 来源已核对(2026-06-04):type=`"markdown"`、data 字段 `content`(string)。
    /// ENDPOINT: LagrangeDev/Lagrange.Core Lagrange.OneBot/Message/Entity/MarkdownSegment.cs
    Markdown {
        content: String,
    },
    /// 长消息引用(Lagrange `longmsg` 段,主要入站)。`id` 为长消息 res_id。
    /// Milky 无对应段(出站降级跳过)。
    /// 来源已核对(2026-06-04):type=`"longmsg"`、data 字段 `id`(string,**非** `res_id`)。
    /// ENDPOINT: LagrangeDev/Lagrange.Core Lagrange.OneBot/Message/Entity/LongMsgSegment.cs
    LongMsg {
        id: String,
    },
    /// QQ 闪传卡片(LLOneBot 私有 `flash_file` 段)。入站由 LLOneBot 解析「闪传」
    /// markdown 卡片得到;出站可据此重建段。`title` 在 wire 上偶有缺省(标题属性可能
    /// 取不到),故为 `Option`(缺失 → None,保持 decode 无误)。
    /// 来源已核对(2026-06-04):type=`"flash_file"`、data 字段
    /// `title`(string,可缺)/`file_set_id`(string)/`scene_type`(number)。
    /// ENDPOINT: LLOneBot/LLOneBot src/onebot11/types.ts (OB11MessageFlashFile)
    ///   + src/onebot11/transform/message/incoming.ts。
    FlashFile {
        title: Option<String>,
        file_set_id: String,
        scene_type: i32,
    },
    /// 小程序卡片(NapCat 私有 `miniapp` 段)。`payload` 为小程序的 JSON 字符串
    /// (NapCat data 字段 `data`,原样透传,便于手工构建/转发)。与 LightApp/Xml 并列。
    /// 来源已核对(2026-06-04):type=`"miniapp"`、data 字段 `data`(string,小程序 JSON)。
    /// ENDPOINT: NapNeko/NapCatQQ packages/napcat-onebot/types/message.ts (OB11MessageMiniAppSchema)
    MiniApp {
        payload: String,
    },
    /// 在线文件/文件夹卡片(NapCat 私有 `onlinefile` 段)。
    /// 来源已核对(2026-06-04):type=`"onlinefile"`、data 字段
    /// `msgId`(string)/`elementId`(string)/`fileName`(string)/`fileSize`(string)/`isDir`(bool)。
    /// ENDPOINT: NapNeko/NapCatQQ packages/napcat-onebot/types/message.ts (OB11MessageOnlineFileSchema)
    OnlineFile {
        msg_id: String,
        element_id: String,
        file_name: String,
        file_size: String,
        is_dir: bool,
    },
    /// QQ 闪传卡片(NapCat 私有 `flashtransfer` 段)。`file_set_id` 为闪传文件集 id。
    /// 与 LLOneBot 的 `FlashFile` 同属闪传但 wire 名/字段不同,故各自建模。
    /// 来源已核对(2026-06-04):type=`"flashtransfer"`、data 字段 `fileSetId`(string)。
    /// ENDPOINT: NapNeko/NapCatQQ packages/napcat-onebot/types/message.ts (OB11MessageFlashTransferSchema)
    FlashTransfer {
        file_set_id: String,
    },
    /// 逃生口:协议私有/未知段。adapter 的 decode 必须把未知段塞进来,绝不丢弃。
    Raw {
        protocol: Protocol,
        kind: String,
        data: Map<String, Value>,
    },
}

impl Segment {
    pub fn text(s: impl Into<String>) -> Self {
        Segment::Text(s.into())
    }
    pub fn at(user: impl Into<Uin>) -> Self {
        Segment::Mention { user: user.into(), name: None }
    }
    pub fn at_all() -> Self {
        Segment::MentionAll
    }
    pub fn face(id: impl Into<String>) -> Self {
        Segment::Face { id: id.into(), large: false, result_id: None, chain_count: None, sub_type: None }
    }
    pub fn image_bytes(b: impl Into<bytes::Bytes>) -> Self {
        Segment::Image {
            res: Media::from_source(ResourceSource::bytes(b)),
            sub_type: ImageSubType::Normal,
            hints: MediaSendHints::default(),
        }
    }
    pub fn image_url(u: impl Into<String>) -> Self {
        Segment::Image {
            res: Media::from_source(ResourceSource::url(u)),
            sub_type: ImageSubType::Normal,
            hints: MediaSendHints::default(),
        }
    }
    /// 本地文件图片([`ResourceSource::Path`],发送时由 adapter 读取/编码)。
    /// 与 [`image_bytes`](Self::image_bytes)/[`image_url`](Self::image_url) 三态对齐。
    pub fn image_path(p: impl Into<std::path::PathBuf>) -> Self {
        Segment::Image {
            res: Media::from_source(ResourceSource::path(p)),
            sub_type: ImageSubType::Normal,
            hints: MediaSendHints::default(),
        }
    }
    pub fn reply(id: MessageId) -> Self {
        Segment::Reply { id, sender: None, time: None, quoted: Vec::new() }
    }
    pub fn xml(payload: impl Into<String>) -> Self {
        Segment::Xml { service_id: None, payload: payload.into() }
    }
    pub fn poke(kind: i32, id: i32) -> Self {
        Segment::Poke { kind, id, strength: None, name: None }
    }
    pub fn location(lat: f64, lon: f64) -> Self {
        Segment::Location { lat, lon, title: None, content: None }
    }
    pub fn music(platform: impl Into<String>, id: impl Into<String>) -> Self {
        Segment::Music(MusicShare::Platform { ty: platform.into(), id: id.into() })
    }
    pub fn share(url: impl Into<String>, title: impl Into<String>) -> Self {
        Segment::Share { url: url.into(), title: title.into(), content: None, image: None }
    }
    pub fn anonymous(ignore: bool) -> Self {
        Segment::Anonymous { ignore: Some(ignore) }
    }
    /// 带 summary(替代文本)的图片。Milky 出站 image.summary / OneBot image summary。
    pub fn image_url_with_summary(u: impl Into<String>, summary: impl Into<String>) -> Self {
        let mut res = Media::from_source(ResourceSource::url(u));
        res.summary = Some(summary.into());
        Segment::Image { res, sub_type: ImageSubType::Normal, hints: MediaSendHints::default() }
    }
    /// 视频 URL 段(无缩略图)。
    pub fn video_url(u: impl Into<String>) -> Self {
        Segment::Video {
            res: Media::from_source(ResourceSource::url(u)),
            hints: MediaSendHints::default(),
            thumb: None,
        }
    }
    /// 若是文本段返回其内容。
    pub fn as_text(&self) -> Option<&str> {
        match self {
            Segment::Text(t) => Some(t),
            _ => None,
        }
    }
    /// 合并转发段的便捷入口:`Segment::forward(nodes)` ≡
    /// `Segment::Forward(Forward::nodes(nodes))`。需要标题/卡片预览时用
    /// [`Forward::nodes`] + 链式 setter,再自行 `Segment::Forward(..)`。
    pub fn forward(nodes: Vec<ForwardNode>) -> Self {
        Segment::Forward(Forward::nodes(nodes))
    }
}

impl Forward {
    /// 内联合并转发的常用构造:只给节点,4 个少用字段
    /// (`summary`/`prompt`/`news`/`source`)默认空。需要其一时用链式 setter 或直接
    /// 构造 [`Forward::Nodes`] 结构体变体。镜像 `Segment::share`/`music` 的默认化约定。
    pub fn nodes(nodes: Vec<ForwardNode>) -> Self {
        Forward::Nodes { nodes, title: None, summary: None, prompt: None, news: Vec::new(), source: None }
    }
    /// 设卡片标题(仅对 [`Forward::Nodes`] 生效;`Ref` 变体原样返回)。
    pub fn title(mut self, t: impl Into<String>) -> Self {
        if let Forward::Nodes { title, .. } = &mut self {
            *title = Some(t.into());
        }
        self
    }
}

impl ForwardNode {
    /// 一个转发节点:发送者 + 名字 + 任意段内容(`time` 默认 `None`)。
    pub fn new(user: impl Into<Uin>, name: impl Into<String>, content: Vec<Segment>) -> Self {
        ForwardNode { user: user.into(), name: name.into(), content, time: None }
    }
    /// 最常见的「一个发送者说一段纯文本」节点,省去各插件自造的 `node()`/`text_node()`。
    pub fn text(user: impl Into<Uin>, name: impl Into<String>, text: impl Into<String>) -> Self {
        ForwardNode::new(user, name, vec![Segment::text(text)])
    }
    /// 设节点气泡时间戳(链式)。
    pub fn at_time(mut self, t: i64) -> Self {
        self.time = Some(t);
        self
    }

    /// 把一组「逻辑条目」打包成每节点 ≤ `max_chars` 字的若干纯文本转发节点 —— **按条目切,绝不拆条目**。
    ///
    /// 节点内条目以 `sep` 连接;累计字数(含 `sep`)超过上限就另起一节点,但一个条目永远完整落在
    /// 某一个节点里(条目自身就超限时它独占一节点,也不切开)。字数按 Unicode 字符计,`name` 每节点重复。
    /// 适合「N 条漂流瓶 / N 条记录 / N 条命令」这类列表:按条目而非按字断开。条目为空 ⇒ 空 `Vec`。
    pub fn chunk_items<I, S>(
        user: impl Into<Uin>,
        name: impl Into<String>,
        items: I,
        sep: &str,
        max_chars: usize,
    ) -> Vec<ForwardNode>
    where
        I: IntoIterator<Item = S>,
        S: Into<String>,
    {
        let user = user.into();
        let name = name.into();
        let sep_len = sep.chars().count();
        let mut nodes = Vec::new();
        let mut buf = String::new();
        let mut len = 0usize;
        for item in items {
            let item: String = item.into();
            let il = item.chars().count();
            // 装不下就先把当前节点收口,再放这个条目(条目本身整块进新节点,不切开)。
            if !buf.is_empty() && len + sep_len + il > max_chars {
                nodes.push(ForwardNode::text(user, name.clone(), std::mem::take(&mut buf)));
                len = 0;
            }
            if !buf.is_empty() {
                buf.push_str(sep);
                len += sep_len;
            }
            buf.push_str(&item);
            len += il;
        }
        if !buf.is_empty() {
            nodes.push(ForwardNode::text(user, name, buf));
        }
        nodes
    }

    /// 把一大段多行文本按**行**切成 ≤ `max_chars` 字的若干文本节点([`Self::chunk_items`] 的便捷形:
    /// 条目 = 行、分隔 = 换行)。行本身不会被切开;但逻辑条目跨多行时,请直接用 [`Self::chunk_items`]
    /// 按条目切,以免一个条目被拆到两个节点。文本为空 ⇒ 空 `Vec`。
    pub fn chunk_text(
        user: impl Into<Uin>,
        name: impl Into<String>,
        text: impl Into<String>,
        max_chars: usize,
    ) -> Vec<ForwardNode> {
        let text = text.into();
        let lines: Vec<&str> = text.lines().collect();
        Self::chunk_items(user, name, lines, "\n", max_chars)
    }
}