use serde::Serialize;
use std::path::PathBuf;
use thiserror::Error;
#[derive(Debug, Clone)]
pub enum AttachmentSource {
LocalFile(PathBuf),
Token(String),
}
#[derive(Debug, Clone, Serialize)]
pub struct ContactData {
#[serde(skip_serializing_if = "Option::is_none")]
pub name: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub contact_id: Option<i64>,
#[serde(skip_serializing_if = "Option::is_none")]
pub vcf_info: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub vcf_phone: Option<String>,
}
#[derive(Debug, Clone, Serialize)]
pub struct InlineKeyboardButton {
pub r#type: String,
pub text: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub payload: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub url: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub quick: Option<bool>,
#[serde(skip_serializing_if = "Option::is_none")]
pub web_app: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub contact_id: Option<i64>,
#[serde(skip_serializing_if = "Option::is_none")]
pub app_payload: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub message_text: Option<String>,
}
#[derive(Debug, Clone, Serialize)]
pub struct InlineKeyboard {
pub buttons: Vec<Vec<InlineKeyboardButton>>,
}
#[derive(Debug, Clone, Serialize)]
pub struct ShareData {
#[serde(skip_serializing_if = "Option::is_none")]
pub url: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub token: Option<String>,
}
#[derive(Debug, Clone, Serialize)]
#[serde(tag = "type", content = "payload")]
pub enum Attachment {
#[serde(rename = "image")]
Image {
#[serde(skip)]
source: AttachmentSource,
token: Option<String>,
url: Option<String>,
},
#[serde(rename = "video")]
Video {
#[serde(skip)]
source: AttachmentSource,
token: Option<String>,
url: Option<String>,
filename: Option<String>,
},
#[serde(rename = "audio")]
Audio {
#[serde(skip)]
source: AttachmentSource,
token: Option<String>,
url: Option<String>,
filename: Option<String>,
},
#[serde(rename = "file")]
File {
#[serde(skip)]
source: AttachmentSource,
token: Option<String>,
url: Option<String>,
filename: Option<String>,
},
#[serde(rename = "sticker")]
Sticker {
code: String,
},
#[serde(rename = "contact")]
Contact(ContactData),
#[serde(rename = "inline_keyboard")]
InlineKeyboard(InlineKeyboard),
#[serde(rename = "location")]
Location {
latitude: f64,
longitude: f64,
},
#[serde(rename = "share")]
Share(ShareData),
}
impl Attachment {
pub fn get_type(&self) -> &'static str {
match self {
Attachment::Image { .. } => "image",
Attachment::Video { .. } => "video",
Attachment::Audio { .. } => "audio",
Attachment::File { .. } => "file",
Attachment::Sticker { .. } => "sticker",
Attachment::Contact(_) => "contact",
Attachment::InlineKeyboard(_) => "inline_keyboard",
Attachment::Location { .. } => "location",
Attachment::Share(_) => "share",
}
}
pub fn image_local(path: impl Into<PathBuf>) -> Self {
Attachment::Image {
source: AttachmentSource::LocalFile(path.into()),
token: None,
url: None,
}
}
pub fn image_token(token: impl Into<String>) -> Self {
Attachment::Image {
source: AttachmentSource::Token(token.into()),
token: None,
url: None,
}
}
pub fn image_url(url: impl Into<String>) -> Self {
Attachment::Image {
source: AttachmentSource::Token(String::new()), token: None,
url: Some(url.into()),
}
}
pub fn video_local(path: impl Into<PathBuf>) -> Self {
Attachment::Video {
source: AttachmentSource::LocalFile(path.into()),
token: None,
url: None,
filename: None,
}
}
pub fn video_token(token: impl Into<String>) -> Self {
Attachment::Video {
source: AttachmentSource::Token(token.into()),
token: None,
url: None,
filename: None,
}
}
pub fn video_url(url: impl Into<String>, filename: impl Into<String>) -> Self {
Attachment::Video {
source: AttachmentSource::Token(String::new()),
token: None,
url: Some(url.into()),
filename: Some(filename.into()),
}
}
pub fn audio_local(path: impl Into<PathBuf>) -> Self {
Attachment::Audio {
source: AttachmentSource::LocalFile(path.into()),
token: None,
url: None,
filename: None,
}
}
pub fn audio_token(token: impl Into<String>) -> Self {
Attachment::Audio {
source: AttachmentSource::Token(token.into()),
token: None,
url: None,
filename: None,
}
}
pub fn audio_url(url: impl Into<String>, filename: impl Into<String>) -> Self {
Attachment::Audio {
source: AttachmentSource::Token(String::new()),
token: None,
url: Some(url.into()),
filename: Some(filename.into()),
}
}
pub fn file_local(path: impl Into<PathBuf>) -> Self {
Attachment::File {
source: AttachmentSource::LocalFile(path.into()),
token: None,
url: None,
filename: None,
}
}
pub fn file_token(token: impl Into<String>) -> Self {
Attachment::File {
source: AttachmentSource::Token(token.into()),
token: None,
url: None,
filename: None,
}
}
pub fn file_url(url: impl Into<String>, filename: impl Into<String>) -> Self {
Attachment::File {
source: AttachmentSource::Token(String::new()),
token: None,
url: Some(url.into()),
filename: Some(filename.into()),
}
}
pub fn sticker(code: impl Into<String>) -> Self {
Attachment::Sticker { code: code.into() }
}
pub fn contact(data: ContactData) -> Self {
Attachment::Contact(data)
}
pub fn inline_keyboard(keyboard: InlineKeyboard) -> Self {
Attachment::InlineKeyboard(keyboard)
}
pub fn location(latitude: f64, longitude: f64) -> Self {
Attachment::Location { latitude, longitude }
}
pub fn share(data: ShareData) -> Self {
Attachment::Share(data)
}
}
#[derive(Debug, Error, PartialEq)]
pub enum KeyboardValidationError {
#[error("Too many rows: maximum allowed is 30, got {0}")]
TooManyRows(usize),
#[error("Too many buttons in row: maximum allowed is 7 (or 3 if row contains any special button type), got {0} in row {1}")]
TooManyButtonsInRow(usize, usize),
#[error("Total number of buttons exceeds maximum allowed: maximum is 210, got {0}")]
TotalButtonsExceeded(usize),
}
pub struct InlineKeyboardBuilder {
rows: Vec<Vec<InlineKeyboardButton>>,
}
impl InlineKeyboardBuilder {
pub fn new() -> Self {
Self { rows: vec![] }
}
pub fn row(mut self, buttons: Vec<InlineKeyboardButton>) -> Self {
if let Some(last) = self.rows.last() {
if last.is_empty() {
*self.rows.last_mut().unwrap() = buttons;
return self;
}
}
self.rows.push(buttons);
self
}
pub fn button(mut self, btn: InlineKeyboardButton) -> Self {
if self.rows.is_empty() {
self.rows.push(vec![]);
}
self.rows.last_mut().unwrap().push(btn);
self
}
pub fn buttons(mut self, btns: Vec<InlineKeyboardButton>) -> Self {
if self.rows.is_empty() {
self.rows.push(vec![]);
}
self.rows.last_mut().unwrap().extend(btns);
self
}
pub fn new_row(mut self) -> Self {
if self.rows.is_empty() {
self.rows.push(vec![]);
} else {
let last_row = self.rows.last().unwrap();
if !last_row.is_empty() {
self.rows.push(vec![]);
}
}
self
}
fn validate(&self) -> Result<(), KeyboardValidationError> {
let total_rows = self.rows.len();
if total_rows > 30 {
return Err(KeyboardValidationError::TooManyRows(total_rows));
}
let mut total_buttons = 0;
for (row_idx, row) in self.rows.iter().enumerate() {
let row_len = row.len();
total_buttons += row_len;
let has_special = row.iter().any(|btn| {
matches!(
btn.r#type.as_str(),
"link" | "open_app" | "request_contact" | "request_geo_location"
)
});
let max_in_row = if has_special { 3 } else { 7 };
if row_len > max_in_row {
return Err(KeyboardValidationError::TooManyButtonsInRow(row_len, row_idx));
}
}
if total_buttons > 210 {
return Err(KeyboardValidationError::TotalButtonsExceeded(total_buttons));
}
Ok(())
}
pub fn build(self) -> Result<InlineKeyboard, KeyboardValidationError> {
self.validate()?;
Ok(InlineKeyboard { buttons: self.rows })
}
fn max_buttons_in_row(row: &[InlineKeyboardButton]) -> usize {
let has_special = row.iter().any(|btn| {
matches!(
btn.r#type.as_str(),
"link" | "open_app" | "request_contact" | "request_geo_location"
)
});
if has_special { 3 } else { 7 }
}
fn is_special_button(btn: &InlineKeyboardButton) -> bool {
matches!(
btn.r#type.as_str(),
"link" | "open_app" | "request_contact" | "request_geo_location"
)
}
pub fn push_button(mut self, btn: InlineKeyboardButton) -> Self {
let is_special = Self::is_special_button(&btn);
if self.rows.is_empty() {
self.rows.push(vec![]);
}
let last_row_idx = self.rows.len() - 1;
let current_row = &self.rows[last_row_idx];
let max_allowed = Self::max_buttons_in_row(current_row);
let can_add = if is_special {
current_row.len() < 3
} else {
current_row.len() < max_allowed
};
if can_add {
self.rows.last_mut().unwrap().push(btn);
} else {
self.rows.push(vec![btn]);
}
self
}
pub fn push_buttons(mut self, btns: Vec<InlineKeyboardButton>) -> Self {
for btn in btns {
self = self.push_button(btn);
}
self
}
}
impl Default for InlineKeyboardBuilder {
fn default() -> Self {
Self::new()
}
}
impl InlineKeyboardButton {
pub fn callback(text: impl Into<String>, payload: impl Into<String>) -> Self {
Self {
r#type: "callback".to_string(),
text: text.into(),
payload: Some(payload.into()),
url: None,
quick: None,
web_app: None,
contact_id: None,
app_payload: None,
message_text: None,
}
}
pub fn link(text: impl Into<String>, url: impl Into<String>) -> Self {
let url = url.into();
if url.len() > 2048 {
#[cfg(debug_assertions)]
eprintln!("Warning: link URL exceeds 2048 characters, may be rejected by MAX");
}
Self {
r#type: "link".to_string(),
text: text.into(),
payload: None,
url: Some(url),
quick: None,
web_app: None,
contact_id: None,
app_payload: None,
message_text: None,
}
}
pub fn request_contact(text: impl Into<String>) -> Self {
Self {
r#type: "request_contact".to_string(),
text: text.into(),
payload: None,
url: None,
quick: Some(true),
web_app: None,
contact_id: None,
app_payload: None,
message_text: None,
}
}
pub fn request_location(text: impl Into<String>) -> Self {
Self {
r#type: "request_geo_location".to_string(),
text: text.into(),
payload: None,
url: None,
quick: Some(true),
web_app: None,
contact_id: None,
app_payload: None,
message_text: None,
}
}
pub fn open_app(
text: impl Into<String>,
web_app_url: impl Into<String>,
contact_id: i64,
payload: Option<String>,
) -> Self {
Self {
r#type: "open_app".to_string(),
text: text.into(),
payload,
url: None,
quick: None,
web_app: Some(web_app_url.into()),
contact_id: Some(contact_id),
app_payload: None,
message_text: None,
}
}
pub fn message(text: impl Into<String>, message_text: impl Into<String>) -> Self {
Self {
r#type: "message".to_string(),
text: text.into(),
payload: None,
url: None,
quick: None,
web_app: None,
contact_id: None,
app_payload: None,
message_text: Some(message_text.into()),
}
}
pub fn clipboard(text: impl Into<String>, payload: impl Into<String>) -> Self {
Self {
r#type: "clipboard".to_string(),
text: text.into(),
payload: Some(payload.into()),
url: None,
quick: None,
web_app: None,
contact_id: None,
app_payload: None,
message_text: None,
}
}
}