mzrs-sdk 0.1.21

High-level Rust SDK for Mezon platform
Documentation
//! Message content builder and fluent message builder.
//!
//! [`MessageContent`] is the structured content that goes inside a message.
//! [`MessageBuilder`] wraps a [`ChannelHandle`] and [`MessageContent`] for
//! a fluent send-on-build pattern.

use mzrs_proto::api::{MessageAttachment, MessageMention, MessageRef};
use mzrs_proto::realtime as rt;
use serde::Serialize;
use serde_json::Value;

use crate::builders::components::ActionRow;
use crate::builders::embed::Embed;
use crate::builders::rich_text::{
    EmojiSpan, FormattingSpan, HashtagSpan, LinkSpan, RichText, SpanKind,
};
use crate::error::SdkError;
use crate::handles::ChannelHandle;

// ── ContentJson ─────────────────────────────────────────────────────

/// Internal JSON content structure matching the Mezon wire format.
#[derive(Debug, Clone, Serialize, Default)]
struct ContentJson {
    #[serde(rename = "t", skip_serializing_if = "str::is_empty")]
    text: String,
    #[serde(rename = "mk", skip_serializing_if = "Vec::is_empty")]
    mk: Vec<FormattingSpan>,
    #[serde(rename = "hg", skip_serializing_if = "Vec::is_empty")]
    hg: Vec<HashtagSpan>,
    #[serde(rename = "ej", skip_serializing_if = "Vec::is_empty")]
    ej: Vec<EmojiSpan>,
    #[serde(rename = "lk", skip_serializing_if = "Vec::is_empty")]
    lk: Vec<LinkSpan>,
    #[serde(rename = "vk", skip_serializing_if = "Vec::is_empty")]
    vk: Vec<LinkSpan>,
    #[serde(rename = "embed", skip_serializing_if = "Vec::is_empty")]
    embeds: Vec<Embed>,
    #[serde(rename = "components", skip_serializing_if = "Vec::is_empty")]
    components: Vec<Value>,
}

// ── PreparedContent ─────────────────────────────────────────────────

/// The result of building a [`MessageContent`].
///
/// Carries both the serialised JSON and the proto mentions list so both
/// can be sent together.
#[derive(Debug, Clone, Default)]
pub struct PreparedContent {
    /// Serialised JSON for the `content` field.
    pub json: String,
    /// Proto mentions list.
    pub mentions: Vec<MessageMention>,
}

// ── MessageContent ──────────────────────────────────────────────────

/// Structured message content builder.
///
/// Combines text, formatting spans, embeds, and action rows into the
/// JSON format expected by the Mezon API.
///
/// # Example
///
/// ```rust
/// use mzrs_sdk::builders::message::MessageContent;
/// use mzrs_sdk::Embed;
///
/// let content = MessageContent::new()
///     .with_text("Hello!")
///     .embed(Embed::new().title("Info").description("Details here."));
///
/// let json = content.build();
/// assert!(json.contains("Hello!"));
/// ```
#[derive(Debug, Clone, Default)]
pub struct MessageContent {
    inner: ContentJson,
    /// Proto mentions generated from rich text.
    mentions: Vec<MessageMention>,
}

impl MessageContent {
    /// Create an empty message content builder.
    pub fn new() -> Self {
        Self::default()
    }

    // ── Text shortcuts ──────────────────────────────────────────────

    /// Create a plain text message.
    pub fn text(t: impl Into<String>) -> Self {
        Self::new().with_text(t)
    }

    /// Create a code block message.
    pub fn code_block(code: impl Into<String>) -> Self {
        let code = code.into();
        let e = code.encode_utf16().count();
        let mut s = Self::new();
        s.inner.text = code;
        s.inner.mk.push(FormattingSpan {
            kind: SpanKind::Pre,
            s: 0,
            e,
            l: None,
        });
        s
    }

    /// Set the text portion from a [`RichText`] builder.
    ///
    /// Rich text spans, mentions, and links are all applied. Can be
    /// combined with embeds and action rows.
    pub fn with_rich_text(mut self, rt: RichText) -> Self {
        let parts = rt.into_parts();
        self.inner.text = parts.text;
        self.inner.mk = parts.mk;
        self.inner.hg = parts.hg;
        self.inner.ej = parts.ej;
        self.inner.lk = parts.lk;
        self.inner.vk = parts.vk;
        self.mentions = parts.mentions;
        self
    }

    // ── Builder methods ─────────────────────────────────────────────

    /// Set the plain-text portion.
    pub fn with_text(mut self, t: impl Into<String>) -> Self {
        self.inner.text = t.into();
        self
    }

    /// Append an [`Embed`] to the message.
    pub fn embed(mut self, e: Embed) -> Self {
        self.inner.embeds.push(e);
        self
    }

    /// Append an [`ActionRow`] (buttons / selects) to the message.
    pub fn action_row(mut self, row: ActionRow) -> Self {
        self.inner.components.push(row.build());
        self
    }

    // ── Build ───────────────────────────────────────────────────────

    /// Serialise to a JSON string (mentions are discarded).
    pub fn build(self) -> String {
        serde_json::to_string(&self.inner).unwrap_or_else(|_| "{}".into())
    }

    /// Serialise and return both the JSON string and proto mentions.
    pub fn build_prepared(self) -> PreparedContent {
        PreparedContent {
            json: serde_json::to_string(&self.inner).unwrap_or_else(|_| "{}".into()),
            mentions: self.mentions,
        }
    }
}

// ── MessageBuilder ──────────────────────────────────────────────────

