pub(crate) const MAX_IMAGE_BYTES: usize = 8 * 1024 * 1024;
pub(crate) const MAX_MEDIA_BYTES: usize = 25 * 1024 * 1024;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum AttachmentKind {
Photo,
Video,
Audio,
}
pub(crate) fn check_attachment_size(
bytes: &[u8],
kind: AttachmentKind,
) -> crate::error::Result<()> {
let (max, noun) = match kind {
AttachmentKind::Photo => (MAX_IMAGE_BYTES, "image"),
AttachmentKind::Video => (MAX_MEDIA_BYTES, "video"),
AttachmentKind::Audio => (MAX_MEDIA_BYTES, "audio"),
};
if bytes.len() > max {
tracing::warn!(len = bytes.len(), max, "{noun} attachment too large");
return Err(crate::error::Error::Other(format!(
"{noun} too large: {} bytes (max {max})",
bytes.len()
)));
}
Ok(())
}
#[derive(Debug, Clone)]
pub struct Button {
pub(crate) label: String,
pub(crate) kind: ButtonKind,
}
#[derive(Debug, Clone)]
pub(crate) enum ButtonKind {
Callback(String),
Url(String),
WebApp(String),
}
impl Button {
pub fn callback(label: impl Into<String>, id: impl Into<String>) -> Self {
Self {
label: label.into(),
kind: ButtonKind::Callback(id.into()),
}
}
pub fn url(label: impl Into<String>, url: impl Into<String>) -> Self {
Self {
label: label.into(),
kind: ButtonKind::Url(url.into()),
}
}
pub fn web_app(label: impl Into<String>, url: impl Into<String>) -> Self {
Self {
label: label.into(),
kind: ButtonKind::WebApp(url.into()),
}
}
pub fn label(&self) -> &str {
&self.label
}
pub fn callback_id(&self) -> Option<&str> {
match &self.kind {
ButtonKind::Callback(id) => Some(id.as_str()),
_ => None,
}
}
pub fn url_target(&self) -> Option<&str> {
match &self.kind {
ButtonKind::Url(u) => Some(u.as_str()),
_ => None,
}
}
pub fn web_app_url(&self) -> Option<&str> {
match &self.kind {
ButtonKind::WebApp(u) => Some(u.as_str()),
_ => None,
}
}
}
#[derive(Debug, Clone, Default)]
pub struct Keyboard {
pub(crate) rows: Vec<Vec<Button>>,
}
impl Keyboard {
pub fn new() -> Self {
Self { rows: Vec::new() }
}
pub fn row(mut self, buttons: impl IntoIterator<Item = Button>) -> Self {
self.rows.push(buttons.into_iter().collect());
self
}
pub fn rows(&self) -> &[Vec<Button>] {
&self.rows
}
pub fn len(&self) -> usize {
self.rows.iter().map(|r| r.len()).sum()
}
pub fn is_empty(&self) -> bool {
self.rows.iter().all(|r| r.is_empty())
}
}
#[derive(Debug, Clone)]
pub struct EmbedField {
pub(crate) name: String,
pub(crate) value: String,
pub(crate) inline: bool,
}
impl EmbedField {
pub fn new(name: impl Into<String>, value: impl Into<String>) -> Self {
Self {
name: name.into(),
value: value.into(),
inline: false,
}
}
pub fn inline(mut self, yes: bool) -> Self {
self.inline = yes;
self
}
pub fn name(&self) -> &str {
&self.name
}
pub fn value(&self) -> &str {
&self.value
}
pub fn is_inline(&self) -> bool {
self.inline
}
}
#[derive(Debug, Clone, Default)]
pub struct Embed {
pub(crate) title: Option<String>,
pub(crate) description: Option<String>,
pub(crate) fields: Vec<EmbedField>,
pub(crate) footer: Option<String>,
pub(crate) color: Option<u32>,
pub(crate) url: Option<String>,
pub(crate) image_url: Option<String>,
pub(crate) thumbnail_url: Option<String>,
}
impl Embed {
pub fn new() -> Self {
Self::default()
}
pub fn title(mut self, t: impl Into<String>) -> Self {
self.title = Some(t.into());
self
}
pub fn description(mut self, d: impl Into<String>) -> Self {
self.description = Some(d.into());
self
}
pub fn field(mut self, name: impl Into<String>, value: impl Into<String>) -> Self {
self.fields.push(EmbedField::new(name, value));
self
}
pub fn field_inline(mut self, name: impl Into<String>, value: impl Into<String>) -> Self {
self.fields.push(EmbedField::new(name, value).inline(true));
self
}
pub fn push_field(mut self, field: EmbedField) -> Self {
self.fields.push(field);
self
}
pub fn footer(mut self, f: impl Into<String>) -> Self {
self.footer = Some(f.into());
self
}
pub fn color(mut self, rgb: u32) -> Self {
self.color = Some(rgb & 0x00FF_FFFF);
self
}
pub fn url(mut self, u: impl Into<String>) -> Self {
self.url = Some(u.into());
self
}
pub fn image(mut self, url: impl Into<String>) -> Self {
self.image_url = Some(url.into());
self
}
pub fn thumbnail(mut self, url: impl Into<String>) -> Self {
self.thumbnail_url = Some(url.into());
self
}
pub fn get_title(&self) -> Option<&str> {
self.title.as_deref()
}
pub fn get_description(&self) -> Option<&str> {
self.description.as_deref()
}
pub fn get_fields(&self) -> &[EmbedField] {
&self.fields
}
pub fn get_footer(&self) -> Option<&str> {
self.footer.as_deref()
}
pub fn get_color(&self) -> Option<u32> {
self.color
}
pub fn get_url(&self) -> Option<&str> {
self.url.as_deref()
}
pub fn get_image(&self) -> Option<&str> {
self.image_url.as_deref()
}
pub fn get_thumbnail(&self) -> Option<&str> {
self.thumbnail_url.as_deref()
}
pub fn is_empty(&self) -> bool {
self.title.is_none()
&& self.description.is_none()
&& self.fields.is_empty()
&& self.footer.is_none()
}
}
#[derive(Debug, Clone, Default)]
pub struct Reply {
pub(crate) text: String,
pub(crate) embed: Option<Embed>,
pub(crate) keyboard: Option<Keyboard>,
pub(crate) raw: bool,
pub(crate) attachment: Option<(Vec<u8>, String, AttachmentKind)>,
}
impl Reply {
pub fn text(text: impl Into<String>) -> Self {
Self {
text: text.into(),
..Self::default()
}
}
pub fn embed(embed: Embed) -> Self {
Self {
embed: Some(embed),
..Self::default()
}
}
pub fn with_embed(mut self, embed: Embed) -> Self {
self.embed = Some(embed);
self
}
pub fn keyboard(mut self, kb: Keyboard) -> Self {
self.keyboard = Some(kb);
self
}
pub fn with_text(mut self, text: impl Into<String>) -> Self {
self.text = text.into();
self
}
pub fn get_text(&self) -> &str {
&self.text
}
pub fn raw(mut self, raw: bool) -> Self {
self.raw = raw;
self
}
pub fn is_raw(&self) -> bool {
self.raw
}
pub fn image_bytes(mut self, bytes: Vec<u8>, filename: impl Into<String>) -> Self {
self.attachment = Some((bytes, filename.into(), AttachmentKind::Photo));
self
}
pub fn video_bytes(mut self, bytes: Vec<u8>, filename: impl Into<String>) -> Self {
self.attachment = Some((bytes, filename.into(), AttachmentKind::Video));
self
}
pub fn audio_bytes(mut self, bytes: Vec<u8>, filename: impl Into<String>) -> Self {
self.attachment = Some((bytes, filename.into(), AttachmentKind::Audio));
self
}
pub fn get_image_bytes(&self) -> Option<(&[u8], &str)> {
match &self.attachment {
Some((b, n, AttachmentKind::Photo)) => Some((b.as_slice(), n.as_str())),
_ => None,
}
}
pub(crate) fn get_attachment(&self) -> Option<(&[u8], &str, AttachmentKind)> {
self.attachment
.as_ref()
.map(|(b, n, k)| (b.as_slice(), n.as_str(), *k))
}
pub fn get_embed(&self) -> Option<&Embed> {
self.embed.as_ref()
}
pub fn get_keyboard(&self) -> Option<&Keyboard> {
self.keyboard.as_ref()
}
}
impl From<&str> for Reply {
fn from(s: &str) -> Self {
Self::text(s)
}
}
impl From<String> for Reply {
fn from(s: String) -> Self {
Self::text(s)
}
}
impl From<Embed> for Reply {
fn from(e: Embed) -> Self {
Self::embed(e)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn image_bytes_is_stored() {
let reply = Reply::text("look").image_bytes(vec![1, 2, 3], "pic.png");
let (bytes, name) = reply.get_image_bytes().expect("image should be set");
assert_eq!(bytes, &[1, 2, 3]);
assert_eq!(name, "pic.png");
}
#[test]
fn image_bytes_absent_by_default() {
assert!(Reply::text("hi").get_image_bytes().is_none());
assert!(Reply::embed(Embed::new()).get_image_bytes().is_none());
}
#[test]
fn image_size_within_cap_is_ok() {
assert!(check_attachment_size(&[0u8; 16], AttachmentKind::Photo).is_ok());
assert!(check_attachment_size(&vec![0u8; MAX_IMAGE_BYTES], AttachmentKind::Photo).is_ok());
}
#[test]
fn image_size_over_cap_is_rejected() {
let err = check_attachment_size(&vec![0u8; MAX_IMAGE_BYTES + 1], AttachmentKind::Photo)
.unwrap_err();
assert!(matches!(err, crate::error::Error::Other(_)));
assert!(err.to_string().contains("image too large"));
}
#[test]
fn media_size_within_cap_is_ok() {
assert!(check_attachment_size(&vec![0u8; MAX_MEDIA_BYTES], AttachmentKind::Video).is_ok());
assert!(check_attachment_size(&vec![0u8; MAX_MEDIA_BYTES], AttachmentKind::Audio).is_ok());
}
#[test]
fn media_size_over_cap_is_rejected() {
let err = check_attachment_size(&vec![0u8; MAX_MEDIA_BYTES + 1], AttachmentKind::Video)
.unwrap_err();
assert!(err.to_string().contains("video too large"));
let err = check_attachment_size(&vec![0u8; MAX_MEDIA_BYTES + 1], AttachmentKind::Audio)
.unwrap_err();
assert!(err.to_string().contains("audio too large"));
}
#[test]
fn video_bytes_is_stored() {
let reply = Reply::text("watch").video_bytes(vec![4, 5], "clip.mp4");
let (bytes, name, kind) = reply.get_attachment().expect("video should be set");
assert_eq!(bytes, &[4, 5]);
assert_eq!(name, "clip.mp4");
assert_eq!(kind, AttachmentKind::Video);
assert!(reply.get_image_bytes().is_none());
}
#[test]
fn audio_bytes_is_stored() {
let reply = Reply::text("listen").audio_bytes(vec![7], "song.mp3");
let (bytes, name, kind) = reply.get_attachment().expect("audio should be set");
assert_eq!(bytes, &[7]);
assert_eq!(name, "song.mp3");
assert_eq!(kind, AttachmentKind::Audio);
assert!(reply.get_image_bytes().is_none());
}
#[test]
fn last_attachment_wins() {
let reply = Reply::text("x")
.image_bytes(vec![1], "a.png")
.video_bytes(vec![2], "b.mp4")
.audio_bytes(vec![3], "c.mp3");
let (bytes, name, kind) = reply.get_attachment().expect("attachment should be set");
assert_eq!(bytes, &[3]);
assert_eq!(name, "c.mp3");
assert_eq!(kind, AttachmentKind::Audio);
let reply = Reply::text("y")
.audio_bytes(vec![3], "c.mp3")
.image_bytes(vec![1], "a.png");
let (bytes, name) = reply.get_image_bytes().expect("photo should win");
assert_eq!(bytes, &[1]);
assert_eq!(name, "a.png");
}
#[test]
fn web_app_button_keeps_kind() {
let btn = Button::web_app("Open app", "https://app.example.com/game");
assert_eq!(btn.label(), "Open app");
assert_eq!(btn.web_app_url(), Some("https://app.example.com/game"));
assert!(btn.callback_id().is_none());
assert!(btn.url_target().is_none());
assert!(Button::url("x", "https://x.example")
.web_app_url()
.is_none());
assert!(Button::callback("y", "y").web_app_url().is_none());
}
#[test]
fn image_bytes_combines_with_embed_and_keyboard() {
let reply = Reply::embed(Embed::new().title("t"))
.keyboard(Keyboard::new().row([Button::callback("ok", "ok")]))
.image_bytes(vec![9], "gen.jpg");
assert!(reply.get_embed().is_some());
assert!(reply.get_keyboard().is_some());
assert!(reply.get_image_bytes().is_some());
}
}