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}