use kcode_telegram_transport_state::{DeliveryMedia, SentMessage};
use teloxide::{
payloads::SendDocumentSetters,
requests::Request as TelegramRequest,
types::{InputFile, ReplyParameters},
};
use super::*;
const TELEGRAM_CAPTION_LIMIT: usize = 1_024;
const MAX_FILE_NAME_CHARACTERS: usize = 255;
const MAX_MIME_TYPE_CHARACTERS: usize = 255;
#[derive(Debug, Default)]
struct OutboundFile {
conversation_id: Option<String>,
expected_conversation_id: Option<String>,
kind: Option<String>,
explicit_file_name: Option<String>,
mime_type: Option<String>,
caption: Option<String>,
complete: bool,
bytes: Option<Vec<u8>>,
}
fn outbound_file(
attachment: Attachment,
maximum_bytes: usize,
conversation_id: Option<String>,
expected_conversation_id: Option<String>,
complete: bool,
) -> Result<OutboundFile, ApiError> {
if attachment.bytes.len() > maximum_bytes {
return Err(ApiError::bad(format!(
"The file exceeds the configured {maximum_bytes}-byte Telegram media limit."
)));
}
Ok(OutboundFile {
conversation_id,
expected_conversation_id,
kind: attachment.kind,
explicit_file_name: attachment.file_name,
mime_type: attachment.media_type,
caption: attachment.caption,
complete,
bytes: Some(attachment.bytes),
})
}
impl Service {
pub async fn send_cold_private_attachment(
&self,
telegram_user_id: i64,
attachment: Attachment,
) -> Result<Value, Error> {
let input = outbound_file(attachment, self.state.max_voice_bytes, None, None, false)?;
send_private_attachment(self.state.clone(), telegram_user_id, input).await
}
pub async fn send_private_attachment(
&self,
telegram_user_id: i64,
conversation_id: String,
expected_conversation_id: Option<String>,
attachment: Attachment,
) -> Result<Value, Error> {
let input = outbound_file(
attachment,
self.state.max_voice_bytes,
Some(conversation_id),
expected_conversation_id,
false,
)?;
send_private_attachment(self.state.clone(), telegram_user_id, input).await
}
pub async fn send_group_attachment(
&self,
group_id: String,
attachment: Attachment,
) -> Result<Value, Error> {
let input = outbound_file(attachment, self.state.max_voice_bytes, None, None, false)?;
send_group_attachment(self.state.clone(), group_id, input).await
}
pub async fn send_event_attachment(
&self,
event_id: String,
conversation_id: String,
attachment: Attachment,
complete: bool,
) -> Result<Value, Error> {
let native = attachment.kind.is_some();
let input = outbound_file(
attachment,
self.state.max_voice_bytes,
Some(conversation_id),
None,
complete,
)?;
if native {
send_event_media(self.state.clone(), event_id, input).await
} else {
send_event_file(self.state.clone(), event_id, input).await
}
}
}
async fn send_private_attachment(
state: AppState,
telegram_user_id: i64,
input: OutboundFile,
) -> Result<Value, ApiError> {
let started = Instant::now();
let delivery = match input.conversation_id.as_ref() {
Some(conversation_id) => Some(
state
.transport
.private_delivery(
telegram_user_id,
conversation_id.clone(),
input.expected_conversation_id.clone(),
)
.map_err(ApiError::state)?,
),
None if input.expected_conversation_id.is_some() => {
return Err(ApiError::bad(
"expectedConversationId requires conversationId.",
));
}
None => None,
};
let cold_delivery;
let target = if let Some(delivery) = delivery.as_ref() {
delivery
} else {
cold_delivery = state
.transport
.cold_private_delivery(telegram_user_id)
.map_err(ApiError::state)?;
&cold_delivery
};
let kind = input
.kind
.as_deref()
.map(|value| {
native_media::NativeMediaKind::parse(value).ok_or_else(|| {
ApiError::bad(
"kind must be photo, video, animation, audio, video_note, or sticker.",
)
})
})
.transpose()?;
validate_caption(input.caption.as_deref(), kind, "attachment")?;
let caption = input.caption.as_deref().and_then(nonempty_verbatim);
let bytes = required_bytes(input.bytes)?;
let supplied_file_name = input.explicit_file_name.as_deref();
if let Some(file_name) = supplied_file_name {
validate_file_name(file_name)?;
}
let mime_type = input.mime_type.as_deref().unwrap_or_else(|| {
kind.map(|kind| kind.fallback_mime(supplied_file_name))
.unwrap_or("application/octet-stream")
});
validate_mime_type(mime_type)?;
let file_name = supplied_file_name
.map(ToOwned::to_owned)
.unwrap_or_else(|| {
kind.map(|kind| kind.default_file_name(telegram_user_id, mime_type))
.unwrap_or_else(|| format!("attachment-{telegram_user_id}.bin"))
});
validate_file_name(&file_name)?;
let bot = state.bot.as_ref().ok_or_else(ApiError::unavailable)?;
let sent = send_attachment(
bot,
TelegramAttachment {
chat_id: target.chat_id(),
kind,
bytes,
file_name: &file_name,
caption,
reply_parameters: None,
label: "private attachment",
},
)
.await?;
if let Some(delivery) = delivery {
delivery.record_accepted().map_err(ApiError::state)?;
tracing::info!(
%telegram_user_id,
conversation_id=%input.conversation_id.as_deref().expect("bound delivery"),
duration_ms=started.elapsed().as_millis(),
"Telegram session-bound direct-message attachment"
);
} else {
tracing::info!(
%telegram_user_id,
duration_ms=started.elapsed().as_millis(),
"Telegram cold direct-message attachment"
);
}
let mut response = json!({
"telegramUserId":telegram_user_id,
"kind":kind.map(|kind| kind.as_str()).unwrap_or("document"),
"fileName":file_name,
"mimeType":mime_type,
"telegramMessageId":i64::from(sent.id.0),
});
if let Some(conversation_id) = input.conversation_id {
response["conversationId"] = json!(conversation_id);
}
Ok(response)
}
async fn send_group_attachment(
state: AppState,
group_id: String,
input: OutboundFile,
) -> Result<Value, ApiError> {
let started = Instant::now();
let group_id = validate_opaque_group_id(&group_id)?.to_owned();
let kind = input
.kind
.as_deref()
.map(|value| {
native_media::NativeMediaKind::parse(value).ok_or_else(|| {
ApiError::bad(
"kind must be photo, video, animation, audio, video_note, or sticker.",
)
})
})
.transpose()?;
validate_caption(input.caption.as_deref(), kind, "attachment")?;
let caption = input.caption.as_deref().and_then(nonempty_verbatim);
let bytes = required_bytes(input.bytes)?;
let supplied_file_name = input.explicit_file_name.as_deref();
if let Some(file_name) = supplied_file_name {
validate_file_name(file_name)?;
}
let mime_type = input.mime_type.as_deref().unwrap_or_else(|| {
kind.map(|kind| kind.fallback_mime(supplied_file_name))
.unwrap_or("application/octet-stream")
});
validate_mime_type(mime_type)?;
let file_name = supplied_file_name
.map(ToOwned::to_owned)
.unwrap_or_else(|| {
kind.map(|kind| kind.default_file_name(0, mime_type))
.unwrap_or_else(|| "attachment.bin".into())
});
validate_file_name(&file_name)?;
let delivery = validated_group_delivery(&state, &group_id).await?;
let bot = state.bot.as_ref().ok_or_else(ApiError::unavailable)?;
let sent = send_attachment(
bot,
TelegramAttachment {
chat_id: delivery.chat_id(),
kind,
bytes,
file_name: &file_name,
caption,
reply_parameters: None,
label: "cold group attachment",
},
)
.await?;
tracing::info!(
%group_id,
duration_ms=started.elapsed().as_millis(),
"Telegram cold group attachment"
);
Ok(json!({
"groupId":group_id,
"kind":kind.map(|kind| kind.as_str()).unwrap_or("document"),
"fileName":file_name,
"mimeType":mime_type,
"telegramMessageId":i64::from(sent.id.0),
}))
}
async fn send_event_file(
state: AppState,
event_id: String,
input: OutboundFile,
) -> Result<Value, ApiError> {
let conversation_id = input
.conversation_id
.as_deref()
.ok_or_else(|| ApiError::bad("conversationId is required."))?;
let file_name = input
.explicit_file_name
.as_deref()
.ok_or_else(|| ApiError::bad("The file must have a fileName."))?;
validate_file_name(file_name)?;
let mime_type = input
.mime_type
.as_deref()
.unwrap_or("application/octet-stream");
validate_mime_type(mime_type)?;
let caption = input.caption.as_deref().and_then(nonempty_verbatim);
if caption.is_some_and(|value| value.encode_utf16().count() > TELEGRAM_CAPTION_LIMIT) {
return Err(ApiError::bad(
"The Telegram file caption exceeds 1024 UTF-16 code units.",
));
}
let bytes = required_bytes(input.bytes)?;
let delivery = state
.transport
.event_delivery(&event_id, conversation_id)
.map_err(ApiError::state)?;
let event = delivery.event();
let reply = group_reply_parameters(event);
let bot = state.bot.as_ref().ok_or_else(ApiError::unavailable)?;
let sent = send_attachment(
bot,
TelegramAttachment {
chat_id: event.chat_id,
kind: None,
bytes: bytes.clone(),
file_name,
caption,
reply_parameters: reply,
label: "file",
},
)
.await?;
let sent_message = SentMessage {
message_id: i64::from(sent.id.0),
text: sent.text().unwrap_or("").into(),
sent_at: sent.date.to_rfc3339(),
};
let event = delivery
.record_document(
sent_message,
DeliveryMedia {
kind: "document".into(),
bytes,
mime_type: mime_type.into(),
file_name: file_name.into(),
caption: caption.map(ToOwned::to_owned),
duration_seconds: None,
},
input.complete,
)
.map_err(ApiError::state)?;
Ok(json!({
"event":event,
"fileName":file_name,
"mimeType":mime_type,
"telegramMessageId":i64::from(sent.id.0),
"complete":input.complete,
}))
}
async fn send_event_media(
state: AppState,
event_id: String,
input: OutboundFile,
) -> Result<Value, ApiError> {
let conversation_id = input
.conversation_id
.as_deref()
.ok_or_else(|| ApiError::bad("conversationId is required."))?;
let kind_text = input
.kind
.as_deref()
.ok_or_else(|| ApiError::bad("kind is required."))?;
let kind = native_media::NativeMediaKind::parse(kind_text).ok_or_else(|| {
ApiError::bad("kind must be photo, video, animation, audio, video_note, or sticker.")
})?;
validate_caption(input.caption.as_deref(), Some(kind), "media")?;
let caption = input.caption.as_deref().and_then(nonempty_verbatim);
let bytes = required_bytes(input.bytes)?;
let delivery = state
.transport
.event_delivery(&event_id, conversation_id)
.map_err(ApiError::state)?;
let event = delivery.event();
let supplied_file_name = input.explicit_file_name.as_deref();
if let Some(file_name) = supplied_file_name {
validate_file_name(file_name)?;
}
let mime_type = input
.mime_type
.as_deref()
.unwrap_or_else(|| kind.fallback_mime(supplied_file_name));
validate_mime_type(mime_type)?;
let file_name = supplied_file_name
.map(ToOwned::to_owned)
.unwrap_or_else(|| kind.default_file_name(event.message_id, mime_type));
validate_file_name(&file_name)?;
let reply = group_reply_parameters(event);
let bot = state.bot.as_ref().ok_or_else(ApiError::unavailable)?;
let sent = send_attachment(
bot,
TelegramAttachment {
chat_id: event.chat_id,
kind: Some(kind),
bytes: bytes.clone(),
file_name: &file_name,
caption,
reply_parameters: reply,
label: "native media",
},
)
.await?;
let duration_seconds = native_media::message_duration(&sent, kind);
let sent_message = SentMessage {
message_id: i64::from(sent.id.0),
text: sent.text().unwrap_or("").into(),
sent_at: sent.date.to_rfc3339(),
};
let event = delivery
.record_native_media(
sent_message,
DeliveryMedia {
kind: kind.as_str().into(),
bytes,
mime_type: mime_type.into(),
file_name: file_name.clone(),
caption: caption.map(ToOwned::to_owned),
duration_seconds,
},
input.complete,
)
.map_err(ApiError::state)?;
Ok(json!({
"event":event,
"kind":kind.as_str(),
"fileName":file_name,
"mimeType":mime_type,
"telegramMessageId":i64::from(sent.id.0),
"complete":input.complete,
}))
}
struct TelegramAttachment<'a> {
chat_id: i64,
kind: Option<native_media::NativeMediaKind>,
bytes: Vec<u8>,
file_name: &'a str,
caption: Option<&'a str>,
reply_parameters: Option<ReplyParameters>,
label: &'a str,
}
async fn send_attachment(
bot: &Bot,
attachment: TelegramAttachment<'_>,
) -> Result<Message, ApiError> {
let result = if let Some(kind) = attachment.kind {
native_media::send_native_media(
bot,
attachment.chat_id,
kind,
&attachment.bytes,
attachment.file_name,
attachment.caption,
attachment.reply_parameters,
)
.await
} else {
let mut request = bot.send_document(
ChatId(attachment.chat_id),
InputFile::memory(attachment.bytes).file_name(attachment.file_name.to_owned()),
);
if let Some(caption) = attachment.caption {
request = request.caption(caption.to_owned());
}
if let Some(reply_parameters) = attachment.reply_parameters {
request = request.reply_parameters(reply_parameters);
}
telegram_requests::retry_request("send_document", || request.clone().send()).await
};
result.map_err(|error| {
tracing::warn!(
chat_id = attachment.chat_id,
error_class = telegram_requests::request_error_class(&error),
label = attachment.label,
"Telegram attachment send failed"
);
let message = if attachment.label == "private attachment"
|| attachment.label == "cold group attachment"
{
"Telegram did not accept the attachment."
} else if attachment.kind.is_some() {
"Telegram did not accept the native media."
} else {
"Telegram did not accept the file."
};
ApiError::new("telegram_send_failed", message)
})
}
fn required_bytes(bytes: Option<Vec<u8>>) -> Result<Vec<u8>, ApiError> {
let bytes = bytes.ok_or_else(|| ApiError::bad("A nonempty file part is required."))?;
if bytes.is_empty() {
return Err(ApiError::bad("A nonempty file part is required."));
}
Ok(bytes)
}
fn validate_caption(
caption: Option<&str>,
kind: Option<native_media::NativeMediaKind>,
label: &str,
) -> Result<(), ApiError> {
if caption.is_some() && kind.is_some_and(|kind| !kind.accepts_caption()) {
return Err(ApiError::bad(format!(
"caption is not accepted for {} media.",
kind.expect("checked as present").as_str()
)));
}
let caption = caption.and_then(nonempty_verbatim);
if caption.is_some_and(|value| value.encode_utf16().count() > TELEGRAM_CAPTION_LIMIT) {
return Err(ApiError::bad(format!(
"The Telegram {label} caption exceeds 1024 UTF-16 code units."
)));
}
Ok(())
}
fn group_reply_parameters(event: &Event) -> Option<ReplyParameters> {
(event.session_kind == "group")
.then(|| i32::try_from(event.message_id).ok())
.flatten()
.map(|message_id| {
ReplyParameters::new(teloxide::types::MessageId(message_id))
.allow_sending_without_reply()
})
}
fn validate_file_name(value: &str) -> Result<(), ApiError> {
if value.trim().is_empty()
|| value.chars().count() > MAX_FILE_NAME_CHARACTERS
|| value
.chars()
.any(|character| character.is_control() || matches!(character, '/' | '\\'))
{
return Err(ApiError::bad(
"fileName must be a nonempty path-free name of at most 255 characters.",
));
}
Ok(())
}
fn validate_mime_type(value: &str) -> Result<(), ApiError> {
if value.trim().is_empty()
|| value.chars().count() > MAX_MIME_TYPE_CHARACTERS
|| value.chars().any(char::is_control)
{
return Err(ApiError::bad(
"The file content type must be a nonempty value of at most 255 characters.",
));
}
Ok(())
}