use reqwest::multipart::{Form, Part};
use serde_json::{Value, json};
use std::time::Duration;
use crate::discord::ids::{
Id,
marker::{ChannelMarker, MessageMarker},
};
use crate::{
AppError, Result,
discord::{
BASE_ATTACHMENT_LIMIT_BYTES, MAX_UPLOAD_ATTACHMENT_COUNT, MessageAttachmentUpload,
MessageInfo, ReplyReference, gateway::parse_message_info,
},
logging,
};
use super::{DiscordRest, clone_array, extra_fields, rest_error_kind};
pub(in crate::discord) enum MessageEditRequest<'a> {
Content(&'a str),
Flags(u64),
}
pub(in crate::discord) struct MessageCreateRequest<'a> {
pub nonce: Id<MessageMarker>,
pub content: &'a str,
pub reply_to: Option<ReplyReference>,
pub attachments: &'a [MessageAttachmentUpload],
}
impl DiscordRest {
pub async fn send_message(
&self,
channel_id: Id<ChannelMarker>,
request: MessageCreateRequest<'_>,
upload_limit: u64,
slow_mode: Option<Duration>,
) -> Result<MessageInfo> {
validate_message_payload(request.content, request.attachments, upload_limit)?;
let body = message_request_body(
request.content,
request.nonce,
request.reply_to,
request.attachments,
);
self.send_message_body(
channel_id,
body,
request.attachments,
upload_limit,
slow_mode,
)
.await
}
pub fn spawn_typing(&self, channel_id: Id<ChannelMarker>) {
let rest = self.clone();
tokio::spawn(async move {
if let Err(error) = rest.trigger_typing(channel_id).await {
logging::error(
"rest",
format!(
"background request failed action=\"trigger typing\" reason={}",
rest_error_kind(&error)
),
);
}
});
}
async fn trigger_typing(&self, channel_id: Id<ChannelMarker>) -> Result<()> {
self.send_unit(
self.raw_http.post(format!(
"https://discord.com/api/v9/channels/{}/typing",
channel_id.get()
)),
"trigger typing",
)
.await
}
pub async fn send_tts_message(
&self,
channel_id: Id<ChannelMarker>,
nonce: Id<MessageMarker>,
content: &str,
slow_mode: Option<Duration>,
) -> Result<MessageInfo> {
validate_message_content(content)?;
let body = message_request_body_with_tts(content, nonce, None, &[], true);
self.send_message_body(
channel_id,
body,
&[],
BASE_ATTACHMENT_LIMIT_BYTES,
slow_mode,
)
.await
}
async fn send_message_body(
&self,
channel_id: Id<ChannelMarker>,
body: Value,
attachments: &[MessageAttachmentUpload],
upload_limit: u64,
slow_mode: Option<Duration>,
) -> Result<MessageInfo> {
let _channel_guard = self.message_sends.acquire(channel_id).await;
self.message_sends.ensure_cooldown_elapsed(channel_id)?;
let request = self.raw_http.post(format!(
"https://discord.com/api/v9/channels/{}/messages",
channel_id.get()
));
let request = if attachments.is_empty() {
request.json(&body)
} else {
request.multipart(message_multipart_form(body, attachments, upload_limit).await?)
};
let result = self
.send_json(request, "send message")
.await
.and_then(|raw| {
parse_message_response(raw, "send message response")
.map(|response| response.message)
});
match &result {
Ok(_) => {
if let Some(slow_mode) = slow_mode {
self.message_sends.record_cooldown(channel_id, slow_mode);
}
}
Err(AppError::DiscordRateLimited {
retry_after_millis, ..
}) => self
.message_sends
.record_cooldown(channel_id, Duration::from_millis(*retry_after_millis)),
Err(_) => {}
}
result
}
pub async fn edit_message(
&self,
channel_id: Id<ChannelMarker>,
message_id: Id<MessageMarker>,
request: MessageEditRequest<'_>,
) -> Result<MessageInfo> {
let (body, action) = edit_message_request_body(request)?;
let raw: Value = self
.send_json(
self.raw_http
.patch(format!(
"https://discord.com/api/v9/channels/{}/messages/{}",
channel_id.get(),
message_id.get()
))
.json(&body),
action,
)
.await?;
parse_message_response(raw, &format!("{action} response")).map(|response| response.message)
}
pub async fn delete_message(
&self,
channel_id: Id<ChannelMarker>,
message_id: Id<MessageMarker>,
) -> Result<()> {
self.send_unit(
self.raw_http.delete(format!(
"https://discord.com/api/v9/channels/{}/messages/{}",
channel_id.get(),
message_id.get()
)),
"delete message",
)
.await
}
pub async fn load_message_history(
&self,
channel_id: Id<ChannelMarker>,
before: Option<Id<MessageMarker>>,
limit: u16,
) -> Result<Vec<MessageInfo>> {
let mut request = self
.raw_http
.get(format!(
"https://discord.com/api/v9/channels/{}/messages",
channel_id.get()
))
.query(&[("limit", limit.to_string())]);
if let Some(message_id) = before {
request = request.query(&[("before", message_id.to_string())]);
}
let raw_messages: Vec<Value> = self.send_json(request, "message history").await?;
parse_message_list_response(raw_messages, "history message response")
.map(|response| response.messages)
}
pub async fn load_recent_mentions(
&self,
before: Option<Id<MessageMarker>>,
limit: u16,
) -> Result<Vec<MessageInfo>> {
let request = recent_mentions_request(&self.raw_http, before, limit);
let raw_messages: Vec<Value> = self.send_json(request, "recent mentions").await?;
parse_message_list_response(raw_messages, "recent mentions response")
.map(|response| response.messages)
}
pub async fn delete_recent_mention(&self, message_id: Id<MessageMarker>) -> Result<()> {
self.send_unit(
recent_mention_delete_request(&self.raw_http, message_id),
"delete recent mention",
)
.await
}
pub async fn load_message_history_around(
&self,
channel_id: Id<ChannelMarker>,
message_id: Id<MessageMarker>,
limit: u16,
) -> Result<Vec<MessageInfo>> {
self.load_message_history_with_anchor(channel_id, "around", message_id, limit)
.await
}
pub async fn load_message_history_after(
&self,
channel_id: Id<ChannelMarker>,
message_id: Id<MessageMarker>,
limit: u16,
) -> Result<Vec<MessageInfo>> {
self.load_message_history_with_anchor(channel_id, "after", message_id, limit)
.await
}
async fn load_message_history_with_anchor(
&self,
channel_id: Id<ChannelMarker>,
anchor_name: &str,
message_id: Id<MessageMarker>,
limit: u16,
) -> Result<Vec<MessageInfo>> {
let request = self
.raw_http
.get(format!(
"https://discord.com/api/v9/channels/{}/messages",
channel_id.get()
))
.query(&[("limit", limit.to_string())])
.query(&[(anchor_name, message_id.to_string())]);
let raw_messages: Vec<Value> = self.send_json(request, "message history").await?;
parse_message_list_response(raw_messages, "history message response")
.map(|response| response.messages)
}
pub async fn load_pinned_messages(
&self,
channel_id: Id<ChannelMarker>,
) -> Result<Vec<MessageInfo>> {
let raw: Value = self
.send_json(
self.raw_http
.get(format!(
"https://discord.com/api/v9/channels/{}/messages/pins",
channel_id.get()
))
.query(&[("limit", "50")]),
"pins",
)
.await?;
parse_pinned_messages_response(&raw).map(|response| response.messages)
}
pub async fn set_message_pinned(
&self,
channel_id: Id<ChannelMarker>,
message_id: Id<MessageMarker>,
pinned: bool,
) -> Result<()> {
let request = if pinned {
self.raw_http.put(format!(
"https://discord.com/api/v9/channels/{}/messages/pins/{}",
channel_id.get(),
message_id.get()
))
} else {
self.raw_http.delete(format!(
"https://discord.com/api/v9/channels/{}/messages/pins/{}",
channel_id.get(),
message_id.get()
))
};
self.send_unit(request, "pin update").await
}
}
fn recent_mentions_request(
http: &reqwest::Client,
before: Option<Id<MessageMarker>>,
limit: u16,
) -> reqwest::RequestBuilder {
let mut request = http
.get("https://discord.com/api/v9/users/@me/mentions")
.query(&[
("limit", limit.to_string()),
("roles", "true".to_owned()),
("everyone", "true".to_owned()),
]);
if let Some(before) = before {
request = request.query(&[("before", before.to_string())]);
}
request
}
fn recent_mention_delete_request(
http: &reqwest::Client,
message_id: Id<MessageMarker>,
) -> reqwest::RequestBuilder {
http.delete(format!(
"https://discord.com/api/v9/users/@me/mentions/{}",
message_id.get()
))
}
#[derive(Clone, Debug, PartialEq)]
pub(super) struct PinnedMessagesResponse {
pub(super) messages: Vec<MessageInfo>,
pub(super) raw_items: Vec<Value>,
pub(super) extra_fields: std::collections::BTreeMap<String, Value>,
}
#[derive(Clone, Debug, PartialEq)]
pub(super) struct MessageResponse {
pub(super) message: MessageInfo,
pub(super) raw_message: Value,
}
#[derive(Clone, Debug, PartialEq)]
pub(super) struct MessageListResponse {
pub(super) messages: Vec<MessageInfo>,
pub(super) raw_messages: Vec<Value>,
}
pub(super) fn parse_pinned_messages_response(raw: &Value) -> Result<PinnedMessagesResponse> {
let messages: Vec<&Value> = match raw {
Value::Array(items) => items.iter().collect(),
Value::Object(object) => object
.get("items")
.and_then(Value::as_array)
.map(|items| {
items
.iter()
.filter_map(|item| item.get("message"))
.collect()
})
.unwrap_or_default(),
_ => Vec::new(),
};
let messages = messages
.into_iter()
.map(|raw| {
parse_message_info(raw).ok_or_else(|| {
AppError::DiscordRequest("pin message was missing required fields".to_owned())
})
})
.collect::<Result<Vec<_>>>()?;
Ok(PinnedMessagesResponse {
messages,
raw_items: match raw {
Value::Array(_) => clone_array(Some(raw)),
Value::Object(_) => clone_array(raw.get("items")),
_ => Vec::new(),
},
extra_fields: extra_fields(raw, &["items"]),
})
}
pub(super) fn parse_message_response(raw_message: Value, label: &str) -> Result<MessageResponse> {
let message = parse_message_info(&raw_message)
.ok_or_else(|| AppError::DiscordRequest(format!("{label} was missing required fields")))?;
Ok(MessageResponse {
message,
raw_message,
})
}
fn parse_message_list_response(
raw_messages: Vec<Value>,
label: &str,
) -> Result<MessageListResponse> {
let messages = raw_messages
.iter()
.map(|raw| {
parse_message_info(raw).ok_or_else(|| {
AppError::DiscordRequest(format!("{label} was missing required fields"))
})
})
.collect::<Result<Vec<_>>>()?;
Ok(MessageListResponse {
messages,
raw_messages,
})
}
pub(super) fn message_request_body(
content: &str,
nonce: Id<MessageMarker>,
reply_to: Option<ReplyReference>,
attachments: &[MessageAttachmentUpload],
) -> Value {
message_request_body_with_nonce(content, nonce, reply_to, attachments, false)
}
pub(super) fn message_request_body_with_tts(
content: &str,
nonce: Id<MessageMarker>,
reply_to: Option<ReplyReference>,
attachments: &[MessageAttachmentUpload],
tts: bool,
) -> Value {
message_request_body_with_nonce(content, nonce, reply_to, attachments, tts)
}
fn message_request_body_with_nonce(
content: &str,
nonce: Id<MessageMarker>,
reply_to: Option<ReplyReference>,
attachments: &[MessageAttachmentUpload],
tts: bool,
) -> Value {
let mut body = json!({
"mobile_network_type": "unknown",
"content": content,
"nonce": nonce.to_string(),
"enforce_nonce": true,
"tts": tts,
"flags": 0,
});
if let Some(reply) = reply_to {
body["message_reference"] = json!({ "message_id": reply.message_id.to_string() });
if !reply.mention_author {
body["allowed_mentions"] = json!({
"parse": ["users", "roles", "everyone"],
"replied_user": false,
});
}
}
if !attachments.is_empty() {
body["attachments"] = Value::Array(
attachments
.iter()
.enumerate()
.map(|(index, attachment)| {
json!({
"id": index,
"filename": attachment.filename,
})
})
.collect(),
);
}
body
}
pub(super) fn edit_message_request_body(
request: MessageEditRequest<'_>,
) -> Result<(Value, &'static str)> {
match request {
MessageEditRequest::Content(content) => {
validate_message_content(content)?;
Ok((json!({ "content": content }), "edit message"))
}
MessageEditRequest::Flags(flags) => Ok((json!({ "flags": flags }), "update message flags")),
}
}
pub(super) async fn message_multipart_form(
body: Value,
attachments: &[MessageAttachmentUpload],
upload_limit: u64,
) -> Result<Form> {
let actual_sizes = attachment_sizes(attachments).await?;
validate_attachment_sizes(&actual_sizes, upload_limit)?;
let mut form = Form::new().part(
"payload_json",
Part::text(body.to_string())
.mime_str("application/json")
.map_err(|error| AppError::DiscordRequest(format!("upload payload failed: {error}")))?,
);
for (index, attachment) in attachments.iter().enumerate() {
let bytes = attachment_bytes(attachment).await?;
validate_attachment_sizes(
&[(attachment.filename.clone(), bytes.len() as u64)],
upload_limit,
)?;
let content_type = upload_content_type(&attachment.filename);
let part = Part::bytes(bytes)
.file_name(attachment.filename.clone())
.mime_str(&content_type)
.map_err(|error| {
AppError::DiscordRequest(format!(
"attachment {} content type failed: {error}",
attachment.filename
))
})?;
form = form.part(format!("files[{index}]"), part);
}
Ok(form)
}
async fn attachment_sizes(attachments: &[MessageAttachmentUpload]) -> Result<Vec<(String, u64)>> {
let mut sizes = Vec::with_capacity(attachments.len());
for attachment in attachments {
let size = if let Some(path) = attachment.path() {
tokio::fs::metadata(path)
.await
.map_err(|error| {
AppError::DiscordRequest(format!(
"stat attachment {} failed: {error}",
attachment.filename
))
})?
.len()
} else {
attachment.size_bytes
};
sizes.push((attachment.filename.clone(), size));
}
Ok(sizes)
}
async fn attachment_bytes(attachment: &MessageAttachmentUpload) -> Result<Vec<u8>> {
if let Some(bytes) = attachment.bytes() {
return Ok(bytes.to_vec());
}
let Some(path) = attachment.path() else {
return Err(AppError::DiscordRequest(format!(
"attachment {} has no data",
attachment.filename
)));
};
tokio::fs::read(path).await.map_err(|error| {
AppError::DiscordRequest(format!(
"read attachment {} failed: {error}",
attachment.filename
))
})
}
pub(super) fn upload_content_type(filename: &str) -> String {
mime_guess::from_path(filename)
.first_or_octet_stream()
.essence_str()
.to_owned()
}
pub(super) fn validate_message_payload(
content: &str,
attachments: &[MessageAttachmentUpload],
upload_limit: u64,
) -> Result<()> {
if content.trim().is_empty() && attachments.is_empty() {
return Err(AppError::EmptyMessageContent);
}
let len = content.chars().count();
if len > 2_000 {
return Err(AppError::MessageTooLong { len });
}
let sizes = attachments
.iter()
.map(|attachment| (attachment.filename.clone(), attachment.size_bytes))
.collect::<Vec<_>>();
validate_attachment_sizes(&sizes, upload_limit)
}
fn validate_attachment_sizes(attachments: &[(String, u64)], upload_limit: u64) -> Result<()> {
if attachments.len() > MAX_UPLOAD_ATTACHMENT_COUNT {
return Err(AppError::TooManyAttachments {
count: attachments.len(),
});
}
for (filename, size) in attachments {
if *size > upload_limit {
return Err(AppError::AttachmentTooLarge {
filename: filename.clone(),
size: *size,
limit: upload_limit,
});
}
}
Ok(())
}
pub(super) fn validate_message_content(content: &str) -> Result<()> {
validate_message_payload(content, &[], BASE_ATTACHMENT_LIMIT_BYTES)
}
#[cfg(test)]
mod recent_mentions_tests {
use super::*;
#[test]
fn recent_mention_requests_use_message_ids_and_documented_filters() {
let _ = rustls::crypto::ring::default_provider().install_default();
let http = reqwest::Client::new();
let list = recent_mentions_request(&http, Some(Id::new(700)), 25)
.build()
.expect("recent mentions request builds");
assert_eq!(list.url().path(), "/api/v9/users/@me/mentions");
let query = list.url().query_pairs().collect::<Vec<_>>();
assert!(query.contains(&("before".into(), "700".into())));
assert!(query.contains(&("roles".into(), "true".into())));
assert!(query.contains(&("everyone".into(), "true".into())));
let delete = recent_mention_delete_request(&http, Id::new(700))
.build()
.expect("recent mention delete request builds");
assert_eq!(delete.method(), reqwest::Method::DELETE);
assert_eq!(delete.url().path(), "/api/v9/users/@me/mentions/700");
}
}