use std::collections::BTreeSet;
use serde::{Deserialize, Serialize};
use crate::types::common::{ChatId, UserId};
use crate::{Error, Result};
const MAX_BOT_NAME_CHARS: usize = 64;
const MAX_BOT_DESCRIPTION_CHARS: usize = 512;
const MAX_BOT_SHORT_DESCRIPTION_CHARS: usize = 120;
const MAX_LANGUAGE_CODE_BYTES: usize = 35;
#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
#[non_exhaustive]
pub struct BotCommand {
pub command: String,
pub description: String,
}
impl BotCommand {
pub fn new(command: impl Into<String>, description: impl Into<String>) -> Result<Self> {
let command = command.into();
let description = description.into();
validate_bot_command_name(&command)?;
validate_bot_command_description(&description)?;
Ok(Self {
command,
description,
})
}
}
fn validate_bot_command_name(command: &str) -> Result<()> {
let length = command.len();
if length == 0 || length > 32 {
return Err(Error::InvalidRequest {
reason: "command must be 1-32 characters".to_owned(),
});
}
let first = command.as_bytes()[0];
if !first.is_ascii_lowercase() {
return Err(Error::InvalidRequest {
reason: "command must start with a lowercase ASCII letter".to_owned(),
});
}
if !command
.bytes()
.all(|byte| byte.is_ascii_lowercase() || byte.is_ascii_digit() || byte == b'_')
{
return Err(Error::InvalidRequest {
reason: "command must contain only lowercase ASCII letters, digits, or `_`".to_owned(),
});
}
Ok(())
}
fn validate_bot_command_description(description: &str) -> Result<()> {
let length = description.chars().count();
if !(3..=256).contains(&length) {
return Err(Error::InvalidRequest {
reason: "command description must be 3-256 characters".to_owned(),
});
}
if description.chars().any(char::is_control) {
return Err(Error::InvalidRequest {
reason: "command description must not contain control characters".to_owned(),
});
}
Ok(())
}
fn validate_bot_commands(commands: &[BotCommand]) -> Result<()> {
if commands.is_empty() {
return Err(Error::InvalidRequest {
reason: "setMyCommands requires at least one command".to_owned(),
});
}
if commands.len() > 100 {
return Err(Error::InvalidRequest {
reason: "setMyCommands accepts at most 100 commands".to_owned(),
});
}
let mut seen = BTreeSet::new();
for command in commands {
validate_bot_command_name(&command.command)?;
validate_bot_command_description(&command.description)?;
if !seen.insert(command.command.as_str()) {
return Err(Error::InvalidRequest {
reason: format!("duplicate bot command `{}`", command.command),
});
}
}
Ok(())
}
pub(crate) fn validate_language_code_value(language_code: &str) -> Result<()> {
if language_code.is_empty() || language_code.len() > MAX_LANGUAGE_CODE_BYTES {
return Err(Error::InvalidRequest {
reason: format!("language_code must be 1-{MAX_LANGUAGE_CODE_BYTES} bytes").to_owned(),
});
}
if !language_code
.bytes()
.all(|byte| byte.is_ascii_alphanumeric() || byte == b'-')
{
return Err(Error::InvalidRequest {
reason: "language_code must contain only ASCII letters, digits, or `-`".to_owned(),
});
}
if language_code.starts_with('-')
|| language_code.ends_with('-')
|| language_code.contains("--")
{
return Err(Error::InvalidRequest {
reason: "language_code must not contain empty subtags".to_owned(),
});
}
Ok(())
}
fn validate_language_code_field(language_code: Option<&str>) -> Result<()> {
if let Some(language_code) = language_code {
validate_language_code_value(language_code)?;
}
Ok(())
}
#[derive(Clone, Copy)]
enum TextLayout {
SingleLine,
MultiLine,
}
fn validate_optional_text(
field: &str,
value: Option<&str>,
layout: TextLayout,
max_chars: usize,
) -> Result<()> {
let Some(value) = value else {
return Ok(());
};
let length = value.chars().count();
if length > max_chars {
return Err(Error::InvalidRequest {
reason: format!("{field} must be at most {max_chars} characters"),
});
}
if value
.chars()
.any(|character| is_disallowed_control(character, layout))
{
return Err(Error::InvalidRequest {
reason: format!("{field} must not contain control characters"),
});
}
Ok(())
}
fn is_disallowed_control(character: char, layout: TextLayout) -> bool {
match layout {
TextLayout::SingleLine => character.is_control(),
TextLayout::MultiLine => character.is_control() && !matches!(character, '\n' | '\r' | '\t'),
}
}
#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
#[serde(tag = "type", rename_all = "snake_case")]
pub enum BotCommandScope {
Default,
AllPrivateChats,
AllGroupChats,
AllChatAdministrators,
Chat { chat_id: ChatId },
ChatAdministrators { chat_id: ChatId },
ChatMember { chat_id: ChatId, user_id: UserId },
}
#[derive(Clone, Debug, Serialize)]
pub struct SetMyCommandsRequest {
pub commands: Vec<BotCommand>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub scope: Option<BotCommandScope>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub language_code: Option<String>,
}
impl SetMyCommandsRequest {
pub fn new(commands: Vec<BotCommand>) -> Result<Self> {
validate_bot_commands(&commands)?;
Ok(Self {
commands,
scope: None,
language_code: None,
})
}
pub fn scope(mut self, scope: BotCommandScope) -> Self {
self.scope = Some(scope);
self
}
pub fn language_code(mut self, language_code: impl Into<String>) -> Result<Self> {
let language_code = language_code.into();
validate_language_code_value(&language_code)?;
self.language_code = Some(language_code);
Ok(self)
}
pub fn validate(&self) -> Result<()> {
validate_bot_commands(&self.commands)?;
validate_language_code_field(self.language_code.as_deref())
}
}
#[derive(Clone, Debug, Default, Serialize)]
pub struct GetMyCommandsRequest {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub scope: Option<BotCommandScope>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub language_code: Option<String>,
}
impl GetMyCommandsRequest {
pub fn validate(&self) -> Result<()> {
validate_language_code_field(self.language_code.as_deref())
}
}
#[derive(Clone, Debug, Default, Serialize)]
pub struct DeleteMyCommandsRequest {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub scope: Option<BotCommandScope>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub language_code: Option<String>,
}
impl DeleteMyCommandsRequest {
pub fn validate(&self) -> Result<()> {
validate_language_code_field(self.language_code.as_deref())
}
}
#[derive(Clone, Debug, Default, Serialize)]
pub struct SetMyNameRequest {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub name: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub language_code: Option<String>,
}
impl SetMyNameRequest {
pub fn validate(&self) -> Result<()> {
validate_optional_text(
"name",
self.name.as_deref(),
TextLayout::SingleLine,
MAX_BOT_NAME_CHARS,
)?;
validate_language_code_field(self.language_code.as_deref())
}
}
#[derive(Clone, Debug, Default, Serialize)]
pub struct GetMyNameRequest {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub language_code: Option<String>,
}
impl GetMyNameRequest {
pub fn validate(&self) -> Result<()> {
validate_language_code_field(self.language_code.as_deref())
}
}
#[derive(Clone, Debug, Default, Serialize)]
pub struct SetMyDescriptionRequest {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub description: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub language_code: Option<String>,
}
impl SetMyDescriptionRequest {
pub fn validate(&self) -> Result<()> {
validate_optional_text(
"description",
self.description.as_deref(),
TextLayout::MultiLine,
MAX_BOT_DESCRIPTION_CHARS,
)?;
validate_language_code_field(self.language_code.as_deref())
}
}
#[derive(Clone, Debug, Default, Serialize)]
pub struct GetMyDescriptionRequest {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub language_code: Option<String>,
}
impl GetMyDescriptionRequest {
pub fn validate(&self) -> Result<()> {
validate_language_code_field(self.language_code.as_deref())
}
}
#[derive(Clone, Debug, Default, Serialize)]
pub struct SetMyShortDescriptionRequest {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub short_description: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub language_code: Option<String>,
}
impl SetMyShortDescriptionRequest {
pub fn validate(&self) -> Result<()> {
validate_optional_text(
"short_description",
self.short_description.as_deref(),
TextLayout::SingleLine,
MAX_BOT_SHORT_DESCRIPTION_CHARS,
)?;
validate_language_code_field(self.language_code.as_deref())
}
}
#[derive(Clone, Debug, Default, Serialize)]
pub struct GetMyShortDescriptionRequest {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub language_code: Option<String>,
}
impl GetMyShortDescriptionRequest {
pub fn validate(&self) -> Result<()> {
validate_language_code_field(self.language_code.as_deref())
}
}
#[derive(Clone, Debug, Deserialize, Serialize)]
pub struct BotName {
pub name: String,
}
#[derive(Clone, Debug, Deserialize, Serialize)]
pub struct BotDescription {
pub description: String,
}
#[derive(Clone, Debug, Deserialize, Serialize)]
pub struct BotShortDescription {
pub short_description: String,
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn validates_bot_command_contract() -> Result<()> {
assert!(BotCommand::new("start_1", "start the bot").is_ok());
for command in [
"",
"1start",
"_start",
"Start",
"/start",
"start-bot",
"a_very_long_command_name_over_32_chars",
] {
assert!(matches!(
BotCommand::new(command, "valid description"),
Err(Error::InvalidRequest { .. })
));
}
for description in ["", "go", "bad\ntext"] {
assert!(matches!(
BotCommand::new("start", description),
Err(Error::InvalidRequest { .. })
));
}
Ok(())
}
#[test]
fn validates_set_my_commands_collection() -> Result<()> {
assert!(matches!(
SetMyCommandsRequest::new(Vec::new()),
Err(Error::InvalidRequest { .. })
));
let duplicate = vec![
BotCommand::new("start", "start the bot")?,
BotCommand::new("start", "start again")?,
];
assert!(matches!(
SetMyCommandsRequest::new(duplicate),
Err(Error::InvalidRequest { .. })
));
let too_many = (0..101)
.map(|index| BotCommand::new(format!("cmd{index}"), "valid description"))
.collect::<Result<Vec<_>>>()?;
assert!(matches!(
SetMyCommandsRequest::new(too_many),
Err(Error::InvalidRequest { .. })
));
Ok(())
}
#[test]
fn validates_language_codes_on_public_requests() -> Result<()> {
SetMyCommandsRequest::new(vec![BotCommand::new("start", "start the bot")?])?
.language_code("zh-hans")?
.validate()?;
for language_code in ["", "-", "en-", "en--us", "en us", "en_us"] {
let request = GetMyCommandsRequest {
language_code: Some(language_code.to_owned()),
..GetMyCommandsRequest::default()
};
assert!(matches!(
request.validate(),
Err(Error::InvalidRequest { .. })
));
}
Ok(())
}
#[test]
fn validates_bot_profile_text_limits() {
let valid = SetMyNameRequest {
name: Some(String::new()),
..SetMyNameRequest::default()
};
assert!(valid.validate().is_ok());
let invalid_name = SetMyNameRequest {
name: Some("a".repeat(MAX_BOT_NAME_CHARS + 1)),
..SetMyNameRequest::default()
};
assert!(matches!(
invalid_name.validate(),
Err(Error::InvalidRequest { .. })
));
let multiline_name = SetMyNameRequest {
name: Some("bot\nname".to_owned()),
..SetMyNameRequest::default()
};
assert!(matches!(
multiline_name.validate(),
Err(Error::InvalidRequest { .. })
));
let invalid_description = SetMyDescriptionRequest {
description: Some("a".repeat(MAX_BOT_DESCRIPTION_CHARS + 1)),
..SetMyDescriptionRequest::default()
};
assert!(matches!(
invalid_description.validate(),
Err(Error::InvalidRequest { .. })
));
let multiline_description = SetMyDescriptionRequest {
description: Some("hello\nworld".to_owned()),
..SetMyDescriptionRequest::default()
};
assert!(multiline_description.validate().is_ok());
let invalid_short_description = SetMyShortDescriptionRequest {
short_description: Some("a".repeat(MAX_BOT_SHORT_DESCRIPTION_CHARS + 1)),
..SetMyShortDescriptionRequest::default()
};
assert!(matches!(
invalid_short_description.validate(),
Err(Error::InvalidRequest { .. })
));
}
}