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