Skip to main content

kcode_tg_kennedy_bot/
transport_extensions.rs

1use teloxide::{
2    payloads::SendDocumentSetters,
3    requests::Request as TelegramRequest,
4    types::{InputFile, ReplyParameters},
5};
6
7use super::*;
8
9const TELEGRAM_CAPTION_LIMIT: usize = 1_024;
10const MAX_FILE_NAME_CHARACTERS: usize = 255;
11const MAX_MIME_TYPE_CHARACTERS: usize = 255;
12
13#[derive(Deserialize)]
14#[serde(rename_all = "camelCase")]
15pub(super) struct DetachGroupSession {
16    pub(super) group_id: String,
17    pub(super) telegram_user_id: i64,
18}
19
20pub(super) async fn detach_group_session(
21    state: AppState,
22    conversation_id: String,
23    input: DetachGroupSession,
24) -> Result<Value, ApiError> {
25    validate_conversation_id(&conversation_id)?;
26    let group_id = input.group_id.trim();
27    if group_id.is_empty() || group_id.len() > 200 || group_id.chars().any(char::is_control) {
28        return Err(ApiError::bad("groupId is not a valid opaque group ID."));
29    }
30
31    let db = state.db.lock().map_err(ApiError::internal)?;
32    let changed = db
33        .execute(
34            "UPDATE telegram_group_sessions
35             SET current_conversation_id=NULL,updated_at=?1
36             WHERE group_id=?2 AND telegram_user_id=?3
37               AND current_conversation_id=?4",
38            params![
39                Utc::now().to_rfc3339(),
40                group_id,
41                input.telegram_user_id,
42                conversation_id
43            ],
44        )
45        .map_err(ApiError::internal)?;
46    if changed != 1 {
47        return Err(ApiError::conflict(
48            "This Telegram group session is absent, detached, or bound to a newer conversation.",
49        ));
50    }
51    reclaim_group_working_messages(&db, group_id).map_err(ApiError::internal)?;
52
53    Ok(json!({
54        "conversationId":conversation_id,
55        "groupId":group_id,
56        "telegramUserId":input.telegram_user_id,
57        "status":"detached",
58    }))
59}
60
61#[derive(Debug, Default)]
62struct OutboundFile {
63    conversation_id: Option<String>,
64    expected_conversation_id: Option<String>,
65    kind: Option<String>,
66    explicit_file_name: Option<String>,
67    mime_type: Option<String>,
68    caption: Option<String>,
69    complete: bool,
70    bytes: Option<Vec<u8>>,
71}
72
73fn outbound_file(
74    attachment: Attachment,
75    maximum_bytes: usize,
76    conversation_id: Option<String>,
77    expected_conversation_id: Option<String>,
78    complete: bool,
79) -> Result<OutboundFile, ApiError> {
80    if attachment.bytes.len() > maximum_bytes {
81        return Err(ApiError::bad(format!(
82            "The file exceeds the configured {maximum_bytes}-byte Telegram media limit."
83        )));
84    }
85    Ok(OutboundFile {
86        conversation_id,
87        expected_conversation_id,
88        kind: attachment.kind,
89        explicit_file_name: attachment.file_name,
90        mime_type: attachment.media_type,
91        caption: attachment.caption,
92        complete,
93        bytes: Some(attachment.bytes),
94    })
95}
96
97impl Service {
98    pub async fn send_cold_private_attachment(
99        &self,
100        telegram_user_id: i64,
101        attachment: Attachment,
102    ) -> Result<Value, Error> {
103        let input = outbound_file(attachment, self.state.max_voice_bytes, None, None, false)?;
104        send_private_attachment(self.state.clone(), telegram_user_id, input).await
105    }
106
107    pub async fn send_private_attachment(
108        &self,
109        telegram_user_id: i64,
110        conversation_id: String,
111        expected_conversation_id: Option<String>,
112        attachment: Attachment,
113    ) -> Result<Value, Error> {
114        let input = outbound_file(
115            attachment,
116            self.state.max_voice_bytes,
117            Some(conversation_id),
118            expected_conversation_id,
119            false,
120        )?;
121        send_private_attachment(self.state.clone(), telegram_user_id, input).await
122    }
123
124    pub async fn send_group_attachment(
125        &self,
126        group_id: String,
127        attachment: Attachment,
128    ) -> Result<Value, Error> {
129        let input = outbound_file(attachment, self.state.max_voice_bytes, None, None, false)?;
130        send_group_attachment(self.state.clone(), group_id, input).await
131    }
132
133    pub async fn send_event_attachment(
134        &self,
135        event_id: String,
136        conversation_id: String,
137        attachment: Attachment,
138        complete: bool,
139    ) -> Result<Value, Error> {
140        let is_native_media = attachment.kind.is_some();
141        let input = outbound_file(
142            attachment,
143            self.state.max_voice_bytes,
144            Some(conversation_id),
145            None,
146            complete,
147        )?;
148        if is_native_media {
149            send_event_media(self.state.clone(), event_id, input).await
150        } else {
151            send_event_file(self.state.clone(), event_id, input).await
152        }
153    }
154}
155
156async fn send_private_attachment(
157    state: AppState,
158    telegram_user_id: i64,
159    input: OutboundFile,
160) -> Result<Value, ApiError> {
161    let started = Instant::now();
162    let conversation_id = input.conversation_id.clone();
163    let expected_conversation_id = input.expected_conversation_id.clone();
164    if let Some(conversation_id) = conversation_id.as_deref() {
165        validate_conversation_id(conversation_id)?;
166    } else if expected_conversation_id.is_some() {
167        return Err(ApiError::bad(
168            "expectedConversationId requires conversationId.",
169        ));
170    }
171    if let Some(expected) = expected_conversation_id.as_deref() {
172        validate_conversation_id(expected)?;
173    }
174
175    let (chat_id, current_conversation_id) = private_delivery_target(&state, telegram_user_id)?;
176    if conversation_id.is_some() && current_conversation_id != expected_conversation_id {
177        return Err(ApiError::conflict(
178            "The Telegram user's current private conversation changed before delivery.",
179        ));
180    }
181
182    let kind = input
183        .kind
184        .as_deref()
185        .map(|value| {
186            native_media::NativeMediaKind::parse(value).ok_or_else(|| {
187                ApiError::bad(
188                    "kind must be photo, video, animation, audio, video_note, or sticker.",
189                )
190            })
191        })
192        .transpose()?;
193    if input.caption.is_some() && kind.is_some_and(|kind| !kind.accepts_caption()) {
194        return Err(ApiError::bad(format!(
195            "caption is not accepted for {} media.",
196            kind.expect("checked as present").as_str()
197        )));
198    }
199    let caption = input.caption.as_deref().and_then(nonempty_verbatim);
200    if caption.is_some_and(|value| value.encode_utf16().count() > TELEGRAM_CAPTION_LIMIT) {
201        return Err(ApiError::bad(
202            "The Telegram attachment caption exceeds 1024 UTF-16 code units.",
203        ));
204    }
205    let bytes = input
206        .bytes
207        .ok_or_else(|| ApiError::bad("A nonempty file part is required."))?;
208    if bytes.is_empty() {
209        return Err(ApiError::bad("A nonempty file part is required."));
210    }
211
212    let supplied_file_name = input.explicit_file_name.as_deref();
213    if let Some(file_name) = supplied_file_name {
214        validate_file_name(file_name)?;
215    }
216    let mime_type = input.mime_type.as_deref().unwrap_or_else(|| {
217        kind.map(|kind| kind.fallback_mime(supplied_file_name))
218            .unwrap_or("application/octet-stream")
219    });
220    validate_mime_type(mime_type)?;
221    let file_name = supplied_file_name
222        .map(ToOwned::to_owned)
223        .unwrap_or_else(|| {
224            kind.map(|kind| kind.default_file_name(telegram_user_id, mime_type))
225                .unwrap_or_else(|| format!("attachment-{telegram_user_id}.bin"))
226        });
227    validate_file_name(&file_name)?;
228
229    let bot = state.bot.as_ref().ok_or_else(ApiError::unavailable)?;
230    let sent = if let Some(kind) = kind {
231        native_media::send_native_media(bot, chat_id, kind, &bytes, &file_name, caption, None).await
232    } else {
233        let mut request = bot.send_document(
234            ChatId(chat_id),
235            InputFile::memory(bytes).file_name(file_name.clone()),
236        );
237        if let Some(caption) = caption {
238            request = request.caption(caption.to_owned());
239        }
240        telegram_requests::retry_request("send_document", || request.clone().send()).await
241    }
242    .map_err(|error| {
243        tracing::warn!(
244            %telegram_user_id,
245            error_class = telegram_requests::request_error_class(&error),
246            "Telegram private attachment send failed"
247        );
248        ApiError::new(
249            "telegram_send_failed",
250            "Telegram did not accept the attachment.",
251        )
252    })?;
253
254    if let Some(conversation_id) = conversation_id.as_deref() {
255        let changed = {
256            let db = state.db.lock().map_err(ApiError::internal)?;
257            db.execute(
258                "UPDATE telegram_private_sessions
259                 SET current_conversation_id=?1,updated_at=?2
260                 WHERE telegram_user_id=?3 AND current_conversation_id IS ?4",
261                params![
262                    conversation_id,
263                    Utc::now().to_rfc3339(),
264                    telegram_user_id,
265                    expected_conversation_id,
266                ],
267            )
268            .map_err(ApiError::internal)?
269        };
270        if changed != 1 {
271            return Err(ApiError::conflict(
272                "Telegram accepted the attachment, but the user's current private conversation changed before it could be attached.",
273            ));
274        }
275        tracing::info!(
276            %telegram_user_id,
277            %conversation_id,
278            duration_ms=started.elapsed().as_millis(),
279            "Telegram session-bound direct-message attachment"
280        );
281    } else {
282        tracing::info!(
283            %telegram_user_id,
284            duration_ms=started.elapsed().as_millis(),
285            "Telegram cold direct-message attachment"
286        );
287    }
288    let mut response = json!({
289        "telegramUserId":telegram_user_id,
290        "kind":kind.map(|kind| kind.as_str()).unwrap_or("document"),
291        "fileName":file_name,
292        "mimeType":mime_type,
293        "telegramMessageId":i64::from(sent.id.0),
294    });
295    if let Some(conversation_id) = conversation_id {
296        response["conversationId"] = json!(conversation_id);
297    }
298    Ok(response)
299}
300
301async fn send_group_attachment(
302    state: AppState,
303    group_id: String,
304    input: OutboundFile,
305) -> Result<Value, ApiError> {
306    let started = Instant::now();
307    let group_id = validate_opaque_group_id(&group_id)?.to_owned();
308    let kind = input
309        .kind
310        .as_deref()
311        .map(|value| {
312            native_media::NativeMediaKind::parse(value).ok_or_else(|| {
313                ApiError::bad(
314                    "kind must be photo, video, animation, audio, video_note, or sticker.",
315                )
316            })
317        })
318        .transpose()?;
319    if input.caption.is_some() && kind.is_some_and(|kind| !kind.accepts_caption()) {
320        return Err(ApiError::bad(format!(
321            "caption is not accepted for {} media.",
322            kind.expect("checked as present").as_str()
323        )));
324    }
325    let caption = input.caption.as_deref().and_then(nonempty_verbatim);
326    if caption.is_some_and(|value| value.encode_utf16().count() > TELEGRAM_CAPTION_LIMIT) {
327        return Err(ApiError::bad(
328            "The Telegram attachment caption exceeds 1024 UTF-16 code units.",
329        ));
330    }
331    let bytes = input
332        .bytes
333        .ok_or_else(|| ApiError::bad("A nonempty file part is required."))?;
334    if bytes.is_empty() {
335        return Err(ApiError::bad("A nonempty file part is required."));
336    }
337
338    let supplied_file_name = input.explicit_file_name.as_deref();
339    if let Some(file_name) = supplied_file_name {
340        validate_file_name(file_name)?;
341    }
342    let mime_type = input.mime_type.as_deref().unwrap_or_else(|| {
343        kind.map(|kind| kind.fallback_mime(supplied_file_name))
344            .unwrap_or("application/octet-stream")
345    });
346    validate_mime_type(mime_type)?;
347    let file_name = supplied_file_name
348        .map(ToOwned::to_owned)
349        .unwrap_or_else(|| {
350            kind.map(|kind| kind.default_file_name(0, mime_type))
351                .unwrap_or_else(|| "attachment.bin".into())
352        });
353    validate_file_name(&file_name)?;
354
355    let chat_id = validated_group_delivery_target(&state, &group_id).await?;
356    let bot = state.bot.as_ref().ok_or_else(ApiError::unavailable)?;
357    let sent = if let Some(kind) = kind {
358        native_media::send_native_media(bot, chat_id, kind, &bytes, &file_name, caption, None).await
359    } else {
360        let mut request = bot.send_document(
361            ChatId(chat_id),
362            InputFile::memory(bytes).file_name(file_name.clone()),
363        );
364        if let Some(caption) = caption {
365            request = request.caption(caption.to_owned());
366        }
367        telegram_requests::retry_request("send_document", || request.clone().send()).await
368    }
369    .map_err(|error| {
370        tracing::warn!(
371            %group_id,
372            error_class = telegram_requests::request_error_class(&error),
373            "Telegram cold group attachment send failed"
374        );
375        ApiError::new(
376            "telegram_send_failed",
377            "Telegram did not accept the attachment.",
378        )
379    })?;
380
381    tracing::info!(
382        %group_id,
383        duration_ms=started.elapsed().as_millis(),
384        "Telegram cold group attachment"
385    );
386    Ok(json!({
387        "groupId":group_id,
388        "kind":kind.map(|kind| kind.as_str()).unwrap_or("document"),
389        "fileName":file_name,
390        "mimeType":mime_type,
391        "telegramMessageId":i64::from(sent.id.0),
392    }))
393}
394
395fn validate_file_name(value: &str) -> Result<(), ApiError> {
396    if value.trim().is_empty()
397        || value.chars().count() > MAX_FILE_NAME_CHARACTERS
398        || value
399            .chars()
400            .any(|character| character.is_control() || matches!(character, '/' | '\\'))
401    {
402        return Err(ApiError::bad(
403            "fileName must be a nonempty path-free name of at most 255 characters.",
404        ));
405    }
406    Ok(())
407}
408
409fn validate_mime_type(value: &str) -> Result<(), ApiError> {
410    if value.trim().is_empty()
411        || value.chars().count() > MAX_MIME_TYPE_CHARACTERS
412        || value.chars().any(char::is_control)
413    {
414        return Err(ApiError::bad(
415            "The file content type must be a nonempty value of at most 255 characters.",
416        ));
417    }
418    Ok(())
419}
420
421fn outbound_event(
422    db: &Connection,
423    event_id: &str,
424    conversation_id: &str,
425) -> Result<RelayEvent, ApiError> {
426    let event = fetch_event(db, event_id)?;
427    if event.status == "complete" {
428        return Err(ApiError::conflict(
429            "The Telegram event is already complete.",
430        ));
431    }
432    if event.conversation_id.as_deref() != Some(conversation_id) {
433        return Err(ApiError::conflict(
434            "The event is not bound to this conversation.",
435        ));
436    }
437    Ok(event)
438}
439
440fn reconcile_outbound_event(
441    db: &Connection,
442    event_id: &str,
443    conversation_id: &str,
444    complete: bool,
445    expected_batch_size: usize,
446    delivery_label: &str,
447) -> Result<RelayEvent, ApiError> {
448    if complete {
449        let event = fetch_event(db, event_id)?;
450        let changed = db
451            .execute(
452                "UPDATE telegram_events SET status='complete',completed_at=?1
453                 WHERE COALESCE(batch_id,id)=?2 AND status<>'complete'
454                   AND conversation_id=?3",
455                params![
456                    Utc::now().to_rfc3339(),
457                    event_batch_id(&event),
458                    conversation_id
459                ],
460            )
461            .map_err(ApiError::internal)?;
462        if changed != expected_batch_size {
463            return Err(ApiError::conflict(format!(
464                "The {delivery_label} was sent, but the event binding changed before completion."
465            )));
466        }
467    } else {
468        let current = fetch_event(db, event_id)?;
469        if current.status == "complete"
470            || current.conversation_id.as_deref() != Some(conversation_id)
471        {
472            return Err(ApiError::conflict(format!(
473                "The {delivery_label} was sent, but the event binding changed before delivery could be reconciled."
474            )));
475        }
476    }
477    let event = fetch_event(db, event_id)?;
478    if complete && let Some(group_id) = event.group_id.as_deref() {
479        reclaim_group_working_messages(db, group_id).map_err(ApiError::internal)?;
480    }
481    Ok(event)
482}
483
484async fn send_event_file(
485    state: AppState,
486    event_id: String,
487    input: OutboundFile,
488) -> Result<Value, ApiError> {
489    let conversation_id = input
490        .conversation_id
491        .as_deref()
492        .ok_or_else(|| ApiError::bad("conversationId is required."))?;
493    validate_conversation_id(conversation_id)?;
494
495    let file_name = input
496        .explicit_file_name
497        .as_deref()
498        .ok_or_else(|| ApiError::bad("The file must have a fileName."))?;
499    validate_file_name(file_name)?;
500
501    let mime_type = input
502        .mime_type
503        .as_deref()
504        .unwrap_or("application/octet-stream");
505    validate_mime_type(mime_type)?;
506
507    let caption = input.caption.as_deref().and_then(nonempty_verbatim);
508    if caption.is_some_and(|value| value.encode_utf16().count() > TELEGRAM_CAPTION_LIMIT) {
509        return Err(ApiError::bad(
510            "The Telegram file caption exceeds 1024 UTF-16 code units.",
511        ));
512    }
513
514    let bytes = input
515        .bytes
516        .ok_or_else(|| ApiError::bad("A nonempty file part is required."))?;
517    if bytes.is_empty() {
518        return Err(ApiError::bad("A nonempty file part is required."));
519    }
520
521    let (event, expected_batch_size) = {
522        let db = state.db.lock().map_err(ApiError::internal)?;
523        let event = outbound_event(&db, &event_id, conversation_id)?;
524        let expected_batch_size = active_event_batch_size(&db, &event)?;
525        (event, expected_batch_size)
526    };
527
528    let bot = state.bot.as_ref().ok_or_else(ApiError::unavailable)?;
529    let mut request = bot.send_document(
530        ChatId(event.chat_id),
531        InputFile::memory(bytes.clone()).file_name(file_name.to_owned()),
532    );
533    if let Some(caption) = caption {
534        request = request.caption(caption.to_owned());
535    }
536    if event.session_kind == "group"
537        && let Ok(message_id) = i32::try_from(event.message_id)
538    {
539        request = request.reply_parameters(
540            ReplyParameters::new(teloxide::types::MessageId(message_id))
541                .allow_sending_without_reply(),
542        );
543    }
544    let sent = telegram_requests::retry_request("send_document", || request.clone().send())
545        .await
546        .map_err(|error| {
547            tracing::warn!(
548                event_id = %event_id,
549                error_class = telegram_requests::request_error_class(&error),
550                "Telegram file send failed"
551            );
552            ApiError::new("telegram_send_failed", "Telegram did not accept the file.")
553        })?;
554
555    let db = state.db.lock().map_err(ApiError::internal)?;
556    if event.session_kind == "group" {
557        let archive_text = caption
558            .map(ToOwned::to_owned)
559            .unwrap_or_else(|| format!("[File: {file_name}]"));
560        db.execute(
561            "INSERT INTO telegram_group_messages(
562                 chat_id,message_id,update_id,display_name,text,reply_to_message_id,
563                 sent_by_kennedy,created_at,kind,media_bytes,mime_type,file_name,
564                 source_conversation_id,group_id
565             ) VALUES(?1,?2,0,'Kennedy',?3,?4,1,?5,'document',?6,?7,?8,?9,?10)
566             ON CONFLICT(chat_id,message_id) DO NOTHING",
567            params![
568                event.chat_id,
569                i64::from(sent.id.0),
570                archive_text,
571                event.message_id,
572                sent.date.to_rfc3339(),
573                bytes,
574                mime_type,
575                file_name,
576                conversation_id,
577                event.group_id
578            ],
579        )
580        .map_err(ApiError::internal)?;
581        if let Some(group_id) = event.group_id.as_deref() {
582            queue_stale_group_session_resets(&db, event.chat_id, group_id, i64::from(sent.id.0))
583                .map_err(ApiError::internal)?;
584        }
585    }
586
587    let reconciled_event = reconcile_outbound_event(
588        &db,
589        &event_id,
590        conversation_id,
591        input.complete,
592        expected_batch_size,
593        "file",
594    )?;
595
596    Ok(json!({
597        "event":reconciled_event,
598        "fileName":file_name,
599        "mimeType":mime_type,
600        "telegramMessageId":i64::from(sent.id.0),
601        "complete":input.complete,
602    }))
603}
604
605async fn send_event_media(
606    state: AppState,
607    event_id: String,
608    input: OutboundFile,
609) -> Result<Value, ApiError> {
610    let conversation_id = input
611        .conversation_id
612        .as_deref()
613        .ok_or_else(|| ApiError::bad("conversationId is required."))?;
614    validate_conversation_id(conversation_id)?;
615    let kind_text = input
616        .kind
617        .as_deref()
618        .ok_or_else(|| ApiError::bad("kind is required."))?;
619    let kind = native_media::NativeMediaKind::parse(kind_text).ok_or_else(|| {
620        ApiError::bad("kind must be photo, video, animation, audio, video_note, or sticker.")
621    })?;
622    if input.caption.is_some() && !kind.accepts_caption() {
623        return Err(ApiError::bad(format!(
624            "caption is not accepted for {} media.",
625            kind.as_str()
626        )));
627    }
628    let caption = input.caption.as_deref().and_then(nonempty_verbatim);
629    if caption.is_some_and(|value| value.encode_utf16().count() > TELEGRAM_CAPTION_LIMIT) {
630        return Err(ApiError::bad(
631            "The Telegram media caption exceeds 1024 UTF-16 code units.",
632        ));
633    }
634    let bytes = input
635        .bytes
636        .ok_or_else(|| ApiError::bad("A nonempty file part is required."))?;
637    if bytes.is_empty() {
638        return Err(ApiError::bad("A nonempty file part is required."));
639    }
640
641    let (event, expected_batch_size) = {
642        let db = state.db.lock().map_err(ApiError::internal)?;
643        let event = outbound_event(&db, &event_id, conversation_id)?;
644        let expected_batch_size = active_event_batch_size(&db, &event)?;
645        (event, expected_batch_size)
646    };
647
648    let supplied_file_name = input.explicit_file_name.as_deref();
649    if let Some(file_name) = supplied_file_name {
650        validate_file_name(file_name)?;
651    }
652    let mime_type = input
653        .mime_type
654        .as_deref()
655        .unwrap_or_else(|| kind.fallback_mime(supplied_file_name));
656    validate_mime_type(mime_type)?;
657    let file_name = supplied_file_name
658        .map(ToOwned::to_owned)
659        .unwrap_or_else(|| kind.default_file_name(event.message_id, mime_type));
660    validate_file_name(&file_name)?;
661
662    let reply_parameters = if event.session_kind == "group" {
663        i32::try_from(event.message_id).ok().map(|message_id| {
664            ReplyParameters::new(teloxide::types::MessageId(message_id))
665                .allow_sending_without_reply()
666        })
667    } else {
668        None
669    };
670    let bot = state.bot.as_ref().ok_or_else(ApiError::unavailable)?;
671    let sent = native_media::send_native_media(
672        bot,
673        event.chat_id,
674        kind,
675        &bytes,
676        &file_name,
677        caption,
678        reply_parameters,
679    )
680    .await
681    .map_err(|error| {
682        tracing::warn!(
683            event_id = %event_id,
684            media_kind = kind.as_str(),
685            error_class = telegram_requests::request_error_class(&error),
686            "Telegram native-media send failed"
687        );
688        ApiError::new(
689            "telegram_send_failed",
690            "Telegram did not accept the native media.",
691        )
692    })?;
693
694    let db = state.db.lock().map_err(ApiError::internal)?;
695    if event.session_kind == "group" {
696        let duration_seconds = native_media::message_duration(&sent, kind);
697        db.execute(
698            "INSERT INTO telegram_group_messages(
699                 chat_id,message_id,update_id,display_name,text,reply_to_message_id,
700                 sent_by_kennedy,created_at,kind,media_bytes,mime_type,file_name,
701                 duration_seconds,source_conversation_id,group_id
702             ) VALUES(?1,?2,0,'Kennedy',?3,?4,1,?5,?6,?7,?8,?9,?10,?11,?12)
703             ON CONFLICT(chat_id,message_id) DO NOTHING",
704            params![
705                event.chat_id,
706                i64::from(sent.id.0),
707                caption.unwrap_or(""),
708                event.message_id,
709                sent.date.to_rfc3339(),
710                kind.as_str(),
711                bytes,
712                mime_type,
713                file_name,
714                duration_seconds,
715                conversation_id,
716                event.group_id
717            ],
718        )
719        .map_err(ApiError::internal)?;
720        if let Some(group_id) = event.group_id.as_deref() {
721            queue_stale_group_session_resets(&db, event.chat_id, group_id, i64::from(sent.id.0))
722                .map_err(ApiError::internal)?;
723        }
724    }
725
726    let reconciled_event = reconcile_outbound_event(
727        &db,
728        &event_id,
729        conversation_id,
730        input.complete,
731        expected_batch_size,
732        "native media",
733    )?;
734
735    Ok(json!({
736        "event":reconciled_event,
737        "kind":kind.as_str(),
738        "fileName":file_name,
739        "mimeType":mime_type,
740        "telegramMessageId":i64::from(sent.id.0),
741        "complete":input.complete,
742    }))
743}
744
745#[cfg(test)]
746mod tests {
747    use super::*;
748
749    #[derive(Default)]
750    struct ExtensionIdentitySink;
751
752    impl IdentitySink for ExtensionIdentitySink {
753        fn observe_identity(&self, _observation: &IdentityObservation) -> anyhow::Result<()> {
754            Ok(())
755        }
756
757        fn whitelist(&self) -> anyhow::Result<WhitelistSnapshot> {
758            Ok(WhitelistSnapshot::default())
759        }
760
761        fn request_add_user(
762            &self,
763            _requested_by_telegram_user_id: i64,
764            _handle: &str,
765        ) -> anyhow::Result<AddUserOutcome> {
766            Ok(AddUserOutcome::Forbidden)
767        }
768
769        fn observe_group(&self, _group_id: &str) -> anyhow::Result<()> {
770            Ok(())
771        }
772    }
773
774    fn extension_state(database: Connection) -> AppState {
775        AppState {
776            db: Arc::new(Mutex::new(database)),
777            identity_sink: Arc::new(ExtensionIdentitySink),
778            bot: None,
779            max_voice_bytes: 1024,
780            bot_user_id: None,
781            bot_username: None,
782        }
783    }
784
785    fn group_database() -> (Connection, String) {
786        let database = Connection::open_in_memory().unwrap();
787        database.execute_batch("PRAGMA foreign_keys=ON;").unwrap();
788        apply_migrations(&database).unwrap();
789        let group = ensure_group(&database, -100, "Friends").unwrap();
790        let now = Utc::now().to_rfc3339();
791        database
792            .execute(
793                "INSERT INTO telegram_group_messages(
794                     chat_id,message_id,update_id,display_name,text,created_at,kind,group_id
795                 ) VALUES(-100,1,1,'Participant','hello',?1,'text',?2)",
796                params![now, group.group_id],
797            )
798            .unwrap();
799        (database, group.group_id)
800    }
801
802    #[tokio::test]
803    async fn matching_detach_clears_only_the_expected_group_user_pointer() {
804        let (database, group_id) = group_database();
805        let expected = "019f5ca7-020f-7b63-be2f-82785fb68c03";
806        let other = "119f5ca7-020f-7b63-be2f-82785fb68c04";
807        let now = Utc::now().to_rfc3339();
808        for (user_id, conversation_id) in [(42, expected), (77, other)] {
809            database
810                .execute(
811                    "INSERT INTO telegram_group_sessions(
812                         group_id,telegram_user_id,current_conversation_id,updated_at,
813                         last_context_message_id,last_invocation_message_id
814                     ) VALUES(?1,?2,?3,?4,0,0)",
815                    params![group_id, user_id, conversation_id, now],
816                )
817                .unwrap();
818        }
819        let state = extension_state(database);
820
821        let before = list_group_session_updates(state.clone()).await.unwrap();
822        assert_eq!(before["updates"].as_array().unwrap().len(), 2);
823
824        let _ = detach_group_session(
825            state.clone(),
826            expected.to_owned(),
827            DetachGroupSession {
828                group_id: group_id.clone(),
829                telegram_user_id: 42,
830            },
831        )
832        .await
833        .unwrap();
834
835        {
836            let database = state.db.lock().unwrap();
837            assert_eq!(
838                database
839                    .query_row(
840                        "SELECT current_conversation_id FROM telegram_group_sessions
841                         WHERE group_id=?1 AND telegram_user_id=42",
842                        [&group_id],
843                        |row| row.get::<_, Option<String>>(0),
844                    )
845                    .unwrap(),
846                None
847            );
848            assert_eq!(
849                database
850                    .query_row(
851                        "SELECT current_conversation_id FROM telegram_group_sessions
852                         WHERE group_id=?1 AND telegram_user_id=77",
853                        [&group_id],
854                        |row| row.get::<_, Option<String>>(0),
855                    )
856                    .unwrap()
857                    .as_deref(),
858                Some(other)
859            );
860        }
861
862        let after = list_group_session_updates(state).await.unwrap();
863        let conversations = after["updates"]
864            .as_array()
865            .unwrap()
866            .iter()
867            .filter_map(|update| update["conversationId"].as_str())
868            .collect::<Vec<_>>();
869        assert!(!conversations.contains(&expected));
870        assert!(conversations.contains(&other));
871    }
872
873    #[tokio::test]
874    async fn stale_detach_cannot_clear_a_rebound_group_session() {
875        let (database, group_id) = group_database();
876        let stale = "019f5ca7-020f-7b63-be2f-82785fb68c03";
877        let current = "219f5ca7-020f-7b63-be2f-82785fb68c05";
878        database
879            .execute(
880                "INSERT INTO telegram_group_sessions(
881                     group_id,telegram_user_id,current_conversation_id,updated_at,
882                     last_context_message_id,last_invocation_message_id
883                 ) VALUES(?1,42,?2,?3,0,0)",
884                params![group_id, current, Utc::now().to_rfc3339()],
885            )
886            .unwrap();
887        let state = extension_state(database);
888
889        let error = detach_group_session(
890            state.clone(),
891            stale.to_owned(),
892            DetachGroupSession {
893                group_id: group_id.clone(),
894                telegram_user_id: 42,
895            },
896        )
897        .await
898        .unwrap_err();
899        assert_eq!(error.code, "state_conflict");
900
901        assert_eq!(
902            state
903                .db
904                .lock()
905                .unwrap()
906                .query_row(
907                    "SELECT current_conversation_id FROM telegram_group_sessions
908                     WHERE group_id=?1 AND telegram_user_id=42",
909                    [&group_id],
910                    |row| row.get::<_, String>(0),
911                )
912                .unwrap(),
913            current
914        );
915    }
916
917    #[test]
918    fn outbound_file_names_are_bounded_and_path_free() {
919        assert!(validate_file_name("report.pdf").is_ok());
920        assert!(validate_file_name("../secret").is_err());
921        assert!(validate_file_name("folder\\secret").is_err());
922        assert!(validate_file_name("").is_err());
923        assert!(validate_file_name(&"a".repeat(256)).is_err());
924    }
925
926    #[test]
927    fn outbound_completion_rejects_a_batch_invalidated_during_delivery() {
928        let database = Connection::open_in_memory().unwrap();
929        database.execute_batch("PRAGMA foreign_keys=ON;").unwrap();
930        apply_migrations(&database).unwrap();
931        let conversation_id = "019f5ca7-020f-7b63-be2f-82785fb68c03";
932        let now = Utc::now().to_rfc3339();
933        for (id, update_id, message_id) in [("first", 1_i64, 1_i64), ("second", 2_i64, 2_i64)] {
934            database
935                .execute(
936                    "INSERT INTO telegram_events(
937                         id,update_id,message_id,telegram_user_id,chat_id,display_name,
938                         kind,text,status,conversation_id,processing_started_at,created_at,
939                         session_kind,batch_id,batch_ready_at
940                     ) VALUES(?1,?2,?3,42,42,'David','text',?1,'processing',?4,
941                              ?5,?5,'private','batch',?5)",
942                    params![id, update_id, message_id, conversation_id, now],
943                )
944                .unwrap();
945        }
946        let event = fetch_event(&database, "second").unwrap();
947        let expected_batch_size = active_event_batch_size(&database, &event).unwrap();
948        assert_eq!(expected_batch_size, 2);
949
950        database
951            .execute(
952                "UPDATE telegram_events SET status='complete' WHERE batch_id='batch'",
953                [],
954            )
955            .unwrap();
956        let error = reconcile_outbound_event(
957            &database,
958            "second",
959            conversation_id,
960            true,
961            expected_batch_size,
962            "file",
963        )
964        .unwrap_err();
965        assert_eq!(error.code, "state_conflict");
966    }
967
968    #[tokio::test]
969    async fn native_media_rejects_inapplicable_captions() {
970        let state = extension_state(group_database().0);
971        let inapplicable = outbound_file(
972            Attachment {
973                bytes: b"media".to_vec(),
974                file_name: Some("note.mp4".into()),
975                media_type: Some("video/mp4".into()),
976                kind: Some("video_note".into()),
977                caption: Some("not allowed".into()),
978            },
979            1024,
980            Some("019f5ca7-020f-7b63-be2f-82785fb68c03".into()),
981            None,
982            false,
983        )
984        .unwrap();
985        let error = send_event_media(state, "event".into(), inapplicable)
986            .await
987            .unwrap_err();
988        assert_eq!(error.code, "invalid_request");
989        assert!(error.message.contains("caption is not accepted"));
990    }
991
992    #[tokio::test]
993    async fn private_attachment_requires_an_established_chat_and_exact_binding() {
994        let state = extension_state(group_database().0);
995        let attachment = || Attachment {
996            bytes: b"hello".to_vec(),
997            file_name: Some("note.txt".into()),
998            media_type: Some("text/plain".into()),
999            kind: None,
1000            caption: None,
1001        };
1002        let missing = outbound_file(
1003            attachment(),
1004            1024,
1005            Some("019f5ca7-020f-7b63-be2f-82785fb68c03".into()),
1006            None,
1007            false,
1008        )
1009        .unwrap();
1010        let error = send_private_attachment(state.clone(), 42, missing)
1011            .await
1012            .unwrap_err();
1013        assert_eq!(error.code, "private_session_not_found");
1014
1015        {
1016            let database = state.db.lock().unwrap();
1017            ensure_transport_user(&database, 42, 9001).unwrap();
1018            database
1019                .execute(
1020                    "UPDATE telegram_private_sessions SET current_conversation_id=?1
1021                     WHERE telegram_user_id=42",
1022                    ["119f5ca7-020f-7b63-be2f-82785fb68c04"],
1023                )
1024                .unwrap();
1025        }
1026        let stale = outbound_file(
1027            attachment(),
1028            1024,
1029            Some("019f5ca7-020f-7b63-be2f-82785fb68c03".into()),
1030            Some("219f5ca7-020f-7b63-be2f-82785fb68c05".into()),
1031            false,
1032        )
1033        .unwrap();
1034        let error = send_private_attachment(state, 42, stale).await.unwrap_err();
1035        assert_eq!(error.code, "state_conflict");
1036    }
1037
1038    #[tokio::test]
1039    async fn private_attachment_bound_and_cold_delivery_keep_distinct_binding_semantics() {
1040        async fn accept(uri: axum::http::Uri) -> Json<Value> {
1041            assert!(uri.path().to_ascii_lowercase().ends_with("/senddocument"));
1042            Json(json!({
1043                "ok":true,
1044                "result":{
1045                    "message_id":901,
1046                    "date":1629404938,
1047                    "from":{
1048                        "id":999,
1049                        "is_bot":true,
1050                        "first_name":"Kennedy",
1051                        "username":"KennedyBot"
1052                    },
1053                    "chat":{"id":9001,"first_name":"User","type":"private"},
1054                    "document":{
1055                        "file_id":"sent",
1056                        "file_unique_id":"sent-unique",
1057                        "file_name":"note.txt",
1058                        "mime_type":"text/plain",
1059                        "file_size":5
1060                    }
1061                }
1062            }))
1063        }
1064
1065        let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
1066        let address = listener.local_addr().unwrap();
1067        let server = tokio::spawn(async move {
1068            axum::serve(
1069                listener,
1070                Router::new().fallback(axum::routing::post(accept)),
1071            )
1072            .await
1073            .unwrap();
1074        });
1075
1076        let (database, _) = group_database();
1077        ensure_transport_user(&database, 42, 9001).unwrap();
1078        let mut state = extension_state(database);
1079        state.bot =
1080            Some(Bot::new("test-token").set_api_url(format!("http://{address}").parse().unwrap()));
1081        let conversation_id = "019f5ca7-020f-7b63-be2f-82785fb68c03";
1082        let request = outbound_file(
1083            Attachment {
1084                bytes: b"hello".to_vec(),
1085                file_name: Some("note.txt".into()),
1086                media_type: Some("text/plain".into()),
1087                kind: None,
1088                caption: None,
1089            },
1090            1024,
1091            Some(conversation_id.into()),
1092            None,
1093            false,
1094        )
1095        .unwrap();
1096
1097        let response = send_private_attachment(state.clone(), 42, request)
1098            .await
1099            .unwrap();
1100        assert_eq!(response["kind"], "document");
1101        assert_eq!(response["telegramMessageId"], 901);
1102        assert_eq!(
1103            state
1104                .db
1105                .lock()
1106                .unwrap()
1107                .query_row(
1108                    "SELECT current_conversation_id FROM telegram_private_sessions
1109                     WHERE telegram_user_id=42",
1110                    [],
1111                    |row| row.get::<_, String>(0),
1112                )
1113                .unwrap(),
1114            conversation_id
1115        );
1116
1117        let service = Service {
1118            state: state.clone(),
1119        };
1120        let cold = service
1121            .send_cold_private_attachment(
1122                42,
1123                Attachment {
1124                    bytes: b"cold".to_vec(),
1125                    file_name: Some("cold.txt".into()),
1126                    media_type: Some("text/plain".into()),
1127                    kind: None,
1128                    caption: None,
1129                },
1130            )
1131            .await
1132            .unwrap();
1133        assert!(cold.get("conversationId").is_none());
1134        assert_eq!(cold["telegramMessageId"], 901);
1135        assert_eq!(
1136            state
1137                .db
1138                .lock()
1139                .unwrap()
1140                .query_row(
1141                    "SELECT current_conversation_id FROM telegram_private_sessions
1142                     WHERE telegram_user_id=42",
1143                    [],
1144                    |row| row.get::<_, String>(0),
1145                )
1146                .unwrap(),
1147            conversation_id
1148        );
1149
1150        server.abort();
1151    }
1152
1153    #[tokio::test]
1154    async fn native_group_send_archives_exact_media_and_completes_after_telegram_success() {
1155        #[derive(Clone, Default)]
1156        struct Capture(Arc<Mutex<Vec<(String, String)>>>);
1157
1158        async fn accept(
1159            State(capture): State<Capture>,
1160            uri: axum::http::Uri,
1161            body: axum::body::Bytes,
1162        ) -> Json<Value> {
1163            capture.0.lock().unwrap().push((
1164                uri.path().to_ascii_lowercase(),
1165                String::from_utf8_lossy(&body).into(),
1166            ));
1167            Json(json!({
1168                "ok":true,
1169                "result":{
1170                    "message_id":900,
1171                    "date":1629404938,
1172                    "from":{
1173                        "id":999,
1174                        "is_bot":true,
1175                        "first_name":"Kennedy",
1176                        "username":"KennedyBot"
1177                    },
1178                    "chat":{"id":-100,"title":"Friends","type":"supergroup"},
1179                    "photo":[
1180                        {
1181                            "file_id":"sent",
1182                            "file_unique_id":"sent-unique",
1183                            "width":100,
1184                            "height":100,
1185                            "file_size":5
1186                        }
1187                    ],
1188                    "caption":"exact caption"
1189                }
1190            }))
1191        }
1192
1193        let capture = Capture::default();
1194        let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
1195        let address = listener.local_addr().unwrap();
1196        let server_capture = capture.clone();
1197        let server = tokio::spawn(async move {
1198            axum::serve(
1199                listener,
1200                Router::new()
1201                    .fallback(axum::routing::post(accept))
1202                    .with_state(server_capture),
1203            )
1204            .await
1205            .unwrap();
1206        });
1207
1208        let (database, group_id) = group_database();
1209        let conversation_id = "019f5ca7-020f-7b63-be2f-82785fb68c03";
1210        database
1211            .execute(
1212                "INSERT INTO telegram_events(
1213                     id,update_id,message_id,telegram_user_id,chat_id,display_name,
1214                     kind,text,status,conversation_id,created_at,session_kind,group_id
1215                 ) VALUES(
1216                     'event',10,7,42,-100,'David','text','invoke','processing',
1217                     ?1,?2,'group',?3
1218                 )",
1219                params![conversation_id, Utc::now().to_rfc3339(), group_id],
1220            )
1221            .unwrap();
1222        let mut state = extension_state(database);
1223        state.bot =
1224            Some(Bot::new("test-token").set_api_url(format!("http://{address}").parse().unwrap()));
1225        let request = outbound_file(
1226            Attachment {
1227                bytes: b"media".to_vec(),
1228                file_name: Some("photo.jpg".into()),
1229                media_type: Some("image/jpeg".into()),
1230                kind: Some("photo".into()),
1231                caption: Some("exact caption".into()),
1232            },
1233            1024,
1234            Some(conversation_id.into()),
1235            None,
1236            true,
1237        )
1238        .unwrap();
1239
1240        let response = send_event_media(state.clone(), "event".into(), request)
1241            .await
1242            .unwrap();
1243        assert_eq!(response["kind"], "photo");
1244        assert_eq!(response["complete"], true);
1245        assert_eq!(response["event"]["status"], "complete");
1246        let requests = capture.0.lock().unwrap();
1247        assert_eq!(requests.len(), 1);
1248        assert!(requests[0].0.ends_with("/sendphoto"));
1249        assert!(requests[0].1.contains("\"message_id\":7"));
1250        assert!(
1251            requests[0]
1252                .1
1253                .contains("\"allow_sending_without_reply\":true")
1254        );
1255        drop(requests);
1256
1257        let archived = state
1258            .db
1259            .lock()
1260            .unwrap()
1261            .query_row(
1262                "SELECT kind,text,media_bytes,mime_type,file_name,
1263                        reply_to_message_id,source_conversation_id,group_id
1264                 FROM telegram_group_messages
1265                 WHERE chat_id=-100 AND message_id=900",
1266                [],
1267                |row| {
1268                    Ok((
1269                        row.get::<_, String>(0)?,
1270                        row.get::<_, String>(1)?,
1271                        row.get::<_, Vec<u8>>(2)?,
1272                        row.get::<_, String>(3)?,
1273                        row.get::<_, String>(4)?,
1274                        row.get::<_, i64>(5)?,
1275                        row.get::<_, String>(6)?,
1276                        row.get::<_, String>(7)?,
1277                    ))
1278                },
1279            )
1280            .unwrap();
1281        assert_eq!(
1282            archived,
1283            (
1284                "photo".into(),
1285                "exact caption".into(),
1286                b"media".to_vec(),
1287                "image/jpeg".into(),
1288                "photo.jpg".into(),
1289                7,
1290                conversation_id.into(),
1291                group_id,
1292            )
1293        );
1294        server.abort();
1295    }
1296}