Skip to main content

kcode_tg_kennedy_bot/
transport_extensions.rs

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}