Skip to main content

nagisa_types/
segment.rs

1//! 统一消息段 [`Segment`] 及其附属类型(图片子类型、合并转发、音乐分享、媒体下载提示等)。
2//! 业务收发用同一套 [`Segment`],wire 级的入/出差异由适配器私有承担;协议私有/未知段一律落到
3//! [`Segment::Raw`] 逃生口,绝不丢弃。[`Segment`] 上的关联函数(`text` / `at` / `image_url` …)
4//! 是常用段的便捷构造器。
5use crate::capability::Protocol;
6use crate::id::{MessageId, Uin};
7use crate::resource::{Media, ResourceSource};
8use serde_json::{Map, Value};
9
10#[derive(Clone, Copy, PartialEq, Eq, Debug)]
11pub enum ImageSubType {
12    Normal,
13    /// 大表情/原创表情(OneBot `subType` 非 0)。
14    Sticker,
15    /// 闪照(阅后即焚):OneBot `data.type = "flash"` / Mirai `FlashImage`。内容过滤、
16    /// 反撤回类插件需按此分支。
17    Flash,
18}
19
20/// `Contact` 段的推荐目标类型。
21#[derive(Clone, Copy, PartialEq, Eq, Debug)]
22pub enum ContactKind {
23    Friend,
24    Group,
25}
26
27/// 音乐分享:平台预设(qq/163/kugou/migu/kuwo…)或自定义卡片。
28#[derive(Clone, Debug)]
29pub enum MusicShare {
30    /// 平台预设:`ty` 为平台标识(qq/163/kugou/migu/kuwo),`id` 为歌曲 id。
31    Platform { ty: String, id: String },
32    /// 自定义分享卡片。
33    Custom { url: String, audio: String, title: String, content: Option<String>, image: Option<String> },
34}
35
36/// 合并转发:接收为引用 + 预览元信息;发送为内联节点。
37#[derive(Clone, Debug)]
38pub enum Forward {
39    Ref {
40        id: String,
41        title: Option<String>,
42        preview: Vec<String>,
43        summary: Option<String>,
44    },
45    Nodes {
46        nodes: Vec<ForwardNode>,
47        title: Option<String>,
48        summary: Option<String>,
49        prompt: Option<String>,
50        /// 自定义合并转发卡片的预览行(gocq/NapCat `news`,形如 `[{text}]`)。
51        /// 空 → 不写出该字段。
52        news: Vec<String>,
53        /// 自定义合并转发卡片来源标题(gocq/NapCat `source`)。缺省 → None。
54        source: Option<String>,
55    },
56}
57
58/// 媒体段发送侧下载行为提示(OneBot image/record/video 的 `cache`/`proxy`/`timeout`)。
59/// 仅发送侧有意义;接收侧恒为默认。Milky 无对应字段(降级忽略)。
60/// OFFICIAL: https://github.com/botuniverse/onebot-11/blob/master/message/segment.md (§图片/§语音/§短视频 cache/proxy/timeout)
61#[derive(Clone, Debug, Default, PartialEq, Eq)]
62pub struct MediaSendHints {
63    /// 是否使用已缓存的文件(`false` → 不使用缓存)。
64    pub cache: Option<bool>,
65    /// 是否通过代理下载远程文件。
66    pub proxy: Option<bool>,
67    /// 远程文件下载超时秒数。
68    pub timeout: Option<i32>,
69}
70
71/// 合并转发的单个节点(发送者 + 内容)。
72///
73/// **跨协议非对称(固有限制)**:[`user`](Self::user) 在 **Milky** 后端解码时恒为
74/// `Uin(0)` 哨兵——Milky 的 `IncomingForwardedMessage` 只带 `sender_name`,wire 上
75/// 没有发送者 `user_id`,故无法填充真实 uin(见 `nagisa-milky/src/decode.rs`
76/// `forward_node_from_value`)。OneBot 的 `node` 段带 `user_id`,能正确填充。下游若在
77/// Milky 后端按 `user` 寻址转发节点的发送者,须以 `name`(sender_name)为准——这是
78/// Milky IR 的天然缺口,非解码缺陷。
79#[derive(Clone, Debug)]
80pub struct ForwardNode {
81    /// 节点发送者 QQ 号。Milky 后端无此 wire 字段 → 恒为 `Uin(0)`(详见结构体级文档)。
82    pub user: Uin,
83    pub name: String,
84    pub content: Vec<Segment>,
85    /// 合并转发节点气泡时间戳(部分实现收发;可缺省)。
86    pub time: Option<i64>,
87}
88
89/// 统一消息段。`#[non_exhaustive]`:加段不破坏下游。
90#[derive(Clone, Debug)]
91#[non_exhaustive]
92pub enum Segment {
93    Text(String),
94    Mention {
95        user: Uin,
96        name: Option<String>,
97    },
98    MentionAll,
99    /// QQ 表情段。`id`/`large` 为 OneBot v11 标准字段。
100    /// `result_id`/`chain_count` 为 NapCat「超级表情」(super-face) 扩展(连发动画表情,
101    /// 仅 NapCat 收发;其余厂商缺省 → None)。`sub_type` 为 LLOneBot 的 `FaceType`
102    /// (表情子类型,如 0=普通 / 1=超级 / 2=原创,仅 LLOneBot 透传 → 其余 None)。
103    /// 来源已核对(2026-06-04):NapCat `OB11MessageFace` data `resultId`(string)/`chainCount`(number);
104    /// LLOneBot `OB11MessageFace` data `sub_type`(number, FaceType)。
105    /// ENDPOINT: NapNeko/NapCatQQ packages/napcat-onebot/types/message.ts (OB11MessageFaceSchema)
106    ///   + LLOneBot/LLOneBot src/onebot11/types.ts (OB11MessageFace)。
107    Face {
108        id: String,
109        large: bool,
110        result_id: Option<String>,
111        chain_count: Option<i32>,
112        sub_type: Option<i32>,
113    },
114    Reply {
115        id: MessageId,
116        sender: Option<Uin>,
117        time: Option<i64>,
118        quoted: Vec<Segment>,
119    },
120    Image {
121        res: Media,
122        sub_type: ImageSubType,
123        hints: MediaSendHints,
124    },
125    Record {
126        res: Media,
127        magic: Option<i32>,
128        hints: MediaSendHints,
129    },
130    /// 视频段。`thumb`(封面缩略图)为**跨协议非对称(固有限制)**字段:**标准 OneBot v11
131    /// 的 video 段没有 thumb wire 字段**,故纯 v11 端 encode 时必然丢弃 `thumb`;nagisa 仍会
132    /// 在 encode 时写出 `thumb` 键(LLOneBot/go-cqhttp 扩展接受它,标准端忽略多发键,无害——
133    /// 见 `nagisa-onebot/src/encode.rs`),但**标准 v11 解码侧 `thumb` 恒为 `None`**。Milky 有
134    /// 对称的 `thumb_uri` wire 字段,能完整收发。即:`thumb` 在标准 OneBot v11 下是只写不读
135    /// 的有损字段,这是 v11 wire 的天然缺口,非编解码缺陷。
136    Video {
137        res: Media,
138        hints: MediaSendHints,
139        thumb: Option<ResourceSource>,
140    },
141    File {
142        id: String,
143        name: String,
144        size: u64,
145        hash: Option<String>,
146        url: Option<String>,
147    },
148    Forward(Forward),
149    MarketFace {
150        package_id: i32,
151        emoji_id: String,
152        key: String,
153        summary: Option<String>,
154        url: Option<String>,
155    },
156    LightApp {
157        app_name: Option<String>,
158        payload: String,
159    },
160    /// XML 卡片(OneBot `xml` / Milky 入站 `xml`)。与 `LightApp` 对称,避免 json 有
161    /// 类型而 xml 退化成 `Raw` 的不对称。
162    Xml {
163        service_id: Option<i32>,
164        payload: String,
165    },
166    /// 戳一戳消息段(OneBot)。注意区别于 `send_nudge` 动作与 nudge 通知。
167    Poke {
168        kind: i32,
169        id: i32,
170        strength: Option<i32>,
171        name: Option<String>,
172    },
173    /// 推荐好友/群名片。
174    Contact {
175        kind: ContactKind,
176        id: Uin,
177    },
178    /// 位置分享。
179    Location {
180        lat: f64,
181        lon: f64,
182        title: Option<String>,
183        content: Option<String>,
184    },
185    /// 音乐分享(发送向;接收通常表现为 `LightApp`)。
186    Music(MusicShare),
187    /// 链接分享卡片(OneBot `share`)。`url`/`title` 必填,`content`/`image` 可选。
188    /// 与已建模的 LightApp/Xml/Music 卡片并列;Milky 发送侧无对应段(降级跳过)。
189    Share {
190        url: String,
191        title: String,
192        content: Option<String>,
193        image: Option<String>,
194    },
195    /// 猜拳魔法表情(OneBot `rps`,收发皆有)。标准 v11 为空 data;NapCat 额外带
196    /// `result`(1=布 / 2=剪刀 / 3=石头,随机数已定)→ `result` 保留(缺省 → None)。
197    /// ENDPOINT: NapNeko/NapCatQQ packages/napcat-onebot/types/message.ts (OB11MessageRPS data `result`)
198    Rps {
199        result: Option<i32>,
200    },
201    /// 掷骰子魔法表情(OneBot `dice`,收发皆有)。标准 v11 为空 data;NapCat 额外带
202    /// `result`(1–6 点数)→ `result` 保留(缺省 → None)。
203    /// ENDPOINT: NapNeko/NapCatQQ packages/napcat-onebot/types/message.ts (OB11MessageDice data `result`)
204    Dice {
205        result: Option<i32>,
206    },
207    /// 窗口抖动 / 戳一戳快捷(OneBot `shake`,空 data,仅发送)。
208    Shake,
209    /// 匿名发送(OneBot `anonymous`,仅发送)。`ignore=Some(true)` 表示无法匿名时
210    /// 继续以普通身份发送;`None` = 不带该字段。
211    Anonymous {
212        ignore: Option<bool>,
213    },
214    /// QQ-Bot 内联键盘(Lagrange `keyboard` 段)。`content` 为 `KeyboardData` JSON
215    /// 对象(按钮行/列)。Milky 无对应段(出站降级跳过)。
216    /// 来源已核对(2026-06-04):type=`"keyboard"`、data 字段 `content`(KeyboardData JSON)。
217    /// ENDPOINT: LagrangeDev/Lagrange.Core Lagrange.OneBot/Message/Entity/KeyboardSegment.cs
218    Keyboard {
219        content: Value,
220    },
221    /// Markdown 卡片(Lagrange `markdown` 段)。`content` 为 Markdown 文本字符串。
222    /// Milky 无对应段(出站降级跳过)。
223    /// 来源已核对(2026-06-04):type=`"markdown"`、data 字段 `content`(string)。
224    /// ENDPOINT: LagrangeDev/Lagrange.Core Lagrange.OneBot/Message/Entity/MarkdownSegment.cs
225    Markdown {
226        content: String,
227    },
228    /// 长消息引用(Lagrange `longmsg` 段,主要入站)。`id` 为长消息 res_id。
229    /// Milky 无对应段(出站降级跳过)。
230    /// 来源已核对(2026-06-04):type=`"longmsg"`、data 字段 `id`(string,**非** `res_id`)。
231    /// ENDPOINT: LagrangeDev/Lagrange.Core Lagrange.OneBot/Message/Entity/LongMsgSegment.cs
232    LongMsg {
233        id: String,
234    },
235    /// QQ 闪传卡片(LLOneBot 私有 `flash_file` 段)。入站由 LLOneBot 解析「闪传」
236    /// markdown 卡片得到;出站可据此重建段。`title` 在 wire 上偶有缺省(标题属性可能
237    /// 取不到),故为 `Option`(缺失 → None,保持 decode 无误)。
238    /// 来源已核对(2026-06-04):type=`"flash_file"`、data 字段
239    /// `title`(string,可缺)/`file_set_id`(string)/`scene_type`(number)。
240    /// ENDPOINT: LLOneBot/LLOneBot src/onebot11/types.ts (OB11MessageFlashFile)
241    ///   + src/onebot11/transform/message/incoming.ts。
242    FlashFile {
243        title: Option<String>,
244        file_set_id: String,
245        scene_type: i32,
246    },
247    /// 小程序卡片(NapCat 私有 `miniapp` 段)。`payload` 为小程序的 JSON 字符串
248    /// (NapCat data 字段 `data`,原样透传,便于手工构建/转发)。与 LightApp/Xml 并列。
249    /// 来源已核对(2026-06-04):type=`"miniapp"`、data 字段 `data`(string,小程序 JSON)。
250    /// ENDPOINT: NapNeko/NapCatQQ packages/napcat-onebot/types/message.ts (OB11MessageMiniAppSchema)
251    MiniApp {
252        payload: String,
253    },
254    /// 在线文件/文件夹卡片(NapCat 私有 `onlinefile` 段)。
255    /// 来源已核对(2026-06-04):type=`"onlinefile"`、data 字段
256    /// `msgId`(string)/`elementId`(string)/`fileName`(string)/`fileSize`(string)/`isDir`(bool)。
257    /// ENDPOINT: NapNeko/NapCatQQ packages/napcat-onebot/types/message.ts (OB11MessageOnlineFileSchema)
258    OnlineFile {
259        msg_id: String,
260        element_id: String,
261        file_name: String,
262        file_size: String,
263        is_dir: bool,
264    },
265    /// QQ 闪传卡片(NapCat 私有 `flashtransfer` 段)。`file_set_id` 为闪传文件集 id。
266    /// 与 LLOneBot 的 `FlashFile` 同属闪传但 wire 名/字段不同,故各自建模。
267    /// 来源已核对(2026-06-04):type=`"flashtransfer"`、data 字段 `fileSetId`(string)。
268    /// ENDPOINT: NapNeko/NapCatQQ packages/napcat-onebot/types/message.ts (OB11MessageFlashTransferSchema)
269    FlashTransfer {
270        file_set_id: String,
271    },
272    /// 逃生口:协议私有/未知段。adapter 的 decode 必须把未知段塞进来,绝不丢弃。
273    Raw {
274        protocol: Protocol,
275        kind: String,
276        data: Map<String, Value>,
277    },
278}
279
280impl Segment {
281    pub fn text(s: impl Into<String>) -> Self {
282        Segment::Text(s.into())
283    }
284    pub fn at(user: impl Into<Uin>) -> Self {
285        Segment::Mention { user: user.into(), name: None }
286    }
287    pub fn at_all() -> Self {
288        Segment::MentionAll
289    }
290    pub fn face(id: impl Into<String>) -> Self {
291        Segment::Face { id: id.into(), large: false, result_id: None, chain_count: None, sub_type: None }
292    }
293    pub fn image_bytes(b: impl Into<bytes::Bytes>) -> Self {
294        Segment::Image {
295            res: Media::from_source(ResourceSource::bytes(b)),
296            sub_type: ImageSubType::Normal,
297            hints: MediaSendHints::default(),
298        }
299    }
300    pub fn image_url(u: impl Into<String>) -> Self {
301        Segment::Image {
302            res: Media::from_source(ResourceSource::url(u)),
303            sub_type: ImageSubType::Normal,
304            hints: MediaSendHints::default(),
305        }
306    }
307    /// 本地文件图片([`ResourceSource::Path`],发送时由 adapter 读取/编码)。
308    /// 与 [`image_bytes`](Self::image_bytes)/[`image_url`](Self::image_url) 三态对齐。
309    pub fn image_path(p: impl Into<std::path::PathBuf>) -> Self {
310        Segment::Image {
311            res: Media::from_source(ResourceSource::path(p)),
312            sub_type: ImageSubType::Normal,
313            hints: MediaSendHints::default(),
314        }
315    }
316    pub fn reply(id: MessageId) -> Self {
317        Segment::Reply { id, sender: None, time: None, quoted: Vec::new() }
318    }
319    pub fn xml(payload: impl Into<String>) -> Self {
320        Segment::Xml { service_id: None, payload: payload.into() }
321    }
322    pub fn poke(kind: i32, id: i32) -> Self {
323        Segment::Poke { kind, id, strength: None, name: None }
324    }
325    pub fn location(lat: f64, lon: f64) -> Self {
326        Segment::Location { lat, lon, title: None, content: None }
327    }
328    pub fn music(platform: impl Into<String>, id: impl Into<String>) -> Self {
329        Segment::Music(MusicShare::Platform { ty: platform.into(), id: id.into() })
330    }
331    pub fn share(url: impl Into<String>, title: impl Into<String>) -> Self {
332        Segment::Share { url: url.into(), title: title.into(), content: None, image: None }
333    }
334    pub fn anonymous(ignore: bool) -> Self {
335        Segment::Anonymous { ignore: Some(ignore) }
336    }
337    /// 带 summary(替代文本)的图片。Milky 出站 image.summary / OneBot image summary。
338    pub fn image_url_with_summary(u: impl Into<String>, summary: impl Into<String>) -> Self {
339        let mut res = Media::from_source(ResourceSource::url(u));
340        res.summary = Some(summary.into());
341        Segment::Image { res, sub_type: ImageSubType::Normal, hints: MediaSendHints::default() }
342    }
343    /// 视频 URL 段(无缩略图)。
344    pub fn video_url(u: impl Into<String>) -> Self {
345        Segment::Video {
346            res: Media::from_source(ResourceSource::url(u)),
347            hints: MediaSendHints::default(),
348            thumb: None,
349        }
350    }
351    /// 若是文本段返回其内容。
352    pub fn as_text(&self) -> Option<&str> {
353        match self {
354            Segment::Text(t) => Some(t),
355            _ => None,
356        }
357    }
358    /// 合并转发段的便捷入口:`Segment::forward(nodes)` ≡
359    /// `Segment::Forward(Forward::nodes(nodes))`。需要标题/卡片预览时用
360    /// [`Forward::nodes`] + 链式 setter,再自行 `Segment::Forward(..)`。
361    pub fn forward(nodes: Vec<ForwardNode>) -> Self {
362        Segment::Forward(Forward::nodes(nodes))
363    }
364}
365
366impl Forward {
367    /// 内联合并转发的常用构造:只给节点,4 个少用字段
368    /// (`summary`/`prompt`/`news`/`source`)默认空。需要其一时用链式 setter 或直接
369    /// 构造 [`Forward::Nodes`] 结构体变体。镜像 `Segment::share`/`music` 的默认化约定。
370    pub fn nodes(nodes: Vec<ForwardNode>) -> Self {
371        Forward::Nodes { nodes, title: None, summary: None, prompt: None, news: Vec::new(), source: None }
372    }
373    /// 设卡片标题(仅对 [`Forward::Nodes`] 生效;`Ref` 变体原样返回)。
374    pub fn title(mut self, t: impl Into<String>) -> Self {
375        if let Forward::Nodes { title, .. } = &mut self {
376            *title = Some(t.into());
377        }
378        self
379    }
380}
381
382impl ForwardNode {
383    /// 一个转发节点:发送者 + 名字 + 任意段内容(`time` 默认 `None`)。
384    pub fn new(user: impl Into<Uin>, name: impl Into<String>, content: Vec<Segment>) -> Self {
385        ForwardNode { user: user.into(), name: name.into(), content, time: None }
386    }
387    /// 最常见的「一个发送者说一段纯文本」节点,省去各插件自造的 `node()`/`text_node()`。
388    pub fn text(user: impl Into<Uin>, name: impl Into<String>, text: impl Into<String>) -> Self {
389        ForwardNode::new(user, name, vec![Segment::text(text)])
390    }
391    /// 设节点气泡时间戳(链式)。
392    pub fn at_time(mut self, t: i64) -> Self {
393        self.time = Some(t);
394        self
395    }
396
397    /// 把一组「逻辑条目」打包成每节点 ≤ `max_chars` 字的若干纯文本转发节点 —— **按条目切,绝不拆条目**。
398    ///
399    /// 节点内条目以 `sep` 连接;累计字数(含 `sep`)超过上限就另起一节点,但一个条目永远完整落在
400    /// 某一个节点里(条目自身就超限时它独占一节点,也不切开)。字数按 Unicode 字符计,`name` 每节点重复。
401    /// 适合「N 条漂流瓶 / N 条记录 / N 条命令」这类列表:按条目而非按字断开。条目为空 ⇒ 空 `Vec`。
402    pub fn chunk_items<I, S>(
403        user: impl Into<Uin>,
404        name: impl Into<String>,
405        items: I,
406        sep: &str,
407        max_chars: usize,
408    ) -> Vec<ForwardNode>
409    where
410        I: IntoIterator<Item = S>,
411        S: Into<String>,
412    {
413        let user = user.into();
414        let name = name.into();
415        let sep_len = sep.chars().count();
416        let mut nodes = Vec::new();
417        let mut buf = String::new();
418        let mut len = 0usize;
419        for item in items {
420            let item: String = item.into();
421            let il = item.chars().count();
422            // 装不下就先把当前节点收口,再放这个条目(条目本身整块进新节点,不切开)。
423            if !buf.is_empty() && len + sep_len + il > max_chars {
424                nodes.push(ForwardNode::text(user, name.clone(), std::mem::take(&mut buf)));
425                len = 0;
426            }
427            if !buf.is_empty() {
428                buf.push_str(sep);
429                len += sep_len;
430            }
431            buf.push_str(&item);
432            len += il;
433        }
434        if !buf.is_empty() {
435            nodes.push(ForwardNode::text(user, name, buf));
436        }
437        nodes
438    }
439
440    /// 把一大段多行文本按**行**切成 ≤ `max_chars` 字的若干文本节点([`Self::chunk_items`] 的便捷形:
441    /// 条目 = 行、分隔 = 换行)。行本身不会被切开;但逻辑条目跨多行时,请直接用 [`Self::chunk_items`]
442    /// 按条目切,以免一个条目被拆到两个节点。文本为空 ⇒ 空 `Vec`。
443    pub fn chunk_text(
444        user: impl Into<Uin>,
445        name: impl Into<String>,
446        text: impl Into<String>,
447        max_chars: usize,
448    ) -> Vec<ForwardNode> {
449        let text = text.into();
450        let lines: Vec<&str> = text.lines().collect();
451        Self::chunk_items(user, name, lines, "\n", max_chars)
452    }
453}