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}