/// Fluent builder for sending a message through a [`ChannelHandle`].
///
/// Provides a streamlined API for common message patterns. Finalise
/// with `.send().await`.
///
/// # Example
///
/// ```rust,ignore
/// channel.message()
///     .text("Hello!")
///     .embed(Embed::new().title("Info"))
///     .button("ok", "OK", ButtonStyle::Primary)
///     .send()
///     .await?;
/// ```
pub struct MessageBuilder {
    channel: ChannelHandle,
    content: MessageContent,
    references: Vec<MessageRef>,
    attachments: Vec<MessageAttachment>,
    anonymous: bool,
    mention_everyone: bool,
}

impl MessageBuilder {
    /// Create a new message builder for the given channel.
    pub(crate) fn new(channel: ChannelHandle) -> Self {
        Self {
            channel,
            content: MessageContent::new(),
            references: Vec::new(),
            attachments: Vec::new(),
            anonymous: false,
            mention_everyone: false,
        }
    }

    /// Set the plain text portion.
    pub fn text(mut self, t: impl Into<String>) -> Self {
        self.content = self.content.with_text(t);
        self
    }

    /// Set the content from a [`RichText`] builder.
    pub fn rich_text(mut self, rt: RichText) -> Self {
        self.content = self.content.with_rich_text(rt);
        self
    }

    /// Append an embed.
    pub fn embed(mut self, e: Embed) -> Self {
        self.content = self.content.embed(e);
        self
    }

    /// Add a button to the message (creates an action row if needed).
    pub fn button(
        mut self,
        id: impl Into<String>,
        label: impl Into<String>,
        style: crate::builders::components::ButtonStyle,
    ) -> Self {
        let row = ActionRow::new().button(id, label, style);
        self.content = self.content.action_row(row);
        self
    }

    /// Attach a prepared file metadata object to the message.
    ///
    /// For bots, provide a [`MessageAttachment`] that already points to an
    /// externally hosted URL. Direct upload through Mezon bot tokens is not
    /// supported.
    ///
    /// # Example
    ///
    /// ```rust,ignore
    /// let att = mzrs_sdk::api::MessageAttachment {
    ///     filename: "img.png".to_string(),
    ///     url: "https://cdn.example.com/img.png".to_string(),
    ///     filetype: "image/png".to_string(),
    ///     size: 12345,
    ///     ..Default::default()
    /// };
    /// channel.message()
    ///     .text("See attached image:")
    ///     .attach_uploaded(att)
    ///     .send()
    ///     .await?;
    /// ```
    pub fn attach_uploaded(mut self, attachment: MessageAttachment) -> Self {
        self.attachments.push(attachment);
        self
    }

    /// Add a reply reference to another message.
    pub fn reply_to(mut self, message_id: i64, message_ref_id: i64) -> Self {
        self.references.push(MessageRef {
            message_id,
            message_ref_id,
            ref_type: 0, // reply
            ..Default::default()
        });
        self
    }

    /// Add a user mention (appended to the text).
    pub fn mention(mut self, user_id: i64, username: &str) -> Self {
        // Record the mention in the content builder via RichText-like approach
        let pos = self.content.inner.text.encode_utf16().count() as i32;
        let at_text = format!("@{username}");
        self.content.inner.text.push_str(&at_text);
        let end = self.content.inner.text.encode_utf16().count() as i32;
        self.content.mentions.push(MessageMention {
            user_id,
            username: username.to_owned(),
            s: pos,
            e: end,
            ..Default::default()
        });
        self
    }

    /// Mention everyone in the channel.
    pub fn mention_everyone(mut self) -> Self {
        self.mention_everyone = true;
        self
    }

    /// Send as an anonymous message.
    pub fn anonymous(mut self) -> Self {
        self.anonymous = true;
        self
    }

    /// Finalise and send the message.
    ///
    /// # Errors
    ///
    /// Returns [`SdkError`] on validation or network failure.
    #[tracing::instrument(skip(self))]
    pub async fn send(self) -> Result<rt::ChannelMessageAck, SdkError> {
        let prepared = self.content.build_prepared();
        let json_value: Value = serde_json::from_str(&prepared.json).unwrap_or(Value::Null);

        self.channel
            .client()
            .send_message(crate::client::SendMessageRequest {
                clan_id: self.channel.clan_id().to_string(),
                channel_id: self.channel.id().to_string(),
                mode: self.channel.mode(),
                is_public: self.channel.is_public(),
                content: json_value,
                mentions: prepared.mentions,
                attachments: self.attachments,
                references: self.references,
                anonymous_message: self.anonymous,
                mention_everyone: self.mention_everyone,
                avatar: None,
                code: 0,
                topic_id: None,
                id: None,
            })
            .await
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn message_content_text() {
        let json = MessageContent::text("hello").build();
        assert!(json.contains(r#""t":"hello"#));
    }

    #[test]
    fn message_content_code_block() {
        let json = MessageContent::code_block("fn main() {}").build();
        assert!(json.contains(r#""t":"fn main() {}"#));
        assert!(json.contains(r#""type":"pre"#));
    }

    #[test]
    fn message_content_with_embed() {
        let json = MessageContent::new()
            .embed(Embed::new().title("Test"))
            .build();
        assert!(json.contains("Test"));
    }

    #[test]
    fn message_content_rich_text_link_keeps_link_spans() {
        let json = MessageContent::new()
            .with_rich_text(RichText::new().text("Docs: ").link("https://example.com"))
            .build();
        assert!(json.contains(r#""lk""#));
        assert!(json.contains(r#""type":"lk""#));
        assert!(json.contains("https://example.com"));
    }
}