mnemo_cards/mnemo_cards_telegram_bot/bin/main.dart

682 lines
21 KiB
Dart
Raw Normal View History

2025-12-11 18:23:55 +00:00
import 'dart:async';
2025-11-10 23:55:41 +00:00
import 'dart:io';
import 'package:path/path.dart';
import 'package:mnemo_cards_telegram_bot/bot_config.dart';
import 'package:mnemo_cards_telegram_bot/image_generator.dart';
import 'package:process_run/process_run.dart';
import 'package:teledart/teledart.dart';
import 'package:teledart/telegram.dart';
2025-12-11 17:49:15 +00:00
import 'package:mnemo_cards_telegram_bot/backend_client.dart';
2025-11-10 23:55:41 +00:00
const String version = '0.0.1';
2025-12-11 18:23:55 +00:00
// Global variables for graceful shutdown
BackendClient? _globalBackendClient;
TeleDart? _globalTeledart;
bool _isShuttingDown = false;
final Completer<void> _shutdownCompleter = Completer<void>();
2025-12-11 17:49:15 +00:00
void main() async {
2025-12-11 18:23:55 +00:00
// Setup signal handlers for graceful shutdown
ProcessSignal.sigterm.watch().listen((signal) {
print('[BOT] Received SIGTERM, shutting down gracefully...');
_shutdown();
});
ProcessSignal.sigint.watch().listen((signal) {
print('[BOT] Received SIGINT, shutting down gracefully...');
_shutdown();
});
2025-12-11 17:49:15 +00:00
return _run();
2025-11-10 23:55:41 +00:00
}
2025-12-11 18:23:55 +00:00
Future<void> _shutdown() async {
if (_isShuttingDown) {
print('[BOT] Shutdown already in progress');
return;
}
_isShuttingDown = true;
print('[BOT] Starting graceful shutdown...');
try {
if (_globalTeledart != null) {
print('[BOT] Stopping TeleDart...');
// TeleDart doesn't have explicit stop method, but we can dispose resources
// The process will exit naturally when main completes
}
} catch (e) {
print('[BOT] Error stopping TeleDart: $e');
}
try {
if (_globalBackendClient != null) {
print('[BOT] Disposing BackendClient...');
_globalBackendClient!.dispose();
}
} catch (e) {
print('[BOT] Error disposing BackendClient: $e');
}
print('[BOT] Shutdown complete');
// Complete the completer to allow main to exit
if (!_shutdownCompleter.isCompleted) {
_shutdownCompleter.complete();
}
exit(0);
}
2025-11-10 23:55:41 +00:00
String? _extractCodeFromText(String? rawText) {
if (rawText == null) {
return null;
}
final text = rawText.trim();
if (text.isEmpty) {
return null;
}
if (text.startsWith('/start')) {
final parts = text.split(' ');
if (parts.length > 1) {
final payload = parts.sublist(1).join(' ').trim();
return _extractCodeFromPayload(payload);
}
return null;
}
return _extractCodeFromPayload(text);
}
String? _extractCodeFromPayload(String payload) {
final sanitized = payload.trim();
if (sanitized.isEmpty) {
return null;
}
final match = RegExp(r'(\d{6})$').firstMatch(sanitized);
if (match == null) {
return null;
}
final code = match.group(1);
if (code == null) {
return null;
}
return RegExp(r'^\d{6}$').hasMatch(code) ? code : null;
}
Future<void> _handleCodeClaim({
2025-12-11 17:49:15 +00:00
required BackendClient backendClient,
2025-11-10 23:55:41 +00:00
required TeleDart teledartInstance,
required dynamic message,
required String code,
}) async {
final from = message.from;
if (from == null) {
return;
}
2025-12-11 17:49:15 +00:00
final claimed = await backendClient.claimWebAuthCode(
2025-11-10 23:55:41 +00:00
code: code,
telegramUserId: from.id.toString(),
telegramUsername: from.username,
firstName: from.firstName,
lastName: from.lastName,
);
if (claimed) {
await teledartInstance.sendMessage(
message.chat.id,
'Код подтверждён ✅\n'
'Вернись на сайт и нажми «Войти» в течение 5 минут.',
replyToMessageId: message.messageId,
);
} else {
await teledartInstance.sendMessage(
message.chat.id,
'Не удалось подтвердить код 😔\n'
'Возможно он уже использован или истёк. Сгенерируй новый код на сайте.',
replyToMessageId: message.messageId,
);
}
}
2025-12-11 17:49:15 +00:00
Future<void> _run() async {
2025-12-11 18:23:55 +00:00
if (_isShuttingDown) {
print('[BOT] Shutdown in progress, not starting bot');
return;
}
2025-12-11 18:16:10 +00:00
print('[BOT] Starting bot version $version');
2025-12-11 17:49:15 +00:00
BackendClient? backendClient;
2025-11-10 23:55:41 +00:00
TeleDart? teledart;
try {
2025-12-11 17:49:15 +00:00
final config = BotConfig.fromEnvironment();
2025-11-10 23:55:41 +00:00
2025-12-11 20:21:38 +00:00
final adminIds = config.adminIds;
print('[BOT] Admin IDs: $adminIds');
2025-11-10 23:55:41 +00:00
2025-12-11 17:49:15 +00:00
final username = (await Telegram(config.botToken).getMe()).username;
teledart = TeleDart(config.botToken, Event(username!));
2025-12-11 18:23:55 +00:00
// Store globally for shutdown handler
_globalTeledart = teledart;
2025-11-10 23:55:41 +00:00
2025-12-11 18:16:10 +00:00
print('[BOT] Using backend API at ${config.backendUrl}');
2025-11-10 23:55:41 +00:00
2025-12-11 17:49:15 +00:00
backendClient = BackendClient(
2025-11-10 23:55:41 +00:00
backendUrl: config.backendUrl,
2025-12-11 17:49:15 +00:00
apiKey: config.apiKey,
2025-11-10 23:55:41 +00:00
);
2025-12-11 18:23:55 +00:00
// Store globally for shutdown handler
_globalBackendClient = backendClient;
2025-11-10 23:55:41 +00:00
// At this point all variables are initialized and non-null
final teledartInstance = teledart;
2025-12-11 17:49:15 +00:00
final backendClientInstance = backendClient;
2025-11-10 23:55:41 +00:00
teledartInstance.onCommand('logs').listen(
(message) async {
String result;
try {
final r = await Process.run(
'journalctl',
['-u', 'mnemo_cards_backend', '--since', "5min ago"],
);
result = '${r.stdout.toString()} ${r.stderr}';
} catch (e) {
result = 'Error: $e';
}
message.reply(result);
},
);
teledart.onCommand('start').listen(
(message) async {
final from = message.from;
if (from == null) {
return;
}
final payloadCode = _extractCodeFromText(message.text);
if (payloadCode != null) {
await _handleCodeClaim(
2025-12-11 17:49:15 +00:00
backendClient: backendClientInstance,
2025-11-10 23:55:41 +00:00
teledartInstance: teledartInstance,
message: message,
code: payloadCode,
);
return;
}
// Generate auth code for the user (legacy flow)
2025-12-11 17:49:15 +00:00
final code = await backendClientInstance.generateAuthCode(
2025-11-10 23:55:41 +00:00
telegramUserId: from.id.toString(),
telegramUsername: from.username,
firstName: from.firstName,
lastName: from.lastName,
);
if (code == null) {
await message.reply(
'Ошибка при генерации кода. Попробуй позже.',
);
} else {
await message.reply(
'Привет! Для авторизации на сайте используй этот код:\n\n'
'**$code**\n\n'
'Код действителен 5 минут.',
);
}
},
);
teledart.onCommand('login').listen(
(message) async {
final from = message.from;
if (from == null) {
return;
}
// Generate auth code for the user
2025-12-11 17:49:15 +00:00
final code = await backendClientInstance.generateAuthCode(
2025-11-10 23:55:41 +00:00
telegramUserId: from.id.toString(),
telegramUsername: from.username,
firstName: from.firstName,
lastName: from.lastName,
);
if (code == null) {
await message.reply(
'Ошибка при генерации кода. Попробуй позже.',
);
} else {
await message.reply(
'Код для авторизации на сайте:\n\n'
'**$code**\n\n'
'Код действителен 5 минут.\n'
'Введи его на сайте для входа.',
);
}
},
);
teledart.onCommand('code').listen(
(message) async {
final from = message.from;
if (from == null) {
return;
}
// Generate auth code for the user
2025-12-11 17:49:15 +00:00
final code = await backendClientInstance.generateAuthCode(
2025-11-10 23:55:41 +00:00
telegramUserId: from.id.toString(),
telegramUsername: from.username,
firstName: from.firstName,
lastName: from.lastName,
);
if (code == null) {
await message.reply(
'Ошибка при генерации кода. Попробуй позже.',
);
} else {
await message.reply(
'Твой код авторизации:\n\n'
'**$code**\n\n'
'⏱ Действителен 5 минут',
);
}
},
);
teledartInstance.onCommand('app').listen((message) {
try {
final file = File('apks/app.apk');
final t = file.lastModifiedSync();
teledartInstance.sendMessage(message.chat.id, 'Загрузка apk...');
message.replyDocument(file, caption: t.simpleString);
} on Object catch (e, s) {
if (adminIds.contains(message.from?.id.toString())) {
message.reply(e.toString());
} else {
message.reply('Что-то пошло не так');
}
2025-12-11 18:16:10 +00:00
print('[BOT] ERROR: app error: $e\n$s');
2025-11-10 23:55:41 +00:00
}
});
teledartInstance.onCommand('info').listen((message) async {
if (adminIds.contains(message.from?.id.toString())) {
2025-12-11 17:49:15 +00:00
final info = await backendClientInstance.info();
2025-11-10 23:55:41 +00:00
message.reply(info);
}
});
teledartInstance.onCommand('user').listen((message) async {
if (adminIds.contains(message.from?.id.toString())) {
final userId = message.text?.replaceFirst('/user', '').trim() ?? '';
2025-12-11 17:49:15 +00:00
final info = await backendClientInstance.userInfo(userId);
2025-11-10 23:55:41 +00:00
message.reply(info);
}
});
teledartInstance.onCommand('words').listen((message) async {
if (adminIds.contains(message.from?.id.toString())) {
final separator = message.text?.replaceFirst('/words', '').trim() ?? '';
2025-12-11 17:49:15 +00:00
final words = await backendClientInstance.words();
2025-11-10 23:55:41 +00:00
message.reply(words.join(separator.isEmpty ? ',\n' : separator));
}
});
teledartInstance.onCommand('admin_app').listen((message) {
if (adminIds.contains(message.from?.id.toString())) {
try {
final file = File('apks/admin_app.apk');
final t = file.lastModifiedSync();
teledartInstance.sendMessage(message.chat.id, 'Загрузка apk...');
message.replyDocument(file, caption: t.simpleString);
} on Object catch (e, s) {
2025-12-11 18:16:10 +00:00
print('[BOT] ERROR: app error: $e\n$s');
2025-11-10 23:55:41 +00:00
message.reply(e.toString());
}
}
});
teledartInstance.onCommand('clear_cache').listen((message) async {
var answer = StringBuffer();
if (adminIds.contains(message.from?.id.toString())) {
final types = ['images', 'cards'];
final sizes = ['big', 'small', 'medium', 'extraSmall'];
for (final type in types) {
for (final size in sizes) {
final dir = Directory('../mnemo_cards_backend/data/$type/$size');
if (await dir.exists()) {
try {
await dir.delete(recursive: true);
answer.write('$type/$size - ok\n');
} catch (e, s) {
answer.write('$type/$size - error\n');
2025-12-11 18:16:10 +00:00
print('[BOT] ERROR: clear cache error $dir: $e\n$s');
2025-11-10 23:55:41 +00:00
}
} else {
answer.write('$type/$size - not exists\n');
}
}
}
message.reply(answer.toString());
}
});
teledartInstance.onCommand('images').listen((message) async {
if (adminIds.contains(message.from?.id.toString())) {
try {
final loadingMessage = await teledartInstance.sendMessage(
message.chat.id,
'Загрузка...',
);
final cardsDir = Directory('../mnemo_cards_backend/data/cards/');
final dir = Directory('zips');
if (dir.existsSync()) {
dir.deleteSync(recursive: true);
}
dir.createSync();
var shell = Shell();
await shell.run(
'zip -s 49m -vr ${join(dir.path, 'images.zip')} ${cardsDir.path}');
final zips = dir.listSync().whereType<File>();
int i = 0;
print('Will send ${zips.length} zip files:');
print('${zips.map((e) => e.path).toList()}');
teledartInstance.deleteMessage(
loadingMessage.chat.id,
loadingMessage.messageId,
);
for (final zip in zips) {
i++;
await teledartInstance.sendDocument(
message.chat.id,
zip,
caption: '$i/${zips.length}',
);
}
} on Object catch (e, s) {
2025-12-11 18:16:10 +00:00
print('[BOT] ERROR: app error: $e\n$s');
2025-11-10 23:55:41 +00:00
message.reply(e.toString());
}
}
});
teledartInstance.onCommand('cards').listen((message) async {
if (adminIds.contains(message.from?.id.toString())) {
try {
final cardsDir = Directory('../mnemo_cards_backend/data/cards/');
final names = cardsDir
.listSync()
.whereType<File>()
.map((f) => basename(f.path))
.toList()
..sort((p, n) {
final pParts = p.split('_');
final nParts = n.split('_');
return (int.tryParse(pParts.isNotEmpty ? pParts.first : '') ??
99999)
.compareTo(
int.tryParse(nParts.isNotEmpty ? nParts.first : '') ??
9999);
});
message.reply(
names.take(50).join('\n'),
);
if (names.length > 50) {
message.reply(
names.sublist(50).join('\n'),
);
}
} on Object catch (e, s) {
2025-12-11 18:16:10 +00:00
print('[BOT] ERROR: app error: $e\n$s');
2025-11-10 23:55:41 +00:00
message.reply(e.toString());
}
}
});
teledartInstance.onCommand('backup').listen((message) async {
if (adminIds.contains(message.from?.id.toString())) {
try {
final dir = Directory('backups/');
if (!dir.existsSync()) {
message.reply('No backups');
}
final files = dir.listSync().whereType<File>().toList();
files.sort(
(a, b) => b.lastModifiedSync().compareTo(a.lastModifiedSync()));
if (files.isNotEmpty) {
teledartInstance.sendMessage(message.chat.id, 'Загрузка backup...');
var shell = Shell();
final file = files.first;
final name = basename(file.path).split('.').first;
final dir = Directory('zips');
if (dir.existsSync()) {
dir.deleteSync(recursive: true);
}
dir.createSync();
await shell
.run('zip -s 49m ${join(dir.path, '$name.zip')} ${file.path}');
final zips = dir.listSync().whereType<File>();
int i = 0;
print('Will send ${zips.length} zip files:');
print('${zips.map((e) => e.path).toList()}');
for (final zip in zips) {
i++;
await teledartInstance.sendDocument(
message.chat.id,
zip,
caption: '$i/${zips.length}',
);
}
// dir.deleteSync();
} else {
message.reply('No backups');
}
} on Object catch (e, s) {
2025-12-11 18:16:10 +00:00
print('[BOT] ERROR: backup error: $e\n$s');
2025-11-10 23:55:41 +00:00
message.reply(e.toString());
}
}
});
teledartInstance.onCommand('share').listen((message) async {
try {
final from = message.from;
if (from == null) {
await message.reply('Ошибка: не удалось определить пользователя');
return;
}
final telegramUserId = from.id.toString();
print('[SHARE_CMD] User $telegramUserId requested /share command');
// Check rate limit
2025-12-11 17:49:15 +00:00
if (!await backendClientInstance.canShareToday(
2025-11-10 23:55:41 +00:00
telegramUserId,
config.shareDailyLimit,
)) {
print('[SHARE_BLOCKED] User $telegramUserId reached daily limit');
await message.reply(
'Ты уже поделился сегодня. Попробуй завтра! '
'(Лимит: ${config.shareDailyLimit} раз в день)',
);
return;
}
// Send loading message
final loadingMsg = await teledartInstance.sendMessage(
message.chat.id,
'Готовлю красивое сообщение...',
);
// Get random card
2025-12-11 17:49:15 +00:00
final card = await backendClientInstance.getRandomCard();
2025-11-10 23:55:41 +00:00
if (card == null) {
try {
await teledartInstance.editMessageText(
'К сожалению, карточки недоступны. Попробуй позже.',
chatId: loadingMsg.chat.id.toString(),
messageId: loadingMsg.messageId,
);
} catch (e) {
print('Error editing message: $e');
}
return;
}
// Generate image
final imageGenerator = ImageGenerator();
final imageBytes = await imageGenerator.generateShareImage(card);
if (imageBytes == null || imageBytes.isEmpty) {
try {
await teledartInstance.editMessageText(
'Не удалось создать изображение. Попробуй позже.',
chatId: loadingMsg.chat.id.toString(),
messageId: loadingMsg.messageId,
);
} catch (e) {
print('Error editing message: $e');
}
return;
}
// Prepare share message
final shareMessage = 'Приветствую! Я тестирую приложение mnemo cards '
'для изучения языков.\n\n'
'Присоединяйся и начни учиться вместе со мной!';
// Delete loading message
try {
await teledartInstance.deleteMessage(
loadingMsg.chat.id,
loadingMsg.messageId,
);
} catch (e) {
print('Error deleting loading message: $e');
}
// Send image with caption
message.reply('Sending image...');
File? temp;
try {
final dir = await Directory.systemTemp.createTemp('share_image');
temp = await File("${dir.path}/${DateTime.now().millisecondsSinceEpoch}.png").create();
await temp.writeAsBytes(imageBytes);
await message.replyPhoto(
temp,
caption: shareMessage,
);
print('[SHARE_SUCCESS] Image sent to user $telegramUserId');
} catch (e) {
message.reply('Error sending image: $e');
print('Error sending image: $e');
} finally {
await temp?.delete();
}
// Record the share request
2025-12-11 17:49:15 +00:00
await backendClientInstance.recordShareRequest(
2025-11-10 23:55:41 +00:00
telegramUserId: telegramUserId,
telegramUsername: from.username,
sharedCardId: card.id,
);
print('[SHARE_COMPLETE] User $telegramUserId completed share flow');
} catch (e, s) {
print('Error in /share command: $e');
print('Stack trace: $s');
await message.reply(
'Ошибка при создании сообщения. Пожалуйста, попробуй позже.',
);
}
});
teledartInstance.onMessage().listen((message) async {
final text = message.text?.trim();
// Skip command updates - handled by command listeners
if (text != null && text.startsWith('/')) {
return;
}
final code = _extractCodeFromText(text);
if (code != null) {
await _handleCodeClaim(
2025-12-11 17:49:15 +00:00
backendClient: backendClientInstance,
2025-11-10 23:55:41 +00:00
teledartInstance: teledartInstance,
message: message,
code: code,
);
return;
}
await message.reply('Спасибо, переслал.');
teledartInstance.forwardMessage(
adminIds.first,
message.chat.id,
message.messageId,
messageThreadId: message.messageThreadId,
);
});
teledartInstance.start();
2025-12-11 18:16:10 +00:00
print('[BOT] Bot started successfully');
2025-12-11 18:23:55 +00:00
// Wait for shutdown signal
// The bot will run until it receives SIGTERM/SIGINT
await _shutdownCompleter.future;
2025-11-10 23:55:41 +00:00
} on Exception catch (e, s) {
2025-12-11 18:23:55 +00:00
if (_isShuttingDown) {
print('[BOT] Error during shutdown, exiting...');
return;
}
2025-12-11 18:16:10 +00:00
print('[BOT] ERROR: Error: $e\n$s');
print('[BOT] Error while starting bot: $e');
2025-11-10 23:55:41 +00:00
print('Stack trace: $s');
2025-12-11 17:49:15 +00:00
print('');
print('Required environment variables:');
print(' - TELEGRAM_BOT_TOKEN (required)');
print(' - TELEGRAM_BOT_API_KEY (required)');
2025-12-11 20:21:38 +00:00
print(' - ADMIN_IDS (required, comma or space separated)');
2025-12-11 17:49:15 +00:00
print(' - BACKEND_URL or MNEMO_BACKEND_URL (optional, default: https://api.mnemo-cards.online)');
print(' - BOT_SHARE_DAILY_LIMIT (optional, default: 1)');
2025-11-10 23:55:41 +00:00
2025-12-11 18:23:55 +00:00
// Cleanup resources
2025-11-10 23:55:41 +00:00
try {
2025-12-11 17:49:15 +00:00
backendClient?.dispose();
2025-11-10 23:55:41 +00:00
} catch (_) {
// Ignore cleanup errors
}
2025-12-11 18:23:55 +00:00
// Exit with error code instead of restarting
// Let Docker/Coolify handle restarts with restart policy
print('[BOT] Exiting with error code...');
exit(1);
2025-11-10 23:55:41 +00:00
}
}
extension on DateTime {
String get simpleString =>
'${year}.${month.toString().padLeft(2, '0')}.${day.toString().padLeft(2, '0')} '
'${hour.toString().padLeft(2, '0')}:${minute.toString().padLeft(2, '0')}';
}