refactor(tests): reformat code and update dependency management

- Reorganized import statements in adapter tests for better readability
- Replaced or_insert_with(Vec::new) with or_default() in test closures
- Updated Cargo.lock with new dependency versions and checksums
- Added TLS features to tonic dependency configuration
- Included sqlx, chrono, and uuid dependencies with specific features
- Added jsonwebtoken and arc-swap as project dependencies
- Reformatted assertion statements to comply with line length limits
- Adjusted base64 import order in engine codec module
- Updated protobuf include statement formatting
This commit is contained in:
zhenyi
2026-06-11 12:11:05 +08:00
parent 06e8ee96a5
commit 821537186e
111 changed files with 10458 additions and 385 deletions
+175
View File
@@ -0,0 +1,175 @@
//! Core message model — maps to PostgreSQL `message` table.
//!
//! Discord-style: `body` holds plain text / markdown, rich content lives in
//! companion tables (attachment, embed, poll, reaction, mention).
//! IDs are UUID v7 (time-ordered) so `ORDER BY id` = chronological order.
use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use uuid::Uuid;
/// Discriminator for system / event messages vs. regular user messages.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
#[serde(rename_all = "snake_case")]
pub enum MessageType {
/// Regular user message (text / markdown).
#[default]
Text,
/// System-generated notice (e.g. "user joined the channel").
System,
/// Channel event (pinned a message, changed topic, etc.).
Event,
/// Forum article / long-form post displayed as waterfall cards.
Article,
}
impl MessageType {
pub fn as_str(&self) -> &'static str {
match self {
Self::Text => "text",
Self::System => "system",
Self::Event => "event",
Self::Article => "article",
}
}
pub fn from_str_lossy(s: &str) -> Self {
match s {
"system" => Self::System,
"event" => Self::Event,
"article" => Self::Article,
_ => Self::Text,
}
}
}
impl std::fmt::Display for MessageType {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str(self.as_str())
}
}
/// Direct mapping of the `message` table row.
#[derive(Debug, Clone, Serialize, Deserialize, sqlx::FromRow)]
pub struct Message {
/// UUID v7 — time-ordered primary key.
pub id: Uuid,
pub channel_id: Uuid,
pub author_id: Uuid,
/// Thread this message belongs to (NULL = not threaded).
pub thread_id: Option<Uuid>,
/// Direct reply reference (NULL = top-level message).
pub reply_to_message_id: Option<Uuid>,
/// "text" | "system" | "event"
pub message_type: String,
/// Plain text or markdown body.
pub body: String,
/// Extensible metadata (flags, locale, interaction ref, etc.).
pub metadata: Option<serde_json::Value>,
pub pinned: bool,
/// True for bot / system generated messages.
pub system: bool,
pub edited_at: Option<DateTime<Utc>>,
pub deleted_at: Option<DateTime<Utc>>,
pub created_at: DateTime<Utc>,
pub updated_at: DateTime<Utc>,
}
/// Lightweight author info embedded in [`MessageDetail`].
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct AuthorInfo {
pub id: Uuid,
pub username: String,
pub display_name: Option<String>,
pub avatar_url: Option<String>,
pub is_bot: bool,
}
/// Message with resolved author and reaction/attachment aggregates.
/// Returned by read APIs; never stored directly.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct MessageDetail {
#[serde(flatten)]
pub message: Message,
pub author: AuthorInfo,
/// Aggregated reaction counts: `{ "👍": 3, "🎉": 1 }`.
pub reactions: std::collections::HashMap<String, i64>,
pub attachment_count: i64,
pub embed_count: i64,
/// Whether the current user has bookmarked this message.
pub bookmarked: bool,
/// Reply chain depth (0 = top-level).
pub reply_depth: i32,
}
/// Generate a new UUID v7 (time-ordered) for message IDs.
pub fn new_message_id() -> Uuid {
Uuid::now_v7()
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_message_type_roundtrip() {
let t = MessageType::Text;
assert_eq!(t.as_str(), "text");
assert_eq!(MessageType::from_str_lossy("text"), MessageType::Text);
assert_eq!(MessageType::from_str_lossy("system"), MessageType::System);
assert_eq!(MessageType::from_str_lossy("unknown"), MessageType::Text);
}
#[test]
fn test_message_id_ordering() {
// UUID v7 IDs generated later should sort after earlier ones.
let a = new_message_id();
std::thread::sleep(std::time::Duration::from_millis(2));
let b = new_message_id();
assert!(b > a, "UUID v7 should be time-ordered");
}
#[test]
fn test_message_detail_serialize() {
let msg = Message {
id: Uuid::now_v7(),
channel_id: Uuid::now_v7(),
author_id: Uuid::now_v7(),
thread_id: None,
reply_to_message_id: None,
message_type: "text".to_string(),
body: "hello world".to_string(),
metadata: None,
pinned: false,
system: false,
edited_at: None,
deleted_at: None,
created_at: Utc::now(),
updated_at: Utc::now(),
};
let detail = MessageDetail {
message: msg,
author: AuthorInfo {
id: Uuid::now_v7(),
username: "alice".to_string(),
display_name: Some("Alice".to_string()),
avatar_url: None,
is_bot: false,
},
reactions: std::collections::HashMap::from([
("👍".to_string(), 3),
("🎉".to_string(), 1),
]),
attachment_count: 0,
embed_count: 0,
bookmarked: false,
reply_depth: 0,
};
let json = serde_json::to_value(&detail).unwrap();
assert_eq!(json["body"], "hello world");
assert_eq!(json["author"]["username"], "alice");
assert_eq!(json["reactions"]["👍"], 3);
}
}
+196
View File
@@ -0,0 +1,196 @@
//! Forum article / long-form post — maps to `message_article` table.
//!
//! Forum articles extend a regular [`Message`] with title, cover image, tags,
//! and view/like stats. Rendered as waterfall cards in forum channel views.
//! One article per message (1:1), linked via `message_id`.
//!
//! A message is an article when `message.message_type = "article"`.
use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use uuid::Uuid;
/// Extended metadata for a forum article / post.
#[derive(Debug, Clone, Serialize, Deserialize, sqlx::FromRow)]
pub struct MessageArticle {
pub id: Uuid,
/// FK to `message.id`. UNIQUE constraint ensures 1:1.
pub message_id: Uuid,
/// Article title (plain text, max 256 chars).
pub title: String,
/// Short excerpt for card preview (plain text, max 512 chars).
pub summary: Option<String>,
/// Cover image URL (displayed at the top of the card).
pub cover_url: Option<String>,
/// Cover image width in pixels (for waterfall layout height calculation).
pub cover_width: Option<i32>,
/// Cover image height in pixels.
pub cover_height: Option<i32>,
/// Cover image dominant color (hex, for placeholder while loading).
pub cover_color: Option<String>,
/// Tag IDs referencing `forum_tag` table, stored as JSON array.
pub tags: Option<serde_json::Value>,
/// View count (denormalized, incremented on read).
pub view_count: i64,
/// Reaction / like count (denormalized).
pub like_count: i64,
/// Bookmark count (denormalized).
pub bookmark_count: i64,
/// Reply count (denormalized, threads inside the article).
pub reply_count: i64,
/// Most recent reply message id.
pub last_reply_message_id: Option<Uuid>,
/// Most recent reply timestamp.
pub last_reply_at: Option<DateTime<Utc>>,
/// User id of the last replier.
pub last_reply_user_id: Option<Uuid>,
/// Whether the article is pinned to the top of the forum channel.
pub is_pinned_to_top: bool,
/// Whether the question has been answered / resolved.
pub is_answered: bool,
/// Who marked it as answered.
pub answered_by: Option<Uuid>,
/// When it was marked as answered.
pub answered_at: Option<DateTime<Utc>>,
pub created_at: DateTime<Utc>,
pub updated_at: DateTime<Utc>,
}
/// Waterfall card view — article enriched with author info + first attachment
/// (cover fallback). Returned by forum list APIs.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ArticleCard {
#[serde(flatten)]
pub article: MessageArticle,
/// Author display info.
pub author: super::message::AuthorInfo,
/// Resolved tag names (e.g. ["Bug Report", "High Priority"]).
pub tag_names: Vec<String>,
/// First image attachment URL (fallback when cover_url is NULL).
pub first_image_url: Option<String>,
/// Whether the current user has bookmarked this article.
pub bookmarked: bool,
/// Whether the current user has liked this article.
pub liked: bool,
}
/// Input payload for creating a new forum article.
///
/// Sent by the client when composing a forum post.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct CreateArticleInput {
pub channel_id: Uuid,
pub title: String,
pub body: String,
pub summary: Option<String>,
pub cover_url: Option<String>,
pub tags: Option<Vec<Uuid>>,
}
/// Input payload for updating an existing forum article.
///
/// All fields are optional — only provided fields are updated.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct UpdateArticleInput {
pub title: Option<String>,
pub body: Option<String>,
pub summary: Option<String>,
pub cover_url: Option<String>,
pub cover_color: Option<String>,
pub tags: Option<Vec<Uuid>>,
}
/// Sort modes for forum article listing.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
#[serde(rename_all = "snake_case")]
pub enum ArticleSort {
/// Most recent activity first.
#[default]
LatestActivity,
/// Newest articles first.
Newest,
/// Most viewed first.
MostViewed,
/// Most liked first.
MostLiked,
/// Pinned articles first, then by latest activity.
PinnedFirst,
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_article_card_serialize() {
let article = MessageArticle {
id: Uuid::now_v7(),
message_id: Uuid::now_v7(),
title: "Bug Report: Login fails on Safari".into(),
summary: Some("Users report that login fails on Safari 18...".into()),
cover_url: Some("https://cdn.example.com/covers/bug.png".into()),
cover_width: Some(1200),
cover_height: Some(630),
cover_color: Some("#FF6B6B".into()),
tags: Some(serde_json::json!(["01909a", "01909b"])),
view_count: 142,
like_count: 7,
bookmark_count: 3,
reply_count: 5,
last_reply_message_id: Some(Uuid::now_v7()),
last_reply_at: Some(Utc::now()),
last_reply_user_id: Some(Uuid::now_v7()),
is_pinned_to_top: true,
is_answered: false,
answered_by: None,
answered_at: None,
created_at: Utc::now(),
updated_at: Utc::now(),
};
let card = ArticleCard {
article,
author: super::super::message::AuthorInfo {
id: Uuid::now_v7(),
username: "alice".into(),
display_name: Some("Alice".into()),
avatar_url: None,
is_bot: false,
},
tag_names: vec!["Bug Report".into(), "High Priority".into()],
first_image_url: None,
bookmarked: false,
liked: true,
};
let json = serde_json::to_value(&card).unwrap();
assert_eq!(json["title"], "Bug Report: Login fails on Safari");
assert_eq!(json["view_count"], 142);
assert_eq!(json["author"]["username"], "alice");
assert_eq!(json["tag_names"][0], "Bug Report");
assert_eq!(json["is_pinned_to_top"], true);
}
#[test]
fn test_article_sort_serialize() {
let sort = ArticleSort::MostViewed;
let json = serde_json::to_value(sort).unwrap();
assert_eq!(json, "most_viewed");
}
#[test]
fn test_create_article_input_serialize() {
let input = CreateArticleInput {
channel_id: Uuid::now_v7(),
title: "New Feature Proposal".into(),
body: "I'd like to propose...".into(),
summary: Some("A proposal for...".into()),
cover_url: None,
tags: Some(vec![Uuid::now_v7()]),
};
let json = serde_json::to_value(&input).unwrap();
assert_eq!(json["title"], "New Feature Proposal");
assert_eq!(json["tags"].as_array().unwrap().len(), 1);
}
}
+96
View File
@@ -0,0 +1,96 @@
//! File / image attachment on a message — new table `message_attachment`.
//!
//! Discord-style: each message can have multiple attachments. Files are
//! uploaded to S3 first, then the URL is stored here.
use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use uuid::Uuid;
/// A file or image attachment on a message.
///
/// Maps to the `message_attachment` table. Files are uploaded to object
/// storage first, then the URL is stored here.
#[derive(Debug, Clone, Serialize, Deserialize, sqlx::FromRow)]
pub struct MessageAttachment {
pub id: Uuid,
pub message_id: Uuid,
/// Original filename as uploaded by the user.
pub filename: String,
/// MIME type: "image/png", "application/pdf", etc.
pub content_type: Option<String>,
/// File size in bytes.
pub size: i64,
/// Public URL (S3 presigned or CDN).
pub url: String,
/// S3 / object-store key for backend access.
pub storage_key: Option<String>,
/// Image / video width in pixels.
pub width: Option<i32>,
/// Image / video height in pixels.
pub height: Option<i32>,
/// Audio / video duration in seconds.
pub duration_secs: Option<f64>,
/// Blurred low-res preview for progressive loading (base64 data URI).
pub blurhash: Option<String>,
/// Whether this attachment should be rendered as a spoiler (hidden until click).
pub spoiler: bool,
pub created_at: DateTime<Utc>,
}
/// Lightweight attachment summary for list views.
///
/// Omits URL and storage_key to reduce payload size.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct AttachmentSummary {
pub id: Uuid,
pub filename: String,
pub content_type: Option<String>,
pub size: i64,
pub width: Option<i32>,
pub height: Option<i32>,
pub spoiler: bool,
}
impl From<MessageAttachment> for AttachmentSummary {
fn from(a: MessageAttachment) -> Self {
Self {
id: a.id,
filename: a.filename,
content_type: a.content_type,
size: a.size,
width: a.width,
height: a.height,
spoiler: a.spoiler,
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_attachment_conversion() {
let att = MessageAttachment {
id: Uuid::now_v7(),
message_id: Uuid::now_v7(),
filename: "photo.png".into(),
content_type: Some("image/png".into()),
size: 1024,
url: "https://cdn.example.com/photo.png".into(),
storage_key: None,
width: Some(800),
height: Some(600),
duration_secs: None,
blurhash: None,
spoiler: false,
created_at: Utc::now(),
};
let summary: AttachmentSummary = att.into();
assert_eq!(summary.filename, "photo.png");
assert_eq!(summary.size, 1024);
assert_eq!(summary.width, Some(800));
}
}
+46
View File
@@ -0,0 +1,46 @@
//! User bookmark on a message — maps to `message_bookmark` table.
//!
//! Similar to browser bookmarks: user saves a message for later reference,
//! optionally with a personal note.
use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use uuid::Uuid;
/// A user bookmark on a message for later reference.
///
/// Maps to the `message_bookmark` table. Similar to browser bookmarks,
/// optionally with a personal note.
#[derive(Debug, Clone, Serialize, Deserialize, sqlx::FromRow)]
pub struct MessageBookmark {
pub id: Uuid,
pub message_id: Uuid,
pub channel_id: Uuid,
pub user_id: Uuid,
/// Personal note the user attached to this bookmark.
pub note: Option<String>,
pub created_at: DateTime<Utc>,
pub updated_at: DateTime<Utc>,
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_bookmark_serialize() {
let bm = MessageBookmark {
id: Uuid::now_v7(),
message_id: Uuid::now_v7(),
channel_id: Uuid::now_v7(),
user_id: Uuid::now_v7(),
note: Some("Important reference".into()),
created_at: Utc::now(),
updated_at: Utc::now(),
};
let json = serde_json::to_value(&bm).unwrap();
assert_eq!(json["note"], "Important reference");
assert!(json["message_id"].is_string());
}
}
+95
View File
@@ -0,0 +1,95 @@
//! Interactive message components — maps to `message_component` table.
//!
//! Discord-style interactive elements attached to messages: buttons, select
//! menus, etc. Each component belongs to a message and has its own layout row.
//! Clicking a component emits an interaction event back to the bot/webhook.
use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use uuid::Uuid;
/// An interactive component attached to a message (button, select menu, etc.).
///
/// Maps to the `message_component` table. Each component belongs to a message
/// and has a layout row/position. User interactions emit callbacks.
#[derive(Debug, Clone, Serialize, Deserialize, sqlx::FromRow)]
pub struct MessageComponent {
pub id: Uuid,
pub message_id: Uuid,
/// Layout row within the message (0-based).
pub row: i32,
/// Position within the row (0-based).
pub position: i32,
/// "button" | "select_menu" | "text_input"
pub component_type: String,
/// Unique identifier sent back in interaction callbacks.
pub custom_id: String,
/// Display label.
pub label: Option<String>,
/// Emoji shown on the button (unicode or `:name:id`).
pub emoji: Option<String>,
/// Button style: "primary" | "secondary" | "success" | "danger" | "link"
pub style: Option<String>,
/// URL for link-style buttons.
pub url: Option<String>,
/// Whether the component is disabled.
pub disabled: bool,
/// Placeholder text for select menus.
pub placeholder: Option<String>,
/// Min/max selections for select menus.
pub min_values: Option<i32>,
pub max_values: Option<i32>,
/// Options for select menus, stored as JSON array.
pub options: Option<serde_json::Value>,
pub created_at: DateTime<Utc>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
#[serde(rename_all = "snake_case")]
pub enum ComponentType {
#[default]
Button,
SelectMenu,
TextInput,
}
impl ComponentType {
pub fn as_str(&self) -> &'static str {
match self {
Self::Button => "button",
Self::SelectMenu => "select_menu",
Self::TextInput => "text_input",
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_component_serialize() {
let c = MessageComponent {
id: Uuid::now_v7(),
message_id: Uuid::now_v7(),
row: 0,
position: 0,
component_type: ComponentType::Button.as_str().into(),
custom_id: "btn_approve".into(),
label: Some("Approve".into()),
emoji: Some("".into()),
style: Some("success".into()),
url: None,
disabled: false,
placeholder: None,
min_values: None,
max_values: None,
options: None,
created_at: Utc::now(),
};
let json = serde_json::to_value(&c).unwrap();
assert_eq!(json["component_type"], "button");
assert_eq!(json["style"], "success");
}
}
+77
View File
@@ -0,0 +1,77 @@
//! Message drafts — maps to `message_draft` table.
//!
//! Stores unsent messages so they survive browser refreshes and sync across
//! the user's connected devices. One draft per (channel, user, optional thread).
//! Drafts are upserted on every keystroke debounce and deleted on send.
use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use uuid::Uuid;
/// A user's unsent message draft in a channel.
#[derive(Debug, Clone, Serialize, Deserialize, sqlx::FromRow)]
pub struct MessageDraft {
pub id: Uuid,
pub channel_id: Uuid,
pub user_id: Uuid,
/// Thread the draft belongs to (NULL = top-level).
pub thread_id: Option<Uuid>,
/// Message this draft is replying to (NULL = new message).
pub reply_to_message_id: Option<Uuid>,
/// Plain text or markdown body.
pub body: String,
/// Extensible metadata (attachments to be uploaded, etc.).
pub metadata: Option<serde_json::Value>,
pub created_at: DateTime<Utc>,
pub updated_at: DateTime<Utc>,
}
/// Input for upserting a draft (sent from client on debounced keystroke).
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct DraftUpsertInput {
pub channel_id: Uuid,
pub thread_id: Option<Uuid>,
pub reply_to_message_id: Option<Uuid>,
pub body: String,
pub metadata: Option<serde_json::Value>,
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_draft_upsert_input_serialize() {
let input = DraftUpsertInput {
channel_id: Uuid::now_v7(),
thread_id: None,
reply_to_message_id: None,
body: "hello, this is a draft".to_string(),
metadata: None,
};
let json = serde_json::to_value(&input).unwrap();
assert_eq!(json["body"], "hello, this is a draft");
assert!(json["thread_id"].is_null());
}
#[test]
fn test_draft_serialize() {
let draft = MessageDraft {
id: Uuid::now_v7(),
channel_id: Uuid::now_v7(),
user_id: Uuid::now_v7(),
thread_id: Some(Uuid::now_v7()),
reply_to_message_id: None,
body: "draft body".to_string(),
metadata: Some(serde_json::json!({"pending_attachments": []})),
created_at: Utc::now(),
updated_at: Utc::now(),
};
let json = serde_json::to_value(&draft).unwrap();
assert_eq!(json["body"], "draft body");
assert!(json["thread_id"].is_string());
assert!(json["metadata"]["pending_attachments"].is_array());
}
}
+77
View File
@@ -0,0 +1,77 @@
//! Message edit history — maps to `message_edit` table.
//!
//! Immutable append-only log of every edit to a message.
//! Used for audit trails, "edited" indicators with hover-to-see-original,
//! and compliance / moderation review.
use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use uuid::Uuid;
/// One edit record. Stored every time a message body is modified.
#[derive(Debug, Clone, Serialize, Deserialize, sqlx::FromRow)]
pub struct MessageEdit {
pub id: Uuid,
pub message_id: Uuid,
/// Who made the edit (usually the author; can be a moderator).
pub edited_by: Uuid,
/// Body content before the edit.
pub old_body: String,
/// Body content after the edit.
pub new_body: String,
pub edited_at: DateTime<Utc>,
}
/// Lightweight summary for the "edited" tooltip (no full body).
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct EditSummary {
pub edit_count: i64,
pub last_edited_at: Option<DateTime<Utc>>,
pub last_edited_by: Option<Uuid>,
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_message_edit_serialize() {
let edit = MessageEdit {
id: Uuid::now_v7(),
message_id: Uuid::now_v7(),
edited_by: Uuid::now_v7(),
old_body: "before edit".to_string(),
new_body: "after edit".to_string(),
edited_at: Utc::now(),
};
let json = serde_json::to_value(&edit).unwrap();
assert_eq!(json["old_body"], "before edit");
assert_eq!(json["new_body"], "after edit");
}
#[test]
fn test_edit_summary_serialize() {
let summary = EditSummary {
edit_count: 3,
last_edited_at: Some(Utc::now()),
last_edited_by: Some(Uuid::now_v7()),
};
let json = serde_json::to_value(&summary).unwrap();
assert_eq!(json["edit_count"], 3);
}
#[test]
fn test_edit_summary_no_edits() {
let summary = EditSummary {
edit_count: 0,
last_edited_at: None,
last_edited_by: None,
};
let json = serde_json::to_value(&summary).unwrap();
assert_eq!(json["edit_count"], 0);
assert!(json["last_edited_at"].is_null());
}
}
+163
View File
@@ -0,0 +1,163 @@
//! Rich embed on a message — new table `message_embed` + `message_embed_field`.
//!
//! Discord-style embeds: link previews, rich cards with title/description/
//! thumbnail/image/footer/fields. Generated by the server (link preview)
//! or sent by bots/webhooks.
use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use uuid::Uuid;
/// A single embed attached to a message.
/// One message can have multiple embeds (e.g. link preview + bot embed).
#[derive(Debug, Clone, Serialize, Deserialize, sqlx::FromRow)]
pub struct MessageEmbed {
pub id: Uuid,
pub message_id: Uuid,
/// "link" | "article" | "image" | "video" | "rich"
pub embed_type: String,
pub title: Option<String>,
pub description: Option<String>,
pub url: Option<String>,
/// Embed accent color as integer (Discord format: 0xRRGGBB).
pub color: Option<i32>,
// Media
/// Main image URL.
pub image_url: Option<String>,
pub image_width: Option<i32>,
pub image_height: Option<i32>,
/// Small thumbnail URL.
pub thumbnail_url: Option<String>,
pub thumbnail_width: Option<i32>,
pub thumbnail_height: Option<i32>,
/// Video URL (for video embeds).
pub video_url: Option<String>,
pub video_width: Option<i32>,
pub video_height: Option<i32>,
// Footer
pub author_name: Option<String>,
pub author_url: Option<String>,
pub author_icon_url: Option<String>,
pub footer_text: Option<String>,
pub footer_icon_url: Option<String>,
/// Provider name (e.g. "YouTube", "GitHub").
pub provider_name: Option<String>,
pub provider_url: Option<String>,
pub created_at: DateTime<Utc>,
}
/// A key-value field within an embed (Discord-style field rows).
/// Stored in a separate table for flexibility.
#[derive(Debug, Clone, Serialize, Deserialize, sqlx::FromRow)]
pub struct MessageEmbedField {
pub id: Uuid,
pub embed_id: Uuid,
pub name: String,
pub value: String,
/// Whether this field should display inline (side by side with other inline fields).
pub inline: bool,
pub position: i32,
}
/// An embed with its fields resolved in a single structure.
///
/// Convenience type returned by read APIs, joining embed and fields.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct EmbedDetail {
#[serde(flatten)]
pub embed: MessageEmbed,
pub fields: Vec<MessageEmbedField>,
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_embed_serialize() {
let embed = MessageEmbed {
id: Uuid::now_v7(),
message_id: Uuid::now_v7(),
embed_type: "link".into(),
title: Some("Example".into()),
description: Some("A link preview".into()),
url: Some("https://example.com".into()),
color: Some(0x00FF00),
image_url: None,
image_width: None,
image_height: None,
thumbnail_url: None,
thumbnail_width: None,
thumbnail_height: None,
video_url: None,
video_width: None,
video_height: None,
author_name: None,
author_url: None,
author_icon_url: None,
footer_text: None,
footer_icon_url: None,
provider_name: Some("GitHub".into()),
provider_url: None,
created_at: Utc::now(),
};
let json = serde_json::to_value(&embed).unwrap();
assert_eq!(json["embed_type"], "link");
assert_eq!(json["title"], "Example");
}
#[test]
fn test_embed_detail_serialize() {
let embed = MessageEmbed {
id: Uuid::now_v7(),
message_id: Uuid::now_v7(),
embed_type: "rich".into(),
title: Some("Article".into()),
description: None,
url: None,
color: None,
image_url: None,
image_width: None,
image_height: None,
thumbnail_url: None,
thumbnail_width: None,
thumbnail_height: None,
video_url: None,
video_width: None,
video_height: None,
author_name: None,
author_url: None,
author_icon_url: None,
footer_text: None,
footer_icon_url: None,
provider_name: None,
provider_url: None,
created_at: Utc::now(),
};
let field = MessageEmbedField {
id: Uuid::now_v7(),
embed_id: embed.id,
name: "Key".into(),
value: "Value".into(),
inline: true,
position: 0,
};
let detail = EmbedDetail {
embed,
fields: vec![field],
};
let json = serde_json::to_value(&detail).unwrap();
assert_eq!(json["embed_type"], "rich");
assert!(json["fields"].is_array());
}
}
+48
View File
@@ -0,0 +1,48 @@
//! Message forwarding trail — maps to `message_forward` table.
//!
//! When a user forwards a message from one channel to another, this table
//! records the provenance. The forwarded message is a new message with a
//! copy of the original body, linked back via `source_message_id`.
use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use uuid::Uuid;
/// A forwarded message linking the copy to the original.
///
/// Maps to the `message_forward` table. Records provenance when a user
/// forwards a message from one channel to another.
#[derive(Debug, Clone, Serialize, Deserialize, sqlx::FromRow)]
pub struct MessageForward {
pub id: Uuid,
/// The new (forwarded) message.
pub message_id: Uuid,
/// The original message being forwarded.
pub source_message_id: Uuid,
/// The channel the original message came from.
pub source_channel_id: Uuid,
/// Who forwarded the message.
pub forwarded_by: Uuid,
pub created_at: DateTime<Utc>,
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_forward_serialize() {
let f = MessageForward {
id: Uuid::now_v7(),
message_id: Uuid::now_v7(),
source_message_id: Uuid::now_v7(),
source_channel_id: Uuid::now_v7(),
forwarded_by: Uuid::now_v7(),
created_at: Utc::now(),
};
let json = serde_json::to_value(&f).unwrap();
assert!(json["source_message_id"].is_string());
assert!(json["source_channel_id"].is_string());
}
}
+123
View File
@@ -0,0 +1,123 @@
//! @mention tracking — maps to `message_mention` table.
//!
//! Parsed from message body on send. Used for notification and
//! "mentions" feed.
use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use uuid::Uuid;
/// An @mention of a user in a message.
///
/// Maps to the `message_mention` table. Parsed from message body on send
/// and used for notifications and the mentions feed.
#[derive(Debug, Clone, Serialize, Deserialize, sqlx::FromRow)]
pub struct MessageMention {
pub id: Uuid,
pub message_id: Uuid,
pub channel_id: Uuid,
pub mentioned_user_id: Uuid,
pub mentioned_by: Uuid,
/// When the mentioned user read the notification.
pub read_at: Option<DateTime<Utc>>,
pub created_at: DateTime<Utc>,
}
/// Parse @username mentions from a message body.
/// Returns unique usernames (without the `@` prefix).
///
/// Matches `@word` where word is `[a-zA-Z0-9_-]+`, min 2 chars.
/// Ignores `@@` (escaped) and mentions inside code spans/blocks.
pub fn parse_mentions(body: &str) -> Vec<String> {
let mut seen = std::collections::HashSet::new();
let mut mentions = Vec::new();
// Simple state machine: skip content inside backtick spans.
let mut in_code = false;
let bytes = body.as_bytes();
let mut i = 0;
while i < bytes.len() {
if bytes[i] == b'`' {
in_code = !in_code;
i += 1;
continue;
}
if !in_code && bytes[i] == b'@' {
// Skip escaped @@
if i + 1 < bytes.len() && bytes[i + 1] == b'@' {
i += 2;
continue;
}
let start = i + 1;
let mut end = start;
while end < bytes.len()
&& (bytes[end].is_ascii_alphanumeric() || bytes[end] == b'_' || bytes[end] == b'-')
{
end += 1;
}
let len = end - start;
if len >= 2 {
let name = body[start..end].to_lowercase();
if seen.insert(name.clone()) {
mentions.push(name);
}
}
i = end;
} else {
i += 1;
}
}
mentions
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_parse_mentions_basic() {
let m = parse_mentions("hey @alice, cc @bob");
assert_eq!(m, vec!["alice".to_string(), "bob".to_string()]);
}
#[test]
fn test_parse_mentions_dedup() {
let m = parse_mentions("@alice said hi to @alice");
assert_eq!(m, vec!["alice".to_string()]);
}
#[test]
fn test_parse_mentions_case_insensitive() {
let m = parse_mentions("@Alice and @ALICE");
assert_eq!(m, vec!["alice".to_string()]);
}
#[test]
fn test_parse_mentions_skip_code_span() {
let m = parse_mentions("use `@here` to notify @alice");
assert_eq!(m, vec!["alice".to_string()]);
}
#[test]
fn test_parse_mentions_skip_escaped() {
let m = parse_mentions("@@alice is not a mention, but @bob is");
assert_eq!(m, vec!["bob".to_string()]);
}
#[test]
fn test_parse_mentions_short_name_ignored() {
let m = parse_mentions("@a is too short, @ab is ok");
assert_eq!(m, vec!["ab".to_string()]);
}
#[test]
fn test_parse_mentions_empty() {
assert!(parse_mentions("no mentions here").is_empty());
assert!(parse_mentions("").is_empty());
}
}
+97
View File
@@ -0,0 +1,97 @@
//! Notification delivery tracking — maps to `message_notification` table.
//!
//! Records when a message triggers a notification for a user (mention, reply,
//! thread activity, etc.) and tracks the delivery/read lifecycle.
//! Separate from `message_mention` which only covers @mentions.
use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use uuid::Uuid;
/// A notification triggered for a user by a message.
///
/// Maps to the `message_notification` table. Records when a message triggers
/// a notification (mention, reply, thread activity) and tracks delivery/read.
#[derive(Debug, Clone, Serialize, Deserialize, sqlx::FromRow)]
pub struct MessageNotification {
pub id: Uuid,
pub message_id: Uuid,
pub channel_id: Uuid,
pub user_id: Uuid,
/// "mention" | "reply" | "thread" | "watch"
pub reason: String,
/// "pending" | "delivered" | "read" | "dismissed"
pub status: String,
/// Channel of delivery: "push" | "email" | "in_app"
pub delivery_channel: Option<String>,
pub delivered_at: Option<DateTime<Utc>>,
pub read_at: Option<DateTime<Utc>>,
pub created_at: DateTime<Utc>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
#[serde(rename_all = "snake_case")]
pub enum NotificationReason {
#[default]
Mention,
Reply,
Thread,
Watch,
}
impl NotificationReason {
pub fn as_str(&self) -> &'static str {
match self {
Self::Mention => "mention",
Self::Reply => "reply",
Self::Thread => "thread",
Self::Watch => "watch",
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
#[serde(rename_all = "snake_case")]
pub enum NotificationStatus {
#[default]
Pending,
Delivered,
Read,
Dismissed,
}
impl NotificationStatus {
pub fn as_str(&self) -> &'static str {
match self {
Self::Pending => "pending",
Self::Delivered => "delivered",
Self::Read => "read",
Self::Dismissed => "dismissed",
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_notification_serialize() {
let n = MessageNotification {
id: Uuid::now_v7(),
message_id: Uuid::now_v7(),
channel_id: Uuid::now_v7(),
user_id: Uuid::now_v7(),
reason: NotificationReason::Mention.as_str().into(),
status: NotificationStatus::Pending.as_str().into(),
delivery_channel: Some("push".into()),
delivered_at: None,
read_at: None,
created_at: Utc::now(),
};
let json = serde_json::to_value(&n).unwrap();
assert_eq!(json["reason"], "mention");
assert_eq!(json["status"], "pending");
}
}
+60
View File
@@ -0,0 +1,60 @@
//! Pinned message management — maps to `message_pin` table.
//!
//! A channel can have multiple pinned messages with explicit ordering.
//! Unlike the `message.pinned` boolean (which just marks the row),
//! this table tracks *who* pinned, *when*, and the display *position*.
use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use uuid::Uuid;
/// One pinned message entry. Ordered by `position` ascending.
#[derive(Debug, Clone, Serialize, Deserialize, sqlx::FromRow)]
pub struct MessagePin {
pub id: Uuid,
pub channel_id: Uuid,
pub message_id: Uuid,
/// Who pinned this message.
pub pinned_by: Uuid,
/// Display position in the pinned list (0 = top).
pub position: i32,
pub created_at: DateTime<Utc>,
}
/// Summary view returned by list-pins APIs (joined with message content).
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct PinDetail {
#[serde(flatten)]
pub pin: MessagePin,
pub message_body: String,
pub message_author_id: Uuid,
pub message_created_at: DateTime<Utc>,
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_pin_detail_serialize() {
let pin = MessagePin {
id: Uuid::now_v7(),
channel_id: Uuid::now_v7(),
message_id: Uuid::now_v7(),
pinned_by: Uuid::now_v7(),
position: 0,
created_at: Utc::now(),
};
let detail = PinDetail {
pin,
message_body: "important announcement".to_string(),
message_author_id: Uuid::now_v7(),
message_created_at: Utc::now(),
};
let json = serde_json::to_value(&detail).unwrap();
assert_eq!(json["position"], 0);
assert_eq!(json["message_body"], "important announcement");
}
}
+170
View File
@@ -0,0 +1,170 @@
//! Poll on a message — new tables `message_poll`, `message_poll_option`,
//! `message_poll_vote`.
//!
//! Discord-style polls: attached to a message, with multiple options,
//! optional multi-vote, and an expiry time.
use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use uuid::Uuid;
/// The poll itself (one per message, optional).
#[derive(Debug, Clone, Serialize, Deserialize, sqlx::FromRow)]
pub struct MessagePoll {
pub id: Uuid,
pub message_id: Uuid,
/// The question displayed to voters.
pub question: String,
/// Whether users can select multiple options.
pub allow_multiselect: bool,
/// Maximum number of options a user can select (NULL = unlimited when multiselect).
pub max_selections: Option<i32>,
/// When voting closes (NULL = no expiry).
pub expires_at: Option<DateTime<Utc>>,
/// Total number of votes cast (denormalized for fast reads).
pub total_votes: i64,
pub created_at: DateTime<Utc>,
pub updated_at: DateTime<Utc>,
}
/// A single selectable option within a poll.
#[derive(Debug, Clone, Serialize, Deserialize, sqlx::FromRow)]
pub struct MessagePollOption {
pub id: Uuid,
pub poll_id: Uuid,
/// Display text for this option.
pub text: String,
/// Optional emoji prefix (Discord-style).
pub emoji: Option<String>,
/// Number of votes this option received (denormalized).
pub vote_count: i64,
/// Display order.
pub position: i32,
}
/// A single vote cast by a user.
#[derive(Debug, Clone, Serialize, Deserialize, sqlx::FromRow)]
pub struct MessagePollVote {
pub id: Uuid,
pub poll_id: Uuid,
pub option_id: Uuid,
pub user_id: Uuid,
pub created_at: DateTime<Utc>,
}
/// Aggregated poll results for API responses.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct PollResult {
pub poll: MessagePoll,
pub options: Vec<PollOptionResult>,
/// Which options the current user voted for (empty if not voted).
pub my_votes: Vec<Uuid>,
/// Whether the poll has expired.
pub is_expired: bool,
}
/// Option with its vote count and percentage.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct PollOptionResult {
#[serde(flatten)]
pub option: MessagePollOption,
/// Percentage of total votes (0.0100.0), rounded to 1 decimal.
pub percentage: f64,
}
impl PollResult {
/// Compute percentages from total_votes.
pub fn from_poll(
poll: MessagePoll,
options: Vec<MessagePollOption>,
my_votes: Vec<Uuid>,
) -> Self {
let total = poll.total_votes.max(1) as f64;
let now = Utc::now();
let is_expired = poll.expires_at.is_some_and(|exp| now >= exp);
let options = options
.into_iter()
.map(|opt| {
let pct = if poll.total_votes > 0 {
(opt.vote_count as f64 / total * 100.0 * 10.0).round() / 10.0
} else {
0.0
};
PollOptionResult {
option: opt,
percentage: pct,
}
})
.collect();
Self {
poll,
options,
my_votes,
is_expired,
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_poll_result_percentages() {
let poll = MessagePoll {
id: Uuid::now_v7(),
message_id: Uuid::now_v7(),
question: "Best language?".to_string(),
allow_multiselect: false,
max_selections: None,
expires_at: None,
total_votes: 10,
created_at: Utc::now(),
updated_at: Utc::now(),
};
let options = vec![
MessagePollOption {
id: Uuid::now_v7(),
poll_id: poll.id,
text: "Rust".to_string(),
emoji: None,
vote_count: 7,
position: 0,
},
MessagePollOption {
id: Uuid::now_v7(),
poll_id: poll.id,
text: "Go".to_string(),
emoji: None,
vote_count: 3,
position: 1,
},
];
let result = PollResult::from_poll(poll, options, vec![]);
assert!(!result.is_expired);
assert_eq!(result.options[0].percentage, 70.0);
assert_eq!(result.options[1].percentage, 30.0);
}
#[test]
fn test_poll_result_zero_votes() {
let poll = MessagePoll {
id: Uuid::now_v7(),
message_id: Uuid::now_v7(),
question: "Empty poll".to_string(),
allow_multiselect: false,
max_selections: None,
expires_at: None,
total_votes: 0,
created_at: Utc::now(),
updated_at: Utc::now(),
};
let result = PollResult::from_poll(poll, vec![], vec![]);
assert!(result.options.is_empty());
}
}
+66
View File
@@ -0,0 +1,66 @@
//! Emoji reaction on a message — maps to `message_reaction` table.
//!
//! One row per (user, message, emoji). `content` stores the emoji string
//! (Unicode emoji or custom `:name:id` format like Discord).
use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use uuid::Uuid;
/// A single emoji reaction on a message by a user.
///
/// Maps to the `message_reaction` table. One row per (user, message, emoji).
#[derive(Debug, Clone, Serialize, Deserialize, sqlx::FromRow)]
pub struct MessageReaction {
pub id: Uuid,
pub message_id: Uuid,
pub channel_id: Uuid,
pub user_id: Uuid,
/// Emoji string: "👍", "🎉", or custom ":appks:01909a..."
pub content: String,
pub created_at: DateTime<Utc>,
}
/// Aggregated reaction count for a single emoji on a message.
/// Returned in [`MessageDetail::reactions`] and list APIs.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ReactionCount {
pub content: String,
pub count: i64,
/// Whether the current user reacted with this emoji.
pub me: bool,
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_reaction_serialize() {
let reaction = MessageReaction {
id: Uuid::now_v7(),
message_id: Uuid::now_v7(),
channel_id: Uuid::now_v7(),
user_id: Uuid::now_v7(),
content: "👍".into(),
created_at: Utc::now(),
};
let json = serde_json::to_value(&reaction).unwrap();
assert_eq!(json["content"], "👍");
}
#[test]
fn test_reaction_count_serialize() {
let count = ReactionCount {
content: "🎉".into(),
count: 3,
me: true,
};
let json = serde_json::to_value(&count).unwrap();
assert_eq!(json["content"], "🎉");
assert_eq!(json["count"], 3);
assert_eq!(json["me"], true);
}
}
+92
View File
@@ -0,0 +1,92 @@
//! Per-user read state — maps to `message_read_state` table.
//!
//! Tracks the last message each user has read in each channel.
//! Used for unread badges, "mark as read", and notification suppression.
//! One row per (channel_id, user_id), upserted on each read.
use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use uuid::Uuid;
/// A user's read progress in one channel.
#[derive(Debug, Clone, Serialize, Deserialize, sqlx::FromRow)]
pub struct MessageReadState {
pub id: Uuid,
pub channel_id: Uuid,
pub user_id: Uuid,
/// The last message id the user has read (cursor).
pub last_read_message_id: Option<Uuid>,
/// When the user last opened / scrolled through this channel.
pub last_read_at: Option<DateTime<Utc>>,
/// Total unread message count (denormalized for fast badge display).
pub unread_count: i64,
/// Total unread @mentions for this channel (denormalized).
pub unread_mentions: i64,
pub created_at: DateTime<Utc>,
pub updated_at: DateTime<Utc>,
}
/// Summary of a user's read state for the client-side channel list.
///
/// Includes unread badge count and mention count for a single channel.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ReadStateSummary {
pub channel_id: Uuid,
pub unread_count: i64,
pub unread_mentions: i64,
pub has_unread: bool,
}
impl From<MessageReadState> for ReadStateSummary {
fn from(s: MessageReadState) -> Self {
Self {
channel_id: s.channel_id,
unread_count: s.unread_count,
unread_mentions: s.unread_mentions,
has_unread: s.unread_count > 0,
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_read_state_summary_conversion() {
let state = MessageReadState {
id: Uuid::now_v7(),
channel_id: Uuid::now_v7(),
user_id: Uuid::now_v7(),
last_read_message_id: None,
last_read_at: None,
unread_count: 5,
unread_mentions: 2,
created_at: Utc::now(),
updated_at: Utc::now(),
};
let summary: ReadStateSummary = state.into();
assert!(summary.has_unread);
assert_eq!(summary.unread_count, 5);
assert_eq!(summary.unread_mentions, 2);
}
#[test]
fn test_read_state_summary_no_unread() {
let state = MessageReadState {
id: Uuid::now_v7(),
channel_id: Uuid::now_v7(),
user_id: Uuid::now_v7(),
last_read_message_id: None,
last_read_at: None,
unread_count: 0,
unread_mentions: 0,
created_at: Utc::now(),
updated_at: Utc::now(),
};
let summary: ReadStateSummary = state.into();
assert!(!summary.has_unread);
}
}
+82
View File
@@ -0,0 +1,82 @@
//! Scheduled messages — maps to `message_scheduled` table.
//!
//! A message that the user has composed but wants to send at a future time.
//! A background job picks up rows where `scheduled_at <= now()` and dispatches
//! them through the normal send path.
use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use uuid::Uuid;
/// A message scheduled to be sent at a future time.
///
/// Maps to the `message_scheduled` table. A background job picks up rows
/// where `scheduled_at <= now()` and dispatches them through the normal send path.
#[derive(Debug, Clone, Serialize, Deserialize, sqlx::FromRow)]
pub struct MessageScheduled {
pub id: Uuid,
pub channel_id: Uuid,
pub author_id: Uuid,
pub thread_id: Option<Uuid>,
pub reply_to_message_id: Option<Uuid>,
pub body: String,
pub metadata: Option<serde_json::Value>,
/// When the message should be sent.
pub scheduled_at: DateTime<Utc>,
/// "pending" | "sent" | "cancelled" | "failed"
pub status: String,
/// Set after the message is dispatched; points to the sent `message.id`.
pub sent_message_id: Option<Uuid>,
/// Error message if dispatch failed.
pub error: Option<String>,
pub created_at: DateTime<Utc>,
pub updated_at: DateTime<Utc>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
#[serde(rename_all = "snake_case")]
pub enum ScheduledStatus {
#[default]
Pending,
Sent,
Cancelled,
Failed,
}
impl ScheduledStatus {
pub fn as_str(&self) -> &'static str {
match self {
Self::Pending => "pending",
Self::Sent => "sent",
Self::Cancelled => "cancelled",
Self::Failed => "failed",
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_scheduled_serialize() {
let s = MessageScheduled {
id: Uuid::now_v7(),
channel_id: Uuid::now_v7(),
author_id: Uuid::now_v7(),
thread_id: None,
reply_to_message_id: None,
body: "Good morning everyone!".into(),
metadata: None,
scheduled_at: Utc::now(),
status: ScheduledStatus::Pending.as_str().into(),
sent_message_id: None,
error: None,
created_at: Utc::now(),
updated_at: Utc::now(),
};
let json = serde_json::to_value(&s).unwrap();
assert_eq!(json["status"], "pending");
}
}
+56
View File
@@ -0,0 +1,56 @@
//! Sticker attachment on a message — maps to `message_sticker` table.
//!
//! Large sticker images sent in messages, distinct from emoji reactions.
//! Stickers are either workspace-level (custom, defined in appks) or
//! system-level (built-in sticker packs).
use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use uuid::Uuid;
/// A sticker attached to a message.
///
/// Maps to the `message_sticker` table. Stickers are larger than emoji
/// and can be workspace-level (custom) or system-level (built-in packs).
#[derive(Debug, Clone, Serialize, Deserialize, sqlx::FromRow)]
pub struct MessageSticker {
pub id: Uuid,
pub message_id: Uuid,
/// References a sticker defined in appks (workspace or system sticker).
pub sticker_id: Uuid,
/// Sticker name at time of send (snapshot for history).
pub name: String,
/// Image URL (snapshot).
pub image_url: String,
/// "png" | "apng" | "lottie"
pub format_type: String,
/// Pack name (e.g. "Wumpus" or workspace name).
pub pack_name: Option<String>,
/// Search tags for discovery.
pub tags: Option<String>,
pub created_at: DateTime<Utc>,
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_sticker_serialize() {
let s = MessageSticker {
id: Uuid::now_v7(),
message_id: Uuid::now_v7(),
sticker_id: Uuid::now_v7(),
name: "Hype!".into(),
image_url: "https://cdn.example.com/stickers/hype.png".into(),
format_type: "png".into(),
pack_name: Some("Wumpus".into()),
tags: Some("excited,hype".into()),
created_at: Utc::now(),
};
let json = serde_json::to_value(&s).unwrap();
assert_eq!(json["name"], "Hype!");
assert_eq!(json["format_type"], "png");
}
}
+60
View File
@@ -0,0 +1,60 @@
//! Thread metadata — maps to `message_thread` table.
//!
//! A thread is anchored by a root message. Reply messages in the same thread
//! set `message.thread_id = message_thread.id`.
use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use uuid::Uuid;
/// A message thread anchored by a root message.
///
/// Maps to the `message_thread` table. Reply messages set
/// `message.thread_id = message_thread.id` to join the thread.
#[derive(Debug, Clone, Serialize, Deserialize, sqlx::FromRow)]
pub struct MessageThread {
pub id: Uuid,
pub channel_id: Uuid,
/// The first message that started this thread.
pub root_message_id: Uuid,
pub created_by: Uuid,
pub replies_count: i64,
pub participants_count: i64,
pub last_reply_message_id: Option<Uuid>,
pub last_reply_at: Option<DateTime<Utc>>,
/// Forum-style: mark thread as resolved / answered.
pub resolved: bool,
pub resolved_by: Option<Uuid>,
pub resolved_at: Option<DateTime<Utc>>,
pub created_at: DateTime<Utc>,
pub updated_at: DateTime<Utc>,
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_thread_serialize() {
let thread = MessageThread {
id: Uuid::now_v7(),
channel_id: Uuid::now_v7(),
root_message_id: Uuid::now_v7(),
created_by: Uuid::now_v7(),
replies_count: 5,
participants_count: 3,
last_reply_message_id: None,
last_reply_at: None,
resolved: false,
resolved_by: None,
resolved_at: None,
created_at: Utc::now(),
updated_at: Utc::now(),
};
let json = serde_json::to_value(&thread).unwrap();
assert_eq!(json["replies_count"], 5);
assert_eq!(json["resolved"], false);
assert!(json["id"].is_string());
}
}
+71
View File
@@ -0,0 +1,71 @@
//! Thread participant membership — maps to `message_thread_participant` table.
//!
//! Tracks which users are part of a thread. A user becomes a participant when
//! they reply in a thread, get @mentioned, or are explicitly added.
//! Without this table, `message_thread.participants_count` is un-verifiable.
use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use uuid::Uuid;
/// A user participating in a thread.
///
/// Maps to the `message_thread_participant` table. Users become participants
/// when they reply, get @mentioned, or are explicitly added.
#[derive(Debug, Clone, Serialize, Deserialize, sqlx::FromRow)]
pub struct MessageThreadParticipant {
pub id: Uuid,
pub thread_id: Uuid,
pub user_id: Uuid,
/// How the user joined the thread.
pub joined_reason: Option<String>,
pub last_read_message_id: Option<Uuid>,
pub last_read_at: Option<DateTime<Utc>>,
pub joined_at: DateTime<Utc>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
#[serde(rename_all = "snake_case")]
pub enum JoinReason {
/// User sent a reply in the thread.
#[default]
Reply,
/// User was @mentioned in a thread message.
Mentioned,
/// User was explicitly added by another participant.
Added,
/// User joined the thread themselves.
Joined,
}
impl JoinReason {
pub fn as_str(&self) -> &'static str {
match self {
Self::Reply => "reply",
Self::Mentioned => "mentioned",
Self::Added => "added",
Self::Joined => "joined",
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_participant_serialize() {
let p = MessageThreadParticipant {
id: Uuid::now_v7(),
thread_id: Uuid::now_v7(),
user_id: Uuid::now_v7(),
joined_reason: Some(JoinReason::Reply.as_str().into()),
last_read_message_id: None,
last_read_at: None,
joined_at: Utc::now(),
};
let json = serde_json::to_value(&p).unwrap();
assert_eq!(json["joined_reason"], "reply");
}
}
+43
View File
@@ -0,0 +1,43 @@
pub mod message;
pub mod message_article;
pub mod message_attachment;
pub mod message_bookmark;
pub mod message_component;
pub mod message_draft;
pub mod message_edit;
pub mod message_embed;
pub mod message_forward;
pub mod message_mention;
pub mod message_notification;
pub mod message_pin;
pub mod message_poll;
pub mod message_reaction;
pub mod message_read_state;
pub mod message_scheduled;
pub mod message_sticker;
pub mod message_thread;
pub mod message_thread_participant;
pub use message::{Message, MessageDetail, MessageType};
pub use message_article::{
ArticleCard, ArticleSort, CreateArticleInput, MessageArticle, UpdateArticleInput,
};
pub use message_attachment::{AttachmentSummary, MessageAttachment};
pub use message_bookmark::MessageBookmark;
pub use message_component::{ComponentType, MessageComponent};
pub use message_draft::{DraftUpsertInput, MessageDraft};
pub use message_edit::{EditSummary, MessageEdit};
pub use message_embed::{EmbedDetail, MessageEmbed, MessageEmbedField};
pub use message_forward::MessageForward;
pub use message_mention::MessageMention;
pub use message_notification::{MessageNotification, NotificationReason, NotificationStatus};
pub use message_pin::{MessagePin, PinDetail};
pub use message_poll::{
MessagePoll, MessagePollOption, MessagePollVote, PollOptionResult, PollResult,
};
pub use message_reaction::{MessageReaction, ReactionCount};
pub use message_read_state::{MessageReadState, ReadStateSummary};
pub use message_scheduled::{MessageScheduled, ScheduledStatus};
pub use message_sticker::MessageSticker;
pub use message_thread::MessageThread;
pub use message_thread_participant::{JoinReason, MessageThreadParticipant};