fix passwordless recovery
Some checks are pending
Flutter analyze & test / flutter_analyze_and_test (push) Waiting to run

This commit is contained in:
otsmr 2026-09-05 13:11:01 +02:00
parent d0b4dd5249
commit 1ba5c15279
18 changed files with 247 additions and 84 deletions

@ -1 +1 @@
Subproject commit 60da6275f8c82c4238f0c3cf88a798e63b4a2ffd Subproject commit 4d22ef849031619d6bb4dcf8ea1709d3c1ff8b3f

View file

@ -4088,6 +4088,18 @@ abstract class AppLocalizations {
/// **'Recovery share sent!'** /// **'Recovery share sent!'**
String get passwordlessRecoveryShareSent; String get passwordlessRecoveryShareSent;
/// No description provided for @passwordlessRecoveryAuthReason.
///
/// In en, this message translates to:
/// **'Authenticate to send your recovery share'**
String get passwordlessRecoveryAuthReason;
/// No description provided for @passwordlessRecoveryAuthFailed.
///
/// In en, this message translates to:
/// **'Authentication failed, the recovery share was not sent.'**
String get passwordlessRecoveryAuthFailed;
/// No description provided for @passwordlessRecoveryNetworkError. /// No description provided for @passwordlessRecoveryNetworkError.
/// ///
/// In en, this message translates to: /// In en, this message translates to:

View file

@ -2364,6 +2364,14 @@ class AppLocalizationsDe extends AppLocalizations {
String get passwordlessRecoveryShareSent => String get passwordlessRecoveryShareSent =>
'Wiederherstellungs-Teil gesendet!'; 'Wiederherstellungs-Teil gesendet!';
@override
String get passwordlessRecoveryAuthReason =>
'Authentifiziere dich, um deinen Wiederherstellungs-Teil zu senden';
@override
String get passwordlessRecoveryAuthFailed =>
'Authentifizierung fehlgeschlagen, der Wiederherstellungs-Teil wurde nicht gesendet.';
@override @override
String get passwordlessRecoveryNetworkError => String get passwordlessRecoveryNetworkError =>
'Netzwerkfehler, bitte stelle sicher, dass du Internet hast'; 'Netzwerkfehler, bitte stelle sicher, dass du Internet hast';

View file

@ -2343,6 +2343,14 @@ class AppLocalizationsEn extends AppLocalizations {
@override @override
String get passwordlessRecoveryShareSent => 'Recovery share sent!'; String get passwordlessRecoveryShareSent => 'Recovery share sent!';
@override
String get passwordlessRecoveryAuthReason =>
'Authenticate to send your recovery share';
@override
String get passwordlessRecoveryAuthFailed =>
'Authentication failed, the recovery share was not sent.';
@override @override
String get passwordlessRecoveryNetworkError => String get passwordlessRecoveryNetworkError =>
'Network error, please ensure you have internet'; 'Network error, please ensure you have internet';

View file

@ -870,6 +870,8 @@
"passwordlessRecoveryNoShareStored": "Kein Wiederherstellungs-Teil für diesen Kontakt gespeichert.", "passwordlessRecoveryNoShareStored": "Kein Wiederherstellungs-Teil für diesen Kontakt gespeichert.",
"passwordlessRecoveryEmailSent": "Wiederherstellungs-E-Mail gesendet!", "passwordlessRecoveryEmailSent": "Wiederherstellungs-E-Mail gesendet!",
"passwordlessRecoveryShareSent": "Wiederherstellungs-Teil gesendet!", "passwordlessRecoveryShareSent": "Wiederherstellungs-Teil gesendet!",
"passwordlessRecoveryAuthReason": "Authentifiziere dich, um deinen Wiederherstellungs-Teil zu senden",
"passwordlessRecoveryAuthFailed": "Authentifizierung fehlgeschlagen, der Wiederherstellungs-Teil wurde nicht gesendet.",
"passwordlessRecoveryNetworkError": "Netzwerkfehler, bitte stelle sicher, dass du Internet hast", "passwordlessRecoveryNetworkError": "Netzwerkfehler, bitte stelle sicher, dass du Internet hast",
"passwordlessRecoveryInvalidEmail": "Die E-Mail-Adresse ist ungültig.", "passwordlessRecoveryInvalidEmail": "Die E-Mail-Adresse ist ungültig.",
"passwordlessRecoveryResendEmail": "E-Mail erneut senden", "passwordlessRecoveryResendEmail": "E-Mail erneut senden",

View file

@ -880,6 +880,8 @@
"passwordlessRecoveryNoShareStored": "No recovery share stored for this contact.", "passwordlessRecoveryNoShareStored": "No recovery share stored for this contact.",
"passwordlessRecoveryEmailSent": "Recovery email sent!", "passwordlessRecoveryEmailSent": "Recovery email sent!",
"passwordlessRecoveryShareSent": "Recovery share sent!", "passwordlessRecoveryShareSent": "Recovery share sent!",
"passwordlessRecoveryAuthReason": "Authenticate to send your recovery share",
"passwordlessRecoveryAuthFailed": "Authentication failed, the recovery share was not sent.",
"passwordlessRecoveryNetworkError": "Network error, please ensure you have internet", "passwordlessRecoveryNetworkError": "Network error, please ensure you have internet",
"passwordlessRecoveryInvalidEmail": "The email address is invalid.", "passwordlessRecoveryInvalidEmail": "The email address is invalid.",
"passwordlessRecoveryResendEmail": "Resend recovery email", "passwordlessRecoveryResendEmail": "Resend recovery email",

View file

@ -53,6 +53,14 @@ class ApiService {
// Function is called after the user is authenticated at the server // Function is called after the user is authenticated at the server
Future<void> onAuthenticated() async { Future<void> onAuthenticated() async {
// A passwordless recovery restores the identity into the key manager
// before a user config exists, so the socket can authenticate while
// `currentUser` is still unset. Everything below reads that config.
if (!userService.isUserCreated) {
Log.info('Skipping onAuthenticated: the user config is not loaded yet');
return;
}
await FcmNotificationService.initFCMAfterAuthenticated(); await FcmNotificationService.initFCMAfterAuthenticated();
if (!AppState.isAppInBackground) { if (!AppState.isAppInBackground) {

View file

@ -338,12 +338,15 @@ class BackupService {
await RustBackupArchive.restoreBackupArchive( await RustBackupArchive.restoreBackupArchive(
filePath: archiveFile.path, filePath: archiveFile.path,
); );
Log.info('Restored the backup archive.');
await UserService.update((u) { await UserService.update((u) {
u.deviceId += 1; u.deviceId += 1;
}); });
Log.info('Bumped the device id after the recovery.');
await KeyValueStore.delete( await KeyValueStore.delete(
KeyValueKeys.backupRecoveryState, KeyValueKeys.backupRecoveryState,
); );
Log.info('Recovery finished, restarting the app.');
} catch (e) { } catch (e) {
Log.error(e); Log.error(e);
return RecoveryError.unkownError; return RecoveryError.unkownError;
@ -411,8 +414,10 @@ class BackupService {
// Import KeyManager keys into secure storage & in-memory key manager // Import KeyManager keys into secure storage & in-memory key manager
await RustKeyManager.importSerialized(serializedBytes: keyManagerBytes); await RustKeyManager.importSerialized(serializedBytes: keyManagerBytes);
Log.info('Imported the recovered key manager.');
await KeyValueStore.put(KeyValueKeys.backupRecoveryState, state.toJson()); await KeyValueStore.put(KeyValueKeys.backupRecoveryState, state.toJson());
Log.info('Stored the recovery state, entering the archive stage.');
return _nextBackupStage(onProgress: onProgress); return _nextBackupStage(onProgress: onProgress);
} }

View file

@ -30,6 +30,10 @@ class FcmNotificationService {
} }
static Future<void> initFCMAfterAuthenticated({bool force = false}) async { static Future<void> initFCMAfterAuthenticated({bool force = false}) async {
// Reading `currentUser` before a config is loaded throws a
// LateInitializationError, which an authenticated socket can trigger
// during a recovery.
if (!userService.isUserCreated) return;
final fcmToken = userService.currentUser.fcmToken; final fcmToken = userService.currentUser.fcmToken;
if (userService.currentUser.updateFcmToken || force) { if (userService.currentUser.updateFcmToken || force) {
if (fcmToken == null) { if (fcmToken == null) {

View file

@ -384,10 +384,9 @@ class PasswordlessRecoveryService {
alreadyReceivedMessageIds: alreadyReceivedIds, alreadyReceivedMessageIds: alreadyReceivedIds,
); );
} catch (error) { } catch (error) {
Log.error( // This runs on a 10s poll, so a socket that is still connecting or
'Failed to load passwordless recovery messages', // briefly offline is expected. The next tick retries.
error: error, Log.warn('Failed to load passwordless recovery messages', error);
);
return false; return false;
} }

View file

@ -29,7 +29,7 @@ class Log {
record.level >= Level.WARNING) { record.level >= Level.WARNING) {
// ignore: avoid_print // ignore: avoid_print
print( print(
'${record.level.name} [f] [twonly] ${record.loggerName} > ${record.message}', '${record.level.name} [f] [twonly] ${record.loggerName} > ${_formatRecord(record)}',
); );
} }
} }
@ -71,7 +71,7 @@ class Log {
_ => rust_logging.LogLevel.finest, _ => rust_logging.LogLevel.finest,
}, },
source: record.loggerName, source: record.loggerName,
message: record.message, message: _formatRecord(record),
// Background work runs natively now; anything logged from Dart is by // Background work runs natively now; anything logged from Dart is by
// definition the foreground runtime. // definition the foreground runtime.
inBackground: false, inBackground: false,
@ -86,6 +86,20 @@ class Log {
} }
} }
/// Folds the optional [LogRecord.error] and [LogRecord.stackTrace] into the
/// rendered line. Without this, callers passing an error see only their own
/// message and never the cause.
static String _formatRecord(LogRecord record) {
final buffer = StringBuffer(record.message);
if (record.error != null) {
buffer.write(': ${filterLogMessage('${record.error}')}');
}
if (record.stackTrace != null) {
buffer.write('\n${record.stackTrace}');
}
return buffer.toString();
}
static String filterLogMessage(String msg) { static String filterLogMessage(String msg) {
if (msg.contains('SqliteException')) { if (msg.contains('SqliteException')) {
// Do not log data which would be inserted into the DB. // Do not log data which would be inserted into the DB.

View file

@ -5,6 +5,7 @@ import 'package:path_provider/path_provider.dart';
import 'package:twonly/core/bridge/wrapper/key_manager.dart'; import 'package:twonly/core/bridge/wrapper/key_manager.dart';
import 'package:twonly/locator.dart'; import 'package:twonly/locator.dart';
import 'package:twonly/src/database/twonly.db.dart'; import 'package:twonly/src/database/twonly.db.dart';
import 'package:twonly/src/utils/log.dart';
/// Deletes local databases and files. /// Deletes local databases and files.
/// ///
@ -13,8 +14,16 @@ import 'package:twonly/src/database/twonly.db.dart';
Future<bool> deleteLocalUserData({bool removeCredentials = false}) async { Future<bool> deleteLocalUserData({bool removeCredentials = false}) async {
if (removeCredentials) { if (removeCredentials) {
await RustKeyManager.removeLocalCredentials(); await RustKeyManager.removeLocalCredentials();
Log.info('Removed the local credentials.');
}
// The database files are deleted a few lines down either way, so a drift
// isolate that no longer answers must not block the whole recovery.
try {
await twonlyDB.close().timeout(const Duration(seconds: 5));
Log.info('Closed the app database.');
} catch (e) {
Log.warn('Could not close the app database, deleting it anyway', e);
} }
await twonlyDB.close();
// Wait for the background drift isolate to potentially shut down // Wait for the background drift isolate to potentially shut down
await Future.delayed(const Duration(milliseconds: 200)); await Future.delayed(const Duration(milliseconds: 200));
@ -29,5 +38,6 @@ Future<bool> deleteLocalUserData({bool removeCredentials = false}) async {
locator locator
..unregister<TwonlyDB>() ..unregister<TwonlyDB>()
..registerLazySingleton<TwonlyDB>(TwonlyDB.new); ..registerLazySingleton<TwonlyDB>(TwonlyDB.new);
Log.info('Deleted the local user data.');
return true; return true;
} }

View file

@ -1,5 +1,6 @@
import 'package:flutter/gestures.dart'; import 'package:flutter/gestures.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:twonly/src/services/intent/links.intent.dart';
import 'package:twonly/src/utils/log.dart'; import 'package:twonly/src/utils/log.dart';
import 'package:url_launcher/url_launcher.dart'; import 'package:url_launcher/url_launcher.dart';
@ -10,15 +11,6 @@ final _urlRegExp = RegExp(
caseSensitive: false, caseSensitive: false,
); );
Future<void> _openUrl(String url) async {
final lUrl = Uri.parse(url.startsWith('http') ? url : 'http://$url');
try {
await launchUrl(lUrl, mode: LaunchMode.externalApplication);
} catch (e) {
Log.error('Could not launch $e');
}
}
class BetterText extends StatefulWidget { class BetterText extends StatefulWidget {
const BetterText({required this.text, required this.textColor, super.key}); const BetterText({required this.text, required this.textColor, super.key});
final String text; final String text;
@ -64,6 +56,19 @@ class _BetterTextState extends State<BetterText> {
_recognizers.clear(); _recognizers.clear();
} }
/// twonly's own links (profile, QR and passwordless recovery links) are
/// handled in the app instead of being handed to the browser, which would
/// only land on a page that cannot do anything with the fragment.
Future<void> _openUrl(String url) async {
final lUrl = Uri.parse(url.startsWith('http') ? url : 'http://$url');
if (mounted && await handleIntentUrl(context, lUrl)) return;
try {
await launchUrl(lUrl, mode: LaunchMode.externalApplication);
} catch (e) {
Log.error('Could not launch $e');
}
}
void _buildSpans() { void _buildSpans() {
final text = widget.text; final text = widget.text;
final spans = <TextSpan>[]; final spans = <TextSpan>[];

View file

@ -3,6 +3,7 @@ import 'dart:async';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:restart_app/restart_app.dart'; import 'package:restart_app/restart_app.dart';
import 'package:twonly/src/services/backup.service.dart'; import 'package:twonly/src/services/backup.service.dart';
import 'package:twonly/src/utils/log.dart';
import 'package:twonly/src/utils/misc.dart'; import 'package:twonly/src/utils/misc.dart';
import 'package:twonly/src/visual/elements/my_button.element.dart'; import 'package:twonly/src/visual/elements/my_button.element.dart';
import 'package:twonly/src/visual/views/onboarding/components/link_logo_animation.dart'; import 'package:twonly/src/visual/views/onboarding/components/link_logo_animation.dart';
@ -67,12 +68,20 @@ class _RecoveryProgressViewState extends State<RecoveryProgressView> {
_error = null; _error = null;
}); });
final error = await widget.runRecovery((progress) { RecoveryError? error;
try {
error = await widget.runRecovery((progress) {
final index = widget.steps.indexOf(progress); final index = widget.steps.indexOf(progress);
if (mounted && index >= 0) { if (mounted && index >= 0) {
setState(() => _currentIndex = index); setState(() => _currentIndex = index);
} }
}); });
} catch (e, stackTrace) {
// Without this the step keeps spinning forever and the user is stuck on
// a screen they cannot leave, with no way to retry.
Log.error('Recovery failed', error: e, stackTrace: stackTrace);
error = RecoveryError.unkownError;
}
if (!mounted) return; if (!mounted) return;
if (error != null) { if (error != null) {
@ -82,11 +91,22 @@ class _RecoveryProgressViewState extends State<RecoveryProgressView> {
setState(() => _isFinishing = true); setState(() => _isFinishing = true);
try {
await Restart.restartApp( await Restart.restartApp(
notificationTitle: context.lang.recoverSuccessTitle, notificationTitle: context.lang.recoverSuccessTitle,
notificationBody: context.lang.recoverSuccessBody, notificationBody: context.lang.recoverSuccessBody,
forceKill: true, forceKill: true,
); );
} catch (e, stackTrace) {
// The data is restored at this point, only the restart failed. Show the
// failure so the user knows to reopen the app themselves.
Log.error(
'Restart after recovery failed',
error: e,
stackTrace: stackTrace,
);
if (mounted) setState(() => _error = RecoveryError.unkownError);
}
} }
_StepStatus _statusFor(int index) { _StepStatus _statusFor(int index) {

View file

@ -78,6 +78,19 @@ class _HelpAFriendPasswordlessRecoveryViewState
} }
Future<void> _submitShare(Contact contact) async { Future<void> _submitShare(Contact contact) async {
final verified = await authenticateUser(
context.lang.passwordlessRecoveryAuthReason,
force: false,
);
if (!mounted) return;
if (!verified) {
showSnackbar(
context,
context.lang.passwordlessRecoveryAuthFailed,
);
return;
}
setState(() => _isLoading = true); setState(() => _isLoading = true);
final res = await PasswordlessRecoveryService.submitRecoveryShare( final res = await PasswordlessRecoveryService.submitRecoveryShare(
widget.notificationId, widget.notificationId,

View file

@ -124,6 +124,11 @@ impl Server {
decode_ok_value(bytes, |value| match value { decode_ok_value(bytes, |value| match value {
ResponseOk::PasswordlessNotificationMessages(msgs) => Some(msgs), ResponseOk::PasswordlessNotificationMessages(msgs) => Some(msgs),
// The server answers with `None` whenever the poll finds no unseen
// message, which is the normal outcome once every share arrived.
ResponseOk::None(_) => Some(
proto::server_to_client::response::PasswordlessNotificationMessages::default(),
),
_ => None, _ => None,
}) })
} }

View file

@ -20,6 +20,19 @@ use zeroize::Zeroize;
use zip::write::SimpleFileOptions; use zip::write::SimpleFileOptions;
use zip::{CompressionMethod, ZipArchive, ZipWriter}; use zip::{CompressionMethod, ZipArchive, ZipWriter};
/// How long a database pool gets to hand its connections back before the
/// restore stops waiting for it.
const POOL_CLOSE_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(10);
async fn close_pool_or_log(pool: &sqlx::SqlitePool, name: &str) {
if tokio::time::timeout(POOL_CLOSE_TIMEOUT, pool.close())
.await
.is_err()
{
tracing::warn!("{name} pool did not close within the timeout, replacing it anyway");
}
}
pub(crate) struct BackupArchive {} pub(crate) struct BackupArchive {}
const BACKUP_MANIFEST_FILE: &str = "backup-manifest.json"; const BACKUP_MANIFEST_FILE: &str = "backup-manifest.json";
@ -261,10 +274,16 @@ impl BackupArchive {
// app_db.sqlite is owned by a replaceable Rust handle. Close it before // app_db.sqlite is owned by a replaceable Rust handle. Close it before
// replacing the file so subsequent DAO calls cannot continue using an // replacing the file so subsequent DAO calls cannot continue using an
// unlinked pre-restore database. // unlinked pre-restore database.
// `Pool::close` waits for every checked out connection to come back. A
// caller that still holds one would hang the restore here forever,
// while this task keeps the KeyManager locked and every later recovery
// attempt blocks on it too. The files are replaced right below and the
// handles are swapped out, so a pool that refuses to drain is not worth
// waiting for.
let current_app_database = ctx.app_db.read().await.clone(); let current_app_database = ctx.app_db.read().await.clone();
current_app_database.pool.close().await; close_pool_or_log(&current_app_database.pool, "app_db").await;
let current_rust_database = ctx.rust_db.read().await.clone(); let current_rust_database = ctx.rust_db.read().await.clone();
current_rust_database.pool.close().await; close_pool_or_log(&current_rust_database.pool, "rust_db").await;
for (file_name, target_dir, is_db, _) in Self::get_backup_files(ctx, &key_manager)? { for (file_name, target_dir, is_db, _) in Self::get_backup_files(ctx, &key_manager)? {
let src = restore_temp_dir.join(file_name); let src = restore_temp_dir.join(file_name);

View file

@ -478,20 +478,21 @@ impl GroupService {
/// function of the state rather than a finished state: it can simply be /// function of the state rather than a finished state: it can simply be
/// applied again to the version that won. /// applied again to the version that won.
/// ///
/// `change` returning `None` means there is nothing left to write. /// `change` returning `None` means there is nothing left to write, which
/// this reports back as `false`.
async fn update_state_with_retry( async fn update_state_with_retry(
&self, &self,
group: &GroupRecord, group: &GroupRecord,
mut change: impl FnMut(&mut EncryptedGroupState) -> Result<Option<AdminKeys>>, mut change: impl FnMut(&mut EncryptedGroupState) -> Result<Option<AdminKeys>>,
) -> Result<()> { ) -> Result<bool> {
let mut attempt = 1; let mut attempt = 1;
loop { loop {
let (version, mut state) = GroupApi::load_state(group).await?; let (version, mut state) = GroupApi::load_state(group).await?;
let Some(keys) = change(&mut state)? else { let Some(keys) = change(&mut state)? else {
return Ok(()); return Ok(false);
}; };
match GroupApi::update_remote(group, version, &state, keys.add, keys.remove).await { match GroupApi::update_remote(group, version, &state, keys.add, keys.remove).await {
Ok(()) => return Ok(()), Ok(()) => return Ok(true),
Err(TwonlyError::GroupStateConflict) if attempt < STATE_UPDATE_ATTEMPTS => { Err(TwonlyError::GroupStateConflict) if attempt < STATE_UPDATE_ATTEMPTS => {
attempt += 1; attempt += 1;
tracing::info!( tracing::info!(
@ -675,7 +676,8 @@ impl GroupService {
.member_public_key(&db, &g, &group_id, contact_id) .member_public_key(&db, &g, &group_id, contact_id)
.await .await
.ok(); .ok();
self.update_state_with_retry(&g, |state| { let removed = self
.update_state_with_retry(&g, |state| {
if !state.member_ids.contains(&contact_id) { if !state.member_ids.contains(&contact_id) {
return Ok(None); return Ok(None);
} }
@ -696,6 +698,10 @@ impl GroupService {
})) }))
}) })
.await?; .await?;
if !removed {
// Somebody else already took them out of the group.
return Ok(true);
}
self.announce(&group_id, "removedMember", Some(contact_id), None, None) self.announce(&group_id, "removedMember", Some(contact_id), None, None)
.await?; .await?;
let mut tr = db.pool.begin().await?; let mut tr = db.pool.begin().await?;
@ -715,13 +721,22 @@ impl GroupService {
pub async fn leave_group(&self, group_id: String) -> Result<bool> { pub async fn leave_group(&self, group_id: String) -> Result<bool> {
let (db, group) = self.load_group(&group_id).await?; let (db, group) = self.load_group(&group_id).await?;
let user_id = self.ctx.user_id().await?; let user_id = self.ctx.user_id().await?;
let (_, group_state) = GroupApi::load_state(&group).await?; let identity = group.identity()?;
let public_key = identity.identity_key().serialize().to_vec();
// The append is addressed to the version the server holds right now,
// not to the local mirror of it: an admin promoting somebody, or
// anyone else's leave, moves the group on without this member having
// refreshed, and the server rejects an append that does not follow its
// current version. Reading it here is also what decides which of the
// two ways out of a group applies.
let mut attempt = 1;
loop {
let (version, group_state) = GroupApi::load_state(&group).await?;
if group_state.admin_ids.contains(&user_id) { if group_state.admin_ids.contains(&user_id) {
return self.remove_member(group_id, user_id).await; return self.remove_member(group_id, user_id).await;
} }
let identity = group.identity()?;
let public_key = identity.identity_key().serialize().to_vec();
let append = EncryptedAppendedGroupState { let append = EncryptedAppendedGroupState {
r#type: encrypted_appended_group_state::Type::LeftGroup as i32, r#type: encrypted_appended_group_state::Type::LeftGroup as i32,
}; };
@ -740,12 +755,26 @@ impl GroupService {
.calculate_signature(&append_tbs.encode_to_vec(), &mut rng) .calculate_signature(&append_tbs.encode_to_vec(), &mut rng)
.map_err(|error| TwonlyError::Signal(error.to_string()))? .map_err(|error| TwonlyError::Signal(error.to_string()))?
.into_vec(); .into_vec();
GroupApi::append(AppendGroupState { match GroupApi::append(AppendGroupState {
signature, signature,
append_tbs: Some(append_tbs), append_tbs: Some(append_tbs),
version_id: group.state_version_id as u64 + 1, version_id: version + 1,
}) })
.await?; .await
{
Ok(()) => break,
Err(TwonlyError::GroupStateConflict) if attempt < STATE_UPDATE_ATTEMPTS => {
attempt += 1;
tracing::info!(
group_id,
attempt,
"group state moved on while leaving it, appending to the new version"
);
}
Err(error) => return Err(error),
}
}
self.announce(&group_id, "leftGroup", None, None, None) self.announce(&group_id, "leftGroup", None, None, None)
.await?; .await?;
let mut tr = db.pool.begin().await?; let mut tr = db.pool.begin().await?;