1use kcode_telegram_transport_state::{DeliveryMedia, SentMessage};
2use teloxide::{
3 payloads::SendDocumentSetters,
4 requests::Request as TelegramRequest,
5 types::{InputFile, ReplyParameters},
6};
7
8use super::*;
9
10const TELEGRAM_CAPTION_LIMIT: usize = 1_024;
11const MAX_FILE_NAME_CHARACTERS: usize = 255;
12const MAX_MIME_TYPE_CHARACTERS: usize = 255;
13
14#[derive(Debug, Default)]
15struct OutboundFile {
16 conversation_id: Option<String>,
17 expected_conversation_id: Option<String>,
18 kind: Option<String>,
19 explicit_file_name: Option<String>,
20 mime_type: Option<String>,
21 caption: Option<String>,
22 complete: bool,
23 bytes: Option<Vec<u8>>,
24}
25
26fn outbound_file(
27 attachment: Attachment,
28 maximum_bytes: usize,
29 conversation_id: Option<String>,
30 expected_conversation_id: Option<String>,
31 complete: bool,
32) -> Result<OutboundFile, ApiError> {
33 if attachment.bytes.len() > maximum_bytes {
34 return Err(ApiError::bad(format!(
35 "The file exceeds the configured {maximum_bytes}-byte Telegram media limit."
36 )));
37 }
38 Ok(OutboundFile {
39 conversation_id,
40 expected_conversation_id,
41 kind: attachment.kind,
42 explicit_file_name: attachment.file_name,
43 mime_type: attachment.media_type,
44 caption: attachment.caption,
45 complete,
46 bytes: Some(attachment.bytes),
47 })
48}
49
50impl Service {
51 pub async fn send_cold_private_attachment(
52 &self,
53 telegram_user_id: i64,
54 attachment: Attachment,
55 ) -> Result<Value, Error> {
56 let input = outbound_file(attachment, self.state.max_voice_bytes, None, None, false)?;
57 send_private_attachment(self.state.clone(), telegram_user_id, input).await
58 }
59
60 pub async fn send_private_attachment(
61 &self,
62 telegram_user_id: i64,
63 conversation_id: String,
64 expected_conversation_id: Option<String>,
65 attachment: Attachment,
66 ) -> Result<Value, Error> {
67 let input = outbound_file(
68 attachment,
69 self.state.max_voice_bytes,
70 Some(conversation_id),
71 expected_conversation_id,
72 false,
73 )?;
74 send_private_attachment(self.state.clone(), telegram_user_id, input).await
75 }
76
77 pub async fn send_group_attachment(
78 &self,
79 group_id: String,
80 attachment: Attachment,
81 ) -> Result<Value, Error> {
82 let input = outbound_file(attachment, self.state.max_voice_bytes, None, None, false)?;
83 send_group_attachment(self.state.clone(), group_id, input).await
84 }
85
86 pub async fn send_event_attachment(
87 &self,
88 event_id: String,
89 conversation_id: String,
90 attachment: Attachment,
91 complete: bool,
92 ) -> Result<Value, Error> {
93 let native = attachment.kind.is_some();
94 let input = outbound_file(
95 attachment,
96 self.state.max_voice_bytes,
97 Some(conversation_id),
98 None,
99 complete,
100 )?;
101 if native {
102 send_event_media(self.state.clone(), event_id, input).await
103 } else {
104 send_event_file(self.state.clone(), event_id, input).await
105 }
106 }
107}
108
109async fn send_private_attachment(
110 state: AppState,
111 telegram_user_id: i64,
112 input: OutboundFile,
113) -> Result<Value, ApiError> {
114 let started = Instant::now();
115 let delivery = match input.conversation_id.as_ref() {
116 Some(conversation_id) => Some(
117 state
118 .transport
119 .private_delivery(
120 telegram_user_id,
121 conversation_id.clone(),
122 input.expected_conversation_id.clone(),
123 )
124 .map_err(ApiError::state)?,
125 ),
126 None if input.expected_conversation_id.is_some() => {
127 return Err(ApiError::bad(
128 "expectedConversationId requires conversationId.",
129 ));
130 }
131 None => None,
132 };
133 let cold_delivery;
134 let target = if let Some(delivery) = delivery.as_ref() {
135 delivery
136 } else {
137 cold_delivery = state
138 .transport
139 .cold_private_delivery(telegram_user_id)
140 .map_err(ApiError::state)?;
141 &cold_delivery
142 };
143 let kind = input
144 .kind
145 .as_deref()
146 .map(|value| {
147 native_media::NativeMediaKind::parse(value).ok_or_else(|| {
148 ApiError::bad(
149 "kind must be photo, video, animation, audio, video_note, or sticker.",
150 )
151 })
152 })
153 .transpose()?;
154 validate_caption(input.caption.as_deref(), kind, "attachment")?;
155 let caption = input.caption.as_deref().and_then(nonempty_verbatim);
156 let bytes = required_bytes(input.bytes)?;
157 let supplied_file_name = input.explicit_file_name.as_deref();
158 if let Some(file_name) = supplied_file_name {
159 validate_file_name(file_name)?;
160 }
161 let mime_type = input.mime_type.as_deref().unwrap_or_else(|| {
162 kind.map(|kind| kind.fallback_mime(supplied_file_name))
163 .unwrap_or("application/octet-stream")
164 });
165 validate_mime_type(mime_type)?;
166 let file_name = supplied_file_name
167 .map(ToOwned::to_owned)
168 .unwrap_or_else(|| {
169 kind.map(|kind| kind.default_file_name(telegram_user_id, mime_type))
170 .unwrap_or_else(|| format!("attachment-{telegram_user_id}.bin"))
171 });
172 validate_file_name(&file_name)?;
173 let bot = state.bot.as_ref().ok_or_else(ApiError::unavailable)?;
174 let sent = send_attachment(
175 bot,
176 TelegramAttachment {
177 chat_id: target.chat_id(),
178 kind,
179 bytes,
180 file_name: &file_name,
181 caption,
182 reply_parameters: None,
183 label: "private attachment",
184 },
185 )
186 .await?;
187 if let Some(delivery) = delivery {
188 delivery.record_accepted().map_err(ApiError::state)?;
189 tracing::info!(
190 %telegram_user_id,
191 conversation_id=%input.conversation_id.as_deref().expect("bound delivery"),
192 duration_ms=started.elapsed().as_millis(),
193 "Telegram session-bound direct-message attachment"
194 );
195 } else {
196 tracing::info!(
197 %telegram_user_id,
198 duration_ms=started.elapsed().as_millis(),
199 "Telegram cold direct-message attachment"
200 );
201 }
202 let mut response = json!({
203 "telegramUserId":telegram_user_id,
204 "kind":kind.map(|kind| kind.as_str()).unwrap_or("document"),
205 "fileName":file_name,
206 "mimeType":mime_type,
207 "telegramMessageId":i64::from(sent.id.0),
208 });
209 if let Some(conversation_id) = input.conversation_id {
210 response["conversationId"] = json!(conversation_id);
211 }
212 Ok(response)
213}
214
215async fn send_group_attachment(
216 state: AppState,
217 group_id: String,
218 input: OutboundFile,
219) -> Result<Value, ApiError> {
220 let started = Instant::now();
221 let group_id = validate_opaque_group_id(&group_id)?.to_owned();
222 let kind = input
223 .kind
224 .as_deref()
225 .map(|value| {
226 native_media::NativeMediaKind::parse(value).ok_or_else(|| {
227 ApiError::bad(
228 "kind must be photo, video, animation, audio, video_note, or sticker.",
229 )
230 })
231 })
232 .transpose()?;
233 validate_caption(input.caption.as_deref(), kind, "attachment")?;
234 let caption = input.caption.as_deref().and_then(nonempty_verbatim);
235 let bytes = required_bytes(input.bytes)?;
236 let supplied_file_name = input.explicit_file_name.as_deref();
237 if let Some(file_name) = supplied_file_name {
238 validate_file_name(file_name)?;
239 }
240 let mime_type = input.mime_type.as_deref().unwrap_or_else(|| {
241 kind.map(|kind| kind.fallback_mime(supplied_file_name))
242 .unwrap_or("application/octet-stream")
243 });
244 validate_mime_type(mime_type)?;
245 let file_name = supplied_file_name
246 .map(ToOwned::to_owned)
247 .unwrap_or_else(|| {
248 kind.map(|kind| kind.default_file_name(0, mime_type))
249 .unwrap_or_else(|| "attachment.bin".into())
250 });
251 validate_file_name(&file_name)?;
252 let delivery = validated_group_delivery(&state, &group_id).await?;
253 let bot = state.bot.as_ref().ok_or_else(ApiError::unavailable)?;
254 let sent = send_attachment(
255 bot,
256 TelegramAttachment {
257 chat_id: delivery.chat_id(),
258 kind,
259 bytes,
260 file_name: &file_name,
261 caption,
262 reply_parameters: None,
263 label: "cold group attachment",
264 },
265 )
266 .await?;
267 tracing::info!(
268 %group_id,
269 duration_ms=started.elapsed().as_millis(),
270 "Telegram cold group attachment"
271 );
272 Ok(json!({
273 "groupId":group_id,
274 "kind":kind.map(|kind| kind.as_str()).unwrap_or("document"),
275 "fileName":file_name,
276 "mimeType":mime_type,
277 "telegramMessageId":i64::from(sent.id.0),
278 }))
279}
280
281async fn send_event_file(
282 state: AppState,
283 event_id: String,
284 input: OutboundFile,
285) -> Result<Value, ApiError> {
286 let conversation_id = input
287 .conversation_id
288 .as_deref()
289 .ok_or_else(|| ApiError::bad("conversationId is required."))?;
290 let file_name = input
291 .explicit_file_name
292 .as_deref()
293 .ok_or_else(|| ApiError::bad("The file must have a fileName."))?;
294 validate_file_name(file_name)?;
295 let mime_type = input
296 .mime_type
297 .as_deref()
298 .unwrap_or("application/octet-stream");
299 validate_mime_type(mime_type)?;
300 let caption = input.caption.as_deref().and_then(nonempty_verbatim);
301 if caption.is_some_and(|value| value.encode_utf16().count() > TELEGRAM_CAPTION_LIMIT) {
302 return Err(ApiError::bad(
303 "The Telegram file caption exceeds 1024 UTF-16 code units.",
304 ));
305 }
306 let bytes = required_bytes(input.bytes)?;
307 let delivery = state
308 .transport
309 .event_delivery(&event_id, conversation_id)
310 .map_err(ApiError::state)?;
311 let event = delivery.event();
312 let reply = group_reply_parameters(event);
313 let bot = state.bot.as_ref().ok_or_else(ApiError::unavailable)?;
314 let sent = send_attachment(
315 bot,
316 TelegramAttachment {
317 chat_id: event.chat_id,
318 kind: None,
319 bytes: bytes.clone(),
320 file_name,
321 caption,
322 reply_parameters: reply,
323 label: "file",
324 },
325 )
326 .await?;
327 let sent_message = SentMessage {
328 message_id: i64::from(sent.id.0),
329 text: sent.text().unwrap_or("").into(),
330 sent_at: sent.date.to_rfc3339(),
331 };
332 let event = delivery
333 .record_document(
334 sent_message,
335 DeliveryMedia {
336 kind: "document".into(),
337 bytes,
338 mime_type: mime_type.into(),
339 file_name: file_name.into(),
340 caption: caption.map(ToOwned::to_owned),
341 duration_seconds: None,
342 },
343 input.complete,
344 )
345 .map_err(ApiError::state)?;
346 Ok(json!({
347 "event":event,
348 "fileName":file_name,
349 "mimeType":mime_type,
350 "telegramMessageId":i64::from(sent.id.0),
351 "complete":input.complete,
352 }))
353}
354
355async fn send_event_media(
356 state: AppState,
357 event_id: String,
358 input: OutboundFile,
359) -> Result<Value, ApiError> {
360 let conversation_id = input
361 .conversation_id
362 .as_deref()
363 .ok_or_else(|| ApiError::bad("conversationId is required."))?;
364 let kind_text = input
365 .kind
366 .as_deref()
367 .ok_or_else(|| ApiError::bad("kind is required."))?;
368 let kind = native_media::NativeMediaKind::parse(kind_text).ok_or_else(|| {
369 ApiError::bad("kind must be photo, video, animation, audio, video_note, or sticker.")
370 })?;
371 validate_caption(input.caption.as_deref(), Some(kind), "media")?;
372 let caption = input.caption.as_deref().and_then(nonempty_verbatim);
373 let bytes = required_bytes(input.bytes)?;
374 let delivery = state
375 .transport
376 .event_delivery(&event_id, conversation_id)
377 .map_err(ApiError::state)?;
378 let event = delivery.event();
379 let supplied_file_name = input.explicit_file_name.as_deref();
380 if let Some(file_name) = supplied_file_name {
381 validate_file_name(file_name)?;
382 }
383 let mime_type = input
384 .mime_type
385 .as_deref()
386 .unwrap_or_else(|| kind.fallback_mime(supplied_file_name));
387 validate_mime_type(mime_type)?;
388 let file_name = supplied_file_name
389 .map(ToOwned::to_owned)
390 .unwrap_or_else(|| kind.default_file_name(event.message_id, mime_type));
391 validate_file_name(&file_name)?;
392 let reply = group_reply_parameters(event);
393 let bot = state.bot.as_ref().ok_or_else(ApiError::unavailable)?;
394 let sent = send_attachment(
395 bot,
396 TelegramAttachment {
397 chat_id: event.chat_id,
398 kind: Some(kind),
399 bytes: bytes.clone(),
400 file_name: &file_name,
401 caption,
402 reply_parameters: reply,
403 label: "native media",
404 },
405 )
406 .await?;
407 let duration_seconds = native_media::message_duration(&sent, kind);
408 let sent_message = SentMessage {
409 message_id: i64::from(sent.id.0),
410 text: sent.text().unwrap_or("").into(),
411 sent_at: sent.date.to_rfc3339(),
412 };
413 let event = delivery
414 .record_native_media(
415 sent_message,
416 DeliveryMedia {
417 kind: kind.as_str().into(),
418 bytes,
419 mime_type: mime_type.into(),
420 file_name: file_name.clone(),
421 caption: caption.map(ToOwned::to_owned),
422 duration_seconds,
423 },
424 input.complete,
425 )
426 .map_err(ApiError::state)?;
427 Ok(json!({
428 "event":event,
429 "kind":kind.as_str(),
430 "fileName":file_name,
431 "mimeType":mime_type,
432 "telegramMessageId":i64::from(sent.id.0),
433 "complete":input.complete,
434 }))
435}
436
437struct TelegramAttachment<'a> {
438 chat_id: i64,
439 kind: Option<native_media::NativeMediaKind>,
440 bytes: Vec<u8>,
441 file_name: &'a str,
442 caption: Option<&'a str>,
443 reply_parameters: Option<ReplyParameters>,
444 label: &'a str,
445}
446
447async fn send_attachment(
448 bot: &Bot,
449 attachment: TelegramAttachment<'_>,
450) -> Result<Message, ApiError> {
451 let result = if let Some(kind) = attachment.kind {
452 native_media::send_native_media(
453 bot,
454 attachment.chat_id,
455 kind,
456 &attachment.bytes,
457 attachment.file_name,
458 attachment.caption,
459 attachment.reply_parameters,
460 )
461 .await
462 } else {
463 let mut request = bot.send_document(
464 ChatId(attachment.chat_id),
465 InputFile::memory(attachment.bytes).file_name(attachment.file_name.to_owned()),
466 );
467 if let Some(caption) = attachment.caption {
468 request = request.caption(caption.to_owned());
469 }
470 if let Some(reply_parameters) = attachment.reply_parameters {
471 request = request.reply_parameters(reply_parameters);
472 }
473 telegram_requests::retry_request("send_document", || request.clone().send()).await
474 };
475 result.map_err(|error| {
476 tracing::warn!(
477 chat_id = attachment.chat_id,
478 error_class = telegram_requests::request_error_class(&error),
479 label = attachment.label,
480 "Telegram attachment send failed"
481 );
482 let message = if attachment.label == "private attachment"
483 || attachment.label == "cold group attachment"
484 {
485 "Telegram did not accept the attachment."
486 } else if attachment.kind.is_some() {
487 "Telegram did not accept the native media."
488 } else {
489 "Telegram did not accept the file."
490 };
491 ApiError::new("telegram_send_failed", message)
492 })
493}
494
495fn required_bytes(bytes: Option<Vec<u8>>) -> Result<Vec<u8>, ApiError> {
496 let bytes = bytes.ok_or_else(|| ApiError::bad("A nonempty file part is required."))?;
497 if bytes.is_empty() {
498 return Err(ApiError::bad("A nonempty file part is required."));
499 }
500 Ok(bytes)
501}
502
503fn validate_caption(
504 caption: Option<&str>,
505 kind: Option<native_media::NativeMediaKind>,
506 label: &str,
507) -> Result<(), ApiError> {
508 if caption.is_some() && kind.is_some_and(|kind| !kind.accepts_caption()) {
509 return Err(ApiError::bad(format!(
510 "caption is not accepted for {} media.",
511 kind.expect("checked as present").as_str()
512 )));
513 }
514 let caption = caption.and_then(nonempty_verbatim);
515 if caption.is_some_and(|value| value.encode_utf16().count() > TELEGRAM_CAPTION_LIMIT) {
516 return Err(ApiError::bad(format!(
517 "The Telegram {label} caption exceeds 1024 UTF-16 code units."
518 )));
519 }
520 Ok(())
521}
522
523fn group_reply_parameters(event: &Event) -> Option<ReplyParameters> {
524 (event.session_kind == "group")
525 .then(|| i32::try_from(event.message_id).ok())
526 .flatten()
527 .map(|message_id| {
528 ReplyParameters::new(teloxide::types::MessageId(message_id))
529 .allow_sending_without_reply()
530 })
531}
532
533fn validate_file_name(value: &str) -> Result<(), ApiError> {
534 if value.trim().is_empty()
535 || value.chars().count() > MAX_FILE_NAME_CHARACTERS
536 || value
537 .chars()
538 .any(|character| character.is_control() || matches!(character, '/' | '\\'))
539 {
540 return Err(ApiError::bad(
541 "fileName must be a nonempty path-free name of at most 255 characters.",
542 ));
543 }
544 Ok(())
545}
546
547fn validate_mime_type(value: &str) -> Result<(), ApiError> {
548 if value.trim().is_empty()
549 || value.chars().count() > MAX_MIME_TYPE_CHARACTERS
550 || value.chars().any(char::is_control)
551 {
552 return Err(ApiError::bad(
553 "The file content type must be a nonempty value of at most 255 characters.",
554 ));
555 }
556 Ok(())
557}