mnemo_cards/mnemo_cards_telegram_bot/bin/main.dart
Dmitry 7439c6aaa9
Some checks are pending
Backend CI / test (push) Waiting to run
Backend CI / build (push) Blocked by required conditions
Deploy Admin Panel / Deploy Admin Panel (push) Waiting to run
Deploy Admin Panel / Admin Panel Verification (push) Blocked by required conditions
Deploy Mnemo Cards / Deploy Backend (push) Waiting to run
Deploy Mnemo Cards / Deploy Web App (push) Blocked by required conditions
Deploy Mnemo Cards / Final Verification (push) Blocked by required conditions
Deploy Telegram Bot / Deploy Telegram Bot (push) Waiting to run
telegram
2025-12-11 21:23:55 +03:00

695 lines
21 KiB
Dart
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import 'dart:async';
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';
import 'package:mnemo_cards_telegram_bot/backend_client.dart';
const String version = '0.0.1';
// Global variables for graceful shutdown
BackendClient? _globalBackendClient;
TeleDart? _globalTeledart;
bool _isShuttingDown = false;
final Completer<void> _shutdownCompleter = Completer<void>();
void main() async {
// 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();
});
return _run();
}
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);
}
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({
required BackendClient backendClient,
required TeleDart teledartInstance,
required dynamic message,
required String code,
}) async {
final from = message.from;
if (from == null) {
return;
}
final claimed = await backendClient.claimWebAuthCode(
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,
);
}
}
Future<void> _run() async {
if (_isShuttingDown) {
print('[BOT] Shutdown in progress, not starting bot');
return;
}
print('[BOT] Starting bot version $version');
BackendClient? backendClient;
TeleDart? teledart;
try {
final config = BotConfig.fromEnvironment();
final lines = File('admins').readAsLinesSync();
final adminIds = lines.map((e) => e.trim()).toList();
if (adminIds.isEmpty) {
print('[BOT] ERROR: No admins found in admins file');
return;
}
final username = (await Telegram(config.botToken).getMe()).username;
teledart = TeleDart(config.botToken, Event(username!));
// Store globally for shutdown handler
_globalTeledart = teledart;
Future<void> notifyAdmins(String message) async {
for (final admin in adminIds) {
try {
await teledart!.sendMessage(admin, message);
} catch (e) {
print('[BOT] ERROR: Failed to notify admin $admin: $e');
}
}
}
print('[BOT] Using backend API at ${config.backendUrl}');
backendClient = BackendClient(
backendUrl: config.backendUrl,
apiKey: config.apiKey,
);
// Store globally for shutdown handler
_globalBackendClient = backendClient;
// At this point all variables are initialized and non-null
final teledartInstance = teledart;
final backendClientInstance = backendClient;
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(
backendClient: backendClientInstance,
teledartInstance: teledartInstance,
message: message,
code: payloadCode,
);
return;
}
// Generate auth code for the user (legacy flow)
final code = await backendClientInstance.generateAuthCode(
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
final code = await backendClientInstance.generateAuthCode(
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
final code = await backendClientInstance.generateAuthCode(
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('Что-то пошло не так');
}
print('[BOT] ERROR: app error: $e\n$s');
}
});
teledartInstance.onCommand('info').listen((message) async {
if (adminIds.contains(message.from?.id.toString())) {
final info = await backendClientInstance.info();
message.reply(info);
}
});
teledartInstance.onCommand('user').listen((message) async {
if (adminIds.contains(message.from?.id.toString())) {
final userId = message.text?.replaceFirst('/user', '').trim() ?? '';
final info = await backendClientInstance.userInfo(userId);
message.reply(info);
}
});
teledartInstance.onCommand('words').listen((message) async {
if (adminIds.contains(message.from?.id.toString())) {
final separator = message.text?.replaceFirst('/words', '').trim() ?? '';
final words = await backendClientInstance.words();
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) {
print('[BOT] ERROR: app error: $e\n$s');
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');
print('[BOT] ERROR: clear cache error $dir: $e\n$s');
}
} 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) {
print('[BOT] ERROR: app error: $e\n$s');
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) {
print('[BOT] ERROR: app error: $e\n$s');
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) {
print('[BOT] ERROR: backup error: $e\n$s');
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
if (!await backendClientInstance.canShareToday(
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
final card = await backendClientInstance.getRandomCard();
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
await backendClientInstance.recordShareRequest(
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(
backendClient: backendClientInstance,
teledartInstance: teledartInstance,
message: message,
code: code,
);
return;
}
await message.reply('Спасибо, переслал.');
teledartInstance.forwardMessage(
adminIds.first,
message.chat.id,
message.messageId,
messageThreadId: message.messageThreadId,
);
});
teledartInstance.start();
print('[BOT] Bot started successfully');
// Wait for shutdown signal
// The bot will run until it receives SIGTERM/SIGINT
await _shutdownCompleter.future;
} on Exception catch (e, s) {
if (_isShuttingDown) {
print('[BOT] Error during shutdown, exiting...');
return;
}
print('[BOT] ERROR: Error: $e\n$s');
print('[BOT] Error while starting bot: $e');
print('Stack trace: $s');
print('');
print('Required environment variables:');
print(' - TELEGRAM_BOT_TOKEN (required)');
print(' - TELEGRAM_BOT_API_KEY (required)');
print(' - BACKEND_URL or MNEMO_BACKEND_URL (optional, default: https://api.mnemo-cards.online)');
print(' - BOT_SHARE_DAILY_LIMIT (optional, default: 1)');
// Cleanup resources
try {
backendClient?.dispose();
} catch (_) {
// Ignore cleanup errors
}
// Exit with error code instead of restarting
// Let Docker/Coolify handle restarts with restart policy
print('[BOT] Exiting with error code...');
exit(1);
}
}
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')}';
}