Some checks are pending
Backend CI / test (push) Waiting to run
Backend CI / build (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
681 lines
21 KiB
Dart
681 lines
21 KiB
Dart
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 adminIds = config.adminIds;
|
||
print('[BOT] Admin IDs: $adminIds');
|
||
|
||
final username = (await Telegram(config.botToken).getMe()).username;
|
||
teledart = TeleDart(config.botToken, Event(username!));
|
||
|
||
// Store globally for shutdown handler
|
||
_globalTeledart = teledart;
|
||
|
||
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(' - ADMIN_IDS (required, comma or space separated)');
|
||
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')}';
|
||
}
|