//! Message service — the business logic layer connecting auth, permissions, //! persistence, and real-time broadcast. //! //! Validates every operation through the gRPC permission chain before //! touching the database or broadcasting to the room. use std::sync::Arc; use std::time::{Duration, Instant}; use chrono::Utc; use dashmap::DashMap; use tracing; use uuid::Uuid; use crate::auth::Authenticator; use crate::models::message::Message; use crate::pb::im::{ CheckPermissionRequest, EnsureReadableRequest, ImPermission, IsMemberRequest, ResolveChannelRequest, }; use crate::repo::message_repo::MessageRepo; use crate::rpc::AppksClients; use crate::socket::namespace::NamespaceManager; use crate::socket::socket::Socket; use crate::{ImksError, ImksResult}; /// Central business-logic service for message operations. /// /// Every mutating operation performs the following checks in order: /// 1. JWT authentication (via `Authenticator`) /// 2. Nonce deduplication (prevent duplicate sends) /// 3. Rate limiting (per-user, per-channel sliding window) /// 4. Message body size validation (max 100 KB) /// 5. Channel readability (`PermissionService.EnsureReadable`) /// 6. Channel status (`ResolveChannel` → read_only / archived) /// 7. Channel membership (`MemberService.IsMember`) /// 8. Operation-specific permission (`PermissionService.CheckPermission`) /// 9. Ownership validation (for edit/delete of others' messages) /// /// Only after all gates pass does the operation reach the database /// and the broadcast adapter. #[derive(Clone)] pub struct MessageService { pub(crate) repo: MessageRepo, pub(crate) auth: Arc, pub(crate) clients: AppksClients, pub(crate) namespaces: Arc, /// Rate limiter: stores timestamps of recent sends per (user, channel). rate_limits: Arc>>, /// Nonce dedup cache: nonce → first-seen timestamp. Uses TTL eviction. nonces: Arc>, /// Max message body length in bytes. max_body_size: usize, /// Per-user, per-channel messages allowed in the rate window. rate_limit_count: usize, /// Rate-limiting window duration. rate_window: Duration, /// Nonce TTL before reuse is allowed again. nonce_ttl: Duration, } impl MessageService { /// Create a new message service. /// /// Internally initializes the JWT `Authenticator` from the token client /// so that `authenticate_socket()` can verify JWT tokens during `on_connect`. pub async fn new( repo: MessageRepo, clients: AppksClients, namespaces: Arc, ) -> ImksResult { let auth = Arc::new(Authenticator::new(clients.token.clone()).await?); Ok(Self { repo, auth, clients, namespaces, rate_limits: Arc::new(DashMap::new()), nonces: Arc::new(DashMap::new()), max_body_size: 100_000, rate_limit_count: 10, rate_window: Duration::from_secs(10), nonce_ttl: Duration::from_secs(300), }) } pub fn namespaces(&self) -> &NamespaceManager { &self.namespaces } /// Verify a JWT token and attach the authenticated `user_id` to the socket. /// Call this from `on_connect` before registering any event handlers. pub fn authenticate_socket( &self, socket: &Socket, auth_data: Option<&serde_json::Value>, ) -> ImksResult<()> { let token = auth_data .and_then(|v| v.get("token")) .and_then(|v| v.as_str()) .ok_or_else(|| ImksError::Auth("Missing auth token".into()))?; let claims = self.auth.verify_local(token)?; if !claims.has_scope("im:read") && !claims.has_scope("im:write") { return Err(ImksError::Auth("Token lacks im scope".into())); } let user_id = Uuid::parse_str(&claims.sub) .map_err(|_| ImksError::Auth(format!("Invalid user ID in token: {}", claims.sub)))?; socket.set_user_id(user_id); tracing::info!(user_id = %user_id, socket_sid = %socket.sid, "Socket authenticated"); Ok(()) } /// Handle `channel:join` event by adding the socket to the channel room. pub async fn join_channel( &self, socket: Arc, data: &serde_json::Value, ) -> ImksResult<()> { let user_id = self.user_id(&socket)?; let payload = Self::first_payload(data)?; let channel_id: Uuid = Self::parse_field(payload, "channel_id")?; let channel_id_str = channel_id.to_string(); let user_id_str = user_id.to_string(); self.ensure_readable(&channel_id_str, &user_id_str).await?; self.ensure_member(&channel_id_str, &user_id_str).await?; let namespace = self .namespaces .get_namespace(&socket.namespace) .ok_or_else(|| { ImksError::Namespace(format!("namespace {} not found", socket.namespace)) })?; namespace.join_room(&socket.sid, &channel_id_str).await?; tracing::info!(socket_sid = %socket.sid, %channel_id, %user_id, "Socket joined channel room"); Ok(()) } /// Handle `channel:leave` event by removing the socket from the channel room. pub async fn leave_channel( &self, socket: Arc, data: &serde_json::Value, ) -> ImksResult<()> { let _user_id = self.user_id(&socket)?; let payload = Self::first_payload(data)?; let channel_id: Uuid = Self::parse_field(payload, "channel_id")?; let channel_id_str = channel_id.to_string(); if let Some(namespace) = self.namespaces.get_namespace(&socket.namespace) { namespace.leave_room(&socket.sid, &channel_id_str).await?; } tracing::info!(socket_sid = %socket.sid, %channel_id, "Socket left channel room"); Ok(()) } /// Handle `message:send` event from a connected socket. /// /// Expected `data` shape (Socket.IO event array tail): /// ```json /// [{ /// "channel_id": "...", /// "body": "hello world", /// "thread_id": null, /// "reply_to_message_id": null, /// "nonce": "optional-client-idempotency-key" /// }] /// ``` pub async fn send_message( &self, socket: Arc, data: &serde_json::Value, ) -> ImksResult { let user_id = socket .user_id() .ok_or_else(|| ImksError::Auth("Socket not authenticated".into()))?; let payload = self.parse_send_payload(data)?; let channel_id = payload.channel_id; let channel_id_str = channel_id.to_string(); let user_id_str = user_id.to_string(); self.validate_send_payload(&payload.body, &payload.nonce, user_id, channel_id)?; self.validate_channel_write(&channel_id_str, &user_id_str) .await?; let nonce_key = match payload.nonce.as_deref() { Some(nonce) => Some(self.reserve_nonce(nonce, user_id, channel_id)?), None => None, }; let result = self .create_and_dispatch(payload, user_id, &socket.namespace) .await; if result.is_err() { self.release_nonce(nonce_key); } result } /// Handle `message:edit` event. /// /// The author can edit their own message. Users with `MANAGE_MESSAGES` /// permission can edit any message in the channel. pub async fn edit_message( &self, socket: Arc, data: &serde_json::Value, ) -> ImksResult { let user_id = socket .user_id() .ok_or_else(|| ImksError::Auth("Socket not authenticated".into()))?; let payload = Self::first_payload(data)?; let message_id: Uuid = Self::parse_field(payload, "message_id")?; let new_body: String = Self::parse_field(payload, "body")?; let existing = self .repo .get(message_id) .await? .ok_or_else(|| ImksError::NotFound(format!("message {message_id}")))?; self.ensure_author_or_mod( existing.author_id, &existing.channel_id.to_string(), user_id, ) .await?; let old_body = existing.body.clone(); let updated = self.repo.update_body(message_id, &new_body).await?; self.repo .record_edit(message_id, user_id, &old_body, &new_body) .await?; let namespace = self.namespaces.get_namespace(&socket.namespace); if let Some(ns) = namespace { ns.emit_to_room( &existing.channel_id.to_string(), "message:updated", serde_json::to_value(&updated).unwrap_or_default(), ) .await; } tracing::info!(message_id = %message_id, user_id = %user_id, "Message edited"); Ok(updated) } /// Handle `message:delete` event (soft-delete). /// /// Same ownership / moderator rules as edit. pub async fn delete_message( &self, socket: Arc, data: &serde_json::Value, ) -> ImksResult<()> { let user_id = socket .user_id() .ok_or_else(|| ImksError::Auth("Socket not authenticated".into()))?; let payload = Self::first_payload(data)?; let message_id: Uuid = Self::parse_field(payload, "message_id")?; let existing = self .repo .get(message_id) .await? .ok_or_else(|| ImksError::NotFound(format!("message {message_id}")))?; self.ensure_author_or_mod( existing.author_id, &existing.channel_id.to_string(), user_id, ) .await?; self.repo.soft_delete(message_id).await?; let namespace = self.namespaces.get_namespace(&socket.namespace); if let Some(ns) = namespace { ns.emit_to_room( &existing.channel_id.to_string(), "message:deleted", serde_json::json!({ "id": message_id.to_string(), "channel_id": existing.channel_id.to_string() }), ) .await; } tracing::info!(message_id = %message_id, user_id = %user_id, "Message deleted"); Ok(()) } // Permission validation helpers /// Full write-access gate: resolve channel + readability + membership + SEND_MESSAGE. pub(crate) async fn validate_channel_write( &self, channel_id: &str, user_id: &str, ) -> ImksResult<()> { // Check read-only / archived first (fast gRPC, returns early) let channel_info = self.resolve_channel(channel_id).await?; if channel_info.read_only { return Err(ImksError::Auth(format!( "Channel {channel_id} is read-only" ))); } if channel_info.archived { return Err(ImksError::Auth(format!("Channel {channel_id} is archived"))); } self.ensure_readable(channel_id, user_id).await?; self.ensure_member(channel_id, user_id).await?; self.ensure_permission(channel_id, user_id, ImPermission::SendMessage) .await?; Ok(()) } /// Verify the user can read this channel at all. pub(crate) async fn ensure_readable(&self, channel_id: &str, user_id: &str) -> ImksResult<()> { let mut client = self.clients.permission.clone(); let resp = client .ensure_readable(EnsureReadableRequest { channel_id: channel_id.to_string(), user_id: user_id.to_string(), }) .await?; let inner = resp.into_inner(); if !inner.allowed { return Err(ImksError::Auth(format!( "User {user_id} cannot read channel {channel_id}" ))); } Ok(()) } pub(crate) fn user_id(&self, socket: &Socket) -> crate::ImksResult { socket .user_id() .ok_or_else(|| ImksError::Auth("Socket not authenticated".into())) } pub(crate) async fn ensure_member( &self, channel_id: &str, user_id: &str, ) -> crate::ImksResult<()> { let mut client = self.clients.member.clone(); let resp = client .is_member(IsMemberRequest { channel_id: channel_id.to_string(), user_id: user_id.to_string(), }) .await?; let inner = resp.into_inner(); if !inner.is_member { return Err(ImksError::Auth(format!( "User {user_id} is not a member of channel {channel_id}" ))); } Ok(()) } /// Verify a specific permission. async fn ensure_permission( &self, channel_id: &str, user_id: &str, permission: ImPermission, ) -> ImksResult<()> { let allowed = self .check_permission(channel_id, user_id, permission) .await?; if !allowed { return Err(ImksError::Auth(format!( "User {user_id} lacks permission {permission:?} in channel {channel_id}" ))); } Ok(()) } /// Verify the user is the message author or has ManageMessages permission. pub(crate) async fn ensure_author_or_mod( &self, message_author_id: Uuid, channel_id: &str, user_id: Uuid, ) -> ImksResult<()> { if message_author_id == user_id { return Ok(()); } let allowed = self .check_permission( channel_id, &user_id.to_string(), ImPermission::ManageMessages, ) .await?; if !allowed { return Err(ImksError::Auth( "Only the author or a moderator can modify this message".into(), )); } Ok(()) } /// Low-level permission check returning a boolean. pub(crate) async fn check_permission( &self, channel_id: &str, user_id: &str, permission: ImPermission, ) -> ImksResult { let mut client = self.clients.permission.clone(); let resp = client .check_permission(CheckPermissionRequest { channel_id: channel_id.to_string(), user_id: user_id.to_string(), permission: permission as i32, }) .await?; Ok(resp.into_inner().allowed) } /// Resolve channel metadata via gRPC to check read_only / archived status. async fn resolve_channel( &self, channel_id: &str, ) -> ImksResult { let mut client = self.clients.permission.clone(); let resp = client .resolve_channel(ResolveChannelRequest { channel_id: channel_id.to_string(), }) .await?; Ok(resp.into_inner()) } // Rate limiting & dedup /// Reserve a nonce atomically. Nonces expire after `nonce_ttl`. fn reserve_nonce(&self, nonce: &str, user_id: Uuid, channel_id: Uuid) -> ImksResult { let now = Instant::now(); let key = format!("{user_id}:{channel_id}:{nonce}"); // Cleanup expired nonces periodically (probabilistic, ~1/64 chance per check) if rand::random::().is_multiple_of(64) { self.nonces .retain(|_, t| now.duration_since(*t) < self.nonce_ttl); } match self.nonces.entry(key.clone()) { dashmap::mapref::entry::Entry::Occupied(mut entry) => { if now.duration_since(*entry.get()) < self.nonce_ttl { return Err(ImksError::InvalidInput( "Duplicate message: this nonce was already used".into(), )); } entry.insert(now); } dashmap::mapref::entry::Entry::Vacant(entry) => { entry.insert(now); } } Ok(key) } fn release_nonce(&self, nonce_key: Option) { if let Some(key) = nonce_key { self.nonces.remove(&key); } } fn validate_body_size(&self, body: &str) -> ImksResult<()> { if body.len() > self.max_body_size { return Err(ImksError::InvalidInput(format!( "Message body exceeds max size of {} bytes (got {})", self.max_body_size, body.len() ))); } Ok(()) } /// Check per-user, per-channel rate limit using a sliding window. fn check_rate_limit(&self, user_id: Uuid, channel_id: Uuid) -> ImksResult<()> { let key = (user_id, channel_id); let now = Instant::now(); let mut entry = self.rate_limits.entry(key).or_default(); // Evict timestamps outside the window entry.retain(|t| now.duration_since(*t) < self.rate_window); if entry.len() >= self.rate_limit_count { return Err(ImksError::InvalidInput(format!( "Rate limit exceeded: max {} messages per {}s", self.rate_limit_count, self.rate_window.as_secs() ))); } entry.push(now); Ok(()) } /// Combined safety checks for message sending: body size and rate limit. fn validate_send_payload( &self, body: &str, _nonce: &Option, user_id: Uuid, channel_id: Uuid, ) -> ImksResult<()> { self.validate_body_size(body)?; self.check_rate_limit(user_id, channel_id)?; Ok(()) } // Payload parsers /// Parse attachment inputs from a JSON payload. fn parse_attachments(value: &serde_json::Value) -> Vec { value .get("attachments") .and_then(|v| v.as_array()) .map(|arr| { arr.iter() .filter_map(|a| { Some(AttachmentInput { filename: a.get("filename")?.as_str()?.to_string(), url: a.get("url")?.as_str()?.to_string(), size: a.get("size")?.as_i64()?, content_type: a .get("content_type") .and_then(|v| v.as_str()) .map(String::from), }) }) .collect() }) .unwrap_or_default() } /// Parse embed inputs from a JSON payload. fn parse_embeds(value: &serde_json::Value) -> Vec { value .get("embeds") .and_then(|v| v.as_array()) .map(|arr| { arr.iter() .filter_map(|e| { let fields: Vec<(String, String, bool)> = e .get("fields") .and_then(|v| v.as_array()) .map(|fa| { fa.iter() .filter_map(|f| { Some(( f.get("name")?.as_str()?.to_string(), f.get("value")?.as_str()?.to_string(), f.get("inline") .and_then(|v| v.as_bool()) .unwrap_or(false), )) }) .collect() }) .unwrap_or_default(); Some(EmbedInput { embed_type: e.get("embed_type")?.as_str()?.to_string(), title: e.get("title").and_then(|v| v.as_str()).map(String::from), description: e .get("description") .and_then(|v| v.as_str()) .map(String::from), url: e.get("url").and_then(|v| v.as_str()).map(String::from), image_url: e .get("image_url") .and_then(|v| v.as_str()) .map(String::from), fields, }) }) .collect() }) .unwrap_or_default() } /// Parse a sticker input from a JSON payload (single object or null). fn parse_sticker(value: &serde_json::Value) -> Option { value.get("sticker").and_then(|s| { Some(StickerInput { sticker_id: MessageService::parse_field(s, "sticker_id").ok()?, name: MessageService::parse_field(s, "name").ok()?, image_url: MessageService::parse_field(s, "image_url").ok()?, format_type: s .get("format_type") .and_then(|v| v.as_str()) .unwrap_or("png") .to_string(), }) }) } /// Parse a forward input from a JSON payload (single object or null). fn parse_forward(value: &serde_json::Value) -> Option { value.get("forward").and_then(|f| { Some(ForwardInput { source_message_id: MessageService::parse_field(f, "source_message_id").ok()?, source_channel_id: MessageService::parse_field(f, "source_channel_id").ok()?, }) }) } /// Parse the `message:send` event payload including optional rich content. fn parse_send_payload(&self, data: &serde_json::Value) -> ImksResult { let arr = data .as_array() .ok_or_else(|| ImksError::InvalidInput("Event data must be a JSON array".into()))?; if arr.is_empty() { return Err(ImksError::InvalidInput("Empty event data".into())); } let payload = &arr[0]; let channel_id: Uuid = Self::parse_field(payload, "channel_id")?; let body: String = Self::parse_field(payload, "body")?; let thread_id = Self::parse_optional(payload, "thread_id")?; let reply_to_message_id = Self::parse_optional(payload, "reply_to_message_id")?; let nonce: Option = Self::parse_optional(payload, "nonce")?; let mentioned_user_ids: Vec = Self::parse_optional(payload, "mentioned_user_ids")?.unwrap_or_default(); let attachments = Self::parse_attachments(payload); let embeds = Self::parse_embeds(payload); let sticker = Self::parse_sticker(payload); let forward = Self::parse_forward(payload); Ok(SendPayload { channel_id, body, thread_id, reply_to_message_id, nonce, mentioned_user_ids, attachments, embeds, sticker, forward, }) } pub(crate) fn first_payload(data: &serde_json::Value) -> ImksResult<&serde_json::Value> { data.as_array() .and_then(|a| a.first()) .ok_or_else(|| ImksError::InvalidInput("Expected [payload] array".into())) } pub(crate) fn parse_field( value: &serde_json::Value, field: &str, ) -> crate::ImksResult { let field_value = value .get(field) .ok_or_else(|| ImksError::InvalidInput(format!("Missing required field: {field}")))?; serde_json::from_value(field_value.clone()) .map_err(|e| ImksError::InvalidInput(format!("Invalid field {field}: {e}"))) } pub(crate) fn parse_optional( value: &serde_json::Value, field: &str, ) -> ImksResult> { match value.get(field) { Some(v) if v.is_null() => Ok(None), Some(v) => serde_json::from_value(v.clone()) .map(Some) .map_err(|e| ImksError::InvalidInput(format!("Invalid field {field}: {e}"))), None => Ok(None), } } /// Create the message and all rich content in one database transaction, /// then broadcast to the channel room after commit. async fn create_and_dispatch( &self, payload: SendPayload, user_id: Uuid, namespace_path: &str, ) -> ImksResult { let mut tx = self.repo.pool().begin().await?; let message_id = Uuid::now_v7(); let now = Utc::now(); let message = sqlx::query_as::<_, Message>( r#" INSERT INTO message ( id, channel_id, author_id, thread_id, reply_to_message_id, message_type, body, metadata, pinned, system, edited_at, deleted_at, created_at, updated_at ) VALUES ( $1, $2, $3, $4, $5, 'text', $6, NULL, FALSE, FALSE, NULL, NULL, $7, $7 ) RETURNING * "#, ) .bind(message_id) .bind(payload.channel_id) .bind(user_id) .bind(payload.thread_id) .bind(payload.reply_to_message_id) .bind(&payload.body) .bind(now) .fetch_one(&mut *tx) .await?; if let Some(thread_id) = payload.thread_id { sqlx::query( r#" UPDATE message_thread SET replies_count = replies_count + 1, last_reply_message_id = $1, last_reply_at = $2, updated_at = $2 WHERE id = $3 "#, ) .bind(message_id) .bind(now) .bind(thread_id) .execute(&mut *tx) .await?; sqlx::query( r#" INSERT INTO message_thread_participant (id, thread_id, user_id, joined_reason, joined_at) VALUES ($1, $2, $3, 'reply', $4) ON CONFLICT (thread_id, user_id) DO UPDATE SET joined_reason = EXCLUDED.joined_reason "#, ) .bind(Uuid::now_v7()) .bind(thread_id) .bind(user_id) .bind(now) .execute(&mut *tx) .await?; sqlx::query( "UPDATE message_thread SET participants_count = (SELECT COUNT(*) FROM message_thread_participant WHERE thread_id = $1) WHERE id = $1", ) .bind(thread_id) .execute(&mut *tx) .await?; } for att in &payload.attachments { sqlx::query( r#" INSERT INTO message_attachment ( id, message_id, filename, content_type, size, url, storage_key, width, height, spoiler ) VALUES ($1, $2, $3, $4, $5, $6, NULL, NULL, NULL, FALSE) "#, ) .bind(Uuid::now_v7()) .bind(message_id) .bind(&att.filename) .bind(att.content_type.as_deref()) .bind(att.size) .bind(&att.url) .execute(&mut *tx) .await?; } for emb in &payload.embeds { let embed_id = Uuid::now_v7(); sqlx::query( r#" INSERT INTO message_embed ( id, message_id, embed_type, title, description, url, color, image_url, author_name, author_url, footer_text, provider_name ) VALUES ($1, $2, $3, $4, $5, $6, NULL, $7, NULL, NULL, NULL, NULL) "#, ) .bind(embed_id) .bind(message_id) .bind(&emb.embed_type) .bind(emb.title.as_deref()) .bind(emb.description.as_deref()) .bind(emb.url.as_deref()) .bind(emb.image_url.as_deref()) .execute(&mut *tx) .await?; for (position, (name, value, inline)) in emb.fields.iter().enumerate() { sqlx::query( r#" INSERT INTO message_embed_field (id, embed_id, name, value, inline, position) VALUES ($1, $2, $3, $4, $5, $6) "#, ) .bind(Uuid::now_v7()) .bind(embed_id) .bind(name) .bind(value) .bind(*inline) .bind(position as i32) .execute(&mut *tx) .await?; } } if let Some(sticker) = &payload.sticker { sqlx::query( r#" INSERT INTO message_sticker (id, message_id, sticker_id, name, image_url, format_type, pack_name, tags) VALUES ($1, $2, $3, $4, $5, $6, NULL, NULL) "#, ) .bind(Uuid::now_v7()) .bind(message_id) .bind(sticker.sticker_id) .bind(&sticker.name) .bind(&sticker.image_url) .bind(&sticker.format_type) .execute(&mut *tx) .await?; } if let Some(forward) = &payload.forward { sqlx::query( r#" INSERT INTO message_forward (id, message_id, source_message_id, source_channel_id, forwarded_by) VALUES ($1, $2, $3, $4, $5) "#, ) .bind(Uuid::now_v7()) .bind(message_id) .bind(forward.source_message_id) .bind(forward.source_channel_id) .bind(user_id) .execute(&mut *tx) .await?; } for mentioned_id in &payload.mentioned_user_ids { sqlx::query( r#" INSERT INTO message_mention (id, message_id, channel_id, mentioned_user_id, mentioned_by, created_at) VALUES ($1, $2, $3, $4, $5, $6) "#, ) .bind(Uuid::now_v7()) .bind(message_id) .bind(payload.channel_id) .bind(*mentioned_id) .bind(user_id) .bind(now) .execute(&mut *tx) .await?; sqlx::query( r#" INSERT INTO message_notification ( id, message_id, channel_id, user_id, reason, status, delivery_channel, created_at ) VALUES ($1, $2, $3, $4, 'mention', 'pending', NULL, $5) "#, ) .bind(Uuid::now_v7()) .bind(message_id) .bind(payload.channel_id) .bind(*mentioned_id) .bind(now) .execute(&mut *tx) .await?; } tx.commit().await?; self.broadcast_new_message(&message, payload.channel_id, user_id, namespace_path) .await; Ok(message) } /// Broadcast a newly created message to the channel room and log. async fn broadcast_new_message( &self, message: &Message, channel_id: Uuid, user_id: Uuid, namespace_path: &str, ) { let namespace = self.namespaces.get_namespace(namespace_path); if let Some(ns) = namespace { ns.emit_to_room( &channel_id.to_string(), "message:new", serde_json::to_value(message).unwrap_or_default(), ) .await; } tracing::info!( message_id = %message.id, channel_id = %channel_id, user_id = %user_id, "Message sent" ); } } // Rich content input types for parsing pub(crate) struct SendPayload { channel_id: Uuid, body: String, thread_id: Option, reply_to_message_id: Option, nonce: Option, mentioned_user_ids: Vec, attachments: Vec, embeds: Vec, sticker: Option, forward: Option, } pub(crate) struct AttachmentInput { filename: String, url: String, size: i64, content_type: Option, } pub(crate) struct EmbedInput { embed_type: String, title: Option, description: Option, url: Option, image_url: Option, fields: Vec<(String, String, bool)>, } pub(crate) struct StickerInput { sticker_id: Uuid, name: String, image_url: String, format_type: String, } pub(crate) struct ForwardInput { source_message_id: Uuid, source_channel_id: Uuid, }