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}