1use std::collections::{BTreeMap, BTreeSet};
7
8use anyhow::{Context as _, Result};
9use deltachat_derive::FromSql;
10use num_traits::ToPrimitive;
11use pgp::types::KeyDetails as _;
12use rand::distr::SampleString as _;
13use rusqlite::OptionalExtension;
14use serde::Serialize;
15
16use crate::chat::{self, ChatId, MuteDuration};
17use crate::config::Config;
18use crate::constants::{Chattype, DC_VERSION_STR};
19use crate::contact::{Contact, ContactId, Origin, import_vcard, mark_contact_id_as_verified};
20use crate::context::Context;
21use crate::key::{DcKey, load_self_public_key};
22use crate::log::LogExt;
23use crate::message::{Message, Viewtype};
24use crate::securejoin::QrInvite;
25use crate::tools::time;
26
27pub(crate) const STATISTICS_BOT_EMAIL: &str = "self_reporting@testrun.org";
28const STATISTICS_BOT_VCARD: &str = include_str!("../assets/statistics-bot.vcf");
29const SENDING_INTERVAL_SECONDS: i64 = 3600 * 24 * 7; const MESSAGE_STATS_UPDATE_INTERVAL_SECONDS: i64 = 4 * 60; #[derive(Serialize)]
34struct Statistics {
35 core_version: String,
36 number_of_transports: usize,
37 key_create_timestamps: Vec<u32>,
38 number_of_keys: u32,
39 key_version: u8,
41 key_algorithm: String,
42 pubkey_size: usize,
44 stats_id: String,
45 is_chatmail: bool,
46 contact_stats: Vec<ContactStat>,
47 message_stats: BTreeMap<Chattype, MessageStats>,
48 securejoin_sources: SecurejoinSources,
49 securejoin_uipaths: SecurejoinUiPaths,
50 securejoin_invites: Vec<JoinedInvite>,
51 sending_enabled_timestamps: Vec<i64>,
52 sending_disabled_timestamps: Vec<i64>,
53}
54
55#[derive(Serialize, PartialEq)]
56enum VerifiedStatus {
57 Direct,
58 Transitive,
59 TransitiveViaBot,
60 Opportunistic,
61 Unencrypted,
62}
63
64#[derive(Serialize)]
65struct ContactStat {
66 #[serde(skip_serializing)]
67 id: ContactId,
68
69 verified: VerifiedStatus,
70
71 #[serde(skip_serializing_if = "is_false")]
75 bot: bool,
76
77 #[serde(skip_serializing_if = "is_false")]
78 direct_chat: bool,
79
80 last_seen: u64,
81
82 #[serde(skip_serializing_if = "Option::is_none")]
83 transitive_chain: Option<u32>,
84
85 #[serde(skip_serializing_if = "is_false")]
87 new: bool,
88}
89
90fn is_false(b: &bool) -> bool {
91 !b
92}
93
94#[derive(Serialize, Default)]
95struct MessageStats {
96 verified: u32,
97 unverified_encrypted: u32,
98 unencrypted: u32,
99 only_to_self: u32,
100}
101
102#[repr(u32)]
105#[derive(
106 Debug, Clone, Copy, ToPrimitive, FromPrimitive, FromSql, PartialEq, Eq, PartialOrd, Ord,
107)]
108pub enum SecurejoinSource {
109 Unknown = 0,
111 ExternalLink = 1,
113 InternalLink = 2,
115 Clipboard = 3,
117 ImageLoaded = 4,
119 Scan = 5,
121}
122
123#[derive(Serialize)]
124struct SecurejoinSources {
125 unknown: u32,
126 external_link: u32,
127 internal_link: u32,
128 clipboard: u32,
129 image_loaded: u32,
130 scan: u32,
131}
132
133#[derive(
136 Debug, Clone, Copy, ToPrimitive, FromPrimitive, FromSql, PartialEq, Eq, PartialOrd, Ord,
137)]
138pub enum SecurejoinUiPath {
139 Unknown = 0,
141 QrIcon = 1,
143 NewContact = 2,
146}
147
148#[derive(Serialize)]
149struct SecurejoinUiPaths {
150 other: u32,
151 qr_icon: u32,
152 new_contact: u32,
153}
154
155#[derive(Serialize)]
158struct JoinedInvite {
159 already_existed: bool,
162 already_verified: bool,
165 typ: String,
171}
172
173pub(crate) async fn pre_sending_config_change(
174 context: &Context,
175 old_value: bool,
176 new_value: bool,
177) -> Result<()> {
178 ensure_last_old_contact_id(context).await?;
181 stats_id(context).await?;
184
185 if old_value != new_value {
186 if new_value {
187 set_last_counted_msg_id(context).await?;
189 } else {
190 update_message_stats(context).await?;
192 }
193
194 let sql_table = if new_value {
195 "stats_sending_enabled_events"
196 } else {
197 "stats_sending_disabled_events"
198 };
199
200 context
201 .sql
202 .execute(&format!("INSERT INTO {sql_table} VALUES(?)"), (time(),))
203 .await?;
204 }
205
206 Ok(())
207}
208
209pub async fn maybe_send_stats(context: &Context) -> Result<Option<ChatId>> {
216 if should_send_stats(context).await?
217 && time_has_passed(context, Config::StatsLastSent, SENDING_INTERVAL_SECONDS).await?
218 {
219 let chat_id = send_stats(context).await?;
220
221 return Ok(Some(chat_id));
222 }
223 Ok(None)
224}
225
226pub(crate) async fn maybe_update_message_stats(context: &Context) -> Result<()> {
227 if should_send_stats(context).await?
228 && time_has_passed(
229 context,
230 Config::StatsLastUpdate,
231 MESSAGE_STATS_UPDATE_INTERVAL_SECONDS,
232 )
233 .await?
234 {
235 update_message_stats(context).await?;
236 }
237
238 Ok(())
239}
240
241async fn time_has_passed(context: &Context, config: Config, seconds: i64) -> Result<bool> {
242 let last_time = context.get_config_i64(config).await?;
243 let next_time = last_time.saturating_add(seconds);
244
245 let res = if next_time <= time() {
246 context
249 .set_config_internal(config, Some(&time().to_string()))
250 .await?;
251 true
252 } else {
253 if time() < last_time {
254 context
258 .set_config_internal(config, Some(&time().to_string()))
259 .await?;
260 }
261 false
262 };
263
264 Ok(res)
265}
266
267#[allow(clippy::unused_async, unused)]
268pub(crate) async fn should_send_stats(context: &Context) -> Result<bool> {
269 #[cfg(any(target_os = "android", test))]
270 {
271 context.get_config_bool(Config::StatsSending).await
272 }
273
274 #[cfg(not(any(target_os = "android", test)))]
278 {
279 Ok(false)
280 }
281}
282
283async fn send_stats(context: &Context) -> Result<ChatId> {
284 info!(context, "Sending statistics.");
285
286 update_message_stats(context).await?;
287
288 let chat_id = get_stats_chat_id(context).await?;
289
290 let mut msg = Message::new(Viewtype::File);
291 msg.set_text(crate::stock_str::stats_msg_body(context));
292
293 let stats = get_stats(context).await?;
294
295 msg.set_file_from_bytes(
296 context,
297 "statistics.txt",
298 stats.as_bytes(),
299 Some("text/plain"),
300 )?;
301
302 chat::send_msg(context, chat_id, &mut msg)
303 .await
304 .context("Failed to send statistics message")
305 .log_err(context)
306 .ok();
307
308 Ok(chat_id)
309}
310
311async fn set_last_counted_msg_id(context: &Context) -> Result<()> {
312 context
313 .sql
314 .execute(
315 "UPDATE stats_msgs
316 SET last_counted_msg_id=(SELECT MAX(id) FROM msgs)",
317 (),
318 )
319 .await?;
320
321 Ok(())
322}
323
324async fn ensure_last_old_contact_id(context: &Context) -> Result<()> {
325 if context.config_exists(Config::StatsLastOldContactId).await? {
326 return Ok(());
329 }
330
331 let last_contact_id: u64 = context
332 .sql
333 .query_get_value("SELECT MAX(id) FROM contacts", ())
334 .await?
335 .unwrap_or(0);
336
337 context
338 .sql
339 .set_raw_config(
340 Config::StatsLastOldContactId.as_ref(),
341 Some(&last_contact_id.to_string()),
342 )
343 .await?;
344
345 Ok(())
346}
347
348async fn get_stats(context: &Context) -> Result<String> {
349 let last_old_contact = context
352 .get_config_u32(Config::StatsLastOldContactId)
353 .await?;
354
355 let self_public_key = load_self_public_key(context).await?;
356 let key_create_timestamps: Vec<u32> = vec![self_public_key.created_at().as_secs()];
359 let number_of_keys: u32 = context
360 .sql
361 .query_get_value("SELECT COUNT(*) FROM keypairs", ())
362 .await?
363 .unwrap_or(0);
364
365 let sending_enabled_timestamps =
366 get_timestamps(context, "stats_sending_enabled_events").await?;
367 let sending_disabled_timestamps =
368 get_timestamps(context, "stats_sending_disabled_events").await?;
369
370 let stats = Statistics {
371 core_version: DC_VERSION_STR.to_string(),
372 number_of_transports: context.count_transports().await?,
373 key_create_timestamps,
374 number_of_keys,
375 key_version: self_public_key.primary_key.version().into(),
376 key_algorithm: format!("{:?}", self_public_key.algorithm()),
377 pubkey_size: DcKey::to_bytes(&self_public_key).len(),
378 stats_id: stats_id(context).await?,
379 is_chatmail: context.is_chatmail().await?,
380 contact_stats: get_contact_stats(context, last_old_contact).await?,
381 message_stats: get_message_stats(context).await?,
382 securejoin_sources: get_securejoin_source_stats(context).await?,
383 securejoin_uipaths: get_securejoin_uipath_stats(context).await?,
384 securejoin_invites: get_securejoin_invite_stats(context).await?,
385 sending_enabled_timestamps,
386 sending_disabled_timestamps,
387 };
388
389 Ok(serde_json::to_string_pretty(&stats)?)
390}
391
392async fn get_timestamps(context: &Context, sql_table: &str) -> Result<Vec<i64>> {
393 context
394 .sql
395 .query_map_vec(
396 &format!("SELECT timestamp FROM {sql_table} LIMIT 1000"),
397 (),
398 |row| {
399 let timestamp: i64 = row.get(0)?;
400 Ok(timestamp)
401 },
402 )
403 .await
404}
405
406pub(crate) async fn stats_id(context: &Context) -> Result<String> {
407 Ok(match context.get_config(Config::StatsId).await? {
408 Some(id) => id,
409 None => {
410 let id = rand::distr::Alphabetic
411 .sample_string(&mut rand::rng(), 25)
412 .to_lowercase();
413 context
414 .set_config_internal(Config::StatsId, Some(&id))
415 .await?;
416 id
417 }
418 })
419}
420
421async fn get_stats_chat_id(context: &Context) -> Result<ChatId, anyhow::Error> {
422 let contact_id: ContactId = *import_vcard(context, STATISTICS_BOT_VCARD)
423 .await?
424 .first()
425 .context("Statistics bot vCard does not contain a contact")?;
426 mark_contact_id_as_verified(context, contact_id, Some(ContactId::SELF)).await?;
427
428 let chat_id = if let Some(res) = ChatId::lookup_by_contact(context, contact_id).await? {
429 res
431 } else {
432 let chat_id = ChatId::get_for_contact(context, contact_id).await?;
433 chat::set_muted(context, chat_id, MuteDuration::Forever).await?;
434 chat_id
435 };
436
437 Ok(chat_id)
438}
439
440async fn get_contact_stats(context: &Context, last_old_contact: u32) -> Result<Vec<ContactStat>> {
441 let mut verified_by_map: BTreeMap<ContactId, ContactId> = BTreeMap::new();
442 let mut bot_ids: BTreeSet<ContactId> = BTreeSet::new();
443
444 let mut contacts = context
445 .sql
446 .query_map_vec(
447 "SELECT id, fingerprint<>'', verifier, last_seen, is_bot FROM contacts c
448 WHERE id>9 AND origin>? AND addr<>?",
449 (Origin::Hidden, STATISTICS_BOT_EMAIL),
450 |row| {
451 let id = row.get(0)?;
452 let is_encrypted: bool = row.get(1)?;
453 let verifier: ContactId = row.get(2)?;
454 let last_seen: u64 = row.get(3)?;
455 let bot: bool = row.get(4)?;
456
457 let verified = match (is_encrypted, verifier) {
458 (true, ContactId::SELF) => VerifiedStatus::Direct,
459 (true, ContactId::UNDEFINED) => VerifiedStatus::Opportunistic,
460 (true, _) => VerifiedStatus::Transitive, (false, _) => VerifiedStatus::Unencrypted,
462 };
463
464 if verifier != ContactId::UNDEFINED {
465 verified_by_map.insert(id, verifier);
466 }
467
468 if bot {
469 bot_ids.insert(id);
470 }
471
472 Ok(ContactStat {
473 id,
474 verified,
475 bot,
476 direct_chat: false, last_seen,
478 transitive_chain: None, new: id.to_u32() > last_old_contact,
480 })
481 },
482 )
483 .await?;
484
485 for contact in &mut contacts {
487 if contact.verified == VerifiedStatus::Transitive {
488 let mut transitive_chain: u32 = 0;
489 let mut has_bot = false;
490 let mut current_verifier_id = contact.id;
491
492 while current_verifier_id != ContactId::SELF && transitive_chain < 100 {
493 current_verifier_id = match verified_by_map.get(¤t_verifier_id) {
494 Some(id) => *id,
495 None => {
496 transitive_chain = 0;
500 break;
501 }
502 };
503 if bot_ids.contains(¤t_verifier_id) {
504 has_bot = true;
505 }
506 transitive_chain = transitive_chain.saturating_add(1);
507 }
508
509 if transitive_chain > 0 {
510 contact.transitive_chain = Some(transitive_chain);
511 }
512
513 if has_bot {
514 contact.verified = VerifiedStatus::TransitiveViaBot;
515 }
516 }
517 }
518
519 for contact in &mut contacts {
521 let direct_chat = context
522 .sql
523 .exists(
524 "SELECT COUNT(*)
525 FROM chats_contacts cc INNER JOIN chats
526 WHERE cc.contact_id=? AND chats.type=?",
527 (contact.id, Chattype::Single),
528 )
529 .await?;
530 contact.direct_chat = direct_chat;
531 }
532
533 Ok(contacts)
534}
535
536async fn get_message_stats(context: &Context) -> Result<BTreeMap<Chattype, MessageStats>> {
541 let mut map: BTreeMap<Chattype, MessageStats> = context
542 .sql
543 .query_map_collect(
544 "SELECT chattype, verified, unverified_encrypted, unencrypted, only_to_self
545 FROM stats_msgs",
546 (),
547 |row| {
548 let chattype: Chattype = row.get(0)?;
549 let verified: u32 = row.get(1)?;
550 let unverified_encrypted: u32 = row.get(2)?;
551 let unencrypted: u32 = row.get(3)?;
552 let only_to_self: u32 = row.get(4)?;
553 let message_stats = MessageStats {
554 verified,
555 unverified_encrypted,
556 unencrypted,
557 only_to_self,
558 };
559 Ok((chattype, message_stats))
560 },
561 )
562 .await?;
563
564 for chattype in [Chattype::Group, Chattype::Single, Chattype::OutBroadcast] {
566 map.entry(chattype).or_default();
567 }
568
569 Ok(map)
570}
571
572pub(crate) async fn update_message_stats(context: &Context) -> Result<()> {
573 for chattype in [Chattype::Single, Chattype::Group, Chattype::OutBroadcast] {
574 update_message_stats_inner(context, chattype).await?;
575 }
576 context
577 .set_config_internal(Config::StatsLastUpdate, Some(&time().to_string()))
578 .await?;
579 Ok(())
580}
581
582async fn update_message_stats_inner(context: &Context, chattype: Chattype) -> Result<()> {
583 let stats_bot_chat_id = get_stats_chat_id(context).await?;
584
585 let trans_fn = |t: &mut rusqlite::Transaction| {
586 let last_counted_msg_id: u32 = t
589 .query_row(
590 "SELECT last_counted_msg_id FROM stats_msgs WHERE chattype=?",
591 (chattype,),
592 |row| row.get(0),
593 )
594 .optional()?
595 .unwrap_or(0);
596 t.execute(
597 "UPDATE stats_msgs
598 SET last_counted_msg_id=(SELECT MAX(id) FROM msgs)
599 WHERE chattype=?",
600 (chattype,),
601 )?;
602
603 t.execute(
607 "CREATE TEMP TABLE temp.empty_chats (
608 id INTEGER PRIMARY KEY
609 ) STRICT",
610 (),
611 )?;
612
613 t.execute(
616 "INSERT INTO temp.empty_chats
617 SELECT id FROM chats
618 WHERE id>9 AND NOT EXISTS(
619 SELECT *
620 FROM contacts, chats_contacts
621 WHERE chats_contacts.contact_id=contacts.id AND chats_contacts.chat_id=chats.id
622 AND contacts.id>9
623 )",
624 (),
625 )?;
626
627 t.execute(
630 "CREATE TEMP TABLE temp.verified_chats (
631 id INTEGER PRIMARY KEY
632 ) STRICT",
633 (),
634 )?;
635
636 t.execute(
639 "INSERT INTO temp.verified_chats
640 SELECT id FROM chats
641 WHERE id>9
642 AND id NOT IN (SELECT id FROM temp.empty_chats)
643 AND NOT EXISTS(
644 SELECT *
645 FROM contacts, chats_contacts
646 WHERE chats_contacts.contact_id=contacts.id AND chats_contacts.chat_id=chats.id
647 AND contacts.id>9
648 AND contacts.verifier=0
649 )",
650 (),
651 )?;
652
653 t.execute(
655 "CREATE TEMP TABLE temp.chat_with_correct_type (
656 id INTEGER PRIMARY KEY
657 ) STRICT",
658 (),
659 )?;
660
661 t.execute(
662 "INSERT INTO temp.chat_with_correct_type
663 SELECT id FROM chats
664 WHERE type=?;",
665 (chattype,),
666 )?;
667
668 let general_requirements = "id>? AND from_id=? AND chat_id<>?
675 AND hidden=0 AND chat_id>9 AND chat_id IN temp.chat_with_correct_type"
676 .to_string();
677 let params = (last_counted_msg_id, ContactId::SELF, stats_bot_chat_id);
678
679 let verified: u32 = t.query_row(
680 &format!(
681 "SELECT COUNT(*) FROM msgs
682 WHERE chat_id IN temp.verified_chats
683 AND {general_requirements}"
684 ),
685 params,
686 |row| row.get(0),
687 )?;
688
689 let unverified_encrypted: u32 = t.query_row(
690 &format!(
691 "SELECT COUNT(*) FROM msgs
693 WHERE chat_id NOT IN temp.verified_chats AND chat_id NOT IN temp.empty_chats
694 AND (param GLOB '*\nc=1*' OR param GLOB 'c=1*')
695 AND {general_requirements}"
696 ),
697 params,
698 |row| row.get(0),
699 )?;
700
701 let unencrypted: u32 = t.query_row(
702 &format!(
703 "SELECT COUNT(*) FROM msgs
704 WHERE chat_id NOT IN temp.verified_chats AND chat_id NOT IN temp.empty_chats
705 AND NOT (param GLOB '*\nc=1*' OR param GLOB 'c=1*')
706 AND {general_requirements}"
707 ),
708 params,
709 |row| row.get(0),
710 )?;
711
712 let only_to_self: u32 = t.query_row(
713 &format!(
714 "SELECT COUNT(*) FROM msgs
715 WHERE chat_id IN temp.empty_chats
716 AND {general_requirements}"
717 ),
718 params,
719 |row| row.get(0),
720 )?;
721
722 t.execute("DROP TABLE temp.verified_chats", ())?;
723 t.execute("DROP TABLE temp.empty_chats", ())?;
724 t.execute("DROP TABLE temp.chat_with_correct_type", ())?;
725
726 t.execute(
727 "INSERT INTO stats_msgs(chattype) VALUES (?)
728 ON CONFLICT(chattype) DO NOTHING",
729 (chattype,),
730 )?;
731 t.execute(
732 "UPDATE stats_msgs SET
733 verified=verified+?,
734 unverified_encrypted=unverified_encrypted+?,
735 unencrypted=unencrypted+?,
736 only_to_self=only_to_self+?
737 WHERE chattype=?",
738 (
739 verified,
740 unverified_encrypted,
741 unencrypted,
742 only_to_self,
743 chattype,
744 ),
745 )?;
746
747 Ok(())
748 };
749
750 context.sql.transaction(trans_fn).await?;
751
752 Ok(())
753}
754
755pub(crate) async fn count_securejoin_ux_info(
756 context: &Context,
757 source: Option<SecurejoinSource>,
758 uipath: Option<SecurejoinUiPath>,
759) -> Result<()> {
760 if !should_send_stats(context).await? {
761 return Ok(());
762 }
763
764 let source = source
765 .context("Missing securejoin source")
766 .log_err(context)
767 .unwrap_or(SecurejoinSource::Unknown);
768
769 let uipath = uipath.unwrap_or(SecurejoinUiPath::Unknown);
773
774 context
775 .sql
776 .transaction(|conn| {
777 conn.execute(
778 "INSERT INTO stats_securejoin_sources VALUES (?, 1)
779 ON CONFLICT (source) DO UPDATE SET count=count+1;",
780 (source.to_u32(),),
781 )?;
782
783 conn.execute(
784 "INSERT INTO stats_securejoin_uipaths VALUES (?, 1)
785 ON CONFLICT (uipath) DO UPDATE SET count=count+1;",
786 (uipath.to_u32(),),
787 )?;
788 Ok(())
789 })
790 .await?;
791
792 Ok(())
793}
794
795async fn get_securejoin_source_stats(context: &Context) -> Result<SecurejoinSources> {
796 let map: BTreeMap<SecurejoinSource, u32> = context
797 .sql
798 .query_map_collect(
799 "SELECT source, count FROM stats_securejoin_sources",
800 (),
801 |row| {
802 let source: SecurejoinSource = row.get(0)?;
803 let count: u32 = row.get(1)?;
804 Ok((source, count))
805 },
806 )
807 .await?;
808
809 let stats = SecurejoinSources {
810 unknown: *map.get(&SecurejoinSource::Unknown).unwrap_or(&0),
811 external_link: *map.get(&SecurejoinSource::ExternalLink).unwrap_or(&0),
812 internal_link: *map.get(&SecurejoinSource::InternalLink).unwrap_or(&0),
813 clipboard: *map.get(&SecurejoinSource::Clipboard).unwrap_or(&0),
814 image_loaded: *map.get(&SecurejoinSource::ImageLoaded).unwrap_or(&0),
815 scan: *map.get(&SecurejoinSource::Scan).unwrap_or(&0),
816 };
817
818 Ok(stats)
819}
820
821async fn get_securejoin_uipath_stats(context: &Context) -> Result<SecurejoinUiPaths> {
822 let map: BTreeMap<SecurejoinUiPath, u32> = context
823 .sql
824 .query_map_collect(
825 "SELECT uipath, count FROM stats_securejoin_uipaths",
826 (),
827 |row| {
828 let uipath: SecurejoinUiPath = row.get(0)?;
829 let count: u32 = row.get(1)?;
830 Ok((uipath, count))
831 },
832 )
833 .await?;
834
835 let stats = SecurejoinUiPaths {
836 other: *map.get(&SecurejoinUiPath::Unknown).unwrap_or(&0),
837 qr_icon: *map.get(&SecurejoinUiPath::QrIcon).unwrap_or(&0),
838 new_contact: *map.get(&SecurejoinUiPath::NewContact).unwrap_or(&0),
839 };
840
841 Ok(stats)
842}
843
844pub(crate) async fn count_securejoin_invite(context: &Context, invite: &QrInvite) -> Result<()> {
845 if !should_send_stats(context).await? {
846 return Ok(());
847 }
848
849 let contact = Contact::get_by_id(context, invite.contact_id()).await?;
850
851 let already_existed = contact.origin > Origin::UnhandledSecurejoinQrScan;
857
858 let already_verified = contact.is_verified(context).await?;
860
861 let typ = match invite {
862 QrInvite::Contact { .. } => "contact",
863 QrInvite::Group { .. } => "group",
864 QrInvite::Broadcast { .. } => "broadcast",
865 };
866
867 context
868 .sql
869 .execute(
870 "INSERT INTO stats_securejoin_invites (already_existed, already_verified, type)
871 VALUES (?, ?, ?)",
872 (already_existed, already_verified, typ),
873 )
874 .await?;
875
876 Ok(())
877}
878
879async fn get_securejoin_invite_stats(context: &Context) -> Result<Vec<JoinedInvite>> {
880 context
881 .sql
882 .query_map_vec(
883 "SELECT already_existed, already_verified, type FROM stats_securejoin_invites",
884 (),
885 |row| {
886 let already_existed: bool = row.get(0)?;
887 let already_verified: bool = row.get(1)?;
888 let typ: String = row.get(2)?;
889
890 Ok(JoinedInvite {
891 already_existed,
892 already_verified,
893 typ,
894 })
895 },
896 )
897 .await
898}
899
900#[cfg(test)]
901mod stats_tests;